这是《Agent Harness 从用到会造》第 2 讲。
上一讲我们把工具接上了。接上之后,很多人会松一口气:模型能调工具了,链路通了,demo 跑起来了。这一讲要泼一盆冷水——接上只是第一段,后面还有四段,每一段都有自己的坏法,而失败没人接是大多数工具事故的共同根因。
先把这一讲的判断摆在最前面,后面所有内容都是围绕它展开的:
工具调用是一条五段链路:传输 → 服务 → 参数校验 → 执行 → 回滚。MCP 只把前两段标准化了,责任没有跟着标准化。「接上工具」完成的只是第一段,后面四段里每一段都有自己的坏法,而失败没人接,是大多数工具事故的共同根因。
这句话里有两个容易被忽略的点。第一,MCP 标准化的是「怎么把请求送过去、怎么把工具暴露出来」,也就是传输和服务这两段。它没有规定参数校验该谁做、执行抛错该谁接、写了一半挂掉该谁回滚。第二,责任不会因为协议标准化就自动分配——协议给了接口,接不接、怎么接,是写 harness 的人自己的事。
先给结论:接上 ≠ 能用
如果你只从这一讲带走一句话,就带走这句:接上工具,只说明传输和服务这两段通了;后面三段——校验、执行、回滚——协议不管,得你自己管。
这句话不是修辞,它有量化含义。同一个工具服务,接两条线:一条 naive(不做任何额外处理),一条 guarded(每一段都加拦截)。六组故障各跑一遍,机械记录「拦在哪一段 / 有没有人报错 / 报错能不能定位 / 留没留副作用 / 走到第几段」。
结果是这样的:
- naive 线:6/6 组故障
caught_at=none、reporter=nobody——没有任何一段接住。其中 5/6 组的报错文本里可定位项 = 0,也就是说报错了,但你从报错里看不出是哪一段、哪个参数、哪个工具出的问题。partial_write组还留下了副作用——state/tool_chain_note.txt真被写了。 - guarded 线:拦在
transport1 组(超时)、service1 组(工具名不存在)、validate1 组(参数类型不对)、exec2 组(执行抛错、写一半挂掉),可定位项 2–4 个;exec那组带回滚。
同一个工具服务,只换接法,6 组故障里有 5 组从「冲出链路、没人接」变成「拦在对应那一段」,剩下 1 组(静默截断)从「静默截断」变成「带截断标记的截断」——它不是错误,所以不报错,但它不再骗人。这就是「接上 ≠ 能用」的量化含义。下面我们把这五段拆开,一段一段看它怎么坏、怎么接。
原理:五段链路,只有前两段被协议管住
五段链路是怎么划出来的
先声明一件事:这五段是我按工程直觉划的,不是协议里的分层。它是一张归因表,不是一个标准。别的实现完全可以合成四段或六段。之所以划成五段,是因为这五段各自有独立的失败模式,而且失败之后能定位到具体是哪一段出的问题。
- 传输(transport):请求从客户端到服务端这一段。失败模式是超时、连接断开、DNS 解析不了、TLS 握手失败。
- 服务(service):服务端收到请求之后,找到对应的工具。失败模式是工具名不存在、工具没注册、版本对不上。
- 参数校验(validate):拿到参数之后,检查参数是否符合 schema。失败模式是类型不对、必填项缺失、枚举值越界。
- 执行(exec):真正跑工具的逻辑。失败模式是执行抛错、写一半挂掉、外部依赖挂了。
- 回滚(rollback):如果执行有副作用,出问题之后要撤销。失败模式是根本没记怎么撤销、或者撤销本身也失败了。
MCP 管住了前两段。它规定了工具怎么暴露(tools/list)、怎么调用(tools/call)、请求怎么传(JSON-RPC over stdio 或 HTTP)。但它没有规定参数校验该在客户端做还是服务端做,没有规定执行抛错该怎么包装,更没有规定有副作用的工具该怎么回滚。
为什么是这五段,而不是三段或七段
你可能会问:凭什么划五段?划三段(请求、执行、返回)不行吗?
不行。因为划段的目的不是描述流程,而是归因。当一次工具调用出问题时,你要能一眼看出「这是哪一段坏的」。三段太粗——「执行」这一段里既有参数校验又有真跑逻辑,出了问题你分不清是参数错了还是逻辑错了。七段太细——把「序列化」「反序列化」都单列出来,归因时反而找不到重点。
五段的分界点是:每一段都有一个独立的、可被单独拦截的失败模式。 传输段能单独拦超时,服务段能单独拦工具名,校验段能单独拦参数,执行段能单独拦抛错,回滚段能单独拦副作用。这五段之间没有重叠,也没有遗漏。
再强调一次:这是一张归因表,不是标准。你的实现如果合成四段,只要每一段都能独立归因,就没问题。
接上 ≠ 能用
这是这一讲最核心的一句话。接上工具,只说明传输和服务这两段通了。后面三段——校验、执行、回滚——协议不管,得你自己管。
很多人对「接上」的理解是:模型能发出 tools/call,服务端能收到,能返回结果。这只是第一段和第二段。真正的「能用」是:参数错了能被拦下、执行挂了能被接住、写了一半能被撤销。这三件事协议都不管。
schema 校验发生在执行之前:拦截点越靠前越便宜
第二段原理是:schema 校验发生在执行之前,拦截点越靠前越便宜。 越早拦,已经白干的工作越少。
这个「便宜」是可以量出来的。看 bad_params 这一组故障——参数类型不对。guard 在 validate 段拦下,请求只走到第 2 段。naive 一路走到第 4 段,工具真跑起来了才炸。同一组故障,2 段 vs 4 段。
为什么这很重要?因为第 4 段是执行段。执行段可能已经读了文件、调了外部 API、写了数据库。你在第 2 段拦下,这些都没发生;你在第 4 段才炸,这些可能已经发生了,还得回滚。拦截点越靠前,需要撤销的东西越少。
失败没人接,是大多数工具事故的共同根因
第三段原理:失败没人接,是大多数工具事故的共同根因;而「静默成功」比报错更贵。
报错至少还出声了。静默成功是——调用返回了,看起来一切正常,但结果是错的、或者被截断了、或者被污染了,而没有任何标记告诉你。
看静默截断这一组:dump(n=20000)。naive 返回 2000 字,没有任何截断标记。调用方拿到 2000 字,以为这就是全部。guarded 返回 2025 字,带 …[已截断:共 20000 字,只给了 2000]。多出来的 25 个字就是那行标记,但它让调用方知道结果不完整。
再看旧实验里静默失败的那一半:data/stdout-tax-evidence.md §四的 8 组臂里,只有 2 组(half_line、fake_id_match)把调用打死,这 2 组的「可定位」列都是否;另外 4 组污染被客户端静默吞掉、调用照常返回。也就是说,8 组里有 4 组是「看起来成功、实际被污染」,而且没有任何标记。
回滚不是可选项
第四段原理:有改动的工具必须先把「怎么撤销」记下来,再动手。
partial_write 组就是例子。naive 线留下副作用——state/tool_chain_note.txt 真被写了,而且没人回滚。guarded 线在 exec 段拦下,带回滚,文件被撤销干净。
回滚为什么必须是链路的一段?因为执行段不可能保证 100% 成功。外部依赖会挂、进程会被 kill、磁盘会满。只要工具有副作用,就必须先想好怎么撤销,再动手。这不是可选项。
MCP 那一半:协议没把责任标准化
有人会说:MCP 不是已经标准化了吗?协议会管这些吧?
我们用旧实验的数据回答。data/boundary-tax-results.json 里 8 臂,E_guard_on(我自己的 exec.py 路径校验开着)= 0/6 越界读;其余 6 个有 readable 的臂全部 6/6 读到——包括 B_fake_root_etc(客户端声明 root=file:///etc)、C_no_roots_capability(客户端根本不支持 roots)、D_cwd_tmp_true_root、F_guard_off、G_bash_blacklist。第 8 臂 H_own_guard_probes 只是探针记录,无 readable 字段。
结论很清楚:拦住越界的是我自己的代码,不是协议。 协议给了 roots 这个接口,但客户端可以选择不接、可以声明假的 root、可以根本不支持。责任没有跟着标准化。
还有一条:MCP 不省上下文。data/boundary-valve-evidence.md §三 里,C 臂(MCP)20/20 成功、进主上下文 10,544 B,与 A 臂(直读)逐位相同;协议/执行 raw 65,400 B、墙钟 10.48 s。也就是说,MCP 把传输和服务标准化了,但上下文该进多少还是进多少,没有因为用了协议就变少。
拆开看:每一段的失败长什么样
原理讲完了,现在把五段拆开,一段一段看它的失败长什么样。这一段是给你建立「归因直觉」用的——以后线上出问题,你要能一眼看出是哪一段。
传输段:超时是最常见的坏法
传输段的失败,最典型的是超时。请求发出去了,服务端没在预算内回。这时候 naive 线的表现是:调用方一直等,等到外层超时,抛一个「timeout」出来,但你看不出是网络慢、服务端卡、还是工具本身跑太久。
guarded 线的做法是:在传输段设一个明确的预算(比如 0.5s),超了就拦在 transport,报错里带上「请求已发出、等待 X 秒未回」。这样你一眼就知道是传输段的问题,不用去猜。
注意这里有个坑:传输段的拦截给的是「预算」,不是「确定性」。 我们后面会讲,把超时从 0.5s 改成 5s,同一组故障就不再被拦了。所以不能写成「一定会拦住」,只能说「在给定预算内拦住」。
服务段:工具名不存在
服务段的失败,最典型的是工具名不存在。客户端发了一个 tools/call,服务端找不到这个名字的工具。naive 线的表现是:服务端返回一个错误,但错误文本里可能只有一句「unknown tool」,没有告诉你客户端发的是什么名字、服务端注册了哪些名字。
guarded 线的做法是:在服务段拦下,报错里带上「客户端请求的工具名 = X,服务端已注册的工具名 = [A, B, C]」。这样你一眼就知道是名字拼错了还是没注册。
校验段:参数类型不对
校验段的失败,最典型的是参数类型不对。schema 说这个参数是整数,客户端传了个字符串。naive 线的表现是:一路走到执行段,工具真跑起来了,跑到一半发现类型不对,炸了。这时候可能已经读了文件、调了 API。
guarded 线的做法是:在 validate 段拦下,请求只走到第 2 段。报错里带上「参数 X 期望 integer,实际 string」。这就是「越靠前越便宜」——同一组故障,2 段 vs 4 段。
执行段:抛错和写一半挂掉
执行段的失败有两种。一种是抛错——工具逻辑本身有问题,抛了个异常出来。另一种是写一半挂掉——工具开始写文件/写数据库,写到一半进程被 kill 了,留下一个半成品。
naive 线对这两种都不接。抛错就抛错,写一半就写一半。partial_write 组就是例子——state/tool_chain_note.txt 真被写了,而且没人回滚。
guarded 线的做法是:抛错接住,报错里带上「工具 X 执行到第 N 步抛错」;写一半挂掉的话,触发回滚,把半成品撤销掉。
回滚段:撤销本身也会失败
回滚段的失败,是撤销本身也失败了。比如你想删掉一个写了一半的文件,结果文件被别的进程锁住了,删不掉。这时候 naive 线和 guarded 线都可能出问题——guarded 线虽然触发了回滚,但回滚本身失败了。
这一段的坏法最隐蔽,因为它发生在「已经出问题之后」。很多人以为触发了回滚就万事大吉,其实回滚本身也需要被监控。
动手做:写一个故意失败的 server,看拦截发生在哪一段
原理讲完了,现在动手。这一讲的产物是 tools/harness_lab/tool_chain_lab.py——同一个工具服务接两条线(naive / guarded),六组故障各跑一遍,机械记录「拦在哪一段 / 有没有人报错 / 报错能不能定位 / 留没留副作用 / 走到第几段」。
旧台架 tools/boundary_lab/ 复现 MCP 那一半,这一讲不重复实验,只引用。
环境准备
台架是纯本地的,不调模型、不联网、不需要 API key。你只需要 Python 3.8+ 和一个能写文件的目录。
# 确认 Python 版本
python3 --version
# 预期输出:Python 3.8.x 或更高
# 台架要往这个目录写「副作用」检查用的临时文件,先建好
mkdir -p state
(台架源码在仓库里,不用 clone;下面所有命令都在仓库根目录执行。)
跑起来
# 纯本地、确定性、不调模型,约 3 秒跑完
python3 tools/harness_lab/tool_chain_lab.py
# 只跑阳性对照
python3 tools/harness_lab/tool_chain_lab.py --selftest
先看阳性对照两行——干净调用两条线都必须拿到结果。如果出现「阳性对照 BAD」,说明台架坏了,下面的数不许看。这是台架的自检,不是可选项。
预期:naive 6/6 没有任何一段接住。
六组故障
台架造六组故障,每组在两条线上各跑一遍:
- 超时:传输段失败。
- 工具名不存在:服务段失败。
- 参数类型不对:校验段失败。
- 执行抛错:执行段失败。
- 写一半挂掉:执行段失败,且有副作用。
- 静默截断:
dump(n=20000),返回被截断但不标记。
每组记录几个字段:caught_at(拦在哪一段)、reporter(谁把失败变成人话:chain / tool / nobody)、locatable_n(报错文本里能定位到几个线索:段名 / 参数名 / 工具名 / 那个坏值)、partial_effect(有没有留下改了一半的副作用,真去检查临时文件)、stages_entered(出事之前已经走到第几段——这就是这次拦截有多贵)。
看结果
跑完之后,先看阳性对照两行。然后看 12 行故障结果(6 故障 × 2 线)。
naive 线的预期是:6/6 组 caught_at=none、reporter=nobody,其中 5/6 组可定位项 = 0,partial_write 组留下副作用。
guarded 线的预期是:拦在 transport 1 组、service 1 组、validate 1 组、exec 2 组,可定位项 2–4 个,exec 那组带回滚。
自己补一段新实验
台架是现成的,但这一讲要求你补一段新实验。建议方向:给 naive 线补一个全局 try/except,再跑一遍六组故障,看可定位项有没有变化。
这里提前说一个会推翻本讲判断的现象:给 naive 线补一个全局 try/except 之后,6 组的可定位项仍然 ≤1。 这说明「接住」不难,难的是「接住之后还能定位」。如果这个现象成立,本讲的判断要改成「分类能力才是核心」,而不是「有没有 try」。
还有一个会推翻的现象:guard 线的超时改成 0.5s→5s 后,同一组故障不再被拦。 这说明这里的拦截给的是「预算」,不是「确定性」,不能写成「一定会拦住」。
最后一个:MCP 的某个客户端实现真的按 roots 拒绝了越界读(例如某版本 Claude Desktop)。如果这个现象成立,「协议不管执行段」这句要收回一半,改成「协议给了接口,客户端可以选择接」。
这三个现象都不是本讲的结论,而是本讲结论的边界。写教程要敢下判断,但也要把判断的边界说清楚。
代码骨架
下面是台架的骨架,照着跑得起来。完整版在 tools/harness_lab/tool_chain_lab.py。
先看结果对象和五段链路的定义——注意链路第五段叫 compensate(补偿),不是 rollback:回滚是它要做的事,段名说的是「这一段负责收拾」。
# tools/harness_lab/tool_chain_lab.py(节选)
import json
MAX_CHARS = 2000 # 链路的返回长度上限
NAIVE_DEADLINE = None # naive 不设超时
GUARDED_DEADLINE = 0.5 # guarded 的每次调用预算(秒)
STAGES = ["transport", "service", "validate", "exec", "compensate"]
class Result:
def __init__(self) -> None:
self.caught_at: str | None = None # 在哪一段被终止;None = 冲出链路,没人接
self.reporter = "nobody" # 谁把失败变成人话:chain / tool / nobody
self.error_text = ""
self.value: object = None
self.truncated = False
naive 线长这样:五段一段都不查,工具名不存在就在服务段直接抛 KeyError,参数类型不对一路走到执行段才炸,工具真跑了才抛错,结果超长直接截断还不说话:
def naive_call(tool: str, args: dict) -> Result:
"""一个正常开发者会写的接法:没有校验、没有超时、没有分类、没有补偿。"""
r = Result()
r.stages_entered = 1
tool, args = _wire(tool, args) # 1 传输
if tool not in TOOLS: # 2 服务:直接取,取不到就炸
e = KeyError(f"{tool}(工具清单里没有这个名字)")
e._l02_stages = 1 # 出事前已走过的段数
raise e
fn = TOOLS[tool]["fn"]
r.stages_entered = 2
try:
out = fn(**args) # 3、4 没有校验、没有 try、没有分类
except Exception as e: # 原样往上抛(这就是 naive 的坏法)
e._l02_stages = 4
raise
r.stages_entered = 4
r.value = out[:MAX_CHARS] if isinstance(out, str) else out # 截断,但不说话
return r
guarded 线是同一份工具服务,逐段加判断:
def guarded_call(tool: str, args: dict, deadline: float = GUARDED_DEADLINE) -> Result:
"""五段各有一道判断的接法。"""
r = Result()
try:
tool, args = _wire(tool, args) # 1 传输
if tool not in TOOLS: # 2 服务
near = [k for k in TOOLS if k.startswith(tool[:3]) or tool[:3] in k]
r.caught_at, r.reporter = "service", "chain"
r.error_text = f"[service] 没有这个工具:{tool}" + (f";最接近的是 {near}" if near else "")
return r
spec = TOOLS[tool]
schema = spec["schema"] # 3 参数校验
for key, value in args.items():
if key not in schema:
r.caught_at, r.reporter = "validate", "chain"
r.error_text = f"[validate] {tool}.{key} 不是它的参数;可用:{sorted(schema)}"
return r
want = schema[key]["type"]
# 注:Python 里 True 也是 int,这里必须显式挡掉——这就是「schema 要写全」
if not isinstance(value, want) or (want is int and isinstance(value, bool)):
r.caught_at, r.reporter = "validate", "chain"
r.error_text = (f"[validate] {tool}.{key} 类型不对:要 "
f"{getattr(want, '__name__', want)},给的是 {type(value).__name__}={value!r}")
return r
undo_had = NOTE_FILE.exists() # 5 补偿:先记账再动手
undo_txt = NOTE_FILE.read_text(encoding="utf-8") if undo_had else None
mutating = spec["mutating"]
try: # 4 执行
in_time, value = _call_in_thread(tool, args, deadline)
if not in_time:
r.caught_at, r.reporter = "transport", "chain"
r.error_text = (f"[transport] {tool} 超过 {deadline}s 预算,已放弃等待"
f"(工具还在跑,是它没被拦住)")
if mutating:
_rollback(undo_had, undo_txt)
r.error_text += ";已回滚"
return r
except Exception as e: # 执行段:把异常变成分类错误
if mutating:
_rollback(undo_had, undo_txt)
r.caught_at, r.reporter = "exec", "chain"
r.error_text = (f"[exec] {tool} 跑挂了:{type(e).__name__}: {e}"
f";参数={json.dumps(args, ensure_ascii=False)[:80]}"
f";{'已回滚' if mutating else '无副作用'}")
return r
if isinstance(value, str) and len(value) > MAX_CHARS:
r.value = value[:MAX_CHARS] + f"…[已截断:共 {len(value)} 字,只给了 {MAX_CHARS}]"
else:
r.value = value
return r
except Exception as e: # 兜底线:链路自己出错也要带段信息
r.caught_at, r.reporter = r.caught_at or "transport", "chain"
r.error_text = f"[chain-internal] {type(e).__name__}: {e}"
return r
两段代码放一起看,差异就一句话:naive 把失败原样往上抛,guarded 把失败变成「哪一段、哪个参数、哪个工具、什么值」的人话。 这就是「接住」和「接得住」的差别。
这段骨架能跑,但它是简化版。完整版在 tools/harness_lab/tool_chain_lab.py,带阳性对照、带 --selftest、带更细的记录字段。
阳性对照
阳性对照是台架的自检。干净调用两条线都必须拿到结果——naive 和 guarded 都拿到 55 字结果。如果阳性对照挂了,说明台架本身有问题,下面的数不许看。
这一步不能省。很多人在台架上跑出「6/6 都没接住」就兴奋了,结果发现是台架自己坏了。阳性对照就是防这个的。
实测结果:12 行原始值怎么读
跑完台架,你会拿到 12 行结果(6 故障 × 2 线)。这 12 行怎么读,决定了你能不能从实验里学到东西。
先看阳性对照
阳性对照是两行:naive 干净调用、guarded 干净调用。两行都必须拿到 55 字结果。如果哪一行是 BAD,台架坏了,下面的 12 行不许看。
为什么是 55 字?因为干净调用的返回是固定的,55 字是它的长度。这个数字本身不重要,重要的是「两条线拿到一样的结果」——如果 guarded 线在干净调用上就拦了,说明拦截加过头了,把正常调用也拦了。
再看 naive 线
naive 线的 6 行,预期是全部 caught_at=none、reporter=nobody。其中 5 行的 locatable_n=0,1 行(partial_write)的 partial_effect=true。
locatable_n=0 的意思是:报错文本里没有任何可定位的信息。你拿到这个报错,不知道是哪一段、哪个参数、哪个工具出的问题。这就是「失败没人接」的具体表现——不是没报错,是报错了但没用。
partial_effect=true 的意思是:副作用留下了。state/tool_chain_note.txt 真被写了,而且没人回滚。
再看 guarded 线
guarded 线的 6 行,预期是拦在四段:transport 1 组、service 1 组、validate 1 组、exec 2 组;剩下 1 组(silent_truncate)不是错误而是标记,caught_at 仍是 none,但返回值里带了「已截断」三个字。locatable_n 在 2–4 之间。exec 那组(partial_write)的 partial_effect=false,说明回滚生效了。
注意 bad_params 这一行的 stages_entered=2。这是「越靠前越便宜」的直接证据——同一组故障,naive 走到第 4 段,guarded 只走到第 2 段。
对比两条线
把两条线并排看,你会发现:同一个工具服务,只换接法,6/6 组故障从「没人接」变成「拦在对应那一段」。这就是这一讲的核心判断的实验支撑。
怎么判断你做对了
跑完台架,怎么知道你做对了?看三个信号。
信号一:阳性对照两行都是 OK
如果阳性对照有一行是 BAD,说明台架坏了,先修台架,别看下面的数。这是最基本的自检。
信号二:naive 线 6/6 都是 none
如果 naive 线有任何一行 caught_at 不是 none,说明你的 naive 线其实做了拦截,不是真的 naive。检查一下是不是不小心加了 try/except 或者参数检查。
信号三:guarded 线的 stages_entered 分布
guarded 线的 stages_entered 应该呈现「越靠前的段拦得越多」的分布:transport 和 service 拦下的组,stages_entered 是 1 或 2;validate 拦下的组,stages_entered 是 2;exec 拦下的组,stages_entered 是 4。
如果你看到 validate 拦下的组 stages_entered=4,说明校验加晚了,请求已经走到执行段才被拦。这就是「拦截点越靠前越便宜」的反面教材。
常见坑
这一讲有几个坑,几乎每个人都会踩至少一个。
坑一:把「接上」当成「能用」
最常见的坑。模型能调工具了,demo 跑起来了,就以为完事了。实际上你只完成了第一段和第二段,后面三段全是裸奔。
判断方法:问自己「如果参数错了,谁拦?」「如果执行挂了,谁接?」「如果写了一半,谁撤?」三个问题有一个答不上来,就是踩了这个坑。
坑二:加了 try/except 就以为接住了
第二个坑。给 naive 线补一个全局 try/except,看起来「接住」了,但可定位项仍然 ≤1。也就是说,你接住了错,但接住之后还是不知道错在哪。
判断方法:看 locatable_n 字段。如果它 ≤1,说明你只是接住了,没有分类。接住不难,难的是接住之后还能定位。
坑三:把「预算」当成「确定性」
第三个坑。guard 线的超时设成 0.5s,看起来「一定能拦住超时」。但把超时改成 5s,同一组故障就不再被拦了。这说明拦截给的是「预算」,不是「确定性」。
判断方法:问自己「如果预算放宽,还能拦住吗?」如果答案是「不能」,那你给的是预算,不是确定性。写文档的时候要写清楚,不能写成「一定会拦住」。
坑四:以为协议会帮你管
第四个坑。以为用了 MCP,协议就会帮你管参数校验、执行抛错、回滚。实际上协议只标准化了传输和服务两段,后面三段协议不管。
判断方法:看旧实验数据。8 臂里只有 E_guard_on(我自己的路径校验开着)能挡住越界读,其余 6 个有 readable 的臂全部 6/6 读到。拦住越界的是我自己的代码,不是协议。
坑五:回滚没记就动手
第五个坑。有副作用的工具,没先想好怎么撤销就动手。partial_write 组就是例子——naive 线留下副作用,没人回滚。
判断方法:问自己「这个工具如果写了一半挂掉,怎么撤?」答不上来,就是踩了这个坑。
课后思考题
第一题:五段里哪一段最容易「静默失败」?你怎么让它出声?
提示:静默失败的定义是——调用返回了,看起来正常,但结果是错的、被截断的、被污染的,而没有任何标记。从这一讲的数据看,silent_truncate 和旧实验里那 4 组被静默吞掉的污染,都是静默失败。
让它出声的办法,不是加 try/except——try/except 只能接住「抛出来的错」,接不住「没抛出来的错」。让它出声的办法是在每一段的出口加断言:传输段出口断言「请求确实发出去了」、服务段出口断言「工具确实找到了」、校验段出口断言「参数确实符合 schema」、执行段出口断言「结果确实完整」、回滚段出口断言「副作用确实撤销了」。
断言和 try/except 的区别是:try/except 等错抛出来,断言主动去查。静默失败不抛错,所以只能靠断言。
第二题:如果你只能保住一段,你保哪一段?
提示:这道题没有标准答案,但有一个判断框架——看哪一段的失败最不可逆。
传输段失败,重试就行。服务段失败,改个名字就行。校验段失败,改个参数就行。执行段失败,如果没副作用,重跑就行;如果有副作用,得回滚。回滚段失败,副作用撤不回来,不可逆。
按这个框架,如果只能保一段,保回滚。因为其他四段失败都可以重来,回滚失败不可逆。但这里有个前提:只有有副作用的工具才需要回滚。如果你的工具全是只读的,那保校验段更划算——越靠前越便宜。
这道题的真正价值不是选哪一段,而是让你意识到:「只能保一段」这个约束本身就是错的。 五段链路是一个整体,保一段的意思是其他四段裸奔。正确的做法是每一段都保,只是保的力度可以不同——传输段可以只加重试,回滚段必须加撤销。
下一讲
这一讲我们把五段链路拆开了,也动手跑了一个故意失败的 server,看到了拦截发生在哪一段。下一讲我们会把这条链路接到真实的 harness 上,看一个完整的工具调用从发起到回滚,中间到底经过了哪些代码。
具体来说,下一讲会做三件事:第一,把这一讲的 tool_chain_lab.py 接到一个真实的 harness 上,不再是模拟的故障,而是真实的工具调用;第二,看真实 harness 里这五段分别对应哪些代码,哪些是框架给的、哪些是你自己写的;第三,把「失败没人接」这个根因,落到具体的代码位置上——哪一行该加拦截、哪一行该加断言、哪一行该加回滚。
这一讲的核心判断再重复一遍:工具调用是一条五段链路(传输 → 服务 → 参数校验 → 执行 → 回滚);MCP 只把前两段标准化了,责任没有跟着标准化。「接上工具」完成的只是第一段,后面四段里每一段都有自己的坏法,而失败没人接是大多数工具事故的共同根因。
下一讲我们会把这条链路接到真实的 harness 上,看一个完整的工具调用从发起到回滚,中间到底经过了哪些代码。