从零解剖一个 AI Agent Tool是如何实现的

26 阅读6分钟

从零解剖一个 AI Agent Tool是如何实现的

一篇带你彻底理解 LangChain 中 Tool 是如何从"函数"变成"模型的手和脚"的深度解析。


一、先讲一个故事:模型为什么需要 Tool?

想象一下,你雇了一位世界顶级的代码审查专家。他智商 200,精通所有编程语言,能在毫秒间分析算法复杂度——但他双目失明,双手被绑在身后

你递给他一个项目说:"帮我看看 tool-file-read.mjs 这个文件有没有 bug。"

专家会非常礼貌地告诉你:"我很乐意帮忙,但我看不到文件。"

这就是大语言模型的处境。它拥有惊人的推理能力,但它被关在一个黑盒子里——没有眼睛看文件系统,没有手去执行命令,没有嘴巴去调用 API。

Tool(工具)就是给模型装上眼睛和双手。


二、全局架构:一张图看懂整个流程

用户提问
   │
   ▼
┌─────────────────────────────────────────────────┐
│              messages 数组(对话历史)              │
│  [SystemMessage, HumanMessage]                   │
└─────────────────────────────────────────────────┘
   │
   ▼
┌─────────────────────────────────────────────────┐
│          modelWithTools.invoke(messages)          │
│  模型分析:我需要读取文件 → 返回 tool_calls         │
└─────────────────────────────────────────────────┘
   │
   ▼
   response.tool_calls 有内容吗?
   │                    │
   │ 有                │ 没有 → 输出最终答案,结束
   ▼
┌─────────────────────────────────────────────────┐
│  遍历每个 tool_call,找到对应 Tool,执行 invoke()   │
│  收集所有 toolResults                            │
└─────────────────────────────────────────────────┘
   │
   ▼
┌─────────────────────────────────────────────────┐
│  将 toolResults 包装成 ToolMessage,push 进 messages │
└─────────────────────────────────────────────────┘
   │
   ▼
   再次调用 modelWithTools.invoke(messages) ←── 循环

这个循环就是 Agent 的核心——模型思考 → 调用工具 → 获取结果 → 再思考,直到它认为不需要再调用工具了。


三、第一步:创建一个"有手"的模型

const model = new ChatOpenAI({
    modelName: process.env.MODEL_NAME,
    apiKey: process.env.OPENAI_API_KEY,
    configuration: {
        baseURL: process.env.OPENAI_BASE_URL
    },
    temperature: 0,
})

到这一步,model 只是一个普通的 LLM 实例。它能聊天,但不能做任何事。它的 .invoke() 返回的 response 只有文字,没有 tool_calls

关键的变化在下面这一行:

const modelWithTools = model.bindTools(tools);

这行代码做了什么?它向模型声明了一个能力清单。在发送请求时,LangChain 会将 tools 数组中的每个工具名称、描述、参数 schema 一并发送给模型。模型看到后就知道:

"哦,我现在有一个叫 read_file 的工具可以用,它需要一个 path 参数。当用户让我读文件时,我应该返回一个 tool_call,而不是凭空编造文件内容。"

bindTools 是让模型从"纯聊天机器人"变成"能调用工具的 Agent"的分水岭。


四、第二步:定义一个 Tool——麻雀虽小,五脏俱全

这是全文最核心的部分。让我们一行一行地拆解:

const readFileTool = tool(
    // 第一个参数:处理函数(Tool 的执行体)
    async ({ path }) => {
        const content = await fs.readFile(path, 'utf-8');
        console.log(`[工具调用] read_file("${path}") 成功读取 ${content.length} 字节`);
        return content;
    },
    // 第二个参数:元数据配置
    {
        name: 'read_file',
        description: `用此工具来读取文件内容。当用户需要读取文件、查看代码、
        分析文件内容时,调用此工具。输入文件路径(可以是相对路径或绝对路径)`,
        schema: z.object({
            path: z.string().describe('要读取的文件路径')
        })
    }
);

2.1 处理函数——Tool 的"肉体"

async ({ path }) => {
    const content = await fs.readFile(path, 'utf-8');
    return content;
}

这就是 Tool 真正执行的动作。当模型决定调用 read_file 时,这段代码会真正运行。它接收模型传来的参数 path,用 Node.js 的 fs.readFile 读取文件,返回文件内容。

注意它是 async 的——文件读取是异步 I/O,Tool 天然支持异步操作。

