1 minute read

前两篇写 PlanningReAct 的时候,我其实一直没把问题说到底。

Planning 让我看到,计划会错; ReAct 让我看到,闭环会漂;但这两个问题再严重,很多时候都还停留在“想错了”这一级。真正让系统开始变得危险,是模型从“想”走到“做”的那一刻。

因为一旦接上工具,错误就不再只是答错一段话。它会写库,会发消息,会扣费,会改权限,会把一个还算容易重试的问题,变成一个需要补偿、对账、人工收拾残局的问题。

也是从这里开始,我不太愿意再把 ToolUse 理解成“模型多了双手”。更准确一点说,它暴露的是一条更根本的分界线:模型可以继续负责提议,但执行权不能再留在模型那里。

所以这一篇我真正想写的,不是 ToolUse 怎么设计,而是另一个更硬的问题:在零信任的 Agent 系统里,模型不可信,那执行层到底该信什么。

我最早踩的坑,是把 tool call 当成“命令”而不是“提议”

一开始做工具调用,很容易有一种错觉:既然模型已经选了工具、填了参数,那系统剩下的工作就是执行。

这个想法在 demo 阶段问题不大,一上线就开始咬人。

最典型的坑,是模型这一轮回复里给了多个 tool_call。比如第一个查用户,第二个改套餐,第三个发通知。你看一眼会觉得很顺:既然 1 和 3 参数都合法,那就先跑掉,只有第 2 个参数校验没过,再把错误回给模型补一下就行。

我一开始真这么想过。后来把这个方案彻底放弃了。

因为问题根本不在“哪几个能跑”,而在这一轮多个 tool call 在系统里到底代表什么语义

如果你把它们当成三个独立请求,那部分成功当然成立;但真实情况通常更接近另一种东西:它们是模型在同一轮推理里形成的一组行动计划。第三个调用虽然参数合法,语义上却可能依赖第二个调用成功之后的世界状态。比如通知必须在套餐更新成功以后才能发,收据必须在扣费成功以后才能发,权限同步必须在账号创建成功以后才能做。

这时候你如果偷偷执行了 1 和 3,不执行 2,下一轮模型脑子里的世界和系统真实发生过的世界就分叉了。它以为自己还在延续同一套计划,执行层其实已经把计划切碎了。

这类分叉特别难调。因为日志表面上并不一定报错,系统甚至显得很勤快:有些步骤成功了,有些失败了,也都回写了。真正坏掉的是状态语义的一致性。

所以我后来把一轮多个 tool call 默认当成一个弱事务批次来看。不是数据库事务那种 ACID,也不是说所有工具都要支持回滚,而是说:在执行之前,先把这一批统一校验;只要其中一个关键调用不合法,这一批默认一个都不执行。

这个策略看起来保守,工程上却稳定很多。至少模型和执行层对“刚刚到底发生了什么”这件事,不会各自理解一套。

零信任的关键,不是“不让模型做事”,而是“不让模型直接碰副作用”

我后来越来越不愿意把模型输出的 tool call 叫“指令”,更愿意把它叫“待审批的动作提议”。

这两个说法听起来只差一点,系统设计上差很多。

如果它是指令,执行层的职责就是尽快跑完;如果它是提议,执行层的职责就变成:先判断这东西配不配进入真实世界。

这时候零信任的边界才会清楚。

模型可以提议:

  • 用哪个工具
  • 传什么参数
  • 先做哪一步,后做哪一步
  • 遇到失败以后想怎么修

但系统不能因为它提议得像那么回事,就默认它有资格执行。中间至少还得过几层:

  • 参数是不是符合 schema
  • 工具是不是当前可用
  • 这一步是不是只读
  • 如果不是只读,需不需要审批
  • 当前环境允不允许这类调用
  • 多个调用之间有没有依赖和顺序约束
  • 这个动作是不是落在沙箱里
  • 失败以后要不要升级权限重试,还是直接返回错误

这些层一旦立住,模型在系统里的角色就变了。它不再是一个直接操作世界的 actor,而更像一个不断提交 action proposal 的组件。系统负责决定,哪些 proposal 只是想法,哪些才有资格变成副作用。

这件事我后来越做越重。因为模型在“准备执行”这个节点,往往最像是对的。它会给出一个很完整的理由,很像人类工程师“已经看清情况了,接下来就这么干”。但真正进生产以后你会发现,最危险的不是明显胡来,而是“说得通,但其实没有被验证过”

零信任不是怀疑模型没有能力,而是默认它的能力不能直接换成权限。

真正可信的,不是 JSON,而是整条结构化执行链

我以前会把工具调用的可靠性理解成“输出必须是结构化 JSON”。后来我发现这只说对了一小半。

