Skip to content

第 5 章 模型提出的工具调用怎样真正执行 ​

本章要解决的问题

第 4 章把“执行工具”画成了流程中的一个节点。真实实现里,这个节点还要处理不同模型接口的消息格式、参数校验、工具查找、执行错误和结果配对。

本章沿数据流展开一次完整往返:模型怎样看见工具、返回值怎样变成真正的函数调用、结果又怎样配对并进入下一次请求。

5.1 一次函数调用的完整过程 ​

把第 4 章的循环画成两个参与者之间的对话,就是函数调用的全部:

一次函数调用的完整往返:请求、tool_calls、执行、组装回传

图中的协议形状

下面的 tool_calls、arguments 字符串和 role: "tool" 是 OpenAI Chat Completions 风格的例子,不是所有模型接口的统一格式。Anthropic 使用 tool_use / tool_result 内容块,参数直接是对象;OpenAI Responses API 也有自己的数据形状。生产实现通常先由模型适配层转成内部统一结构,再进入同一套执行流程。

图中把 Agent 与它调用的 LLM 分开画,是为了说明模型 API 两侧分别发生了什么。一次往返有四个阶段,其中“执行”发生在 Agent 内部,不是一条消息:

  1. 请求(Agent → LLM):messages(模型可见的前文)+ tools(它能用的工具清单)。工具清单本身也是上下文的一部分。
  2. 响应(LLM → Agent):模型不执行任何东西。以 Chat Completions 为例,它返回一条带 tool_calls 的 assistant 消息,其中每个调用含 { id, name, arguments },且 arguments 是 JSON 字符串;其他接口可能直接返回参数对象。
  3. 执行(Agent 内部):Agent 把 tool_calls 变成真正的函数调用——解析、校验、查表、执行、限制并格式化结果,5.3 的主题。
  4. 回传(Agent → LLM):Agent 把执行结果与前文组装成下一次请求——assistant(tool_calls) + tool(结果),5.4 的主题。然后循环回到 ①。

先看两边实际传输的 API 数据。响应格式在第 4 章已经见过:

json
{
  "role": "assistant",
  "tool_calls": [{
    "id": "call_abc123",
    "type": "function",
    "function": { "name": "read_file", "arguments": "{\"path\":\"src/index.ts\"}" }
  }]
}

在这个 API 中,arguments 外面裹着引号——它是字符串里面的 JSON,不是 JSON 对象。这是接口的序列化约定,不是所有模型服务都如此。第 4 章 4.4 的四项检查,都发生在把接口返回的参数转换为可执行参数的过程中。

回传时,你要追加的是两条消息——assistant 原样回传,结果单独成条:

json
{ "role": "assistant", "tool_calls": [ /* 上面那条,原样 */ ] }
{ "role": "tool", "tool_call_id": "call_abc123", "content": "文件内容……" }

为什么 assistant 那条也要发回去?

因为下一轮模型输入必须包含这次调用及其结果的对应关系;使用 Chat Completions 时,通常要把发起调用的 assistant 消息和对应 tool 消息一起带回。服务端会话接口可以用会话 id 引用已有历史,不一定由客户端每轮重新发送全部文本。

这就是"综合前文与本次执行结果"的字面意思:前文(含发起调用的那条 assistant 消息)+ 本次结果,一个都不能少。

5.2 函数如何被模型看见 ​

5.1 先画了一次往返的全图。按数据流向展开,第一步是 Agent 先把可用函数发给模型。模型对某个工具最直接的说明,来自请求里 tools 数组的 name、description、parameters;系统提示、项目指令和示例也会影响它怎样选择工具。

parameters 通常使用 JSON Schema 描述允许的字段、类型和必填项,具体支持的子集由模型服务决定。

但是每个工具的完整 schema 都进上下文,几十个工具轻松吃掉几千 token。 第 10 章会说明 codex 的 tool_search 怎样延迟加载工具,改变每次请求的工具清单。

5.3 JSON 字符串怎样变成真正的函数调用 ​

模型返回要调用 read_file 后,代码要经过五个步骤才能执行对应函数:解析、校验、查表、执行、格式化结果。每一步解决一个具体问题。

第一步:解析——把字符串变成对象 ​

Chat Completions 风格的 arguments 到达时是 JSON 字符串,执行前要先变成对象。这里要处理两件事:

  • 参数可能流式、分片到达: 拼完之前不要执行半截 JSON。按调用 id 和分片序号累积,收到完成事件后再整体解析。
  • 解析可能失败: 不要让异常带崩循环,而应生成结构化的格式错误;在不泄露敏感数据的前提下,可以附上必要的原文片段帮助模型纠正。

审计日志最好区分原始输入和解析后的参数,但不能不加选择地永久保存原文:参数可能含密钥、令牌或用户数据。应按数据分级设置脱敏、访问控制和保留期;需要可重放时,还要记录模型接口的格式版本和转换规则。

