M12.1 · Harness 执法契约重构——从阶段禁令到预算制 —— 开发文档(面试向)#
这篇讲的是为什么这么做、做了哪些取舍、踩了哪些坑,不讲具体代码。目标是读完能用大白话讲出来。 起点是一次真实线上死锁事故,终点是一条执法哲学的改写:墙必须带门,而最好的门是把墙换成预算。
一句话概括#
一次「换品类被误判成复用」的线上事故暴露了阶段状态机的结构性缺陷:效率闸拿着安全闸的执法权。 四段重构把它拆干净——热修给错墙开门(连拒 2 次放行)、看门狗兜住一切停滞、逃生规则通用化、 最后把阶段白名单禁令整个拆掉,换成「复用轮小预算、永不为 0」的经济约束。阶段机降级为遥测 + 提示的依据,不再执法。
0. 事故:27 轮「被拒→重试」死循环#
用户上一轮搜冲锋衣,这一轮说「换个方向,要衬衫式上衣」。planner 把这次换品类误判成了
retrieval=reuse(在旧候选上收紧条件),于是:
- 阶段机直跳 COMPARING,把 item_search 列为「当前阶段不可用」,拦死;
- 旧候选全是冲锋衣,被本轮 exclude_terms 全部排除——池子实际是空的;
- 两条回退腿(refine_backfill / phase_rollback)都以「精挑过」为前置,而模型认为精挑一个 注定全空的池子没有意义,拒绝配合——回退腿永远不点火。
模型每一轮都坚持调 item_search(它是对的!),每一轮都被拦,27 轮、76 秒,直到用户手动取消。 系统里每个组件都在按自己的规则正确运行,组合起来是死锁。
1. 诊断:效率闸拿了安全闸的执法权#
复盘时最重要的一步是给「闸」分了类,两类的执法权应该完全不同:
| 判定依据 | 判错的后果 | 应有的执法方式 | |
|---|---|---|---|
| 安全闸(fork 深度/检索总量/token 预算/终结硬停) | 精确事实(计数器、标志位) | 不会「判错」——预算耗尽就是耗尽 | 永远硬拒 |
| 效率闸(阶段表/postfork 直搜/websearch 动机) | 上游推定(planner 意图、「fork 即检索结束」语义) | 把模型唯一正确的路堵死 | 必须 fail open |
阶段状态机守护的是效率(少走冤枉路),依据的是 planner 的意图判定——而上游判定是假设, 不是承诺。它却拿着「无限次硬拒」的安全闸执法权。这呼应本项目一贯的原则「预算打动机、 机制打机制」:phase machine 恰恰违反了它——拿机制去打一个可能判错的动机。
2. 四段重构的弧线#
第一段(热修):给错墙开门。 按 (阶段, 工具) 计拒绝次数,同一堵墙连拒 2 次后第 3 次放行; 检索类放行时同步做阶段回退(退 SEARCHING、reuse 改写 augment、解锁 postfork 闸),否则这道闸 放了行、下一道闸又拦死。要点:模型对同一堵墙的反复坚持,本身就是「墙可能立错了」的强信号。
第二段(看门狗 + 逃生通用化):兜底与推广。 liveness 看门狗以「工具真实执行成功」为唯一
进展口径(被拦、回放都不算),停滞 45s 注入收敛指令、再宽限 30s 就硬停交部分结果——从此
任何死锁都有天花板,不管哪道闸立错了墙。逃生门上提到 Hook Pipeline 统一实现:效率闸声明
escape_key 即接入,闩锁语义(赢过一次后同 key 直接放行,不再每次攒 2 轮拒绝烧延迟)。
第三段(本篇落地):把墙拆了。 逃生门本质还是「给错墙打补丁」——模型要白挨 2 次拒绝
(每次一轮 LLM 往返)才能走上正确的路。既然 reuse 判定本来就可能错,正确的形态是不设禁令、
设小预算:复用轮全树检索预算收紧到 1(可配,代码里 max(1, ...) 钉死永不为 0),预算内
第一次补搜当场放行执行,结果尾部缀「搜完即收敛」的软线提示,越线才硬挡。阶段白名单
禁令整个删除,阶段机降级为三个角色:遥测(trace 里看走到哪一步)、提示的依据(「检索收线」
指路文案)、少数安全底线的判据(没规划/精挑过不许交卷——这是事实判定,不是白名单)。
3. 为什么是预算而不是更聪明的门#
三个候选方案都考虑过:
- 让 planner 判得更准(改 prompt / 加 few-shot)——治标。判定准确率永远到不了 100%, 而禁令制下 1% 的误判就是 100% 的死锁。修判定不如改「判错的代价」。
- 逃生门(热修方案)——能解死锁,但代价是白挨 2 轮拒绝,且「连拒计数」这种机制本身 就是复杂度;它是急救包,不是终态。
- 小预算——判对时(真 reuse):模型本来就不想搜,预算 1 次纯粹是备而不用,正常链路 零感知;判错时(假 reuse):第一次补搜直接执行,零轮浪费。预算制让误判的代价从 「死锁」降到「多花一次检索」,而这次检索恰恰是用户需要的。
关键不变量是「永不为 0」:配 0 等于把预算制又改回禁令制。所以代码里 max(1, ...) 钉死,
测试里单独有一条断言守着这个常量。
4. 自洽性检查:拆墙之后谁来守效率#
拆掉阶段白名单前,把它原来拦的每类行为都问了一遍「现在谁兜」:
- reuse 轮乱检索 → 复用轮小预算(1 次软线 + 硬挡),refine_backfill 判「池子真不够」时 改写 augment 恢复全预算——授权补搜是确定性机制决策,不是模型说了算;
- 有候选后继续泛搜 → 全树检索总量预算(8 次)+ postfork 直搜闸(带逃生)+ websearch 动机闸(带逃生)照常在;
- 精挑完还打转比价 → 收线通告降级为纯提示,打转由 LoopDetector(重复调用提示)、 tool_memo(同参回放不真跑)、token 预算、看门狗四层兜;
- 没干活就交卷 → phase_check 保留的两道安全底线:候选池空、或本轮没规划/精挑过, 拒绝 shopping_summary——这两条是精确事实判定,永远硬。
结论:白名单唯一「独占」的功能是按阶段禁工具,而这恰恰是它闯祸的功能。其余全部有更合适的 机制在管。
5. 踩过的坑#
- 放行一道闸 ≠ 放行一条路。 热修最早只让阶段闸放行,结果同一次调用又撞死在下一道 postfork 闸上——逃生必须连带做配套回退(改 mode、发授权、重新武装通告)。链路上的闸是 串联的,开门要开一整条走廊。
- 通告文案要跟机制同步改。 拆墙后「会被机制拒绝」的旧话术变成假话——对模型撒谎会反噬 (它会拿真实行为去校准你说的话)。所有哨兵/通告按新语义重写:预算约束说预算,动机建议说 建议。
- 回退腿不能以「模型配合」为前置。 refine_backfill 曾要求 picker_attempted 才点火,而 模型拒绝精挑空池子——机制的逃生路径必须是确定性的,不依赖模型愿不愿意先走一步冤枉路。
6. 验收#
- 823 条单测全过(重写阶段白名单相关用例为「不再拦截」+ 新增复用轮预算三条);
- q19(换品类死锁回归,多轮):基线 76.7 → 100,无 P1 违规——死锁场景下模型第一次 补搜直接执行,链路更顺;
- q01(存量 bad case):26.7 → 40,无系统性下降;P0 红线 2/2。
面试连珠炮#
- Q:为什么不直接删掉状态机? 遥测和提示仍然需要「现在走到哪一步」这个概念;删掉的是 它的执法权,不是它的观测价值。收尾资格底线也还要用它判「本轮规划过没有」。
- Q:小预算被恶意烧完怎么办? 复用轮硬挡后模型只剩精挑/收尾两条路,若精挑证明池子真 不够,机制(refine_backfill)会主动授权补搜——「要更多预算」的裁量权在确定性代码手里。
- Q:这套分类怎么推广? 立一道新闸先问:判定依据是事实还是推定?推定 → 要么带逃生门、 要么改成预算;事实 → 可以硬,但必须有看门狗这类全局兜底,因为「组合死锁」不挑单个组件 的对错。