Skip to content

你从第 08 章进入,还缺 01/03/07 前置。

自测代码证据锁定

第 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 处理事件,走四步:

text
Event(事件)→ Matcher(匹配器)→ Handler(处理器)→ Decision(决策)
  • Event:事件类型,比如 before_tool_callafter_tool_callon_stop
  • Matcher:这个 Hook 对哪些工具生效。"*" 匹配所有,"Edit|Write" 匹配写入类,"run_command" 精确匹配;
  • Handler:实际干活的部分——审计、拒绝、改写参数或质量检查;
  • Decision:返回结构化结果,而不是一句自然语言。

教学仓库 step12_hooks.py 里的 HookDecision 有四种动作,加一个可选改写:

python
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 按注册顺序触发,有四条核心规则:

  1. 按注册顺序执行,先注册的先收到事件;
  2. before/after_tool_call 事件按 matcher 过滤;
  3. 任一 Hook 返回非 allow 的决策,立即短路;
  4. 单个 Hook 抛异常不影响其他 Hook,打印 [hook error] 后继续。

第三条规则意味着:注册顺序本身属于策略的一部分——排在前面的安全 Hook 优先拦截。第四条规则则带来一个必须回答的问题:一个 Hook 崩了,是跳过它继续(fail-open),还是拦下整个动作(fail-closed)?安全类 Hook 必须显式选择,不能靠默认行为蒙混。

beforeafter 解决的是两类不同的问题:

时点能做什么不能替代什么
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 在每个节点留下一条证据,但一条条散开的证据还不是历史。轨迹把同一任务的所有事件按顺序串起来,让外部观察者无需解析自然语言就能恢复状态。

一条可评估轨迹至少要有四类东西:

python
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 字符上限的展示,永远不能替代原始记录。生产实现应该保存三件套——预览、哈希、产物引用:

python
{
    "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_stateexpected_outcome 逐字段比对
ProcessVerifier过程合不合规策略、隐私、依据、承诺与动作一致性
HeuristicQualityJudge表达质量这类软指标可读性、是否绕开拒绝去给替代方案
TrajectoryVerifier汇总并给出整体建议合并多维结果,标出关键失败维度

注意 ProcessVerifier 里有一条**「承诺与动作一致性」**检查:轨迹里如果 Agent 承诺「我要用 search_kb」,那成功调用的工具集合里就必须真的出现 search_kb;说了却没成功调用,这条维度直接判失败。这就把「嘴上说要做」和「真的做了」分开了——和上一章「把 Tool Schema 交给模型不等于授权」是同一个道理的另一面:把「我说完成了」当成事实,是最常见的一种幻觉来源。

TrajectoryVerifier 的输出不是神秘总分,而是一份带原因的判词:

python
{
    "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() 把晋级条件写成一串不可互相抵消的约束

python
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 用同样的思路管「提示词补丁」的发布,四项检查缺一不可:

python
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 审核这个具体实例

前面是通用链路,代码审核是它最直观的一个实例:

text
MR diff
→ lint/type/test/secret scan
→ Reviewer 提议问题
→ Verifier 回查 file + line + evidence
→ 结构化报告
→ P0/P1 阻断;P2/P3 提示
→ Artifact + MR Note

Mini 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)。

python
# 错误:检查器崩了就跳过,危险动作照样放行
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。

失败二:用一个神秘总分做门禁,安全回退被平均掉。

python
# 错误:一个神秘总分,安全回退反而成了加分项
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 之所以不能是一个总分,是因为约束不可互换:安全回退必须零容忍,不是可以被成功率涨幅抵消的一项指标。

跑完后用现成测试验证「安全回退零容忍」这道闸真实有效:

bash
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 → 发布或保留。标出每一道关里「谁生成证据、谁保存证据、谁评估证据、谁量化证据、谁决定发布」。

再追问四个问题:

  1. 为什么 before_tool_call 能预防,却不能证明工具成功?
  2. 一条可评估轨迹至少包含哪些字段?输出被截断时靠什么找回原文?
  3. ResultVerifier 与 ProcessVerifier 分别证明什么?
  4. 为什么「成功率提高」不能抵消「安全回退 1 个」?

门禁·证据:提交一条真实运行轨迹,标出至少两个 Hook 决策(含 decisionreason)、一条被 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 打分」的理由。

本文提到的代码与出处(有兴趣逐条核对时用):

← 上一章:Tool、Skill 与 MCP · 下一章:Goal 目标驱动

Markdown 是唯一内容源,HTML 由构建流程生成。