Tool Use 背后的技术逻辑
不只是 API 调用。
引子:那些"聪明"的 AI
- 豆包可以自动搜索网页------两个工具:日期获取工具、网络搜索工具
- Claude 可以分析 Excel 表格------两个工具:读取文件工具、Excel 分析工具
- AI Agent 可以操作电脑
Agent = LLM + tools
AI 有自我意识吗?作为开发者,这是一个精心设计的错觉------用户看到的是 LLM"完成"了工作,其实是它调用了 tools。
在显卡里疯狂跑的 LLM,本质上还是词语接龙游戏。它是被困在服务器里的大脑,看不见屏幕、摸不到键盘。一个只能做 Next Token Prediction 的概率模型,是怎么突破物理限制、调用 API、读数据库、操作物理世界的?
答案就是 Tool Use。
三阶段模型
工具是函数。LLM 能调用工具,靠的是三个阶段:
阶段一:认知植入
在执行任务之前,在 system prompt 里配置工具 的时候,就在做一件非常精妙的事------认知植入。
让 Tool 成为语言------用语言描述函数是什么、作用是什么、需要什么参数、返回什么结果。LLM 不懂什么是天气 API,也不懂数据库查询,但它听得懂语言。
JSON Schema 就是将复杂的软件接口函数,翻译成 LLM 能理解的"使用说明书"。
- 外层用 JSON 声明工具格式(函数名、描述、参数列表)
- 内层
parameters字段用 JSON Schema 约束参数类型(string、number、required)
因为 LLM 有概率随机性,工具描述必须具体清晰。
json
{
"type": "function",
"function": {
"name": "get_closing_price",
"description": "获取指定股票的收盘价",
"parameters": {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "股票名称"
}
},
"required": ["name"]
}
}
}
在这个阶段,一个复杂的软件工具(get_closing_price),被降维成了一个纯粹的文本描述(JSON Schema)。用户提问"青岛啤酒的收盘价是多少?",LLM 回答不了------但它知道自己有工具可以用。
阶段二:意图识别
LLM 开始推理:训练数据里没有实时股价 → 回答不了 → 检查认知植入的工具 → 发现有 get_closing_price → 决定调用工具。
LLM 不再直接回复用户,而是输出 tool_calls------严格按照 JSON Schema 说明书,生成结构化的工具调用指令。注意 content 可能是空字符串,也可能附带一句过渡语(如"我来帮您查询"),取决于模型实现:
json
{
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": "call_xxx",
"type": "function",
"function": {
"name": "get_closing_price",
"arguments": "{\"name\": \"青岛啤酒\"}"
}
}
]
}
LLM 不能执行工具------开发者可以!LLM 只负责通过模式识别和推理,输出"要调哪个函数、传什么参数",实际执行完全在模型之外。
阶段三:你的代码介入
LLM 输出了 tool_calls 就停了。接下来是应用层代码(Node/Python/Java 等)接管------解析 tool_calls,匹配函数名,传入参数,真正执行工具函数,拿到结果。
关键点:结果不是直接返回给用户 ,而是以 tool 角色塞回 messages,再次发给大模型。大模型根据完整上下文(用户原始问题 → 自己做了什么决策 → 工具返回了什么结果)生成最终回复。
实战:消息流转全解
以 index.mjs 中"查询青岛啤酒收盘价"为例,拆解每一阶段 messages 数组的变化。
核心角色
| 角色 | 谁产生 | 含义 |
|---|---|---|
user |
用户/前端 | 用户提的问题 |
assistant |
大模型 | 大模型说的话(或工具调用指令) |
tool |
你的代码 | 工具函数的执行结果 |
阶段 0:初始 messages
js
let messages = [{ role: 'user', content: '青岛啤酒的收盘价为多少?' }];
json
[
{ "role": "user", "content": "青岛啤酒的收盘价为多少?" }
]
就一条用户问题,这是整个对话的起点。
阶段 1:第一轮调用大模型 → 对应三阶段模型的"意图识别"
js
const response = await sendMessage(messages);
大模型看到用户问股价,训练数据里没有实时股价,于是检查自己的工具列表------发现了 get_closing_price,不返回普通文字,而是返回 tool_calls:
json
{
"role": "assistant",
"content": "我来帮您查询青岛啤酒的收盘价。",
"tool_calls": [
{
"index": 0,
"id": "call_00_B05dAElKlxAKyio9AXDN4208",
"type": "function",
"function": {
"name": "get_closing_price",
"arguments": "{\"name\": \"青岛啤酒\"}"
}
}
]
}
tool_calls 结构
| 字段 | 含义 |
|---|---|
id |
本次调用的唯一标识,后面 tool 消息靠它关联 |
type |
固定为 "function" |
function.name |
要调用的函数名 |
function.arguments |
JSON 字符串,传入的参数 |
content 和 tool_calls 可以同时存在------content 是对用户说的"客气话",tool_calls 才是真正的动作。但大模型只会"说"要调什么,它自己不会执行。
大模型怎么知道有哪些工具------这正是"认知植入"
js
const res = await client.chat.completions.create({
model: 'deepseek-v4-pro',
messages,
tools, // ← 认知植入:JSON Schema 描述的函数说明书
tool_choice: 'auto'
});
大模型不懂什么是 API,但它读得懂语言。这就是将函数降维为语言 ------把代码函数翻译成大模型能理解的文本描述。tool_choice: 'auto' 表示让大模型自己判断要不要用工具。
阶段 2:push assistant 消息
js
messages.push({
role: message.role,
content: message.content,
tool_calls: message.tool_calls
});
json
[
{ "role": "user", "content": "青岛啤酒的收盘价为多少?" },
{
"role": "assistant",
"content": "我来帮您查询青岛啤酒的收盘价。",
"tool_calls": [
{
"id": "call_xxx",
"type": "function",
"function": { "name": "get_closing_price", "arguments": "{\"name\":\"青岛啤酒\"}" }
}
]
}
]
这一步告诉上下文:"刚才大模型看过用户问题,决定要调 get_closing_price('青岛啤酒')"。这是对话历史的一部分,第二轮调用时需要这个上下文。
常见 Bug :如果这里 push 了两遍 assistant 消息,API 会报
insufficient tool messages following tool_calls message------每个带 tool_calls 的 assistant 消息后面都必须跟对应的 tool 消息。
阶段 3:执行工具 → 对应三阶段模型的"代码介入"
js
if (response.choices[0].message.tool_calls) {
const toolCall = response.choices[0].message.tool_calls[0];
为什么取 0
tool_calls 是数组,大模型可以一次要求同时调多个工具。比如用户问"青岛啤酒收盘价和北京天气",大模型可能返回两个 tool_calls。这里取 [0] 是简化处理。
为什么要 JSON.parse
js
const args = JSON.parse(toolCall.function.arguments);
// "{\"name\": \"青岛啤酒\"}" → { name: "青岛啤酒" }
const price = get_closing_price(args.name); // "67.92"
arguments 是字符串 ,不是 JS 对象。不 parse 拿不到 .name 属性。
push 工具结果
js
messages.push({
role: 'tool',
content: price, // "67.92"
tool_call_id: toolCall.id // "call_xxx"
});
json
[
{ "role": "user", "content": "青岛啤酒的收盘价为多少?" },
{ "role": "assistant", "content": "...", "tool_calls": [{"id": "call_xxx", ...}] },
{ "role": "tool", "content": "67.92", "tool_call_id": "call_xxx" }
]
tool_call_id 跟 assistant 消息里的 tool_calls[0].id 配对------大模型靠这个 id 知道 "67.92" 是"收盘价查询的结果",而不是天气查询的结果。
关键:结果不是直接返回给用户,而是返回给大模型。
阶段 4:第二轮调用大模型
js
const finalRes = await sendMessage(messages);
大模型看到完整上下文------用户问了股价、自己决定调了 get_closing_price("青岛啤酒")、工具返回了 "67.92"------于是消化成自然语言:
json
{
"role": "assistant",
"content": "青岛啤酒的收盘价为 67.92 元。"
}
完整流水图
csharp
messages 变化过程(4 条消息,2 轮 API 调用):
[user]
│
├─ 第 1 轮 sendMessage ────────── 意图识别:大模型决策调哪个工具
│
▼
[user, assistant(tool_calls)]
│
├─ 你的代码执行 get_closing_price ── 代码介入:实际执行工具
│
▼
[user, assistant(tool_calls), tool(67.92)]
│
├─ 第 2 轮 sendMessage ────────── 大模型消化结果,生成回复
│
▼
[user, assistant(tool_calls), tool, assistant]
两轮调用的区别
| 第一轮 | 第二轮 | |
|---|---|---|
| 大模型角色 | 决策者------要不要调工具、调哪个 | 总结者------把工具结果变成人话 |
| 对应阶段 | 意图识别 | 代码介入后的收尾 |
| messages 里有 tool 吗 | ❌ | ✅ |
| 返回值 | tool_calls(调工具的指令) |
纯 content(给用户的答案) |
| 谁执行 | 大模型只出指令 | 大模型只是解释结果 |
本质
Agent = LLM + Tools 的深层含义:
- LLM 是大脑:做决策、理解语言、生成回复
- Tools 是手脚:查数据库、调 API、操作文件
- 应用代码是神经系统:连接大脑和手脚,把 LLM 的决策翻译成函数调用、把工具结果翻译回对话上下文
整个 Tool Use 机制就是这三者之间的消息传递协议。用户看到的"AI 很聪明"的错觉,来自这个精心设计的三阶段流水线------LLM 负责决策和表达,tools 负责执行,而串联这一切的,是 messages 数组里那几条 role 不同的 JSON 消息。