Agent 开发完全指南:从工作流程到并发优化,手写一个能读代码的 AI 助理

2 阅读14分钟

给大模型装上“手和脚”,让它从“嘴炮”变成“实干家”。

你使用过大模型的 API 吗?发一个 prompt,等几秒,拿到一段文本——这已经是常规操作。但当你真的想让 AI 帮你读项目代码、自动创建文件、执行终端命令时,你会发现它只会给你“建议”,却无法真正动手。原因很简单:大模型(LLM)本质上是 stateless 的文本生成器,它没有记忆,更无法与外部世界交互。

Agent(智能体)  正是为了解决这个问题而生的。它在 LLM 之上构建了一套增强系统,赋予模型 记忆(Memory)工具调用(Tool Use)检索增强(RAG)  等能力,让它不仅能思考,更能行动

本文会带你从零开始,用 LangChain 亲手打造一个具备文件读取能力的 Agent。我们会深入拆解:

  • Agent 的工作流程
  • LangChain 的核心抽象
  • 工具调用 ID(tool_call_id)的作用与匹配机制
  • Promise 异步机制与 Promise.all 并发优化

每一行代码都会附上详细解析,确保你“读完就能用”。


一、Agent 是什么?——补上大模型的“先天短板”

在动手之前,我们先弄清 Agent 要解决哪些痛点:

大模型的缺陷Agent 的解决方案
无状态,记不住历史对话Memory 模块:持久化存储对话记录(数据库、Redis 等)
只会动嘴,不能操作文件、网页、系统Tool Use 模块:将外部函数封装成工具,供模型调用
知识截止于训练数据,不知道最新信息RAG(检索增强生成) :从外部知识库检索相关内容再生成
无法访问企业内部私有文档结合 RAG + 私有向量数据库
复杂任务需要多步骤规划Planning/Reasoning:模型自主分解任务,按序调用工具

Agent = LLM + Memory + Tool Use + RAG + MCP + Skills
它知道自己能干什么、该用什么工具、如何记住重要信息——这就是你的 AI 得力助手。


二、Agent 的工作流程详解(思考→行动→观察循环)

一个典型的 Agent 执行任务时会经历  “思考 → 行动 → 观察”  的循环,直到任务完成。我们拆解每一步:

  1. 用户输入(User Prompt)
    用户用自然语言提出一个复杂任务,例如:“读取 tool.mjs 文件并解释它的作用”。

  2. 规划与推理(Planning / Reasoning)
    Agent 将任务交给 LLM。模型会分析意图,判断是否需要:

    • 加载历史记忆(Memory)
    • 调用某个或多个工具(Tool)
    • 从 RAG 检索辅助知识
  3. 工具调用(Tool Call)
    如果模型决定调用工具,它会生成一个特殊的 tool_calls 结构,包含:

    • id:一个唯一标识符(UUID),用于后续匹配工具执行结果。
    • name:要调用的工具名称。
    • args:符合工具 Schema 的参数对象。

    注意:  此时模型不会直接输出最终答案,而是“暂停”并等待工具返回结果。

  4. 执行工具(Tool Execution)
    开发者(或框架)负责解析 tool_calls,调用对应的函数(如读取文件、查询数据库),得到结果。

  5. 关联结果与调用(Matching with ID)
    执行完成后,我们必须将结果包装成 ToolMessage,并原样带上原始调用中的 id。模型依赖这个 ID 将结果对应到正确的调用,尤其当多个工具并发执行时,这个 ID 是唯一的路由凭证。

  6. 观察结果(Observation)
    将 ToolMessage 追加到对话历史中,再次交给模型。

  7. 生成最终回复(Final Response)
    模型基于工具返回的数据,结合原始问题,生成自然语言答案返回给用户。

如果任务尚未完成(例如需要连续调用多个工具),这个循环会重复执行。LangChain 和 LangGraph 提供了 AgentExecutor 等组件来自动管理这个循环。


三、LangChain 框架:为什么选它?

LangChain 是构建 Agent 最成熟的开源框架之一(支持 Python 和 JavaScript)。在 JS/TS 生态中,它提供了:

  • 统一的模型接口ChatOpenAIChatAnthropic 等,方便切换不同厂商的 LLM。
  • 工具抽象tool() 函数将任意异步函数包装为符合 OpenAI 工具调用规范的对象。
  • 消息管理SystemMessageHumanMessageAIMessageToolMessage 标准化对话轮次。
  • 绑定机制model.bindTools(tools) 将工具列表注册到模型,让模型自动决策。
  • 内置 ID 管理:LangChain 自动为每次工具调用生成唯一 ID,并确保 ToolMessage 正确引用该 ID。