2.2 name——Tool 的"身份证"

name: 'read_file',

这个名字会在三个地方用到:

  1. 发送给模型:模型通过名字决定调用哪个工具
  2. 响应中的 tool_call.name:告诉你模型想调用哪个工具
  3. 查找匹配:代码中用 tools.find(t => t.name === toolCall.name) 定位工具

2.3 description——Tool 的"使用说明书"

description: `用此工具来读取文件内容。当用户需要读取文件、查看代码、
分析文件内容时,调用此工具。...`,

这是整个 Tool 定义中最容易被低估,却最关键的部分。模型不是通过代码逻辑来决定何时使用工具的,而是通过阅读这段文字描述来理解的

描述写得好不好,直接决定了模型"会不会用这个工具"、"什么时候用"、"会不会误用"。可以把它理解为 Prompt Engineering 在 Tool 层面的延续。

2.4 schema——Tool 的"合同条款"

schema: z.object({
    path: z.string().describe('要读取的文件路径')
})

使用 Zod 定义参数 schema,这做了三件事:

作用说明
类型约束z.string() 告诉模型:path 必须是字符串
语义说明.describe() 告诉模型:这个参数的含义是什么
运行时校验调用时自动验证参数格式,防止"垃圾进垃圾出"

模型在生成 tool_call 时,会严格按照这个 schema 来构造参数——这就是为什么它能准确地给出 { path: "tool-file-read.mjs" } 而不是瞎编。


五、第三步:Agent 循环——模型和工具的交谊舞

这是整个程序中最精彩的部分,实现了 "模型思考 → 工具执行 → 模型再思考" 的闭环。

5.1 初始化:搭建"对话舞台"

const messages = [
    new SystemMessage(`你是一个代码助手,可以使用工具读取文件并解释代码。
    工作流程:
    1. 用户要求读取文件时,立即调用 read_file 工具
    2. 等待工具返回文件内容
    3. 基于文件内容进行分析和解释
    可用工具:- read_file: 读取文件内容`),
    new HumanMessage('请读取tool-file-read.mjs文件内容并解释代码')
];

三种消息类型构成了完整的对话结构:

┌──────────────────────────────────────┐
│  SystemMessage  →  设定角色和规则     │  "你是一个代码助手……"
│  HumanMessage   →  用户的提问         │  "请读取文件并解释代码"
│  ToolMessage    →  工具执行的结果     │  "文件内容是……"(稍后加入)
└──────────────────────────────────────┘

5.2 第一次调用:模型决定"我需要工具"

let response = await modelWithTools.invoke(messages);
messages.push(response);

这一步是整个故事的转折点。模型收到了 SystemMessage(角色设定)和 HumanMessage(用户需求),它发现:

  • 用户要我读文件
  • 我有个 read_file 工具
  • 我需要调用它

于是模型不返回文字答案,而是返回一个 tool_calls 数组

{
  "tool_calls": [
    {
      "name": "read_file",
      "args": { "path": "tool-file-read.mjs" },
      "id": "call_xxxxx"
    }
  ]
}

然后把这个 AI 的回复也 push 进 messages——这不是废话,是维护完整对话历史,后面模型会用到。

5.3 执行循环:把模型的"意图"变成"行动"

while (response.tool_calls && response.tool_calls.length > 0) {
    // 第一步:并行执行所有工具调用
    const toolResults = await Promise.all(
        response.tool_calls.map(async (toolCall) => {
            const tool = tools.find(t => t.name === toolCall.name);
            if (!tool) {
                return `错误,找不到工具 ${toolCall.name}`;
            }
            const result = await tool.invoke(toolCall.args);
            return result;
        })
    );

    // 第二步:把每个结果包装成 ToolMessage,加入对话
    response.tool_calls.forEach((toolCall, index) => {
        messages.push(new ToolMessage({
            content: toolResults[index],
            tool_call_id: toolCall.id
        }));
    });

    // 第三步:让模型基于新信息重新思考
    response = await modelWithTools.invoke(messages);
}

拆解这个 while 循环的每一层逻辑:

第一层:response.tool_calls.length > 0——循环的"油门和刹车"
有 tool_calls  →  继续循环(模型还想调用工具)
没有 tool_calls →  停止循环(模型认为任务完成,返回最终答案)

这是一个自驱式循环——不需要人工判断何时停止,模型自己决定。

第二层:Promise.all——并行执行,效率翻倍

