深入理解AI Agent工具调用:从原理到代码实现
翻了不少文章,讲 AI Agent "能做什么"的多,讲"怎么做到的"少。 我第一次看 SDK 文档时有个疑问:LLM 没联网、没权限,它是怎么调用工具的? 跑完代码才发现------它根本没调。
tool_calls只是 LLM 输出的一段 JSON 格式文本,你的代码读到它之后,才真正去执行对应函数。整个过程 LLM 只做了一件事:写字。 这篇文章用一段不到 100 行的 Node.js 代码,把 Tool Calling 拆开给你看:
tools参数是写给谁看的- 为什么需要调两次 LLM
tool_calls和tool消息在messages里的顺序为什么不能错
一、现象:AI 怎么什么都能干?
你看到的"超能力"
豆包 可以自动搜索网页。比如你问"今天的世界杯有哪些比赛?",它会自动搜索最新赛程。背后需要两个工具:日期获取工具 + 网络搜索工具 。搜索流程是 web_search 搜出链接列表 → web_fetch 打开链接读全文 → LLM 整理回答。
Claude 可以分析 Excel 表格。背后需要一个工具:读取文件工具 。流程是 read_excel 把 .xlsx 二进制文件解析成纯文本 → LLM 读懂后分析、计算、总结。
AI Agent 可以操作电脑、发邮件、调API......这些能力哪来的?
核心洞察
这些看似"AI 什么都会"的能力,背后全都是同一套模式------LLM 决定要用什么工具,代码真正去执行。
二、本质:LLM + Tools = Agent
LLM 只负责"想"和"说",Tool 负责"动手"。Agent = LLM + Tools 的组合体。
精心设计的错觉
作为开发者,我们知道这是一个精心设计的错觉------让用户以为是 LLM 完成的,其实不是。
用户看到的是"豆包搜到了新闻",背后的真相是:LLM 说了句"我需要搜索"(tool_calls),代码去调了搜索引擎,LLM 把结果整理成话。用户只看到最后一步。
Agent 的魔力来自 LLM 的"决策力" + 代码的"执行力"------两者缺一不可。
三、核心悖论:LLM 的"文字世界" vs 真实世界
LLM 的本质:一个只能预测下一个词的概率模型(Next Token Prediction)。它被困在服务器里,没有系统权限------看不见屏幕,摸不到键盘,不能联网、不能读文件、不能调 API。
那它是怎么"调用API"、"读取文件"、"搜索网页"的?
答案:它从来没有突破这个限制
LLM 只是输出了一段特定格式的文字(tool_calls JSON)。你的代码读到这段文字,把它翻译成真实的函数调用,执行完再把结果以文字形式塞回去。
整个过程 LLM 始终在"文字世界"里,一步都没离开过。
比喻 :LLM 像被关在房间里的专家,只能通过纸条(文本)与外界交流。你给了它一本"工具说明书"(
tools参数),它需要帮助时就在纸条上写"请帮我调用 XXX 工具"(tool_calls)。你的代码就是那个跑腿的人,看到纸条就去执行,然后把结果写回来(tool消息)。LLM 从头到尾没离开过纸条和文字。
四、前置准备
准备工作: ① 安装依赖
npm init -y && npm install openai dotenv② 创建.env文件,写入 DEEPSEEK_API_KEY 和 DEEPSEEK_BASE_URL
第一步:连接 API
javascript
import OpenAI from 'openai';
import dotenv from 'dotenv';
dotenv.config();
const client = new OpenAI({
apiKey: process.env.DEEPSEEK_API_KEY,
baseURL: process.env.DEEPSEEK_BASE_URL
});
// 现在 client 就是你的"大模型热线"
第二步:写工具说明书(JSON Schema)
工具就是函数。LLM 看不懂代码,只看得懂文字。所以必须把复杂的函数降维成它看得懂的"使用说明书"。
javascript
const tools = [
{
type: "function",
function: {
name: "get_closing_price",
description: "获取股票的收盘价",
parameters: {
type: "object",
properties: {
name: {
type: "string",
description: "股票名称,如'贵州茅台'"
}
},
required: ["name"]
}
}
},
// 第二个工具:查天气(独立元素)
{
type: "function",
function: {
name: "get_weather",
description: "获取城市的天气",
parameters: {
type: "object",
properties: {
city: {
type: "string",
description: "城市名称,如'北京'"
}
},
required: ["city"]
}
}
}
];
注意 :每个工具是数组里的独立元素 ,不能把两个
function塞进同一个对象里。JS 对象里同名属性后面的会覆盖前面的。
为什么用 JSON Schema?
OpenAI API 的 tools 参数要求使用 JSON Schema 格式来描述函数。原因是:
- 标准化:JSON Schema 是业界标准,LLM 在训练时已经见过大量此类数据
- 类型安全 :定义了参数的类型和必填项,LLM 生成
arguments时会更准确 - 可验证:你的代码可以用 JSON Schema 校验器验证 LLM 生成的参数是否合法
parameters 是你出的"填空题题目",arguments 是 LLM 做完的"填空题答案"。
第三步:写真正的函数
javascript
// 工具函数------这才是真正干活的东西
function get_closing_price(name) {
if (name === '青岛啤酒') {
return '67.92';
} else if (name === '贵州茅台') {
return '1488.21';
} else {
return '未找到该股票';
}
}
第四步:封装 API 调用
javascript
async function sendMessage(messages) {
const response = await client.chat.completions.create({
model: "deepseek-chat",
messages,
tools,
tool_choice: "auto", // 让 LLM 自己决定要不要用工具
});
return response;
}
tool_choice 参数详解
tool_choice |
效果 | 使用场景 |
|---|---|---|
"auto" |
LLM 自己判断要不要用工具 | 通用场景(90% 的情况) |
"required" |
LLM 每次必须调用工具 | 需要严格查证的场景 |
"none" |
LLM 不准调用工具 | 纯闲聊 |
{ type: "function", function: { name: "xxx" } } |
强制用某个工具 | 专用机器人 |
五、核心概念串联
认知植入
在请求中传入 tools 参数,本质上就是在做"认知植入"------给 LLM 注入一段它训练时没有的知识。LLM 区分不了"训练时学到的知识"和"请求时塞进去的工具定义"。你说它有 get_closing_price 工具,它就信自己能查股价。
整个对话期间,LLM 不会怀疑"我到底有没有这个能力"------这就是认知植入的力量。
意图识别
用户问 → LLM 先查自己知识(回答不了)→ 回来看 tools 说明书 → 找到匹配工具 → 决定调用
LLM 有概率随机性,所以工具的 description 需要写得具体且清晰,否则 LLM 可能用错工具或不用工具。
tool_calls 从哪来?
| 层面 | 来源 |
|---|---|
| 格式(JSON 结构长什么样) | 训练时学的。LLM 见过大量 tool_calls 格式的对话样本 |
| 内容(具体调哪个工具、传什么参数) | 当场根据你的 tools 说明书 + 用户问题推导出来的 |
parameters(你的说明书)是"填空题的题目",arguments(LLM 的答案)是"填空题的作答"。
没有说明书,LLM 也会写 tool_calls,但不知道你的工具叫 get_closing_price 还是 get_stock_price。所以 description 写得准不准确,直接决定了 LLM 什么时候用、用什么工具。
六、执行流程
为什么需要两次调用? 第一次:LLM 发现数据不够,输出
tool_calls求救(决策 ) 第二次:把工具结果塞回去,LLM 组织成回答(回答)
步骤 1:准备第一句话
javascript
let messages = [
{ role: "user", content: "青岛啤酒的收盘价是多少?" }
];
// 此时对话历史就一条:用户的问题
步骤 2:第一次打电话
javascript
const response = await sendMessage(messages);
const message = response.choices[0].message;
console.log("模型返回的message对象 ", JSON.stringify(message, null, 2));
发出去的内容:聊天记录 + 工具说明书。大模型收到后开始推理:
大模型内部推理过程:
- 用户问"青岛啤酒收盘价" → 这是实时数据,我的训练数据回答不了
- 回头看"认知植入"给我的
tools说明书 → 有个get_closing_price工具,描述是"获取股票收盘价" - 用户问股价 ↔ 工具有查股价功能 → 应该调这个工具!
- 不瞎编 → 输出
tool_calls
大模型返回的内容 (注意 content 是 null):
json
{
"role": "assistant",
"content": null,
"tool_calls": [
{
"id": "call_abc123",
"type": "function",
"function": {
"name": "get_closing_price",
"arguments": "{\"name\":\"青岛啤酒\"}"
}
}
]
}
大模型停止了跟用户的对话,开始"自言自语"------tool_calls 是说给代码听的暗号,不是说给用户听的。
这里出现了最关键的信号 :content: null + tool_calls: [...]。正常对话时 content 有值、tool_calls 不存在;需要工具时 content 是 null、tool_calls 出现。代码靠 if(message.tool_calls) 判断该不该干活。
步骤 3:记录对话(必须放 if 外面!)
javascript
// 不管有没有调工具,都记录 LLM 的回复
messages.push({
role: message.role,
content: message.content,
tool_calls: message.tool_calls,
});
现在对话历史:
yaml
① { role: "user", content: "青岛啤酒收盘价?" }
② { role: "assistant", content: null, tool_calls: [...] }
步骤 4:检查求救信号
javascript
if (response.choices[0].message.tool_calls) {
// 有 tool_calls!大模型在求救,进去帮忙
}
if(message.tool_calls) 是整个 Tool Calling 机制的连接点。没有这行判断,LLM 的求救信号就石沉大海。它就是"大脑"和"手脚"之间的神经突触------LLM 想好了要干什么,这行代码决定要不要帮它干。
步骤 5:拆求救信
javascript
const toolCall = response.choices[0].message.tool_calls[0];
// toolCall.function.name = "get_closing_price"
// toolCall.function.arguments = '{"name":"青岛啤酒"}'
步骤 6:执行真正的函数
javascript
if (toolCall.function.name === 'get_closing_price') {
const args = JSON.parse(toolCall.function.arguments);
// JSON.parse 把字符串 '{"name":"青岛啤酒"}' 转成对象 { name: "青岛啤酒" }
const price = get_closing_price(args.name);
// 执行真函数!传入 "青岛啤酒" → 函数返回 "67.92"
}
LLM 没有执行任何东西! 它只是输出了函数名和参数。是你的代码在这里真正调用了 get_closing_price。
对 LLM 来说,
tool_calls和"你好"没有本质区别,都只是文字接龙的下一个词。它从头到尾只是在"预测下一个词"------而这个词碰巧是 JSON 格式,碰巧被你的代码解析成了函数调用。
步骤 7:记录工具结果(必须放 if 里面!)
javascript
messages.push({
role: "tool",
tool_call_id: toolCall.id, // 关联到步骤 5 的求救信
content: price // 工具返回的结果
});
现在对话历史:
css
① { role: "user", content: "青岛啤酒收盘价?" }
② { role: "assistant", content: null, tool_calls: [...] }
③ { role: "tool", tool_call_id: "xxx", content: "67.92" }
messages 顺序不能颠倒! 必须是 用户问 → LLM决定调工具 → 工具返回结果。如果顺序错了,LLM 看到的是"工具结果凭空出现了,但我还没说要调工具呢"------它会直接忽略工具结果,自己瞎编。
步骤 8:第二次打电话,组织回答
javascript
const finalRes = await sendMessage(messages);
console.log('最终回答:', finalRes.choices[0].message.content);
// 输出:青岛啤酒的收盘价是 67.92 元。
大模型这次收到的完整上下文:
- 用户问:"青岛啤酒收盘价?"
- 我(LLM)上次说:"调 get_closing_price(青岛啤酒)"
- 工具返回:"67.92"
大模型这次不再输出 tool_calls(因为数据已经有了),而是直接用工具结果组织成自然语言回答。
七、多工具路由
一个工具到多个工具,只需要在 if/else 链上增加分支:
javascript
if (toolCall.function.name === 'get_closing_price') {
const args = JSON.parse(toolCall.function.arguments);
const result = get_closing_price(args.name);
messages.push({ role: "tool", tool_call_id: toolCall.id, content: result });
} else if (toolCall.function.name === 'get_weather') {
const args = JSON.parse(toolCall.function.arguments);
const result = get_weather(args.city);
messages.push({ role: "tool", tool_call_id: toolCall.id, content: result });
}
架构不需要变,只加一个 else if 就行。
八、生产环境错误处理
工具调用可能因为各种原因失败:网络超时、参数格式错误、数据源不可用等。
javascript
try {
const result = get_closing_price(args.name);
messages.push({
role: "tool",
tool_call_id: toolCall.id,
content: result
});
} catch (error) {
// 把错误信息也当成"工具结果"塞回去
messages.push({
role: "tool",
tool_call_id: toolCall.id,
content: `查询失败:${error.message},请稍后重试`
});
console.error('工具执行失败:', error);
}
// 继续第二次调用,LLM 会读到错误信息并友好地告诉用户
const finalRes = await sendMessage(messages);
核心思想:永远不要直接报错给用户。把错误信息"喂"给 LLM,让它用自然语言组织成友好的提示。
九、陷阱:流式输出与 Tool Calling 的冲突
开了 stream: true 后,tool_calls 是分块(delta)返回的,不能直接使用。
问题本质
json
// 你期望一次性收到:
{
"tool_calls": [{
"function": { "name": "get_closing_price", "arguments": "{\"name\":\"青岛啤酒\"}" }
}]
}
// 流式实际收到的是碎片(delta):
第1块: {"tool_calls":[{"function":{"name":"get_c"}}
第2块: "losing_price","arguments":"{\"name\":"
第3块: "\"青岛啤酒\"}"}}]}
解决方案:手动拼接
javascript
let toolCallAccumulator = {};
for await (const chunk of stream) {
const delta = chunk.choices[0].delta;
if (delta.tool_calls) {
for (const tc of delta.tool_calls) {
const index = tc.index || 0;
if (!toolCallAccumulator[index]) {
toolCallAccumulator[index] = { function: { name: '', arguments: '' } };
}
if (tc.function?.name) {
toolCallAccumulator[index].function.name += tc.function.name;
}
if (tc.function?.arguments) {
toolCallAccumulator[index].function.arguments += tc.function.arguments;
}
}
}
}
// 流结束后,拼接完整
const toolCalls = Object.values(toolCallAccumulator);
最佳实践
javascript
// 第一次调用:不用流式,确保完整拿到 tool_calls
const response = await client.chat.completions.create({
model: "deepseek-chat",
messages,
tools,
tool_choice: "auto",
stream: false, // ← 关键
});
// 执行工具...
// 第二次调用:可以用流式输出给用户
const stream = await client.chat.completions.create({
model: "deepseek-chat",
messages,
stream: true, // ← 这时候可以开了
});
for await (const chunk of stream) {
process.stdout.write(chunk.choices[0]?.delta?.content || '');
}
最佳实践 :决策阶段关流式,回答阶段开流式。工具调用需要完整 JSON,流式只会增加复杂度。
十、完整执行流程图
messages 顺序示意
十一、总结
核心思想一句话
LLM 是只会写纸条的"文字接龙大师"。你告诉它工具有哪些(
tools),它需要时就写张纸条(tool_calls)递出来。你的代码读到纸条后去干活,干完把结果写成纸条塞回去(tool消息)。LLM 从头到尾没离开过文字世界。
messages 两条铁律
| 操作 | 放哪里 | 原因 |
|---|---|---|
push(message) --- LLM 的回复 |
if 外面 | 不管有没有调工具,都要记 |
push({role:"tool", ...}) --- 工具结果 |
if 里面 | 只有调了工具才有结果 |
关键要点速览
| 概念 | 一句话解释 |
|---|---|
| LLM | 只会预测下一个词的"文字接龙大师" |
| Tool | 真正干活的函数(你的代码) |
| Agent | LLM + Tools 的组合体 |
| 认知植入 | 通过 tools 参数告诉 LLM 它有哪些工具 |
| tool_calls | LLM 写给代码的"求救纸条" |
| JSON Schema | 把函数翻译成 LLM 能看懂的说明书 |
| messages | LLM 的"记忆线",顺序决定一切 |
| 两次调用 | 第一次决策,第二次回答 |
写在最后
Tool Calling 不是什么神奇魔法,它是一套精心设计的"大脑 ↔ 手脚"通信协议。理解了这个机制,你就掌握了构建 AI Agent 的基石。
现在,轮到你用代码去"指挥"LLM 了!🚀