详情

首页手游攻略 深入掌握AI Agent工具调用:从原理到代码实现

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

佚名 2026-06-30 09:04:05

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

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

你看到的"超能力"

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

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

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 始终在"文字世界"里,一步都没离开过。

四、前置准备

第一步:连接 API

 复制代码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 看不懂代码,只看得懂文字。所以必须把复杂的函数降维成它看得懂的"使用说明书"

 复制代码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"]
      }
    }
  }
];

为什么用 JSON Schema?

OpenAI API 的 tools 参数要求使用 JSON Schema 格式来描述函数。原因是:

  1. 标准化:JSON Schema 是业界标准,LLM 在训练时已经见过大量此类数据
  2. 类型安全:定义了参数的类型和必填项,LLM 生成 arguments 时会更准确
  3. 可验证:你的代码可以用 JSON Schema 校验器验证 LLM 生成的参数是否合法

parameters 是你出的"填空题题目",arguments 是 LLM 做完的"填空题答案"。

第三步:写真正的函数

 复制代码// 工具函数——这才是真正干活的东西
function get_closing_price(name) {
  if (name === '青岛啤酒') {
    return '67.92';
  } else if (name === '贵州茅台') {
    return '1488.21';
  } else {
    return '未找到该股票';
  }
}

第四步:封装 API 调用

 复制代码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 先查自己知识(回答不了)→ 回来看 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 什么时候用、用什么工具。

六、执行流程

步骤 1:准备第一句话

 复制代码let messages = [
  { role: "user", content: "青岛啤酒的收盘价是多少?" }
];
// 此时对话历史就一条:用户的问题

步骤 2:第一次打电话

 复制代码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):

 复制代码{
  "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 外面!)

 复制代码// 不管有没有调工具,都记录 LLM 的回复
messages.push({
  role: message.role,
  content: message.content,
  tool_calls: message.tool_calls,
});

现在对话历史:

 复制代码① { role: "user", content: "青岛啤酒收盘价?" }
② { role: "assistant", content: null, tool_calls: [...] }

步骤 4:检查求救信号

 复制代码if (response.choices[0].message.tool_calls) {
  // 有 tool_calls!大模型在求救,进去帮忙
}

if(message.tool_calls)整个 Tool Calling 机制的连接点。没有这行判断,LLM 的求救信号就石沉大海。它就是"大脑"和"手脚"之间的神经突触——LLM 想好了要干什么,这行代码决定要不要帮它干。

步骤 5:拆求救信

 复制代码const toolCall = response.choices[0].message.tool_calls[0];
// toolCall.function.name = "get_closing_price"
// toolCall.function.arguments = '{"name":"青岛啤酒"}'

步骤 6:执行真正的函数

 复制代码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

步骤 7:记录工具结果(必须放 if 里面!)

 复制代码messages.push({
  role: "tool",
  tool_call_id: toolCall.id,  // 关联到步骤 5 的求救信
  content: price              // 工具返回的结果
});

现在对话历史:

 复制代码① { role: "user", content: "青岛啤酒收盘价?" }
② { role: "assistant", content: null, tool_calls: [...] }
③ { role: "tool", tool_call_id: "xxx", content: "67.92" }

步骤 8:第二次打电话,组织回答

 复制代码const finalRes = await sendMessage(messages);
console.log('最终回答:', finalRes.choices[0].message.content);
// 输出:青岛啤酒的收盘价是 67.92 元。

大模型这次收到的完整上下文:

  • 用户问:"青岛啤酒收盘价?"
  • 我(LLM)上次说:"调 get_closing_price(青岛啤酒)"
  • 工具返回:"67.92"

大模型这次不再输出 tool_calls(因为数据已经有了),而是直接用工具结果组织成自然语言回答。

七、多工具路由

一个工具到多个工具,只需要在 if/else 链上增加分支:

 复制代码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 就行。

八、生产环境错误处理

工具调用可能因为各种原因失败:网络超时、参数格式错误、数据源不可用等。

 复制代码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);

九、陷阱:流式输出与 Tool Calling 的冲突

开了 stream: true 后,tool_calls 是分块(delta)返回的,不能直接使用。

问题本质

 复制代码// 你期望一次性收到:
{
  "tool_calls": [{
    "function": { "name": "get_closing_price", "arguments": "{"name":"青岛啤酒"}" }
  }]
}// 流式实际收到的是碎片(delta):1块: {"tool_calls":[{"function":{"name":"get_c"}}2块: "losing_price","arguments":"{"name":"3块: ""青岛啤酒"}"}}]}

解决方案:手动拼接

 复制代码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);

最佳实践

 复制代码// 第一次调用:不用流式,确保完整拿到 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 || '');
}

十、完整执行流程图

messages 顺序示意

十一、总结

核心思想一句话

messages 两条铁律

操作放哪里原因
push(message) — LLM 的回复if 外面不管有没有调工具,都要记
push({role:"tool", ...}) — 工具结果if 里面只有调了工具才有结果

关键要点速览

概念一句话解释
LLM只会预测下一个词的"文字接龙大师"
Tool真正干活的函数(你的代码)
AgentLLM + Tools 的组合体
认知植入通过 tools 参数告诉 LLM 它有哪些工具
tool_callsLLM 写给代码的"求救纸条"
JSON Schema把函数翻译成 LLM 能看懂的说明书
messagesLLM 的"记忆线",顺序决定一切
两次调用第一次决策,第二次回答

写在最后

Tool Calling 不是什么神奇魔法,它是一套精心设计的"大脑 ↔ 手脚"通信协议。理解了这个机制,你就掌握了构建 AI Agent 的基石。

现在,轮到你用代码去"指挥"LLM 了!

点击查看更多
推荐专题
热门阅读