读完这篇,你也能让 AI 自己读文件、写代码、跑命令,再也不用复制粘贴了。


一、从“只会聊天”到“自动干活”

你有没有想过,让大模型直接帮你创建一个 React + Vite 的 TodoList 项目?你只需要说一句话:

创建一个 react+vite 的 todolist

如果模型只是“动嘴”,它会给你一堆命令行和代码,你还得自己复制、粘贴、执行。但如果它能动工具呢?

  • 第一步:调用 write_file 工具,创建项目文件;
  • 第二步:调用 cli 工具,执行 npm create vite@latest
  • 第三步:再调用 cli 工具,执行 npm install 和 npm run dev

整个过程全自动,你只需要看着终端滚动。这就是 Agent 的魅力——让 LLM 拥有“手”和“脚”。

说白了,工具(Tool)就是大模型的“外挂器官” ,让它能读文件、写代码、调 API、操作数据库……而我们要做的,就是把这些工具注册给模型,并教会它什么时候用、怎么用。

今天,我就带你手写一个简化版的 Claude Code Agent,用 LangChain 把读文件、解释代码这件事跑通。最终你会理解 ReAct 工作流的核心,并能快速扩展到任何工具。


二、先徒手搓一个 Agent 原型(不依赖框架)

在引入 LangChain 之前,我们先理解 Agent 的本质:LLM + Tools + Loop

// 伪代码
const messages = [用户问题];
let response = await llm.chat(messages);

while (response 有工具调用) {
  执行工具,得到结果;
  把结果拼回 messages;
  response = await llm.chat(messages);
}

// 最终回复
console.log(response.content);

就这么简单。LLM 本身是 stateless 的,但我们可以通过不断维护 messages 数组,把每一次工具调用的结果都喂回去,让它“记住”已经做了什么,再决定下一步。

这也是为什么很多人说 Agent 就是“带记忆的对话 + 工具执行器” 。


三、LangChain:给你一套顺手工具,别重复造轮子

上面那个 loop 我们自己写没问题,但真实场景下,你要兼容多家 LLM(OpenAI、DeepSeek、Claude…),要处理流式、重试、结构化输出、工具 schema 校验……这些如果自己撸,工程量不小。

LangChain 早在 OpenAI 大火之前就诞生了,它的核心价值就是给 LLM 开发提供一套统一抽象的框架

  • 统一接口:ChatOpenAIChatAnthropic……换模型只改一行配置;
  • 工具抽象:用 tool() 或 @tool 装饰器定义工具,自动生成 JSON Schema;
  • 消息类型:SystemMessageHumanMessageAIMessageToolMessage,语义清晰。

我们先看一段完整的代码(基于 DeepSeek API),这个 Agent 可以读文件并解释代码。


四、代码实战:从零搭建一个文件读取 Agent

1. 环境准备

npm init -y
npm install @langchain/openai langchain zod dotenv

在项目根目录创建 .env

DEEPSEEK_API_KEY=sk-xxxxxx

2. 引入依赖和模型实例

import 'dotenv/config';
import { ChatOpenAI } from '@langchain/openai';
import { tool } from '@langchain/core/tools';
import {
    HumanMessage,
    SystemMessage,
    ToolMessage,
    AIMessage
} from '@langchain/core/messages';
import fs from 'node:fs/promises';
import { z } from 'zod';

const model = new ChatOpenAI({
    modelName: 'deepseek-chat',  // 或者 'deepseek-v4-flash'
    apiKey: process.env.DEEPSEEK_API_KEY,
    temperature: 0,
    configuration: {
        baseURL: 'https://api.deepseek.com/v1',
    }
});

注意:DeepSeek 兼容 OpenAI 接口,所以我们用 @langchain/openai 即可。

3. 定义第一个工具:read_File

