Agent Harness 从用到会造:把工具接上,只是开始负责

0 阅读1分钟

这是《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 线:拦在 transport 1 组(超时)、service 1 组(工具名不存在)、validate 1 组(参数类型不对)、exec 2 组(执行抛错、写一半挂掉),可定位项 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 没有任何一段接住。

六组故障

台架造六组故障,每组在两条线上各跑一遍:

  1. 超时:传输段失败。
  2. 工具名不存在:服务段失败。
  3. 参数类型不对:校验段失败。
  4. 执行抛错:执行段失败。
  5. 写一半挂掉:执行段失败,且有副作用。
  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 上,看一个完整的工具调用从发起到回滚,中间到底经过了哪些代码。