这是《Agent Harness 从用到会造》第 6 讲。
前五讲我们一直在拆零件。prompt 怎么写、tool 怎么接、context 怎么裁、guardrail 怎么设、verify 怎么判——每一层单独拿出来,你都能跑出一个像样的 demo。但真把五个部件装进一个进程里,你会发现一件很反直觉的事:报错的那一层,往往不是坏掉的那层。
具体长什么样。工具调用报了一个参数错误,你去查 tool 层,代码没问题;去查 prompt 层,模板也没问题。问题出在 context 层——它把上一轮的中间结果裁掉了一半,于是下游拿到的是残缺数据,tool 老老实实按契约报错,可错误信息指向 tool,不是 context。
这就是本讲要解决的事。单层能跑不算数;连起来跑通、还能定位是哪一层出的问题,才算 harness。
先给结论:定位能力是结构的内禀属性
在展开细节之前,先把核心判断放在最前面,因为它决定了后面每一段代码为什么那样写:
定位能力由结构决定,不由数据决定。
这不是修辞,它有可测量的含义:在已知哪一层被注入错误的前提下,链路报出的层是否等于真正出错的层,这个比例叫定位率。我们会看到,同一套结构换 12 个随机种子,定位率完全不变,方差为零;而换一套结构,定位率立刻从 5 跳到 15,再跳到 18。
所以如果你现在的 harness 定位不准,第一件要做的事不是调 prompt、不是调温度、不是加重试,而是看结构。结构不改,定位率的天花板就焊死在那里。
原理:串联不是拼接,接口要能报错
拼接和串联的区别
大多数人第一次把五个部件连起来,写出来的是这样的东西:
def run(user_input):
p = build_prompt(user_input)
r = call_tool(p)
c = trim_context(r)
g = check_guardrail(c)
v = verify(g)
return v
这叫拼接:五个函数首尾相接,数据从一头流到另一头。它能跑,happy path 上一切正常。但它有一个致命缺陷——当最下游返回失败时,你无法知道是哪一层导致的。
下游只告诉你「最终结果不对」。是 prompt 没构造好?是 tool 返回了脏数据?是 context 裁剪把关键信息丢了?是 guardrail 误杀?还是 verify 自己的判据太严?这些可能,在拼接结构里长得一模一样:都是一个 False。
拼接结构下这种失败长什么样,值得写具体一点。假设 context 这层少产出了 parsed,拼接链会这样走:guardrail 去取 p["parsed"],抛一个 KeyError;你打开日志,看到的是 guardrail 抛异常、context 那行没有任何异常。于是你的第一反应是去读 guardrail 的代码——因为异常就在那儿。它没做错任何事,它只是第一个用到坏数据的人。报错的位置是「受害者位置」,不是「肇事者位置」,这就是拼接结构最贵的地方:它把排查方向指到了错误的方向上。
串联要求的另一件事是:每一层的产出必须能被检验,而且检验失败时要停在这一层。层与层之间的边界不是函数调用,而是接口;接口要有契约,契约要能报错。
接口要能报错,意思是能区分三种结果
一个能报错的接口,至少要能区分三种状态:
- 本层正常,向下传递——收到的输入符合契约,产出的输出也符合契约。
- 本层违约,就地拦截——我的产出违反了契约,我拒绝往下传,并明确说明「错在我这一层」。
- 结构合法但内容可疑——我的产出挑不出毛病,但我知道它可能不该是这样。
第三种状态最容易被漏掉,而它恰恰是定位能力的分水岭。真实系统里,大部分故障不是某层直接崩掉,而是上游悄悄改了点什么,下游勉强兼容,最后在某一层爆出来。如果链路只有「正常 / 异常」两种信号,故障就会一路漂移到最下游才暴露,而最下游的报错对定位毫无帮助。
三种串联方式,其实就是这三种结果的三种实现:
| 结果 | bare | contract | full |
|---|---|---|---|
| 本层正常,向下传递 | 有 | 有 | 有 |
| 本层违约,就地拦截 | 无(漂到下游才炸) | 有(停在出口) | 有(停在出口) |
| 结构合法但内容可疑 | 无 | 无 | 有(回放回指) |
bare 只实现了第一行,contract 实现到第二行,full 三行全有。这就是为什么定位数是 5、15、18——不是加了判断,是逐行补齐了「结果种类」。
一次跑通不等于稳定跑通
第三个原理点关于「跑通」这个词。
一次跑通可能只是运气:换个输入、换个随机种子、换个温度,链路就可能断在完全不同的地方。稳定性不是「每次都成功」,而是「每次失败都能定位到同一层」。
这两件事经常被混为一谈。一个成功率只有六成的 harness,如果它六成的失败全部能准确指向具体层,比一个成功率九成五、失败时只会说「出错了」的 harness 更有工程价值——前者可以修,后者只能猜。
所以本讲要测的指标不是成功率,而是定位率。
拆开看:五个部件各自的输出契约
动手串之前,先把每一层的契约写清楚。契约不是文档,是代码里能执行的检查。后面所有实验都建立在这张表上。
台架用一个购物清单当输入(6 行明细,税率 13%),五层依次是:
| 层 | 产出主键 | 输出契约 | 违约时 |
|---|---|---|---|
| prompt | items | 列表非空,每项含 name(字符串)/qty(整数)/price(浮点) | 就地拦截,报 prompt |
| tool | raw | 非空字符串 | 就地拦截,报 tool |
| context | parsed | 列表非空,每项 qty/price 是浮点 | 就地拦截,报 context |
| guardrail | checked | 含 ok(布尔)/total(浮点)/reasons(列表) | 就地拦截,报 guardrail |
| verify | verdict | 含 pass(布尔)/mismatches(列表) | 就地拦截,报 verify |
这张表里最该盯的是「契约约束的是结果,不是行为」。context 这一层本来就要裁剪,它裁了不是违约;它裁到产出物缺了字段,才是违约。同理,guardrail 把不合格的行拦下来不是违约,它算出总额才是它的产出物。
动手做:把五个部件串成一条可跑序列
台架长什么样
台架源码在 tools/harness_lab/assemble.py。每层就是一个函数,产出写进同一个 payload 字典;契约集中在一张表里:
# tools/harness_lab/assemble.py(节选)
LAYERS = ["prompt", "tool", "context", "guardrail", "verify"]
CONTRACT = {
"prompt": ("items", lambda v: isinstance(v, list) and len(v) > 0 and all(
isinstance(x, dict) and isinstance(x.get("name"), str)
and isinstance(x.get("qty"), int) and isinstance(x.get("price"), float) for x in v)),
"tool": ("raw", lambda v: isinstance(v, str) and len(v) > 0),
"context": ("parsed", lambda v: isinstance(v, list) and len(v) > 0 and all(
isinstance(x, dict) and isinstance(x.get("qty"), float)
and isinstance(x.get("price"), float) for x in v)),
"guardrail": ("checked", lambda v: isinstance(v, dict) and isinstance(v.get("ok"), bool)
and isinstance(v.get("total"), float) and isinstance(v.get("reasons"), list)),
"verify": ("verdict", lambda v: isinstance(v, dict) and isinstance(v.get("pass"), bool)
and isinstance(v.get("mismatches"), list)),
}
注意契约的形状:每层一个产出主键加一个判据函数。判据只检查结果,不检查过程——这是「契约约束结果」的直接落地。
四类故障,其中一类是故意抓不到的
每层注入四类故障,一层一个:
| 故障类 | 怎么坏 | 性质 |
|---|---|---|
| crash | 这一层自己抛异常 | 契约类 |
| missing | 少产出自己的主键 | 契约类 |
| malformed | 产出类型不对 | 契约类 |
| corrupt | 产出完全合法,只是某个数值被悄悄改了 | 静默类 |
5 层 × 4 类 = 20 个注入点,其中契约类 15 个、静默类 5 个。corrupt 是这里的关键设计:它专治「只做结构校验」的假闸门——结构完全对得上,只有数值不对,任何契约都抓不住它。
三种串联方式
- bare(裸拼):层与层直接调用,谁都不校验自己的产出。
- contract(契约串联):每层产出后立刻按 CONTRACT 校验,坏了当场停。
- full(契约 + 回放):在契约之上,让验收层从锚点(prompt 的原始产出)往下逐段重算,看哪一段对不上。
跑起来
# 复现命令:默认 6 行明细 / 种子 7,20 个注入点 × 3 种串联方式,
# 再用 12 个种子重跑一遍看定位数稳不稳,并把逐点结果与证据底稿写盘
python3 tools/harness_lab/assemble.py --sweep 12 --write-evidence
逐点结果落在 out/harness/L06/assemble.json,证据底稿落在 data/harness-l06-assemble.md。汇总出来是这样:
| 串联方式 | 契约类命中 | 契约类定位 | 静默类命中 | 静默类定位 | 静默错结果 |
|---|---|---|---|---|---|
| bare | 12/15 | 5/15 | 3/5 | 0/5 | 1 |
| contract | 15/15 | 15/15 | 3/5 | 0/5 | 1 |
| full | 15/15 | 15/15 | 3/5 | 3/5 | 1 |
故意在第 3 层注入错误
这是规格里要求的验证:在 context 层制造一次违约,看链路能不能报出「错在 context」。
python3 tools/harness_lab/assemble.py \
--inject context \
--modes bare,contract,full \
--verbose
输出:
注入点:context / missing | 真值总额 800.9553 | 模式 bare,contract,full
bare 报出层=guardrail 渠道=traceback 检出=是 定位=否
contract 报出层=context 渠道=contract 检出=是 定位=是
full 报出层=context 渠道=contract 检出=是 定位=是
这就是本讲标题那件事的现场:坏掉的是 context,裸拼报出来的是 guardrail——错误漂到了下一层,定位失败;带契约和带回放的两种结构都稳稳停在 context。
验证:三条判据
判据一:契约类错误,裸拼 5/15,契约串联 15/15
裸拼为什么只中五个?把逐类渠道摊开就明白了:
| 故障类 | bare 命中/定位 | 渠道 |
|---|---|---|
| crash | 5/5 | traceback×5 |
| missing | 4/5 | traceback×4,None×1 |
| malformed | 3/5 | traceback×3,None×2 |
裸拼唯一全中的,是 crash:谁抛异常,traceback 就指向谁,而那一层恰好就是出错层。这是运气,不是能力——它靠的是「崩得响」。一旦故障变成「少产出」「类型不对」,错误就顺着数据流漂下去:context 少产出 parsed,guardrail 取值时炸,报的是 guardrail。
带契约的串联在每一层出口加一道检查:15 个契约类注入点全部就地停住,定位 15/15。多出来的那十次定位,不是加了十行判断,是加了「在哪停」这个决定。
判据二:静默类错误 full 3/5,剩下的是结构边界
静默类错误是某层不报错、只是悄悄改了点什么。契约类检查对它完全无效——三种结构都是 3/5 命中、0/5 定位(bare 与 contract),因为它们只能发现「总数对不上」,说不出「是哪一层改的」。
只有 full 的回放能回指:从锚点往下逐段重算,第一处对不上的层就是元凶,3/5 定位成功。
那另外两个为什么定位不了?因为它们破坏了比对的基准:
- corrupt 打在被当成锚点的那一层(prompt)——基准本身被改了,回放重算出来的新基准和旧基准一致,找不到分歧点。
- corrupt 打在验收层自己(verify)——它把结论直接抹成「通过」,压根没有可比的结论。
这两个不是 bug,是结构边界:回放能回指的前提是「有一个可信的基准」。要覆盖它们,得给锚点本身加独立副本、给验收层加自检——那是给结构加零件,不是加判断。
顺带看那张表的最后一列:三种结构都有一次「静默错结果」——链路一路走完、验收层返回「通过」,可算出来的总额是错的。这是整张表里最危险的一格:它不报错、不漂移,只是悄悄给出一个错答案。契约类故障全被拦在出口,走不到这一步;能走完的只有 corrupt 这类「结构完全合法、只是数值被改」的故障。能发现总数不对,靠的是对比;能指出是哪一层改的,靠的是回放。这两件事不是一回事。
判据三:12 个种子,定位数零方差
多种子扫描(12 个种子 × 20 个注入点,看定位数稳不稳):
bare_localized 定位 5–5 / 20
contract_localized 定位 15–15 / 20
full_localized 定位 18–18 / 20
同一套结构,换 12 组随机数据,定位数一个格子都没动。方差为零,意味着定位能力不随数据波动——它是结构的函数。
反过来看也成立:如果某个种子下 contract 的定位数变成 13、另一个变成 16,那说明定位能力其实受数据影响,「结构决定论」就被推翻了。实测没有出现这种情况。
附带发现:加观测点救不了定位
顺手测了「只在某一处加检查」的覆盖上限:
| 观测点 | 发现 | 定位 |
|---|---|---|
| boundary@prompt | 15 | 7 |
| boundary@tool | 15 | 7 |
| boundary@context | 15 | 7 |
| boundary@guardrail | 16 | 7 |
| boundary@verify | 17 | 7 |
| anchor+replay@verify | 15 | 8 |
单点观测最多定位 7/20,加锚点回放是 8/20,而把结构搭满是 18/20。定位上限不来自某一个聪明的检查点,来自结构的组合。 想靠「我加个校验就完事」的人,可以先把这张表看一眼。
把契约搬到你自己的系统里
台架的契约只有几行,因为它做了一件容易被忽略的事:把「这一层交出去什么」写成了可执行的判据。 搬到你自己的链路,三步:
第一步,给每层命名产出主键。 问一句「下一层靠什么干活」:靠 items、靠 raw、靠 parsed、靠 checked、靠 verdict。主键说不出来的层,多半是两层之间靠约定在传数据——这种层没有契约可写,因为它自己都不知道自己交出去的是什么。
第二步,为每个主键写判据函数。 只写「类型对不对」是不够的,还要写「内容像不像这一层该产出的东西」。本讲台架的判据偏结构(字段齐不齐、类型对不对),所以 corrupt 那类故障要靠回放才抓得到——这一步就是把「内容对不对」也交给判据的机会。
第三步,把检查放在出口。 入口检查防的是「上游给我坏数据」,出口检查防的是「我交出去坏数据」。真正会漂移的错误都发生在出口。
做完这三步,你会发现一件事:那些原本靠「等它报错再排查」的问题,现在在产生的那一刻就被摁住了,而且日志里直接写着是哪一层。这就是从「能跑」到「能维护」的那一步。
常见坑
坑一:把契约检查写成断言。 assert 违约时直接抛异常,链路中断,你拿到的是一个 traceback,而不是「错在 context」这个结构化结论。契约检查要返回失败结果,不是把程序炸了。
坑二:只在入口检查,不在出口检查。 只查每层收到的东西,漏掉它交出去的东西。而 context 的问题恰恰出在出口——入口是好的,出口被裁坏了。
坑三:把「命中」当成「定位」。 命中是「知道出问题了」,定位是「知道是哪一层出的问题」。静默类故障三种结构都能命中 3/5,定位却是 0/5 和 3/5。只统计命中率的仪表盘,会让你觉得自己已经修好了。
坑四:拿成功率当质量指标。 成功率掩盖定位率。一个成功率九成五但只会说「出错了」的链路,和一个成功率六成但每次都能指到层的链路,返工速度差着量级。
坑五:把台架的数字当成你系统的承诺。 本讲的 15/15 是这条链、这批故障、这套契约下的数字,不是通用保证。换个系统、换批数据都要重测——但结构不变时,数字会跟着结构走,这正是这一讲要证明的那件事。
课后思考题
第一题:你的链路里,哪一层的产出没有契约?
按本讲那张表的形状,逐层问:这一层交出去的东西,主键是什么?判据是什么?答不上来的那一层,就是错误会漂过去的那一层。然后问第二个问题:这一层漂过去的错误,最后是在谁那里炸的?——那才是你平时在日志里看到的那一层。
第二题:造一个 corrupt 类的静默故障,看你的链路能不能定位它。
在某一层改一个数值,不破坏任何结构,看下游报出来的是哪一层。如果只报「总数不对」,说明你只有契约没有回放;如果连总数都看不出来,说明你连发现都做不到。这道题没有标准答案,但有明确的对错判据:报不出层,就是没做到。
下一讲我们讲 MCP server。上一讲说「错误要能被调用方看懂,不能只写进自己的日志」,下一讲就落到具体:写 server 的时候,先列它会怎么坏——超时、参数不匹配、执行失败、静默截断,这四类分别长什么样、各自该在哪一层被接住。你们那边有没有哪一层到现在还是「报不出层」的?评论区说说,我一条条看。