agent之tools调用
在 LangChain 里,一个工具不是随便一个函数,而是有固定结构的对象。用 `DynamicStructuredTool` 定义,每个工具由**四件套**组成:
Leo
2026.08.25 · Updated 2026.09.13
Agent 的工具系统:让大模型长出"手和眼睛"
大模型很聪明,但它没有手,不会读文件、不会写代码、不会执行命令。工具(Tool)就是给大模型装上"手和眼睛"的方式。
一、先理解问题:大模型天生"看不见、摸不着"
你让 GPT 帮你统计一个项目里有多少个文件,它做不到——因为它根本读不到你磁盘上的文件。它所有的"知识"都来自训练数据,而不是你的电脑。
那怎么让它"读到"呢?思路是:
用户提问 → 模型"决定"要用哪个工具 → 程序替模型执行 → 结果回传 → 模型基于结果回答
模型不真正执行代码,它只生成一段描述调用的 JSON,真正干活的是程序。这就像老板不会自己搬砖,他下指令,工人搬。
这套机制在 LangChain 里叫 Tool-Calling(工具调用),配上循环就成了 Agent(智能体)。
二、工具的四件套:name / description / schema / func
在 LangChain 里,一个工具不是随便一个函数,而是有固定结构的对象。用 DynamicStructuredTool 定义,每个工具由四件套组成:
import { DynamicStructuredTool } from '@langchain/core/tools';
import { z } from 'zod';
import { readFile } from 'node:fs/promises';
export const readFileTool = new DynamicStructuredTool({
name: 'read_file', // ① 工具名:模型用它发起调用
description: // ② 写给模型看的说明书
'读取本地文件的文本内容。当用户询问某个文件的内容、查看代码或配置文件时使用。',
schema: z.object({ // ③ 参数契约(zod)
filePath: z.string().describe('要读取的文件的绝对路径或相对于项目根目录的路径'),
encoding: z
.enum(['utf8', 'utf-16le', 'latin1', 'base64'])
.optional()
.default('utf8')
.describe('文件编码,默认 utf8'),
}),
func: async ({ filePath, encoding }) => { // ④ 真正执行的函数
try {
const content = await readFile(filePath, encoding);
return content;
} catch (err) {
return `读取文件失败: ${err.message}`;
}
},
});
四个字段,各司其职:
| 字段 | 作用 |
|---|---|
name | 工具名,模型在输出里写"我要调用 read_file" |
description | 写给模型看的:什么时候该用、传什么参数、注意什么 |
schema | 参数定义(zod),既描述给模型看、又做运行时校验 |
func | 真正干活的函数,由程序执行 |
关键认知:工具不是 AI 的固有格式,而是框架的约定。 是 LangChain 定义了这个四件套结构,模型才"认识"你的工具。
三、Zod 到底是什么?为什么非得有它
很多人第一次看到 z.object(...) 会困惑:这跟 TypeScript 类型不是一回事吗?
Zod 是运行时数据校验库。它在这套体系里承担两个角色:
1. 描述给模型看
schema 会被框架自动转换成 JSON Schema 发给模型,相当于告诉模型:
- 这个工具有哪些参数
- 每个参数是什么类型
- 有哪些可选项(
.enum就像给模型一份"菜单",防止乱填) - 哪个参数可以不填、默认值是什么
2. 运行时校验
模型生成参数是概率性的——它可能漏字段、填错类型、甚至产生幻觉。schema 会在调用 func 之前做一次校验/转换/补默认值,保证 func 收到的参数是合法的。
数据流是这样的:
模型生成 JSON → schema.parse() 校验/转换/补默认值 → 传给 func
如果不用 schema 会怎样?
- 模型传错 key(传了
path而不是filePath)→func里解构到undefined,直接崩 - 模型填非法取值(如
encoding: "gbk")→ 程序照单全收 - 模型多塞的字段 → 照单全收
- 模型甚至可能不传对象(传了个字符串)→ 解构直接炸
一句话:schema 是"既描述给模型看、又校验模型输出"的参数契约,是模型输出与真实世界之间唯一的安全网。
四、schema 与 func 怎么对应起来?
靠名字一一对应,不是自动发现的:
schema: z.object({ filePath, encoding }) // 定义对象形状
func: async ({ filePath, encoding }) => {} // 解构收到的就是这个对象
模型生成的 JSON 经过 schema.parse() 后,func 接收到的就是一个 { filePath, encoding } 对象,正好能解构。
五、模型是怎么"调用"工具的?—— tool_calls
你可能好奇:模型到底怎么表示"我要调用工具"?
答案是:模型在回复里附带一个 tool_calls 字段,里面声明"调哪个工具、传什么参数":
{
"tool_calls": [
{
"id": "call_abc123",
"name": "read_file",
"args": { "filePath": "./src/index.js" }
}
]
}
模型没有执行任何代码,它只是声明了调用意图。框架拿到这个 JSON 后:
- 找到对应的工具对象
- 用
schema.parse()校验参数 - 调用
func真正执行 - 把结果作为消息回传给模型
六、Agent 主循环:Think → Act → Observe
一个工具调用还不够,要完成复杂任务(比如"创建一个 TodoList 应用"),需要循环:模型思考 → 调工具 → 看结果 → 再思考 → 再调工具……
这就是 Agent 的核心循环,也叫 ReAct 模式(Reason + Act):
模型思考 → 有 tool_calls 吗?
├─ 没有 → 输出最终答复,结束
└─ 有 → 逐个执行工具,结果塞回消息历史 → 继续循环
我写了一个 mini-cursor.js,用不到 60 行核心代码复刻了这个循环:
for (let i = 0; i < maxIterations; i++) {
const response = await modelWithTools.invoke(messages);
messages.push(response);
// ① 没有工具调用 → 输出最终答案
if (!response.tool_calls || response.tool_calls.length === 0) {
return String(response.content ?? '');
}
// ② 有工具调用 → 逐个执行
for (const toolCall of response.tool_calls) {
const foundTool = tools.find(t => t.name === toolCall.name);
const toolResult = await foundTool.invoke(toolCall.args);
messages.push(new ToolMessage({
content: toolResult,
tool_call_id: toolCall.id, // 把结果"挂回"对应的调用
}));
}
}
四个关键点:
- 绑定工具:
model.bindTools(tools)让模型"知道"有哪些工具可用,并能输出结构化的调用指令。 - 消息历史 = 工作记忆:每次
invoke都带上完整历史,模型才"记得"自己刚才读了什么、写了什么,才能一步步完成复杂任务。 - 自我纠错:工具报错或调了未知工具时,把错误信息回给模型,让它修正后重试,不需要人盯着。
- 一句话 → 应用:模型自己规划(拆子任务)→ 执行(调工具)→ 验证(列目录)→ 收尾。缺任何一个环节都做不到。
七、四类消息与 tool_call_id
在 LangChain 里,不同的"说话者"用不同类型的消息表示:
| 类型 | 谁说的 | 作用 |
|---|---|---|
SystemMessage | 系统 | 设定人设 / 规则 / 可用工具说明 |
HumanMessage | 用户 | 提出请求,是任务来源 |
AIMessage | 模型 | 回复内容;要调工具时带上 tool_calls |
ToolMessage | 工具 | 把工具真实执行结果回传给模型 |
两个容易踩的坑:
AIMessage不用手动 new:model.invoke(messages)的返回值就是AIMessage,直接messages.push(response)把它放回历史,是为了给模型记忆上下文。tool_call_id是"挂号信":一条 AIMessage 里可能有多次工具调用,ToolMessage靠tool_call_id把结果"挂回"之前那条 AI 消息里对应的某一次调用(call.id),模型才知道这条结果对应哪个请求。
消息的顺序就是数据流:
System → Human → AI(带 tool_calls) → Tool → AI(最终回答)
模型看到自己的工具调用请求和工具结果被拼在同一份消息列表里,才能基于真实数据收尾回答,而不是靠幻觉。
八、模型也会犯错:异常处理
模型输出是概率性的,所以生产级 Agent 一定要处理两类异常:
1. 调了未知工具
if (!foundTool) {
messages.push(new ToolMessage({
content: `未知工具: ${toolCall.name},可用工具: ${tools.map(t => t.name).join(', ')}`,
tool_call_id: toolCall.id,
}));
continue;
}
把"没有这个工具 + 有哪些可用工具"告诉模型,它会重新选择。
2. 工具执行出错
try {
const toolResult = await foundTool.invoke(toolCall.args);
// ...
} catch (err) {
messages.push(new ToolMessage({
content: `工具 ${toolCall.name} 执行失败: ${err.message}`,
tool_call_id: toolCall.id,
}));
}
把错误信息回给模型,让它修正参数重试。
3. 死循环兜底
for 循环设了 maxIterations(比如 30),耗尽后明确提示,而不是盲目返回最后一条(很可能是工具结果)。
九、小结
- 工具四件套:
name/description(写给模型)/schema(参数契约)/func(真正执行)。 - 模型不执行代码,它只生成描述调用的 JSON,框架负责"翻译 + 校验 + 执行"。
- Zod 双重身份:描述给模型看 + 校验模型输出,是安全网。
- Agent = 模型思考 + 工具动手 + 循环驱动 + 消息历史当记忆。
- 自我纠错是 Agent 靠谱的关键:把错误回给模型,让它自己修正。
下一篇文章,我会讲这套工具系统如何通过 MCP 协议标准化——让写一次工具,Cursor、Claude Code、自己的程序都能用。
Leo
BloggerIndependent developer / Blogger and the maintainer of the original blog “大道至简”. Migrating years of posts and shiyu from WordPress to Next.js.
Reader comments
COMMENTS · 0Leave a comment