上一篇讲的是常用工具。
Tool Registry 让工具调用有了协议,文件工具和网页工具让 Agent 开始接触对话之外的材料。到这一步,系统已经不只是一个会聊天的模型接口。它可以读文件,可以搜索目录,可以抓取网页,也可以把这些结果包装成 observation。
但这里还有一个很容易被忽略的问题:工具能调用,不等于 Agent 真的形成了连续循环。
一个很典型的断裂感是这样的。
用户说:读取 README.md,然后总结这个项目是做什么的。
模型决定调用 file.read。runtime 执行工具,文件内容也成功返回。可是系统到这里就停住了。用户还要再问一句“继续总结”,模型才基于刚才的文件内容输出答案。
从功能上看,工具调用成功了。
从体验上看,它不像 Agent。
因为真正的 Agent 不应该只会触发工具。它还应该能在工具返回以后,把 observation 吸收回上下文,然后继续判断下一步是再调用工具,还是给出最终回答。
这一篇要讲的就是这个问题:如何让工具调用从“一次函数执行”,变成真正的 Agent Loop。
工具调用后的断裂感
早期 Agent 原型里,最容易满足的是“模型能不能调用工具”。
只要模型能输出一个工具名和参数,runtime 能找到对应 handler,工具能返回结果,链路看起来就跑通了。
但用户真正感受到的不是链路,而是连续性。
如果一次请求里,模型只是说“我要读文件”,系统读完文件以后就停住,那么用户看到的是一个半自动流程。它像一个被模型触发的命令执行器,而不是一个可以自己完成任务的 Agent。
这个问题不在文件工具本身。
文件工具已经完成了它的职责:读出内容,控制大小,处理错误,返回 observation。真正的问题在 Agent Loop:工具结果有没有自动回到模型?模型有没有机会基于这个结果继续思考?runtime 有没有把“工具调用”和“最终回答”区分开?
Agent 的连续性不是界面效果,而是内核语义。
如果工具调用后不能自动继续,系统仍然缺了一段最关键的闭环。
模型请求工具
-> runtime 执行工具
-> 工具返回 observation
-> observation 回到模型
-> 模型继续判断
-> 最终回答或继续调用工具
这条链路完整,用户才会感觉自己交给 Agent 的是一个任务,而不是一串手动接力的命令。
能调用工具,不等于形成循环
工具调用至少有两个层次。
第一层,是模型能表达“我要调用某个工具”。
这解决的是决策出口。模型不只是输出自然语言,也可以输出一个行动意图:调用哪个工具,带什么参数。
第二层,是工具执行后的结果能自动回到模型。
这解决的是观察入口。runtime 执行完工具以后,不能只把结果记录在自己这里,也不能只打印给用户看。它必须把结果重新放进模型上下文,让模型基于新的状态继续判断。
很多系统只做到了第一层。
模型能触发函数,函数能返回值,日志里也能看到工具执行成功。但这更像是“模型驱动了一段程序”。它还没有形成 Agent Loop。
Agent Loop 的关键不是“调用了工具”,而是“调用工具以后还能继续决策”。
这也是第一篇里那个最小闭环真正落地的地方:
上下文 -> 决策 -> 行动 -> 观察 -> 再决策
如果少了最后的“再决策”,行动能力就会变成一次性动作。它可以完成简单命令,但很难完成真实任务。
真实任务通常需要连续判断。
读了一个文件,可能发现还要读另一个文件。抓了一个网页,可能发现需要再搜索本地资料。写入一条记忆后,可能需要告诉用户已经完成。工具失败了,可能要换一个路径、解释失败原因,或者请求用户补充信息。
这些都要求工具结果进入下一轮模型调用。
所以从 Agent Core 的角度看,工具调用不应该是一次 call() 后结束,而应该是一轮循环里的一个阶段。
JSON 文本协议的价值和上限
最小原型里,用 JSON 文本表达工具调用是一个很自然的选择。
比如模型输出:
{"tool": "file.read", "arguments": {"path": "README.md"}}
runtime 解析这段文本,找到 file.read,执行工具,再把结果作为 observation 放回消息历史。
这个方案有明显价值。
它简单。模型不需要依赖特定 provider 的工具协议,只要能输出文本就能表达工具调用。
它直观。工具名和参数都在一个普通 JSON 对象里,调试时很好看。
它适合教学。早期最重要的是把闭环跑起来,而不是一上来就处理完整 provider 协议。
所以 JSON 文本协议不是错误方案。它是原型阶段很好的脚手架。
但系统继续长大以后,它的上限也会变得明显。
第一,普通回答和工具调用都混在文本里。
模型输出一段 JSON,到底是想调用工具,还是在给用户展示一段 JSON 示例?runtime 只能靠解析规则猜。只要文本稍微不稳定,边界就会模糊。
第二,JSON 可能不合法。
模型少写一个引号,多写一段解释,或者把参数写成非对象,runtime 都要额外兜底。工具调用协议越依赖自然语言输出,系统就越容易被格式问题拖住。
第三,多工具调用很别扭。
如果模型一轮里想读两个文件,或者同时写入两条记忆,单个 JSON 对象就不够了。你可以设计数组格式,但这又变成自己发明一套工具协议。
第四,调用 id 很难自然表达。
在原生工具协议里,每次 tool call 都可以有 id。runtime 执行完以后,tool observation 可以明确对应到哪一次调用。JSON 文本当然也可以塞一个 id,但它不是 provider 理解的结构,只是文本里的约定。
第五,当 provider 已经支持原生 tool_calls 时,继续用 JSON 文本就绕远了。
OpenAI-compatible provider 已经可以让模型用结构化字段表达工具调用。runtime 如果仍然只盯着 content 文本,就会把一个正式协议退化成 prompt 约定。
所以更好的演进方式不是马上删除 JSON 文本协议,而是把它降级为 fallback。
原生 tool_calls 优先。JSON 文本仍然保留,用来兼容旧模型、简单 demo 和不完整支持工具协议的 endpoint。
这是一种很实用的过渡:核心语义升级,旧入口不断。
内部协议要表达 ToolCall
要让工具调用变成真正的循环,内部协议也要跟着升级。
如果 ModelResponse 只有一个 content 字段,Agent Core 就只能把模型输出当成普通文本。工具调用只能伪装在文本里。
但工具调用不是普通文本。
它至少包含几个结构化信息:
- 调用 id
- 工具名
- 参数
这就是 ToolCall 应该表达的东西。
内部可以把它理解成:
ToolCall
id
name
arguments
对应地,ModelResponse 也不应该只表达“模型说了什么”,还应该表达“模型要做什么”。
所以它需要 ModelResponse.tool_calls。
同样,消息历史里的 Message 也不能永远只有 role/content。当 assistant 发起工具调用时,这条 assistant message 需要带上 tool calls。当 tool 返回 observation 时,这条 tool message 最好能通过 Message.tool_call_id 指回对应的调用。
这里的重点不是字段数量,而是语义边界。
Agent Core 应该面向 Agent 语义编程,而不是长期面向某一种 prompt 文本格式编程。
文本可以作为展示层。
JSON 可以作为 fallback。
provider 协议可以作为外部适配层。
但内核里,工具调用应该是工具调用,最终回答应该是最终回答,observation 应该是 observation。
这层内部协议稳定以后,系统才更容易接不同 provider。一个 provider 返回原生 tool_calls,另一个 provider 只能输出 JSON 文本,Agent Core 都可以把它们统一成内部 ToolCall 再处理。
Observation 必须回到模型
工具结果不是普通函数返回值。
普通程序里,函数返回值主要给调用它的代码继续计算。代码拿到字符串、对象、数字,就能按照确定逻辑继续执行。
但 Agent 系统里,工具结果还有一个更重要的读者:下一轮模型。
模型需要知道刚才发生了什么。
文件读到了什么内容。
网页返回了什么标题和正文。
记忆是否写入成功。
路径是不是不存在。
工具是不是被 policy gate 拒绝。
参数是不是缺失。
这些都应该变成 observation 回到上下文。
如果 observation 不回到模型,runtime 也许知道工具成功了,但模型不知道。模型不知道,就无法基于结果继续判断。系统表面上行动过,实际上下一轮推理还是断的。
这也是为什么失败结果同样重要。
工具失败不是异常终点。它也是信息。
如果 file.read 返回“file not found”,模型可以告诉用户路径不存在,也可以尝试搜索相近文件。如果 file.write 被策略拒绝,模型可以解释这个工具没有执行,因为当前策略不允许危险操作。如果网页抓取超时,模型可以说明资料没有取到,而不是假装已经看过。
一个好的 Agent Loop,不只会处理 happy path。
它应该允许工具失败进入 observation,然后把失败也交给模型继续判断。
这会让系统更像一个能处理现实环境噪声的 Agent,而不是只能在完美输入下运行的脚本。
所以工具调用循环里,最关键的一步不是执行工具,而是把工具结果重新喂回模型。
ToolResult
-> tool observation
-> message history
-> next model call
这一步一旦稳定,Agent 才能真正把行动和理解连起来。
工具轮次限制不是模型回答限制
连续循环带来的另一个问题是边界。
如果模型可以在工具结果之后继续调用工具,那它会不会无限调用下去?
所以 Agent Core 需要类似 max_tool_steps 的限制。
但这里有一个很容易踩的坑:限制工具轮次,不等于限制模型回答。
比如 max_tool_steps=0。
这个配置应该表示:当前请求里不允许执行工具轮次。它不应该表示:模型连直接回答的机会都没有。
用户问“你好”,模型应该可以直接回答。只有当模型想调用工具时,runtime 才应该因为工具轮次为零而拒绝继续行动。
再比如 max_tool_steps=1。
模型第一轮请求 file.read,runtime 执行工具,拿到文件内容。这个时候工具轮次已经用完了。
但系统仍然应该允许模型再调用一次,基于刚才的 observation 输出最终总结。
真正应该被拒绝的是:模型在看完 observation 后还想继续调用第二轮工具。
这背后的语义很重要。
max_tool_steps 限制的是行动次数,不是思考次数,也不是总结能力。
如果执行完最后一个允许的工具后立刻返回“工具步数用尽”,用户会看到一种很奇怪的失败:系统明明已经读到了文件,却不允许模型总结。这不是安全,而是 loop 边界写错了。
更合理的循环是:
- 模型可以直接回答。
- 如果模型请求工具,runtime 检查工具轮次是否还有剩余。
- 有剩余就执行工具,把 observation 放回上下文。
- 再次调用模型。
- 如果模型直接回答,就返回。
- 如果模型还要调用工具,但工具轮次已经用完,才返回明确的 step limit。
这个细节看起来小,但它决定了 Agent 是不是能在边界内完成任务。
安全边界不应该切断最终回答。
它应该切断超出限制的行动。
Provider 是翻译层
当内部协议稳定以后,provider 的职责也会变清楚。
Provider 不应该决定 Agent Loop 怎么跑。它更像翻译层:把内部协议翻译成外部模型 API 理解的格式,再把外部响应翻译回内部协议。
对 OpenAI-compatible provider 来说,请求模型时需要把工具列表变成 function tool schema。
内部的工具 spec 可能很简单:
name
description
risk_level
外部 provider 需要的是另一种格式:tools、tool_choice、function name、parameters schema。
第一版可以先使用宽松的参数 schema,让具体工具 handler 继续负责参数校验。后续再给每个工具补更精确的 JSON Schema。
响应回来以后,provider 也要把外部的 tool_calls 转成内部 ToolCall。
这样 Agent Core 不需要知道外部 API 的每个字段。它只关心一件事:这一轮模型有没有请求工具?请求了哪些工具?
消息转换也一样。
assistant 发起工具调用时,要转换成 provider 支持的 assistant tool call message。工具返回 observation 时,要转换成带 tool_call_id 的 tool message。历史里没有结构化 id 的旧 observation,则可以 fallback 成普通 user message,避免旧会话直接失效。
还有一些很工程化的小细节,也应该放在 provider 层。
比如内部工具名喜欢用 file.read、memory.write、web.fetch 这种 dotted name,因为它们对人和系统都很清楚。
但 OpenAI function name 对字符有约束,不一定接受点号。
这时 provider 就应该负责映射。
内部叫 file.read,发给 provider 时可以变成 file__read。provider 返回 file__read 时,再映射回内部的 file.read。
这件事不应该污染 Tool Registry,也不应该让 Agent Core 关心。它只是外部协议的适配细节。
一个好的 provider abstraction,不是把外部 API 原样漏进系统内核,而是把外部 API 翻译成系统自己的稳定语义。
这一篇的结论
从头搭建 Agent,工具调用做到“能执行”还不够。
工具执行只是行动。Agent 真正需要的是行动之后的连续判断。
如果模型请求工具,runtime 执行工具,observation 回到模型,模型再决定继续行动还是输出最终回答,这个系统才开始形成真正的 Agent Loop。
JSON 文本协议适合原型阶段,因为它简单、直观、容易调试。但随着系统开始支持多工具、调用 id、provider 原生协议和更完整的消息历史,内部就需要结构化的 ToolCall,需要 ModelResponse.tool_calls,也需要 Message.tool_call_id 这样的关联信息。
工具轮次限制也要围绕这个语义来设计。
限制的是继续行动,不是最终回答。
Provider 则应该站在边界上,负责把内部协议和外部模型 API 互相翻译。工具名映射、function schema、assistant/tool message 转换,都属于这一层的职责。
到这里,Agent 的“行动循环”才更接近真实工作流:
用户请求
-> 模型判断
-> 工具调用
-> observation
-> 模型继续判断
-> 最终回答
工具越多,不一定越像 Agent。
真正让系统像 Agent 的,是它能把工具结果吸收回来,继续判断,并在边界内完成任务。
当 Agent 能连续行动以后,approval、权限模型和 sandbox 就不再是锦上添花。它们会变成系统必须补上的边界:哪些行动可以自动执行,哪些行动必须确认,哪些行动即使确认了也应该被限制在受控环境里。
Discussion