深入理解 AI Agent:从原理到实践
引言
在 AI 技术飞速发展的今天,Agent(智能体)已经成为最具商业价值的技术方向之一。但 Agent 究竟是什么?它与直接调用大模型接口有何不同?本文将带你深入理解 Agent 的核心概念、工作流程以及开发实践。
为什么不能直接调用大模型接口?
很多人以为 AI 应用就是简单地调用大模型 API,但实际落地中,裸调 LLM 存在诸多局限:
1. 无状态问题 ------ Memory 模块
LLM 本身是无状态的(stateless)。你上周和它聊过的信息,它完全不记得。要让 LLM 具备"记忆",需要结合:
- 数据库:持久化存储对话历史
- 前端存储 / Redis:缓存会话状态
- 后端服务:管理会话生命周期
这就是 Agent 中 Memory 模块 的价值------让 LLM 拥有"记性"。
2. 行动能力缺失 ------ Tool Use
让 LLM 帮你访问一个网页、做一些实际操作?LLM 只能告诉你思路,但无法亲自执行。豆包做 PPT 等场景,就需要 Tool Use(工具调用) 能力------让 LLM 能够真正"动手"。
3. 私有知识盲区 ------ RAG 模块
LLM 无法访问企业内部私有文档。通过 RAG(检索增强生成) 模块,Agent 可以从私有知识库中检索相关内容,弥补 LLM 知识的局限性。
4. 实时信息缺失 ------ MCP
最新的世界杯新闻、实时股市数据,这些不在 LLM 的预训练数据中。MCP(Model Context Protocol) 作为第三方工具的通用协议,让 Agent 能够接入外部数据源和服务。
5. 复杂任务编排 ------ Skills
做 PPT、分析股市并自动买卖等复杂任务,需要将多个能力组合成可复用的 Skills(技能),通过蒸馏和编排实现端到端的自动化。
Agent 究竟是什么?
综上,Agent 就是围绕以上问题,给 LLM 加上各种扩展能力:
ini
Agent = LLM + Memory + Tool + RAG + MCP + Skills
一个知道内部知识、能思考能规划、能够帮你做事情的扩展后的大模型,就是 Agent。
典型的 Agent 产品案例:
- Claude Code / Codex:Coding Agent,辅助编程
- Manus:自动化任务 Agent
Agent 的工作流程
Agent 处理任务的典型流程如下:
markdown
用户 Prompt(复杂任务)
│
▼
Agent 智能体
│
▼
LLM Planning + Reasoning(规划/推理)
│
├──► 加载 Memory?(历史记忆)
│
├──► 调用 Tool?(分步骤、多工具)
│
├──► 查询 RAG?(检索增强)
│ │
│ ▼
│ Prompt Template + 检索内容
│
▼
Response → 用户(任务完成)
核心在于:LLM 本身具备思考和规划能力,给它 Tool 扩展行动能力,用 Memory 管理记忆,用 RAG 补充私有知识。
Agent 开发技术栈
推荐的技术组合:
| 层级 | 技术选型 | 用途 |
|---|---|---|
| 后端框架 | NestJS | 构建可扩展的企业级后端服务 |
| 单智能体 | LangChain | 单一 Agent 的构建与编排 |
| 多智能体 | LangGraph | 多 Agent 协作与状态管理 |
| 能力扩展 | MCP / Skills / RAG | 工具调用、技能封装、知识检索 |
深入 LangChain
LLM 抽象层
LangChain 提供了统一且兼容的 LLM 接口,例如 @langchain/openai,支持按需加载不同模型,屏蔽底层差异。
Tool 工具接口
LangChain 接管了 @langchain/core 和 Zod 验证工具,将工具接口抽象为两个核心部分:
1. 函数描述对象
- description:详细描述工具的功能、适用场景、参数需求
- schema:参数约束(Zod 验证),LLM 必须提供符合 schema 约定的参数才能调用该工具
2. Tool 的返回格式
当 LLM 判断需要调用工具时:
- 不会直接生成回答,而是停下来,告诉用户需要调用的工具列表(tool-calls)
- 每个 tool-call 包含:
id、name、arguments id用于关联------将工具调用的返回结果与对应的 tool-call 匹配,组成完整的任务上下文
工作流程:
bash
用户提问 → LLM 推理 → 识别需要工具 → 返回 tool-calls(id + name + arguments)
→ 后端执行工具 → 将结果按 id 关联回会话 → LLM 基于完整上下文生成最终回答
实战:构建文件读取 Tool
下面通过一个完整的 LangChain Tool 示例(tool.mjs),逐行拆解 Agent 工具调用的每一个环节。该示例创建一个 read_file 工具,让 Agent 能够读取本地文件并解释代码。完整代码约 70 行,我们将其拆为 5 个部分逐一分析。
第一部分:依赖导入(第 1-10 行)
javascript
import 'dotenv/config' // ①
import {ChatOpenAI} from '@langchain/openai' // ②
import {tool} from '@langchain/core/tools' // ③
import {HumanMessage, // ④
ToolMessage,
SystemMessage,
AIMessage,
} from '@langchain/core/messages'
import fs from 'fs/promises' // ⑤
import {z} from 'zod' // ⑥
逐行解析:
| 行 | 导入内容 | 作用 | 为什么需要它 |
|---|---|---|---|
| ① | dotenv/config |
自动读取项目根目录 .env 文件,将环境变量注入 process.env |
避免在代码中硬编码 API Key,保证密钥安全 |
| ② | ChatOpenAI |
LangChain 对 OpenAI 兼容模型的统一封装类 | 屏蔽不同模型 API 的差异,通过一套接口调用多种模型 |
| ③ | tool |
LangChain 核心工具构造函数 | 将普通 JS 函数包装为 LLM 可识别、可调用的 Tool 对象 |
| ④ | HumanMessage / ToolMessage / SystemMessage / AIMessage |
四种对话消息类,分别代表用户输入、工具返回结果、系统指令、LLM 回复 | 对话上下文由消息数组组成,不同类型的消息让 LLM 区分每条内容的来源和角色 |
| ⑤ | fs/promises |
Node.js 内置的文件系统 Promise 版 API | 提供异步读文件能力(readFile),避免阻塞事件循环 |
| ⑥ | z(Zod) |
TypeScript 优先的运行时校验库 | 定义工具参数的"契约"------LLM 传参必须符合 schema 约束,否则执行前就能拦截 |
第二部分:初始化模型(第 12-22 行)
javascript
const model = new ChatOpenAI({ // ① 创建模型实例
modelName: 'deepseek-v4-flash', // ② 指定模型名称
apiKey: process.env.DEEPSEEK_API_KEY, // ③ 从环境变量读取密钥
temperature: 0, // ④ 控制输出随机性
configuration: { // ⑤ 自定义 HTTP 配置
baseURL: 'https://api.deepseek.com/v1', // ⑥ 重定向 API 端点
}
})
逐行解析:
| 行 | 配置项 | 值 | 含义 |
|---|---|---|---|
| ① | new ChatOpenAI() |
--- | 实例化一个 Chat 模型客户端,后续所有 LLM 调用都通过此对象发起 |
| ② | modelName |
'deepseek-v4-flash' |
告诉 DeepSeek API 使用哪个具体模型。flash 后缀表示轻量快速版,适合 Agent 场景下对延迟敏感的 Tool Calling |
| ③ | apiKey |
process.env.DEEPSEEK_API_KEY |
API 鉴权凭证。从 .env 文件注入,代码中不暴露明文,安全且便于环境切换 |
| ④ | temperature |
0 |
设为 0 意味着 LLM 输出完全确定性(每次同样输入得到同样输出)。Agent 场景强烈建议设 0 或极低值------你不想让工具调用决策带有随机性 |
| ⑤ | configuration |
{ baseURL: ... } |
透传给底层 HTTP 客户端的配置对象,用于自定义 API 地址 |
| ⑥ | baseURL |
'https://api.deepseek.com/v1' |
这是关键 :将 OpenAI SDK 的默认请求地址 https://api.openai.com/v1 重定向到 DeepSeek 的 API。因为 DeepSeek 接口完全兼容 OpenAI 协议,所以 ChatOpenAI 不需要任何修改就能直接调用它 |
核心认知 :LangChain 的
ChatOpenAI并不是"OpenAI 专用"------它本质上是 OpenAI 协议客户端 。任何实现了 OpenAI 兼容接口的模型服务(DeepSeek、Moonshot、通义千问、本地 vLLM 等),只需改一行baseURL+modelName即可无缝切换。这就是 LangChain 模型抽象层的真正价值。
第三部分:定义 Tool(第 24-41 行)
这是整个文件最核心的部分------将一个普通函数"注册"为 LLM 可用的工具。
javascript
const readFileTool = tool( // ① tool() 构造函数
async ({filePath}) => { // ② 第一个参数:功能函数
const content = await fs.readFile( // ③ 实际执行:读取文件
filePath, // ④ 入参来自 LLM 生成的 arguments
'utf-8' // ⑤ 指定编码,返回字符串而非 Buffer
)
console.log( // ⑥ 进度反馈日志
`[工具调用] read_file(${filePath}) 成功读取 ${content.length} 字节`
)
return content // ⑦ 返回值作为 ToolMessage 内容回传给 LLM
},
{ // ⑧ 第二个参数:元数据对象
name: 'read_file', // ⑨ 工具的唯一标识名
description: `用此工具来读取文件内容...`, // ⑩ 功能描述 ------ LLM 据此判断何时调用
schema: z.object({ // ⑪ Zod schema:参数"契约"
filePath: z.string() // ⑫ 参数名 + 类型约束
.describe('要读取的文件路径') // ⑬ 参数说明 ------ LLM 据此填充参数值
})
}
)
逐行深度解析:
| 行 | 代码 | 作用 | 深层原理 |
|---|---|---|---|
| ① | tool(...) |
来自 @langchain/core/tools,将一个 JS 函数包装成 LangChain 的 Tool 对象 |
返回的 Tool 对象同时包含可执行逻辑(给运行时)和结构化元数据(给 LLM),实现了"描述与实现"的分离 |
| ② | async ({filePath}) => {...} |
第一个参数:功能函数 。异步执行,从参数对象中解构出 filePath |
这个函数只在后端运行,LLM 看不到它的内部实现。filePath 的值由 LLM 根据 schema 生成,经过 Zod 校验后传入 |
| ③ | fs.readFile(...) |
Node.js 异步文件读取 | Promise 版本(fs/promises),配合 await 同步风格写出异步逻辑 |
| ④ | filePath |
文件路径参数 | 来自 LLM 生成的 tool-call 的 arguments.filePath。可以是绝对路径(/home/user/foo.txt)或相对路径(./tool.mjs),由 description 和 schema 告知 LLM |
| ⑤ | 'utf-8' |
编码参数 | 不传此参数将返回原始 Buffer 对象(字节数组),LLM 无法直接理解。必须指定文本编码 |
| ⑥ | console.log(...) |
终端日志 | 不是可选的------Agent 执行工具时用户处于等待状态,如果没有反馈,用户不知道 Agent 是卡住了还是在正常工作。这个日志让执行过程透明可见 |
| ⑦ | return content |
函数返回值 | 返回值会被自动包装为 ToolMessage,注入到对话上下文(messages 数组)中,供 LLM 下一步推理使用 |
| ⑧ | { name, description, schema } |
第二个参数:元数据对象 | 这个对象会在 bindTools() 时被序列化并注入到 LLM 的请求中,告诉 LLM:"我这里有这样一个工具,你可以调用它" |
| ⑨ | name: 'read_file' |
工具的唯一标识符 | LLM 生成的 tool-call 中 name 字段就是它。后端根据 name 查找对应的工具函数执行。必须全局唯一 |
| ⑩ | description: '用此工具来...' |
工具的功能描述 | 这是 LLM 决定是否调用该工具的唯一依据。描述越精确(什么场景用、什么场景不用),LLM 越不容易误调用或漏调用。写 description 是开发 Agent 最关键的工作之一 |
| ⑪ | schema: z.object({...}) |
Zod 定义的参数约束对象 | 定义该工具接受哪些参数、每个参数的类型和含义。LLM 生成的 arguments 必须符合此 schema |
| ⑫ | filePath: z.string() |
参数名 + 类型 | filePath 是参数名(与功能函数的解构参数名一致),z.string() 约束该参数必须是字符串 |
| ⑬ | .describe('要读取的文件路径') |
参数说明 | 告诉 LLM 这个参数的语义------它应该填什么,从而让 LLM 从用户自然语言中提取正确的值填入 |
tool()的设计哲学:将"做什么"(功能函数,给机器执行)和"怎么描述"(元数据,给 LLM 理解)彻底解耦。LLM 不需要知道文件是怎么读的,只需要知道"有个工具叫 read_file,需要一个文件路径,用来读取文件内容"------描述得越清楚,LLM 用起来越准确。
第四部分:注册工具并绑定到模型(第 43-48 行)
javascript
const tools = [readFileTool] // ① 工具注册表
const modelWithTools = model.bindTools(tools) // ② 绑定工具到模型
逐行解析:
| 行 | 代码 | 作用 | 深层原理 |
|---|---|---|---|
| ① | const tools = [readFileTool] |
将所有已定义的 Tool 对象放入数组 | 本示例只有一个工具,但实际项目中这里可能包含多个工具([readFileTool, searchTool, calculatorTool, ...])。每个工具都会被 LLM 感知 |
| ② | model.bindTools(tools) |
调用 bindTools() 返回一个新的模型实例 |
这是 LangChain 最精妙的设计 :bindTools() 不是修改原 model 对象,而是返回一个"携带工具能力"的新模型实例。它内部做了两件事:(1)遍历 tools 数组,将每个工具的 name、description、schema 序列化为 OpenAI Function Calling 格式;(2)在后续每次 invoke() 时,自动将这些工具定义附加到 API 请求中 |
bindTools()的不可变性:返回新实例而非修改原对象,意味着你可以同时持有一个"不带工具的基础模型"和一个"带工具的增强模型",在不同场景灵活使用。这在复杂 Agent 系统中尤为重要------有时你只需要纯文本对话,不需要浪费 token 传输工具定义。
第五部分:构建对话上下文并执行(第 49-72 行)
javascript
const messages = [ // ① 对话上下文数组
new SystemMessage(` // ② 系统消息:定义 Agent 人设
你是一个代码助手,可以使用工具读取并解释代码。
工作流程: // ③ 引导 LLM 按步骤执行
1. 用户要求读取文件时,立即调用 read_file 工具
2. 等待工具返回文件内容
3. 基于文件内容进行分析和解释
可用工具: // ④ 显式声明工具(双重保险)
- read_file:读取文件内容(使用此工具来读取文件内容)
`),
new HumanMessage( // ⑤ 用户消息
'读取 tool.mjs 文件内容并解释代码'
),
]
let response = await modelWithTools.invoke(messages) // ⑥ 调用模型
messages.push(response) // ⑦ 追加回复到对话历史
逐行解析:
| 行 | 代码 | 作用 | 深层原理 |
|---|---|---|---|
| ① | const messages = [...] |
创建一个消息数组 | 这是发给 LLM 的完整上下文。数组中的每条消息按顺序排列,LLM 按时间线理解对话。Agent 的所有"记忆"本质上就是这个 messages 数组 |
| ② | new SystemMessage(...) |
系统级指令,定义 Agent 的角色和行为边界 | SystemMessage 是整个对话的"最高优先级指令"。它不会被后续对话覆盖,LLM 必须始终遵守其中的约束。类比:这是给员工的《岗位说明书》 |
| ③ | 工作流程:1. 2. 3. |
分步骤引导 LLM 行为 | 虽然 bindTools() 已经让 LLM 知道了工具,但在 System Prompt 中显式描述使用流程,能显著提高 LLM 按预期调用工具的概率。这是 Prompt Engineering 的实践:明确告诉 LLM "什么时候该做什么" |
| ④ | 可用工具:- read_file |
在文本中也声明一次工具 | 为什么要在 System Prompt 里再写一遍?因为 bindTools() 注入的工具定义是结构化的(JSON Schema 格式),而自然语言的工具列表能帮助 LLM 更快建立"意图 → 工具"的映射。两者互补,提升调用准确率 |
| ⑤ | new HumanMessage(...) |
用户的对话输入 | HumanMessage 对应对话角色 role: 'user'。LLM 会将其视为"人类对我的提问",据此生成回复或决定调用工具 |
| ⑥ | modelWithTools.invoke(messages) |
向模型发送请求并等待响应 | invoke() 是 LangChain 的标准调用方法(遵循 LCEL --- LangChain Expression Language)。执行流程:序列化 messages → 附加工具定义 → 发送 HTTP 请求 → 解析响应 → 返回 AIMessage 对象 |
| ⑦ | messages.push(response) |
将 LLM 回复追加到对话数组 | 这一步至关重要 ------如果不 push,下一次 invoke() 时 LLM 就丢失了上一轮的上下文(包括它自己生成的 tool-calls)。这就是为什么 Agent 的"记忆"本质上是不断增长的 messages 数组 |
完整执行流程(串联所有部分)
将这 70 行代码的实际运行过程可视化如下:
css
┌─────────────────────────────────────────────────────────────┐
│ messages 数组(对话上下文) │
├─────────────────────────────────────────────────────────────┤
│ [0] SystemMessage: "你是代码助手... 工作流程..." │
│ [1] HumanMessage: "读取 tool.mjs 文件内容并解释代码" │
└─────────────────────────────────────────────────────────────┘
│
▼
modelWithTools.invoke(messages)
│
┌─────────────┴─────────────┐
│ 发送到 DeepSeek API 的请求 │
│ - messages (含 system + user)│
│ - tools: [{ │
│ name: "read_file", │ ← bindTools() 注入
│ description: "...", │
│ parameters: { filePath } │
│ }] │
└─────────────┬───────────────┘
│
▼
LLM 推理决策
│
┌───────────────┴───────────────┐
│ LLM 判断:用户要求读取文件, │
│ 我需要调用 read_file 工具 │
└───────────────┬───────────────┘
│
▼
LLM 返回 AIMessage:
┌─────────────────────────────────────┐
│ role: "assistant" │
│ content: "" (无文本,仅有 tool_call) │
│ tool_calls: [{ │
│ id: "call_abc123", │
│ name: "read_file", │
│ arguments: { filePath: "tool.mjs" } │
│ }] │
└─────────────────────────────────────┘
│
▼
messages.push(response)
│
┌───────────┴───────────┐
│ 后端检测到 tool_calls │
│ 不是 null / 非空数组 │
└───────────┬───────────┘
│
▼
执行 readFileTool("tool.mjs")
┌─────────────────────────────┐
│ fs.readFile("tool.mjs") │
│ → 文件内容字符串 │
│ console.log("[工具调用]...") │ ← 用户看到进度
│ return content │
└─────────────┬───────────────┘
│
▼
创建 ToolMessage 并 push:
┌─────────────────────────────────────┐
│ role: "tool" │
│ tool_call_id: "call_abc123" │ ← 与 tool-call 的 id 对应
│ content: "import 'dotenv/config'..." │ ← 文件实际内容
└─────────────────────────────────────┘
│
▼
再次 modelWithTools.invoke(messages)
│
┌───────────────────┴───────────────────┐
│ 此时 messages 包含: │
│ [0] SystemMessage (系统指令) │
│ [1] HumanMessage (用户请求) │
│ [2] AIMessage (tool_call: read_file) │
│ [3] ToolMessage (文件内容) │
└───────────────────┬───────────────────┘
│
▼
LLM 最终推理
│
┌─────────────────┴─────────────────┐
│ LLM 看到完整上下文(用户请求 + │
│ 文件内容),生成自然语言解释: │
│ "这个文件是一个 LangChain Tool │
│ 示例,它导入了..." │
└─────────────────┬─────────────────┘
│
▼
返回 AIMessage(最终回答)
content: "该文件展示了 LangChain 中..."
│
▼
用户看到代码解释
关键要点总结
| 概念 | 一句话理解 |
|---|---|
dotenv/config |
从 .env 文件安全加载密钥,避免硬编码 |
ChatOpenAI + baseURL |
一套代码接入任意兼容 OpenAI 协议的模型 |
tool() 第一个参数 |
后端执行的实际逻辑(LLM 看不见内部实现) |
tool() 第二个参数 |
给 LLM 看的"说明书"(name + description + schema) |
zod schema |
参数"契约":LLM 填入的参数必须通过此校验 |
bindTools() |
将工具说明书注入到每次 API 请求中,LLM 据此决策 |
SystemMessage |
定义 Agent 角色和行为约束,最高优先级指令 |
HumanMessage |
用户的自然语言输入 |
AIMessage(含 tool_calls) |
LLM 决定调用工具,不生成文本,只输出 tool-call 结构 |
ToolMessage |
工具执行结果,通过 tool_call_id 关联回对应的 tool-call |
messages.push() |
不断追加消息,维持上下文------这就是 Agent 的"记忆" |
console.log(工具内) |
给用户实时反馈,避免长时间无响应造成的焦虑 |
temperature: 0 |
确定性输出,Agent 场景下工具调用决策必须稳定可靠 |
这个 70 行的例子完整展示了 Agent Tool Use 的核心循环:LLM 推理 → 生成 tool-calls → 后端执行 → 结果反馈 → LLM 整合输出。理解了它,就理解了所有 Agent 框架的底层工作原理。
LLM Tool 调用性能优化
当任务复杂时,LLM 可能需要调用多个工具,或同一个工具多次调用。此时可以利用异步并发优化性能。
Promise 与异步处理
ES6 引入的 Promise 有三种状态:
- Pending(等待中):异步操作进行中
- Fulfilled (已成功):
resolve()被调用,pending → fulfilled - Rejected (已失败):
reject()被调用,pending → rejected
状态只能从 pending 单向转换到 fulfilled 或 rejected,不可逆转。
并行优化
javascript
// Promise.all 并行执行多个工具调用
const results = await Promise.all([
toolCall1(),
toolCall2(),
toolCall3(),
]);
// 返回结果顺序与 Promise 数组顺序一致
使用 Promise.all 可以并行执行多个独立的工具调用,等待所有操作完成后统一返回结果,配合 await(ES8)优雅地实现异步转同步的写法。
总结
Agent 并不复杂------它的本质是对 LLM 的能力扩展:
- Memory 让它记住你
- Tool 让它能做事
- RAG 让它懂你的业务
- MCP 让它连接世界
- Skills 让它精通特定任务
结合 NestJS 后端框架 + LangChain/LangGraph 开发框架,你可以打造出真正落地的 AI 全栈 Agent 产品,让 AI 技术通过 Harness Engineering 实现商业价值(FDE)。