Agent 工具调用成长史
工具的重要性
Agent 是一个能够感知环境并通过行动影响环境的实体。
Agent = LLM + 上下文 + 工具 + 约束 + 验证 + 纠正 = Model + Harness
工具是 Agent 的手脚:它不只是几个可调用的 API 函数,而是 Agent 能做的所有事情的集合
在 AI Coding 的时候,你是希望 Agent 直接来改代码,而不是复制 AI 的代码到处 ctrl cv,那么 Tools 系统就可以实现这个能力。
Toolformer(2023年初,meta)🚩
Toolformer 的目标是让语言模型自主学习如何调用外部工具(如计算器、搜索引擎、翻译系统、日历等),而无需大量人工标注数据。
核心是:让模型自己教自己用工具,回答的是:模型能否学会用工具?(能力问题)
回答的问题:模型怎么知道此时需要使用 Tool 、怎么学会使用工具
Toolformer、Gorilla、HuggingGPT 的具体方法,没有一个被今天的产品直接采用。 但这个阶段集体完成了一个关键认知转变
之前的假设: 模型不会用工具 → 需要精心设计人工规则
之后的共识: 模型本身就有能力理解工具 → 关键是给它好的接口
OpenAI 的判断是:既然模型已经足够聪明到能理解工具描述,那就不需要 Toolformer 那种复杂的自监督训练了,直接给一个结构化接口就行。
基本原理
Toolformer 不改变模型的基础架构,它的核心做法是:将 API 调用和返回结果,都视为一种特殊的文本标记,插入到原始的训练语料中。
比如,一段普通的文本:"巴黎是法国的首都,拥有 210 万人口。" 经过 Toolformer 处理后,会变成:"巴黎是法国的首都,拥有 [Calculator(2.1 * 1000000) -> 2100000] 210 万人口。"
模型在学习预测下一个词时,不仅学会了自然语言,也学会了在合适的位置生成 [Calculator(...)] 这样的魔法咒语,来召唤工具并利用其结果。
整个过程就像让模型进行一场大规模的"自我对话",分四步走:
1. 探索与采样:用"试错"寻找工具的用武之地
首先,准备一个只有纯文本、没标注过工具的大数据集。然后,让一个"种子模型"(就是我们要训练增强的那个基础模型)在这个数据集上"通读"一遍。
关键在于,我们会用一种带"缺陷"的方式,随机在文本句子的头、中、尾部 ,强行插入 [Tool(...)] 标记,让模型补全具体的 API 调用参数,并拿到返回结果。
比如,随机抽取句子"奥运会每四年举办一次",在它前面加上 [Search(],让模型尝试补全,它可能会输出"奥运会)"并得到搜索结果 -> 奥林匹克运动会,是国际...。这样我们就收集到了成千上万个 [Search(奥运会) -> ...] 的生成片段。
2. 过滤筛选:留下真正能帮上忙的"好样本"
上一步是撒网,会捞出很多"没用"甚至"有害"的调用。比如,给一个常识句子强行加个搜索,搜出来的结果和模型自己知道的一样,这个调用就是多余的。
Toolformer 设计了一个巧妙的过滤函数,核心是衡量交叉熵损失:
- 有工具更好吗? 计算模型在看到工具返回结果后,预测后续真实文本的难度(困惑度)。
- 没有工具会怎样? 再计算模型在没有工具帮助下,预测同样后续文本的难度。
如果"有工具"时的预测难度,显著低于"没有工具"时的难度,就说明这个工具调用真的帮模型减少了困惑,提供了有价值的信息。再通过一个预设的权重阈值,只保留那些能显著提升预测准确性的样本。
3. 数据增强:将成功的"咒语"植入原始文本
筛选出高质量的工具调用文本后,就把这些 [Tool(参数) -> 结果] 文本块,缝合进原始语料的对应位置,形成全新的、带有工具调用和结果的增强语料。
4. 模型微调:学习在语言中自然地使用工具
最后一步,用增强后的庞大数据集,对原始的种子模型进行标准的语言模型任务微调。模型就会学会:
- 何时用工具 :在需要事实、计算、翻译等场景,自动生成
[Calculator(21 * 2)]这样的调用。 - 如何用结果 :在生成调用标记后,会自然地读取
-> 42的结果,并把它作为上下文,继续生成后续的文本。
现代大模型如何学会工具调用:两阶段训练
| 训练阶段 | 训练目标 | 具体方法 | 解决的问题 |
|---|---|---|---|
| 监督微调 (SFT) | 学会基本操作模式 | 使用大量"用户问题-工具调用-工具结果-最终回复"的四段式对话数据进行训练 | 让模型学会: 1. 何时切换输出模式(自然语言→工具调用) 2. 如何生成正确的工具调用格式 3. 如何整合工具结果生成回复 |
| 强化学习 (RLHF) | 优化决策"分寸感" | 基于人类反馈或环境奖励,优化工具调用的准确性 | 解决边界情况: 1. 该调工具时没调 2. 不该调工具时调了 3. 参数填错 4. 调用错工具 |
训练数据示例:
python
{
"messages": [
{"role": "user", "content": "今天北京天气怎么样?"},
{"role": "assistant", "content": "[tool_call]{"name": "get_weather", "args": {"city": "北京"}}[/tool_call]"},
{"role": "tool", "content": "{"temp": 25, "condition": "晴"}"},
{"role": "assistant", "content": "今天北京天气晴朗,气温25度。"}
]
}
模型通过大量这样的样本,学会了"用户问题涉及可调用的工具→输出工具调用格式"的模式
Function Calling(OpenAI,2023.6)🚩
OpenAI 通过微调(Fine-tuning),让模型在底层"原生"支持了调用外部工具的能力,这就是 Function Calling。
Function Calling 是一种 LLM 与外部工具之间的契约(contract):你定义哪些操作可用及其参数结构,模型决定何时调用它们并输出结构化请求,你的代码负责执行并返回结果。模型本身永远不直接执行任何操作。
回答的问题:工具调用的可靠性
将工具调用从"研究问题"变成"开发者 API"
它同时解决了三个问题:
- 可靠性:结构化输出远比自由文本解析稳定
- 通用性:任何能描述成 JSON Schema 的工具都能接入
- 简洁性:开发者定义一个 schema 就完成注册,零训练成本
实现原理
- 在调用LLM前,开发者需要预先定义好一系列可用的函数
- 这些定义以JSON Schema的格式,通过系统提示词(System Prompt)提供给LLM,作为它的"工具手册"。
- LLM 会决策是否调用 以及调用哪个函数。
- 如果LLM决定调用函数,它不会生成自然语言回复,而是输出一个严格结构化的JSON对象
- 你的程序在本地或远程实际调用对应的函数代码,将函数执行的结果作为新的上下文,再次发送给LLM。
- LLM将原始问题、函数调用结果整合,生成面向用户的自然语言回复
关键:结构化输入输出
json
// 调用 LLM 时的输入:工具清单
{
"name": "get_weather",
"description": "获取指定城市的当前天气",
"parameters": {
"type": "object",
"properties": {
"city": { "type": "string", "description": "城市名" }
},
"required": ["city"]
}
}
// LLM 的输出:如果LLM决定调用函数,它不会生成自然语言回复,而是一个**严格结构化**的JSON对象
{
"name": "get_weather",
"arguments": { "city": "北京" }
}
逐渐成为现代工具系统的标准规范
这一个设计,现在是事实标准。 OpenAI、Anthropic、Google、Mistral、阿里、字节------所有主流 LLM 厂商的工具调用都采用几乎相同的模式。
不同厂商有一些细微的区别。
不同厂商的特殊 Token
example:tool_call{"name":"get_weather"}/tool_call
| 厂商 | 开始标签 | 结束标签 | 特点 |
|---|---|---|---|
| OpenAI | <function_call> |
</function_call> |
XML 风格 |
| Claude | <tool_use> |
</tool_use> |
XML 风格 |
| 某些国产模型 | [TOOL_CALL] |
[/TOOL_CALL] |
方括号风格 |
本质都是 function calling,但没有统一
2023 年 OpenAI 推出功能时,行业还没有"标准"可言。 它只是 OpenAI 自家 API 的一个特性,不是 RFC,不是 W3C 标准,也不是行业联盟协议。其他家不可能也没义务直接照搬------尤其是直接竞争对手(如 Anthropic),照搬就意味着把自己的 API 设计主导权拱手让人。
OpenAI 与 Anthropic 的消息架构完全不同
OpenAI 的早期路线(Chat Completion API) :
- 消息内容是纯文本
content: "string"或 null。 - 工具调用是一个独立于
content的顶层字段tool_calls。 - 当模型要调工具时,
content经常是null,工具信息完全外挂在消息体上。 - 这导致"文本回复"和"工具调用"在架构上是割裂的。
Anthropic 的 Messages API 设计哲学:
- 消息
content从来就不是纯文本,而是一个内容块(Content Block)数组。 - 从一开始就支持
text块、image块...... 工具调用自然也应该是这个数组里的一种块:tool_use。 - 当模型要调工具时,它可能先输出一段思考文本(
text块),然后紧接着在同一个content数组里给出tool_use块。两者可以自然交错、组合。 - 工具结果也是用
tool_result块回传,同样是内容数组的一部分。
Anthropic 消息协议就是"多模态内容流"架构,工具只是其中一种内容类型。强行套用 OpenAI 那种"纯文本 + 外挂工具调用"的模式,会破坏它自身的设计一致性。
一些模型(尤其是开源或早期国产模型)没有能力或不想动底层 API 结构,就直接在文本流里插入特殊的 token 或字符串,用类似模板注入的方式来实现工具调用。这本质上是在"纯文本流"上打补丁,优点是实现简单,缺点是结构化差、容易解析错误、和真正的 API 集成存在天然鸿沟。
模型与应用的解耦:模型网关🚩
我们现在知道,OpenAI 和 Anthropic 的模型,在使用工具时交互协议是不一样的,那为什么 Claude Code 也能用 GPT 模型呢?答案是模型网关
模型网关是架在应用 与各种大模型 API 之间的一个统一代理层。它做三件核心事:
- 统一入口:应用只对接网关,不用改代码换 API 地址。
- 协议转换:把应用的请求/响应格式,转换成各个后端模型需要的格式。
- 增强治理:负载均衡、故障转移、限流、日志、计费等。
网关 example:OpenRouter & LLM Gateway
网关实现核心原理
1、消息格式转换
Claude API 的消息体是:
json
{
"messages": [
{"role": "user", "content": "..."},
{"role": "assistant", "content": [{"type": "tool_use", ...}]},
{"role": "user", "content": [{"type": "tool_result", ...}]}
]
}
OpenAI API 的消息体是:
json
{
"messages": [
{"role": "user", "content": "..."},
{"role": "assistant", "tool_calls": [...]},
{"role": "tool", "tool_call_id": "...", "content": "..."}
]
}
网关会做这样的映射:
tool_use块 ↔tool_calls数组tool_result用户消息 ↔role: "tool"消息- 工具定义的
input_schema↔parameters
2、工具调用 ID 对齐
Claude 的 tool_use_id 和 OpenAI 的 tool_call_id 都由模型生成,网关负责保管对应关系,确保返回结果时能精确匹配。
3、流式响应重封装
Claude 用 content_block_start/delta/stop 来增量传输工具调用参数;OpenAI 用 tool_calls 数组的增量 delta。网关需要在这两种流式结构间实时转译。
MCP:标准化协议🚩
mcp.so/ is a third-party MCP Marketplace with 22904 MCP Servers collected.
回答的问题:Tool 生态 & 用户自定义 Tool
Tool 生态问题,各平台工具不互通
假如你是 Google,你希望 Claude Code 和 Codex 在需要进行网页搜索时,使用你提供的 Google web search,需要怎么做呢?
在过去,我们需要为 Claude Code 和 Codex 各适配一份
每个组合(Agent × 工具)都要写胶水代码、处理认证、管理上下文窗口、转换格式,成本巨大且生态割裂。
还有一点,Claude Code 没办法内置全部工具,总有些 Tool 是用户需要自己去定制的,直接去改 Claude Code (闭源)源码文件几乎是不可能的
至少在 Claude Code 被意外开源前是不可能的
因此针对用户自定义 Tool 的能力,MCP 诞生了。
MCP 角色:Client & Server
- MCP Host:真正的 AI 应用,比如 Claude Desktop、Claude Code、自研的 Agent 平台。
- MCP Client:嵌入在 Host 里的协议实现,负责与 Server 建立一对一连接。一般就是指 Cursor,Claude Code,Cherry Studio 这样的你使用的工具。
- MCP Server:独立进程,封装了具体工具或数据源,通过标准协议暴露能力。
MCP 的协议设计是 基于 JSON-RPC 的方法调用,而不是 RESTful 风格的 URL 路径。
MCP 两种官方 transport
1、stdio ------ "server 是 client 的子进程"
example:chrome-devtools-mcp(Google 官方 MCP)
需要 Client 侧注册:
json
{
"mcpServers": {
"chrome-devtools": {
"command": "npx",
"args": ["-y", "chrome-devtools-mcp@latest"]
}
}
}
// The MCP server will start the browser automatically once the MCP client uses a tool that requires a running browser instance. Connecting to the Chrome DevTools MCP server on its own will not automatically start the browser.
可以大概看一下,没有什么神奇的地方。
scss
┌─────────────────────────── 用户的笔记本 ───────────────────────────┐
│ │
│ ┌─────────────┐ spawn() ┌──────────────────────────┐ │
│ │ │ ───────────────▶ │ │ │
│ │ Cursor │ │ chrome-devtools-mcp │ │
│ │ (MCP Client)│ stdin (请求) │ (MCP Server) │ │
│ │ │ ───────────────▶ │ │ │
│ │ │ stdout (响应) │ ┌────────────────┐ │ │
│ │ │ ◀─────────────── │ │ puppeteer 拉起 │ │ │
│ └─────────────┘ │ │ Chrome 浏览器 │ ──▶│ │
│ 父进程 │ └────────────────┘ │ │
│ └──────────────────────────┘ │
│ 子进程 │
└────────────────────────────────────────────────────────────────────┘
全程在本地,没网络
// spawn:启动运行子进程
npx 简介
npx 是 Node.js 自带的命令行工具 (跟着 npm 一起装的,装了 Node 就有)。全名是 "Node Package eXecute"------ 执行一个 npm 包里的命令 。
它干一件事:给我一个包名,我帮你 找到 / 下载 / 运行 这个包里声明的可执行命令
除了 npx,其他的 stdio 接入(code-review-graph)
stdio 模式下,client 配置文件里的 command 字段写啥都行, 只要能产出一个能读 stdin/写 stdout 的进程。Anthropic 官方有一堆 Python 写的 MCP server(filesystem、git、postgres 之类),用 uvx 启动。
这本质是对你的代码仓库做索引,包括一个函数有哪些地方做了调用,特点是实时更新。可以解决大仓库 AI 用 grep 读不准的问题。
AI 编码工具在审查任务中可能会反复读取代码库的大量内容。code-review-graph 解决了这个问题。它使用 Tree-sitter 构建代码的结构化映射,增量跟踪变更,并通过 MCP 为 AI 助手提供精准的上下文,使其只读取真正需要的内容。
这种方案下,在初始化 install 命令时,它会执行一个脚本,在本地启动 MCP Server。
怎么判断一个 MCP server 是哪种:看有没有 url
看它的安装文档:
- 让你在 client 配置里写 command + args → stdio ,本地子进程。
- 让你写 url (或 transport: "http" )→ HTTP ,远程服务。
- 让你 pip install / npm install -g 然后配 command → 还是 stdio。
perl
// gitlab 这是 stdio 传输方式。
// 如果是 HTTP(即 Streamable HTTP 或 SSE)传输方式,配置中会使用 "url" 字段指向一个已运行的服务端点,而不是用 "command" 来启动进程。
{
"mcpServers": {
"gitlab": {
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-gitlab"
],
"env": {
// "用什么身份" 去访问 "哪个 GitLab"。
"GITLAB_PERSONAL_ACCESS_TOKEN": "<YOUR_TOKEN>", // MCP 权限系统
"GITLAB_API_URL": "https://gitlab.com/api/v4" // 连接的是 GitLab.com 公共实例
}
}
}
}
2、Streamable HTTP ------ "server 是远程 HTTP 服务"
监听 HTTP Path:/mcp
server 是个常驻 HTTP 服务,监听端口(比如 api.example.com/mcp ),可以在 远程服务器、云上、Docker 里 ,一份 server 服务多个用户。
标准并未强制要求端点必须是 /mcp。Server 可以提供任何路径(如 /、/api),只要在配置时告知 Client 即可。不过 /mcp 已经是事实上的社区约定。
client 通过 HTTP POST + SSE(Server-Sent Events)跨网络连过去。
消息协议:JSON
注意区分消息格式 vs. 传输通道
dart
// req
POST /mcp HTTP/1.1
Host: api.example.com
Content-Type: application/json
{"jsonrpc":"2.0","method":"tools/list","params":{},"id":1}
// resp
HTTP/1.1 200 OK
Content-Type: application/json
{"jsonrpc":"2.0","id":1,"result":{"tools":[...]}}
- 传输层 是 HTTP,负责把请求路由到服务器、携带认证头、处理状态码等。
- 消息体 依然是 JSON-RPC 2.0 格式,和 stdio 模式下的一模一样。
为什么不是 WebSocket
HTTP + SSE 比 WebSocket 更容易穿透防火墙、做负载均衡、无状态扩展,而且兼容现有的 SaaS 认证体系(OAuth 等)。
典型场景:后端官方 MCP
GitHub 官方 MCP、Notion MCP、Linear MCP 这种"云端 SaaS 接 MCP",你不可能让用户在本机跑他们的后端。
Client 侧实现:Claude Code
Client 侧一般需要注册 MCP,像下面这样:
json
{
"mcpServers": {
"chrome-devtools": {
"command": "npx",
"args": ["-y", "chrome-devtools-mcp@latest"]
}
}
}
// The MCP server will start the browser automatically once the MCP client uses a tool that requires a running browser instance. Connecting to the Chrome DevTools MCP server on its own will not automatically start the browser.
这本质上时 MCP Client 会创建一个 chrome-devtools-mcp@latest 的子进程,启动起来,并通过 stdin out 与它交互
| 能力 | 提供方 | 调用方 | 说明 |
|---|---|---|---|
tools/list |
Server | Client | 列出可用工具 |
resources/list |
Server | Client | 列出可读资源 |
prompts/list |
Server | Client | 列出提示模板 |
roots/list |
Client | Server | 获取工作区根目录,让 Server 能够针对项目根目录做资源扫描、文件搜索等操作,而不需要用户手动输入路径。 Client 在初始化时声明自己支持 roots 能力(capabilities.roots)。 Server 如果也支持 roots,就可以在需要时通过传输层向 Client 发送 roots/list 请求。 请求体里携带 "method": "roots/list",服务器(如果是 Client 端)根据方法名路由到相应处理函数,而不是根据 URL 路由。 |
Server 侧实现:Google 官方 MCP
比如 click 是模拟在页面上点击某个元素的行为