LangChain 的出现比 OpenAI 函数调用(Function Calling)还早,它屏蔽了各家 API 的差异,让我们专注于业务逻辑。


四、实战:用 LangChain 构建一个“读文件” Agent

我们从零开始,逐步构建一个能读取文件内容并解释代码的 Agent。代码即文档,每一行我都会详细解析。

4.1 环境初始化

npm init -y
npm install @langchain/openai @langchain/core dotenv zod
# 或者用 pnpm / yarn

创建 .env 文件,存放你的 API 密钥(这里以 DeepSeek 为例,兼容 OpenAI 接口):

DEEPSEEK_API_KEY=sk-xxxxxxxxxxxxxxxxxxxx

解析:

  • @langchain/openai:封装了 OpenAI 兼容的模型调用(包括 DeepSeek、通义千问等)。
  • @langchain/core:提供核心抽象(toolmessagesChatOpenAI 基类)。
  • dotenv:加载环境变量。
  • zod:用于定义工具参数的 Schema,确保类型安全。

4.2 初始化模型

新建 index.mjs(使用 ES Module):

import { ChatOpenAI } from '@langchain/openai';
import 'dotenv/config';

// 1. 实例化模型客户端
const model = new ChatOpenAI({
  modelName: 'deepseek-v4-flash',  // 模型名称
  apiKey: process.env.DEEPSEEK_API_KEY, // 从环境变量读取
  temperature: 0,                  // 0 表示确定性输出,适合工具调用
  configuration: {
    baseURL: 'https://api.deepseek.com/v1', // DeepSeek 的兼容端点
  },
});

// 2. 简单调用测试
const response = await model.invoke('棍王杯台球比赛应该设什么奖励?');
console.log(response.content);

代码解析:

  • ChatOpenAI 是 LangChain 提供的聊天模型类。它的 invoke 方法发送消息并返回 AIMessage 对象。
  • modelName 和 apiKey 必填。configuration.baseURL 可以指向任何兼容 OpenAI 格式的代理地址。
  • temperature = 0 使输出更确定,适合工具调用场景(高温度会增加随机性,可能导致模型滥用工具)。
  • invoke 是 LangChain 统一的消息入口,底层会调用 client.chat.completions.create()

此时我们已经能正常调用大模型,但模型还不能调用工具。


4.3 定义一个工具:读取文件

新建 tool.mjs,我们将在这个文件中完成完整的工具定义和调用流程。

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

解析:

  • tool 是一个高阶函数,接受两个参数:执行函数和配置对象,返回一个 Tool 实例。

  • 消息类型:

    • SystemMessage:系统提示,设定角色和行为准则。
    • HumanMessage:用户输入。
    • AIMessage:模型的回复(可能包含 tool_calls)。
    • ToolMessage:工具执行后的结果,必须与 tool_call_id 对应。
  • fs/promises:Node.js 原生异步文件操作。

  • zod:定义参数结构。


定义 readFileTool

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('要读取的文件路径'),
    }),
  }
);

逐行解析:

  • 执行函数:接收一个对象参数 { filePath },这是由模型根据 schema 生成的。函数内部使用 fs.readFile 读取文件,返回内容字符串。工具可以返回任何可序列化的数据(字符串、对象、数组等)。

  • 日志输出:在终端打印工具调用情况,让开发者感知进度(对于耗时任务尤其重要,避免用户以为程序卡死)。

  • 元数据配置

    • name:工具的唯一标识,模型在 tool_calls 中会使用这个名字。
    • description这是模型决策的关键!  模型会根据描述来判断何时调用此工具,所以要尽可能详细,覆盖典型使用场景。
    • schema:使用 Zod 定义参数类型和描述。模型会根据 schema 生成符合格式的 JSON 参数。z.string().describe(...) 既提供了类型校验,又给模型提供了参数说明。

绑定工具到模型

const tools = [readFileTool];
const modelWithTools = model.bindTools(tools);

解析:

  • model.bindTools(tools) 会生成一个新的模型实例,它内部会将工具列表转换为 OpenAI Function Calling 格式,并在每次 invoke 时自动注入到请求中。
  • 绑定后,模型在生成回复时会根据用户消息和系统提示,自主决定是否调用工具以及调用哪个工具。

构造对话消息

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

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

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

解析:

  • SystemMessage 用于设定 AI 的角色和规则。我们明确告诉模型:当需要读取文件时,必须调用工具,不要自己编造内容。
  • 这种提示工程(Prompt Engineering)对于 Agent 的稳定性至关重要。清晰的指令能让模型少犯错。

第一次调用:模型决策是否使用工具

