tc9011

Tool Calling 不是让模型执行代码:而是在建立行动闭环

16 min

一旦你真的把 LLM 接进业务系统,很快就会碰到一个分界点:用户不再只问“解释一下”,而开始要求系统“帮我查一下”“帮我算一下”“帮我看看这个文件”。这时候,模型光会组织自然语言已经不够了,系统必须开始接触外部能力。

但这里有个特别危险的误解:很多人说“让模型调用函数”,脑子里想象的是模型直接进入运行时、拿到执行权限、自己把代码跑了。不是。模型不会执行你的函数。它只会根据你暴露的工具描述,生成一份结构化调用请求。真正执行动作的始终是你的程序。

这条边界如果不先说清楚,后面一谈 Agent,很容易滑向一种模糊又不安全的幻觉:好像只要给模型接几个函数,它就天然会做事。实际上,它只是开始会“发指令”,而不是获得了“执行权”。

为什么 Tool Calling 是 Agent 架构里的第一道硬分界

没有工具时,模型基本只能停留在文本世界:

  • 解释问题
  • 改写内容
  • 做一些封闭式推理
  • 猜一个大概率正确的答案

有了工具以后,系统才第一次形成真正的行动闭环:

  1. 模型判断要不要借助外部能力
  2. 模型产出工具名和参数
  3. 应用决定是否执行,以及怎么执行
  4. 工具返回真实结果
  5. 模型基于结果组织最终回答

这和“更会聊天”完全不是一回事。前者是在建系统闭环,后者只是把话说漂亮。

为什么这件事重要

因为只要 Tool Calling 这条边界不清楚,后面几乎所有治理问题都会失控:

  • 你不知道权限该放在哪一层
  • 你不知道参数校验该由谁做
  • 你不知道为什么工具返回值还要再喂回模型
  • 你不知道多步调用里到底哪一步出了错

下面这个例子虽然只演示天气查询和加法,但它已经把 Agent 最小行动闭环完整摊开了。

完整代码

// 03-tool-calling-basic.js
// 目标:让 AI 调用函数 (Function Calling)
// 这是 Agent 能够与外部世界交互的基础。

import { GoogleGenerativeAI } from '@google/generative-ai';
import dotenv from 'dotenv';
dotenv.config();

const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY);

// 1. 定义我们的工具函数 (真正的逻辑)
const tools = {
  // 模拟一个天气查询 API
  getWeather: ({ city }) => {
    console.log(`[System] 正在查询 ${city} 的天气...`);
    const weatherData = {
      "Shanghai": "Sunny, 25°C",
      "Beijing": "Cloudy, 18°C",
      "London": "Rainy, 12°C"
    };
    return weatherData[city] || "Unknown weather data for this city.";
  },

  // 模拟一个简单的计算器
  add: ({ a, b }) => {
    console.log(`[System] 计算 ${a} + ${b}...`);
    return a + b;
  }
};

// 2. 定义工具的 Schema (告诉 LLM 这些工具长什么样)
// Gemini 使用标准的 OpenAPI Schema 格式
const toolsSchema = [
  {
    functionDeclarations: [
      {
        name: "getWeather",
        description: "Get the current weather in a given city.",
        parameters: {
          type: "OBJECT",
          properties: {
            city: {
              type: "STRING",
              description: "The city name, e.g. 'Shanghai', 'New York'",
            },
          },
          required: ["city"],
        },
      },
      {
        name: "add",
        description: "Add two numbers together.",
        parameters: {
          type: "OBJECT",
          properties: {
            a: { type: "NUMBER", description: "First number" },
            b: { type: "NUMBER", description: "Second number" },
          },
          required: ["a", "b"],
        },
      },
    ],
  },
];

