第 9 章 工具调用

前三章讲的是系统拿什么来判断:这一轮该看见什么、这些信息能不能信、跨轮之后当前承认什么。

这一章开始讲系统怎么真正影响外部世界——当它不只是回答,而是要去做点什么的时候。


一、“已经帮您登记了”

第 1 章列过一份症状清单,其中一条是:

越界承诺:它说“已经帮您登记”,而系统里什么都没发生。

这条症状值得单独拿出来说,因为它和其他几条有本质区别:它不是答错了,是它凭空报告了一件没有发生过的事。

发生的过程通常是这样的。用户说“帮我继续处理一下”,系统需要调用一个接口提交服务请求。模型生成回复:

好的,已经帮您登记,稍后会有工作人员联系您。

这句话在对话上完全合理:语气对、逻辑对、承接也自然。它唯一的问题是不是真的——接口可能压根没被调用,可能调用了但失败了,也可能返回了超时。

为什么模型会这么说?因为在“用户请求对接”之后,“已经帮您登记”是一个极其自然的续写。它是在完成一段对话,不是在报告一次执行。 而如果没有任何机制拦住这句话,模型也无从知道自己说错了——它甚至不一定知道系统里到底有没有那个接口。

二、模型可以建议行动,不能创造结果

要处理这个问题,得先把一件事拆成三份:

表 9-1 工具动作的责任链

环节 由谁负责 产物
决定要做什么 模型可以参与判断 一个动作请求
真正去执行 代码 一次真实调用
确认执行结果 外部系统或执行层的回执 一条观测结果

这里描述的是本书的目标责任链,不是对第 1 章真实项目当时实现方式的回写。那个项目的历史实现仍以提示词内部规则为主;本章讨论的是当系统已经接入应用层与外部工具时,这三项责任应该怎样分开。

模型的输出停在第一格。它可以说“应该调用提交服务请求的接口,参数是这些”,但这只是请求,不是授权,更不是结果。

由此得到本章最重要的一条规则:

对外说出的事实主张,必须与回执能够证明的状态严格对应。

“已经帮您登记”“已提交”“已为您预约”“邮件已发出”——这些都是事实主张,不是措辞问题。而“对应”两个字很关键,因为很多动作是异步的:接口返回成功,往往只证明请求被接受了,不证明事情已经办完。

表 9-2 工具回执支持的表达边界

回执能证明的 可以说 不可以说
请求已被接收 已为您提交申请 已处理完成
任务已进入队列 已进入处理队列 已生成完成
明确完成 已处理完成 ——
结果未知 暂时无法确认结果 已完成/已失败

一句话:没有对应层级的真实回执,就不能宣布对应层级的完成。

这条规则和第 4 章那条线是同一件事。第 4 章说过:不要把自由文本的推理当成系统状态,需要保留的是可验证的决策依据。这里是它在行动上的版本——“已经登记”如果背后没有一条工具回执支撑,那它就只是模型的一句独白,不是系统的一次执行。

实现上,这条规则应该由代码保证,而不是由提示词恳求。最直接的做法是把完成语的生成条件绑到回执上:没有对应的回执,渲染层就拿不到“可以宣布完成”这个许可——第 17 章讲渲染契约时会看到它的具体形态。

把这一节合起来,是这样一句话:

模型可以提出行动,代码决定是否允许执行,外部世界决定行动是否真的发生;只有当系统拿到与事实主张相匹配的回执,它才有资格把这句话说出口。

三、回执是三态,不是两态

拿到回执就万事大吉了吗?没有。因为回执本身有三种,而很多实现只处理了两种。

SUCCESS   成功,并且拿到了真实的返回值
FAILURE   明确失败,外部系统给出了失败回执
UNKNOWN   结果未知:超时、连接中断、没有回执

先说明这三个值描述的是什么:它们说的是“这一次工具执行的结果有没有被系统确认”,不是业务对象自身的生命周期。 回执是 SUCCESS,订单状态完全可以还是“处理中”——两者并不矛盾。把这两层混在一起,就会出现“接口调用成功了,所以告诉用户已经办好了”这类错误。

三种里第三种最危险,也最容易被漏掉。