let response = await modelWithTools.invoke(messages);
messages.push(response);
console.log(JSON.stringify(response, null, 2));

解析:

  • 调用 modelWithTools.invoke(messages),LangChain 会将 messages 发送给 API。

  • 由于模型绑定了工具,并且用户要求读取文件,模型会返回一个 AIMessage,其中包含 tool_calls 字段,而不是直接的文本内容。

  • tool_calls 是一个数组,每个元素包含:

    • id:唯一标识此次调用的 ID(例如 "call_abc123"),由模型提供商生成。这个 ID 至关重要,它用于后续将工具执行结果与这次调用关联起来。
    • name:要调用的工具名称(read_File)。
    • args:工具参数对象({ filePath: 'tool.mjs' })。
  • 我们将该 AIMessage 追加到 messages 中,保留完整的对话上下文。


执行工具并返回结果(含 Tool ID 深度解析)

接下来我们需要手动(或借助 AgentExecutor)处理 tool_calls

// 遍历所有 tool_calls
for (const toolCall of response.tool_calls) {
  if (toolCall.name === 'read_File') {
    // ① 调用工具的执行函数
    const result = await readFileTool.invoke(toolCall.args);
    // ② 构造 ToolMessage,务必带上 tool_call_id
    const toolMessage = new ToolMessage({
      content: result,
      tool_call_id: toolCall.id, // 必须与原始调用 ID 一致
    });
    messages.push(toolMessage);
  }
}

// 第二次调用模型,让模型根据工具结果生成最终回答
const finalResponse = await modelWithTools.invoke(messages);
console.log(finalResponse.content);

代码详解:

  • readFileTool.invoke(toolCall.args) 会执行我们定义好的异步函数,返回文件内容。

  • ToolMessage 的 tool_call_id 必须和原始 tool_calls[].id 完全一致。为什么?

    • 在大模型中,一次请求可以包含多个工具调用(例如同时读 3 个文件)。模型需要知道哪个结果对应哪个调用,以便正确理解上下文。
    • 即使只有一个工具调用,ID 也是必需的,因为模型内部将工具调用视为“异步事件”,返回结果时需要明确归属。
    • 如果 ID 不匹配,模型会忽略该结果或报错,导致 Agent 流程中断。
    • 在并发场景下,ID 的作用更加凸显:当多个工具并行执行时,结果的返回顺序可能与调用顺序不同。只有通过 ID 才能将结果精准地路由到对应的调用位置,保证对话逻辑的正确性。
  • 将 ToolMessage 追加到 messages 后,再次调用 modelWithTools.invoke(messages)。此时模型看到工具返回的内容,会基于此生成最终的自然语言回复。

  • 最终 finalResponse.content 就是用户想要的解释。

金句:tool_call_id 是连接“请求”与“结果”的唯一凭证,是多工具并发场景下的定海神针。


五、LangChain 的消息流转机制(含 ID 路由)

为了帮助理解,我们梳理一下 LangChain 中的消息类型和流转顺序,并特别标注 ID 的流动:

  1. 用户输入 → HumanMessage
  2. 系统指令 → SystemMessage
  3. 模型决策(含 tool_calls,每个带有唯一 id)→ AIMessage
  4. 工具执行 → 返回结果,封装为 ToolMessage,并必须携带对应的 tool_call_id
  5. 模型根据 ID 将结果匹配到之前的调用,生成最终回答 → AIMessage

所有消息按顺序存储在 messages 数组中,形成一个完整的对话历史。这个历史会被持续传递给模型,使其具备上下文感知能力。ID 的匹配过程由 LangChain 或开发者显式处理,确保无误。


六、并发优化:Promise 与 Promise.all 深入解析

在 Agent 场景中,模型可能会一次性返回多个工具调用(比如一次读取 3 个文件,或同时搜索网页和查询数据库)。如果我们串行执行这些工具,总耗时是每个工具耗时的累加,用户体验会很差。

JavaScript 的异步特性让我们可以并发执行这些 I/O 操作。下面我们深入讲解 Promise 和 Promise.all,并强调在并发时如何正确处理 ID。

6.1 Promise 的三个状态

  • pending:初始状态,异步操作尚未完成。
  • fulfilled:操作成功完成,resolve(value) 被调用。
  • rejected:操作失败,reject(error) 被调用。

关键规则:  一旦状态从 pending 变为 fulfilled 或 rejected,就不能再改变。

6.2 Promise.all —— 并行执行多个异步任务

Promise.all(iterable) 接收一个 Promise 数组,返回一个新的 Promise。它会在所有输入 Promise 都变为 fulfilled 后,返回一个结果数组(顺序与输入一致);如果任何一个 Promise rejected,则立即 rejected。