async function main() {
  // 3. 初始化带有工具的模型
  const model = genAI.getGenerativeModel({
    model: "gemini-flash-latest", // 还是用这个最稳的模型
    tools: toolsSchema,
  });

  const chat = model.startChat();

  console.log("🤖 Agent with Tools 在线。");
  console.log("试试问它:'上海今天天气怎么样?' 或者 '33 加 44 等于多少?'\n");

  // 4. 发送一个 Query
  const prompt = "上海今天天气怎么样?";
  console.log(`User: ${prompt}`);

  const result = await chat.sendMessage(prompt);
  const response = result.response;

  // 5. 检查 LLM 是否想要调用工具
  // Gemini 的 response.functionCalls() 会返回调用请求
  const functionCalls = response.functionCalls();

  if (functionCalls && functionCalls.length > 0) {
    const call = functionCalls[0];
    const { name, args } = call;
    console.log(`\n👉 LLM 决定调用工具: ${name}(${JSON.stringify(args)})`);

    // 6. 执行工具代码
    const toolResult = tools[name](args);
    console.log(`✅ 工具执行结果: ${toolResult}`);

    // 7. 把结果喂回给 LLM (这一步很关键)
    // 我们必须告诉 LLM: "你刚才调用的 get_weather 结果是 'Sunny, 25°C'"
    const result2 = await chat.sendMessage([
      {
        functionResponse: {
          name: name,
          response: { result: toolResult } // 格式必须是 { result: ... }
        }
      }
    ]);

    // 8. 获取最终的自然语言回答
    console.log(`\nAI: ${result2.response.text()}`);
  } else {
    // 如果 LLM 不需要调用工具,直接打印回答
    console.log(`\nAI: ${response.text()}`);
  }
}

main();

先看一个真实运行结果

当前版本运行 node 03-tool-calling-basic.js,你会看到类似下面的输出:

🤖 Agent with Tools 在线。
试试问它:'上海今天天气怎么样?' 或者 '33 加 44 等于多少?'

User: 上海今天天气怎么样?

👉 LLM 决定调用工具: getWeather({"city":"Shanghai"})
[System] 正在查询 Shanghai 的天气...
✅ 工具执行结果: Sunny, 25°C

AI: 上海今天晴,气温 25°C。

这个输出很适合放在代码前面看,因为它把整条调用链直接摊开了:模型先决定调用哪个工具,你的程序执行工具,再把结果回给模型生成最终答案。

先看这段代码真正建立了什么

它表面上是个天气 + 计算器 demo,实际上把 Agent 的核心闭环拆成了四层:

  1. 真实工具实现:你程序里真能执行的能力
  2. 工具 schema:暴露给模型看的能力说明书
  3. 模型调用请求:模型根据 schema 产出的结构化指令
  4. 结果回流:工具执行后的结果再喂给模型生成最终回答

只要这四层在脑子里足够清楚,后面你无论换成哪个框架,基本都不会迷路。

按责任边界拆代码

1. tools:这里才是真正能动手的系统能力

const tools = {
  getWeather: ({ city }) => {
    console.log(`[System] 正在查询 ${city} 的天气...`);
    const weatherData = {
      "Shanghai": "Sunny, 25°C",
      "Beijing": "Cloudy, 18°C",
      "London": "Rainy, 12°C"
    };
    return weatherData[city] || "Unknown weather data for this city.";
  },
  add: ({ a, b }) => {
    console.log(`[System] 计算 ${a} + ${b}...`);
    return a + b;
  }
};

这一层运行在你的程序里,是真实执行层。

天气数据虽然是 mock 的,但不影响它表达清楚工程边界:真正触达外部世界的,是这些工具函数,而不是模型本身。换成生产场景,这里完全可能是:

  • 调公司内部 API
  • 查数据库
  • 读文件系统
  • 创建工单
  • 发消息或执行命令

2. toolsSchema:模型只能看见能力描述,看不见实现细节

const toolsSchema = [
  {
    functionDeclarations: [
      {
        name: "getWeather",
        description: "Get the current weather in a given city.",
        parameters: {
          type: "OBJECT",
          properties: {
            city: {
              type: "STRING",
              description: "The city name, e.g. 'Shanghai', 'New York'",
            },
          },
          required: ["city"],
        },
      },
      {
        name: "add",
        description: "Add two numbers together.",
        parameters: {
          type: "OBJECT",
          properties: {
            a: { type: "NUMBER", description: "First number" },
            b: { type: "NUMBER", description: "Second number" },
          },
          required: ["a", "b"],
        },
      },
    ],
  },
];

模型不知道你的 JavaScript 代码长什么样。它看到的只有这份 schema。也就是说,从模型视角看,它只是收到一份当前可用能力说明:

  • 工具叫什么
  • 适合解决什么问题
  • 需要哪些参数
  • 参数类型和限制是什么

这也是为什么 Tool Calling 的稳定性,往往更依赖 schema 设计质量,而不是 prompt 写得多花。

3. 初始化模型:注册的是“可请求工具”,不是“执行权限”

const model = genAI.getGenerativeModel({
  model: "gemini-flash-latest",
  tools: toolsSchema,
});

const chat = model.startChat();

这里很多人会误会成“把函数注册给模型了”。更准确的描述应该是:

你告诉模型,在这一轮对话里,你可以请求这些能力。

