面试知识库

M12 · Harness 治理框架与控制面 Hook 化 ⟶ refdocs 17-1 / 17-2 / 17-3 / 17-4(18-1 / 18-2 仅背景)#

把散落在中间件、工具、工具函数里的运行时管控,统一收进一条覆盖 Agent 全生命周期的 Hook Pipeline。 顺带修掉了一批「代码存在、执行路径断裂」的假能力。


1. 背景:Agent = Model + Harness#

到 M11 为止,ShoppingX 的运行时管控已经很多了:结果截断、循环检测、fork 深度闸、检索预算、token 预算硬线、终结纪律、上下文压缩……但它们散在三个地方,用三种接入方式:

  • ToolGuardMiddleware 里一堆 _xxx_gate() 私有方法,靠 wrap_tool_call 里手写的 if 序列串起来;
  • ContextCompressionMiddleware 单独一个中间件;
  • 熔断器散在各个工具内部,包着外呼。

行业共识(refdocs 17-1)把这一层叫 Harness:模型之外的一切运行时基础设施。核心原则是 “Agent 出问题先修 Harness,不是修 Prompt”——线上 bad case 大多不是模型笨,是工具返回太大挤爆上下文、是 ContextVar 没隔离、是缓存断点被 few-shot 顶飞。

散着的后果很具体:新增一道检查要改 3-4 个文件,还得回答「它该排在截断之前还是之后」。而这个顺序问题,在旧实现里是靠注释锁着的。


2. 这个里程碑真正做的事#

2.1 先发现:一半的「能力」在生产路径上是空转的#

app/harness/ 的初版有 41 个单元测试、全绿。但那些测试都是手搓一个 context dict 喂给单个 Hook——它们验证 Hook 自身逻辑对,验证不了 Hook 在真实 Agent 生命周期里拿不拿得到数据

实际断了四处:

断裂后果
三类断言写进 pre/post_tool_call 的 ctx,汇总它们的 handler 却挂在 post_reflect 上读新建的 ctxSchema / Sequencing / Semantic 断言从不产生任何纠正提示
漂移检测的「目标遗忘」拿工具名去匹配 query 关键词命中数恒为 0 → 每 3 轮必报假阳性;且预检首个信号即 return,把后三类信号和 LLM 判定全遮蔽
token_history 没有任何写入点;「偏好丢失」只有 docstring四类漂移信号实际只有一类在跑,还是个恒真误报
阶段门在非 CONCLUDING 阶段拒绝 shopping_summary与系统里所有「强制收尾」通路对顶:一边逼模型收尾,一边不许它收尾

教训:有「靠约定在 context 里传递数据」的设计时,接线测试比 Hook 单测重要。而阶段机、回退这类跨轮状态,连接线测试都未必抓得住——得真跑一条 query 看日志。本里程碑的三个关键 bug(阶段回退误触、顺序断言误报、漂移检测 9 秒延迟)全部是真跑日志暴露的。

2.2 再迁移:控制面 100% 进 Hook#

app/agent/middleware.py 整个删掉,不留兼容层。LangChain 中间件栈只剩唯一的适配器,它不做任何控制决策,只干四件事:翻译生命周期、接力 context、把 Hook 的改写写回真实对象、承担纯观测职责。

on_session_start  阶段机复位
pre_think         token 预算 hint 注入 → 上下文压缩 + 缓存标记
pre_tool_call     终结硬停 → 深度闸 → web_search 门控 → 阶段门 → 顺序断言
                  → item_search 夺权 → fork 预算 → token 硬线 → 检索计数 → 工具熔断
post_tool_call    熔断计数 → 结果截断 → 循环检测/分级提示 → 终结标记
                  → Schema 断言 → 语义断言 → 漂移信号追踪
post_reflect      断言汇总 → 漂移检测 → 阶段转移/回退 → 终结纪律
on_session_end    输出审核
plaintext

新增一道检查 = 写一个函数 + 一行 @harness_hook,不动主循环。


3. 关键设计决策与取舍#

