面试知识库

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. 为什么是预算而不是更聪明的门#

三个候选方案都考虑过:

  1. 让 planner 判得更准(改 prompt / 加 few-shot)——治标。判定准确率永远到不了 100%, 而禁令制下 1% 的误判就是 100% 的死锁。修判定不如改「判错的代价」。
  2. 逃生门(热修方案)——能解死锁,但代价是白挨 2 轮拒绝,且「连拒计数」这种机制本身 就是复杂度;它是急救包,不是终态。
  3. 小预算——判对时(真 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:这套分类怎么推广? 立一道新闸先问:判定依据是事实还是推定?推定 → 要么带逃生门、 要么改成预算;事实 → 可以硬,但必须有看门狗这类全局兜底,因为「组合死锁」不挑单个组件 的对错。