注意,是“可以请求”,不是“可以执行”。执行权仍然在应用手里,这一点对安全控制至关重要。

4. 第一轮推理:模型负责判断要不要借工具

const result = await chat.sendMessage(prompt);
const response = result.response;
const functionCalls = response.functionCalls();

这里是系统的第一个关键分叉:

  • 如果模型觉得自己可以直接回答,就直接输出自然语言
  • 如果模型判断需要外部能力,就返回 functionCalls()

也就是说,模型在这里做的是决策,不是执行。

5. 执行层:你的程序收到指令后才真正开始做事

const call = functionCalls[0];
const { name, args } = call;
console.log(`\n👉 LLM 决定调用工具: ${name}(${JSON.stringify(args)})`);

const toolResult = tools[name](args);
console.log(`✅ 工具执行结果: ${toolResult}`);

这一段把责任划分得非常干净:

  • 模型给出 name + args
  • 应用检查并执行对应工具
  • 工具返回真实结果

而且这里也是你做治理的核心入口。真实系统里,通常不会直接 tools[name](args) 就完事,而是会在这一层补上:

  • 参数校验
  • 权限校验
  • 超时控制
  • 重试策略
  • 幂等保护
  • 审计日志

6. 结果回流:不把工具结果喂回模型,闭环就没完成

const result2 = await chat.sendMessage([
  {
    functionResponse: {
      name: name,
      response: { result: toolResult }
    }
  }
]);

很多人第一次写 Tool Calling,会停在“模型已经给了我要调用的函数名”。但这只完成了一半。

后面必须有第二段推理:

  • 告诉模型它刚才请求的工具已经执行完了
  • 告诉它工具返回了什么
  • 让它基于真实结果组织最终回答

如果没有这一步,系统只是学会了“生成指令格式”,还没有真正形成用户可见的闭环。

7. 最终回答其实来自第二次推理

console.log(`\nAI: ${result2.response.text()}`);

这句看起来简单,但它提醒你一个非常重要的事实:一条完整的 Tool Calling 往往至少包含两个推理阶段。

  1. 判断要不要调工具,以及怎么调
  2. 基于工具返回值生成最终回答

所以 Agent 的复杂度,不只是“模型会不会调用函数”,而是整个调用-执行-回流链条能不能稳定工作。

这段代码背后的三层系统分工

模型负责

  • 理解用户意图
  • 判断是否需要工具
  • 生成工具名与参数
  • 消化工具结果并组织最终回复

应用负责

  • 提供可用工具清单
  • 校验模型输出是否合法
  • 执行工具
  • 记录调用链路
  • 控制权限、错误和重试
  • 把结果回传给模型

工具负责

  • 和真实环境交互
  • 完成具体动作
  • 返回结构化结果或错误信息

这三层别混。Agent 稳不稳定,很多时候不是看模型有多聪明,而是看这三层有没有分清楚。

工程边界和常见坑

1. schema 写得太模糊,或者本地数据映射不完整

工具描述不清、参数定义过松,模型就更容易选错工具或传错参数。即使工具调用链本身是通的,如果你的本地 mock 数据没有覆盖模型常见输出形式,结果也可能出错。

这节课当前版本就是一个很典型的例子:用户问“上海今天天气怎么样”,模型有时会传 Shanghai,有时也可能传 上海。如果天气数据只覆盖其中一种写法,系统就会出现“调用成功,但返回 Unknown”的假阴性结果。

2. 把结构化输出当成可信输入

模型生成的 JSON 比自然语言更可解析,但不代表一定正确。执行层必须兜底。

3. 把模型当执行器

它只是一个决策器。执行权必须始终掌握在系统手里,否则权限和安全根本无从谈起。

4. 只考虑单步调用

真实任务通常不是一步:先搜、再读、再总结;先查状态、再下指令、再确认结果。单步 Tool Calling 只是最小起点。

收尾

Tool Calling 真正改变的,不是模型突然能运行代码了,而是系统终于建立起一套“模型决策、程序执行、结果回流”的行动闭环。

从这一刻开始,Agent 不再只是一个更会聊天的模型,而是一个开始具备外部动作能力的系统。接下来问题会继续升级:有些时候,系统不是不会做,而是不知道。那当模型原本就没有某些私有知识时,应该怎样把这些知识按需接进来?这就是 RAG 要补上的那条链路。

  • 本文作者: tc9011
  • 本文链接: https://tc9011.com/posts/2026/03-tool-calling-不是让模型执行代码-而是让它学会发指令/
  • 版权声明: 本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!