第 8 章:Hooks 与评估——让每一步都能被检查、被数字收口
← 上一章:Tool、Skill 与 MCP · 下一章:Goal 目标驱动
开场:品控只尝出锅的那道菜,够不够?
一家餐厅的品控站在出菜口,每一道菜出锅他都要尝一口——味道对了,就放行。
问题是:他只尝到了成品。他看不见后厨里发生了什么:厨师是照着今天的新菜谱做的,还是凭记忆乱放料?他有没有偷偷用了那把他不被允许碰的刀?有一锅炒糊了,厨师是不是靠多加一勺酱盖过去的?最后菜端出来味道确实不错——但你没法从「一道好菜」倒推出「过程是干净的」。
更糟的是,品控说「这道菜合格」的时候,凭的是一句口头判断。如果下个月他被要求证明「这批菜确实没有一只用违规食材」,他拿不出任何东西——没有检查记录、没有时间戳、没有是谁在哪个环节确认过的证据。
Agent 也是一样。只看最终回答,你永远不知道它有没有在回答前读了正确资料、有没有调用未授权工具、有没有在工具报错后偷偷换掉结论、有没有编造一句「任务已完成」。这一章就把品控升级成一整套可检查、可验证、可比较、可拦停的治理链。本章结束时,你能回答:为什么「最终回答是对的」不等于「这次运行是干净的」?
为什么「只检查最终回答」不够?
- A:检查太慢,拖慢回复
- B:只看最终成品,无法知道过程是否合规、有没有越权调用、错误有没有被掩盖
- C:最终回答通常是对的
- D:模型不喜欢被打扰
只检查结果,会漏掉什么
不看过程,常见的失败长这样:
| 表象 | 真正缺口 | 后果 |
|---|---|---|
| 最终答案正确 | 没保存过程 | 无法复现,也无法定位偶然成功 |
| Hook 抛错后继续 | 安全 Hook 采用 fail-open | 检查器坏掉时,危险动作反而放行 |
| 输出被截断 | 没保存原文哈希和产物引用 | 审计证据不可恢复 |
| 只比较平均成功率 | 没检查安全回退与样本差异 | 新版总体更高,关键风险用例却退化 |
| LLM 说「第 42 行有问题」 | 没有 Verifier 回查 diff | 幻觉被当成门禁证据 |
工程上最危险的不是失败,而是系统把失败记录成成功。这一章的主线,就是把「检查」从一句话升级成五道关:
Hook 在生命周期节点留下证据 → 轨迹把证据串起来 → Verifier 判断证据是否支持结论 → Metrics 把判断变成可比较的数字 → Release Gate 决定能不能发布。
五道关缺一不可:没有 Hook,就无处下钩;没有轨迹,证据就散落一地;没有 Verifier,模型说什么你都信;没有 Metrics,两版没法比;没有 Release Gate,评估再好也上不了线。
第一关:Hook——把检查点挂到生命周期上
Hook 不是「随手加的一个回调」,而是 Harness 的横切控制面。它要回答一个重复出现的问题:日志、安全、格式、质量门禁这些检查,如果散落在每个工具函数里,每个工具都要重复一遍,改一处要改全套。把检查点抽出来,挂到 Agent 生命周期上,才是统一治理。
一次 Hook 处理事件,走四步:
Event(事件)→ Matcher(匹配器)→ Handler(处理器)→ Decision(决策)- Event:事件类型,比如
before_tool_call、after_tool_call、on_stop; - Matcher:这个 Hook 对哪些工具生效。
"*"匹配所有,"Edit|Write"匹配写入类,"run_command"精确匹配; - Handler:实际干活的部分——审计、拒绝、改写参数或质量检查;
- Decision:返回结构化结果,而不是一句自然语言。
教学仓库 step12_hooks.py 里的 HookDecision 有四种动作,加一个可选改写:
class HookDecision:
def __init__(self, action, reason="", updated_input=None):
self.action = action # "allow" | "deny" | "ask" | "block"
self.reason = reason # 人类可读原因
self.updated_input = updated_input # 可选:改写工具参数allow 放行;deny 拒绝(原因反馈给 Agent);ask 请求用户确认;block 阻止并给出原因。updated_input 是危险而有用的一招:它能在执行前改写工具参数——比如把演示环境的写路径改写到沙箱。
HookRegistry 按注册顺序触发,有四条核心规则:
- 按注册顺序执行,先注册的先收到事件;
before/after_tool_call事件按matcher过滤;- 任一 Hook 返回非
allow的决策,立即短路; - 单个 Hook 抛异常不影响其他 Hook,打印
[hook error]后继续。
第三条规则意味着:注册顺序本身属于策略的一部分——排在前面的安全 Hook 优先拦截。第四条规则则带来一个必须回答的问题:一个 Hook 崩了,是跳过它继续(fail-open),还是拦下整个动作(fail-closed)?安全类 Hook 必须显式选择,不能靠默认行为蒙混。
before 与 after 解决的是两类不同的问题:
| 时点 | 能做什么 | 不能替代什么 |
|---|---|---|
before_turn | 注入会话规则、检查输入 | 不能验证最终产物 |
before_tool_call | 拒绝、询问、改写参数 | 不能确认工具真实结果 |
after_tool_call | 审计结果、脱敏、截断、计时 | 不能撤销已发生的外部副作用 |
after_turn | 记录一次 turn 的结果 | 不能判断长期 Goal 已完成 |
on_stop | 质量门禁、有限次数重试 | 不能无限阻止 Runner 停止 |
一句话记住边界:Before 负责预防,After 负责审计或补偿。 教学版的 ask 决策默认走同步确认,而非交互环境默认拒绝——这更接近权限 Hook 的 fail-closed 行为。
某个 Hook 的 Handler 抛了异常,HookRegistry 会怎么做?
- A:整个 Agent 崩溃
- B:短路,后续所有 Hook 都不再触发
- C:打印 [hook error] 后继续,单个 Hook 异常不影响其他 Hook
- D:自动重试十次再放行
第二关:轨迹——把检查点留下的证据串起来
Hook 在每个节点留下一条证据,但一条条散开的证据还不是历史。轨迹把同一任务的所有事件按顺序串起来,让外部观察者无需解析自然语言就能恢复状态。
一条可评估轨迹至少要有四类东西:
trajectory = {
"task_id": "review-42",
"events": [
{"type": "turn.started", "seq": 1},
{"type": "tool.completed", "seq": 2, "tool": "read_diff"},
{"type": "review.completed", "seq": 3, "finding_count": 2},
],
"artifacts": [{"kind": "mr-report", "sha256": "..."}],
"terminal": {"status": "completed", "reason": "verified"},
}聊天文字、工具返回、决策、指标和产物引用要分开保存,别混进一个超长 JSON。三个纪律:
事件要能回答「是哪次任务」。 run_id / task_id 把事件归到同一次运行;seq 保证顺序、支持断线续传。观察一个运行,你要能说出现在是执行、验证还是等待,哪个策略作出了决定,是「模型没做」还是「Harness 阻止」。
大输出可以截断,但证据不能丢。 一个 4000 字符上限的展示,永远不能替代原始记录。生产实现应该保存三件套——预览、哈希、产物引用:
{
"preview": output[:4000],
"sha256": sha256(output.encode()).hexdigest(),
"artifact_ref": "artifacts/run-42/tool-3.txt",
}哈希让你能校验「截断的展示」和「原始记录」确实是同一份;产物引用让你能回取原文。「显示省空间」永远不能变成「证据丢失」。
不要只打印「正在执行」。 planning 只是转圈动画,plan.step_completed 才是过去式事实。可重放、可审计的事件才能当证据。
第三关:Verifier——证据支不支持结论
轨迹有了,接下来是这一章最关键的判断:模型说「完成了」,凭什么信? 答案是把「判断」拆成几层,确定性的事实交给程序,只有真正需要理解力的软指标才交给模型。
chapter8/trajectory-verifier/verifier.py 的四层组合给了个好例子:
| 验证器 | 证明什么 | 怎么证明 |
|---|---|---|
ResultVerifier | 最终结果对不对 | 拿 final_state 和 expected_outcome 逐字段比对 |
ProcessVerifier | 过程合不合规 | 策略、隐私、依据、承诺与动作一致性 |
HeuristicQualityJudge | 表达质量这类软指标 | 可读性、是否绕开拒绝去给替代方案 |
TrajectoryVerifier | 汇总并给出整体建议 | 合并多维结果,标出关键失败维度 |
注意 ProcessVerifier 里有一条**「承诺与动作一致性」**检查:轨迹里如果 Agent 承诺「我要用 search_kb」,那成功调用的工具集合里就必须真的出现 search_kb;说了却没成功调用,这条维度直接判失败。这就把「嘴上说要做」和「真的做了」分开了——和上一章「把 Tool Schema 交给模型不等于授权」是同一个道理的另一面:把「我说完成了」当成事实,是最常见的一种幻觉来源。
TrajectoryVerifier 的输出不是神秘总分,而是一份带原因的判词:
{
"trajectory_id": "...",
"overall_score": 0.7,
"release_recommendation": "review_or_accept", # 或 "reject"
"critical_failures": ["privacy_boundary"],
"dimensions": [ {"dimension": "...", "layer": "...", "verdict": "...",
"score": ..., "evidence": [...], "confidence": ...} ],
}关键失败维度(比如隐私泄露、规则违规、事实无依据)中任何一项失败,就直接 reject,不靠其他维度的高分来补。一层层的证据,比一个平均分可靠得多。
before_tool_call 能拒绝一个工具调用,为什么不能证明工具成功?
- A:因为它跑在工具执行之前,看不到真实结果
- B:因为它权限不够
- C:因为工具一定会成功
- D:因为拒绝后工具仍会执行
ResultVerifier 与 ProcessVerifier 分别证明什么?
- A:ResultVerifier 查过程合规,ProcessVerifier 查结果匹配
- B:ResultVerifier 核对最终环境状态,ProcessVerifier 核对必要步骤与策略是否真实发生
- C:两者都只检查日志格式
- D:两者都必须用 LLM 打分
第四关:Metrics 与 Release Gate——数字说话,闸门把关
判断有了,但两版之间怎么比?Metrics 把 Verifier 的判断变成可比较的数字:成功率、成本、延迟、安全回退数。然后 Release Gate 决定「新版能不能上线」。
Mini Emperor 的 SkillEvaluator.decide() 把晋级条件写成一串不可互相抵消的约束:
quality_improvement = success_rate - baseline_success_rate >= 0.10
efficient_without_loss = (
success_rate >= baseline_success_rate
and cost_reduction >= 0.15
)
eligible = (
safety_regressions == 0
and deterministic_tests_passed
and (quality_improvement or efficient_without_loss)
)成功率再高,也不能抵消安全回退;确定性测试失败,直接出局。这不是「算个总分比高低」,而是每一道门都必须单独通过。
chapter8/prompt-auto-optimization/release_gate.py 用同样的思路管「提示词补丁」的发布,四项检查缺一不可:
checks = {
"patch_is_nonempty": bool(manifest.get("diff", "").strip()),
"source_cases_are_recorded": bool(manifest.get("source_case_ids")),
"holdout_did_not_regress": holdout_after >= holdout_before,
"boundary_improved": boundary_after > boundary_before,
}
accepted = all(checks.values())
decision = "release_to_canary" if accepted else "reject_candidate"注意 holdout_did_not_regress——优化器拿训练用例调出来的补丁,必须在没用过的保留集上不低于旧版,边界用例还得更好。优化器只能生成候选,评估和人工批准之后才能切换稳定版本。稳定版不能被自动覆盖。
Release Gate 是「算一个总分」吗?
- A:是,算一个总分比高低就行
- B:是,只看成功率
- C:不是,是一组不可互相抵消的约束,安全回退零容忍
- D:不是,全看运气
五关落地:GitLab MR 审核这个具体实例
前面是通用链路,代码审核是它最直观的一个实例:
MR diff
→ lint/type/test/secret scan
→ Reviewer 提议问题
→ Verifier 回查 file + line + evidence
→ 结构化报告
→ P0/P1 阻断;P2/P3 提示
→ Artifact + MR NoteMini Emperor 的 AsyncReviewPipeline 把「Reviewer 提议」和「Verifier 核验」拆成两步:模型提出的每个问题,必须在 added_lines(diff) 的同一文件、同一行号、同一原文里找到证据;找不到的发现直接丢弃。这一道防线,就是「第 42 行有问题」这类幻觉过不了关的原因。
gitlab_review.py 把报告落地到 GitLab:
- 报告永远保存为 Artifact(稳定证据,不依赖网络);
- 有最小权限 Token 时再写 MR Note(协作入口,不是证据本体);
- CLI 检测到已验证的 P0/P1 后,退出码为 1,在真正合并前把门关上。
为什么报告既要写 Artifact、又可以写 MR Note? Artifact 是审计证据,丢了就无法复现;MR Note 是给协作的人看的。证据和协作入口分开,才不会被一条消息同时承担两个职责。
完整流程:一条证据链串起五关
从事件到发布,每一步留下的证据都串在同一条链上:Hook 生成证据,轨迹保存证据,Verifier 评估证据,Metrics 量化证据,Gate 决定证据够不够上线。 少了任何一环,这一环之后的所有判断都会失去根基。
动手:让两个失败发生,再用一个测试拦住
理论和手感之间隔着一道墙,亲手撞一次才会留下印象。下面两段代码都是故意写错的。
失败一:安全 Hook 崩了就跳过(fail-open)。
# 错误:检查器崩了就跳过,危险动作照样放行
class ToolPolicyHook:
def handle(self, ctx):
if "rm -rf" in ctx["input"]["command"]:
return Decision("deny", "禁止危险命令")
return Decision("allow")
def emit(event, hooks):
for hook in hooks:
try:
d = hook.handle(event)
except Exception:
continue # ← fail-open:检查器坏了,动作反而放行
if d.action != "allow":
return d
return Decision("allow")
# ctx 里缺 "input" 键 → ToolPolicyHook 抛 KeyError → except 跳过 → 返回 allow
print(emit({"tool": "run_command"}, [ToolPolicyHook()]))安全 Hook 一旦崩溃就跳过,意味着「检查器坏了」的结果是「危险命令照常执行」——这是最危险的失败方向。安全 Hook 必须显式 fail-closed:异常时返回 block,并让终止原因指向具体 Hook。
失败二:用一个神秘总分做门禁,安全回退被平均掉。
# 错误:一个神秘总分,安全回退反而成了加分项
new = {"success": 0.95, "cost_reduction": 0.30, "safety_regressions": 1}
score_new = (new["success"] * 1.0
- new["cost_reduction"] * 0.5
+ new["safety_regressions"] * 0.2) # ← 回退数越高分越高,反了
score_old = 0.70
release = score_new > score_old
print("神秘总分:", round(score_new, 2), "→ 放行?", release)回退 1 个安全用例,居然让总分更高——因为它被写成了加分项。Release Gate 之所以不能是一个总分,是因为约束不可互换:安全回退必须零容忍,不是可以被成功率涨幅抵消的一项指标。
跑完后用现成测试验证「安全回退零容忍」这道闸真实有效:
cd "$(git rev-parse --show-toplevel)"
uv run --python 3.12 --extra dev pytest \
mini-emperor/backend/tests/test_skills.py::test_skill_evaluator_rejects_safety_regression -v如果它不过,按这个顺序排查:
- 安全回退被放行 →
safety_regressions == 0有没有作为eligible的前置条件; - 成功率高却没晋级 →
quality_improvement/efficient_without_loss的阈值有没有满足; - 测试根本没跑到 → 测试文件名与函数名是否一致、路径是否在
mini-emperor/backend/tests/下。
门禁·代码:跑通上面这条测试,把输出保存为证据。
本章小结
这一章把「检查」从一句话升级成了五道关。五件事连起来看:
Hook 是生命周期连接点,不是随手回调。 Event → Matcher → Handler → Decision 四层结构让检查成为横切控制面。Before 预防,After 审计或补偿;安全 Hook 必须显式 fail-closed,不能靠默认跳过。
轨迹是串起来的证据,不是日志堆积。 事件要能回答「哪次任务、什么顺序、谁决定、证据在哪」。大输出可以截断,但必须保留哈希和产物引用——展示省空间,证据不能丢。
Verifier 把「说完成」和「真完成」分开。 确定性事实用程序验证,「承诺做了」必须匹配「成功调用」的证据;关键失败维度直接 reject,不靠高分互补。
Metrics 让两版可比,Gate 决定能否上线。 门禁是一组不可互相抵消的约束,不是神秘总分。优化器只能生成候选,评估和人工批准之后才能切换稳定版本。
一句话记住这一章:
每一步都留下可检查的证据,证据串成轨迹,轨迹支撑结论,结论变成数字,数字决定发布。最危险的不是失败,而是系统把失败记录成成功。
如果上面这些话你自己也能说得出来(不看资料),就说明这一章的知识已经连成一片。
复述与证据
合上资料,画出五道关的完整链路:Runner 事件 → Matcher → Handler → Decision → 轨迹 → Verifier → Metrics → Release Gate → 发布或保留。标出每一道关里「谁生成证据、谁保存证据、谁评估证据、谁量化证据、谁决定发布」。
再追问四个问题:
- 为什么
before_tool_call能预防,却不能证明工具成功? - 一条可评估轨迹至少包含哪些字段?输出被截断时靠什么找回原文?
- ResultVerifier 与 ProcessVerifier 分别证明什么?
- 为什么「成功率提高」不能抵消「安全回退 1 个」?
门禁·证据:提交一条真实运行轨迹,标出至少两个 Hook 决策(含 decision 与 reason)、一条被 Verifier 拦截的失败维度、以及 Release Gate 的最终决策。
24 小时后:不看资料,写一个最小 Hook 管线——支持 matcher 过滤、非 allow 短路、fail-closed 异常处理,并给一个「承诺调用 search_kb 却没有成功调用」的轨迹判定为失败。
有兴趣可以继续看
以下资料按优先级排。至少完成一项 P0,并保存完成证据。
视频P08 分钟 · 视频 08 [00:00:45–00:02:58] 三层结构,再看 [00:03:43–00:04:25] before/after 边界
- 预计
- 8 分钟
- 位置
视频 08 [00:00:45–00:02:58] 三层结构,再看 [00:03:43–00:04:25] before/after 边界- 阅读任务
- 画出 Event、Matcher、Handler、Decision,标出 before 与 after 的职责边界。
- 完成证据
- 记录一个 deny、一个 ask、一个 updated_input 的触发输入与结果。
教学仓库P035 分钟 · build-agent-example/code/step12_hooks.py 的 Hook / HookRegistry / ToolPolicyHook / OutputFormattingHook / StopQualityGateHook
- 预计
- 35 分钟
- 位置
build-agent-example/code/step12_hooks.py 的 Hook / HookRegistry / ToolPolicyHook / OutputFormattingHook / StopQualityGateHook- 阅读任务
- 追踪 ToolPolicyHook 从注册、匹配、决策到 Runner 消费的完整路径。
- 完成证据
- 保存 Hook 注册顺序、短路与错误隔离的测试输出,并说明注册顺序为何是策略的一部分。
书与实验P045 分钟 · chapter8/trajectory-verifier/verifier.py 与 demo.py
- 预计
- 45 分钟
- 位置
chapter8/trajectory-verifier/verifier.py 与 demo.py- 阅读任务
- 运行 demo.py 与 test_verifier.py,给一个失败维度补充证据字段。
- 完成证据
- 保存测试输出,并写清结果验证与过程验证的差异。
书与实验P045 分钟 · chapter8/prompt-auto-optimization/release_gate.py
- 预计
- 45 分钟
- 位置
chapter8/prompt-auto-optimization/release_gate.py- 阅读任务
- 验证候选不能覆盖稳定版,并修改一个指标让门禁拒绝。
- 完成证据
- 保存拒绝原因以及稳定版仍有效的断言。
论文P140 分钟 · ../source-notes/papers/classic-papers.md 的 AgentBench 与 SWE-bench
- 预计
- 40 分钟
- 位置
../source-notes/papers/classic-papers.md 的 AgentBench 与 SWE-bench- 阅读任务
- 比较通用 Agent 评估与代码修复评估的环境、任务和可执行判据。
- 完成证据
- 写出三条「不能只靠 LLM 打分」的理由。
本文提到的代码与出处(有兴趣逐条核对时用):
claude-agent-examples@54a18980334541773f13940aa0c0475728d30ee0:build-agent-example/code/step12_hooks.pyclaude-agent-examples@54a18980334541773f13940aa0c0475728d30ee0:tests/test_step12_hooks.pyai-agent-book@1c18370279f8f0457bf2c44dfa585d08d4d5f281:chapter8/trajectory-verifier/verifier.pyai-agent-book@1c18370279f8f0457bf2c44dfa585d08d4d5f281:chapter8/trajectory-verifier/demo.pyai-agent-book@1c18370279f8f0457bf2c44dfa585d08d4d5f281:chapter8/prompt-auto-optimization/release_gate.pyai-agent-book@1c18370279f8f0457bf2c44dfa585d08d4d5f281:chapter8/prompt-auto-optimization/test_learning_and_release.pymini-emperor/backend/src/mini_emperor/review.pymini-emperor/backend/src/mini_emperor/gitlab_review.pymini-emperor/backend/src/mini_emperor/skills.pymini-emperor/backend/tests/test_skills.pymini-emperor/backend/tests/test_review.py