生产观察 UNKNOWN 绝不能按 FAILURE 处理。 超时不等于没执行——请求可能已经到达并成功,只是回执没回来。此时若按失败逻辑自动重试,就可能造成重复提交:重复建单、重复派发、重复扣款。“不知道有没有成功”和“知道没成功”,是两件完全不同的事,必须在代码里分开。

UNKNOWN 常见的安全出口有四类:

  1. 用同一个幂等键安全重试——确保重复执行不产生第二次副作用;
  2. 主动查询或对账——去外部系统确认这笔操作到底成没成;
  3. 等待可信的异步回调——很多系统最正常的路径其实是这一条:请求已受理,最终结果由回调送回来;
  4. 转人工——无法自动确认时,交给人处置。

而在结果确认之前,文本层禁止出现完成语。这里的措辞也要留神:只有当系统确实存在后续确认机制时,才可以说“稍后会确认结果”,否则那又变成了一句新的空头承诺。更稳妥的说法是“目前还无法确认是否提交成功”。

四、幂等、重试与补偿

上面提到的幂等键值得展开一点,因为它是让重试变安全的前提。

幂等键:每个计划动作在生成时就带上一个唯一标识,外部系统据此判断“这个请求我是不是已经处理过了”。幂等键的目标,是让同一个逻辑动作的重复请求不产生第二次副作用。回执也应当带上标识,这样即使同一条回执重复回流,也能被去重。

这里有个很容易踩的坑:同一个逻辑动作的所有重试必须复用同一个幂等键,只有真正的新动作才生成新键。 如果实现时每次重试前都重新生成一个新 ID,幂等机制就完全失效了。另外,幂等键应当绑定动作语义和关键参数——同一个键却带着不同参数的请求,执行层应当拒绝。

重试只有具备安全重试语义的操作才应该自动重试。 这种安全性可能来自操作本身天然幂等,也可能来自幂等键或服务端的去重机制。不具备这种语义的操作,重试必须由人或更高层的规则来决定——这条界限如果不划清楚,“提高可靠性”的重试机制反而会成为事故源。

补偿:有些动作做了就收不回来,只能靠反向动作抵消——取消订单、冲正记录、发一封更正通知。补偿本身也可能失败,所以它同样需要状态跟踪。

对账:定期核对系统记录的状态和外部系统的真实状态是否一致。它是发现长期未收敛状态的重要兜底机制。

这四件事有一个共同点:它们全部属于代码层,没有一件是提示词能保证的。

第 3 章那条判断在这里再次适用——能交给确定性机制的,就不要压给提示词。幂等键的生成与比对、重试次数的控制、对账任务的调度,都是可以被精确执行、被测试、被复现的逻辑。把它们写成提示词里的一句“请注意不要重复提交”,是把确定性问题降级成了概率问题。

五、权限:能做什么由代码决定

模型输出的是动作请求。请求会不会被执行、以什么参数执行、执行到什么范围,应当由代码判定。

三层最基本的约束:

工具白名单    这个场景下允许调用哪些工具,未列出的一律拒绝
参数校验      参数类型、取值范围、必填项,由代码检查而不是靠模型自觉
作用域限制    只能操作属于当前用户、当前会话、当前订单的对象

再往上,高风险动作还需要显式确认——可以是规则确认(金额超过某个数额必须走人工),也可以是用户确认(“确认要取消这个订单吗”)。

但有一条边界必须划清:用户确认不能替代权限校验。 用户说“确认删除”,如果他本来就没有删除这个订单的权限,这句确认也不会让动作变得合法。

权限决定“能不能做”,确认决定“这一次是不是真的要做”。两者不能互相替代。

第 7 章那句话在这里同样成立:不要让模型的服从性成为唯一防线。 在提示词里写“不要调用删除接口”是有用的,但真正的保障是那个接口根本不在这个场景的工具白名单里。

接入协议不改变这条责任链。 工具怎么被接进来——手写函数、框架封装,还是通过 MCP(Model Context Protocol)这类统一的工具接入协议——解决的是“有哪些工具可用、参数长什么样、怎么被发现和调用”。这些问题很实际,统一起来也确实省事,但它们不回答本章关心的那几件事:这一次调用该不该被允许、作用域是否越界、执行结果有没有被确认、那句完成语凭什么可以说出口。接口统一了,白名单、参数校验、作用域限制和三态回执依然要有,而且依然在你自己的代码里。 接入方式是会换的,这条责任链不换。