在 Agent 中,我们可以这样使用:

// 假设 response.tool_calls 包含多个调用
const toolCalls = response.tool_calls; // 例如长度为 3

// 并发执行所有工具
const toolResults = await Promise.all(
  toolCalls.map(async (call) => {
    const result = await readFileTool.invoke(call.args);
    return new ToolMessage({
      content: result,
      tool_call_id: call.id, // 每个结果带上自己对应的 ID
    });
  })
);

// 一次性追加所有 ToolMessage
messages.push(...toolResults);

性能对比:

  • 串行:t1 + t2 + t3(例如 2s + 1s + 0.5s = 3.5s)
  • 并发:max(t1, t2, t3)(大约 2s)

ID 在并发中的关键作用:由于 Promise.all 会并发执行,各个工具完成的顺序可能不同于传入顺序。但我们在构造 ToolMessage 时,从 call.id 中读取了原始 ID,因此无论结果返回先后,每个消息都带上了正确的 ID,模型能正确归因。

6.3 容错:使用 Promise.allSettled

如果某个工具可能失败(例如文件不存在),Promise.all 会整体失败,导致其他工具的结果也丢失。这时可以用 Promise.allSettled

const results = await Promise.allSettled(
  toolCalls.map(call => readFileTool.invoke(call.args))
);

// 遍历 results,分别处理成功和失败的情况
results.forEach((result, index) => {
  const callId = toolCalls[index].id; // 根据索引获取对应的 ID
  if (result.status === 'fulfilled') {
    messages.push(new ToolMessage({
      content: result.value,
      tool_call_id: callId,
    }));
  } else {
    // 将错误信息作为工具结果返回,让模型知道出错
    messages.push(new ToolMessage({
      content: `Error: ${result.reason.message}`,
      tool_call_id: callId,
    }));
  }
});

注意:Promise.allSettled 的结果数组中,每个元素都有 status 和 value(或 reason)。我们通过原数组索引获取对应的 callId,确保即使失败也能正确关联。

6.4 并发示例:模拟天气和推文请求

下面是一个简单的演示(来自笔记中的 1.html),直观展示 Promise.all 的并发优势:

function getWeather() {
  return new Promise(resolve => {
    setTimeout(() => resolve({ temp: 38, conditions: 'Sunny' }), 2000);
  });
}

function getTweets() {
  return new Promise(resolve => {
    setTimeout(() => resolve(['I like cake', 'BBQ is good']), 500);
  });
}

async function main() {
  console.time('concurrent');
  const [weather, tweets] = await Promise.all([getWeather(), getTweets()]);
  console.timeEnd('concurrent'); // 大约 2 秒,而不是 2.5 秒
  console.log(weather, tweets);
}
main();

解析:

  • getWeather 耗时 2s,getTweets 耗时 0.5s。
  • 串行执行需要 2.5s,但用 Promise.all 并发执行,总耗时 ≈ 2s(由最长的任务决定)。
  • 返回结果数组的顺序与传入 Promise 的顺序一致,确保 weather 对应 getWeathertweets 对应 getTweets

6.5 在 Agent 中运用并发:最佳实践

  1. 识别独立的工具调用:只有那些不依赖彼此结果的工具才能并发。如果 tool2 需要 tool1 的输出,则必须串行。
  2. 限制并发数量:如果工具数量非常多(例如几十个),可以使用 p-limit 等库控制并发数,避免系统过载。
  3. 超时控制:为工具设置超时(Promise.race),防止单个工具卡住拖慢整个流程。
  4. ID 管理:始终从原始 tool_call 中提取 id 并正确传递给 ToolMessage,这是并发安全的基石。

七、总结与下一步

我们走完了从理论到实践的完整旅程:

  • ✅ 理解了 Agent 的核心组成(Memory、Tool、RAG、MCP、Skills)和工作流程(规划 → 调用 → 观察)。
  • ✅ 掌握了 LangChain 的核心抽象:ChatOpenAItoolbindTools、消息类型。
  • ✅ 深入解析了 tool_call_id 的作用:它是连接调用请求与执行结果的唯一凭证,尤其在多工具并发场景下不可或缺。
  • ✅ 亲手构建了一个可读取文件的 Agent,并深度解析了每段代码。
  • ✅ 学会了用 Promise.all 并发执行工具调用,大幅提升响应速度。
  • ✅ 了解了 Promise 的状态和容错策略(allSettled)。

Agent 的本质,就是让大模型拥有“手和脚”。而开发 Agent,本质上就是在设计一套“思考-行动”的闭环系统,其中 ID 是串联各环节的“回形针”。