第二步:校验——模型的输出不能当可信输入 ​

解析成功不代表能用:可能缺必填字段、类型不对、多出没定义的参数。校验就是按工具的 schema 把关,不合格就生成一条结构化错误回传给模型(第 4 章 4.4.5 的三要素:哪里错、原文是什么、怎么改)。顺序上先查名字、再验参数,错误才分得清"没有这个工具"和"参数不对"两种情况,模型也好对症下药。

第三步:查表——用工具名找到函数 ​

"模型说调 read_file,运行时怎么找到对应函数?"答案是一张注册表:名字 → 工具定义。每个定义同时保存下面几类信息:

注册信息用途
名称、说明、参数 schema生成给模型看的工具清单
处理函数真正执行工具
并发、幂等、可取消、风险等级供调度器和权限层决策
输出预算决定截断、摘要或落盘

请求前从注册表生成工具声明,执行时按名字找到处理函数,两处共用同一事实来源。这样既避免一长串名称分支,也支持按会话增删工具或接入 MCP。查不到名称时不要让循环崩溃,而应返回结构化的 UNKNOWN_TOOL,并给出当前可用工具或下一步查找方式。

第四步:执行——真正调用你的函数 ​

查到处理函数后调用它,传入两样东西:解析好的参数,以及这次调用的运行时上下文(调用 id、所属会话、取消信号)。上下文由调用方传入,工具尽量不要依赖隐式全局状态。

此外还要满足两条规则:

  • 每个调用有独立的超时和错误边界;只有确认同批调用互不依赖时,才隔离其中一个调用的失败。
  • 沙箱或权限检查可以放在调度器、工具包装层或执行器中,但必须在产生受保护的副作用之前完成。

第五步:返回模型前先限制并格式化结果 ​

工具返回值不能直接放进上下文,因为几万行命令输出既昂贵,也会淹没关键信息。常见做法包括:

  • 按最大 token 或字符数截断超长内容,并注明原文多长、怎样继续读取;
  • 特别大的内容写入文件,只给模型文件路径和预览;
  • 分别保存给模型看的文本和给程序用的结构化结果;
  • 空结果也返回一句占位文本,因为部分模型 API 不接受空字符串。

第 8、13 章会继续讨论输出限制及其上下文成本。

5.4 怎样把执行结果返回给模型 ​

这五步执行完后,还要把结果加入下一次模型请求。伪代码只有四个操作,但必须满足严格规则:

text
messages.append(assistant_msg)            # ① 发起调用的消息原样入史(含 tool_calls)
for call in tool_calls:                   # ② N 个调用,N 条结果,一个都不能少
    result = execute_tool_call(call)       #    解析→校验→查表→执行→格式化
    messages.append(tool_msg(call.id, result))  # ③ 结果带 tool_call_id 与发起方配对
next_request = messages + tools           # ④ 下一轮请求 = 变长了的完整历史 + 工具清单

5.4.1 每个调用都必须与结果正确配对 ​

不同接口的字段名不同:Chat Completions 使用 tool_call_id,Anthropic 使用 tool_use_id。很多模型服务会校验调用与结果的对应关系,但具体限制、错误状态码和是否由服务端补救并不统一。

Agent 应在发请求前自己检查下面三类问题,不要依赖模型服务报错:

  • 工具调用与结果漏配:assistant 说了要调 c1/c2/c3,却只返回 c1、c3 的结果
  • 工具调用与结果多配:给同一个 id 回了两条结果
  • 没有对应调用的结果:出现结果,前面却找不到对应调用

配对齐全不只是模型服务的要求,模型也需要知道每个调用的结果。 缺少结果时,模型可能重复执行并产生两次副作用,也可能编造结果。所以被中断而未启动的调用要补一条"未执行"结果;恢复历史时发现"有调用无输出",也要补一条"已中止"结果。

5.4.2 并行完成后按什么顺序返回 ​

并行执行时,完成顺序可能不同,结果按什么顺序返回?四家给了两个都合法的答案:

  • 按模型序返回(codex、dsh、pi):下一轮模型看到的结果顺序与它发起调用的顺序一致,阅读体验最自然。pi 即使并行执行,也会在整批完成后按原数组顺序生成结果消息
  • 完成后先展示(grok,pi 的执行结束事件也可能如此):内部调度和 UI 可以先展示先完成的结果;发给模型服务时,适配层仍按需要重新组装

结果应靠调用 id 配对,不能只靠数组位置。展示顺序与模型消息顺序是两件事:grok 的并发调度按完成顺序向 UI 发事件,无界面模式下的消息组装器则会按原工具调用顺序整理结果。写入同一路径的调用还需要串行或加锁,避免写冲突。

5.4.3 返回给模型的是处理后的文本 ​

tool 消息的 content 是给模型阅读的文本,不是工具的原始返回值。除了 5.3 第五步的格式化,还要处理两种情况:

