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

从零解剖一个 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',

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

  1. 发送给模型:模型通过名字决定调用哪个工具
  2. 响应中的 tool_call.name:告诉你模型想调用哪个工具
  3. 查找匹配 :代码中用 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 不是什么魔法。它只是在模型和世界之间搭了一座桥------让模型从"会说"变成"会做"。

相关推荐
一个王同学2 小时前
从零到一 | CV转多模态大模型 | week09 | Minillava Refactor结合手搓和llava源码深入理解多模态大模型原理
人工智能·深度学习·机器学习·计算机视觉·改行学it
2601_957787582 小时前
全场景矩阵系统多端统一体验与跨端实时同步技术实践
大数据·人工智能·矩阵·多端统一·跨端同步
liudanzhengxi2 小时前
AI提示词极限赛:突破边界的艺术
人工智能
ZhengEnCi2 小时前
09-斯坦福CS336作业 📝
人工智能
wangruofeng2 小时前
Playwright 深度调研:为什么它成了浏览器自动化的新底座
前端·测试
闭关修炼啊哈2 小时前
[IdeaLoop · 灵感回路] AI时代独立开发者·创业/副业灵感日报 · 2026-05-17
人工智能·远程工作·创业·副业
金銀銅鐵3 小时前
[Java] 如何将 Lambda 表达式对应的类保存到 class 文件中?
java·后端
赢乐3 小时前
大模型学习笔记:检索增强生成(RAG)架构
人工智能·python·深度学习·机器学习·智能体·幻觉·检索增强生成(rag)
飞哥数智坊3 小时前
OPC 需要的不是一个个AI工具,而是一支数字团队
人工智能