Agent Harness 从用到会造:把工具接上,只是开始负责
先给结论「把工具接上」这件事行业已经标准化了一半。现在有一条现成的路把工具做成 MCP server客户端把工具清单递给模型模型提出调用客户端把调用发回 server。传输怎么走、工具叫什么名字、参数怎么序列化、返回怎么塞回上下文——这一整段有协议管着你不用自己想。但一次工具调用实际要走的路比这长。我把它拆成五段传输 → 服务查到工具→ 参数校验 → 执行 → 回滚MCP 管住的是前两段。后面三段——参数对不对、执行炸了怎么办、改了一半的动作谁来撤销——协议一个字都没替你答应。它只是没有阻止你去写而绝大多数接法也确实没写。这不是猜测我写了个台架把它量出来了同一个工具服务只换「接法」这一个变量六组故障各跑一遍。naive 线不校验、不设超时、不分类、不补偿6/6 组故障没有任何一段接住。调用要么是「成功了但你不知道它是错的」要么是一个原生 Python 异常直接冲出链路。guarded 线五段各加一道判断6/6 组都拦在对应的那一段报错文本里能定位到段名、工具名、字段名或那个坏值。拦得越早越便宜这是可量的。参数类型错那一组guard 在第 2 段就拦下naive 一路走到第 4 段——工具已经真跑起来了——才炸。静默截断是最贵的一种。同一个返回 20000 字的工具naive 给了你 2000 字没有任何标记guard 给 2025 字末尾写着「已截断共 20000 字」。下面把这五段拆开给你同样的台架让你在自己机器上复现。原理为什么接上和负责是两件事先把归因说清楚不然后面全是经验之谈。第一工具不是你代码的一部分它是链路上的一跳。你在本地写一个函数调用它要么返回要么抛异常栈里都看得到。工具不一样它跨了进程、可能跨了机器、可能跨了网络中间还有一层模型在替你决定「调哪个、带什么参数」。这中间任何一处出错错误都不会自动长成你熟悉的样子。第二协议能标准化的只有「形状」管不了「责任」。协议可以规定工具清单长什么样、调用怎么编码、返回怎么编码。协议很难规定参数里的lines必须是整数它不知道你的业务里lines是什么、超时该设几秒它不知道你的工具多慢、执行到一半挂了要不要撤销它不知道你的工具有没有副作用。这些只有知道业务的人才答得出来——而能答出来的那个人是你。第三拦截点的位置决定了代价。这是本讲最实用的一条。同一个错误在第 3 段被拦下和在执行完之后被发现代价完全不同前者你损失的是「一次没必要的函数调用」后者你损失的是「一次真跑了、可能改了状态的执行 一次人工回滚」。所以 schema 校验的位置不是风格问题它是链路设计的一部分——校验必须在执行之前不在结果之后。第四没有接住的失败比报错更贵。一个调用失败了但没人报错会以「成功」的形状流进下一步你以为拿到了摘要其实拿到的是截断过的半份数据你以为那个文件写好了其实写了一半。这类失败不会让你停下来它会让你在更远的地方停下来。拆开看五段各自的坏法这五段是我按工程直觉划的不是协议里的分层。它是一张归因表出问题的时候你能一眼指出该改哪一段而不是笼统地说「工具不太好用」。段谁负责典型坏法拦在这里的代价1 传输协议 客户端对方不回、回太慢、回来的东西不是你要的格式最低什么业务动作都还没发生2 服务协议 你工具名写错、版本对不上、清单里根本没有这个工具低只白跑了一次参数序列化3 参数校验你类型不对、必填没给、枚举超范围、多传了字段低工具还没被调用4 执行你抛异常、部分成功、返回被截断高副作用可能已经落地5 回滚你没记「怎么撤销」于是只能人工收拾最高已经晚了只能补救看这一列就明白为什么我说「MCP 只标准化了前两段」协议覆盖的、以及协议给你接口但默认不做的全在上面两行下面三行的责任人那一栏写的都是「你」。动手做一个能跑的对照台架脚本在仓库里纯本地、确定性、不调模型几秒钟跑完python3 tools/harness_lab/tool_chain_lab.py # 全量6 故障 × 2 线 阳性对照 python3 tools/harness_lab/tool_chain_lab.py --selftest # 只跑阳性对照确认台架没坏台架做的事很简单同一个工具服务head/slow_head/dump/append_note四个工具接两条线。naive线就是一个正常开发者会写的接法——直接调取不到工具就让它抛参数不检查没有超时没有 try不记补偿def naive_call(tool: str, args: dict) - Result: tool, args _wire(tool, args) # 1 传输 if tool not in TOOLS: # 2 服务取不到就抛 raise KeyError(f{tool}工具清单里没有这个名字) out TOOLS[tool][fn](**args) # 3、4 没有校验、没有 try、没有分类 r.value out[:MAX_CHARS] if isinstance(out, str) else out # 截断但不说话 return r上面省掉了台架自己的记账代码——记录「出事前走到第几段」那几行跟本讲要看的接法无关。guarded线在五段上各加一道判断。核心是第 3 段——校验必须在执行之前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动手做的重点不在写代码在六组故障。每组只改一个变量组造的故障想看到的timeout工具自己睡 2 秒有没有「预算」这个概念还是傻等unknown_tool调用read_head清单里只有head名字错在几段被发现bad_paramslinesthree要整数校验在不在执行之前exec_throw把一个目录当文件读异常有没有被分类成人话silent_truncate工具返回 20000 字截断说不说partial_write写一行然后故意抛错改了一半谁来撤运行后你会先看到一行阳性对照阳性对照 cleannaiveOK guardedOK ⇒ 台架可用它是防呆用的干净调用两条线都必须拿到结果。如果这里出现 BAD说明台架本身坏了下面的数不许看——先修台架再谈结论。这是我上一讲学到的规矩任何机械判定的实验第一行必须是阳性对照。实测结果同一个服务只换接法12 行结果压成 6 行左边是 naive右边是 guarded故障naiveguardedtimeout等了2.0 秒拿到结果没有超时概念0.5 秒放弃等待拦在transportunknown_toolKeyError冲出链路拦在service报出「最接近的是 head」bad_paramsTypeError冲出链路走到第 4 段拦在validate走到第 2 段exec_throwIsADirectoryError冲出链路拦在exec带工具名 参数回显silent_truncate2000 字无标记2025 字带「已截断共 20000 字」partial_write文件真被写进去了留下副作用拦在exec已回滚无副作用三条要读出来的东西原始 12 行、每格的判据、以及正文引用的外部证据回源位置都在data/harness-l02-tool-chain.md一、naive 的 6 组没有一组是被「接住」的。它在四种情况下抛的是原生异常KeyError、TypeError、IsADirectoryError、RuntimeError。这些异常会冲出调用栈落到调用它的那层代码上——如果那层是模型在驱动的循环它拿到的往往只是一句「工具调用失败」。定位项统计上naive 五组是 0。二、guard 的拦截位置由故障类型决定不是随便找了个地方报错。名字错 →service类型错 →validate执行错 →exec超时 →transport。这条对应关系本身就是价值你从错误文本的第一个方括号就知道该去哪一段改。三、bad_params那一行是「越靠前越便宜」的量化。同一个故障guard 只让请求走到第 2 段naive 走到了第 4 段——差的两段是「参数校验」和「工具真被执行」。在真实系统里第 4 段的代价不是两段代码是「工具真的动了你的生产数据」。再看错误文本差别更直白naive TypeError: list indices must be integers or slices, not str guarded [validate] head.lines 类型不对要 int给的是 strthree第二行同时回答了三个问题错在哪一段validate、错在哪个工具的哪个字段head.lines、错成什么样给了 three。第一行只回答了「Python 不高兴」。这就是「有人接住」和「有人负责」的差别。为什么 MCP 只解决了前两段这部分我不重跑实验直接引旧线的两组数原台架在tools/boundary_lab/结论我回源核过。第一组谁拦得住越界读。8 个臂里只有E_guard_on——也就是我自己的执行器里那段路径校验打开的时候——把 6 次越界读全部挡住0/6 读到。其余六个有记录的臂全部6/6 读到包括这几个客户端诚实地声明了当前的项目根目录客户端声明了一个假的根目录file:///etc客户端根本不支持roots 这个能力server 那边直接报List roots not supported。三行一起看结论很硬拦住越界的不是协议是我自己写的那个startswith(ROOT)。协议把「声明根目录」的接口放在那儿了但声明是声明执行是执行——协议没有替你把判断做掉。第二组MCP 有没有顺手帮你省了上下文。同一个「读文件」任务MCP 臂 20/20 成功进主上下文的净载荷 10,544 字节和直接读那一臂逐位相同但协议加执行层的原始流量是 65,400 字节直读那臂也是 10,544墙钟 10.48 秒。翻译成人话MCP 带来了协议开销但一个字节的上下文都没帮你省。省不省上下文是你在第 3、4 段的选择要不要摘要、要不要截断、给多少不是协议自带的功能。把这两组数和本讲的五段拼起来就是那句判断MCP 标准化的是「怎么把工具递到模型面前」不是「工具坏了谁负责」。前两段你已经站在别人的肩膀上后三段你站在空地上而且没人提醒你脚下是空的。静默失败比报错更贵silent_truncate那一组是我这轮最想做实的naive 拿到 2000 字肉眼完全看不出它是被截断的——它是一个合法的字符串长度也对挑不出毛病。旧线还有一组更狠的数据8 组臂里有 6 组是「往 stdout 里多打了点东西」另两组是干净对照、stderr 对照其中只有 2 组把调用真的打死了half_line半行、fake_id_match假帧顶替另外 4 组污染被客户端静默吞掉、调用照常返回——污染发生了但你没有任何渠道知道。而这 2 组打死调用的臂报错文本里「能不能定位到污染源」这一项都是 false一组是「等待 8.0 秒超时」另一组报的是客户端自己的校验错误。两组合起来是一条经验失败的可定位性和失败本身同等重要。报错文本里如果只有「超时」和「ValidationError」你接下来要做的是重新做一遍实验如果里面有「哪一段 / 哪个工具 / 哪个字段 / 那个坏值」你接下来要做的是改一个地方。这两种体验的差距全在你接住失败的那几行代码里。三条能直接抄走的写法一、参数校验放在执行之前而且报错要带字段名和那个坏值。别等工具自己抛错。一份 schema 声明类型、必填、范围、长度 一个循环就能把第 4 段的代价挪到第 3 段。报错文本按这个顺序写[段名] 工具.字段 错在哪要什么给的是什么。二、每个工具都要有个预算。超时不是「防慢」的保险它是链路的一段到点了就必须有一条明确的错误而不是继续等。这一轮里 naive 那 2.0 秒很说明问题——它没有超时它只是等到了工具自己跑完而真实世界的工具不会这么客气。三、有副作用的工具先把「怎么撤销」记下来再动手。顺序不能反执行前把当前状态存一份哪怕只是一段文本执行挂了就恢复恢复不了至少要在报错里说清楚「可能留下了什么」。这一条在partial_write那组是唯一一次真实差别naive 那一边文件真被改了、只能人工收拾guard 那一边恢复原状。顺带一条取舍这五段不值得在所有工具上平均用力。一个只读的head第 5 段回滚可以直接不做因为无状态可撤一个会写文件、会发请求、会扣额度的工具必须做。判断依据只有一条这个工具失败之后世界上有没有什么东西变了。没变就省掉变了就不能省。常见坑把「有 try/except」当成「有人负责」。兜底很容易try: ... except Exception: return 失败一行就够。但这一轮里最有价值的一半结论恰恰是接住之后能不能定位才是分水岭。一个不带分类和字段信息的失败只是把静默失败从工具挪到了你的链路里。把超时设成一个「够大就行」的数。我这轮把 guard 的预算设成 0.5 秒slow_head睡了 2 秒于是它被拦住了——但这个拦截给的是「预算」不是「确定性」。把预算放宽到 5 秒同一组故障就不再被拦。超时的作用是把「无限等」变成「有上限地等」不是保证失败一定会被发现别把它写成保证。以为协议会兜住执行段。上面那 8 个臂已经说清楚了客户端可以声明根目录、也可以不支持根目录而越界读在两边都是 6/6。看到「协议里有这个字段」就等于「责任被标准化了」是这一步最贵的误判。在测试里只验证「调用成功」。我这轮如果只看success这一个布尔值6 组故障里timeout和silent_truncate两组都会被判成通过——一组真的成功但慢了四倍一组成功但数据缺了十分之九。验收要落在「返回的形状」上长度、有没有标记、耗时在不在预算内。这一条会直接接到后面讲验收的那一讲。把台架的「传输」当成真网络。这份脚本里传输段是一次同进程的线程调用 一次 JSON 往返不是真 socket。这么做是为了让超时、截断、异常这三个自变量可控真网络会多出一层连接错误、DNS、证书失败但它们都落在第 1 段五段的责任划分不变。你要拿去量自己的链路把传输段换成你的真实客户端即可后面四段的判据都不用改。把这张五段表当标准。它是一张归因表不是一个协议分层。别的实现划成四段或六段都行——有用的是「出错时能指到唯一一段」这个性质不是「必须是五」。课后思考题五段里哪一段最容易「静默失败」你怎么让它出声——提示想一想哪一段的结果是「合法的只是不完整」。如果你只能保住一段你保哪一段——提示先问你的工具里哪些会改变世界上别的东西。你手头正在用的工具里挑一个「失败之后世界上有东西变了」的它的撤销路径现在写在哪一段——提示如果答案在「我的脑子里」那它就还没写。三道题都不给答案但都指回同一件事你要观察的是链路不是「这个工具好不好用」。最后说一句技术成长不只是写代码职业规划和自我包装同样重要。我整理了一份简历、面试和职业规划的学习资料适合想在职场上走得更远的朋友看看。资料展示下面是我整理的AI大模型学习资料和工具包预览适合收藏后按主题逐步学习。如果你想看完整资料目录可以在评论区留言「资料」也欢迎告诉我你更关注AI大模型里的哪类内容。