const readFileTool = tool(
    async ({ filePath }) => {
        const content = await fs.readFile(filePath, 'utf-8');
        console.log(`[工具调用] read_file(${filePath}) 成功读取 ${content.length} 字节`);
        return content;
    },
    {
        name: 'read_File',
        description: `用此工具来读取文件内容,当用户要求读取文件、查看代码、分析文件内容时调用。`,
        schema: z.object({
            filePath: z.string().describe('要读取的文件路径'),
        })
    }
);

const tools = [readFileTool];

tool() 帮我们干了三件事:

  • 把异步函数包装成可调用的工具对象;
  • 根据 schema 生成 JSON Schema,供模型理解参数结构;
  • 自动处理参数校验。

4. 绑定工具到模型

const modelWithTools = model.bindTools(tools);

bindTools 是 LangChain 提供的方法,它会把工具列表以 OpenAI 兼容的 tools 参数附在每次请求中。这样模型就知道它现在有这些工具可以用。

5. 构造初始消息

const messages = [
    new SystemMessage(`
        你是一个代码助手,可以使用工具读取文件并解释代码。

        工作流程:
        1. 用户要求读取文件时,立即调用 read_File 工具。
        2. 等待工具返回文件内容。
        3. 基于文件内容进行分析和解释。

        可用工具:
        - read_File: 读取文件内容
    `),
    new HumanMessage('请读取 src/tool.mjs 文件内容并解释代码'),
];

五、深度剖析 Agent 核心循环(while 的精髓)

这是整个 Agent 的“发动机”部分,也是你问得最深入的地方。我们先看完整代码,再逐一拆解。

// 注意:此处必须用 let,不能用 const!
let response = await modelWithTools.invoke(messages);
messages.push(response);