CLI:大模型更喜欢的 Tool🚩
MCP 的不足 & CLI 的优势
2026 年起,Perplexity、飞书、钉钉等头部厂商公开宣布放弃或降低 MCP 优先级,转向 CLI(命令行)与 Skills(技能文件)方案。CLI 凭借其透明度高、组合力强、可直接复用存量系统工具等优势,被视为更符合 AI Agent"母语"的交互方式。Skills 采用的"渐进式披露"设计,仅在需要时加载详细内容,极大节省了上下文资源。
MCP(Model Context Protocol,模型上下文协议)是 Anthropic 在 2024 年底推出的、旨在为大模型与外部工具之间提供通用通信标准的协议。它曾被誉为"AI 界的 USB 接口",但到 2026 年,业界普遍认为其已"过时"或"失宠",主要原因如下:
1、上下文窗口消耗 & Token 消耗
MCP 需要将所有工具的名称、描述、参数结构等全量注入 Agent 的上下文窗口。
一个 MCP 可能有 70 行描述信息,MCP 工具一多就很烧 Token
实测显示,仅连接 3 个 MCP Server 就可能消耗 20 万 token 窗口中约 72% 的空间,导致 Agent 实际可用的推理上下文严重不足。这不仅显著增加 token 成本,更引发"上下文腐烂"(context rot)------工具越多,模型注意力越分散,工具选择准确率可从 43% 骤降至 14% 以下。
CLI 怎么省 Token 的
CLI 分两部分
- 常见的命令行工具,在训练阶段模型就已经学会了,CLI 只要给一个 bash 工具,十几行足矣,能省 token 和上下文
- 对用户自定义的 CLI,通常是用 skill 的形式给他用,简单说就是说明文档(skill 渐进式披露)
CLI 可以组合工具调用
比如1、查看有哪些文件,2、找出其中符合条件的文件
- MCP 要两步,对应两次工具调用
- CLI 只要一步,因为步骤一的输出就是步骤二的输入,CLI 天生支持把这两个调用合在一起
2、开发成本
MCP 协议复杂度过高,开发体验差
为接入一个工具,开发者需编写独立的 MCP Server、定义 Schema、处理错误等,而直接调用 API 往往只需几行代码。MCP 的架构涉及多个独立进程与网络边界,初始化不稳定、认证繁琐(每个工具需单独认证),调试时需在多层日志中定位问题,体验远不如直接的 API/CLI 调用清晰。
示例:Lark-CLI(飞书官方 CLI)
css
npx @larksuite/cli@latest install
npx 一行命令安装即可,会自动把 Skill 注入到 Claude Code 等 AI 工具。
运行形态:子进程
本质:对飞书 OpenAPI 的封装
Example:CreateDoc 创建飞书文档
go
var DocsCreate = common.Shortcut{
Service: "docs",
Command: "+create",
Description: "Create a Lark document",
Risk: "write",
AuthTypes: []string{"user", "bot"},
Scopes: []string{"docx:document:create"},
PostMount: installDocsShortcutHelp("+create"),
Flags: concatFlags(
[]common.Flag{
docsAPIVersionCompatFlag(),
},
v2CreateFlags(),
v1CreateFlags(),
),
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateCreateV2(ctx, runtime)
},
// 不发请求,只 dump method + URL + body,e2e 测试
// 测试的时候你不想真的去调 LARK OPEN API(创建项目、修改文件之类的),但你想验证程序拼出来的请求对不对。DryRun 就是干这个的
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return dryRunCreateV2(ctx, runtime)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return executeCreateV2(ctx, runtime)
},
next
之后有机会讲讲工具调用可能遇到的问题,比如执行环境,沙箱,权限系统等等