Agent Harness 从用到会造:报错的那层,往往不是坏掉的那层

11 阅读1分钟

这是《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 的代码——因为异常就在那儿。它没做错任何事,它只是第一个用到坏数据的人。报错的位置是「受害者位置」,不是「肇事者位置」,这就是拼接结构最贵的地方:它把排查方向指到了错误的方向上。

串联要求的另一件事是:每一层的产出必须能被检验,而且检验失败时要停在这一层。层与层之间的边界不是函数调用,而是接口;接口要有契约,契约要能报错。

接口要能报错,意思是能区分三种结果

一个能报错的接口,至少要能区分三种状态:

  1. 本层正常,向下传递——收到的输入符合契约,产出的输出也符合契约。
  2. 本层违约,就地拦截——我的产出违反了契约,我拒绝往下传,并明确说明「错在我这一层」。
  3. 结构合法但内容可疑——我的产出挑不出毛病,但我知道它可能不该是这样。

第三种状态最容易被漏掉,而它恰恰是定位能力的分水岭。真实系统里,大部分故障不是某层直接崩掉,而是上游悄悄改了点什么,下游勉强兼容,最后在某一层爆出来。如果链路只有「正常 / 异常」两种信号,故障就会一路漂移到最下游才暴露,而最下游的报错对定位毫无帮助。

三种串联方式,其实就是这三种结果的三种实现:

结果barecontractfull
本层正常,向下传递有有有
本层违约,就地拦截无(漂到下游才炸)有(停在出口)有(停在出口)
结构合法但内容可疑无无有(回放回指)

bare 只实现了第一行,contract 实现到第二行,full 三行全有。这就是为什么定位数是 5、15、18——不是加了判断,是逐行补齐了「结果种类」。

一次跑通不等于稳定跑通

第三个原理点关于「跑通」这个词。

一次跑通可能只是运气:换个输入、换个随机种子、换个温度,链路就可能断在完全不同的地方。稳定性不是「每次都成功」,而是「每次失败都能定位到同一层」。

这两件事经常被混为一谈。一个成功率只有六成的 harness,如果它六成的失败全部能准确指向具体层,比一个成功率九成五、失败时只会说「出错了」的 harness 更有工程价值——前者可以修,后者只能猜。

所以本讲要测的指标不是成功率,而是定位率。

拆开看:五个部件各自的输出契约

动手串之前,先把每一层的契约写清楚。契约不是文档,是代码里能执行的检查。后面所有实验都建立在这张表上。

台架用一个购物清单当输入(6 行明细,税率 13%),五层依次是:

层产出主键输出契约违约时
promptitems列表非空,每项含 name(字符串)/qty(整数)/price(浮点)就地拦截,报 prompt
toolraw非空字符串就地拦截,报 tool
contextparsed列表非空,每项 qty/price 是浮点就地拦截,报 context
guardrailchecked含 ok(布尔)/total(浮点)/reasons(列表)就地拦截,报 guardrail
verifyverdict含 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。汇总出来是这样:

串联方式契约类命中契约类定位静默类命中静默类定位静默错结果
bare12/155/153/50/51
contract15/1515/153/50/51
full15/1515/153/53/51

故意在第 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 命中/定位渠道
crash5/5traceback×5
missing4/5traceback×4,None×1
malformed3/5traceback×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@prompt157
boundary@tool157
boundary@context157
boundary@guardrail167
boundary@verify177
anchor+replay@verify158

单点观测最多定位 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 的时候,先列它会怎么坏——超时、参数不匹配、执行失败、静默截断,这四类分别长什么样、各自该在哪一层被接住。你们那边有没有哪一层到现在还是「报不出层」的?评论区说说,我一条条看。