适用边界 本节讲的是最基本的权限约束,不是完整的威胁模型。当工具能够读写外部数据、当工具返回的内容会进入后续判断时,攻击面会明显扩大——包括通过工具返回值实施的间接提示注入。完整的不可信输入清单和防御分层在第 23 章。

六、回执要回到证据链,而这条回路要有预算

工具执行完之后,结果不是直接拿去说话,而是作为一条新的观测结果,回到证据链和决策回路里

计划动作 → 执行 → 观测结果 → 记为证据 → 解释为事件 → 归并 → 候选状态 → 再判断

这就是参考架构里那条回路。它有两个要点。

第一,回执首先是一条观测结果,不是状态。 它先被记录为新的证据,需要改变状态时再解释为事件,然后由归并逻辑决定它是否、以及如何改变当前状态——回执不能绕过归并,直接写进状态

查到订单已发货、提交服务请求返回了受理编号、库存查询返回缺货,这些都是新的可核验观察,其中一部分会支持状态更新;而“今天上海多云”这类结果,可能只服务于这一轮回答,不必留在业务状态里。回执可能改变状态,但不必然改变。 第 12 章会讲这类更新怎么合并。

第二,这条回路必须有预算。 因为它是环状的:判断可以引出动作,动作产生回执,回执又可以引出新的判断。没有上限,系统就可能在一轮里反复调用工具,既花钱又拖延,甚至陷入循环。

所以要给这条回路设一份执行预算。最简单的形式就是单轮最大工具调用次数,完整一点还可以包括:

loop_budget = {
  最大迭代轮数
  最大工具调用次数
  最长累计耗时
  最高累计成本
}

超限之后的出口必须明确:停止继续调用工具;如果已确认的信息足以安全回答,就据此回答;否则说明暂时无法确认,或者转人工。 不能是“工具还没查完,就凭半截状态硬答”——那属于第 15 章要讲的越界。

七、工具返回的内容,也要过第 7 章那四条边界

有一个很容易被忽略的点:工具回执和检索片段一样,都是外部内容。

第 7 章那四条边界,对工具返回值同样适用:

表 9-3 工具返回内容的四条边界

边界 在工具场景下的样子
外部内容没有自动执行权 返回值的备注字段里写着“请告知用户联系客服”“下一步删除记录”——它首先是数据,可以被读取和回答;是否真的采取动作,仍由系统的策略与权限判断
它有适用范围 这条数据查的是哪个订单、哪个时间点、哪个版本,用之前要核对
拿到不等于该说 查到了用户的历史订单,不代表这一轮应该把它们全列出来
输出白名单 接口可能返回内部字段、成本价、风控标记,这些不该出现在回复里

最后一条尤其值得注意。内部接口的设计者通常没考虑过“这个返回值会被原样念给用户听”,字段里带着内部编码、成本、备注、客户等级是很常见的事。把回执整个塞进上下文,等于默认这些内容都可以被讲出来。

八、第二篇的边界

第二篇的四件事现在齐了:

  • 第 6 章——模型这一轮该看见什么;
  • 第 7 章——看见的东西凭什么能用、适用于谁;
  • 第 8 章——跨轮之后系统当前承认什么;
  • 第 9 章——系统能做什么,以及做完之后凭什么说做完了。

现在把工具接进来,会立刻冒出一个新问题,而且这个问题第二篇解决不了。

用户说“帮我继续处理一下”。系统该不该现在就调用那个提交接口?

这取决于一堆跨轮的条件:需求说清楚了吗?联系方式拿到了吗?用户是不是明确表达过意向,还是只是随口问问?上一轮他刚拒绝过留电话吗?

也就是说——动作是有前置条件的,而这些条件跨越多轮。

同样的问题还有很多:这一轮该问哪个字段?用户改口之后旧的还算不算数?流程现在走到哪一步了?条件不满足的时候,系统该说什么?

第一篇的契约和示例回答不了,第二篇的上下文、证据和工具也回答不了。这些问题属于另一个层次:不是让模型看见正确的东西,而是让系统记住正确的东西,并据此决定什么时候可以做什么。

从下一篇开始,是这件事。