大模型 Tool 调用底层拆解:手写原生调用逻辑

不依赖 Agent 框架的自动调度,用最小可运行示例手写 Tool Calling:定义工具、模型决策、外部执行、结果回填,并弄清四类消息如何流转。

单纯的大模型本质是文本生成器。它的知识来自训练数据,拿不到实时信息,也做不了可靠计算,更谈不上操作本地设备或调用外部服务。纯对话模式下容易幻觉、难落地,根因就在这里。

Tool 调用的本质,是一套标准化通信协议。

它让模型先判断任务要不要外部协助,再输出规范的函数调用指令;真正执行则交给外部程序——本地函数、远程 API、云服务都可以。换句话说:模型负责思考和下指令,工具负责干活。Tool Calling 就是两者之间的共同语言。

完整链路可以概括为:

用户提问 → 携带工具定义传入模型 → 模型决定是否调用 → 输出工具名与参数 → 外部程序执行 → 结果回传模型 → 模型整合后给出最终回答

环境准备

新建空项目并安装依赖:

npm init -y
npm install @langchain/openai @langchain/core dotenv zod

在项目根目录创建 .env

OPENAI_API_KEY=你的密钥
OPENAI_BASE_URL=https://api.openai.com/v1
OPENAI_MODEL_NAME=gpt-3.5-turbo

最简案例:手写加法工具调用

要吃透底层,最好从最小例子开始。下面用一个加法计算器,全程手动调度,不走框架自动编排。

新建 raw-tool-demo.mjs

import 'dotenv/config';
import { ChatOpenAI } from "@langchain/openai";
import { tool } from "@langchain/core/tools";
import { z } from "zod";

// 1. 初始化大模型
const llm = new ChatOpenAI({
  apiKey: process.env.OPENAI_API_KEY,
  baseURL: process.env.OPENAI_BASE_URL,
  modelName: process.env.OPENAI_MODEL_NAME,
  temperature: 0
});

// 2. 定义工具:加法计算器
const addTool = tool(
  async ({ a, b }) => {
    return `计算结果:${a + b}`;
  },
  {
    name: "add_calculator",
    description: "用于计算两个数字的加法,精准数学计算使用",
    schema: z.object({
      a: z.number().describe("第一个数字"),
      b: z.number().describe("第二个数字")
    })
  }
);

const tools = [addTool];

// 3. 手写完整原生工具调用流程
async function main() {
  const messages = [
    {
      role: "system",
      content: "遇到数学加法计算,必须调用计算器工具,不要自己计算。"
    },
    {
      role: "user",
      content: "请计算 9876 + 1234"
    }
  ];

  // 第一步:传入问题和工具列表,让模型自主决策
  const response = await llm.invoke(messages, { tools });

  // 第二步:判断是否需要调用工具
  if (response.tool_calls && response.tool_calls.length > 0) {
    const toolCall = response.tool_calls[0];
    const toolName = toolCall.name;
    const toolArgs = toolCall.args;

    console.log("模型选择工具:", toolName);
    console.log("模型传入参数:", toolArgs);

    // 第三步:手动执行本地工具
    let toolResult = "";
    if (toolName === "add_calculator") {
      toolResult = await addTool.invoke(toolArgs);
    }
    console.log("工具执行结果:", toolResult);

    // 第四步:回填消息,让模型整合生成最终答案
    messages.push(response);
    messages.push({
      role: "tool",
      tool_call_id: toolCall.id,
      name: toolName,
      content: toolResult
    });

    const finalRes = await llm.invoke(messages);
    console.log("\n最终回答:", finalRes.content);
  } else {
    console.log("最终回答:", response.content);
  }
}

main().catch(console.error);

流程拆解

这段代码是工具调用的最小原型。常见 Agent / 自动调度能力,大多是在这套逻辑上叠加循环、规划与错误处理。核心四步:

  1. 标准化定义工具:为函数提供唯一名称、功能描述和参数 schema,让模型知道「能做什么、怎么传参」。
  2. 模型自主决策:结合用户需求与工具列表,判断是否调用、调用哪个,并生成合规参数。
  3. 外部手动执行:程序读取 tool_calls,匹配工具并执行,拿到真实结果。
  4. 消息回填闭环:把执行结果写成标准 tool 消息塞回上下文,再让模型生成最终回答。

不变的分工是:模型负责决策,工具负责落地;标准化消息把两边串成闭环。

能力拓展:本地文件与终端工具

掌握单工具调用后,可以把能力扩到常见本地操作:读文件、写文件、建目录、执行命令。同样不做自动 Agent 封装,只保留原生循环。

新建 raw-mini-cursor.mjs

import 'dotenv/config';
import { ChatOpenAI } from "@langchain/openai";
import { tool } from "@langchain/core/tools";
import { z } from "zod";

import fs from 'fs/promises';
import path from 'path';
import { execSync } from 'child_process';

