Goal & Gates 从零到一:让 agent「干完再停」,而不是「说完就停」
这是一份快照
本文的数字、常量、行数取自 2026-09-03 对 sid-code 源码的一次实读。 代码在动,这些数字会腐坏——引用其中任何一个之前,请按文中给出的命令在你自己的仓库里复跑一次。
这份文档想回答的问题:一个 coding agent 凭什么知道自己「做完了」? 它凭什么在没做完的时候不许停下?又凭什么在真的卡住时别烧到天荒地老?
三个问题的答案分别是:Goal(目标)、Gate(闸门)、收敛保险。 这三样加起来,就是本文的主题。
怎么读这份文档
面向谁:完全没接触过 agent harness 的人也能从第 0 章读起。不需要你先懂 OpenTelemetry、不需要你先读过 Claude Code 源码,只需要你知道「大模型是一个 输入文本、输出文本的 HTTP 接口」这件事。
读法建议:
| 你的情况 | 建议 |
|---|---|
| 完全没概念 | 第 0 → 1 → 2 章,读完就有了整套心智模型,可以停 |
| 想搞懂设计取舍 | 加读 3 → 4 → 6 章(三种评估架构、Gate 链、上下文注入) |
| 已经在做 harness | 直接跳第 7、9、10 章:闸门分类学、绿着坏掉的失效模式、怎么度量闸门有效性 |
一条阅读纪律:本文出现的每个具体数字(阈值、默认值、实测比例)都标了它的 来源类型。请注意这个区分——
- 🔬 源码实读:直接从 sid-code 当前源码核过,可复验
- 📊 轨迹实测:从真实运行数据统计出来的,带样本数
- 📄 二手来源:来自公开文档 / 第三方文章,未经我们源码核验
- 💭 设计推论:机理上讲得通,但没有实测支撑
区分它们不是学术洁癖。「机理上讲得通」和「实测有效」之间,隔着本文第 9 章 整整一章的坑。
目录
- 第 0 章 · 为什么需要它:从一个真实的憋屈场景讲起
- 第 1 章 · 名词地图:Goal / Gate / 评估者 / 证据 / 续命 / 硬停,先把词认全
- 第 2 章 · 最小心智模型:一次
end_turn到底发生了什么 - 第 3 章 · 三种评估架构:谁来判「做完了」——自我汇报 / 独立评估者 / 证据链
- 第 4 章 · Gate 链:四道闸的顺序,以及为什么是这个顺序
- 第 5 章 · Evidence Log:为什么它是这套设计里最关键的一块
- 第 6 章 · 上下文注入策略:目标要不要让主模型看见(一个 Cache 的取舍)
- 第 7 章 · 闸门分类学:硬停 / 软续命 / 软提醒,以及默认开关矩阵
- 第 8 章 · 收敛保险:预算 / 轮次 / 卡住 / 不可能,四重保险各防什么
- 第 9 章 · 会「绿着坏掉」的失效模式 ★ 全文最重要
- 第 10 章 · 度量:怎么证明一道闸门真的有用
- 第 11 章 · 面试题库(24 题,分四档)
- 第 12 章 · 术语表与学习路径
- 附录 A · 从零实现一个 mini Goal & Gates
- 附录 B · 可复跑的核验命令
第 0 章 · 为什么需要它
0.1 先看一个憋屈的场景
你让 AI 助手干一件事:
「把
test/auth/下所有测试修到绿灯。」
传统的一问一答模式下,会发生什么?
你:把 test/auth/ 下所有测试修到绿灯
AI:好的,我先跑一下测试。
[跑测试] → 12 个失败
我看到 12 个失败,第一个是 login.test.ts 的断言问题,
我修一下这个文件。
[改文件]
改好了,你要我继续吗?
⏸ ← 停在这里等你
你:继续
AI:[跑测试] → 9 个失败
修了 3 个。继续修下一个……
⏸ ← 又停了
你:继续
AI:……
⏸
你:继续
(重复 20 次)问题很清楚:你在人肉扮演一个 while 循环。
// 你此刻在手工执行的东西
while (还有测试没过) {
发一句「继续」;
}这件事憋屈的地方不在于「多打几个字」,而在于你必须一直盯着。你不能去泡咖啡、 不能开会、不能睡觉。任务本身能自动化,但「判断要不要继续」这件事没有自动化, 于是整件事都不能自动化。
0.2 那把 while 循环写进代码里不就行了?
对,方向就是这个。这就是 /goal 命令的全部出发点:
while (!goal_satisfied()) {
execute_one_turn();
}把「什么时候算完成」从你的脑子里搬到运行时里。你不再输入「继续」,你输入一次 完成条件,然后运行时替你判断要不要继续。
看起来只是把一个循环搬了个位置,但真正的难题藏在那个函数名里:
goal_satisfied()这个函数,谁来实现?
这个问题没有显而易见的答案,而它的三种不同答案,直接分化出了 2026 年三家主流 coding agent 的三种架构(第 3 章会逐个拆)。先感受一下它为什么难:
- 让 AI 自己判断? 那它就是又当运动员又当裁判。第 30 轮的时候模型「累了」—— 上下文塞满、注意力涣散——很可能草率地宣布「我认为已经完成了」。
- 写死规则判断? 「测试全绿」能写规则,那「把这个模块迁移到新 API」怎么写规则? 「审计这份文档并汇总告诉我」怎么写规则?
- 让另一个 AI 判断? 可以,但那个 AI 凭什么知道发生了什么?它得看到证据。 证据从哪来?这就引出了整个第 5 章。
0.3 光有 Goal 不够:还得有人管「别乱停」和「别乱跑」
假设你解决了 goal_satisfied()。你会立刻撞上第二类问题。
问题 A:模型会「做了一半就说完了」。
这不是 bug,是大模型的天性。它被训练成「产出一段看起来完整的回复」,而不是 「把活干完」。所以它常常:
AI:我已经修复了 auth 模块的问题。
(实际上:待办清单里还有 4 项 pending,它一个都没碰)
⏸ end_turn问题 B:模型会在原地打转,直到把你的钱烧光。
轮 18:跑 git status,输出 X
轮 19:跑 git status,输出 X
轮 20:跑 git status,输出 X
……
轮 47:跑 git status,输出 X这两类问题的解法是同一个东西:Gate(闸门)。
Gate 是一段代码,它挂在「模型说自己要停了」这个时刻上,做一次检查:
- 检查不通过 → 不放行,往对话里塞一句「你还有 4 项没做完」,把模型踢回去继续干。 这个动作在本文里叫 续命。
- 检查通过 → 放行,让这一轮真的结束。
于是完整的图景是这样:
Goal :负责回答「什么时候该停」——终点在哪
Gate :负责执行「现在不许停」——路上的收费站
保险 :负责兜住「无论如何也得停」——预算、轮次、卡住检测三者缺一不可,而且三者的失效方式完全不同,这是本文要反复强调的事。
0.4 一个真实的教训,先放在这里
先讲一个真实事故(第 9 章会完整解剖),因为它能一次说明这三样东西为什么必须一起理解。
📊 轨迹实测(sid-code 会话 20260707-102036,模型 glm-5.2):
- 第 16 轮:模型已经输出了完整的审计报告,
stop_reason=end_turn,任务实际上完成了。 - 第 16–31 轮:又跑了 15 轮,全是无用功——重复核查已经查过的东西、重跑全量测试、 写一次性验证脚本、甚至派了两个子代理去「对抗式复核」。
- 第 32 轮:请求 hang 死,白等 600 秒。
- 总账:40 分钟、↑475.6k tokens、$2.53,其中约一半是纯空转。
根因是什么?Goal 评估器全程故障——6 次调用,0 次成功,每次都精确卡在 8 秒超时。 而系统对「评估器坏了」的处理是:当成「目标未满足」,催模型继续干。
这就是本文最想让你记住的那类失败:
闸门本身坏掉的时候,它不会报错停下,它会用最像「正常工作」的方式, 把你的任务困死在里面。
模型自己都识破了(它在第 30 轮的思考里写下了「评估器似乎有问题」),但闸门不放行, 它就出不去。
0.5 本章自检
读完这一章,你应该能回答:
- 为什么「一问一答」模式跑不了长任务?(因为人在充当 while 循环)
/goal这类命令解决的核心难题是哪一个函数?(goal_satisfied())- Gate 和 Goal 的分工是什么?(Goal 定终点,Gate 拦「现在不许停」)
- 为什么闸门坏掉比没有闸门更危险?(它会伪装成正常工作,把任务困死)
第 1 章 · 名词地图:先把词认全
这一章不讲原理,只认词。agent harness 这个领域的词有个恶习:同一个东西三个名字, 不同的东西一个名字。先把地图画出来,后面读什么都不迷路。
建议第一遍粗读,遇到不懂的往回翻。
1.1 目标侧的词
| 词 | 是什么 | 一句话辨析 |
|---|---|---|
| Goal(目标) | 用户给出的完成条件,不是任务描述 | 「修 bug」是任务;「bun test 输出 0 failures」是 Goal。区别在于能不能判真假 |
| objective | Goal 的正文文本字段名 | 源码里 goal.objective 就是你输入的那句话。🔬 上限 4000 字符 |
| GoalState | 一个目标的完整运行时状态 | 包含 objective、状态、已用轮次、已用 token、证据日志 |
| GoalStatus | 目标当前处于哪个状态 | 🔬 sid-code 有 7 个:active / paused / blocked / impossible / budget_limited / turns_limited / complete |
这里有个新手最容易犯的错:把 Goal 写成任务描述。
❌ /goal 帮我优化一下性能
→ 「优化」没有真假值。评估者永远无法判定它满足了,
于是这个任务会一直跑到轮次上限。
✅ /goal 让 bench/api.bench.ts 的 p95 延迟从 340ms 降到 200ms 以下,
并把 bench 输出贴出来
→ 有数、有判据、有「怎么证明」。1.2 判定侧的词(这一组最容易混)
| 词 | 是什么 | 关键区别 |
|---|---|---|
| 评估者(evaluator) | 判断「目标是否达成」的那个东西 | 可以是另一个 LLM、也可以是规则。它是裁判,不是运动员 |
| 主模型(worker) | 真正干活的那个模型 | 读文件、改代码、跑命令的是它 |
| 自我汇报(self-report) | 主模型自己声明「我完成了」 | 便宜,但等于自己给自己打分 |
| Evidence Log(证据日志) | 从工具执行结果里自动抽出来的结构化证据链 | 关键词是「自动」——不靠模型主动汇报 |
| fast-path(快速路径) | 不调 LLM,用规则直接判定完成 | 🔬 省钱,也是评估器挂掉时的兜底 |
| blockerKey | 评估者返回的「当前卡在哪」的短标识符 | 用来做卡住检测。不是给人看的,是给机器比对的 |
评估者 vs 主模型这个区分是全文的地基。为什么必须分开?一句话:
如果让干活的模型自己判断干完了没有,它在没干完的时候, 有能力产出一段「看起来像干完了」的输出,然后自己判定通过。
这个风险叫 自评自判(self-grading),也叫「自圆其说」。架构上把裁判换成另一个 模型,这条路就被物理切断了。
1.3 闸门侧的词
| 词 | 是什么 | 一句话辨析 |
|---|---|---|
| Gate(闸门) | 挂在「模型想停」这个时刻上的检查 | 检查不过 → 不许停 |
| end_turn | 模型主动声明「这一轮我说完了」的信号 | 这是所有 Gate 的触发时机。协议层面是 stop_reason=end_turn |
| 续命(retry / 软续命) | Gate 不放行,注入一句提醒,把模型踢回去继续 | 对话不结束,轮次 +1 |
| 硬停(hard stop) | 无论模型想不想停,强制终止整个循环 | 🔬 源码里的形态是 yield { kind: "done" }; return |
| 软提醒(reminder / nag) | 只往上下文里塞一句提示,不改变流程 | 最轻的一档,不拦不停 |
| 放行(pass) | Gate 检查通过,这一轮正常结束 | |
| 封顶(cap) | 「最多续命/提醒 N 次,之后不再管」 | 防止闸门自己变成死循环 |
「续命 / 软提醒 / 硬停」这三档必须分清,因为它们的风险完全不同:
软提醒 :说一句就走,最坏情况是啰嗦(浪费点 token)
续命 :把模型踢回去,最坏情况是空转(浪费很多钱 + 时间)
硬停 :直接掐断任务,最坏情况是误杀用户正在正常干的活第 7 章会给出完整的分类矩阵。现在只需要记住:越硬的闸门,误伤代价越大, 所以默认越应该关。
1.4 收敛保险侧的词
这些是「无论如何也得停下来」的兜底机制。
| 词 | 是什么 | 🔬 sid-code 默认值 |
|---|---|---|
| tokenBudget | Goal 级别的 token 预算 | 0 = 不限制(需用户显式配) |
| maxTurns | Goal 级别的最大轮次 | 150 |
| blockedThreshold | 连续几轮卡在同一个 blockerKey 就判定「卡住」 | 3 |
| impossible | 评估者判定「这个目标根本不可能达成」 | 默认降级为软提醒,不硬停 |
| minTurnsBeforeEval | 前 N 轮不评估(刚开始干活,不可能已完成) | 2 |
| evaluatorTimeout | 评估者调用超时 | 25000 ms |
(这几个值都是 🔬 源码实读,取自 packages/core/src/goal/config.ts 的 DEFAULT_GOAL_CONFIG。其中 evaluatorTimeout 曾是 8000,被一次真实事故改成 25000 —— 故事在第 9 章。)
1.5 一组容易混淆的近亲
这几对东西名字像、干的事不一样,混了会写出错的设计。
① Goal vs Todo(待办清单)
Todo :模型自己列的「我打算做这几步」——过程清单,模型自己维护
Goal :用户给的「什么算做完」——终点条件,模型改不了关键差异:Todo 是模型的自述,它可以谎报(把 pending 直接标成 completed)。 Goal 的判定权不在模型手里。所以两者是互补的两道闸,不是一个东西。
② Goal 的 tokenBudget vs 「+500k」续写指令
🔬 源码实读,这两个语义正好相反,非常容易混:
/goal 的 tokenBudget:预算耗尽 → 收尾停下 (预算是上限)
消息里写 +500k :预算没花完 → 主动催模型继续深挖(预算是下限)后者在 packages/core/src/query/token-budget-continuation.ts,你不在消息里写 +500k 这类指令,它永远不介入。两套机制刻意互斥——/goal 激活时由 goal 的预算逻辑接管。
③ Gate vs Hook
Hook :用户自己配的外部检查(跑个 lint、跑个脚本),harness 只负责调用
Gate :harness 内置的检查逻辑,代码在仓库里有意思的是 Claude Code 的 /goal 是用 Hook 实现的 Gate:📄 官方文档说它是 「a wrapper around a session-scoped prompt-based Stop hook」。所以这两个词在不同项目里 边界不一样,读别人代码时要注意。
④ /goal vs /loop
这两个都是「让 agent 反复干」,但驱动方式根本不同:
| 维度 | /goal | /loop |
|---|---|---|
| 驱动 | Gate 在 end_turn 处不放行 | 调度器按时间定时注入新 prompt |
| 上下文 | 累积(同一个循环内多轮) | 每次独立(新的一轮对话) |
| 退出 | 评估者判定满足 | 模型自己判断 / 到点 |
| 适合 | 一次集中干完的任务 | 长期监控、轮询型任务 |
1.6 本章自检
- 「修 bug」为什么不是一个合格的 Goal?(没有真假值,判不出满足)
- 续命和硬停的区别是什么?(前者踢回去继续,后者掐断任务)
- Todo Gate 和 Goal Gate 为什么不能合并成一个?(Todo 是模型自述、可谎报;Goal 判定权不在模型手里)
tokenBudget和+500k语义有什么区别?(一个是上限催停,一个是下限催继续)
第 2 章 · 最小心智模型:一次 end_turn 到底发生了什么
这一章把整个机制拆成一张图 + 一段伪代码。读懂这一章,后面所有章节都是在往这张图上 加细节。
2.1 先看没有 Goal 的时候
一个最朴素的 agent 主循环长这样:
async function agentLoop(userInput) {
let messages = [{ role: "user", content: userInput }];
while (true) {
const response = await callModel(messages); // ① 请求大模型
messages.push(response);
if (response.stop_reason === "tool_use") { // ② 模型要用工具
const results = await runTools(response.tools);
messages.push(results);
continue; // → 带着结果再问一遍
}
if (response.stop_reason === "end_turn") { // ③ 模型说「我说完了」
return response; // → 结束,还给用户
}
}
}关键在 ③ 这一行:return。
模型说 end_turn,循环就无条件相信它,直接结束。 这就是第 0 章那个憋屈场景的 全部技术原因——它信了。
2.2 加上 Gate:把那一行 return 变成一次审问
Gate 做的事,本质上就是在 return 之前插一段检查:
if (response.stop_reason === "end_turn") {
// ★ 不再无条件相信,先过闸
const verdict = await runGates(messages, state);
if (verdict.pass) {
return response; // 放行:真的结束
}
messages.push({ // 不放行:塞一句反馈
role: "user",
content: verdict.feedback, // 「你还有 4 项没做完」
});
continue; // ★ 回到循环顶部 —— 这就是「续命」
}就这么点改动。整个 Goal & Gates 体系,骨架就是这十几行。剩下所有复杂度, 都来自那个 runGates() 里面。
2.3 完整流程图
把 /goal 跑起来之后,一次完整的生命周期是这样:
用户输入:/goal 让 test/auth/ 下所有测试跑到绿灯
│
├─▶ 【阶段一】创建 GoalState
│ objective = "让 test/auth/ 下所有测试跑到绿灯"
│ status = active, turnsUsed = 0, evidenceLog = []
│
├─▶ 【阶段二】第一轮特殊:目标本身就是指令
│ 不需要用户再发一条消息,objective 直接作为首轮 prompt 提交
│ (🔬 sid-code 还会在首轮要求模型先验证 baseline,见 5.4)
│
└─▶ 【阶段三】进入循环,每一轮长这样:
┌──────────────────────────────────────────────────┐
│ ① 轮开头:按间隔注入 goal 状态提醒 │
│ (目标是什么 + 第几轮 + 预算还剩多少) │
│ ↓ │
│ ② 模型干活:读文件 / 改代码 / 跑命令 │
│ ↓ │
│ ③ 顺手收集证据:从工具结果里自动抽 Evidence │
│ (跑了测试 → 记一条 test_result) │
│ ↓ │
│ ④ 模型说 end_turn,Gate 链依次审问: │
│ Stop Hook → Todo → Hypothesis → ★ Goal │
│ ↓ │
│ ⑤ Goal Gate 内部顺序(这个顺序有讲究): │
│ 预算够吗 → 轮次够吗 → 前 N 轮跳过吗 │
│ → 规则能直接判定吗(fast-path) │
│ → 都不行,才调评估者 LLM │
│ ↓ │
│ ⑥ 三种出口: │
│ satisfied → 标记 complete,结束 ✓ │
│ not yet → 注入 reason,回到 ①(续命) │
│ 没钱/没轮次/卡住 → 收尾停下 │
└──────────────────────────────────────────────────┘2.4 为什么 ⑤ 的顺序很重要
Goal Gate 内部这个检查顺序不是随便排的,每一步都在省钱或防错:
| 顺序 | 检查 | 为什么排在这 |
|---|---|---|
| 1 | 预算够吗 | 排在评估之前,因为预算已经耗尽了还去调评估者,是在超预算之后又多花一笔 |
| 2 | 轮次够吗 | 同理,已经该停了就别再评估 |
| 3 | 前 N 轮跳过 | 🔬 默认 2 轮。模型刚开始干活,不可能已完成,评估纯属浪费 |
| 4 | fast-path 规则判定 | 能用规则判的就不调 LLM。「最后一条证据是测试全绿」这种情况一眼可判 |
| 5 | 调评估者 LLM | 最贵的一步,放最后 |
📊 成本量级参考(来自设计文档的估算,💭 未经实测复核):一个 30 轮的目标, 评估者额外成本约 $0.19,相对主模型 $3–15 的开销是 1–3%。这个比例是整套 设计的成立前提——如果裁判比运动员还贵,这个架构就不成立了。
2.5 一个必须建立的直觉:Gate 不是「检查器」,是「控制流」
新手最容易把 Gate 理解成「一个返回 true/false 的检查函数」。它不是。
Gate 会改写控制流:它决定循环是继续还是退出,它往对话历史里塞消息,它改变 模型下一轮看到的东西。这带来两个后果,都很反直觉:
后果一:Gate 的 bug 会表现成「模型行为异常」。
用户看到的是「AI 怎么在反复重跑同样的检查」,实际原因是某个 Gate 不放行。 两者在 TUI 上长得一模一样。第 0.4 节那个事故就是这个形态——用户报的三个现象 (「执行到一半中断」「goal 过程不清晰」「评估器不可用」)其实是同一条故障链的 三个切面。
后果二:Gate 的失败方向决定了灾难的量级。
一道 Gate 出错,有两个方向:
假阴性(该放行却不放行)→ 模型出不去 → 空转烧钱,可能到轮次上限才停
假阳性(该拦却放行了) → 交付了半成品 → 用户拿到不完整结果哪个更糟?取决于场景,但假阴性在无人值守场景下是灾难,因为没人在旁边按 ESC。
这就引出了一条重要的设计原则,也是 sid-code 从事故里学到的:
闸门自己故障时,默认应该放行,而不是默认拦住。
「评估器坏了」的语义是「无法判定」,不是「未满足」。把「无法判定」当成 「未满足」,等于让一次基础设施故障变成一次无限催促。
(这条原则在业界叫 fail-open vs fail-closed 的取舍。安全类闸门通常要 fail-closed(宁可拦错也不能放过),而进度类闸门必须 fail-open——因为它拦错的代价 是烧钱空转,而放过的代价只是提前结束一轮。第 9 章有这条原则被违反的完整案例。)
2.6 本章自检
- 没有 Gate 的时候,
end_turn之后循环做了什么?(无条件return) - 「续命」在代码里对应哪个语句?(往 messages 塞反馈 +
continue) - Goal Gate 为什么把预算检查放在评估之前?(省下已经该停时的评估开销)
- 为什么进度类闸门故障时应该 fail-open?(拦错=空转烧钱,放过=只是早结束一轮)
第 3 章 · 三种评估架构:谁来判「做完了」
回到第 0 章那个悬而未决的问题:goal_satisfied() 谁来实现?
2026 年上半年,三家给出了三个不同的答案。这一章逐个拆,重点不是「谁好」, 而是「各自在赌什么」——每种架构都有一个必须成立的前提,前提不成立时它就崩。
3.1 架构 A:自我汇报(Codex 的路子)
📄 二手来源(基于公开仓库源码分析的第三方文章,我们未直接核验 Codex 源码)。
做法:给模型一个工具,让它自己汇报状态。
模型可调用的工具:
create_goal 创建目标
get_goal 查询当前目标状态、预算、已用时间
update_goal 汇报状态 —— 只接受两个值:"complete" 或 "blocked"模型自己决定什么时候调 update_goal(status: "complete")。
它在赌什么:赌 prompt 能约束住模型的诚实度。
为了对冲自评自判的风险,Codex 在每轮开头注入一段隐藏提示(continuation.md), 里面有一段专门的「完成审计」措辞,📄 原文大意:
在判定目标达成之前,把「已完成」当成未经证明的假设,对照实际当前状态去验证。 不要拿意图、部分进展、对早前工作的记忆、或一个看起来合理的最终答案当作完成的证据。
这段话工程上很讲究——它把「证明完成」而不是「假定完成」写成了硬要求。
它的成本优势是真实的:零额外 LLM 调用。裁判就是运动员,不多花一分钱。
它的代价:
| 代价 | 说明 |
|---|---|
| 自评自判风险仍在 | prompt 是软约束。模型在第 40 轮上下文塞满时,注意力涣散是客观事实 |
| 模型可能忘记调这个工具 | 📄 这是被观察到的真实失败模式——它不是谎报,是压根没汇报 |
| 目标每轮都注入 → Cache 命中低 | 见第 6 章。每轮内容都变(预算数字在动),前缀缓存不住 |
但它有一样别人没有的东西:持久化。 📄 Codex 把目标存进独立的 SQLite (goals_1.sqlite),配一个六态状态机(active / paused / blocked / usage_limited / budget_limited / complete)。这意味着进程崩溃、终端关闭、 甚至系统重启之后,/goal resume 能从断点继续。这是三家里工程完整度最高的一块。
(📄 顺带一个有意思的工程插曲:Codex 的 SQLite 后端曾因 feedback 日志写放大, 被测算约合 640 TB/年 的写入量,对 SSD 耐久性构成威胁,后来压缩了约 85%。 这是持久化架构在边界条件下暴露出的典型代价——选了持久化就要为写放大买单。)
3.2 架构 B:独立评估者(Claude Code 的路子)
📄 二手来源(基于官方文档,未读源码)。
做法:把执行者和裁判彻底分开,用另一个模型当裁判。
Claude(主模型)完成一轮
↓
Stop Hook 触发
↓
评估者(默认是 Haiku,小而快的模型)接收:
完成条件 + 到目前为止的完整对话
↓
返回:yes / no + reason(理由)
↓
no → reason 作为指引注入下一轮 → 主模型继续
yes → 停止它在赌什么:赌「对话历史里能看到足够的证据」。
这个赌注很关键,因为📄 官方文档对评估者的能力边界有一句精确的描述:
The evaluator does not call tools, so it can only judge what Claude has already surfaced in the conversation. (评估者不调用工具,所以它只能判断 Claude 已经在对话里显现出来的东西。)
这一句话推导出一条对用户可见的硬约束:
Goal 条件必须是「输出可观测」的。
✅ /goal 把 src/api/ 里所有 TypeScript 类型错误清到零,tsc --noEmit 输出为空
→ 模型跑 tsc,输出进了对话,评估者看得见
❌ /goal 把 temp/ 目录删掉
→ 文件系统状态不在对话里。评估者看不见「删掉了」这件事,
除非模型显式跑一次 ls 并把结果输出出来它的架构优势是真实的:自评自判这条路被物理切断了,不是靠 prompt 劝住的。 主模型没有能力操纵裁判的判定——因为裁判是另一次独立的模型调用。
它的代价:
| 代价 | 说明 |
|---|---|
| 额外 LLM 调用成本 | 每轮一次。用小模型能压到很低,但不是零 |
| 只能看对话,看不到文件系统 | 上面那条硬约束 |
| 长任务时 transcript 巨大 | 小模型在几十万 token 的对话里找关键信息,容易漏 |
| 上下文压缩(compact)会毁掉证据 | 💭 对话被压缩成摘要后,早期的测试输出就没了 |
| 不持久化 | 会话结束就断了 |
最后两条是这个架构的真正软肋,也是下一节那个架构的出发点。
另外,Claude Code 这个设计里有一个很聪明的细节:目标条件不注入主模型的 system prompt。📄 官方描述是「/goal is a wrapper around a session-scoped prompt-based Stop hook」——目标活在 Hook 里,主模型对它几乎无感知,只通过评估者的 reason 间接感知。
好处是 Prompt Cache 完全不受影响(system prompt 一个字节都没变)。代价是主模型对 目标没有主动意识。这个取舍是第 6 章的主题。
3.3 架构 C:证据链(sid-code 的路子)
🔬 源码实读(packages/core/src/goal/ + packages/core/src/query/goal-gate.ts)。
做法:还是用独立评估者,但不让它去对话里「挖」证据,而是提前把证据攒好。
每次工具执行完,harness 自动从结果里抽一条结构化证据:
跑了 bun test → { type: "test_result", summary: "42 pass, 0 fail" }
跑了 tsc → { type: "build_result", summary: "error TS2345: ..." }
写了文件 → { type: "file_change", summary: "文件修改: src/foo.ts" }
这些条目存进 goal.evidenceLog[],与对话历史彼此独立。
评估时,评估者收到:
① 完成条件
② Evidence Log(最近 20 条) ← 主要判据
③ 最近的对话上下文 ← 仅作补充
④ 进度(turnsUsed / maxTurns)它在赌什么:赌「关键证据能被规则自动识别出来」。
这个赌注也有代价(3.5 节会说),但它换来了两样东西:
① compact 不再毁证据。 🔬 Evidence Log 是 GoalState 上的一个数组,它不在 对话历史里。上下文压缩把 messages 压成摘要,evidenceLog 一个字节都不动。
② 不依赖模型配合。 这一条是对架构 A 的直接改进:模型「忘记调 update_goal」 这个失败模式在这里不存在,因为收集是 harness 干的,模型不参与。
🔬 收集逻辑在 packages/core/src/goal/evidence-collector.ts,判据是正则模式匹配:
function hasTestPattern(output: string): boolean {
return /\b(pass|fail|error|test|spec|assert)\b/i.test(output)
&& /\d+\s*(pass|fail|test)/i.test(output);
}注意这个双重条件——光有 "test" 这个词不算,还得有「数字 + pass/fail」的形态。 这是为了避免把「我打算跑个测试」这种文本当成测试结果。
3.4 三架构对照
| 维度 | A · 自我汇报 | B · 独立评估者 | C · 证据链 |
|---|---|---|---|
| 谁判定 | 主模型自己 | 另一个小模型 | 另一个小模型 |
| 证据来自 | 模型主动汇报 | 评估者从 transcript 挖 | harness 自动抽取 |
| 防自评自判 | prompt 级(软) | 架构级(硬) | 架构级(硬) |
| 额外成本 | 0 | 每轮一次小模型调用 | 同 B,但 fast-path 能省掉一部分 |
| 忘记汇报的风险 | 有 📄 | 无 | 无 |
| compact 后证据 | 💭 可能丢 | 💭 会丢 | 🔬 不丢 |
| 持久化 | 📄 SQLite,可跨重启 | 📄 无 | 🔬 JSONL 事件流 |
| 状态机 | 📄 六态 | 📄 二元(继续/停止) | 🔬 七态 |
| Prompt Cache | 📄 每轮变,命中低 | 📄 不注入,命中最高 | 🔬 周期注入,接近最高 |
读这张表的正确方式:不要找「哪一列全是优点」。这三种架构在赌不同的东西, 而你的场景决定哪个赌注更安全:
任务短、成本敏感、模型很强 → A 够用,别为裁判多花钱
任务中等、要防自欺、对话不会爆 → B 是最简洁的正确答案
任务长(几十轮)、会触发 compact → C 是唯一能撑住的
需要跨进程/跨天恢复 → 只有 A 现成,B/C 得自己补3.5 架构 C 也有它自己的坑,不要以为它是免费的升级
这一节很重要,因为**「我们的方案超越了另两家」这种叙述最容易让人漏掉代价**。 架构 C 的代价至少有三条:
① 正则抽取会漏掉非结构化的进展。
🔬 collectEvidence 只认 bash / Write / Edit 这几类工具的输出模式。模型在思考里得出的 关键结论、模型读代码后的判断,都进不了 Evidence Log。设计文档自己承认了这一点, 并且给出的缓解是「评估者仍然看对话上下文作为补充」——也就是说,架构 C 并没有摆脱 对话上下文,只是把它降级成了补充判据。
② 「报告型任务」天生没有客观证据。
这是一个非常真实的缺口。想想这类目标:
/goal 审计一遍这份文档和源码的一致性,把不一致的地方汇总告诉我它的「完成」形态是一段文本,不是一个退出码。Evidence Log 里不会出现 test_result: 0 failures 这种东西,fast-path 的客观信号一条都不命中。此时评估者 LLM 是唯一判据——它一挂,就彻底没兜底。
🔬 sid-code 后来为此专门补了一条 fast-path(evaluator.ts 的 tryFastPathEval):
// 目标含报告类词 + 最后一轮 end_turn + assistant 产出实质文本 → 直接放行
if (
lastStopReason === "end_turn" &&
lastAssistantTextLength != null &&
lastAssistantTextLength > 500 &&
/告诉我|汇总|报告|说明|检查.*结果|审计|分析|总结|review|summarize|report/i.test(goal.objective)
) {
return { satisfied: true, reason: "报告型任务已产出实质文本并 end_turn", progress: 100 };
}这段代码值得盯着看几秒钟,因为它是一个诚实的妥协:它的判据是「目标里有报告类词, 且模型输出了 500 字以上」。这显然可以被一段 500 字的废话骗过。但它的存在理由是 在评估器不可用时,报告型任务至少有一条出路——第 9 章会讲它是从什么事故里长出来的。
③ 多了一个会自己坏掉的部件。
Evidence Log 的收集逻辑本身就是代码,代码会有 bug。抽取规则写错了,评估者拿到的 就是错证据——而且它会以「有证据」的姿态出现,比没有证据更难排查。
3.6 一个横跨三家的共同结论
三家的具体做法差异很大,但有一件事三家的判断是一致的:
判定权不能只有一个来源。
- A 用 prompt 里的「完成审计」当第二道
- B 用独立模型当第二道
- C 用「Gate 链 + fast-path + Evidence Log」当第二、三、四道
这就是下一章的主题:闸门要成链,不要成点。
3.7 本章自检
- 自我汇报架构最真实的失败模式是什么?(模型忘记调汇报工具——不是谎报,是没报)
- 「评估者不调用工具」这一条推出了什么用户可见的约束?(Goal 必须输出可观测)
- Evidence Log 解决的核心问题是什么?(compact 后证据不丢 + 不依赖模型配合)
- 架构 C 的三条代价分别是什么?(正则漏非结构化证据 / 报告型任务无客观信号 / 多一个会坏的部件)
- 如果你的任务只有 5 轮就完成,值得上架构 C 吗?(不值得,B 甚至 A 都够;C 的收益随任务长度增长)
第 4 章 · Gate 链:四道闸的顺序,以及为什么是这个顺序
上一章的结论是「闸门要成链」。这一章讲这条链具体怎么排。
4.1 先看链的全貌
🔬 源码实读(packages/core/src/query/loop.ts,isEndTurnLike && !hasPendingToolUse 分支内,行号取自当前 HEAD):
模型说 end_turn
│
├─▶ [L3494] Stop Hooks 用户自己配的外部检查(跑 lint / 跑测试脚本)
├─▶ [L3527] 未答复兜底 模型只思考没答复 → 拦回去(防 reasoning 泄漏)
├─▶ [L3564] Todo Gate 待办清单还有未完成项 → 拦回去
├─▶ [L3645] Hypothesis Gate 还有未结清的假设 → 拦回去
├─▶ [L3772] Token Budget 续写 预算没花完 → 催继续深挖(注意:语义相反)
└─▶ [L3839] ★ Goal Gate 独立评估者判定目标是否达成
│
└─▶ 全部放行 → 这一轮真的结束六道,不是四道——第 0 章为了讲清概念简化成了「四道」,这里给真实的数字。
4.2 为什么 Goal Gate 排在最后
这个顺序不是随手排的,它背后有一个明确的分工:
前面几道闸:负责「过程完整性」——你该做的步骤做完了吗
Goal Gate :负责「最终结果」 ——用户要的东西拿到了吗推论很直接:如果过程都没走完,就没必要问「结果达成了吗」。
举个例子。模型列了 5 项待办,做完 1 项就说 end_turn:
Todo Gate 先拦:「清单还有 4 项待完成,继续推进 (1/3)」
→ 模型被踢回去继续干
→ Goal Gate 这一轮压根没被调用好处是双重的:
- 省钱:省下一次评估者 LLM 调用。既然 Todo 已经说明「没做完」,再花钱问一遍 评估者是重复劳动。
- 减少误判面:评估者只在「过程看起来完整了」的时候才被问到,它面对的判断 更单纯。
4.3 但这个顺序有一个真实代价,必须点破
🔬 设计文档里记录了这个取舍(ADR-G4),我认为它值得单独讲:
代价:前置 Gate 拦截时,Goal Gate 不触发 → 拿不到评估者返回的 progress → TUI 上无法显示真实进度。
也就是说,你在状态栏看到的 🎯 12/150 只是轮次计数,不是「完成了 12%」。真实进度 需要评估者算,而评估者在前置 Gate 拦截的那些轮里没被调用。
这是一个典型的「为了省钱牺牲了可观测性」的取舍。它不是错的,但它必须被说出来—— 因为一个不知情的人会把 12/150 读成进度条,然后对任务还剩多久做出错误估计。
4.4 每道闸的性质完全不同,别混着理解
这张表是本章最该记住的东西。🔬 全部源码实读:
| 闸门 | 触发条件 | 不通过时的动作 | 封顶 | 封顶后 |
|---|---|---|---|---|
| Stop Hooks | 用户配了 hook 且 hook 要求拦 | 续命 | 由 hook 自己定 | — |
| 未答复兜底 | 模型只输出 thinking、没有正文 | 续命 | MAX_UNANSWERED_RETRIES = 2 | 提示换个问法 |
| Todo Gate | todo 里有 pending / in_progress | 续命 + 注入提醒 | MAX_TODO_GATE_RETRIES = 3 | 放行,但要求如实列出未完成项 |
| Hypothesis Gate | 有未结清 / 被反证的假设 | 续命 + 要求裁决 | MAX_HYPOTHESIS_GATE_RETRIES | 放行,要求在交付物里如实降级 |
| Goal Gate | 评估者判定未满足 | 续命 + 注入 reason | maxTurns = 150 | 标记 turns_limited 停下 |
注意 Todo / Hypothesis 两道闸封顶后的行为:它们放行,但要求模型 「如实呈现未完成项,不假装完成」。
这个设计非常值得学。它回答了一个两难:
一直不放行 → 空转烧钱,无人值守时是灾难
直接放行 → 交付半成品,而且用户不知道它是半成品
折中:放行,但强制它承认自己是半成品🔬 源码里的原话(loop.ts:3634 附近日志):
P0-3:完成度续命已达上限 3,放行但仍有 N 项未完成
对应注入给模型的要求是「如实呈现不假装完成」。这是一条把「失败」转化成 「诚实的失败」的设计,比两个极端都好。
4.5 一个隐藏的坑:两道闸共用一个计数器会互相饿死
📊 轨迹实测 + 🔬 源码实读,这是一个真实发现的缺陷,我把它放在这里因为它是 「多闸门系统」的典型病。
sid-code 有两类无进展提醒:todo 回注、work-log 摘要。它们各有独立的去重字段 (说明设计意图是彼此独立),但共用同一个封顶计数器 state.noProgressNagCount, cap = 2。
后果:
todo 提醒连注 2 次 → cap 耗尽
work-log 提醒第一次尝试注入 → 被抑制
↑ 它一次都没注过,就已经没额度了📊 用真实的判定函数在轨迹上重放:
| 场景 | 注入次数 | 被抑制次数 |
|---|---|---|
| 现状(共享计数器) | 2 | 10 |
| 假设各自独立计数器 | 4 | 8 |
修法上有一个反直觉的点:不要提高 cap。
cap = 2 本身没问题,串台才是问题。提高 cap 只会让两个提醒各自多啰嗦几次, 饿死关系仍然存在。正确修法是拆成两个独立计数器。
这条经验可以推广成一句话:
多个闸门共享一个预算变量时,先到的会静默吃掉后到的额度。 而这件事不会报错,只会表现成「某个提醒好像从来没生效过」。
4.6 另一个隐藏的坑:提醒逐字节完全相同
📊 另一个实测发现:permission-mode 提醒在真实轨迹里注入了 34 次,内容逐字节 完全相同,而且没走去重通道。
为什么这是问题?两个层面:
- 对模型:同一句话说 34 遍,模型会开始忽略它(注意力衰减是真实的)。
- 对 Prompt Cache:每次注入都在动态区插内容,🔬 会影响缓存前缀。
这引出一条闸门设计纪律:
任何会重复注入的提醒,都必须有「逐字节去重」+「次数封顶」两道节流。 只有封顶没去重 → 重复内容占额度;只有去重没封顶 → 内容微变就无限刷。
4.7 一条更普适的教训:催促类提醒必须绑真实进展
这是另一个真实事故的产物,📊 值得单独记住,因为它的后果很反直觉。
有一类提醒叫「催促类」——「你好像没进展,快点干」。如果这类提醒不绑定真实进展信号, 只按轮次间隔刷,会发生什么?
模型会误判「我的消息被截断了」,然后重新发送整段内容。于是:
模型输出一大段 → 收到催促提醒 → 「我的输出可能被截断了」→ 重发一遍
→ 又收到催促 → 又重发一遍 → ……催促本身制造了它要消灭的那个现象。
结论:催促类 reminder 必须满足三条——去重 + 封顶 + 绑定真实进展信号。 「真实进展」不能是「轮次增加了」(那不叫进展),得是「文件被改了」/「测试跑过了」这类 可验证的东西。
4.8 本章自检
- Goal Gate 为什么排在链的最后?(前面几道管过程完整性,它管最终结果;过程没走完就不必问结果)
- 这个顺序的代价是什么?(前置闸拦截时 Goal Gate 不触发 → 拿不到真实 progress → TUI 进度只能显示轮次)
- Todo Gate 续命封顶后为什么选择放行而不是继续拦?(一直拦=空转烧钱;放行+强制承认半成品是折中)
- 两道提醒共用计数器会怎样?(先到的吃掉后到的额度,且不报错)
- 催促类提醒必须绑什么?(真实进展信号,不能只按轮次刷)
第 5 章 · Evidence Log:为什么它是整套设计里最关键的一块
第 3 章介绍过它,这一章讲透。如果你只能从本文记住一个设计,我建议是这个。
5.1 先理解它要解决的问题有多严重
回到架构 B(独立评估者)的软肋:评估者只能看对话历史。
现在考虑一个 60 轮的任务。第 5 轮的时候模型跑了测试,输出 42 pass, 0 fail。 第 40 轮的时候,上下文快满了,harness 触发了 compact——把前面的对话压缩成一段摘要。
压缩后的摘要长这样(示意):
【历史摘要】用户要求修复 auth 模块测试。助手读取了相关文件,
修改了 login.test.ts 和 auth.ts,运行了若干次测试并逐步修复问题。第 45 轮,评估者被问:「测试都过了吗?」
它看到的是上面那段摘要。「42 pass, 0 fail」这个具体证据已经不存在了。 它只能看到「运行了若干次测试并逐步修复问题」——这句话既不能证明完成,也不能证明未完成。
于是评估者只能返回 satisfied: false。任务继续跑。模型再跑一次测试,再产出一次证据, 再被压缩掉……
这不是假想的失败模式,📄 两家公开项目都有对应的 issue 记录(Claude Code 的 transcript 压缩丢证据、Codex 的 continuation.md 在压缩后丢失,issue #19910)。
5.2 Evidence Log 的解法:把证据搬出对话
🔬 源码实读,GoalState 的结构(packages/core/src/goal/state.ts):
export interface EvidenceEntry {
turn: number; // 第几轮产生的
timestamp: number;
type: "command_output" | "test_result" | "build_result" | "file_change" | "verification";
summary: string; // 单行摘要,最长 500 字符
raw?: string; // 原始输出片段,最长 2000 字符
}
export interface GoalState {
// ...
evidenceLog: EvidenceEntry[]; // ★ 关键:它在 GoalState 上,不在 messages 里
}就这一个字段位置的选择,解决了整个 5.1 节的问题:
对话历史(messages):会被 compact 压缩 ← 证据放这里会丢
GoalState.evidenceLog:不受 compact 影响 ← 证据放这里不会丢这是一个「把数据搬到正确的生命周期里」的经典案例。没有引入新算法、没有新组件, 只是换了个存放位置。
5.3 自动收集:不给模型「汇报」这个工具,是刻意的
🔬 设计决策记录(ADR-G5)明确写了这个选择,理由值得完整引用:
决定:从工具调用结果中自动提取证据,不给模型提供
report_evidence类工具。原因:自动收集不依赖模型配合(模型可能忘记汇报或疲劳后省略),也不占用模型的 工具调用配额。Codex 的
update_goal工具方式已被证明容易出现「模型忘记调用」的 失败模式。
这里有一个非常值得学的判断方式:
凡是需要模型「主动记得做」的机制,都要假定它有一定概率不做。
模型不是程序,它没有 finally 块。你给它一个工具让它汇报,它在上下文塞满、 任务紧张的时候,就是会忘。所以能由 harness 做的事,不要委托给模型。
反过来说,这条原则也划出了自动收集的能力边界(3.5 节说过):harness 能自动抽的 只有「有固定形态」的东西。模型在思考里得出的洞察,没有固定形态,抽不出来。
5.4 一个配套设计:首轮强制验证 baseline
🔬 这是 sid-code 首轮 prompt 里的一条,很小但很关键:
工作方式:
0. 先验证当前状态(跑测试/检查错误数/确认文件存在),确认 baseline
1. 分析目标,制定计划
2. 逐步执行,每步验证结果
...
现在开始工作。先执行第 0 步:验证当前状态。为什么要有「第 0 步」?两个理由,第二个更重要:
- 你得知道起点在哪,才知道有没有进步。
- 它给 Evidence Log 播下第一颗种子。 如果第一轮就跑了测试,Evidence Log 立刻有了一条
test_result,评估者从第 3 轮开始就有东西可判。否则前面几轮 Evidence Log 是空的,评估者只能靠对话上下文瞎猜。
5.5 fast-path:用规则省掉一部分 LLM 调用
🔬 有了结构化证据,一部分判定可以不调 LLM 直接做(evaluator.ts 的 tryFastPathEval):
// 目标含"测试通过"类关键词 + 最后一条证据是测试全绿 → 直接判定满足
if (
lastEvidence.type === "test_result" &&
/\b0\s*(fail|error|failure)/i.test(lastEvidence.summary) &&
/test|测试|spec/i.test(goal.objective)
) {
return { satisfied: true, reason: `测试全部通过: ${lastEvidence.summary}`, progress: 100 };
}它有两个价值,第二个才是重点:
- 省一次 LLM 调用(省钱)。
- 它是评估器不可用时的唯一出路。 评估者是网络调用,网络调用会挂。fast-path 是纯本地规则,不会挂。
第 9 章那个事故的最终修法就包含这一条——给「必须靠 LLM 判定」的路径补一条 纯本地兜底。
5.6 但要小心:fast-path 是判据,判据会写错
⚠️ 这里必须给一个警告,因为它命中了本文第 9 章的主题。
看这条 fast-path 的判据:/\b0\s*(fail|error|failure)/i。它匹配「0 fail」。 现在考虑这个真实的测试输出:
Ran 42 tests: 40 passed, 2 failed
Errors: 0Errors: 0 里有 0 紧跟着(跨了冒号空格)Errors……这条正则匹配的是 0 fail / 0 error 这样的形态,Errors: 0 是反的顺序,所以不匹配。这次侥幸没错。
但换一个输出格式:
Test Suites: 3 failed, 5 passed
Tests: 0 failed, 120 passed0 failed 匹配上了 → fast-path 判定「测试全部通过」。但 3 个测试套件整体失败了 (可能是 import 错误导致整个文件没跑起来)。这是一个假阳性:没完成却判定完成。
我没有实测这个 case(💭 这是我读代码时的推论,不是实测出的 bug),所以不把它当作 一个已确认缺陷。但它演示了一条通用纪律:
每一条 fast-path 都是一条「用字符串形态推断语义」的判据。 字符串形态可以在不同工具、不同版本下变化,而判据不会跟着变。
对应的工程做法是:fast-path 只用于放行这种低风险方向时要格外小心(它一放行任务就结束了), 而且每条 fast-path 都应该有一个对应的负例测试——喂一个「看起来像通过但实际没通过」 的输出,断言它不命中。
5.7 容量管理:一个容易忽略的细节
🔬 一个 150 轮的任务可能产出 150+ 条证据。怎么办?
设计上的处理是两个不同的口径:
评估者输入:只取最近 20 条(避免超长)
GoalState :保留全量(用于持久化和 resume)单条证据 raw 上限 2000 字符,20 条 ≈ 40K 字符,对小模型的上下文窗口绰绰有余。
这个「读取口径 ≠ 存储口径」的区分很常见但容易漏。如果两者混成一个(都只留 20 条), 跨会话恢复时就丢了早期证据;如果都给评估者(全量 150 条),评估者的输入会爆掉。
5.8 本章自检
- Evidence Log 解决的核心问题一句话是什么?(compact 会毁掉对话里的证据,所以把证据搬出对话)
- 为什么刻意不给模型「汇报证据」的工具?(凡是要模型主动记得做的事,它就有概率忘)
- 首轮「第 0 步验证 baseline」除了确认起点,还有什么作用?(给 Evidence Log 播第一颗种子)
- fast-path 除了省钱,更重要的价值是什么?(评估器挂掉时的唯一本地兜底)
- fast-path 的风险是什么?该怎么防?(用字符串形态推语义,会有假阳性;每条都要配负例测试)
第 6 章 · 上下文注入策略:目标要不要让主模型看见
这一章讲一个很小的问题,但它牵动的是成本。问题只有一句:
目标条件,要不要放进主模型的上下文里?
直觉会说「当然要,不然它怎么知道要干什么」。但三家有三个不同答案,而且都有道理。
6.1 先补一个前置知识:Prompt Cache 是怎么省钱的
如果你不熟这块,这一小节是必须的,否则后面看不懂为什么这是个取舍。
大模型 API 有个功能叫 prompt caching(提示缓存)。原理一句话:
如果这次请求的开头和上次请求的开头完全一样,服务端可以复用上次的计算结果, 这部分 token 按大幅折扣计费。
关键词是 开头 和 完全一样:
上次请求:[system prompt 8000 字][工具定义 5000 字][对话历史 20000 字]
这次请求:[system prompt 8000 字][工具定义 5000 字][对话历史 22000 字]
↑ 前 13000 字完全一样 → 这部分命中缓存,便宜
↑ 新增部分全价它是前缀匹配的,所以有一条铁律:
越靠前的内容,改动代价越大。 改 system prompt 的一个字 → 后面全部缓存失效。 在对话末尾追加内容 → 前面的缓存全部保住。
一个真实的量级感受:📄 缓存命中的 token 通常只按原价的 10% 左右计费。对一个几十轮的 长任务,命中率从 80% 掉到 0,成本可能翻数倍。
6.2 三种注入位置,三种代价
现在回到问题。目标信息可以放三个位置:
策略 A:不放(Claude Code)
目标只活在 Stop Hook 里,主模型完全不知道自己有个目标。它只是每轮结束后收到评估者的 reason(「你还差 XX」),然后继续。
Prompt Cache:✅ 完全零影响。system prompt 一个字节没动
模型意识 :❌ 主模型对目标无主动意识,全靠外部反馈驱动策略 B:每轮放进 system prompt(Codex)
📄 每轮开头注入 continuation.md,里面有完整目标 + 已用 token + 预算余量。
Prompt Cache:❌ 预算数字每轮都在变 → 前缀每轮都不同 → 命中率低
模型意识 :✅ 模型对目标和预算有实时感知,自主性强注意为什么它必然破缓存:因为它注入的内容包含变化的数字。哪怕目标文本不变, Tokens used: 12345 这一行每轮都不一样,前缀就断了。
策略 C:周期性注入到对话末尾(sid-code)
🔬 通过 reminderParts[] 管道,每 N 轮往最后一条 user 消息前追加一段目标状态。
Prompt Cache:⚠️ 轻微影响(只动末尾,system prompt 完全静态)
模型意识 :✅ 周期性感知,不过度干扰
注入频率 :🔬 reminderInterval = 4,即每 4 轮一次 + 首轮必注入🔬 注入的内容(packages/core/src/goal/reminder.ts):
<goal-status>
目标: {objective}
状态: active | 轮次: 12/150 | Token: 45,231(无预算限制)
上次评估: {lastEvalReason}
</goal-status>
继续推进目标。注意:
- 每次操作后确认结果(跑测试、检查输出),评估者只能看到你输出的内容
- 不要假设操作成功——验证它
- 若某条路走不通,换方向而不是反复重试6.3 策略 C 的三个设计细节,每一个都有理由
细节一:为什么是「每 4 轮」而不是「每轮」?
每轮注入 = 退化成策略 B(缓存命中掉下来,而且模型会对重复内容脱敏,见 4.6)。 不注入 = 长任务里目标漂移。4 轮是一个折中,💭 这个具体数字没有实测依据, 是个合理默认值。
细节二:为什么首轮必须注入?
因为首轮之前模型什么都不知道。🔬 源码里的条件是 goal.turnsUsed === 1 || turnsSinceGoalReminder >= reminderInterval。
细节三:为什么 compact 之后要立刻注入,不等间隔?
🔬 这一条最值得学。压缩会把对话历史变成摘要,目标提醒如果刚好在被压掉的那一段里, 模型就彻底忘了自己在干什么。所以设计上是:
compact 发生 → 立刻注入 goal reminder(重置间隔计数),不等到下一个第 4 轮这是一个「事件驱动补偿周期驱动」的模式:平时按周期刷,遇到会破坏状态的事件 (compact)立刻补一次。这个模式在 harness 里到处都用得上。
6.4 Gate 反馈 vs 周期提醒:两者是互补的,不是重复
容易混淆的一点:Goal Gate 不放行时也会注入内容(reason),周期提醒也注入内容。 这两个是重复劳动吗?不是:
| Gate 反馈 | 周期提醒 | |
|---|---|---|
| 触发 | 事件驱动(评估未通过时) | 时间驱动(每 N 轮) |
| 内容 | 具体差什么(评估者的 reason) | 目标全文 + 进度 + 预算 |
| 作用 | 纠偏——告诉模型下一步该补什么 | 防遗忘——提醒模型整体目标是什么 |
| 频率 | 每次未通过都有 | 每 4 轮一次 |
一个类比:Gate 反馈是「你这题第三步算错了」,周期提醒是「记得你在考数学」。 两句话不能互相替代。
6.5 一个跨章节的呼应:注入的东西会被模型「用力过猛」地对待
第 4.7 节说过催促类提醒会诱发重发。这里有一个同源的、更严重的真实案例,📊 实测, 它说明「往上下文里塞约束」这件事的代价可能远超预期。
sid-code 的 system prompt 里有一条输出红线:
RL-006 不修改测试断言通过 CI:CI fail 时禁止改
expect/assert预期值让测试通过; 应该指向被测代码的实现修复。
这条约束本身是对的(防止「改断言凑绿灯」这种作弊)。但在一次真实会话里发生了这个:
模型修改了一个文档的提示语格式,导致一个回归测试的前提失效了——测试断言 「提示语里字面写着 <!-- AUTO-GEN:END -->」,而改成 HTML 注释后这个字符串 不可能出现(--> 会提前闭合注释)。
这是产品改动导致测试前提变化,与 RL-006 要防的「改断言凑绿」语义相反。 但模型无法确定这一点,于是:
📊 实测数据(会话 20260728-173546,逐轮读原文核对):
| 轮 | thinking 字符 | output tokens | 干了什么 |
|---|---|---|---|
| t23 | 3,273 | 1,642 | 读文件 |
| t24 | 6,247 | 3,197 | 读文件,纠结「选项B:修改测试…但我必须遵守 RL-006」 |
| t25 | 17,490 | 8,851 | 动用 hypothesis_register 登记假设 |
| t26 | 8,684 | 4,155 | 继续读 |
| t27 | 10,498 | 5,322 | 用 hypothesis_challenge 挑战自己 |
| t28 | 7,228 | 4,560 | 终于改了 |
代价:六轮烧掉 thinking 53,420 字符(该会话 68.4%)、output 27,727 tokens (该会话 60.8%)。一次合法且必要的测试前提修正,付掉了六成输出预算。
根因不是约束错了,是约束只写了「禁止」,没写「合法例外的判据」。 于是模型无法自行结案,只能反复推演。
修法也很有启发:不是删掉这条约束(它防的风险是真的),而是补一个正向出口:
例外:若被测代码的契约本身已被本次改动合法变更(如产物格式、提示语措辞变更 导致测试前提不再成立),修正测试前提不属于本红线;此时在 commit/回复里写明 「前提变更原因」即可,无需反复自证。
这条经验推广成一句话:
任何注入给模型的硬约束,必须同时给出「什么情况不算违反」。 只给禁止不给边界,模型会用大量推理去自证清白——而那些 token 是你付的。
6.6 本章自检
- Prompt Cache 是前缀匹配的,这推出了什么设计纪律?(越靠前的内容改动代价越大;变化内容要往后放)
- 为什么 Codex 的每轮注入必然破缓存?(注入内容含每轮变化的 token 数字)
- compact 之后为什么要立刻注入目标提醒,不等间隔?(提醒可能刚好被压掉,模型会彻底忘记目标)
- Gate 反馈和周期提醒为什么不重复?(一个纠偏「差什么」,一个防遗忘「在干什么」)
- 只写「禁止」不写「例外」的约束会造成什么?(模型反复自证,烧掉大量推理 token)
第 7 章 · 闸门分类学:硬停 / 软续命 / 软提醒
前面几章讲的都是 /goal 这一条主线。但一个真实的 harness 里,闸门远不止 Goal Gate 一道——sid-code 里有二十多个「会干预模型行为」的机制。
这一章给一套分类方法,让你面对任何一个 harness 都能快速回答: 它到底会不会掐断我的任务?
7.1 三档强度,按「最坏后果」排序
这是本章的核心框架。任何干预机制都能归到这三档之一:
| 档 | 动作 | 最坏后果 | 默认该开吗 |
|---|---|---|---|
| ① 软提醒 | 往上下文塞一句提示,流程不变 | 啰嗦,浪费一点 token;极端情况诱发误判(见 4.7) | 可以默认开 |
| ② 软续命 | 不放行 end_turn,把模型踢回去 | 空转烧钱 + 浪费时间,无人值守时可能跑到上限 | 谨慎默认开,必须有封顶 |
| ③ 硬停 | 强制终止整个循环 | 误杀用户正在正常干的活 | 默认应该关,除非判据极窄 |
排序的依据是「误伤代价」,不是「有多有用」。这个区分很重要,因为一个机制越有用, 人越容易想默认打开它——而默认打开的决定应该由误伤代价来定,不是由有用程度来定。
7.2 判断一个机制属于哪一档:看代码形态
🔬 在 sid-code 里,三档各有明确的代码指纹:
// ① 软提醒:往 reminderParts 推一条,或往 messages 塞一条,然后什么都不做
reminderParts.push(someReminderText);
// ② 软续命:塞消息 + continue(回到循环顶部)
messages.push({ role: "user", content: feedback });
setTransition(state, { type: "xxx_gate_retry" }, ...);
continue; // ★ 指纹在这里
// ③ 硬停:yield done + return
yield { kind: "system", level: "error", terminal: true, text: "..." };
yield { kind: "done" };
return; // ★ 指纹在这里读别人的 harness 源码时,搜 yield done / return / process.exit 就能把所有硬停 点数出来。 🔬 sid-code 的 loop.ts 里有 22 处 kind: "done"——这就是「所有能掐断 任务的地方」的上界。
7.3 sid-code 的完整闸门矩阵
🔬 全部源码实读。这张表是本章的干货,也是我认为最值得抄走的部分。
① 软提醒类(都有去重 + 封顶,都不终止)
| 机制 | 封顶 | 封顶后 |
|---|---|---|
| 无进展催促(work-log nag) | MAX_NO_PROGRESS_NAGS = 2 | 不再刷提醒 |
| todo 记账催促 | MAX_TODO_BOOKKEEPING_NAGS = 2 | 同上 |
| 思考发散收敛 | MAX_THINKING_DIVERGENCE_INTERVENTIONS = 2 | 同上 |
| 输出停滞提醒 | MAX_OUTPUT_STALL_INTERVENTIONS = 2 | 同上 |
| 低产出空转 | MAX_LOW_YIELD_INTERVENTIONS = 2 | 同上 |
| 权限模式提醒 | 按间隔 | — |
注意这些封顶值清一色是 2。这不是巧合,是一条刻意的设计范式: 给模型两次自我纠正的机会,之后闭嘴。说第三遍就是在跟模型较劲,而模型会开始 忽略重复内容。
② 软续命类(不放行 end_turn,都有封顶,封顶后放行)
| 机制 | 封顶 | 封顶后行为 |
|---|---|---|
| Todo Gate | MAX_TODO_GATE_RETRIES = 3 | 放行,要求如实列出未完成项 |
| Hypothesis Gate | MAX_HYPOTHESIS_GATE_RETRIES | 放行,要求在交付物里如实降级 |
| 未答复兜底 | MAX_UNANSWERED_RETRIES = 2 | 放行,提示换个问法 |
| 空参数重试 | MAX_EMPTY_PARAM_RETRIES = 3 | 放行 |
| Goal Gate | maxTurns = 150 | 标记 turns_limited 停下 |
这一档的共性设计:封顶后放行而不是硬停,但要求模型诚实标注未完成状态。 (4.4 节讲过为什么这是个好设计。)
③ 硬停类(会终止任务)—— 这一档要逐个审
🔬 这是最需要小心的一档。sid-code 里默认生效的硬停只有几个:
| 机制 | 阈值 | 默认 | 有开关吗 | 判据宽窄 |
|---|---|---|---|---|
| 连续压缩失败熔断 | 3 次 | 开 | ❌ 无 env / 无 settings | 中——「压缩没减少消息数」也计入 |
| 只读空转止损阀 | 3 次相同 (命令,输出) | 开 | ❌ 无 | 极窄(见下) |
| 循环检测终止 | — | 关 | 需 SID_ENABLE_LOOP_DETECTION=1 且 SID_LOOP_EXHAUSTED_ACTION=terminate | 宽(shape 检测易误判) |
| Goal blocked / impossible 硬停 | 3 轮同 blockerKey | 关 | 需 SID_ENABLE_GOAL_HARD_STOP=1 | 中 |
| 成本上限 (costLimit) | 100% | 关 | 需用户显式配 > 0 | 精确 |
| BeforeModel / AfterModel hook | 用户定 | 关 | 用户自己配的 hook | 用户定 |
读这张表要抓的重点不是数字,是「默认」和「有开关吗」两列的组合:
默认关 + 有开关 → 最安全的形态(想用的人自己开)
默认开 + 有开关 → 可接受(踩坑的人能关掉)
默认开 + 无开关 → ⚠️ 最危险的形态,无人值守场景下没有逃生通道🔬 sid-code 里落在第三格的有两个:压缩失败熔断、只读空转止损阀。
7.4 「只读空转止损阀」值得单独讲,因为它是一个正面样板
🔬 它是唯一「默认开启 + 能强制终止」的检测器。为什么允许它这样?看它的自述理由 (repeated-readonly-guard.ts 文件头注释):
默认全局启用:与 loop-detection(默认关,靠 shape 易误判)不同,本阀只盯 **「完全相同命令 + 完全相同输出」**这一极窄且高确定性的模式,误伤面极小,故默认开。
注意它的论证结构:它没有说「这个功能很有用所以默认开」,它说的是 「判据极窄所以误伤面小所以默认开」。这是 7.1 节那条原则的正确用法。
它的判据窄到什么程度?签名 = 命令 + 输出,两者都必须完全相同。
📊 然后是最有意思的部分——实测结果(在 481 轮真实 assistant 响应上, 用它自己的导出函数 processObservation 重放):
| 指标 | 实测值 |
|---|---|
| 产生只读探查的轮次 | 182 / 481(37.8%) |
| remind 触发 | 0 |
| terminate 触发 | 0 |
| 观察到的最长连续相同签名 | 1(阈值需要 3) |
| 相邻探查轮签名相同 / 不同 | 0 / 82 |
它一次都没触发过。
现在问一个关键问题:这说明它是死代码,该删吗?
答案是不该,而理由值得学:
- 0 次触发 = 0 次误伤。而它是唯一能掐断用户任务的默认开启检测器,误伤代价最高。 实测 0 误伤是它最重要的好消息。
- 它兜的是有真实事故记录的死锁族——「git 快照冻结死循环」:system prompt 里的 git status 是启动时的一次性快照,整会话不刷新。当快照说「脏」而实时状态说「净」时, 模型会被两个矛盾的事实锁死,反复空转
git status直到用户手动 ESC。 - 实测样本里不复现,是因为样本里的模型换了(多为 glm-5.2,当初出事的是 deepseek-v4-pro)。
所以正确的结论不是「删掉」也不是「它很好」,而是第三种:
判据健康,但收益不可知。
该做的不是动它,而是给它补一条触发埋点——现在它触发与否完全不可观测 (📊 实测:
~/.sid-code/下搜不到任何相关日志)。下次死锁复发时,无法确认这道阀 到底有没有拦住。
这就引出了第 10 章的主题:一道你无法观测的闸门,等于一道你不知道存不存在的闸门。
7.5 「配了却不生效」是另一个方向的缺陷,同样要查
📊 上面讲的都是「限制太严」。但排查时发现了几个方向相反的问题,我认为它们更隐蔽, 因为用户会以为自己有保护,实际没有:
① 速率限制隐式依赖成本上限。
🔬 源码实读(packages/cli/src/app.ts:731-734):
const effectiveCostLimit = quotaConfig?.costLimit ?? opts.config.costLimit;
if (effectiveCostLimit && effectiveCostLimit > 0) {
// 只有这个条件成立,QuotaManager 才会被创建
}后果:如果用户只想配 requestsPerMinute / tokensPerMinute,不配 costLimit, 那 QuotaManager 根本不会被实例化,RPM / TPM 字段静默失效。配置写了,没报错, 不生效。
② 速率限制的检查函数从未被调用。
🔬 我核过:QuotaManager.checkRateLimit() 在生产代码里零调用点——只有 packages/core/tests/llm/quota.test.ts 在调它。
这意味着:即便你同时配了 costLimit 和 RPM/TPM,速率限制也不会真正生效, 只是往滑动窗口里记数而无人查询。
这是一个非常典型的「仅被测试消费」的死代码形态,而且它最阴的地方在于: 单测全绿。测试证明了这个函数逻辑正确,但没人问过「它被接线了吗」。
这条经验推广开:
判断一个防线是否真的存在,不能搜「有没有这个函数」,要搜 「生产代码里有没有调用点」。搜索时必须排除测试文件,否则你会把 「仅被测试消费」误记成「已实现」。
三档结论比两档准确:活代码(生产有调用)/ 仅被测试消费(生产 0、测试 >0) / 真死代码(都是 0)。中间那一档是隐形大头。
7.6 无人值守场景的特殊性:阻塞类也要查
还有一类干预容易被漏掉:等人回答。
权限确认弹窗 → 等人点「允许」
fallback 降级询问 → 等人选「换哪个模型」
AskUserQuestion → 等人回答问题
Plan Mode 审批 → 等人批准计划在 TUI 交互模式下这些都合理(前提就是有人在场)。但在无人值守(-p / headless / SDK)场景下,每一个都是潜在的永久挂起点。
🔬 sid-code 的处理是全部自动降级:
| 阻塞点 | 无头模式下的行为 |
|---|---|
| 权限确认 | 所有该弹窗的 ask 自动转 deny,不挂起 |
| fallback 询问 | 检测到没有交互通道 → 直接自动切默认备用模型 |
| AskUserQuestion | 返回 { status: "unavailable" },告知模型「用最合理的默认继续」 |
| Plan Mode 审批 | 同权限确认,走非交互分支 |
🔬 判定是否非交互的函数值得看一眼,因为它的判据有点出人意料:
private isNonInteractive(): boolean {
return this.config.print === true
|| (this.config.maxTurns !== undefined && this.config.maxTurns > 0);
}注意第二个条件:显式传了 --max-turns 也被判定为非交互。理由是「传了轮次上限 = 批处理语义」。这是一个合理但需要写在文档里的推断——否则一个交互式用户传了 --max-turns 50 会发现自己的权限弹窗都变成了自动拒绝。
顺带一条:🔬 TUI 交互模式下的确认弹窗没有超时兜底,会一直等到用户操作。 这不算缺口(TUI 的前提就是有人在场),但它必须被明确记录为「查过了,是有意的」, 而不是留在「不知道有没有」的状态。
7.7 本章自检
- 三档强度的排序依据是什么?(误伤代价,不是有用程度)
- 怎么在源码里快速找出所有硬停点?(搜
yield done/return/process.exit) - 「默认开 + 无开关」为什么是最危险的组合?(无人值守时没有逃生通道)
- 只读空转止损阀实测 0 触发,为什么不该删?(0 触发=0 误伤,且它兜的是有事故记录的死锁族;真问题是不可观测)
- 判断一个防线是否真实存在,为什么要三档而不是两档?(「仅被测试消费」是隐形大头,会被误记成已实现)
第 8 章 · 收敛保险:四重保险各防什么
Goal Gate 会一直不放行,直到评估者说满足。这里有个显而易见的危险:万一它永远不说 满足呢?
这一章讲兜底。四重保险,它们防的是四种不同的死法——这一点是本章的重点,因为 「多加几道保险」的直觉往往会加出四个防同一件事的东西。
8.1 四种死法,四道保险
| 死法 | 形态 | 对应保险 |
|---|---|---|
| 烧钱 | 目标能达成,但成本超出承受范围 | tokenBudget |
| 磨 | 一直有微小进展,但永远到不了终点 | maxTurns |
| 卡 | 反复撞同一个墙 | blockedThreshold |
| 不可能 | 目标的前提根本不存在 | impossible 判定 |
为什么必须分四个?因为它们的判据完全不同:
烧钱 :看累计 token —— 与进展无关,纯计量
磨 :看轮次 —— 与进展无关,纯计数
卡 :看「阻塞原因是否重复」—— 需要语义判断
不可能:看「前提是否成立」 —— 需要语义判断前两个是机械判据(不会误判,但也不聪明);后两个是语义判据(更聪明,但会误判)。 一个健康的系统需要两类都有:语义判据负责早发现,机械判据负责最终兜住。
8.2 保险一:tokenBudget(预算)
🔬 源码实读(packages/core/src/goal/budget.ts):
export function checkGoalBudget(goal, currentTurnUsage): "ok" | "warning" | "exceeded" {
if (!goal.tokenBudget) return "ok"; // ★ 默认 0 = 不限制,直接短路
goal.tokensUsed +=
currentTurnUsage.inputTokens +
currentTurnUsage.outputTokens +
(currentTurnUsage.cacheCreationTokens ?? 0); // ★ 注意这一项
const ratio = goal.tokensUsed / goal.tokenBudget;
if (ratio >= 1.0) return "exceeded";
if (ratio >= 0.85) return "warning"; // ★ 85% 预警
return "ok";
}三个细节值得说:
① 默认 0 = 不限制。 🔬 这是刻意的,而且有三重短路保证默认不拦: goal.ts 把 0 转成 undefined、budget.ts 判 if (!goal.tokenBudget) return "ok"、 goal-gate.ts 外层再判一次 if (goal.tokenBudget)。
三重看似冗余,但它防的是一类真实风险:任何一层被改错,另两层还兜着。对「默认不该 生效」的机制,这种冗余是值得的。
② cacheCreationTokens 被计入了。 这个细节容易漏。缓存写入的 token 单价 高于普通 input(Anthropic 族),不计它会系统性低估成本。
③ 85% 预警而不是只有 100% 硬停。 预警不停任务,只注入一句提示。这给了模型 一个「该收尾了」的窗口,而不是在 100% 那一刻被突然掐断——突然掐断的问题是它会 在半成品状态终止。
🔬 预算耗尽时注入的内容也设计过:
预算已耗尽,请在本轮内:
1. 总结已完成的进度
2. 列出未完成的部分
3. 给出明确的"下一步"建议(用户可据此决定是否继续)
不要开始新的实质性工作。这是把「硬停」改造成「有序收尾」。同一个约束条件下,用户拿到的东西完全不同: 一个是任务在半空中断掉,一个是一份「做到哪了、还差什么、下一步怎么办」的交接。
8.3 保险二:maxTurns(轮次)
🔬 默认值 150。这个数字变过——设计稿是 50,后来放宽到 150。源码注释里记了理由:
150:goal 是"目标达成前不停"的长任务模式,复杂审计/多文件重构/深度排查动辄几十轮 起步,50 轮对这类任务偏紧(常在收尾阶段被 turns_limited 掐断)。默认无 tokenBudget 时 maxTurns 是唯一硬上限,故放宽到 150 给长任务留足空间。
注意这个调整的方向:它是被真实的误伤推着放宽的,具体形态是 「常在收尾阶段被掐断」——最难受的失败位置,因为前面的工作全做了,就差最后一步。
这里有一条值得记住的设计经验:
轮次上限如果设得太紧,它误伤的会系统性地是「已经快完成的任务」。
因为轮次是单调递增的,撞上限的任务必然是跑得最久的那些,而跑得久往往意味着 做了很多工作。它砍掉的永远是投入最多的那些任务。
另一个关键判断(🔬 记在 ADR-G2 里):为什么不给 Gate 单独设一个 maxGateRetries?
独立的
maxGateRetries与maxTurns语义重叠(Gate 每轮都触发,retry 次数 ≈ 轮次数),去掉后简化心智模型。替代方案
maxGateRetries = 10被否决,理由是:与maxTurns = 50语义矛盾—— 10 次就停了还要 50 干嘛。
这个论证很干净。两个语义重叠的上限,小的那个会让大的那个变成装饰,而读文档的人 会以为自己有 50 轮。
8.4 保险三:blockedThreshold(卡住检测)
🔬 源码实读(packages/core/src/goal/blocked-detector.ts),实现只有 50 行, 但每一行都有讲究:
record(blockerKey: string | undefined): boolean {
if (!blockerKey) {
this.recentBlockerKeys = []; // ★ 无 key = 有进展 → 清零
return false;
}
this.recentBlockerKeys.push(blockerKey);
// ...
const recent = this.recentBlockerKeys.slice(-this.threshold);
return recent.every((k) => k === recent[0]); // ★ 最近 3 条必须全同
}设计细节一:为什么用 blockerKey 而不是文本相似度?
早期的直觉做法是「比较两轮的评估理由,文本相似就算卡住」。这个做法有两个方向的错:
措辞不同但问题相同 → 漏判
「auth 测试第 42 行断言失败」 vs 「登录测试的断言还没通过」→ 文本不像,实为同一件事
关键词重叠但问题不同 → 误判
「auth 测试失败」 vs 「auth 模块类型错误」→ 都有 "auth",实为两件事解法是让评估者显式返回一个短标识符(如 auth-test-line42-assertion)。 把「判断是否同一个问题」这件事交给理解语义的那一方,而 harness 只做机械的字符串 比对。
这是一个很好的职责划分范式:需要语义的部分交给 LLM,需要精确的部分交给代码。 反过来(让代码猜语义、让 LLM 做精确比对)两头都不讨好。
设计细节二:为什么 blockerKey 为空要清零?
因为「评估者没给出阻塞原因」的语义是「它认为有进展」。有进展就应该重新开始计数, 否则一个跨越多轮的老问题会和新问题混在一起累加。
设计细节三:为什么默认不硬停?
🔬 这是本章最重要的一条。卡住检测触发之后,默认行为不是终止任务:
export function isGoalHardStopEnabled(): boolean {
return process.env.SID_ENABLE_GOAL_HARD_STOP === "1"; // ★ 默认 false
}默认走的是软提醒分支:注入提示、shouldContinue: true、继续跑,由 maxTurns / budget 兜底。
理由回到第 7.1 节那条原则:卡住检测是语义判据,语义判据会误判,而硬停的误伤代价 最高。所以:
语义判据 + 硬停 = 默认关(需显式开启)
机械判据 + 硬停 = 可以默认开(maxTurns / budget 就是)8.5 保险四:impossible(不可能达成)
评估者可以返回 impossible: true,表示「这个目标的前提根本不存在」。
典型场景:
/goal 修复 src/nonexistent.ts 的类型错误
→ 第一轮模型跑 ls,发现文件不存在
→ 评估者判定 impossible它的价值是提前退出——不用死磕到 150 轮才停。
🔬 但它和卡住检测一样,默认降级为软提醒:
默认(降级模式):注入软提醒,把判断交还模型,继续循环
硬停模式(需 SID_ENABLE_GOAL_HARD_STOP=1):立刻标记 impossible 并终止为什么?因为评估者可能判错。它只看到 Evidence Log 和一段对话上下文,它可能 不知道模型正打算创建那个文件。把「不可能」的判定权完全交给一个小模型,然后据此 掐断任务,是把太大的权力给了太少的信息。
软提醒的措辞也讲究(🔬 buildImpossibleReminder)——它告诉模型「评估者认为可能 无法达成,理由是 XX」,然后让模型自己决定换路还是收尾。把判断还给信息最全的那一方。
8.6 一个必须点破的取舍:这四重保险默认状态很不一样
🔬 汇总一下默认状态,这张表比前面任何一段都更能说明设计意图:
| 保险 | 判据类型 | 默认 | 触发后 |
|---|---|---|---|
| tokenBudget | 机械(计量) | 关(0 = 不限) | 有序收尾 |
| maxTurns | 机械(计数) | 开(150) | 硬停 |
| blockedThreshold | 语义 | 开(检测),但硬停默认关 | 软提醒 |
| impossible | 语义 | 开(检测),但硬停默认关 | 软提醒 |
读出来的设计立场是:
默认状态下,唯一会真正掐断
/goal任务的东西是 maxTurns = 150。 其余三重要么需要用户显式配置,要么只会提醒。
这个立场对不对?取决于场景,但它是自洽的:它把「唯一的默认硬停」交给了最不会 误判的那个判据(纯计数),而把所有会误判的判据都降级成了提醒。
代价也要说清:这意味着一个卡死的任务默认会跑到 150 轮。如果每轮 3 万 token、 每百万 token $3,那是约 $13 和几十分钟。对无人值守场景,这是一个需要用户知情的数字。
想更早止损的人,🔬 有两条路:设 tokenBudget(推荐,机械判据,精确),或开 SID_ENABLE_GOAL_HARD_STOP=1(语义判据,会误判)。
8.7 本章自检
- 四重保险为什么不能合并?(防四种不同死法,判据类型不同:两个机械、两个语义)
- 为什么
cacheCreationTokens必须计入预算?(缓存写入单价高于普通 input,不计会系统性低估) - 轮次上限设太紧,会系统性误伤哪类任务?(跑得最久的,也就是投入最多、往往快完成的)
- 为什么不给 Gate 单独设
maxGateRetries?(与 maxTurns 语义重叠,小的那个会让大的变成装饰) - 为什么卡住检测和 impossible 默认不硬停?(语义判据会误判,而硬停误伤代价最高)
- 默认状态下唯一会掐断
/goal的是什么?(maxTurns = 150)
第 9 章 · 会「绿着坏掉」的失效模式 ★
这是全文最重要的一章。 如果你只有 20 分钟,读这一章。
前面八章讲的是「怎么设计」。这一章讲的是这些设计会怎么坏,而且坏了之后不会报错。
9.0 先建立一个统一的心智模型
所有本章要讲的失效,都是同一件事的不同形态:
闸门本身故障时,它不会以「故障」的形态出现, 它会以「正常工作」的形态出现。
具体表现有三种:
形态 ① 假阴性伪装成「还没做完」 → 任务被困在里面空转
形态 ② 假阳性伪装成「已经做完」 → 交付半成品,用户不知道
形态 ③ 零触发伪装成「一切正常」 → 防线其实从未生效,但看不出来三种形态的共同点:监控面板上一切正常。没有报错、没有异常状态码、没有告警。 这就是「绿着坏掉」这个说法的来源。
9.1 R1 🔴 评估器全程故障,被静默降级成「目标未满足」
这是本章的旗舰案例,因为它一次性演示了三个独立的错误叠加会造成什么。
📊 轨迹实测(会话 20260707-102036-47dc71ac,模型 glm-5.2)。
现场
用户报了三个现象:
- 「任务执行到一半中断在收尾处」
- 「goal 过程不清晰」
- 「评估器暂时不可用」
这三个是同一条故障链的三个切面。 完整时间线:
| 时刻 | 轮次 | 发生了什么 |
|---|---|---|
| 02:20 | — | 会话启动 |
| ~02:30 | 16 | 模型输出完整审计报告,stop_reason=end_turn——任务实际上完成了 |
| 02:33 | 20 | 评估器首次调用失败,durationMs=8003 |
| 02:33–02:50 | 20–30 | 评估器连续 6 次失败,每次都返回「继续工作」 |
| 02:50 | 32 | 第 32 轮请求 hang 死 |
| 03:00 | 32 | 最外层看门狗在 600 秒后才记录收尾 |
账单:40 分钟、↑475.6k tokens、$2.53,约一半是纯空转。
第 16→31 轮(约 20 分钟)干了些什么?📊 从 LoopTransition 事件读出来:
turn 16: todo_gate_retry ← 报告已出,但 Todo 门禁未放行
turn 20-30: goal_gate_retry ×6 ← 评估器失败催继续(20/21/26/27/29/30)
turn 31: tool_use ← 又派 explore + verify 两个子代理去「对抗式复核」
turn 32: (hang 死,无收尾)补查已经查过的保留项、重跑全量测试、写一次性验证脚本、派子代理对抗复核——全是被 故障评估器逼出来的。
根因不是一个,是三个叠加
根因 A:模型选型错了。
🔬 评估器的模型选定链原本是:
config.goal.evaluatorModel > subAgentModels.verify > subAgentModels.default > 主模型
↑ 问题在这一层用户配的 verify = deepseek-v4-pro(一个强模型),而评估器超时写死 8 秒。
📊 6 次失败的 durationMs 全是 8003–8005ms——每次精确跑满 8 秒被 abort, 一次没成功。
这个错的性质是「语义误用」:verify 的语义是「对抗验证子代理」,需要强模型、 慢没关系;goal 评估的语义是「快速判断是否完成」,需要快模型、只输出 512 token 的 JSON。 把前者的配置复用给后者,等于让一个思考型模型去干一个反射型的活,然后给它一个反射型的 超时。
🔬 修法是把 verify 从选定链里删掉,并且在源码里留了注释解释为什么(防止后人「优化」 时加回来):
// 注意:刻意跳过 subAgentModels.verify —— verify 语义是"对抗验证子代理"(需强模型、慢),
// 而 goal 评估是"快速判是否完成"(需快模型、512 token JSON)。复用 verify 会让强慢模型
// 撞上短超时必然失败(见 20260707 排查 P0-1/P1-4)。两者解耦。
const evaluatorModel =
effectiveGoalConfig.evaluatorModel || config.subAgentModels?.default || config.model;根因 B:降级方向错了。 ← 这一条是本章的核心教训
原来的 catch 分支是这样:
catch (error) {
evalResult = {
satisfied: false, // ★ 错在这里
reason: "(评估器暂时不可用,继续工作)",
};
}「评估器坏了」被翻译成了「目标未满足」。
看清这个错误的严重性:
评估器坏了 → 判定「未满足」→ 催模型继续 → 模型再干一轮 → 评估器又坏了
→ 又判「未满足」→ 又催继续 → ……一次基础设施故障,变成了一台永动机。 而且它每一轮都在花钱。
正确的语义映射是:
评估器返回 satisfied=false → 「我判断了,还没完成」
评估器调用失败 → 「我无法判断」 ← 完全不同的东西!「无法判断」不等于「未满足」。把两者混为一谈,是这类 bug 的通用形态。
根因 C:没有熔断。
连续失败 6 次,系统没有任何「不对劲」的意识。📊 最讽刺的证据:模型自己识破了—— 它在思考里写下了对评估器的怀疑,但闸门不放行,它出不去。
修法:复用已有机制,而不是新写计数器
🔬 修法很优雅,值得学:
catch (error) {
evalResult = {
satisfied: false,
reason: "(评估器暂时不可用,继续工作)",
blockerKey: "__evaluator_unavailable__", // ★ 关键:给它一个 blockerKey
};
}为什么加这一行就够了? 因为 BlockedDetector 的逻辑是「连续 3 轮相同 blockerKey → 判定卡住」。原来评估失败时 blockerKey 是 undefined,而 undefined 在 BlockedDetector 里的语义是「有进展,清零」——它绕过了整个卡住检测机制。
给它一个固定的哨兵值,评估器连续失败就自然被现成的卡住检测抓住了。不需要新写 任何计数逻辑。
这是一个很好的判断示范:
遇到「需要计数连续失败」的需求时,先问一句:系统里已经有一个计数连续失败的东西吗? 有的话,让新情况长成它认识的形状,而不是再造一个。
附带修的两条
🔬 超时 8s → 25000ms。依据是「deepseek-v4-pro 首字节 5-15s 属正常,8s 必超; 25s 够完成 512 token 生成」。
🔬 TUI 告警。原来评估器故障只进 trace 的 evalReason 字段,TUI 上什么都看不到—— 用户只看到模型在莫名其妙反复重跑。现在会推一条 warning:
⚠️ Goal 评估器连续失败 2/3 次,将持续提醒模型自行决定收尾(由轮次/预算上限兜底)。
可 /goal clear 手动结束。这条告警的文案有个细节值得说:🔬 它必须随 isGoalHardStopEnabled() 分支变化。 源码注释解释了原因:
文案必须随
isGoalHardStopEnabled()分支:默认(降级模式)达阈值后不会放行, 只会持续注入软提醒直到 maxTurns/budget 兜底——若沿用「第 N 次将自动放行」文案 会误导用户。
也就是说:修完一个 bug 后,为它写的告警文案本身也可能撒谎。硬停模式下确实会 「第 3 次自动放行」,但降级模式(默认)下不会——沿用同一句文案,用户会等一个永远不来的 自动放行。
9.2 R2 🔴 评估器上下文太小,看不全要判定的东西
📊 同一次事故里发现的更深层问题。
🔬 extractEvalContext 原来的三重截断:
messages.slice(-6) 只取最近 3 轮
block.text.slice(0, 800) 每个文本块截断 800 字符
truncateToLimit(_, 4000) 总上限 4000 字符而这次要判定的审计报告正文约 6.7k 字符。
结论:即使评估器不超时、正常返回,它也只能看到报告的头 4000 字符 + 截断片段, 大概率判不出「已完成」。
这比「8 秒超时」深得多——超时是配置问题,改个数字就好;这个是设计缺陷:对 「输出长报告」类任务,评估者的判据输入天然不足。
🔬 修法有两层:
- 保证最后一条 assistant 消息完整(或放宽到 8000 字符)拼在最前,其余维持 800 截断。理由:评估器判「是否完成」最需要的就是模型最后的完整产出,这一条必须完整。
- 总上限 4000 → 12000,并提升为配置项
evalContextMaxChars。
这条的通用教训:
任何「把大对象截断后喂给判定者」的设计,都要问一句: 被截掉的部分里,有没有恰好是判定所需的那一段?
截断策略通常是「保留最近的」,但判定所需的往往是「最完整的那一条」。 这两个不是同一个东西。
9.3 R3 🔴 abort 送达底层 ≠ 主循环能收到收尾
📊 同一次事故的第 32 轮,这是一个架构级的坑,也是最难发现的一类。
现象
第 32 轮请求发出后 SSE 断流。30 分钟会话超时触发 abort,abort 确实打到了 fetch 层 (warn.log 有铁证:错误栈里含 signalAbortHandler)。
但主循环的两道防线在 turn 32 零触发——events.jsonl 里没有任何 WatchdogKill / TimeoutFired 事件。直到 600 秒后,最外层一个跨模块的看门狗才记录了收尾。
根因:abort 叫不醒阻塞在 for await 上的中间层
🔬 逐层读码坐实的链路:
① session abort → 触发底层 provider 的 abortPromise reject
② provider 最外层 catch 把 abort 错误转成 yield { type: "error" }(不是 throw)
③ 但这个 yield 要被消费,上游三层 for await 必须先醒:
fallback.ts for await (const event of stream)
└─ signal?.aborted 检查在 **循环体内**
stream-processor.ts for await
└─ 超时标志检查也在 **循环体内**
loop.ts await Promise.race([...])
④ 当 SSE 半开(TCP 连接还在,服务端不再发 event):
reader.read() 永不 settle
→ 三层 for await 全部永久阻塞在「等下一个 event」
→ 循环体内的 signal?.aborted 检查永远执行不到一句话:for await 只在下一个元素到达时才执行循环体。如果永远没有下一个元素, 循环体里的所有检查都是死代码。
为什么两道定时器防线也没救回来
🔬 源码注释里有实测记录,很触目:
实测
setTimeout回调延迟 193 秒 → 流 hang 死 35 分钟 实测 22 分钟无反应
原因:Bun 事件循环被半开 TCP IO 占满时,定时器延迟 fire。
这条是最反直觉的一点:setTimeout(fn, 300_000) 不保证 300 秒后执行,它保证的是 「不早于 300 秒后执行」。在事件循环被占满时,「不早于」可以变成「晚很多」。
修法:race,而不是缩短超时
🔬 正确的修法是给每层 for await 补上 abortPromise race:
const it = stream[Symbol.asyncIterator]();
while (true) {
const racers = [it.next()];
if (abortPromise) racers.push(abortPromise);
const result = await Promise.race(racers); // ★ abort 触发 → 立即 reject,不等 event
// ...
}为什么不靠缩短定时器?🔬 源码注释自己给了答案:定时器在这个场景下不可靠。
只有 race 模式不依赖定时器、不依赖底层 reader 响应,是唯一真解。
缩短看门狗超时(600s → 120s)只是兜底,不是修复。
影响面:不是孤例
📊 这里有一个很好的方法示范——从一个事故推广到全量统计。把全部 98 个会话的 末事件分布数出来:
30 SessionStart ← 空会话(启动即退,正常)
23 AfterModel ← 正常收尾
17 StreamPhase ← 流中途断(疑似 hang 的一种)
13 SessionEnd ← 正常退出
6 BeforeModel ← 发出请求后无任何后续(hang)
4 ModelCallUnpaired ← 看门狗记录的 hang(本体)
3 其他hang / 异常结尾占比 ≈ (10+17)/98 ≈ 27.6%。
而且推翻了一个猜测:以 hang 结尾的 10 个会话里,9 个是 deepseek-v4-pro, 只有 1 个是 glm-5.2(即本次)。
所以结论从「glm-5.2 特定问题」变成了「abort 穿透缺陷的普遍表现, deepseek-v4-pro 高发(可能因其首字节慢、更易触发半开 TCP)」。
这个统计动作把优先级从「一个偶发 bug」改成了「必须立即修」。 📊 排查时还发现启动告警里有「78 个疑似 hang / 僵尸会话」——这些不是噪音, 是同一个缺陷的历史累积。
9.4 R4 🟠 「有代码」不等于「有能力」
这是形态 ③(零触发伪装成正常)的典型。
📊 第 7.5 节讲过 QuotaManager.checkRateLimit() 生产零调用点的例子。这里讲怎么系统地 查这类问题,因为它的排查方法本身有坑。
三档结论,不是两档
搜一个函数的调用点,直觉会给两档结论:「有引用」/「没引用」。这不够:
| 档 | 判据 | 含义 |
|---|---|---|
| 活代码 | 生产调用 > 0 | 真的在跑 |
| 仅被测试消费 | 生产 = 0,测试 > 0 | ⚠️ 隐形大头——单测全绿,但功能没接线 |
| 真死代码 | 生产 = 0,测试 = 0 | 纯死 |
中间那一档最危险,因为它有测试、测试还过。所有「质量指标」都是绿的。
排查这件事时,我自己踩的坑(如实记录)
📊 这段是从真实审计记录里抄来的,我觉得它比结论更有价值:
首轮 16 个模块
rg全部零引用,我一度要判「16 条死防线」—— 实为我的正则写错 + NUL 字节问题。
还有两个:
truncation-detector首测 0 命中,实为我写了r.truncated(真实字段是isTruncated), 永远undefined。
hypothesis-guide首测分母只有 1,实为首轮 prompt 提取没剥<available-deferred-tools>包裹。
三个都是同一件事:
零命中看起来像结论,其实是探针坏了。
纪律:零触发必须先归因探针,再归因防线。
具体怎么自证探针没坏?喂一个你确信应该命中的输入:
# 你的正则报告「零命中」。先验证这个正则能抓到已知存在的东西:
rg -a 'your-pattern' <(echo "一个你确信应该命中的字符串")
# 抓不到 → 你的「零命中」毫无意义9.5 R5 🟠 防线自己成了它要消灭的死功能
这一条很有讽刺意味,但它是真实的类别。
📊 sid-code 的一次防线触发率统计:审计类任务的防线触发率 0%——即「防线全在、 调用全 0」。
这引出了一条被写进项目规范的验收判据:
新增防线时的验收判据,不是「build 过 + 单测过」, 而是「真实会话里被触发过」——防线自己成了它当初要消灭的死功能, 这事已经发生过一次。
为什么这条这么重要?因为「加了一道防线」这件事在代码 review 里看起来永远是 正收益:多一层保护,谁能反对?但如果它从未被触发,它的实际贡献是:
零收益(从未拦住任何东西)
+ 负成本(多一份要维护的代码、多一个会自己坏掉的部件)
= 净负资产而且它会占据「这个风险已经被覆盖了」的心理位置,让人不再去想真正的解法。
注意区分两种零触发(这个区分很关键):
零触发 + 判据健康 + 兜的是真实风险 → 保留 + 补埋点(第 7.4 节的只读空转止损阀)
零触发 + 判据不可达 / 风险不存在 → 真的该删区分它们的唯一方法是把判据在真实数据上重放,而不是读代码猜。
9.6 R6 🟠 一个指标区分不了两种修法不同的故障
这一条讲的是「排查时的判据设计」,属于第 10 章的前奏。
假设你观察到「Prompt Cache 命中率掉了」。📊 真实的日志长这样:
CACHE_BREAK 缓存命中下降 100%(49728 tokens)
归因:未知原因,可能服务端缓存波动「未知原因」这个归因本身就是一个信号:缺字段。
缓存断裂有两种成因,修法完全不同:
① 本地前缀断裂 → 是我们的 bug(往前缀插了变化内容)→ 要改代码
② 服务端 TTL / 路由抖动 → 不是我们的问题 → 改代码没用一个「命中率」数字区分不了这两种。所以正确的动作不是去猜,而是加一个能区分的字段:
在
CACHE_BREAK日志点增加:本次请求 messages 前缀 hash + 上次 hash。 下次复现即可判定是本地前缀断裂(hash 变)还是服务端波动(hash 未变)。
这是「加诊断埋点」,不是「修复」——但它是修复的前置条件。
通用纪律:
当一个指标动了,如果你无法从现有数据判断该改哪一层代码, 那这个指标缺一个维度。补维度比猜原因便宜得多。
9.7 R7 🟡 轨迹字段大面积为 null,复盘时会误判
📊 排查这次事故时顺带发现的,属于「排查工具本身的缺陷」:
| 问题 | 形态 |
|---|---|
AfterModel 事件字段大面积 null | content_types / ttft_ms / elapsed_ms 在 31 条 AfterModel 里全为 null——值其实记在了另一个事件(AfterModelRaw)里 |
SubagentStop 的 data 为 null | 两次子代理结束事件没记耗时/结果/token,子代理开销无法从轨迹核算 |
GoalGateDecision.tokensUsed 恒为 0 | 全程 0,无法从轨迹看出 goal 评估消耗了多少 |
第一个最值得说,因为它是「字段存在但不填充」:
字段不存在 → 取数时报错 / 得到 undefined → 你知道自己没有这个数据
字段存在但恒为 null → 取数时得到 null → 很容易被当成「这个值是 0」后者更危险。这引出一条判读纪律:
先看键在不在,再取值。
bash# ❌ 直接取值:null 和「值是 0」分不清 jq '.data.ttft_ms' events.jsonl # ✅ 先看键存不存在 jq 'select(.data | has("ttft_ms"))' events.jsonl | head -1
修法有两个方向,选择哪个也有讲究:删掉冗余的 null 字段,或者把值填充进去。 🔬 sid-code 选了填充——理由是「保持一个事件就能看全」,减少复盘时的跨事件拼接。
9.8 把七条压缩成五句话
如果只记五句:
- 「无法判断」≠「未满足」。 判定器故障时的降级方向,决定了它是自愈还是永动机。
- 零触发先归因探针,再归因防线。 你的搜索、正则、字段名比防线更容易写错。
for await里的检查,在流不再产出时是死代码。 要 race,不要靠定时器。- 截断喂给判定者时,问一句被截掉的是不是恰好是判据。
- 防线的验收判据是「真实会话里被触发过」,不是「build 过 + 单测过」。
9.9 本章自检
- 「绿着坏掉」的三种形态分别是什么?(假阴性伪装成未完成 / 假阳性伪装成已完成 / 零触发伪装成正常)
- 评估器故障返回
satisfied: false为什么会造成永动机?(每轮都判未满足,每轮都催继续,每轮都花钱) - 修评估器熔断时为什么不新写计数器?(系统里已有 BlockedDetector 在计数连续失败,让新情况长成它认识的形状)
- 为什么
for await里的signal.aborted检查救不了半开 TCP?(循环体只在下一个元素到达时执行,永远没有下一个元素就永远不执行) - 「仅被测试消费」为什么是最危险的一档?(单测全绿,所有质量指标都是绿的,但功能没接线)
- 一个防线零触发,什么情况下该保留?(判据健康 + 兜的是有记录的真实风险 → 保留并补埋点)
第 10 章 · 度量:怎么证明一道闸门真的有用
第 9 章讲了闸门会怎么坏。这一章讲怎么在它坏之前就知道。
核心问题一句话:
你加了一道闸门。三个月后,你凭什么说它有用?
10.1 最诱人也最错的答案:「它拦住了 N 次坏事」
直觉答案是数「它触发了几次」。这个答案的问题在于分母。
假设你报告:「卡住检测这个月触发了 12 次」。听起来不错。但:
12 次触发 / 多少次机会?
→ 如果跑了 10000 个任务,12 次 = 0.12%,这道闸几乎不存在
→ 如果跑了 20 个任务,12 次 = 60%,这道闸在疯狂误伤同一个分子,两个相反的结论。 所以有一条铁律:
分母比分子重要。 报「触发率」时必须同时写死分母口径,否则这个数字无意义。
而分母的选择本身就是一个判断。📊 一个真实例子:sid-code 统计安全防线触发率时, 分母刻意限定在「审计核查类任务」,而不是全量任务。理由是:
分母 = 全量任务 → 大量任务压根不涉及安全操作 → 信号被稀释成噪音
分母 = 审计核查类任务 → 分母里每一个都是「本该触发」的候选 → 信号清晰用全量做分母会得到一个很小的百分比,然后你会得出「这道防线几乎不触发」的结论—— 而真相可能是「在该触发的场景里它 100% 触发」。
10.2 安全类闸门的特殊难题:坏事没发生,怎么画曲线
这一节是本章最有价值的部分,因为它是一个看起来无解的问题。
安全类闸门的成效是「坏事没有发生」。但:
用「事故数」当指标 → 分母恒 0、曲线恒平
→ 分不清是防线起作用,还是这个月运气好解法是换成正面信号。sid-code 的做法:
| 换成什么 | 为什么可行 |
|---|---|
| 防线触发率(分母限定在相关任务) | 「它被调用了」是正面事件,可数 |
| HITL 介入率(分工具、分规则) | 「人确认了几次」可数,且它同时是「更安全 ↔ 更快」的计价器 |
| 权限规则匹配正确率 | 该拦的拦住、不该拦的别拦——两个方向都能数 |
📊 而 sid-code 自己的诚实结论是:后两项尚未采集(trace 层无权限决策埋点), 所以出不了曲线,只能靠单测 / e2e 断言。
注意这个坦白的价值:区分「有采集」和「没采集」,比编一个数字重要。 📊 实测跑出来的一个结论是「审计类任务 0% 触发」,读法是「防线全在、调用全 0」—— 这个说法诚实,因为它同时报告了「防线存在」和「触发为 0」两件事,而不是只报一个。
10.3 Goal & Gates 该埋哪些点
🔬 sid-code 当前的埋点清单(源码实读)。这是一份可以直接抄的模板。
GoalGateDecision 事件
每次 Goal Gate 做决策时写一条,reason 字段有 9 个可能值:
budget_exceeded 预算耗尽 → 停
turns_limited 轮次耗尽 → 停
eval_skipped 前 N 轮跳过评估 → 继续
satisfied 判定完成 → 停
impossible 判定不可能(硬停模式)→ 停
impossible_soft 判定不可能(降级模式)→ 继续 + 提醒
blocked 卡住(硬停模式)→ 停
blocked_soft 卡住(降级模式)→ 继续 + 提醒
continue 未满足 → 继续这套 reason 值的设计有一个关键优点:它把「继续」的原因拆成了 4 种 (eval_skipped / impossible_soft / blocked_soft / continue)。
为什么这很重要?因为它们对应的健康状态完全不同:
continue ×15 → 正常工作,模型在推进
blocked_soft ×15 → 卡了 15 轮,很不健康
eval_skipped ×15 → minTurnsBeforeEval 配错了(评估根本没跑)如果这四种都记成 continue,上面三种情况在数据里长得一模一样。 这就是第 9.6 节 说的「一个指标区分不了两种修法不同的故障」的正面案例——分类维度是在埋点时决定的, 事后无法补。
每条决策还带的字段
🔬 同一个事件里还记了:
| 字段 | 用途 | 备注 |
|---|---|---|
goalId | 区分同一会话里的多个目标 | 每次 /goal set 生成新 UUID |
turn / absoluteTurn / promptSeq | 轮次口径 | 见下面 10.4,这三个字段有故事 |
shouldContinue | 决策结果 | |
objective(截 200 字符) | 事后能看出这是什么目标 | |
status | goal 状态 | |
tokensUsed / tokenBudget | 预算消耗 | |
evalTokensUsed | 评估器本身花了多少 token | 📊 曾经恒为 0(第 9.7 节),后补 |
blockerKey | 卡在哪 | 用于卡住归因 |
progress | 完成度估算 | 只在评估器返回时有 |
GOAL_LIFECYCLE 结构化日志
🔬 命令层的生命周期事件(create / pause / resume / edit / turns / budget / clear),每条带 goalId + 当时的 objective / status / turns / tokens / evidence 条数。
它回答的问题是「用户干预了几次」——如果用户经常 /goal clear,说明目标经常 需要人工中断,这本身是一个产品信号。
10.4 一个精细但重要的坑:轮次口径
🔬 这个细节值得单独讲,因为它是「同一个词在不同事件里含义不同」的典型。
GoalGateDecision 原来的 turn 字段用的是 goal.turnsUsed。问题在于:
goal.turnsUsed 是「消息内口径」——跨用户消息会回绕也就是说,用户发第二条消息时,这个计数会重新开始。于是:
离线分析时问:「这条 blocked_soft 决策发生在会话的哪个阶段?」
turn = 5 → 是第一条消息的第 5 轮?还是第三条消息的第 5 轮?分不出来而且它与其他事件(如 hypothesis 各事件)不可直接比较、不可相减——两边的 turn 不是同一个东西。
🔬 修法是补两个字段:
absoluteTurn // 会话累计轮次(跨用户消息不归零)
promptSeq // 第几条用户消息,让 turn 回绕可还原两个都可选——不注入则降级为不落该字段,不阻断。
通用教训:
任何叫
turn/index/step的字段,必须写清它的口径边界: 是全局单调的,还是会在某个事件上归零的?两个不同口径的计数器用同一个字段名,是跨事件分析时最常见的错误来源。
10.5 「更准」这个方向该怎么量:过程病态率
Goal & Gates 服务的主要方向是「更少返工 / 一次做对」。但「更准」不能定义成 「模型更聪明」(那不由 harness 控制)。正确的定义是:
同一个模型,在这套 harness 里返工更少、一次做对的比例更高。
📊 对应的可测指标,从过程到结果四层:
| 层 | 指标 | 状态 |
|---|---|---|
| ① 过程病态率 | retry 浪费比(>20% 判病态)、白建连接数、步数比 | ✅ 有采集 |
| 空转:最长「重复调用且返回值不变」段(≥3 判病态) | ✅ 有采集 | |
| ② 工具层 | 工具调用成功 / 失败率 | ✅ 有采集 |
| tool selection accuracy(选对工具的比例,<90% 说明工具太多或描述差) | ❌ 未派生 | |
| ③ eval 通过率 | 回归套件通过率,每次发布都跑 | ✅ |
| ④ 编辑一次成功率 | 首次 edit 即成功的比例 | ⚠️ 只有连续失败信号,成功率本身未派生 |
| 结果 | exit status 分布(end_turn / 中断 / 错误) | ✅ |
「空转」这个指标为什么绝妙(值得单独说):
它的定义是:连续 N 轮「调了同样的东西,返回值也没变」
它不需要理解语义,纯机械可判
而它抓的恰好是 agent 最典型的病态——在原地打转对 Goal & Gates 来说,这个指标是闸门健康度的直接代理:如果某个会话的空转段 突然变长,很可能是某道闸门在假阴性地不放行。第 9.1 节那个事故,在这个指标上是 一目了然的(第 16→31 轮全是重复核查)。
10.6 一个刻意不追的指标
📊 sid-code 刻意不追 hallucination rate(幻觉率)。理由值得学:
在 coding agent 上没有可复算的 grounded 分母,追它只会得到一个自己定义、 自己达标的数字。
这条判断的推广形式:
一个指标如果它的分母只能由你自己定义,那这个指标就是可以被你自己优化的。 优化它不产生任何真实收益,但会消耗真实的工程时间。
它的位置由「工具失败率」和「eval 通过率」顶上——这两个的分母都是客观的。
10.7 变异自证:怎么确认你的门禁没坏
这是第 9.4 节那条纪律的正式方法。任何门禁上线时都该做一次:
① 在「明知有问题」的状态下跑一次门禁 → 断言它 FAIL
② 修好 → 再跑一次 → 断言它 PASS只做 ② 是不够的。一个恒返 PASS 的门禁,只做 ② 时看起来完美。
📊 一个真实的假门禁案例(不是 Goal 领域,但形态完全一样):
# ❌ 错的判据:「某类 span 的数量 != 0」
# 为什么错:子代理也产生这类 span。子代理跑了几次,这个判据就「通过」了,
# 而真正要检查的东西(会话根节点)依然是 0。这个判据会被伪装成 PASS。
# ✅ 对的判据:分两条,都要过
# ① 根 span 数 == 正常退出的会话数,且这些 span 的 parentSpanId 为 null
# ② 根 span 总数 == traceId 去重数(每棵树恰好一个根)还有一个更阴的形态:
# ❌ 数根节点,顺手加了 sort -u
# → 输出 1 →「只有 1 个根,树成形了!」
# 实际是 28 个孤立根,各自 traceId 不同,但去重逻辑写错了位置对 Goal & Gates 领域,变异自证具体该怎么做(🔬 sid-code 的单测就是这么写的):
// 评估器熔断的变异自证:注入一个必超时的 mock provider
it("评估器连续失败 3 轮后放行", async () => {
const mockProvider = createDelayedProvider(30_000); // 必然超时
// 跑 3 轮 goalGate
// 断言第 3 轮返回 shouldContinue: false ← 这就是变异自证
});
// BlockedDetector 的变异自证:四个方向都测
it("连续相同 blockerKey 触发", () => { /* 第 3 次 → true */ });
it("不同 blockerKey 不触发", () => { /* 交替 → false */ });
it("undefined 重置计数", () => { /* 中间插一个 undefined → 重新计数 */ });
it("reset 后重新计数", () => { /* → false */ });注意后面三个测试的性质:它们测的是**「不该触发的时候不触发」**。 一个只测「该触发时触发」的测试套件,无法排除「永远触发」这种坏法。
10.8 本章自检
- 报「触发率」时为什么必须写死分母口径?(同一分子在不同分母下能得出相反结论)
- 安全类闸门为什么不能用「事故数」当指标?(分母恒 0、曲线恒平,分不清是防线有效还是运气好)
- 为什么「继续」这个决策要拆成 4 种 reason?(正常推进 / 卡住 / 评估被跳过在数据里必须能区分,分类维度事后补不了)
- 「空转」指标为什么适合当闸门健康度的代理?(纯机械可判,且直接反映假阴性不放行)
- 变异自证为什么必须做「不该触发时不触发」的测试?(否则无法排除「永远触发」这种坏法)
- 什么样的指标该刻意不追?(分母只能由自己定义的——你能优化它,但不产生真实收益)
第 12 章 · 术语表与学习路径
12.1 术语速查(按拼音/字母序)
| 术语 | 英文 / 源码字段 | 一句话 |
|---|---|---|
| 保险(收敛保险) | — | 「无论如何也得停」的兜底:预算 / 轮次 / 卡住 / 不可能 |
| 变异自证 | mutation check | 在「明知有问题」的状态下跑一次门禁,断言它 FAIL |
| 闸门 | Gate | 挂在 end_turn 上的检查,不通过就不许停 |
| 证据日志 | evidenceLog | 从工具结果自动抽的结构化证据链,独立于对话历史 |
| 阻塞标识 | blockerKey | 评估者返回的「卡在哪」短标识符,给机器比对用 |
| 放行 | pass | Gate 检查通过,这一轮正常结束 |
| 分母口径 | denominator | 触发率的分母定义。变了整条曲线就平移 |
| 封顶 | cap | 「最多续命/提醒 N 次」,防止闸门自己变死循环 |
| 幻觉率 | hallucination rate | 刻意不追——没有可复算的 grounded 分母 |
| 机械判据 | — | 纯计数/计量,不会误判但不聪明(maxTurns / budget) |
| 空转 | spin | 连续「调用相同 + 返回值不变」,≥3 判病态 |
| 快速路径 | fast-path | 不调 LLM,用规则直接判定完成 |
| 目标 | Goal / objective | 用户给的完成条件,必须有真假值 |
| 评估者 | evaluator | 判断「目标是否达成」的独立小模型 |
| 前缀缓存 | prompt cache | 前缀匹配,越靠前的内容改动代价越大 |
| 软提醒 | reminder / nag | 塞一句提示,流程不变。最轻一档 |
| 软续命 | retry | 不放行 end_turn,把模型踢回去。中间一档 |
| 硬停 | hard stop | 强制终止循环(yield done; return)。最重一档 |
| 上下文压缩 | compact | 把对话历史压成摘要。会毁掉对话里的证据 |
| 死代码三档 | — | 活代码 / 仅被测试消费 / 真死代码 |
| 失败开放 | fail-open | 判定器故障时默认放行。进度类闸门必须如此 |
| 失败关闭 | fail-closed | 判定器故障时默认拦住。安全类闸门应如此 |
| 语义判据 | — | 需要理解含义,聪明但会误判(blocked / impossible) |
| 自评自判 | self-grading | 执行者同时当裁判的风险 |
| 状态机 | GoalStatus | 🔬 7 态:active / paused / blocked / impossible / budget_limited / turns_limited / complete |
12.2 一张速查图:三层强度 × 默认状态
面对任何 harness,用这张图快速定位「它会不会掐断我的任务」:
┌─────────────────────────────────────┐
│ 它会终止我的任务吗? │
└─────────────┬───────────────────────┘
│
┌───────────────────┼───────────────────┐
▼ ▼ ▼
① 软提醒 ② 软续命 ③ 硬停
塞一句就走 踢回去继续 掐断
│ │ │
最坏:啰嗦 最坏:空转烧钱 最坏:误杀正常任务
│ │ │
可默认开 需封顶才能默认开 ┌──────┴──────┐
▼ ▼
判据极窄 判据宽/语义
可默认开 必须默认关
(给 env 开关)
查法:搜 `yield done` / `return` / `process.exit` → 找出所有 ③
搜 `continue` + gate 关键字 → 找出所有 ②12.3 三十秒自检清单
设计或 review 一套 Goal & Gates 时,逐条过:
Goal 侧
- [ ] 完成条件有真假值吗?(不是「优化性能」这种)
- [ ] 完成条件是「输出可观测」的吗?(评估者看得见吗)
- [ ] 首轮有没有验证 baseline?(否则不知道起点,证据链也是空的)
评估侧
- [ ] 裁判和运动员分开了吗?
- [ ] 评估器故障时的降级方向是放行吗?(最重要的一条)
- [ ] 有连续失败熔断吗?用的是已有机制还是新写的计数器?
- [ ] 评估器的输入会被截断吗?被截掉的会不会恰好是判据?
- [ ] 有纯本地的 fast-path 兜底吗?(评估器是网络调用,会挂)
- [ ] fast-path 有负例测试吗?(喂「看起来通过但没通过」的输出)
Gate 侧
- [ ] 每道续命都有封顶吗?
- [ ] 封顶后是放行 + 承认半成品,还是硬停?
- [ ] 多道闸门有共享的计数器/预算变量吗?(会互相饿死)
- [ ] 重复注入的提醒有「去重 + 封顶」两道节流吗?
- [ ] 催促类提醒绑了真实进展信号吗?(不能只按轮次刷)
保险侧
- [ ] 四种死法(烧钱 / 磨 / 卡 / 不可能)都有对应保险吗?
- [ ] 语义判据的硬停默认关了吗?
- [ ] 「默认开 + 无开关」的硬停有几个?无人值守的逃生通道是什么?
- [ ] 预算计入 cache_creation token 了吗?
- [ ] 两个上限语义重叠吗?(小的会让大的变装饰)
注入侧
- [ ] 注入位置会破 prompt cache 吗?(变化内容要往后放)
- [ ] compact 之后会立刻补注入吗?
- [ ] 每条硬约束都写了「什么情况不算违反」吗?
度量侧
- [ ] 每道闸门触发时有埋点吗?(无埋点 = 不知道它存不存在)
- [ ] 「继续」的原因分类够细吗?(正常推进 / 卡住 / 评估被跳过要能区分)
- [ ] 轮次字段写清口径了吗?(全局单调 vs 会归零)
- [ ] 报触发率时写死分母口径了吗?
- [ ] 上线时做过变异自证吗?
12.4 学习路径建议
如果你完全没做过 harness,按这个顺序动手(对应附录 A):
第 1 步:写一个最小 agent 主循环(不含任何 Gate)
→ 亲手体会「它 end_turn 就结束了」这件事有多憋屈
第 2 步:加 Todo Gate + 封顶
→ 亲手体会「忘了封顶」会怎样(第一次跑就会空转)
第 3 步:加独立评估者
→ 故意让评估者超时,观察 fail-closed 造成的永动机
第 4 步:加保险 + 埋点
→ 做一次变异自证第 3 步的「故意让它坏」是整条路径里最有价值的一步。 你会亲眼看到第 9.1 节那个 事故的微缩版,而这个体验比读十遍文档都管用。
如果你已经在做 harness,建议的动作是:
- 把你的仓库里所有
yield done/process.exit数出来,逐个问「默认开吗?有开关吗?」 - 挑一道你最有信心的防线,在真实轨迹上重放它自己的判定函数,看触发率
- 大概率你会发现至少一个「零触发」或「探针坏了」——那就是本文第 9 章的收获
附录 A · 从零实现一个 mini Goal & Gates
这个附录给一份可以照抄的骨架,用 TypeScript 伪代码。目标是让你在半天内跑通 最小闭环,而不是复刻一套生产系统。
A.1 阶段 1:把 return 改成过闸(半天)
// ─── 数据结构:先只要这么多 ───
interface LoopState {
turnCount: number;
todoGateRetryCount: number; // ★ 封顶计数器,第一天就要有
}
const MAX_TODO_GATE_RETRIES = 3;
// ─── 主循环 ───
async function agentLoop(userInput: string) {
const messages: Message[] = [{ role: "user", content: userInput }];
const state: LoopState = { turnCount: 0, todoGateRetryCount: 0 };
while (true) {
state.turnCount++;
const response = await callModel(messages);
messages.push(response);
if (response.stop_reason === "tool_use") {
messages.push(await runTools(response.tools));
continue;
}
if (response.stop_reason === "end_turn") {
// ★★★ 这里是整个体系的入口 ★★★
const unfinished = countUnfinishedTodos();
if (unfinished > 0 && state.todoGateRetryCount < MAX_TODO_GATE_RETRIES) {
state.todoGateRetryCount++;
messages.push({
role: "user",
content: `<system-reminder>清单还有 ${unfinished} 项待完成,`
+ `继续推进 (${state.todoGateRetryCount}/${MAX_TODO_GATE_RETRIES})。`
+ `</system-reminder>`,
});
continue; // ★ 续命
}
if (unfinished > 0) {
// ★ 封顶了:放行,但要求诚实
messages.push({
role: "user",
content: `<system-reminder>续命已达上限。请如实列出未完成的 `
+ `${unfinished} 项,不要假装完成。</system-reminder>`,
});
// 注意:这里不 continue,让模型再产出一次「诚实的收尾」后结束
}
return response;
}
}
}你会撞到的第一个坑:如果忘了 todoGateRetryCount,模型会被无限踢回去。 这个坑必须自己撞一次,因为它是「封顶为什么是第一天就要有的东西」的最好教材。
A.2 阶段 2:加 Goal + 独立评估者(一天)
interface GoalState {
objective: string;
status: "active" | "complete" | "turns_limited";
turnsUsed: number;
maxTurns: number;
}
interface EvalResult {
satisfied: boolean;
reason: string;
blockerKey?: string;
}
// ─── 评估者:注意 catch 分支 ───
async function evaluateGoal(goal: GoalState, context: string): Promise<EvalResult> {
try {
const raw = await callSmallModel({
system: `你是目标完成判定器。只输出 JSON:
{"satisfied": bool, "reason": "简短理由", "blockerKey": "卡在哪的短标识(可选)"}
判定原则:把「已完成」当作未经证明的假设,只在证据明确时才判 satisfied=true。`,
user: `完成条件:${goal.objective}\n\n对话/证据:\n${context}`,
timeout: 25_000, // ★ 别写 8000,会必超
});
return JSON.parse(extractJSON(raw));
} catch (e) {
// ★★★ 这个分支是本附录最重要的五行 ★★★
return {
satisfied: false,
reason: "(评估器暂时不可用)",
blockerKey: "__evaluator_unavailable__", // ★ 让熔断机制能抓到它
};
}
}
// ─── Goal Gate:注意检查顺序 ───
async function goalGate(goal: GoalState, messages: Message[], detector: BlockedDetector) {
// ① 轮次(机械判据,最便宜,放最前)
if (goal.turnsUsed >= goal.maxTurns) {
goal.status = "turns_limited";
return { shouldContinue: false };
}
// ② 前 N 轮跳过(模型刚开始,不可能已完成)
if (goal.turnsUsed < 2) return { shouldContinue: true };
// ③ fast-path(纯本地规则,评估器挂了它还活着)
const fast = tryFastPath(goal);
if (fast) return { shouldContinue: !fast.satisfied, evalResult: fast };
// ④ 最贵的一步放最后
const result = await evaluateGoal(goal, extractContext(messages));
// ⑤ 熔断:连续 3 轮相同 blockerKey
if (detector.record(result.blockerKey)) {
console.warn("⚠️ 连续 3 轮相同阻塞原因,放行让用户决定");
return { shouldContinue: false, evalResult: result }; // ★ fail-open
}
if (result.satisfied) {
goal.status = "complete";
return { shouldContinue: false, evalResult: result };
}
return { shouldContinue: true, evalResult: result };
}这一步必做的实验:把 timeout 改成 100(保证必超),然后跑一个任务。 你会看到评估器每轮都失败,而因为有第 ⑤ 步的熔断,3 轮后它会放行。 再把第 ⑤ 步注释掉重跑一次——你就复现了第 9.1 节那个 $2.53 的事故。
A.3 阶段 3:加保险(半天)
class BlockedDetector {
private keys: string[] = [];
constructor(private threshold = 3) {}
record(blockerKey: string | undefined): boolean {
if (!blockerKey) { this.keys = []; return false; } // ★ 无 key = 有进展 → 清零
this.keys.push(blockerKey);
if (this.keys.length > this.threshold + 2) {
this.keys = this.keys.slice(-this.threshold - 2);
}
if (this.keys.length < this.threshold) return false;
const recent = this.keys.slice(-this.threshold);
return recent.every(k => k === recent[0]);
}
}
function checkBudget(goal, usage): "ok" | "warning" | "exceeded" {
if (!goal.tokenBudget) return "ok"; // ★ 默认不限制
goal.tokensUsed += usage.inputTokens
+ usage.outputTokens
+ (usage.cacheCreationTokens ?? 0); // ★ 别漏这一项
const ratio = goal.tokensUsed / goal.tokenBudget;
if (ratio >= 1.0) return "exceeded";
if (ratio >= 0.85) return "warning"; // ★ 预警,给收尾窗口
return "ok";
}A.4 阶段 3.5:上线前的变异自证(半小时,不能省)
// ★ 只测「该触发时触发」是不够的,必须测四个方向
describe("BlockedDetector", () => {
it("连续 3 次相同 key → 触发", () => {
const d = new BlockedDetector(3);
expect(d.record("k")).toBe(false);
expect(d.record("k")).toBe(false);
expect(d.record("k")).toBe(true); // ★ 第 3 次
});
it("交替不同 key → 不触发", () => { // ★ 排除「永远触发」
const d = new BlockedDetector(3);
["a","b","a","b","a"].forEach(k => expect(d.record(k)).toBe(false));
});
it("undefined 重置计数", () => { // ★ 排除「计数不清零」
const d = new BlockedDetector(3);
d.record("k"); d.record("k");
d.record(undefined); // 清零
expect(d.record("k")).toBe(false);
});
});
// ★ 评估器熔断的变异自证:注入必超时的 provider
it("评估器连续失败达阈值后放行", async () => {
const goal = { objective: "test", turnsUsed: 5, maxTurns: 150, status: "active" };
const detector = new BlockedDetector(3);
const alwaysTimeout = createDelayedProvider(30_000);
let last;
for (let i = 0; i < 3; i++) {
last = await goalGate(goal, [], detector, alwaysTimeout);
}
expect(last.shouldContinue).toBe(false); // ★ 断言:会放行,不是永动机
});A.5 阶段 4:Evidence Log + 埋点(一天,按需)
function collectEvidence(toolName: string, result: string, turn: number): EvidenceEntry | null {
if (toolName !== "bash") {
if (toolName === "Write" || toolName === "Edit") {
return { turn, timestamp: Date.now(), type: "file_change",
summary: `文件修改: ${extractPath(result)}` };
}
return null;
}
// ★ 双重条件:光有 "test" 这个词不算,还要有「数字 + pass/fail」的形态
if (/\b(pass|fail|error|test)\b/i.test(result) && /\d+\s*(pass|fail|test)/i.test(result)) {
return { turn, timestamp: Date.now(), type: "test_result",
summary: extractTestSummary(result), raw: truncate(result, 2000) };
}
return null;
}
// ★ 埋点:「继续」的原因必须分类,事后补不了
function emitDecision(reason:
| "budget_exceeded" | "turns_limited" | "eval_skipped"
| "satisfied" | "impossible_soft" | "blocked_soft" | "continue",
shouldContinue: boolean, extra?: object
) {
appendEvent({
event: "GoalGateDecision",
timestamp: new Date().toISOString(),
data: { reason, shouldContinue, absoluteTurn: sessionTurnCounter, ...extra },
// ↑ 全局单调,别用会归零的那个计数器
});
}A.6 你会亲手撞到的坑(按出现顺序)
| 阶段 | 坑 | 症状 |
|---|---|---|
| 1 | 忘了封顶 | 第一次跑就无限续命 |
| 1 | 封顶后直接 return | 交付半成品且用户不知道 |
| 2 | 评估器超时设太短 | 每次都失败(先量一下你的小模型首字节要多久) |
| 2 | catch 里返回 satisfied: false 不带 blockerKey | 永动机(第 9.1 节的事故) |
| 2 | 评估者 prompt 没要求「只输出 JSON」 | 解析失败,走进 catch,同上 |
| 2 | 上下文截断策略是「保留最近」 | 判定所需的「最完整那一条」被截掉了 |
| 3 | 预算漏算 cache_creation | 系统性低估成本 |
| 3 | 卡住检测默认硬停 | 误杀(语义判据会误判) |
| 4 | 「继续」只记一种 reason | 正常推进和卡住在数据里长得一样 |
| 4 | 轮次字段用了会归零的计数器 | 跨消息分析时对不上 |
附录 B · 可复跑的核验命令
这些命令的用途不是「运行一下看看」,而是让你能自己核对本文的每个数字。 文档会过期,源码不会。
B.1 核对本文引用的默认值
cd <sid-code 仓库>
# Goal 的所有默认值(本文第 1.4、8.x 节引用的数字)
cat packages/core/src/goal/config.ts
# maxTurns 的默认值(本文说 150)
rg -n 'maxTurns' packages/core/src/goal/state.ts
# 各类闸门的封顶阈值(本文第 7.3 节那三张表)
rg -n '^export const (MAX|STUCK)[A-Z_]*\s*=' packages/core/src/query/B.2 数出所有硬停点
# ① 所有能掐断任务的地方(本文说 22 处)
rg -c 'kind: "done"' packages/core/src/query/loop.ts
# ② 所有 env 门控的开关(判断哪些默认关)
rg -n 'process\.env\.SID_ENABLE_|process\.env\.SID_LOOP_' packages/core/src/ \
| rg -v '/tests?/'
# ③ 逐个问:默认开吗?有开关吗?
# 落在「默认开 + 无开关」的就是无人值守场景下没有逃生通道的那些B.3 排查「仅被测试消费」的死代码
# 关键:必须排除测试目录,否则「仅被测试消费」会被误记成「已实现」
FN='checkRateLimit'
echo "生产调用点:"
rg -n --no-filename -w "$FN" packages/ -g '!**/tests/**' -g '!**/*.test.ts' \
| rg -v 'function |=>' | wc -l
echo "测试调用点:"
rg -n --no-filename -w "$FN" packages/ -g '**/*.test.ts' | wc -l
# 生产 0 + 测试 >0 → ⚠️ 仅被测试消费(单测全绿但功能没接线)
# 都是 0 → 真死代码B.4 取数铁律(每一条都是踩出来的)
# ① 一律用 rg -a,不用 grep
# grep 遇到 NUL 字节会静默零输出 → 你会得到一个假的「零命中」
# ② 英语常用词加 -w
rg -w 'hang' src/ # ✅
rg 'hang' src/ # ❌ 被 change/changed/changes 淹没数百条
# ③ 去重计数用 -I,不用 -N
rg -I -o 'pattern' src/ | sort -u | wc -l # ✅ 不带文件名
rg -N -o 'pattern' src/ | sort -u | wc -l # ❌ -N 只去行号不去文件名
# # 同一个值在 N 个文件里被计 N 次
# # 实测虚高 24%:报 1119,真值 903
# ④ 别在数根节点时顺手 sort -u
# 会把「28 个孤立根」压成「1 个根」,得出假 PASS
# ⑤ 自证探针没坏:喂一个你确信应该命中的输入
rg -a 'your-pattern' <(echo "一个确信应该命中的字符串")
# 抓不到 → 你的「零命中」毫无意义B.5 从轨迹里核算 Goal 行为
TRACE=~/.sid-code/trajectories/sessions
# ① 「继续」的原因分布——一眼看出健康度
# continue 多 = 正常;blocked_soft 多 = 卡住;eval_skipped 多 = 配置错了
cat $TRACE/*/events.jsonl \
| jq -r 'select(.event=="GoalGateDecision") | .data.reason' \
| sort | uniq -c | sort -rn
# ② 评估器故障的次数(本文第 9.1 节那个事故的指纹)
rg -c '__evaluator_unavailable__' $TRACE/*/events.jsonl
# ③ 评估器本身花了多少 token(曾经恒为 0,见第 9.7 节)
cat $TRACE/*/events.jsonl \
| jq -r 'select(.event=="GoalGateDecision") | .data.evalTokensUsed' \
| awk '{s+=$1} END {print "评估器累计 token:", s}'
# ④ 判断字段是「不存在」还是「值为 0」——先看键在不在
cat $TRACE/*/events.jsonl \
| jq 'select(.event=="GoalGateDecision") | .data | has("evalTokensUsed")' \
| sort | uniq -c
# ⑤ 会话末事件分布——找 hang(本文第 9.3 节的方法)
for d in $TRACE/*/; do
[ -f "$d/events.jsonl" ] && tail -1 "$d/events.jsonl" | jq -r '.event'
done | sort | uniq -c | sort -rn
# BeforeModel / StreamPhase / ModelCallUnpaired 结尾 → 疑似 hangB.6 一条最有价值的核验:在真实轨迹上重放防线
这是第 7.4 节那组实测数据的做法,也是判断「零触发是防线问题还是探针问题」的 唯一可靠方法:
方法论纪律(针对「探针坏了当结论」的教训):
① 用**被测防线自己的导出函数**在真实轨迹上重放,不要重写判定逻辑
(重写 = 你测的是你的理解,不是它的行为)
② 能拿到「真实注入证据」的,优先用 raw.jsonl 里 request 消息中的**文案指纹**
直接数——那是模型真正收到过的字节,比模拟更硬
③ 先做健全性自检:喂一个确信应该命中的样本,确认它命中
④ 再报零触发最后:这份文档想让你记住的三件事
一、goal_satisfied() 谁来实现,是这个领域唯一的核心问题。 三种答案(自我汇报 / 独立评估者 / 证据链)各有各的赌注,没有免费的升级。选哪个, 由你的任务长度、成本敏感度和是否会触发 compact 决定。
二、闸门坏掉时不会报错,它会伪装成正常工作。 「评估器坏了」被翻译成「目标未满足」,一次基础设施故障就变成永动机——40 分钟、 $2.53、一半是空转,而监控面板全绿。所以:进度类闸门必须 fail-open; 语义判据 + 硬停必须默认关;每道闸门都必须有埋点。
三、零触发先归因探针,再归因防线。 你的正则、字段名、搜索命令比防线更容易写错。判断一个防线是否真实存在要三档 (活代码 / 仅被测试消费 / 真死代码),而中间那一档单测全绿。 防线的验收判据是「真实会话里被触发过」,不是「build 过 + 单测过」。