JSON 当然重要,不结构化你连解析都没法稳定做。但系统真正能信的,不是“模型吐出了一段像样的 JSON”,而是这段输出进入了一条可校验、可审批、可执行、可回写、可追踪的结构化路径

我后来去翻 CodexClaude Code 的源码,细节实现差很多,但它们最后都收敛到一个很像的闭环:

模型输出工具调用候选,执行层先解析,再做 schema 校验、权限判断、审批、调度、handler 分发、结果格式化、历史写回,然后下一轮继续把这些结构化结果灌回模型。

这个闭环里,真正值得信的不是模型中间写了什么 thought,而是下面这些东西:

  • 工具是谁,通过 registry 可以唯一定位
  • 参数长什么样,可以被 schema 检查
  • 这次调用有没有 tool_call_id
  • 它是不是只读、是不是 destructive、能不能并行
  • 它有没有经过审批或沙箱限制
  • 它最后到底有没有执行成功
  • 返回结果和原始调用是不是一一配对
  • 这次结果有没有被规范化、截断、压缩,再写回 history

也就是说,系统信的是路径,不是文本

文本可以合理化,路径很难。
模型可以补叙事,可以漏前提,可以把猜测写得像事实。
但一个 tool call 有没有过 schema、有没有拿到 approval、exit code 是多少、返回是不是和原来的 call id 对上,这些东西不是靠“解释”成立的,它们是执行态证据。

我现在会把这类证据看得比 thought 重得多。因为只要事情进了执行层,你最终排查问题时靠的也不是“模型当时怎么想”,而是“系统当时到底放行了什么、拒绝了什么、真正执行了什么”。

Thought 越像推理,越不能直接拿来当执行依据

ReAct 那篇的时候,我就已经越来越不信任可见的 thought 了。它对调试有用,但不能天真地当成真实因果链。

到了 ToolUse 这里,这个判断更硬。

因为 thought 最容易在执行前一刻变得特别有迷惑性。它会说:

  • “用户意图应该是这个”
  • “为了完成任务,我先更新,再通知”
  • “这个命令大概率是安全的”
  • “如果失败了,我再 retry 一次”

这些句子读起来都很顺,甚至常常比真正的工具调用更像“理解”。问题是,它们很可能只是模型为自己即将采取的动作补的一段解释。

如果系统把这段解释也当成执行依据,边界就没了。

我后来越来越认同一个更冷一点的分工:

  • Thought 可以存在,用来帮助模型整理局部决策,也方便开发时调试
  • 真正能驱动副作用的,只能是结构化 tool call
  • 真正决定 tool call 能不能碰世界的,是独立于模型文本的控制层

这个控制层可以很朴素,不一定非得多智能。
它甚至可以只是 schema、policy、sandbox、approval、risk metadata、并发约束这些“土东西”。
但恰恰是这些土东西,让系统第一次有了一个可以不信模型自觉的执行边界。

这也是为什么我现在更愿意说:ToolUse 不是“模型会调用工具了”,而是“系统终于把执行权从模型手里拿回来了一点”。

错误怎么回给模型,暴露的是系统到底信什么

这一层我踩过很多次。

早些时候我喜欢图省事:工具失败了,就把错误字符串直接塞回上下文。后来发现这种做法特别容易把模型带偏。

最典型的是把两类完全不同的错误混在一起:

  • schema_validation_error
  • execution_error

前者说明模型连参数格式都没构对,这时候真正该修的是调用方式;后者说明调用本身成立,但执行过程中撞到了权限、网络、环境、下游服务或者业务约束。

如果你都回一句 “tool failed”,模型的修复行为会很差。
它可能在参数已经错的情况下反复 retry,也可能在环境失败的情况下不停重写参数。看起来像在自我修复,实际是在错误空间里乱跳。

我后来会尽量在 tool adapter 层就把错误结构化分类,再决定哪些字段该投给模型。不给原始异常堆栈,不给一大坨 schema 内部术语,而是给更短、更可执行的约束摘要。

比如告诉它:

  • 哪个字段缺了
  • 哪个字段类型不对
  • 哪个字段超出允许范围
  • 这次是否有任何工具真的执行过
  • 它应该重发整批,还是只修当前调用

这里有一个我后来很坚持的原则:错误信息不是给人看的,是给下一轮决策用的。

所以它最重要的,不是“还原异常细节”,而是“把修复方向收窄”。

同样地,返回顺序我也不再按“谁先执行完”来组织,而是严格按原始 tool_call 顺序回给模型,并且每条结果都绑定 tool_call_id
原因也很简单:并行一旦进来,底层完成顺序天然不稳定;如果没有 id 和原始顺序,模型下一轮很容易把结果认错对象。

