深入理解AI Agent工具调用:从原理到代码实现

深入理解AI Agent工具调用:从原理到代码实现

翻了不少文章,讲 AI Agent "能做什么"的多,讲"怎么做到的"少。 我第一次看 SDK 文档时有个疑问:LLM 没联网、没权限,它是怎么调用工具的? 跑完代码才发现------它根本没调。tool_calls 只是 LLM 输出的一段 JSON 格式文本,你的代码读到它之后,才真正去执行对应函数。整个过程 LLM 只做了一件事:写字。 这篇文章用一段不到 100 行的 Node.js 代码,把 Tool Calling 拆开给你看:

  • tools 参数是写给谁看的
  • 为什么需要调两次 LLM
  • tool_callstool 消息在 messages 里的顺序为什么不能错

一、现象:AI 怎么什么都能干?

你看到的"超能力"

豆包 可以自动搜索网页。比如你问"今天的世界杯有哪些比赛?",它会自动搜索最新赛程。背后需要两个工具:日期获取工具 + 网络搜索工具 。搜索流程是 web_search 搜出链接列表 → web_fetch 打开链接读全文 → LLM 整理回答。

Claude 可以分析 Excel 表格。背后需要一个工具:读取文件工具 。流程是 read_excel 把 .xlsx 二进制文件解析成纯文本 → LLM 读懂后分析、计算、总结。

AI Agent 可以操作电脑、发邮件、调API......这些能力哪来的?

核心洞察

这些看似"AI 什么都会"的能力,背后全都是同一套模式------LLM 决定要用什么工具,代码真正去执行。

flowchart LR A[用户提问] --> B[LLM 决策] B -->|输出 tool_calls| C[你的代码] C -->|执行| D[Tool 函数] D -->|返回结果| B B -->|组织回答| E[用户]

二、本质: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 格式来描述函数。原因是:

  1. 标准化:JSON Schema 是业界标准,LLM 在训练时已经见过大量此类数据
  2. 类型安全 :定义了参数的类型和必填项,LLM 生成 arguments 时会更准确
  3. 可验证:你的代码可以用 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));

发出去的内容:聊天记录 + 工具说明书。大模型收到后开始推理:

大模型内部推理过程:

  1. 用户问"青岛啤酒收盘价" → 这是实时数据,我的训练数据回答不了
  2. 回头看"认知植入"给我的 tools 说明书 → 有个 get_closing_price 工具,描述是"获取股票收盘价"
  3. 用户问股价 ↔ 工具有查股价功能 → 应该调这个工具!
  4. 不瞎编 → 输出 tool_calls

大模型返回的内容 (注意 contentnull):

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 不存在;需要工具时 contentnulltool_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,流式只会增加复杂度。

十、完整执行流程图

flowchart TD A[用户提问] --> B[第1次调 LLM] B --> C{有 tool_calls?} C -->|否| D[直接返回回答] C -->|是| E[记录 LLM 回复] E --> F[执行工具函数] F --> G[记录工具结果] G --> H[第2次调 LLM] H --> I[组织最终回答] I --> J[输出给用户] D --> J

messages 顺序示意

sequenceDiagram participant 代码 participant LLM 代码->>LLM: ① 用户: "青岛啤酒收盘价?" LLM-->>代码: ② Assistant: tool_calls 代码->>代码: 执行 get_closing_price 代码->>LLM: ③ Tool: "67.92" LLM-->>代码: ④ Assistant: "67.92 元" Note over 代码: 顺序不能乱!

十一、总结

核心思想一句话

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 了!🚀

相关推荐
yLDeveloper1 小时前
从矩阵乘法到多模态大模型 - LLM 篇
llm·nlp
Sokach10152 小时前
Windows使用hermes桌面端个人出现的问题
agent
leeyi2 小时前
Agent Transfer:让 AI 把任务交给更合适的 AI
aigc·agent·ai编程
后端小肥肠2 小时前
Codex + Obsidian 做人生副本视频:输入主题文案,直通剪映草稿
人工智能·aigc·agent
花椒技术3 小时前
Agent 不只会聊天:我们如何用 CLI 整理业务能力入口
agent·ai编程·mcp
DigitalOcean3 小时前
在云端运行 Codex —— DigitalOcean Codex 插件正式推出
agent
FanetheDivine4 小时前
学习Agent开发6 langgraph速览
agent·ai编程
前端君7 小时前
Claude Code 如何配置本地Ollama模型或别的模型(Deepseek等)
llm·agent·claude
程序员小假7 小时前
RAG文档存储与切割策略详解:从基础到进阶
agent