如果模型一次返回了 3 个 tool_call(比如同时读取 3 个文件),Promise.all 会让它们并行执行,而不是一个一个来。这在读多个文件、调用多个独立 API 时尤为重要。

第三层:tools.find(t => t.name === toolCall.name)——工具路由

模型返回的只有工具名字字符串,代码需要按名字匹配找到对应的 Tool 对象,然后调用 .invoke()。这是一个简单的策略模式实现。

第四层:new ToolMessage({ content, tool_call_id })——把结果"翻译"给模型

工具执行完毕,拿到了文件内容,但这个内容模型看不懂——它只是一串字符串。必须包装成 ToolMessage 格式,并且通过 tool_call_id 和之前的 tool_call 关联起来

模型:我想要调用 read_file("tool-file-read.mjs"),id=call_123
系统:好的,结果在这里 → ToolMessage(id=call_123, content="import 'dotenv/config'...")
模型:哦,这就是我刚才要的文件内容,现在我来分析它。

tool_call_id 是关键的粘合剂——它确保模型知道"这个结果对应我之前的哪个请求",尤其在多个工具并行调用时不会搞混。

5.4 循环终止:模型给出最终答案

当模型觉得信息足够了,它返回的 response 就不会再带 tool_calls。此时 while 条件为 false,循环结束。response.content 就是最终的分析结果。


四、完整时序图:一次调用的"生命历程"

时间 ──────────────────────────────────────────────────────►

HumanMessage ──► 模型思考 ──► tool_calls: [{ read_file("x.mjs") }]
                                      │
                                      ▼
                              Tool.invoke({path: "x.mjs"})
                                      │
                                      ▼
                              ToolMessage("文件内容是...")
                                      │
                                      ▼
                              模型再思考 ──► "这个文件实现了..."
                                            (没有 tool_calls,结束)

五、深入思考:这个设计的精妙之处

5.1 关注点分离

角色职责
模型理解用户意图,决定是否用工具、用什么工具、传什么参数
Tool 定义声明"我能做什么"和"我需要什么参数"
Tool 执行真正干活——读文件、调 API、算数据
循环控制器充当"传送带",把模型意图送到工具执行,再把结果送回模型

每一层各司其职,互不耦合。要加新工具?只需要在 tools 数组里多 push 一个定义就行,循环逻辑完全不用改。

5.2 Tool 本质是"I/O 边界的外挂"

模型本身是一个封闭的推理系统。Tool 做的事情,本质上是在推理循环中打开了一个 I/O 缺口

  ┌──────────── 推理世界(纯文本)────────────┐
  │                                            │
  │   模型思考  ←──ToolMessage── 工具结果       │
  │      │                                    │
  │   tool_call                              
  │      │                                    │
  └──────┼────────────────────────────────────┘
         │       ▲
         ▼       │
  ┌──── 现实世界 ────────────────────────────┐
  │                                           │
  │    文件系统    API接口    数据库   命令行     │
  │                                           │
  └───────────────────────────────────────────┘

Tool 就是这个"缺口"的桥梁——它把真实世界的数据翻译成模型能理解的文本,也把模型的意图翻译成真实世界能执行的操作。

5.3 错误处理的艺术

注意这行代码:

if (!tool) {
    return `错误,找不到工具 ${toolCall.name}`;
}

它没有抛出异常让程序崩溃,而是把错误信息作为工具结果返回给模型。这很聪明——模型收到"找不到工具 xxx"后会自己调整策略,比如告诉用户"抱歉,我好像没有这个能力"。

同样是工具执行时的 try/catch

try {
    const result = await tool.invoke(toolCall.args);
    return result;
} catch (error) {
    return `错误: ${error.messages}`;
}

错误不崩溃程序,而是变成对话的一部分。模型看到错误信息可以重试、换参数、或者向用户解释。


六、总结:从零实现一个 Tool 系统的三步公式

如果你要写自己的 Agent Tool 系统,记住这个公式:

1. 定义 Tool = 函数体 + name + description + schema(zod)
2. 绑定 Tool = model.bindTools(tools)
3. 循环调用 = while(tool_calls) { 执行 → ToolMessage → 再调用 }

就这么简单。LangChain 帮你做了大部分脏活累活,但你理解了这个循环的本质后,甚至可以不依赖任何框架,用几十行代码自己实现一个 Agent 循环

Tool 不是什么魔法。它只是在模型和世界之间搭了一座桥——让模型从"会说"变成"会做"。