零信任走到这里,会变成一个很实际的工程判断:不是只要把错误写回模型就行,而是要把错误写成模型能少犯二次错误的结构。

工具结果不是 Observation 那么简单,它也是执行层的一部分

我以前会把工具结果当成一种中性的 observation:工具跑完了,把返回丢给模型继续想。

后来越做越发现,结果处理本身也是控制层,不是附属品。

原因很现实。工具返回的东西往往又长又脏,而且质量参差不齐。有的是一大段网页内容,有的是 shell 输出,有的是半结构化 JSON,有的是下游系统吐出来的错误页。你要是原样全塞回上下文,模型下一轮并不会因此更清醒,通常只会更乱。

这也是为什么成熟一点的系统,几乎都会在工具结果写回前做几件事:

  • 格式化
  • 截断
  • 结构归一化
  • 预算裁剪
  • 配对修复
  • 必要时落盘,只把预览回给模型

我后来越来越把这层看成执行链的一部分,而不是“上下文优化”。

因为它解决的不是 token 节省,而是系统接下来到底基于什么证据继续做决定

如果一个工具返回了两万字网页碎片,而真正关键的只有一句金额和一个时间;如果一个 shell 命令打印了大段日志,而你真正需要的是 exit code 和最后几十行报错;如果一个查询返回了一百个对象,而后续动作其实只能在单个对象确认后执行——那 Observation 就不能按“有多少回多少”来处理。

否则系统表面上像是把更多信息喂给了模型,实际是在把执行态证据稀释成噪音。

到这一步我会更愿意说:工具结果不是给模型看的材料,而是系统为下一轮决策准备的、经过治理的执行证据。

并行、审批、沙箱这些“笨约束”,才是 ToolUse 最值钱的地方

很多人刚开始看 Agent,会把注意力放在“模型有没有选对工具”。这个当然重要,但真进生产以后,你会发现更值钱的是另一层:系统有没有把工具调用放进一个带约束的 runtime 里。

比如并行这件事。

纸面上最优的做法当然是能并就并,吞吐更高,延迟更低。可一旦工具有副作用,或者存在隐含顺序依赖,你就会发现并行不是优化项,而是风险源。

所以我后来不会只给工具打一个很粗的 has_side_effect=true/false。这太不够用了。真实系统里我更关心几类信息:

  • 只读还是写操作
  • 软副作用还是硬副作用
  • 影响哪个业务实体
  • 能不能幂等
  • 能不能安全并发
  • 失败后有没有补偿动作

这些元数据一旦没有,调度器就只能靠 schema 猜,最后一定会出事。
有些依赖关系根本不写在 schema 里,它们是业务规则。create_ordersend_notification 参数看起来互不依赖,不代表业务上能乱序。chargesend_receipt 也是一样。

我后来越来越愿意在这里保守一点:硬副作用默认串行,只有证据非常强时才放开并行。

审批和沙箱也是同一类东西。

很多人会觉得这些约束“打断了 Agent 的自主性”。我现在正好相反。我觉得真正能让 Agent 进生产的,不是它有多自主,而是它的自主有没有被塞进明确的权限边界里。

从这个角度看,审批不是在给模型添麻烦,而是在告诉系统:
哪一步是它自己可以做的,哪一步必须把决定权还给人,哪一步即使模型很想做也不能做。

这就是零信任在执行层最落地的样子。不是写一句“不要乱来”,而是让它就算想乱来,也碰不到那层权限。

写到这里,我对 ToolUse 的看法已经和最早完全不一样了

最早我把 ToolUse 看成 Agent 能力增强:模型不只是会回答了,还能做事

现在我更愿意把它看成一种权力重分配

表面上看,是模型开始能碰工具;实际上,真正成熟的 ToolUse 设计做的是反过来的事:它把“执行”这件事从模型那段不可信的文本里剥出来,交给了一条更可验证的结构化路径。

所以如果要把这一篇和前两篇连起来,我现在会这样看:

  • Planning 让我意识到,计划本身不值得信,真正要信的是计划失效后的止损能力
  • ReAct 让我意识到,thought 和 observation 会互相污染,闭环一旦失稳就会一路传下去
  • ToolUse 则把问题彻底压到了执行边界上:模型不可信,那系统到底靠什么去碰真实世界

我的答案越来越简单,也越来越硬:

不信模型的自觉,不信它的解释,不信它“看起来懂了”。系统真正该信的,是那条结构化、可校验、可审批、可追踪、能留下执行态证据的工具调用路径。

模型负责提议。
系统负责约束。
工具负责把“世界上真的发生了什么”写成证据。

如果非要用一句话收住这一篇,大概就是:

ToolUse 最重要的价值,不是让模型学会执行,而是让系统终于有了一个可以不信模型、还能继续执行的地方。

Tags: ,

Updated: