摘要:2025-2026年,AI Agent(智能体)已从概念炒作进入工程落地阶段,成为大模型应用开发中最具商业价值的方向。Claude Code、Manus、Codex等明星产品背后都遵循着同一套核心架构。本文从"为什么裸LLM不够用"这一根本问题出发,逐层剖析Agent的六大核心模块(Memory、Tool、RAG、MCP、Skills),深入讲解Agent的工作流程和LLM的"自知之明"机制;随后以LangChain.js为主线,通过一个精心设计的示例项目,手把手带你从环境准备、最简模型调用,一路走到Tool-use Agent的完整实现;最后深入探讨Promise并发模型在Agent Tool调用中的性能优化策略,并给出简易版Claude Code Agent的架构设计思路。阅读本文,你将获得打造自己第一个AI Agent所需的完整知识体系和可运行的代码基础。
目录
- 为什么需要Agent?—— 裸LLM的五大局限
- Agent的核心公式:六大模块解析
- Agent的工作流程:从Prompt到任务完成
- LangChain —— 为什么选择它作为Agent开发框架
- 动手实践:Hello LangChain 项目全解析
- Tool性能优化:Promise并发模型深度解析
- 架构实战:手写一个简易版Claude Code Agent
- 总结与展望
1. 为什么需要Agent?—— 裸LLM的五大局限
很多初学者以为"做AI应用 = 调用大模型API",于是写下这样的代码:
const response = await openai.chat.completions.create({
model: 'gpt-4',
messages: [{ role: 'user', content: '帮我分析一下公司的财务报表' }],
});
console.log(response.choices[0].message.content);
跑完之后发现:模型回复头头是道,但实际上什么都没做成——它既没有读到你的财务报表文件,也没有记住你上周告诉它的财务背景,更没有去网上下载最新的行业对比数据。
这就是裸LLM(未经扩展的大语言模型)的根本局限。让我们逐一深入剖析。
1.1 问题一:LLM是无状态的
核心矛盾:你上周和它聊过的信息,这周它还能记住吗?
答案是:不能。每次API调用都是独立的、无状态的(Stateless)。HTTP请求本身是无状态的,LLM API也是如此——每一次 POST /v1/chat/completions 都是一个全新的会话。
这意味着什么? 你必须在每次请求时把整个对话历史重新发送一遍:
// 第1次对话
const messages1 = [{ role: 'user', content: '我叫张三,我是架构师' }];
// LLM回复: "你好张三,有什么可以帮助你的?"
// 第2次对话 —— 如果不带上历史,LLM就忘了你是谁
const messages2 = [{ role: 'user', content: '我叫什么名字?' }];
// LLM: "抱歉,我不知道你的名字……" ← 它真的不知道!
解决方案 —— Memory模块:
| 存储方案 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| 数据库(MySQL/PostgreSQL) | 持久化存储,长期记忆 | 可靠、可查询 | 速度较慢 |
| Redis | 会话缓存,短期记忆 | 极快 | 重启丢失 |
| 前端 localStorage | 用户偏好、设置 | 零服务端成本 | 容量有限 |
Memory模块的核心逻辑是:在每次发给LLM的消息列表中,自动拼接相关的历史记忆。这就是"LLM + 后端"组合的起点。
1.2 问题二:LLM无法与外部世界交互
核心矛盾:你让LLM帮你访问一个网页、读一个文件、发一封邮件——它只能给你思路,没法自己动手。
LLM是一个"缸中之脑":它能生成文本,但它的输出只是一串字符。它不能:
- 🌐 访问网页(没有HTTP客户端)
- 📁 读写文件(没有文件系统权限)
- 💻 执行命令(没有Shell)
- 📧 发送邮件(没有SMTP客户端)
- 🗄️ 查询数据库(没有数据库连接)
这就是Tool Use模块要解决的问题。Tool本质上是一个函数桥接器——把LLM的"意图"("我想读文件A")转化为实际的系统调用(fs.readFile('A')),然后把结果返回给LLM。
💡 类比:LLM像一个瘫痪的天才——大脑完好但四肢不能动。Tool就是给它装上的机械外骨骼,让它能实际作用于世界。
1.3 问题三:LLM无法访问私有/内部知识
核心矛盾:你问它公司内部的API文档、产品规格书、客户合同——它完全不知道。
LLM的知识来自于公开训练数据(网页、书籍、论文等)。这些数据中不包含:
- 你公司的内部Wiki
- 你的Notion文档
- 你的GitLab代码仓库
- 你的客户沟通记录
解决方案 —— RAG(Retrieval-Augmented Generation,检索增强生成):
RAG的工作流程分为两个阶段:
【离线阶段】文档 → 切分(Chunking) → 向量化(Embedding) → 向量数据库
【在线阶段】用户问题 → 向量化 → 相似检索 → 拼接Prompt → LLM生成回答
核心思想:先检索,再生成。不靠LLM的记忆,而是把相关文档"喂"给它。
1.4 问题四:LLM不知道训练截止日期之后的事
核心矛盾:你问最新的世界杯新闻、今天的股价、上周发布的框架文档——训练数据里没有。
所有LLM都有一个知识截止日期(Knowledge Cutoff),模型预训练完成后,其知识就"冻结"了。对于2026年的我们来说,如果你用的模型知识截止于2024年,那么:
- 2025年发布的LangChain 1.0 稳定版 → ❌ 不知道
- 2026年世界杯预选赛结果 → ❌ 不知道
- 今天的天气 → ❌ 不知道
解决方案 —— MCP(Model Context Protocol)+ 实时Tool:
MCP是Anthropic提出的一种标准化的第三方Tool协议,可以理解为"Tool的USB接口"——任何服务只要实现了MCP Server,Agent就能通过统一的MCP Client来调用它。比如:
- 天气MCP Server → Agent获取实时天气
- 新闻MCP Server → Agent获取最新资讯
- GitHub MCP Server → Agent操作代码仓库
1.5 问题五:LLM无法执行复杂的多步骤技能
核心矛盾:你让它"做一份Q2财报PPT,分析股市并自动买卖"——单次对话远远不够。
这类任务需要:
- 多步骤编排:先查数据 → 再分析 → 再生成图表 → 再插入PPT → 再发送
- 条件判断:如果股价跌破阈值 → 自动卖出;如果涨超5% → 提醒用户
- 错误处理:API挂了怎么办?数据格式不对怎么办?
- 长时间运行:可能需要几分钟甚至几小时
解决方案 —— Skills(技能):
Skills是将多个Tool和Memory操作"蒸馏"(组合封装)成一个可复用的技能单元。比如一个"股市分析Skill"内部可能组合了:
Skill: 股市分析
├── Tool: 获取实时股价 (MCP/API)
├── Tool: 获取历史K线数据 (API)
├── Tool: 技术指标计算 (本地函数)
├── Memory: 记住用户的持仓和风险偏好
└── Tool: 执行交易 (券商API)
2. Agent的核心公式:六大模块解析
围绕以上五大问题,Agent的本质就清晰了:给LLM装上各种"外挂",让它从一个只会"说话"的模型,变成一个能"做事"的智能体。
Agent = LLM + Memory + Tool + RAG + MCP + Skills
下面我们逐一深入理解每个模块的定位和作用。
2.1 LLM —— 大脑
| 维度 | 说明 |
|---|---|
| 角色 | Agent的中央处理器 |
| 职责 | 理解用户意图、分解任务、制定计划、推理判断、生成回复 |
| 关键能力 | Planning(规划)+ Reasoning(推理) |
| 类比 | 人的大脑皮层 |
LLM在Agent中不只是"生成文字"的工具,它是决策中心。面对一个复杂任务(比如"帮我搭建一个博客"),LLM需要:
- 理解意图:用户想要什么类型的博客?静态还是动态?有哪些约束?
- 分解任务:这个任务应该拆成哪些子步骤?
- 选择工具:每一步需要调用哪个Tool?
- 处理结果:Tool返回的结果怎么用?下一步是什么?
- 生成回复:最终如何向用户汇报?
2.2 Memory —— 记忆
| 维度 | 说明 |
|---|---|
| 角色 | Agent的记忆系统 |
| 职责 | 存储和检索用户偏好、历史对话、任务上下文 |
| 关键能力 | 持久化存储 + 上下文注入 |
| 类比 | 人的海马体 + 前额叶 |
Memory模块解决的核心问题是:让LLM的每次调用都能"看到"相关的历史信息。实现上通常分为两个层面:
- 短期记忆:当前会话的对话历史(存储在Redis或内存中,TTL较短)
- 长期记忆:用户偏好、重要事实、项目背景(存储在数据库中,持久化)
2.3 Tool —— 双手
| 维度 | 说明 |
|---|---|
| 角色 | Agent的行动能力 |
| 职责 | 执行具体操作(读写文件、调用API、运行命令、查询数据库) |
| 关键能力 | 函数调用 + 参数校验 + 结果返回 |
| 类比 | 人的双手和工具 |
Tool是Agent从"说"到"做"的桥梁。每一个Tool包含两部分:
- 处理函数(async function):实际干活的代码
- 描述对象:告诉LLM这个工具的用途、参数格式和适用场景
🔑 关键设计:LLM不是被硬编码"何时调用哪个工具"的——它通过Tool的描述对象自己判断。这意味着你可以随时添加新工具,LLM会自动学会使用它们!
2.4 RAG —— 检索增强生成
| 维度 | 说明 |
|---|---|
| 角色 | Agent的知识获取系统 |
| 职责 | 从外部知识库检索相关信息,注入到LLM的上下文中 |
| 关键能力 | 文档切分 + 向量化 + 相似检索 + Prompt拼接 |
| 类比 | 人的"查资料"能力 |
RAG的Pipeline:
用户问题 → Embedding模型 → 查询向量
↓
向量数据库(Milvus/Pinecone/pgvector)
↓
返回Top-K相关文档片段
↓
用户问题 + 文档片段 → Prompt Template → LLM → 回答
💡 注意:RAG和Tool的区别在于——RAG是"被动检索"(查资料),Tool是"主动执行"(做事情)。
2.5 MCP —— 模型上下文协议
| 维度 | 说明 |
|---|---|
| 角色 | 标准化的第三方Tool协议 |
| 职责 | 提供统一的Tool接入标准,让任何服务都能被Agent调用 |
| 关键能力 | Client-Server架构 + 工具发现 + 标准化调用 |
| 类比 | USB协议(插上就能用) |
MCP的价值在于标准化。没有MCP时,每接入一个新服务(天气、新闻、GitHub、数据库……),开发者都要手写适配代码。有了MCP,只需对接一次协议,所有MCP Server都能直接使用。
Agent (MCP Client) ←──MCP协议──→ MCP Server A (天气服务)
→ MCP Server B (GitHub)
→ MCP Server C (数据库)
→ MCP Server D (任意服务...)
2.6 Skills —— 技能编排
| 维度 | 说明 |
|---|---|
| 角色 | 多能力的组合编排 |
| 职责 | 将多个Tool + Memory操作"蒸馏"成可复用的技能单元 |
| 关键能力 | 工作流编排 + 条件逻辑 + 错误处理 |
| 类比 | 人的专业技能(如"做财务分析") |
Skills是更高层次的抽象。如果说Tool是"锤子"和"钉子",那Skill就是"做一张桌子"的完整工艺。
2.7 现实中的Agent产品剖析
| 产品 | Agent公式映射 |
|---|---|
| Claude Code | LLM(Claude)+ Tool(fs + cli + grep + ...) + Memory(对话历史) + MCP(第三方工具) + Skills(/review, /fix等) |
| Codex Coding Agent | LLM + Tool(代码编辑 + 终端) + Memory(项目上下文) + RAG(文档检索) |
| Manus | LLM + Tool(浏览器操作 + API调用) + Memory(任务状态) + Skills(自动化流程) |
💡 核心理念:Agent其实也不复杂——LLM本身也可以思考、规划,给它用Tool扩展能力,它就能自己做事情了;用Memory管理记忆,它就可以记住你要它记住的东西;还可以用RAG查询内部知识库来获取知识。这样一个知道内部知识、能思考规划、能够帮你做事情的扩展后的大模型,就是一个Agent。
3. Agent的工作流程:从Prompt到任务完成
3.1 完整执行流程
一个典型的Agent任务执行过程是一个循环决策链:
用户以Prompt形式提出一个复杂任务
↓
┌──────────────────────────────┐
│ LLM Planning / Reasoning │
│ (大模型进行规划和推理) │
│ │
│ 判断需要调用哪些模块: │
│ ├─ 需要加载 Memory 吗? │
│ │ 是 → 检索相关历史/偏好 │
│ ├─ 需要调用 Tool 吗? │
│ │ 是 → 分步骤调用多个工具 │
│ └─ 需要走 RAG 检索吗? │
│ 是 → 查询向量数据库 │
└──────────────────────────────┘
↓
各模块返回结果,组装上下文
↓
LLM 基于所有上下文生成最终响应
↓
返回 Response 给用户(任务完成)
3.2 LLM的"自知之明":tool_calls机制
这是Agent区别于传统程序最核心的特征:LLM自己判断"什么时候该调用什么工具",而不是由开发者用 if-else 硬编码。
传统程序的做法(硬编码):
if (userMessage.includes('读文件')) {
const content = fs.readFileSync(extractPath(userMessage));
// ...
} else if (userMessage.includes('天气')) {
const weather = await fetchWeather(extractCity(userMessage));
// ...
}
// 每新增一个能力,都要加一个 if-else……
Agent的做法(LLM自主决策):
// 只需要注册工具列表,LLM自己判断何时调用、如何调用
const modelWithTools = model.bindTools([readFileTool, weatherTool, emailTool]);
const response = await modelWithTools.invoke(messages);
// LLM的response中要么是文本回复,要么是tool_calls列表
当LLM判断需要调用工具时,它不生成文本回复,而是停下来返回 tool_calls:
{
"role": "assistant",
"content": null, // ← 不生成文本!
"tool_calls": [
{
"id": "call_abc123",
"name": "read_file",
"arguments": {
"filePath": "tool.mjs"
}
},
{
"id": "call_abc124",
"name": "search_web", // ← 可能同时调用多个工具!
"arguments": {
"query": "LangChain tool use best practices"
}
}
]
}
3.3 一个具体的执行示例
以用户请求 "请读取 tool.mjs 文件内容并解释代码" 为例:
Round 1:
用户消息: "请读取tool.mjs文件内容并解释代码"
↓
LLM推理: 我需要先读到文件内容,才能解释代码
↓
LLM返回 tool_calls: [{ name: "read_file", arguments: { filePath: "tool.mjs" } }]
↓
Agent执行 Tool: fs.readFile("tool.mjs") → 返回代码文本
↓
将 ToolMessage(id="call_abc123", content=代码文本) 追加到消息列表
Round 2:
消息列表: [SystemMessage, HumanMessage, AIMessage(tool_calls), ToolMessage(代码)]
↓
LLM推理: 我现在有代码内容了,可以开始解释
↓
LLM返回: "这段代码实现了……它首先导入了……然后定义了……"
↓
返回给用户
🔑 关键机制:
tool_calls中每个调用的id字段用于将工具执行结果(ToolMessage)与调用请求关联。因为Agent可能同时发起多个Tool调用,LLM需要通过id知道"哪个结果对应哪个调用",从而组装完整的任务上下文。这正是Promise.all并发执行多个Tool的基础。
4. LangChain —— 为什么选择它作为Agent开发框架
4.1 LangChain的历史定位
LangChain 是一个比 OpenAI 官方 SDK 诞生还早的 LLM 开发框架。当时 OpenAI 还没有推出 function calling,LangChain 就已经在探索"如何让 LLM 调用外部工具"这个问题了。
它的核心价值在于:在LLM应用开发的"蛮荒时代"建立了一套标准化的抽象层,让开发者不需要为每家模型厂商、每种Tool格式重复造轮子。
版本现状(截至项目创建时):LangChain.js v1.x 稳定版,核心包分为:
@langchain/core:核心抽象层(Tool、Message、Callback等)@langchain/openai:OpenAI兼容的ChatModel实现@langchain/community:社区贡献的各种集成langgraph:多Agent编排框架
4.2 核心能力一:统一的LLM抽象层
市面上有几十家大模型厂商(OpenAI、DeepSeek、通义千问、文心一言、智谱……),每家API格式都不一样。LangChain 的 ChatOpenAI 类提供统一的接入方式:
import { ChatOpenAI } from '@langchain/openai';
// 切换到 DeepSeek
const model = new ChatOpenAI({
modelName: 'deepseek-v4-flash', // 模型名称 → 一行切换
apiKey: process.env.DEEPSEEK_API_KEY,
configuration: {
baseURL: 'https://api.deepseek.com/v1', // API地址 → 一行切换
}
});
// 同样的代码,换到通义千问只需修改配置
const qianwenModel = new ChatOpenAI({
modelName: 'qwen-turbo',
apiKey: process.env.QWEN_API_KEY,
configuration: {
baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
}
});
设计哲学:按需加载。你不用的模型,对应的包根本不会被打包进项目。@langchain/openai 就是这样一个"适配器包"。
4.3 核心能力二:标准化的Tool抽象
在 LangChain 诞生之前,手写 OpenAI function calling 的 Tool 定义非常繁琐:
// 原始 OpenAI function calling 格式 —— 手写JSON Schema
const tools = [{
type: 'function',
function: {
name: 'read_file',
description: '读取文件内容...',
parameters: {
type: 'object',
properties: {
filePath: { type: 'string', description: '要读取的文件路径' }
},
required: ['filePath']
}
}
}];
LangChain 通过 @langchain/core/tools 配合 Zod 将其大大简化:
import { tool } from '@langchain/core/tools';
import { z } from 'zod';
const readFileTool = tool(
async ({ filePath }) => { /* 实现逻辑 */ },
{
name: 'read_file',
description: '读取文件内容',
schema: z.object({
filePath: z.string().describe('要读取的文件路径'),
}),
}
);
Zod在这里扮演了"类型契约"的角色——它既是运行时校验,又自动生成了LLM所需的JSON Schema。
4.4 核心能力三:消息类型体系
LangChain 定义了清晰的消息类型体系(来自 @langchain/core/messages):
| 消息类型 | role | 方向 | 用途 |
|---|---|---|---|
HumanMessage | user | 用户 → LLM | 用户的自然语言输入 |
SystemMessage | system | 系统 → LLM | 系统级指令(角色设定、规则约束) |
AIMessage | assistant | LLM → 用户 | LLM的文本回复或tool_calls |
ToolMessage | tool | Tool → LLM | 工具执行结果,需要关联 tool_call_id |
这种类型体系让消息管理变得类型安全且语义清晰——你不再需要手写 { role: 'user', content: '...' } 这样的裸对象。
4.5 全栈Agent技术架构
把 Agent 开发放到一个完整的生产级技术栈中来看:
| 层次 | 技术选型 | 用途 | 说明 |
|---|---|---|---|
| 🖥️ 后端框架 | NestJS | 企业级API服务 | 依赖注入、模块化、可测试 |
| 🤖 单Agent | LangChain.js | 单智能体开发 | Tool/Memory/RAG一站式 |
| 🕸️ 多Agent | LangGraph | 多Agent协作 | 有状态图、条件分支、循环 |
| 🔌 工具协议 | MCP | 标准化工具接入 | Client-Server,即插即用 |
| 📚 知识检索 | RAG | 内部知识库 | 向量检索 + Prompt注入 |
| 🎯 技能编排 | Skills | 复杂能力组合 | 多Tool的"蒸馏"封装 |
🎯 目标:结合后端技术,开发AI全栈Agent产品,让AI技术通过 Harness Engineering 落地,实现AI技术的商业价值(FDE: Full-Stack Development Engineering)。
5. 动手实践:Hello LangChain 项目全解析
理论讲得够多了,下面我们通过 hello-langchain/ 项目来动手实践。这个项目的设计非常精巧——它用最少的代码覆盖了从"裸LLM调用"到"Tool-use Agent"的完整演进路径。
5.1 项目初始化与环境配置
项目结构
hello-langchain/
├── .env # 环境变量(API Key配置)
├── package.json # 项目元信息与依赖声明
├── pnpm-lock.yaml # 依赖版本锁文件
├── index.mjs # 级别一:最简模型调用
├── tool.mjs # 级别二:Tool-use Agent
└── 1.html # 基础知识:Promise并发演示
依赖解析
项目的 package.json 声明了四个核心依赖:
{
"name": "hello-langchain",
"version": "1.0.0",
"dependencies": {
"@langchain/core": "^1.2.1", // LangChain核心抽象
"@langchain/openai": "^1.5.3", // OpenAI兼容适配器
"dotenv": "^17.4.2", // 环境变量加载
"zod": "^4.4.3" // Schema校验
}
}
| 依赖 | 版本 | 为什么需要它? |
|---|---|---|
@langchain/core | ^1.2.1 | 提供 tool()、HumanMessage、SystemMessage、ToolMessage、AIMessage 等核心抽象。所有LangChain生态的基础。 |
@langchain/openai | ^1.5.3 | 提供 ChatOpenAI 类。虽然叫"openai",但它兼容所有OpenAI API格式的服务(DeepSeek、通义千问等)。 |
dotenv | ^17.4.2 | 将 .env 文件中的 KEY=VALUE 自动加载到 process.env。避免API Key硬编码在源码中。 |
zod | ^4.4.3 | 提供 z.object({...}) 式的Schema定义,既是TypeScript类型推断的基础,也是Tool参数校验的运行时保障。 |
.env 环境配置
.env 文件只包含一行:
DEEPSEEK_API_KEY=sk-6220b65165a14b20b0f78272ef13947e
⚠️ 安全提示:API Key 不应硬编码在源码或
.env文件中提交到 Git 仓库。生产环境中应使用环境变量注入(如 k8s Secrets、CI/CD Variables)。这里的 Key 仅供本地学习使用。
pnpm-lock.yaml 中的关键传递依赖
@langchain/openai@1.5.3
└── openai@6.45.0 ← OpenAI Node.js SDK(HTTP客户端)
└── ... ← 实际的API通信层
@langchain/core@1.2.1
├── @cfworker/json-schema@4.1.1 ← JSON Schema校验
├── js-tiktoken@1.0.21 ← Token计数
├── langsmith@0.7.15 ← 调试追踪(LangSmith平台)
├── p-queue@6.6.2 ← 并发队列管理
└── zod@4.4.3 ← Schema定义
💡 注意
p-queue:这是LangChain内部用于管理并发Tool执行的队列库——和我们要讲的Promise.all天然关联。
5.2 级别一:最简模型调用
这是Agent开发的"Hello World"——用最少的代码验证整个链路是否通畅:
import { ChatOpenAI } from '@langchain/openai';
import 'dotenv/config'; // ← 副作用导入,自动执行dotenv.config()
const model = new ChatOpenAI({
modelName: 'deepseek-v4-flash', // 使用DeepSeek的快速模型
apiKey: process.env.DEEPSEEK_API_KEY, // 从.env加载
configuration: {
baseURL: 'https://api.deepseek.com/v1', // DeepSeek的OpenAI兼容端点
}
});
const response = await model.invoke('台球赛比赛奖励是什么?');
console.log(response.content);
🎯 这个文件的价值:它验证了整个技术栈——Node.js运行时 → npm依赖安装正确 → API Key有效 → 网络能连通DeepSeek → 模型能正常响应。在开始复杂的Agent开发之前,确保这个"Hello World"能跑通是最佳的调试策略。
5.3 级别二:Tool-use Agent
这是整个项目的核心,它展示了如何给LLM装上"双手",让它能真正干活。
分段深度解析
第一段:导入区
import 'dotenv/config';
import { ChatOpenAI } from '@langchain/openai';
import { tool } from '@langchain/core/tools';
import {
HumanMessage, // role: 'user' → 用户的输入
SystemMessage, // role: 'system' → 系统指令/角色设定
ToolMessage, // role: 'tool' → 工具执行结果
AIMessage // role: 'assistant' → LLM的回复(文本或tool_calls)
} from '@langchain/core/messages';
import fs from 'node:fs/promises';
import { z } from 'zod';
这里导入了四种消息类型——它们共同构成了Agent对话的完整消息生命周期:
SystemMessage ──→ 设定角色,一次性注入(不参与多轮对话的用户输入)
HumanMessage ──→ 用户问题,每次对话的起点
AIMessage ──→ LLM回复:要么是文本(content),要么是tool_calls
ToolMessage ──→ 工具执行结果,通过tool_call_id关联到具体的AIMessage.tool_calls[i].id
fs 从 node:fs/promises 导入,注意 node: 前缀——这是Node.js内置模块的标准写法,确保不会被npm包劫持。fs/promises 提供Promise-based的文件API(readFile、writeFile等),返回Promise,天然支持 await。
第二段:模型初始化
const model = new ChatOpenAI({
modelName: 'deepseek-v4-flash',
apiKey: process.env.DEEPSEEK_API_KEY,
temperature: 0, // ← 新增!设为0让输出更确定
configuration: {
baseURL: 'https://api.deepseek.com/v1',
}
});
这里多了 temperature: 0 参数:
| temperature值 | 效果 | 适用场景 |
|---|---|---|
0 | 确定性输出,每次结果基本相同 | Tool调用(需要稳定性和可预测性) |
0.5 | 适度的创造性 | 一般对话 |
1.0 | 高随机性 | 创意写作 |
在Tool-use场景中,temperature: 0 是最佳实践——你希望LLM稳定地选择正确的工具和参数,而不是每次随机选不同的工具。
第三段:Tool定义 —— 本文最核心的代码
const readFileTool = tool(
// ─────────── 第一部分:异步处理函数 ───────────
async ({ filePath }) => {
// fs.readFile是异步的,返回Promise<string>
const content = await fs.readFile(filePath, 'utf-8');
// 实时反馈给用户——Agent任务可能很复杂、很耗时
// 用户如果太久没看到反馈,可能会以为程序卡死了
console.log(`[工具调用] read_file (${filePath})
成功读取${content.length}字节`);
return content; // 返回值会作为ToolMessage的content
},
// ─────────── 第二部分:函数描述对象 ───────────
{
name: 'read_file', // 工具的唯一标识名
description: `用此工具来读取文件内容,
当用户要求读取文件、查看代码、分析文件内容时,
调用此工具。输入文件路径(可以是相对路径或绝对路径)`,
schema: z.object({
filePath: z.string().describe('要读取的文件路径'),
}),
}
);
这段代码展示了LangChain Tool定义的两部分结构,这也是所有Tool的本质:
| 部分 | 类型 | 面向谁? | 作用 |
|---|---|---|---|
| 处理函数 | async (args) => result | 面向运行时 | 实际执行操作,返回结果 |
| 描述对象 | { name, description, schema } | 面向LLM | 告诉LLM"我能做什么、何时用、需要什么参数" |
处理函数的细节:
async ({ filePath }) => { // ← 解构参数,参数名必须和schema中定义的一致
const content = await fs.readFile(filePath, 'utf-8');
console.log(`[工具调用] ...`); // ← 这是Agent开发的重要实践
return content;
}
console.log不是可有可无的装饰——它是用户反馈机制。Agent执行复杂任务时可能需要几十秒甚至几分钟,用户如果看不到任何进度,会以为程序崩溃了return content的值将作为ToolMessage的content字段传回给LLM
描述对象的设计艺术:
{
name: 'read_file', // 简短、准确、英文(与LLM的训练数据一致)
description: `用此工具来读取文件内容,
当用户要求读取文件、查看代码、分析文件内容时,调用此工具。
输入文件路径(可以是相对路径或绝对路径)`,
schema: z.object({
filePath: z.string().describe('要读取的文件路径'),
}),
}
description 的写法至关重要——它相当于给LLM看的API文档。一个好的 description 应该包含:
- 功能说明:这个工具做什么?
- 触发条件:什么时候应该调用它?("当用户要求读取文件、查看代码、分析文件内容时")
- 参数说明:需要什么参数?格式是什么?(通过schema中的
.describe()补充)
description 写得好不好,直接影响LLM能否正确判断"是否该用这个工具"。如果 description 过于模糊,LLM可能在本该调用工具时生成文本回复;如果过于宽泛,LLM可能在不该调用时也触发工具。
Zod Schema的角色:
schema: z.object({
filePath: z.string().describe('要读取的文件路径'),
})
LangChain内部会将这个Zod schema转换为OpenAI function calling所需的JSON Schema格式:
{
"type": "object",
"properties": {
"filePath": { "type": "string", "description": "要读取的文件路径" }
},
"required": ["filePath"]
}
Zod在这里提供了双重价值:
- 编译时:TypeScript类型推断
- 运行时:参数校验(如果LLM传入的参数不符合schema,Zod会抛出明确的错误)
第四段:Tool注册——bindTools
// 将工具放入数组——可以放多个
const tools = [
readFileTool
// 未来可以添加更多工具:
// writeFileTool,
// executeCommandTool,
// searchWebTool,
];
// bindTools 是LangChain提供的核心方法
// 它将Tool列表转换为LLM能理解的function calling格式
const modelWithTools = model.bindTools(tools);
model.bindTools(tools) 是LangChain的魔法所在。它做了什么?
- 遍历
tools数组中的每个Tool - 将每个Tool的描述对象转换为OpenAI function calling格式
- 将所有工具定义注入到后续每次
invoke()请求的tools参数中
效果:之后每次调用 modelWithTools.invoke(messages),LLM在生成回复前都会"看到"所有可用工具的列表,并自行判断是否需要调用工具。
bindTools 返回的是一个新的模型实例(包装后的),原始的 model 不受影响。这意味着你可以:
model.invoke()→ 不带工具的纯对话modelWithTools.invoke()→ 带工具的Agent模式
第五段:消息构建
const messages = [
new SystemMessage(`
你是一个代码助手,可以使用工具读取文件并解释代码。
工作流程:
1. 用户要求读取文件时,立即调用read_file工具。
2. 等待工具返回文件内容。
3. 基于文件内容进行分析和解释。
可用工具:
- read_file: 读取文件内容(使用此工具来读取文件内容)
`),
new HumanMessage('请读取tool.mjs文件内容并解释代码'),
];
SystemMessage(系统消息):这是Agent的"角色设定"。它告诉LLM:
- 你是什么角色(代码助手)
- 你有哪些能力(可以读取文件)
- 你的工作流程是什么(先读文件,再分析)
- 有哪些工具可用(read_file)
SystemMessage的写法没有标准答案,但有几个经验法则:
- 明确角色:"你是一个XXX" 开头
- 说明能力边界:能做什么、不能做什么
- 规定工作流程:分步骤说明
- 列出可用工具:方便LLM快速判断
HumanMessage(用户消息):"请读取tool.mjs文件内容并解释代码"——这是一个典型的"需要Tool"的请求,因为LLM不读文件就看不到代码。
第六段:执行区
let response = await modelWithTools.invoke(messages);
messages.push(response); // 将AI回复加入对话历史
// 多个工具 执行 例如:await read await write 并发?
最后一行注释是项目作者留下的思考——如何处理多个Tool的并发执行?
这正是下一节要深入探讨的问题。如果LLM返回了两个 tool_calls(读文件A和读文件B),我们是否应该串行 await?还是用 Promise.all 并发执行?答案是显而易见的——这就是高性能Agent的关键。
5.4 Tool调用的消息流转全流程
为了让你彻底理解消息在Agent系统中的流转,下面是完整的时序图:
时间线 →
① 用户发起请求
messages = [SystemMessage, HumanMessage("请读取tool.mjs...")]
↓
② modelWithTools.invoke(messages)
LangChain内部:将tools定义+ messages一起发给DeepSeek API
↓
③ LLM推理:我需要调read_file工具
LLM返回 AIMessage {
content: null,
tool_calls: [{
id: "call_abc123",
name: "read_file",
arguments: { filePath: "tool.mjs" }
}]
}
↓
④ messages.push(AIMessage) → 对话历史现在有3条消息
↓
⑤ Agent检查 response.tool_calls
发现需要调用工具 → 执行 readFileTool({ filePath: "tool.mjs" })
工具返回: "import 'dotenv/config'; ...(代码全文)..."
↓
⑥ 创建 ToolMessage {
role: "tool",
content: "import 'dotenv/config'; ...(代码全文)...",
tool_call_id: "call_abc123" ← 关联到第③步的id
}
messages.push(ToolMessage) → 对话历史现在有4条消息
↓
⑦ modelWithTools.invoke(messages) → 第二次调用LLM
LLM现在看到了完整的上下文:
- SystemMessage(角色设定)
- HumanMessage(用户问题)
- AIMessage(之前的tool_calls)
- ToolMessage(工具返回的文件内容) ← LLM现在可以读代码了!
↓
⑧ LLM生成回复 AIMessage {
content: "这段代码实现了一个基于LangChain的Tool-use Agent。
它首先导入了ChatOpenAI模型适配器..."
}
↓
⑨ 返回给用户
🔑 关键洞察:整个过程中,LLM被调用了两次(第②步和第⑦步),但用户只感知到一次交互。这就是Agent的"自主循环"——它自己判断是否需要继续调用工具,直到能给出最终回答。
6. Tool性能优化:Promise并发模型深度解析
6.1 为什么Agent需要并发?
在真实的Agent应用场景中,LLM经常需要同时调用多个Tool:
- 用户:"对比一下两个文件的区别" → 需要同时读2个文件
- 用户:"搜索关于LangChain的最新文章并总结" → 需要同时搜索多个关键词
- 用户:"查一下这三个城市的天气" → 需要同时调用3次天气API
如果串行执行,比如读3个文件(每个500ms):
读文件A (500ms) → 读文件B (500ms) → 读文件C (500ms) = 1500ms
如果使用并发:
Promise.all([读文件A, 读文件B, 读文件C]) = max(500ms, 500ms, 500ms) = 500ms
3倍速度提升,且Tool数量越多,收益越大。
6.2 Promise基础:三种状态与不可变性
在深入实战代码之前,先回顾Promise的核心概念。
Promise是ES6(ECMAScript 2015)引入的异步编程基础设施。它代表一个未来会完成的操作,有三种互斥的状态:
┌─────────────┐
│ Pending │ 初始状态:操作进行中
│ (等待中) │
└──────┬──────┘
│
┌────────────┼────────────┐
│ │
▼ ▼
┌──────────────┐ ┌──────────────┐
│ Fulfilled │ │ Rejected │
│ (已成功) │ │ (已失败) │
└──────────────┘ └──────────────┘
resolve() reject()
Promise不可变性的核心保证:
⚠️ Promise状态只能从Pending转换到Fulfilled或Rejected之一,一旦转换就永远锁死,不能再变。
这意味着:
- 你不能"取消"一个已经成功的Promise
- 你不能"修复"一个已经失败的Promise
- 你只能在Promise完成之后通过
.then()/.catch()/await来处理结果
这不是Bug,而是设计特性——它保证了异步操作的确定性,避免了"回调地狱"中的状态混乱。
6.3 串行 vs 并行:实战对比
下面用两个模拟的异步任务直观展示串行和并行的性能差异。这些代码可以直接在浏览器中运行验证。
模拟函数定义
// 模拟天气查询 —— 耗时2000ms(模拟慢速API)
function getWeather() {
return new Promise((resolve) => {
setTimeout(() => {
resolve({ temp: 38, conditions: 'Sunny with Clouds' });
}, 2000);
});
}
// 模拟推文查询 —— 耗时500ms(模拟快速API)
function getTweets() {
return new Promise((resolve) => {
setTimeout(() => {
resolve(['I like cake', 'BBQ is good too!']);
}, 500);
});
}
这两个函数的设计非常巧妙:
getWeather()耗时2000ms → 模拟慢速Tool(如大文件读取、远程API调用)getTweets()耗时500ms → 模拟快速Tool(如缓存查询、本地文件读取)
它们的耗时差异(2000ms vs 500ms)让性能对比更加直观。
方式一:串行执行(❌ 反模式)
async function main() {
console.time("my-operation");
// 第一个await:启动getWeather()的Promise,等待2000ms
const weatherData = await getWeather();
// ↑ 上面这一行不完成,永远不会执行下面这行
const tweetsData = await getTweets();
// ↑ 再等500ms
console.log(weatherData); // { temp: 38, conditions: 'Sunny with Clouds' }
console.log(tweetsData); // ['I like cake', 'BBQ is good too!']
console.timeEnd("my-operation"); // 输出: my-operation: ~2500ms
}
串行执行的时间线:
0ms ──────────────────────── 2000ms ──────── 2500ms
│ │ │
└─ getWeather() 开始 └─ getWeather 完成
└─ getTweets 开始 └─ getTweets 完成
└─ 打印结果
总耗时 = 2000ms + 500ms = 2500ms
问题很明显:getTweets() 完全不依赖 getWeather() 的结果,但它必须干等2000ms才能开始。这是对时间的纯粹浪费。
方式二:并行执行(✅ 最佳实践)
const main = async () => {
console.time("my-operation");
// Promise.all 同时启动两个Promise
const [weatherData, tweetsData] =
await Promise.all([getWeather(), getTweets()]);
// ↑ 解构赋值,顺序与数组中的顺序一致
console.log(weatherData); // { temp: 38, conditions: 'Sunny with Clouds' }
console.log(tweetsData); // ['I like cake', 'BBQ is good too!']
console.timeEnd("my-operation"); // 输出: my-operation: ~2000ms
};
main();
并行执行的时间线:
0ms ───── 500ms ──────────────────────── 2000ms
│ │ │
├─ getWeather() 开始 ─────────────────────┘ getWeather 完成
└─ getTweets() 开始 ──┘ getTweets 完成 └─ 打印结果(等getWeather也完成)
总耗时 = max(2000ms, 500ms) = 2000ms
性能对比:
| 执行方式 | 耗时 | 节省 |
|---|---|---|
| 串行(await逐个) | 2500ms | - |
| 并行(Promise.all) | 2000ms | 500ms(20%) |
两个Tool就节省了20%,如果你有5个Tool(每个500ms),串行需要2500ms,并行只需要500ms——5倍差距。
关于代码风格
值得注意的细节——main函数的两种写法:
// 写法一:async function 声明
async function main() {
const weatherData = await getWeather();
const tweetsData = await getTweets();
}
// 写法二:箭头函数 + const
const main = async () => {
const [weatherData, tweetsData] =
await Promise.all([getWeather(), getTweets()]);
};
这两种写法在功能上等价,但社区偏好箭头函数 + const 的组合——更简洁、更符合现代JavaScript风格。
6.4 async/await:异步变同步的语法糖
async/await 是ES8(ECMAScript 2017)引入的语法,它是目前最优雅的异步编程方式:
// 没有 async/await 的时代 —— "回调地狱"
getWeather()
.then(weatherData => {
return getTweets().then(tweetsData => {
console.log(weatherData, tweetsData);
});
});
// 有了 async/await —— 看起来像同步代码,但本质仍是异步
const weatherData = await getWeather();
const tweetsData = await getTweets();
console.log(weatherData, tweetsData);
await 的语义:
- 等待一个Promise完成(Fulfilled或Rejected)
- 如果Fulfilled:返回resolve的值(赋给
=左边) - 如果Rejected:抛出一个异常(可以用
try/catch捕获) - 不阻塞JavaScript主线程——它只暂停当前async函数的执行,事件循环继续运转
⚠️ 常见误区:
await让代码"看起来"同步了,但Promise的异步本质没变。await只是语法糖——底层仍是.then()回调。
6.5 Promise.all在Agent中的实战应用
回到Agent场景,当LLM返回多个 tool_calls 时:
// LLM的回复中有两个tool_calls
const response = await modelWithTools.invoke(messages);
// response.tool_calls = [
// { id: 'call_1', name: 'read_file', arguments: { filePath: 'tool.mjs' } },
// { id: 'call_2', name: 'read_file', arguments: { filePath: 'index.mjs' } },
// ]
// 串行执行(❌ 低效)
for (const tc of response.tool_calls) {
const result = await executeTool(tc.name, tc.arguments);
messages.push(new ToolMessage({
content: result,
tool_call_id: tc.id,
}));
}
// 并行执行(✅ 高效)
const toolResults = await Promise.all(
response.tool_calls.map(tc =>
executeTool(tc.name, tc.arguments).then(result => ({
id: tc.id, // 必须保留id!LLM用它关联结果
content: result,
}))
)
);
// 按id将结果组装成ToolMessage
toolResults.forEach(({ id, content }) => {
messages.push(new ToolMessage({ content, tool_call_id: id }));
});
// 所有工具执行完毕后,再次调用LLM
const finalResponse = await modelWithTools.invoke(messages);
这和我们之前讲的 Promise.all 完全一致:
- 各个Tool调用独立,互不依赖 → 可以并行
Promise.all等待所有完成 → 结果顺序与tool_calls数组一致- 通过
id字段保持结果与调用的关联 → LLM能正确组装上下文
🚀 这就是打造高性能Agent的关键技术——用并发替代串行,让多个独立工具同时执行,显著缩短总响应时间。
7. 架构实战:手写一个简易版Claude Code Agent
7.1 核心公式
理解了以上所有概念后,我们就可以设计一个简化版的Claude Code Agent:
简易Agent = LLM + Tool(fs + cli)
- fs(文件系统):读文件、写文件、列目录、删除文件等
- cli(命令行):执行任意Shell命令、启动服务、安装依赖等
这已经是Claude Code的核心能力子集。Claude Code在此基础上还有grep搜索、LSP代码分析、Git操作等更多Tool,但架构模式完全一样。
7.2 Demo场景:创建一个React+Vite的TodoList
以用户输入 "创建一个React+Vite的TodoList" 为例,Agent需要做Planning(规划):
用户输入: "创建一个React+Vite的TodoList"
↓
┌──────────────────────────────┐
│ LLM Planning 规划 │
│ │
│ 判断这是一个编程任务,分解为: │
│ │
│ Step 1: 用Vite创建项目骨架 │
│ → Tool: CLI (npm create vite)│
│ │
│ Step 2: 生成TodoList代码 │
│ → Tool: File (writeFile) │
│ → 需要使用编程能力强的模型 │
│ │
│ Step 3: 启动项目验证 │
│ → Tool: CLI (npm run dev) │
└──────────────────────────────┘
这个Planning过程体现了LLM的推理能力——它理解"创建React+Vite项目"意味着需要执行哪些命令行操作,并且知道步骤之间的依赖关系(必须先创建项目 → 才能写代码 → 才能启动)。
7.3 完整架构设计
┌──────────────────────────────────────────────────────┐
│ 简易 Claude Code Agent │
│ │
│ ┌──────────────────────────────────────────────┐ │
│ │ LLM (DeepSeek/Claude) │ │
│ │ │ │
│ │ • Planning: 分解任务为Tool调用序列 │ │
│ │ • Reasoning: 判断Tool结果,决策下一步 │ │
│ │ • Code Generation: 基于上下文编写代码 │ │
│ └──────────────────┬───────────────────────────┘ │
│ │ │
│ ┌───────────┼───────────┐ │
│ │ │ │
│ ▼ ▼ │
│ ┌─────────────┐ ┌─────────────┐ │
│ │ FS Tools │ │ CLI Tools │ │
│ ├─────────────┤ ├─────────────┤ │
│ │ readFile │ │ execCommand │ │
│ │ writeFile │ │ spawnProcess│ │
│ │ listDir │ │ killProcess │ │
│ │ deleteFile │ │ │ │
│ └─────────────┘ └─────────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌──────────────────────────────────────────────┐ │
│ │ Operating System │ │
│ │ (真实的文件系统和Shell) │ │
│ └──────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────┐ │
│ │ Message History │ │
│ │ [SystemMsg, HumanMsg, AIMsg, ToolMsg, ...] │ │
│ │ ↑ Memory: 管理对话上下文 │ │
│ └──────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────┘
7.4 关键设计决策
在设计Agent系统时,有几个架构决策值得深入思考:
决策一:何时用串行,何时用并行?
// 用例1:互相独立的Tool → Promise.all 并行
// 用户:"同时读A.mjs和B.mjs"
Promise.all([readFile('A.mjs'), readFile('B.mjs')]);
// 用例2:有依赖关系的Tool → 必须串行
// Step 1 的输出是 Step 2 的输入
await writeFile('App.jsx', generatedCode); // 先写
await execCommand('npm run dev'); // 再启动(依赖文件存在)
决策二:任务反馈如何设计?
// 工具执行时实时反馈
console.log(`[工具调用] read_file (${filePath}) 成功读取${content.length}字节`);
console.log(`[工具调用] write_file (${filePath}) 已写入${code.length}字符`);
console.log(`[工具调用] exec_command (${cmd}) 正在执行...`);
// 原因:Agent任务可能很复杂、很耗时,用户太久没看到反馈会退出
决策三:如何管理消息列表?
const messages = [systemMessage, ...historyMessages, userMessage];
while (true) {
const response = await modelWithTools.invoke(messages);
messages.push(response); // 维护完整的对话历史
if (response.tool_calls && response.tool_calls.length > 0) {
// 并行执行所有工具调用
const results = await Promise.all(
response.tool_calls.map(tc => executeTool(tc))
);
// 将结果追加到消息列表
results.forEach(r => messages.push(r));
// 继续循环,让LLM处理结果
} else {
// 没有tool_calls → LLM给出了最终回答
return response.content;
}
}
这个 while 循环就是Agent的核心执行引擎——它会自动循环,直到LLM不再请求工具调用。
8. 总结与展望
8.1 核心知识体系回顾
┌──────────────────────────────────┐
│ AI Agent 全景图 │
│ │
│ ┌─────────────────────────┐ │
│ │ LLM (大脑/决策中心) │ │
│ │ • 理解意图 │ │
│ │ • 规划任务 (Planning) │ │
│ │ • 推理判断 (Reasoning) │ │
│ │ • 生成回复 (Generation) │ │
│ └────────────┬────────────┘ │
│ │ │
│ ┌───────────┼───────────┐ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌──────┐ ┌──────┐ ┌──────┐ │
│ │Memory│ │ Tool │ │ RAG │ │
│ │ 记忆 │ │ 工具 │ │ 检索 │ │
│ └──────┘ └──────┘ └──────┘ │
│ ┌──────┐ ┌──────┐ │
│ │ MCP │ │Skills│ │
│ │ 协议 │ │ 技能 │ │
│ └──────┘ └──────┘ │
│ │
│ 开发框架: LangChain + LangGraph │
│ 后端框架: NestJS │
│ 并发优化: Promise.all │
└──────────────────────────────────┘
8.2 从本文你可以带走的6项能力
| # | 能力 | 具体收获 |
|---|---|---|
| 1 | Agent核心概念 | 深刻理解 Agent = LLM + Memory + Tool + RAG + MCP + Skills,每个模块解决LLM的一个根本局限 |
| 2 | Agent工作流程 | 掌握 用户Prompt → LLM规划推理 → 模块调用 → 组装上下文 → 响应的完整执行链路 |
| 3 | tool_calls机制 | 理解LLM的"自知之明"——它自己判断何时调用工具、如何调用,而非硬编码 |
| 4 | LangChain框架 | 掌握 ChatOpenAI统一接口 + tool()抽象 + bindTools注册 + 四种消息类型的完整用法 |
| 5 | Promise并发优化 | 理解Promise三状态模型、串行vs并行的性能差异、Promise.all在Agent Tool调用中的实战应用 |
| 6 | 架构思维 | 能设计简易Claude Code Agent,理解LLM + Tool(fs+cli) + 并发执行的底层原理 |
8.3 下一步学习路线图
掌握了单Agent的开发基础后,你可以沿着以下路线继续深入:
当前阶段:单Agent开发 (LangChain.js)
│
├──→ LangGraph:多Agent协作编排
│ • 有状态图的Agent工作流
│ • 条件分支、循环、并行
│ • Agent间的消息传递
│
├──→ MCP协议深入:标准化工具生态
│ • 自己实现一个MCP Server
│ • 接入社区MCP工具市场
│ • 理解MCP的Client-Server架构
│
├──→ RAG实战:企业知识库
│ • Embedding模型选型
│ • 向量数据库(Milvus/Pinecone/pgvector)
│ • Chunking策略与检索优化
│
└──→ NestJS集成:生产级Agent服务
• Agent能力封装为REST API
• 用户认证与多租户
• 监控、日志、限流
🚀 Agent开发是当下最值钱的AI工程化方向。结合后端技术(NestJS),开发AI全栈Agent产品,让AI技术通过 Harness Engineering 落地,实现AI技术的商业价值(FDE)。