别再手动调 API 了!手把手教你打造一个能真正干活的 AI Agent
如果你也曾有过类似的困惑,那这篇文章正是为你准备的。

先搞清楚一个问题:Agent 到底是什么?
很多人觉得 Agent 这个概念高深莫测,其实说白了,你可以把大模型想象成一个脑袋聪明但什么实际工作也做不了的实习生:
- 脑子好使,但缺乏记忆——上次聊的内容,转身就忘得一干二净。
- 懂很多理论,但双手是摆设——你让它读个文件,它却给你写一篇教程。
- 对你的业务场景一无所知——你问它内部 API 怎么用,它敢当场给你编造一套。
所以,Agent 的核心任务其实很简单:给这个聪明但无能的实习生配上趁手的工具、装上持久记忆、补齐背景知识。
Agent = 大模型 + 记忆系统 + 工具集 + 知识库
就这么简单,别被那些花里胡哨的技术名词吓住了。
大模型的三大"致命短板"
来,我们逐一剖析大模型到底在哪些方面力不从心:
1. 它记不住你
大模型本质上是无状态的——每一次对话都像一张崭新的白纸。你上周跟它讨论了两小时的方案,今天它完全不记得。所以你必须自己维护一个 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:大模型应用开发框架,兼容多种主流模型
- 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 本质上就是工具与大模型之间的一份"契约"。
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 数组会不断增长:
[
]
当最后一个 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 而非简单的链式调用?
很多人会问:为什么不能直接 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。
你不需要掌握什么高深的理论,亲自跑一遍代码,一切都豁然开朗了。
