从零解剖一个 AI Agent Tool是如何实现的
一篇带你彻底理解 LangChain 中 Tool 是如何从"函数"变成"模型的手和脚"的深度解析。
一、先讲一个故事:模型为什么需要 Tool?
想象一下,你雇了一位世界顶级的代码审查专家。他智商 200,精通所有编程语言,能在毫秒间分析算法复杂度------但他双目失明,双手被绑在身后。
你递给他一个项目说:"帮我看看 tool-file-read.mjs 这个文件有没有 bug。"
专家会非常礼貌地告诉你:"我很乐意帮忙,但我看不到文件。"
这就是大语言模型的处境。它拥有惊人的推理能力,但它被关在一个黑盒子里------没有眼睛看文件系统,没有手去执行命令,没有嘴巴去调用 API。
Tool(工具)就是给模型装上眼睛和双手。
二、全局架构:一张图看懂整个流程
scss
用户提问
│
▼
┌─────────────────────────────────────────────────┐
│ messages 数组(对话历史) │
│ [SystemMessage, HumanMessage] │
└─────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────┐
│ modelWithTools.invoke(messages) │
│ 模型分析:我需要读取文件 → 返回 tool_calls │
└─────────────────────────────────────────────────┘
│
▼
response.tool_calls 有内容吗?
│ │
│ 有 │ 没有 → 输出最终答案,结束
▼
┌─────────────────────────────────────────────────┐
│ 遍历每个 tool_call,找到对应 Tool,执行 invoke() │
│ 收集所有 toolResults │
└─────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────┐
│ 将 toolResults 包装成 ToolMessage,push 进 messages │
└─────────────────────────────────────────────────┘
│
▼
再次调用 modelWithTools.invoke(messages) ←── 循环
这个循环就是 Agent 的核心------模型思考 → 调用工具 → 获取结果 → 再思考,直到它认为不需要再调用工具了。
三、第一步:创建一个"有手"的模型
js
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。
关键的变化在下面这一行:
js
const modelWithTools = model.bindTools(tools);
这行代码做了什么?它向模型声明了一个能力清单 。在发送请求时,LangChain 会将 tools 数组中的每个工具名称、描述、参数 schema 一并发送给模型。模型看到后就知道:
"哦,我现在有一个叫
read_file的工具可以用,它需要一个path参数。当用户让我读文件时,我应该返回一个 tool_call,而不是凭空编造文件内容。"
bindTools 是让模型从"纯聊天机器人"变成"能调用工具的 Agent"的分水岭。
四、第二步:定义一个 Tool------麻雀虽小,五脏俱全
这是全文最核心的部分。让我们一行一行地拆解:
js
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 的"肉体"
js
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 的"身份证"
js
name: 'read_file',
这个名字会在三个地方用到:
- 发送给模型:模型通过名字决定调用哪个工具
- 响应中的
tool_call.name:告诉你模型想调用哪个工具 - 查找匹配 :代码中用
tools.find(t => t.name === toolCall.name)定位工具
2.3 description------Tool 的"使用说明书"
js
description: `用此工具来读取文件内容。当用户需要读取文件、查看代码、
分析文件内容时,调用此工具。...`,
这是整个 Tool 定义中最容易被低估,却最关键 的部分。模型不是通过代码逻辑来决定何时使用工具的,而是通过阅读这段文字描述来理解的。
描述写得好不好,直接决定了模型"会不会用这个工具"、"什么时候用"、"会不会误用"。可以把它理解为 Prompt Engineering 在 Tool 层面的延续。
2.4 schema------Tool 的"合同条款"
js
schema: z.object({
path: z.string().describe('要读取的文件路径')
})
使用 Zod 定义参数 schema,这做了三件事:
| 作用 | 说明 |
|---|---|
| 类型约束 | z.string() 告诉模型:path 必须是字符串 |
| 语义说明 | .describe() 告诉模型:这个参数的含义是什么 |
| 运行时校验 | 调用时自动验证参数格式,防止"垃圾进垃圾出" |
模型在生成 tool_call 时,会严格按照这个 schema 来构造参数------这就是为什么它能准确地给出 { path: "tool-file-read.mjs" } 而不是瞎编。
五、第三步:Agent 循环------模型和工具的交谊舞
这是整个程序中最精彩的部分,实现了 "模型思考 → 工具执行 → 模型再思考" 的闭环。
5.1 初始化:搭建"对话舞台"
js
const messages = [
new SystemMessage(`你是一个代码助手,可以使用工具读取文件并解释代码。
工作流程:
1. 用户要求读取文件时,立即调用 read_file 工具
2. 等待工具返回文件内容
3. 基于文件内容进行分析和解释
可用工具:- read_file: 读取文件内容`),
new HumanMessage('请读取tool-file-read.mjs文件内容并解释代码')
];
三种消息类型构成了完整的对话结构:
arduino
┌──────────────────────────────────────┐
│ SystemMessage → 设定角色和规则 │ "你是一个代码助手......"
│ HumanMessage → 用户的提问 │ "请读取文件并解释代码"
│ ToolMessage → 工具执行的结果 │ "文件内容是......"(稍后加入)
└──────────────────────────────────────┘
5.2 第一次调用:模型决定"我需要工具"
js
let response = await modelWithTools.invoke(messages);
messages.push(response);
这一步是整个故事的转折点。模型收到了 SystemMessage(角色设定)和 HumanMessage(用户需求),它发现:
- 用户要我读文件
- 我有个
read_file工具 - 我需要调用它
于是模型不返回文字答案,而是返回一个 tool_calls 数组:
json
{
"tool_calls": [
{
"name": "read_file",
"args": { "path": "tool-file-read.mjs" },
"id": "call_xxxxx"
}
]
}
然后把这个 AI 的回复也 push 进 messages------这不是废话,是维护完整对话历史,后面模型会用到。
5.3 执行循环:把模型的"意图"变成"行动"
js
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 关联起来:
ini
模型:我想要调用 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 就是最终的分析结果。
四、完整时序图:一次调用的"生命历程"
arduino
时间 ──────────────────────────────────────────────────────►
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 错误处理的艺术
注意这行代码:
js
if (!tool) {
return `错误,找不到工具 ${toolCall.name}`;
}
它没有抛出异常让程序崩溃,而是把错误信息作为工具结果返回给模型。这很聪明------模型收到"找不到工具 xxx"后会自己调整策略,比如告诉用户"抱歉,我好像没有这个能力"。
同样是工具执行时的 try/catch:
js
try {
const result = await tool.invoke(toolCall.args);
return result;
} catch (error) {
return `错误: ${error.messages}`;
}
错误不崩溃程序,而是变成对话的一部分。模型看到错误信息可以重试、换参数、或者向用户解释。
六、总结:从零实现一个 Tool 系统的三步公式
如果你要写自己的 Agent Tool 系统,记住这个公式:
scss
1. 定义 Tool = 函数体 + name + description + schema(zod)
2. 绑定 Tool = model.bindTools(tools)
3. 循环调用 = while(tool_calls) { 执行 → ToolMessage → 再调用 }
就这么简单。LangChain 帮你做了大部分脏活累活,但你理解了这个循环的本质后,甚至可以不依赖任何框架,用几十行代码自己实现一个 Agent 循环。
Tool 不是什么魔法。它只是在模型和世界之间搭了一座桥------让模型从"会说"变成"会做"。