const llm = new ChatOpenAI({
  apiKey: process.env.OPENAI_API_KEY,
  baseURL: process.env.OPENAI_BASE_URL,
  modelName: process.env.OPENAI_MODEL_NAME,
  temperature: 0
});

const readFileTool = tool(
  async ({ filePath }) => {
    try {
      return await fs.readFile(path.resolve(filePath), "utf8");
    } catch (e) {
      return "读取失败:" + e.message;
    }
  },
  {
    name: "read_local_file",
    description: "读取本地文本文件",
    schema: z.object({ filePath: z.string() })
  }
);

const writeFileTool = tool(
  async ({ filePath, content }) => {
    await fs.writeFile(path.resolve(filePath), content, "utf8");
    return "文件写入成功";
  },
  {
    name: "write_local_file",
    description: "创建或写入本地文本文件",
    schema: z.object({ filePath: z.string(), content: z.string() })
  }
);

const mkdirTool = tool(
  async ({ dirPath }) => {
    await fs.mkdir(path.resolve(dirPath), { recursive: true });
    return "目录创建成功";
  },
  {
    name: "mkdir_dir",
    description: "创建本地文件夹,支持多级目录",
    schema: z.object({ dirPath: z.string() })
  }
);

const execTool = tool(
  async ({ cmd }) => {
    return execSync(cmd, { encoding: "utf8" });
  },
  {
    name: "exec_command",
    description: "执行本地终端命令",
    schema: z.object({ cmd: z.string() })
  }
);

const tools = [readFileTool, writeFileTool, mkdirTool, execTool];
const toolMap = {
  read_local_file: readFileTool,
  write_local_file: writeFileTool,
  mkdir_dir: mkdirTool,
  exec_command: execTool
};

async function main() {
  const messages = [
    {
      role: "system",
      content: "你可以调用本地工具完成用户需求,根据需求选择对应工具,不要编造内容。"
    },
    {
      role: "user",
      content: "新建文件夹 demo,在里面创建 intro.txt 并写入 hello tool calling,然后列出当前目录文件"
    }
  ];

  // 手动循环:连续工具调用,直到模型不再请求工具
  while (true) {
    const res = await llm.invoke(messages, { tools });
    if (!res.tool_calls?.length) {
      console.log("\n最终回答:", res.content);
      break;
    }

    for (const call of res.tool_calls) {
      const selected = toolMap[call.name];
      const toolResult = await selected.invoke(call.args);

      messages.push(res);
      messages.push({
        role: "tool",
        tool_call_id: call.id,
        name: call.name,
        content: toolResult
      });
    }
  }
}

main().catch(console.error);

注意:exec_command 会直接执行本机命令,演示环境请谨慎使用;生产场景应做白名单、沙箱与权限隔离。

四类消息:Tool Calling 的骨架

整条链路,其实是四类消息在上下文里不断拼接与回环。理清它们的职责,比背某个框架 API 更重要。

各自定位

  • SystemMessage(系统消息):全局规则。设定角色、约束行为、规定何时必须用工具。通常放在消息数组首位,整段对话保持不变。
  • HumanMessage(人类消息):用户真实需求,是任务起点。后续推理与调用都围绕它展开。
  • AIMessage(AI 消息):模型的唯一响应载体。要么直接给文本答案,要么携带 tool_calls 下发工具指令。
  • ToolMessage(工具消息):工具执行结果的回填载体。没有它,模型看不到真实执行数据,多轮任务无法继续。

流转关系

flowchart LR
  A["SystemMessage 全局规则"] --> B["HumanMessage 用户需求"]
  B --> C["LLM 推理决策"]
  C --> D["AIMessage 模型响应"]
  D -->|需要调用工具| E["工具执行"]
  E --> F["ToolMessage 回填结果"]
  F --> C
  D -->|无需工具| G["输出最终答案"]

使用时机

SystemMessage 在初始化时写一次即可,用来统一行为:优先用工具、禁止瞎编、约束输出格式等。

HumanMessage 对应每一次用户输入;多轮工具调用最终都是为了完成这条需求。

AIMessage 既是答案出口,也是指令出口:是否调用、调用谁、传什么参数,都经它交给程序。

ToolMessage 是闭环关键一步。工具结果必须封装后并入上下文;跳过回填,模型就无法继续二次推理。

小结

可以收成四点:

  1. Tool Calling 是模型与外部程序之间的标准协议;执行体不限于本地,也可是远程 API 或云服务。
  2. 工具就是可被模型调度的外部函数:靠名称、描述和参数 schema,获得「识别—选择—传参」能力。
  3. 原生链路可以完全手写:需求 → 决策 → 执行 → 回填 → 最终输出,不必先依赖 Agent 框架。
  4. System / Human / AI / Tool 四类消息各司其职、循环流转,构成多轮工具任务的底层骨架。

把这层原生逻辑吃透之后,再去看自动化 Agent、编程助手或复杂工具串联,会更容易看清它们在循环、规划和容错上多做了什么,而不是只停留在套模板。