可恢复的错误作为工具结果返回。 未知工具、参数错误、权限拒绝和普通执行失败通常可以回给模型,让它决定下一步。取消、连接/协议损坏、进程内部不变量被破坏等错误则可能需要结束当前 turn;不要把所有异常都伪装成工具结果。

从日志恢复的历史必须能转换成当前模型服务接受的格式。 grok 返回错误时会保留足够的参数信息帮助模型纠正;再次组装请求时,适配层会修复不合法的 arguments,避免把内部错误原样传给模型服务。日志中的敏感原文仍应按前面的规则脱敏和限制访问。

不同模型服务对推理内容的保存和回传规则也不同:有的要求连同工具调用回传加密或签名过的 reasoning item,有的根本不暴露这部分。不要写一条通用的“只在工具调用轮回传”规则,应由适配层按具体接口处理。

5.4.4 原始记录只追加,模型输入可以有记录的变换 ​

"下一次请求 = 前文 + 本次结果"有两种常见实现方式。较稳妥的共同点是:原始事件日志只追加;发给模型的历史可以因裁剪、压缩、接口适配或隐私处理而变化,但这些变换也要有版本和记录。

  • 增量生成消息(dsh):从追加事件生成模型消息;压缩会用有开始、结束和结果记录的操作替换模型可见区间
  • 每轮重建(codex、grok):从内部历史重新组装模型请求,期间可能做规范化或过滤
  • 从当前分支重建(pi):原始 JSONL 保留整棵会话树,模型输入只取当前 leaf 到根的路径;压缩和分支摘要是显式记录

为什么不要无记录地改写已经发过的模型前缀?

第 2 章的前缀缓存给出了答案:请求从开头到某个位置与上一轮一致时,这段共同前缀才有机会复用缓存;是否命中还取决于模型服务、模型、路由、有效期和长度门槛。工具循环只在末尾追加内容,通常更利于保留共同前缀。四家都能从内部记录重建稳定的当前路径:

  • grok 保证推理内容与工具调用的交错顺序跨轮保持字节级稳定——这正是服务端前缀缓存能命中的前提
  • codex 给合成输出用固定规则生成 id,因为改掉它就会改变模型可见的内容、使缓存失效
  • dsh 的重试在重建请求时刻意保持先前的前缀,让它仍然有资格命中模型服务缓存
  • pi 只从当前会话分支组装消息;分支切换或压缩会形成新的显式路径,不会悄悄重排旧记录

因此不要无记录地改写工具结果、重排消息或删除早先轮次。某处内容改变后,该位置之后的缓存不能继续复用;变化之前的共同前缀仍可能命中。上下文压缩、隐私删除和格式迁移都可以改写模型输入,但应作为显式操作写入日志或版本记录(第 13 章)。

一个常见的错误实现

在等待工具结果时修改 messages 里早先的内容(比如把大结果换成"见上文"),下一轮请求照发。 功能上看起来没问题,但变化位置之后的缓存会失效,日志内容与模型看到的内容也不再一致。正确做法: 正常对话用追加;确需裁剪或修正时,把变换的原因、输入范围和结果记录下来,让恢复过程能重现同一份模型输入。

本章小结 ​

  • 一次函数调用是一轮完整往返,形状和你调一次后端接口相同——只不过模型是"客户端"、你的代码是"服务端":发工具声明与上下文 → 模型返回调用请求 → 五个执行步骤 → 结果配对回传。tool_calls 和 JSON 字符串参数只是 Chat Completions 风格的协议形状
  • 执行分五步:解析(先拼接再解析、失败时保留必要且已脱敏的原文片段)→ 校验 → 查表(用名字找到处理函数)→ 执行(处理函数接收参数与运行时上下文)→ 限制并格式化结果
  • 返回结果时必须保证调用与结果按稳定 id 完整配对;字段名、消息形状和模型服务的拒绝方式各不相同
  • codex/dsh/pi 的模型输入按调用顺序组装;grok 和 pi 的内部完成事件可按实际完成时间更新,模型消息仍按稳定规则组装
  • 回给模型的是经过限制和格式化的内容,不是任意原始返回值:可恢复错误也要返回、空结果可用占位、超大结果在安全上限内写入文件、恢复历史前要做格式检查
  • 原始事件只追加,模型输入允许显式变换:稳定前缀有利于缓存;压缩、裁剪和格式迁移必须可记录、可恢复
  • 四家的内部消息格式不同(API 格式直接复用、统一中间格式、事件日志或会话树),共同点是结果配对完整,模型输入变换有明确规则

到这里,通用工具调用链已经完整了。这条链一头连着工具声明和历史消息,另一头连着本轮新产生的调用与结果;它们都会进入模型的下一次请求。因此下一章先回答“每轮究竟给模型看什么、按什么顺序放”;上下文边界确定后,第 7、8 章再展开文件编辑和 Shell 这两类具体工具。

基于 MIT 协议发布,欢迎转载与贡献。