while (response.tool_calls && response.tool_calls.length > 0) {
    console.log(`\n[检测到 ${response.tool_calls.length} 个工具调用`);

    // ----------------------------------------------
    // 核心并发执行区:map + async + Promise.all
    // ----------------------------------------------
    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}`;
            }
            console.log(`[执行工具] ${toolCall.name}(${JSON.stringify(toolCall.args)})`);
            try {
                const result = await tool.invoke(toolCall.args);
                return result;
            } catch (error) {
                return `错误: ${error.message}`;
            }
        })
    );

    // 将工具结果打包成 ToolMessage 追加到消息历史
    response.tool_calls.forEach((toolCall, index) => {
        messages.push(
            new ToolMessage({
                content: toolResults[index],
                tool_call_id: toolCall.id   // 关键:ID 必须匹配,模型靠这个关联请求和结果
            })
        );
    });

    // 再次调用模型,这次带着工具执行结果
    response = await modelWithTools.invoke(messages);
    messages.push(response);
}

// 循环结束,输出最终答案
console.log(response.content);

下面,我们把这段代码里藏着的“魔鬼细节”一条条拎出来。

5.1 为什么 response 必须用 let,而不能是 const

这是 JavaScript 作用域和可变性的基本问题,但在 Agent 开发中极易踩坑。

  • const 定义的是常量引用。一旦 const response = await modelWithTools.invoke(...) 赋值成功,response 这个变量就永远指向第一次大模型返回的那个对象。
  • 但在 while 循环的最后一行,我们写了 response = await modelWithTools.invoke(messages);,这是重新赋值操作。
  • 如果用 const,JavaScript 引擎会直接抛出 TypeError: Assignment to constant variable 错误,程序崩溃。

深层原因:Agent 的工作流天然要求“状态迭代”。第一次调用模型得到的是“带工具调用的回复”,第二次调用模型得到的是“基于工具结果的最终回复”。变量必须能指向不同的内存地址,所以这里 let 是唯一正确的选择。

金句:在 Agent 的世界里,response 就像接力棒,每一棒都在变化。const 适合拿死数据,let 才适合跑动态循环。

5.2 深入解析 while 循环中的 map 和 async

很多同学在这里会卡住,不理解 async 写在 map 里到底返回了什么。

第一层:async 函数的返回值一定是 Promise

当你写下 response.tool_calls.map(async (toolCall) => { ... }) 时,无论 async 函数内部 return 什么,外部 .map() 拿到的都是一个由 Promise 对象组成的新数组

  • 如果内部 return contentmap 得到的是 [Promise, Promise, ...]
  • 如果内部 throw new Error()map 得到的还是 [Promise (rejected), ...]

第二层:Promise.all 的作用

await Promise.all([Promise, Promise, ...]) 会并发执行所有 Promise,等待它们全部完成(或任意一个失败),然后返回一个包含所有实际结果(content 或错误字符串)的新数组。

第三层:为什么要用这种组合?—— 性能飞升

如果模型一次性要求调用 3 个工具(比如读三个不同文件),而我们是串行执行(for 循环 await),总耗时 = 文件1耗时 + 文件2耗时 + 文件3耗时。

但用 map + Promise.all,总耗时 ≈ Math.max(文件1, 文件2, 文件3),因为它们是同时发起的。

在 Agent 工程中,一次请求带多个 tool_calls 极其常见(比如“对比 A、B、C 三个配置文件”),这里用并发,性能直接拉满。如果你的工具是网络请求,这种写法甚至能省几秒钟。

5.3 数据流转详解:从模型输出到工具结果

这是最核心的数据流动环节,我们一步步拆解。

(1)tool_calls 里到底存了什么?

当模型决定调用工具时,LangChain 会在 AIMessage 对象上挂载一个 tool_calls 属性,它是一个数组,每个元素的结构大概如下:

json

{
  "id": "call_abc123",
  "name": "read_File",
  "args": {
    "filePath": "src/tool.mjs"
  }
}
  • id:唯一标识这次工具调用请求,后续 ToolMessage 必须引用它。
  • name:工具名称,我们在定义工具时指定的。
  • args:模型根据我们的 schema 生成的参数对象。

response.tool_calls 就是这样一个数组,它就是模型“想让你做的事情”的清单。

(2)map 之前和之后,数据发生了什么变化?

  • map 之前response.tool_calls 是包含上述对象的数组,每个对象描述一个待执行的任务。

  • map 过程中:我们对每个 toolCall 执行 async 函数,这个函数内部会:

    • 根据 toolCall.name 找到对应的工具实例;
    • 调用 tool.invoke(toolCall.args),这是一个异步操作,返回一个 Promise
    • 如果成功,return 工具的执行结果(例如文件内容字符串);
    • 如果失败,return 一个错误描述字符串(catch 块中)。
  • map 之后(实际上还没完) :map 返回的是一个由 Promise 组成的新数组,而不是最终值。所以我们才需要用 await Promise.all(...) 来解析所有这些 Promise。

重要map 内部 return 的东西,并不是最终给 toolResults 的,而是作为 Promise 的 resolve 值。return 的时机是在异步操作完成后,所以 return result 中的 result 就是工具执行完的真实数据。

(3)toolResults 最终存储了什么?

经过 await Promise.all(...),我们得到 toolResults,它是一个数组,顺序与 response.tool_calls 完全对应。每个元素就是对应工具调用的最终结果:

  • 如果工具执行成功:toolResults[i] 就是 tool.invoke() 返回的值(比如文件内容字符串)。
  • 如果工具执行失败:toolResults[i] 就是 catch 块中返回的错误字符串。

例如,假设我们请求读两个文件,一个存在,一个不存在,toolResults 可能是:

[
  "export const add = (a, b) => a + b;",  // 文件1内容
  "错误: ENOENT: no such file or directory"  // 文件2不存在
]

5.4 为什么要把 toolResults 打包成 ToolMessage 追加?

这是 LangChain 的契约,也是 Agent 能否持续推理的关键。

  • 每个 ToolMessage 必须关联一个 tool_call_id,这个 ID 必须与对应的 toolCall.id 一致。这样当模型看到这条消息时,它知道这个结果是为了响应之前哪个请求。
  • 我们把 toolResults[i] 作为 content 塞进去,相当于告诉模型:“你之前让我读文件,现在我把读到的内容给你。”
  • 如果不打包,模型就会丢失工具的执行结果,下一轮推理就变成了“盲猜”,永远无法给出基于实际内容的回答。

更深层次messages 数组承载的是整个对话的“因果链条”。AIMessage(含 tool_calls)是“因”,ToolMessage(含结果)是“果”。把因果都记录下来,模型才能正确地“推理”。

金句tool_calls 是模型开出的“处方”,toolResults 是抓好的“药”,而 ToolMessage 是把药递回给模型“服用”的勺子——缺一不可。

5.5 循环中的健壮性设计(try/catch 与工具查找)

代码中藏着两处防御性编程:

  1. tools.find(t => t.name === toolCall.name) :万一模型“胡说八道”调用了一个不存在的工具,我们不会崩溃,而是返回错误描述字符串。
  2. try/catch 包裹 tool.invoke:文件可能不存在、权限可能不足,这些运行时错误会被捕获,同样以字符串形式返回。

这样做的好处是:我们把错误当作普通内容喂回给模型。模型看到“错误: 找不到文件 xxx”,可能会在下一次推理中修正路径,或者向用户说明情况。Agent 就有了“自我纠错”的能力。


六、循环结束后发生了什么?(收尾逻辑)

当 while 的条件 response.tool_calls && response.tool_calls.length > 0 为 false 时,循环退出。这意味着:

  • 要么模型压根没想调用工具(直接回答了你的问题);
  • 要么模型在拿到工具结果后,认为信息已经足够,决定直接生成最终回复。

这时的 response 已经是最后一次调用大模型返回的 AIMessage 对象了。它不再包含 tool_calls,而是包含纯文本的 content

我们执行 console.log(response.content),就能看到模型对代码的详细解释。至此,一个完整的 ReAct(推理-行动-观察)  闭环完成。

如果把 Agent 比作一个厨师

  • while 循环是他“尝菜-加调料-再尝”的过程;
  • 退出循环说明“味道完美,可以出锅”;
  • response.content 就是那道最终端上桌的菜。

七、工程化与性能优化小结

通过上面的深度剖析,我们可以提炼出 Agent 开发的三条黄金法则:

  1. 状态变量用 let:只要涉及循环迭代中的重新赋值,必须用 let,别纠结。
  2. 工具调用用 Promise.all 并发map + async 是黄金搭档,能并行的绝不串行。
  3. 消息历史要完整AIMessage(意图)和 ToolMessage(结果)缺一不可,它们是模型推理的基石。

八、扩展思路:下一步你可以加什么?

这个基础框架可以轻松扩展:

  • 写文件工具 (write_File):让模型能修改代码;
  • 执行命令工具 (exec_command):让模型能跑 npm installgit add
  • 网络请求工具 (fetch_url):让模型能查文档;
  • 记忆工具 (向量检索):让模型能访问知识库。

你只需要在 tools 数组里追加定义,模型就会自动学会使用它们。工具是插件,Agent 是运行时。


九、总结:你学到了什么?

通过这篇文章,你不仅掌握了完整的 Agent 代码,更深入理解了:

  1. Agent 的本质:LLM + 工具 + 循环,让模型能自动调用外部能力;
  2. LangChain 工具定义:用 tool() + zod 轻松创建带 schema 的工具;
  3. 工作流编排bindTools → invoke → 检测 tool_calls → 执行 → 回填 ToolMessage → 再次 invoke
  4. 并发与错误处理:用 Promise.all + map 提升性能,用 try/catch 构建容错机制;
  5. 数据流转细节tool_calls 的结构,map 与 async 的返回机制,toolResults 的真实内容,以及打包 ToolMessage 的必要性;
  6. JavaScript 高级特性:为什么 response 必须用 letasync 在 map 中如何返回 Promise 数组。

最后一句总结:Agent 开发不是让模型更聪明,而是给它一双能干活的手,再给它一个不会失忆的循环。  现在就动手试试吧,把你日常的重复工作变成一行命令。

 

更多推荐