Agent Harness与Hook设计#
一句话答案#
Harness 是包在模型外面的控制层:终止判定、工具调用前后的校验与截断、上下文装配、权限、预算都归它管。做法是在 Agent 生命周期上开 hook 切点(会话开始、用户输入、模型调用前后、工具调用前后、准备结束、会话结束),把「不许怎么跑」写成按优先级执行的规则;prompt 只能劝模型,hook 能直接拦下。
核心要点
1. Harness 是什么:模型负责决策,Harness 负责约束#
同一个模型,换一套 Harness,表现可以差很多。模型只输出「下一步想做什么」(文本或 tool_call),放不放行、结果怎么处理、什么时候停,由外层代码决定:
| 职责 | Harness 做什么 | 不做会怎样 |
|---|---|---|
| 终止判定 | 判断「决策 → 行动 → 观察」循环何时真的该结束 | 模型说一句「我这就去做」就停了,任务状态却是成功 |
| 工具调用前后 | 校验参数、截断结果、包装错误 | 超长结果把上下文撑爆;依赖挂了模型还在重试 |
| 上下文 | 装配 system prompt、注入提示、裁剪旧结果 | 注入位置不稳定,前缀缓存反复失效 |
| 权限 | 读/写分级、按工具名放行、人工确认 | 模型把「想买」当「确认买」,真的下了单 |
| 预算 | 迭代数、墙钟时间、token/费用、检索次数 | 死循环烧钱,没人知道 |
| 可观测 | 每个 hook 的决策写进 trace | 被拦了都不知道是哪条规则拦的 |
和 [Agent Runtime与Checkpoint机制](/topics/ai-agent/Agent Runtime与Checkpoint机制) 的分工:Runtime 是执行引擎,驱动循环、调度工具,管任务生命周期(暂停/恢复/持久化);Harness 是挂在这个循环上的规则层,管单个回合内每一步能不能做、什么时候必须停。两者常在同一个框架里,面试时分开讲更清楚。
2. Hook 切点:在生命周期上开口子#
不同框架的事件名各不相同,但切点位置基本一致。先看通用模型:
flowchart LR A[会话开始 / 装配上下文] --> U[用户输入进入] U --> B[模型调用前] B --> C((模型推理)) C --> D[模型调用后] D -->|有 tool_call| E[工具调用前] E -->|放行| F((执行工具)) E -->|拒绝| B F --> G[工具调用后] G --> B D -->|无 tool_call| H[准备结束] H -->|规则要求继续| B H -->|检查通过| I[会话结束]
| 通用切点 | 典型用途 | 能做的动作 |
|---|---|---|
| 会话开始 / 装配期 | 拼策略块、加载目录类信息、初始化日志 | 改 system prompt、注入初始上下文 |
| 用户输入进入 | 输入审核、越界拦截、补充上下文 | 拦截输入、追加上下文 |
| 模型调用前 | 看门狗、预算档位、裁剪旧工具结果 | 注入提示、换模型、跳过本次调用 |
| 模型调用后 | 漂移检测、检查 tool_call 是否合规 | 写纠正提示、改写或丢弃本次输出 |
| 工具调用前 | 权限、终结后拦截、顺序约束、熔断 | 放行 / 拒绝(回一条给模型看的说明)/ 改参数 / 转人工确认 |
| 工具调用后(含失败) | 截断、外部内容加围栏、记终结、循环计数 | 改写结果、追加提示 |
| 准备结束 | 检查是否真的完成(模型想「空口收尾」)、最终答案审计 | 阻止结束并要求再来一轮、改写或拒绝最终输出 |
| 会话结束 | 脱敏、记账、清理资源 | 只做副作用,不再影响本轮 |
此外还有一类旁路切点:上下文压缩前后、子 Agent 启动/结束、需要权限决策时。它们不在主循环上,但同样适合挂审计和治理逻辑。
主流框架的对应事件(事件清单随版本增加,以各自官方文档为准):
| 通用切点 | Claude Code hooks / Claude Agent SDK | OpenAI Agents SDK | LangChain v1 middleware |
|---|---|---|---|
| 会话开始 | SessionStart | on_agent_start(RunHooks)/ on_start(AgentHooks) | before_agent |
| 用户输入进入 | UserPromptSubmit | input guardrail | before_agent |
| 模型调用前 | 无直接对应(用 UserPromptSubmit 注入上下文,压缩走 PreCompact) | on_llm_start(只观察) | before_model、wrap_model_call |
| 模型调用后 | 无直接对应 | on_llm_end(只观察) | after_model、wrap_model_call |
| 工具调用前 | PreToolUse(permissionDecision: allow / deny / ask,可用 updatedInput 改参数)、PermissionRequest | on_tool_start(观察);tool input guardrail(拦截) | wrap_tool_call |
| 工具调用后 | PostToolUse、PostToolUseFailure、PostToolBatch | on_tool_end(观察);tool output guardrail(拦截) | wrap_tool_call |
| 准备结束 | Stop、SubagentStop(decision: "block" 阻止结束) | output guardrail(校验最终输出) | after_model 里 jump_to="model" 让循环继续;after_agent |
| 会话结束 | SessionEnd | on_agent_end / on_end | after_agent |
几点区别值得记住:
- Claude Code 的 hook 是外部命令(Agent SDK 里也可以是回调函数),用 matcher 按工具名过滤。退出码 2 表示阻止(能阻止的事件上生效),退出码 0 时可在 stdout 返回 JSON 做细粒度控制,被阻止的原因会回给模型。Claude Agent SDK 的 Python 和 TypeScript 版事件集不完全相同,部分事件(如
SessionStart、SessionEnd、PostToolBatch)只在 TypeScript 版提供。 - OpenAI Agents SDK 把「观察」和「拦截」分开:
RunHooks/AgentHooks的on_*回调用来记录和埋点;要拦截就用 guardrail,返回tripwire_triggered=True后抛InputGuardrailTripwireTriggered、OutputGuardrailTripwireTriggered或 tool guardrail 对应的异常。input guardrail 默认和 Agent 并行跑(run_in_parallel=True),想在模型花 token 之前拦下,要设成False。 - LangChain v1 middleware 分两种:node 式(
before_agent/before_model/after_model/after_agent,顺序执行,可用jump_to跳到"model"、"tools"、"end")和 wrap 式(wrap_model_call/wrap_tool_call,包住一次调用,能重试、换模型、改请求或结果)。官方内置了HumanInTheLoopMiddleware、SummarizationMiddleware、ModelCallLimitMiddleware、ToolCallLimitMiddleware、PIIMiddleware等,本质就是现成的 harness 规则。
按「能做什么」可以把各家的 hook 分成四类:观察型(只记录)、拦截型(拒绝 / 抛 tripwire)、改写型(改参数、改结果、注入上下文)、跳转型(让循环继续或提前结束)。设计自己的 harness 时,每个切点都应该说清支持哪几类动作。
3. Hook 契约:几条决定可维护性的设计#
class HookReject(Exception):
def __init__(self, msg_to_model: str, escape_key: str | None = None):
self.msg_to_model = msg_to_model # 写给模型看的:为什么拒、下一步该做什么
self.escape_key = escape_key # 只有「效率闸」才允许声明
class HookBus:
def __init__(self):
self.hooks: dict[str, list[tuple[int, callable]]] = {}
def register(self, point: str, priority: int):
def deco(fn):
self.hooks.setdefault(point, []).append((priority, fn))
self.hooks[point].sort(key=lambda x: x[0]) # priority 升序
return fn
return deco
def run(self, point: str, ctx: dict):
for _, fn in self.hooks.get(point, []):
try:
fn(ctx) # 只读事实、写决策,不直接执行副作用
except HookReject:
raise # 拒绝信号向上抛,由适配器落地
except Exception as e: # 治理代码的 bug 不拖垮主链路(fail-open)
log.warning("hook %s failed: %s", fn.__name__, e)python- 钩子只决策,适配器落地:钩子改 ctx 或抛拒绝信号;换模型、拒工具、写状态统一在适配器里做,便于审计。
- priority 是契约:比如「截断」必须早于「追加提示」,否则提示被截掉。顺序契约集中写在一个地方,不散在各文件。
- 判据读事实,不维护影子状态:「本轮调过哪些工具」「候选数」是事实;再造一个阶段状态机去镜像它们,就多了一套要对账的真相。
- 禁用工具不摘工具表:每轮工具列表保持不变,拒绝在执行层回哨兵文案。摘工具会让 prompt 前缀变化、缓存失效。
- 异常策略要写明:fail-open(钩子挂了就跳过)保可用性,但安全钩子应只做纯计算,或内部 catch 后主动抛拒绝,走 fail-closed。
4. 规则分类:按「为什么拦」分五类#
| 类别 | 判据例子 | 动作 | 软/硬 |
|---|---|---|---|
| 终止 | 已调过终结工具;模型想结束但没调终结工具;迭代/墙钟超限 | 拦后续工具;催一次;硬停并合成部分答案 | 由软到硬 |
| 安全 | 写工具未经确认;取消订单前本轮没查单;外部内容 | 硬拒;加 <external_content> 围栏 | 硬,不给逃生门 |
| 预算 | 检索次数、token/费用、单工具配额 | 降档模型 → 注入收尾指令 → 硬挡 | 先软后硬 |
| 重复 | 滑窗内同一工具出现过多;工具连续失败 | 追加「换思路」提示;熔断一段时间 | 多为软 |
| 漂移 | 连续多轮没有实质进展;偏离用户原始约束 | 注入纠偏提示 | 软 |
一个关键区分:安全闸判精确事实,永远硬拒;效率闸判推定(「你大概不需要再搜了」),可能判错,所以要留逃生门——同一闸连拒 N 次后放行,避免把正确操作锁死。
5. 终止控制:从软到硬叠几层#
from collections import deque
class LoopDetector:
"""滑窗内同一工具出现次数超阈值 -> 提示,不硬停"""
def __init__(self, window=6, threshold=4):
self.recent = deque(maxlen=window)
self.threshold = threshold
def observe(self, tool_name: str) -> str | None:
self.recent.append(tool_name)
if self.recent.count(tool_name) >= self.threshold:
return f"[系统提示] 近 {len(self.recent)} 次调用中 {tool_name} 已出现 " \
f"{self.recent.count(tool_name)} 次,请换思路或直接收尾。"
return Nonepython- 终结拦截:定义一组终结工具(出最终答案、下单卡片、追问用户),调用后置位;之后同一回合的其他工具调用一律拦截。并行调用要按「批次」判,同一条模型消息里的兄弟终结调用应放行,否则并发调度顺序决定结果。
- 空口收尾检测:模型不调工具就想结束、且本轮没调过终结工具 → 注入提示再来一轮。每次模型调用最多催 1 次,靠迭代上限封顶。
- 看门狗:用「最近一次工具成功」作为进展信号,停滞超过阈值先注入收尾指令,再超时就用已有结果合成部分答案。
- 迭代上限 + 墙钟超时:最后一道。超时建议把截止时刻下传到各出站调用(取
min(自身超时, 剩余时间)),而不是只在最外层一刀切。 - 循环检测:同工具同参数、滑窗内同工具高频都算。检测后优先提示,硬停交给上面几层。
6. 写操作的多级拦截#
四层依次是:注册时标 is_read_only 分出写工具 → 权限引擎对非只读工具默认挂起、按工具名逐个 ALLOW → 写工具只生成待确认卡,真正执行只走用户点按钮的 HTTP 接口 → 确认卡按 run_id + 动作 + 载荷指纹 做唯一约束。确认卡的字段、暂停方式和各种决议结局见 Human-in-the-loop确认交互设计。
配一条测试守住:断言「每个非只读工具都在放行表里」,否则某个写工具走了别的入口(比如 REST),会一直没人发现它在 Agent 里会被挂起。
7. Hook 规则冲突导致死循环:怎么查#
常见的三种形态:
| 形态 | 现象 | 修法 |
|---|---|---|
| 互相矛盾 | 预算钩子注入「立即收尾」,资格闸却拦下收尾工具(例如「本轮没规划过不许出清单」) | 置一个「强制收尾授权」事实位,收尾指令发出时同步放开对应的闸 |
| Stop 类钩子反复阻止结束 | 每次想停都被要求继续,直到迭代上限 | 催促次数封顶;阻止结束时带上「已阻止过」标记,第二次放行(Claude Code 的 Stop hook 输入里有 stop_hook_active 字段,hook 应据此判断是否已经在阻止后的续跑中,避免无限循环) |
| 效率闸误判 | 正确工具被一直拒,模型每轮重试同一个调用 | 效率闸声明逃生门,连拒 N 次后放行,计数不清零(闩锁) |
排查步骤:
- 每个 hook 的决策都进 trace:
(point, hook, decision, reason, think_step),出问题先按回合拉时间线。 - 看是否出现「拒绝 A → 提示做 A → 再拒绝 A」的交替模式,定位是哪两条规则在打架。
- 检查每一条「你必须做 X」的注入,确认所有闸门都存在允许 X 的路径。
- 检查计数器是边沿触发还是电平触发:「连续 N 轮无进展」应在进展首次出现时重置;写成「有候选就重置」,候选出现后每轮都重置,漂移检测就失效了。
- 用离线回放复现:同一份模型输出序列喂给 Harness,确认是规则问题还是模型问题。
面试回答(2分钟版)
我理解的 Harness 就是模型之外的控制层。模型只负责说下一步想干什么,工具调用放不放行、结果怎么截断、有没有权限、预算还剩多少、什么时候必须停,都由 Harness 决定;循环本身由框架驱动。实现上是在 Agent 生命周期上开 hook 切点:会话开始、用户输入、模型调用前后、工具调用前后、准备结束、会话结束,每个切点挂一串按 priority 排序的钩子。主流框架都是这个结构,比如 Claude Code 的 PreToolUse、PostToolUse、Stop,OpenAI Agents SDK 的 guardrail 和生命周期回调,LangChain 的 before_model、wrap_tool_call 中间件。钩子只做决策,比如注入提示或者抛拒绝信号,真正落地由适配器统一执行。规则我按五类分:终止、安全、预算、重复、漂移。终止是从软到硬叠的:调了终结工具就拦后续调用,模型空口收尾就催一次,看门狗发现长时间没进展先催再硬停,最后是迭代上限和超时。写操作走多级拦截:只读标记、权限引擎按工具名放行、写工具只出确认卡,真正执行只走用户点按钮的接口。几个坑:安全闸判事实要硬拒,效率闸判推定要留逃生门,不然会把正确操作锁死;禁用工具别从工具表里摘,会破坏前缀缓存;规则之间可能互相打架导致死循环,所以每个钩子的决策都要进 trace。结合项目时可以讲:哪些规则从 prompt 挪到了 hook、闸分几类、用什么数据验证拦截没有误伤。
追问与易错
追问方向:
- “规则直接写进 system prompt 不行吗?” → prompt 只能提高模型遵守的概率,hook 在执行层拦截,模型怎么想都过不去。典型是取消订单:模型可能编一个订单号,所以要在工具调用前的切点判断「本轮有没有先查单」,没有就硬拒。
- “被拒的工具调用应该返回什么给模型?” → 返回一条写给模型的说明:为什么被拒、下一步该做什么(比如「已达检索上限,请用现有候选调用 summary 收尾」)。只回「permission denied」,模型大概率换个参数再撞一次。
- “为什么禁用工具不从工具列表里删?” → 工具定义在 prompt 前部,每轮变动会让前缀缓存从那里断开;在执行层拒绝并回哨兵文案,工具表保持字节级一致。代价是模型多一次「调了才知道被拒」的往返。
- “并行工具调用时,终结拦截怎么判?” → 不能只用一个布尔位:并发执行时谁先跑完谁置位,兄弟调用就被拦。要记置位时的批次号(同一条模型消息共享),同批的终结调用放行,下一批一律拦。
- “效率闸的逃生门会不会被滥用?” → 只给判推定的效率闸(比如「大概不需要再搜」),连拒 N 次放行;安全闸(计数超限、未确认写操作)不允许声明逃生门。同一批并行调用的多次拒绝只算一次。
- “钩子本身抛异常怎么办?” → 默认 fail-open,治理代码的 bug 不拖垮主链路;安全钩子只做集合、正则这类纯计算,将来引入带 IO 或 LLM 的安全钩子,必须内部 catch 后主动抛拒绝(fail-closed)。
- “外层有 asyncio.timeout,为什么还要把 deadline 下传?” → 外层超时是一刀切,出站调用不知道还剩多久;下传后各出站点取
min(自身超时, 剩余),剩 3 秒就不会再按 5 秒等。剩余见底时给一个很小的正数,因为有些客户端把 0 当无限等待。 - “迭代上限设多少?” → 没有通用值,按正常任务的步数分布定,留出余量;超限要当错误上报并用中间结果收尾,而不是直接抛异常给用户。
易错点:
- ❌ “Harness 就是框架的 Agent 类” → 框架提供循环,Harness 是你在循环外加的约束层,框架换了这层规则还在。
- ❌ “所有闸都硬拒最安全” → 效率类判断会误判,全硬拒会把正确操作锁死,模型每轮重试同一个调用。
- ❌ “用状态机描述阶段更清晰” → 阶段只是已发生事实的影子,自己维护不变量就成了第二套真相,对不上时不报错。