3.1 哨兵拒绝 vs 摘工具(主动偏离 refdocs#

refdocs 17-4 §2.3 主张「隐藏 > 拒绝」:把当前阶段不可用的工具从 FULL_TOOL_SET 里过滤掉,模型看不到 schema 就不会调。

我们反过来做:工具表恒定,禁用一律在执行层以哨兵消息拒绝

理由是 prompt 前缀缓存。工具 schema 是 system 段之后最长的稳定前缀,每轮改工具表 = 每轮打断缓存前缀。摘工具省的那点解码 token,远抵不上缓存失效的成本。

代价是真实的,也被实测捕捉到了:模型偶尔会调一个当前阶段不可用的工具,白费一轮。这是自觉的取舍,不是疏漏。

3.2 refdocs 两章互相矛盾,得自己裁决#

  • 17-3 §3.5:连续严重漂移 → 强制调 ShoppingSummary 收尾
  • 17-4 §5:shopping_summary 只能在 CONCLUDING 阶段调用(双重保护)

两章都没提对方。照抄任一章都会死锁。裁决方式:终结工具豁免阶段门(候选为空时仍拒绝并引导 chat_fallback,保住「防过早收尾」),同时任何「强制收尾」通路在注入指令时一并把阶段机推到 CONCLUDING ——授权与指令必须同时到位。

3.3 有副作用的闸必须排在最后#

工具级熔断的 allow() 会把断路器从 OPEN 推进到 HALF_OPEN 以放行一次探测。若它排在前面,后面任何一道闸的拒绝都会让这次「已放行的探测」没有对应的成败记录——断路器悬在 HALF_OPEN,再也回不到 CLOSED。

所以它是 pre_tool_call最后一道。代价是被熔断拒绝时检索计数已经自增了一次,方向安全(只会让 Agent 更早收敛),可以接受。

同类契约还有一条:search_authority 读的是 item_search 自增前的计数,自增在 retrieval_charge。谁把自增前移,子 Agent 的「恰好放行一次」会塌成「放行 0 次」。

这两条都用测试钉死了,不靠注释——注释拦不住重构。

3.4 终结纪律必须当场重发模型#

其它 Hook 的纠正都走 inject_messages,下一轮 pre_think 消费。终结纪律不行:模型这次没产出任何 tool_call,Agent 框架据此判定「该结束了」——根本不会有下一轮

所以这个 Hook 只置一个 retry_nudge,由适配器在同一次模型调用里追加提示、重发一次。

配套的坑:重试配额是 per model call 而不是 per loop。把计数器放进 per-loop 的状态对象却不按调用清零,会让整个会话只催一次——长对话里第 12 轮再想用纯文字蒙混过关时就没人管了。这个回归是我自己在迁移中引入的,/code-review 阶段抓出来的,而且当时写的测试把错误行为当成正确的钉住了

3.5 什么不该进 Hook Pipeline#

AGUI 事件上报、工具 RT metrics、token 记账——这三样留在适配器里。

依据是 refdocs 17-2 §1.3 的分工:Hook 能拦截和修改,Callback 只能观测。「只需要看不需要改」的逻辑进 Hook Pipeline 只会让控制面变噪。

3.6 输出审核刻意保守#

on_session_end 的输出审核只做一件事:把 Agent 把 Harness 自己的内部控制文案([系统提示] / [强制收敛])鹦鹉学舌抄进最终回复的行剔掉。这是真实失败模式。

不做 item_id / 商品编号的正则脱敏——那些在购物场景里往往正是用户想要的信息。宁可漏放,不误杀(与 forget_preference 的确定性删除同一取向)。


4. 踩过的坑#

坑 1:全绿的测试掩盖了全断的接线。 41 个单元测试通过,四条链路全断。补的接线测试逐条回滚验证过——每个新测试都能让对应的旧实现变红,否则它只是装饰。

坑 2:测试之间靠副作用互相搀扶。 Hook 注册是全局副作用。新写的接线测试单独跑会「因为没有 Hook 在跑」而空过,全量跑却是绿的——因为另一个用例先调过 setup_harness()。差点把这套测试当成有效的。

坑 3:修好一个 bug 会暴露另一个的成本。 漂移检测的预检原本恒真、永远短路,所以「很快」。修好假阳性后,LLM 判定腿第一次真正被执行,主链路凭空多出 9 秒。查 refdocs 才发现它一直写着「用 lite 模型、单次 < $0.0001」,而实现里调的是 Rubric 评测用的强模型。

坑 4:布尔信号冒充计数。 picks_count 取的是「item_picker 有没有被调过」而不是「它返回了几个 picks」。后果有两个:候选全超预算时(picks: [])阶段机照样推进到 CONCLUDING,产出空清单——正是 17-1 §5 列举的失败模式;且回退闸从此永不触发。

坑 5:Hook 改了 ctx,没人消费。 这个坑踩了两次。第一次是 on_session_end 的返回值被丢弃;改完之后第二次是——审核跑在了落产物、写历史、report_task_result 之后,用户看到的、下载的、回看的全是未审核原文,只有函数返回值是干净的。回写通路的位置和存在同样重要。


5. 面试可讲点#

“Agent 出问题先修 Harness 不是修 Prompt”——能举出具体例子:推荐漂移的根因不是 prompt 不够强,是第 4 轮工具返回 12000 token 把 system prompt 在上下文里的权重挤没了;真正的修法是 post_tool_call 加截断。

Feedforward / Feedback × Computational / Inferential 的二维分类,以及由此得出的工程原则:先用确定性控制覆盖能覆盖的,Inferential 只用在确定性覆盖不了的地方。漂移检测就是这么设计的——四类信号先跑零成本的确定性预检,预检不决才调轻量 LLM。

同质 fork 下控制面的作用域该怎么切:子 Agent 挂的是同一套 Hook(能力同质),但阶段机和漂移检测只在 depth 0 生效,深度闸还会收回子 Agent 的终结授权(授权不同质)。子 loop 的越界靠机制兜(深度闸 / 迭代上限 / 检索预算),不靠再叠一层 LLM 判定。

过程级质量保障 vs 端到端评测:Rubric 跑完才知道结果差,token 已经烧掉了。单步断言(Schema/Sequencing/Semantic)+ LoopDetector(抓重复)+ Silent Drift(抓「做不同的事但在偏离」)构成三层实时防线,各有各的盲区,缺一层就漏一类。

可能的追问#

Q:为什么不按 refdocs 说的摘工具? → 前缀缓存。工具 schema 是 system 段后最长的稳定前缀。答完要主动承认代价:模型偶尔调越权工具白费一轮,实测见过。

Q:Hook 之间怎么传数据? → 三条接力通道,因为每个 Hook 点拿到的是不同的 context dict。这里正是最初四处断裂的根因。

Q:漂移检测每 3 轮调一次 LLM,成本怎么控? → 确定性预检先跑;用 lite 档不用 judge 档(实测差 4-10 倍延迟);prompt 只要三选一,completion ≤ 5 token。

Q:怎么保证控制面没有第二套并行逻辑? → 有测试钉死中间件栈只有一个元素、六个 Hook 点全部有主。


6. 诚实的边界#

  • 子 Agent 不做漂移检测(不传 original_query)。有意为之:fork 的越界由机制兜;且「强制收尾 → 调 shopping_summary」这种纠正语义只对主 loop 成立。
  • 「偏好丢失」信号只查会话级 P_t 的硬 dislike,不查长期 Store。refdocs 17-3 §3.2 说的是长期黑名单,但那需要 async + Store IO,不适合在每次工具返回后同步跑。P_t 已由 curator 从长期偏好合流,覆盖绝大多数场景。
  • 同步路径显式 raise NotImplementedError。全链路 async 是硬约束,静默 pass-through 会绕过整个控制面。
  • 18-2 §4 的「P0 bad case 自动生成 Hook 规则」未落地——它依赖 app/security/,仓库里没有这个包。P1/P2 路径走 SFT/RL,本项目范围外(无 GPU)。
  • 17-1 §2.2 提到的 pre_fork Hook 没有实现。fork 的上下文隔离已由 ContextVar + enter_fork() 保证,再加一个 Hook 点是过度设计。