别再傻傻调 API 了!手把手教你搓一个能干活的 AI Agent
前几天我想让 AI 帮我读个文件,它跟我说"您可以使用 fs.readFile 方法"。我当场就愣住了——我是让你读,不是让我读啊!
如果你也有过类似的体验,那这篇文章就是为你写的。
先搞清楚一个问题:Agent 到底是个啥?
很多人以为 Agent 是什么高深莫测的东西。其实吧,你把大模型想象成一个很聪明但啥也不会的实习生:
- 脑子很好使,但没有记忆——上次聊的全忘了
- 懂很多道理,但不会动手——你让它读文件,它给你写教程
- 不了解你们公司的情况——你问它内部 API 怎么用,它开始瞎编
所以 Agent 干的事情很简单:给这个实习生配工具、装记忆、补知识。
Agent = 大模型 + 记忆 + 工具 + 知识库
就这么简单。别被那些花里胡哨的名词吓到。
大模型的三大"不能"
来,我们一个个看大模型到底哪儿不行:
1. 它记不住你
大模型是 stateless 的——每次对话都是一张白纸。你上周跟它聊了两小时的方案,今天它完全不记得。所以你得自己维护一个 messages 数组,假装它"记住了"。
就像跟一个每天早上失忆的人共事,你得每天早上把昨天的会议记录重新念一遍。
实现上也很直白——就是每次把完整对话历史塞进请求:
// 每次调用都带上完整历史
const messages = [
new SystemMessage('你是...'),
new HumanMessage('你好'), // 第1轮
new AIMessage('你好!有什么...'), // 第1轮回复
new HumanMessage('帮我读...'), // 第2轮
];
const response = await model.invoke(messages);
但这带来一个问题:对话越来越长,token 开销指数级增长。真正的 Agent 还需要 Memory 模块——用 Redis、数据库、或者滑动窗口策略来管理历史,不能真的每次都全塞。
2. 它干不了活
你跟它说"帮我把这段代码重构一下",它会给你一大段分析——然后呢?然后就没有然后了。它只会说,不会做。
3. 它不知道你的东西
你公司的私有文档、内部 API、数据库结构,它一概不知。训练数据里没有的东西,它只能靠猜。
Agent 的工作流:一个不停"转圈"的过程
Agent 的执行逻辑其实就是一个循环:
用户甩来一个任务
↓
LLM 开始动脑子:我需要干嘛?
↓
要调用工具吗?
├── 是 → 调工具 → 拿到结果 → 塞回 messages → 继续想
└── 否 → 直接回复用户,结束
这就是经典的 ReAct 模式:Reason(想)→ Act(做)→ Observe(看结果)→ 再想。
循环往复,直到任务完成。
开搞!LangChain + DeepSeek 造一个 Agent
技术栈:
- LangChain JS:LLM 开发框架,兼容各家大模型
- DeepSeek:便宜又好用的大模型
- Node.js:跑后端逻辑
第一步:跑通基础调用
mkdir hello-langchain && cd hello-langchain
pnpm init
pnpm add @langchain/core @langchain/openai dotenv zod
然后写个最简单的调用:
import { ChatOpenAI } from '@langchain/openai';
import 'dotenv/config';
const model = new ChatOpenAI({
modelName: 'deepseek-v4-flash',
apiKey: process.env.DEEPSEEK_API_KEY,
configuration: {
baseURL: 'https://api.deepseek.com/v1',
},
});
const response = await model.invoke('棍王杯台球比赛应该设什么奖励?');
console.log(response.content);
LangChain 的 ChatOpenAI 是个统一接口——换模型改两行就行,不用重写业务逻辑。它内部做了什么?其实就是一个适配层,把 LangChain 的消息格式翻译成 OpenAI 兼容的 API 格式,然后发 HTTP 请求,再把响应翻译回来。
第二步:给它装一个工具
光能聊天没用,得让它能干实事。我们给它装一个"读文件"工具。
LangChain 里一个 Tool 由两部分组成:
- 功能函数:实际干活的 async 函数
- 描述对象:告诉 LLM 这工具是干嘛的、需要什么参数
import { tool } from '@langchain/core/tools';
import { z } from 'zod';
import fs from 'node:fs/promises';
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('要读取的文件路径'),
}),
}
);
这里有几个细节值得展开:
Zod schema 做了什么?
z.object({ filePath: z.string().describe('...') }) 不只是参数校验。当 LLM 收到这个 schema 时,它会把它转换成 OpenAI function calling 的 JSON Schema 格式,变成这样:
{
"type": "function",
"function": {
"name": "read_file",
"description": "用此工具来读取文件内容...",
"parameters": {
"type": "object",
"properties": {
"filePath": {
"type": "string",
"description": "要读取的文件路径"
}
},
"required": ["filePath"]
}
}
}
LLM 看到这个结构,就知道:调这个工具时必须传一个 filePath 字符串。Schema 就是工具和 LLM 之间的契约。
description 为什么重要?
LLM 判断"该不该调这个工具"的依据就是 description。写得越清晰,LLM 的判断越准确。比如:
- ❌
"读文件"→ 太模糊,LLM 不知道什么时候该调 - ✅
"当用户要求读取文件、查看代码、分析文件内容时,调用此工具"→ 覆盖了使用场景
第三步:LLM 是怎么"决定"调用工具的?
这里有个很精妙的设计——LLM 不会硬编工具的返回值。
当你把工具绑定到模型上:
const modelWithTools = model.bindTools([readFileTool]);
bindTools 做的事情是:把工具的 JSON Schema 注入到 LLM 的请求参数中(通过 OpenAI 的 tools 字段)。LLM 在推理时就"知道"自己有这些工具可用。
当 LLM 判断"我需要调工具"时,它返回的不是文本,而是这样的结构:
{
"tool_calls": [
{
"id": "call_abc123",
"name": "read_file",
"type": "function",
"arguments": { "filePath": "src/index.mjs" }
}
]
}
它承认自己不行,然后甩出工具调用请求。 这种"自知之明"是 Agent 能工作的基础。
而且注意 id 字段——后面工具执行结果要用这个 id 关联回去,否则 LLM 搞不清楚哪个结果对应哪个调用。
第四步:Message 的四种面孔
LangChain 里对话由四种消息类型组成,搞清楚它们就理解了 Agent 的对话结构:
| 消息类型 | 谁说的 | 干什么 | 关键字段 |
|---|---|---|---|
SystemMessage | 开发者(你) | 定义 AI 的角色、能力、规矩 | 无特殊字段 |
HumanMessage | 用户 | 用户的输入 | 无特殊字段 |
AIMessage | LLM | AI 的回复(可能夹带 tool_calls) | tool_calls 数组 |
ToolMessage | Agent 框架 | 工具执行的结果 | tool_call_id(必须匹配) |
一个完整的 Agent 会话,就是这四种消息的排列组合。随着对话推进,messages 数组越来越长:
[SystemMessage, HumanMessage, AIMessage(tool_calls), ToolMessage, AIMessage(tool_calls), ToolMessage, AIMessage]
最终那个 AIMessage 没有 tool_calls,说明 LLM 认为任务完成,直接输出了文本结果。
第五步:ReAct 循环——Agent 的心脏
这是整个项目最核心的代码,也是理解 Agent 最关键的一步:
const messages = [
new SystemMessage('你是一个代码助手,可以使用工具读取文件并解释代码。'),
new HumanMessage('请读取 src/tool.mjs 文件内容并解释代码'),
];
let response = await modelWithTools.invoke(messages);
messages.push(response);
// ReAct 循环
while (response.tool_calls && response.tool_calls.length > 0) {
console.log(`[检测到 ${response.tool_calls.length} 个工具调用]`);
// 并行执行所有工具调用
const toolResults = await Promise.all(
response.tool_calls.map(async (toolCall) => {
const targetTool = tools.find(t => t.name === toolCall.name);
if (!targetTool) return `错误:找不到工具 ${toolCall.name}`;
try {
return await targetTool.invoke(toolCall.args);
} catch (err) {
return `错误:${err.message}`;
}
})
);
// 把工具结果塞回 messages,用 tool_call_id 关联
response.tool_calls.forEach((toolCall, index) => {
messages.push(new ToolMessage({
content: toolResults[index],
tool_call_id: toolCall.id,
tool_call_type: toolCall.type,
}));
});
// 继续调用 LLM,让它决定下一步
response = await modelWithTools.invoke(messages);
messages.push(response);
}
// 循环结束,LLM 不再需要工具,输出最终结果
console.log(response.content);
几个容易踩坑的地方:
1. 为什么要用 Promise.all?
一次任务可能调多个工具。比如用户说"读 index.mjs 和 tool.mjs",LLM 会返回两个 tool_calls。用 Promise.all 并行执行,比串行快多了。
简单回顾一下 Promise 的三种状态:
- Pending:等待中
- Fulfilled(成功):调
resolve()触发 - Rejected(失败):调
reject()触发
Promise.all 等所有 Promise 都完成后,一次性返回结果数组。结果顺序和 Promise 数组顺序一致——即使第一个 Promise 比第二个晚完成,返回顺序还是 [result1, result2]。
2. tool_call_id 必须关联
ToolMessage 里的 tool_call_id 必须和 tool_calls 里的 id 一致,否则 LLM 搞不清哪个结果对应哪个调用。这是最容易出 bug 的地方。
3. 循环的退出条件
当 LLM 返回的 tool_calls 为空或不存在时,循环结束。说明 LLM 认为任务已经完成了,直接输出最终文本。
4. messages 数组会越来越长
每轮循环都会往 messages 里追加 ToolMessage 和 AIMessage。如果工具返回的内容很长(比如读了一个大文件),token 开销会迅速爆炸。真实项目中需要做 消息裁剪——比如只保留最近 N 轮,或者对早期消息做摘要压缩。
一个容易忽略的细节:给用户反馈
Agent 任务可能很耗时——读大文件、调接口、执行命令。如果你不给用户反馈,用户以为程序卡死了就关掉了。
所以工具函数里记得加日志:
console.log(`[工具调用] read_file(${filePath}) 成功读取 ${content.length} 字节`);
看起来不起眼,但这是用户体验的关键细节。Claude Code 为什么体验好?就是因为每一步都有清晰的执行反馈。
进阶:Node.js 子进程干 CLI 的活
如果你想让 Agent 能执行 npm install、vite create 这类命令,Node.js 主线程是扛不住的(它单线程嘛),需要用 child_process 创建子进程:
import { spawn } from 'node:child_process';
function execCommand(cmd, args = []) {
return new Promise((resolve, reject) => {
const child = spawn(cmd, args, { shell: true, stdio: 'pipe' });
let stdout = '';
let stderr = '';
child.stdout.on('data', (data) => { stdout += data; });
child.stderr.on('data', (data) => { stderr += data; });
child.on('close', (code) => {
if (code === 0) resolve(stdout);
else reject(new Error(stderr || `Exit code ${code}`));
});
});
}
架构是这样的:
主进程(Agent 逻辑)
↓ spawn 子进程
子进程(执行 CLI 命令)
↓ IPC 进程间通信
主进程(拿到结果,继续循环)
这样 Agent 的主循环不会被 CLI 命令阻塞,可以继续处理其他逻辑。
更深一层:为什么 Agent 用 ReAct 而不是简单的 chain?
很多人会问:为什么不能 prompt → LLM → 结果 一步到位?
因为现实任务不是线性的。举个例子:用户说"帮我创建一个 React 项目并运行起来"。
LLM 的推理过程是:
1. 需要先创建项目 → 调用 vite create
2. 需要安装依赖 → 调用 pnpm install
3. 需要启动项目 → 调用 pnpm dev
4. 检查启动是否成功 → 可能需要读日志
5. 如果启动失败 → 调整配置,再试一次
每一步都可能失败,每一步的结果都会影响下一步的决策。这种"边做边想"的模式,是线性 chain 做不到的。
ReAct 的 while 循环天然支持这种动态决策——LLM 每次拿到工具结果后,都能重新评估局势,决定下一步做什么。
总结:Agent 真没那么玄乎
拆开来看就这几样东西:
| 模块 | 核心作用 | 一句话解释 |
|---|---|---|
| LLM | 推理引擎 | 负责"想",决定调什么工具、怎么组合结果 |
| Memory | 上下文管理 | 负责"记",让多轮对话不丢失 |
| Tool | 能力扩展 | 负责"做",让模型能干实际的事 |
| RAG | 知识补全 | 负责"查",查私有知识库补训练数据短板 |
| MCP | 外部接入 | 负责"连",对接第三方服务和数据源 |
一个能思考、能记住、能干活、能查资料的扩展后的大模型,就是一个 Agent。
你不需要懂什么高深理论,跑一遍代码就全明白了。