✦ 大道至简 · 时光是画在卷上的河流 · 行到水穷处,坐看云起时

tool2026.08.25 · 15 分钟阅读

agent之tools调用

在 LangChain 里,一个工具不是随便一个函数,而是有固定结构的对象。用 `DynamicStructuredTool` 定义,每个工具由**四件套**组成:

L

Leo

2026.08.25 · 更新于 2026.09.13

6 次浏览
agent之tools调用

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 后:

  1. 找到对应的工具对象
  2. 用 schema.parse() 校验参数
  3. 调用 func 真正执行
  4. 把结果作为消息回传给模型

六、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,   // 把结果"挂回"对应的调用
        }));
    }
}

四个关键点:

  1. 绑定工具:model.bindTools(tools) 让模型"知道"有哪些工具可用,并能输出结构化的调用指令。
  2. 消息历史 = 工作记忆:每次 invoke 都带上完整历史,模型才"记得"自己刚才读了什么、写了什么,才能一步步完成复杂任务。
  3. 自我纠错:工具报错或调了未知工具时,把错误信息回给模型,让它修正后重试,不需要人盯着。
  4. 一句话 → 应用:模型自己规划(拆子任务)→ 执行(调工具)→ 验证(列目录)→ 收尾。缺任何一个环节都做不到。

七、四类消息与 tool_call_id

在 LangChain 里,不同的"说话者"用不同类型的消息表示:

类型谁说的作用
SystemMessage系统设定人设 / 规则 / 可用工具说明
HumanMessage用户提出请求,是任务来源
AIMessage模型回复内容;要调工具时带上 tool_calls
ToolMessage工具把工具真实执行结果回传给模型

两个容易踩的坑:

  1. AIMessage 不用手动 new:model.invoke(messages) 的返回值就是 AIMessage,直接 messages.push(response) 把它放回历史,是为了给模型记忆上下文。
  2. 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、自己的程序都能用。

标签 / TAGStool
L

Leo

博主

独立开发者 / Blogger,原博客「大道至简」维护者。正在把 WordPress 上攒了几年的文章与拾语迁移到 Next.js。

读者留言

COMMENTS · 0

发表留言

评论经审核后展示 · 请友善发言0/100