RAG 让 LLM 能查资料,LangGraph 让 LLM 会思考,但它们都还是「动口不动手」——只能回答问题,不能真正做事。Agent 的本质是让 LLM 拥有「行动能力」:搜索网络、调用 API、读写数据库、执行代码……
什么是 Agent?
一句话定义
Agent = LLM(大脑) + 工具(双手) + 循环(反思)
| 组件 | 作用 |
|---|---|
| LLM | 思考:我现在需要做什么?用哪个工具? |
| 工具集 | 行动:搜索、计算、调用 API、读写文件…… |
| 循环 | 反思:刚才的结果够了吗?还需要继续吗? |
Agent vs 普通 ChatBot
| 维度 | 普通 ChatBot | Agent |
|---|---|---|
| 能力边界 | 只能用训练数据里的知识 | 可以调用外部工具,能力无限扩展 |
| 执行方式 | 单轮调用,直接返回答案 | 多轮循环:思考 → 行动 → 观察 → 再思考 |
| 状态 | 无状态(或简单历史) | 有完整的内部状态:任务、计划、已完成步骤、工具调用记录 |
| 输出 | 直接给答案 | 先做事,再给答案(答案是做事的结果) |
一个真实的例子
用户问:「2024 年北京的平均房价是多少?」
| 普通 ChatBot | Agent |
|---|---|
| 「对不起,我的训练数据截止到 2023 年,无法回答。」 | 🧠 思考:这个问题需要最新数据,我需要搜索 🔧 调用「网络搜索」工具,搜索「2024 北京平均房价」 📋 拿到结果:「2024 年北京平均房价 6.8 万/㎡」 🧠 思考:信息够了,可以回答了 💬 回答:「根据最新数据,2024 年北京平均房价约为 6.8 万元/平方米」 |
一、我的 Agent 演进路径
阶段 1:工具模拟(当前 deep-langgraph.ts)
在 deep-langgraph.ts 中,我已经实现了一个最简单的工具模拟系统:
// src/server/handlers/deep-langgraph.ts:26interface Tool { name: string; description: string; execute: (input: string) => Promise<string>;}
const tools: Tool[] = [ { name: "search", description: "搜索网络信息", execute: async (query: string) => { // 模拟搜索,实际可以换成真实的搜索引擎 API const results: Record<string, string> = { 北京美食: "北京著名美食:烤鸭、炸酱面、豆汁焦圈、涮羊肉", 烤鸭历史: "北京烤鸭起源于南北朝,明朝成为宫廷美食", JavaScript异步: "JavaScript 异步编程主要靠 Promise、async/await", }; const matched = Object.entries(results).find(([key]) => { const chars = key.split(""); return chars.every((c) => query.includes(c)); }); return matched ? matched[1] : `未找到关于"${query}"的信息`; }, },];这是一个完整的工具接口定义:
name: 工具名,LLM 通过名字选择工具description: 工具描述,告诉 LLM 这个工具是做什么的execute: 工具的实际执行逻辑
当前的问题:
- ❌ LLM 知道有工具,但不会主动选择使用
- ❌ Planner 只是把任务拆成步骤,Executor 还是让 LLM 自己「执行」
- ❌ 没有真正的「工具调用」环节
阶段 2:真正的 Tool Calling Agent
我们需要在 LangGraph 中加一个「工具选择」节点:
新的图结构:
[START] ↓planner ← 制定计划 ↓tool_selector ← 选择工具 + 生成参数 ↓tool_executor ← 执行工具调用 ↓replanner ← 评估:够了吗?还要继续吗? ↓ ↘ ↓ (完成) → [END] ↓(继续) → tool_selector ← 循环下面是完整的实现代码:
二、完整的 Tool Calling Agent 实现
第一步:扩展工具系统
先加几个实用的工具:
export interface Tool { name: string; description: string; // 参数定义(告诉 LLM 这个工具需要什么参数) parameters: { type: "object"; properties: Record<string, { type: string; description: string }>; required: string[]; }; execute: (input: Record<string, any>) => Promise<string>;}
// 工具 1:网络搜索export const searchTool: Tool = { name: "web_search", description: "搜索网络上的最新信息,适合查询新闻、价格、数据等时效性问题", parameters: { type: "object", properties: { query: { type: "string", description: "搜索关键词" }, }, required: ["query"], }, execute: async ({ query }) => { // 实际项目中换成 SerpAPI / Bing Search / 搜索引擎 API const mockResults: Record<string, string> = { "北京房价": "2024 年北京平均房价约 6.8 万元/平方米,海淀约 9.5 万,朝阳约 7.8 万", "上海房价": "2024 年上海平均房价约 7.2 万元/平方米,浦东约 8.5 万", "Vue3 教程": "Vue3 官方文档:https://cn.vuejs.org,推荐组合式 API + TypeScript", }; return mockResults[query] || `搜索"${query}"未找到结果,建议换个关键词试试`; },};
// 工具 2:计算器export const calculatorTool: Tool = { name: "calculator", description: "执行数学计算,支持加减乘除、百分比、开方等运算", parameters: { type: "object", properties: { expression: { type: "string", description: "数学表达式,例如:100 * 0.85" }, }, required: ["expression"], }, execute: async ({ expression }) => { try { // 安全的计算方式 const result = Function('"use strict";return (' + expression + ")")(); return `计算结果:${expression} = ${result}`; } catch (e) { return `计算失败:${(e as Error).message}`; } },};
// 工具 3:代码解释器export const codeInterpreterTool: Tool = { name: "code_interpreter", description: "执行 JavaScript 代码,适合数据处理、复杂计算、生成图表等", parameters: { type: "object", properties: { code: { type: "string", description: "要执行的 JavaScript 代码" }, }, required: ["code"], }, execute: async ({ code }) => { try { // 实际项目中应该用安全的沙箱环境执行 const result = eval(code); return `代码执行成功\n输出:${JSON.stringify(result)}`; } catch (e) { return `代码执行失败:${(e as Error).message}`; } },};
// 工具集合export const allTools: Tool[] = [ searchTool, calculatorTool, codeInterpreterTool,];关键设计:每个工具都有 parameters 定义,告诉 LLM 这个工具需要什么参数,以及每个参数的含义。这是 Tool Calling 的核心。
第二步:Agent State 扩展
Agent 需要记录更多状态:工具调用历史、工具返回结果、思考过程等。
import { Annotation, StateGraph, START, END } from "@langchain/langgraph";import { allTools } from "./tools";
const AgentState = Annotation.Root({ // 原始任务 task: Annotation<string>(),
// 执行计划 plan: Annotation<string[]>({ reducer: (_prev, next) => next, default: () => [], }),
// 当前步骤索引 currentStepIndex: Annotation<number>({ reducer: (_prev, next) => next, default: () => 0, }),
// 工具调用历史 toolCalls: Annotation<Array<{ toolName: string; parameters: Record<string, any>; result: string; }>>({ reducer: (prev, next) => [...prev, ...next], default: () => [], }),
// LLM 的思考过程 thoughts: Annotation<string[]>({ reducer: (prev, next) => [...prev, ...next], default: () => [], }),
// 最终结果 result: Annotation<string>({ reducer: (_prev, next) => next, default: () => "", }),});第三步:核心节点实现
节点 1:Planner — 制定计划
const plannerPrompt = PromptTemplate.fromTemplate(`你是一个任务规划专家。请分析用户的请求,制定执行计划。
可用工具:{tools}
请输出 JSON 格式的计划:{{"steps": ["步骤1的描述", "步骤2的描述", ...]}}
注意:- 如果需要外部信息,安排搜索步骤- 如果需要计算,安排计算步骤- 步骤不超过 5 个
用户请求:{task}`);
async function planNode(state: typeof AgentState.State, ws: WebSocket) { sendStatus(ws, "🧠 正在分析任务并制定计划...");
const chain = plannerPrompt.pipe(llm).pipe(parser); const result = await chain.invoke({ tools: allTools.map(t => `${t.name}: ${t.description}`).join("\n"), task: state.task, });
let steps: string[]; try { const jsonMatch = result.match(/\{[\s\S]*\}/); steps = jsonMatch ? JSON.parse(jsonMatch[0]).steps : [state.task]; } catch { steps = [state.task]; }
const planText = steps.map((s, i) => ` ${i + 1}. ${s}`).join("\n"); sendStatus(ws, `📝 已制定计划:\n${planText}`);
return { plan: steps, currentStepIndex: 0 };}节点 2:Tool Selector — 选择工具 + 生成参数
这是 Agent 最核心的节点——决定用哪个工具,以及传什么参数。
const toolSelectorPrompt = PromptTemplate.fromTemplate(`你是一个工具选择专家。请根据当前步骤,选择最合适的工具并生成参数。
当前任务步骤:{currentStep}
可用工具列表:{toolsList}
请输出 JSON 格式,选择一个工具:{{ "thought": "你选择这个工具的原因(1句话)", "toolName": "工具名称", "parameters": {{ "参数名1": "参数值1", "参数名2": "参数值2" }}}}
注意:1. 必须从上面的工具列表中选择,不能自己发明工具2. 参数必须符合工具的参数定义3. 如果不需要工具(直接回答即可),toolName 设为 "direct_answer"`);
async function toolSelectorNode(state: typeof AgentState.State, ws: WebSocket) { const currentStep = state.plan[state.currentStepIndex];
sendStatus( ws, `🔧 正在为步骤 [${state.currentStepIndex + 1}/${state.plan.length}] 选择工具:${currentStep}`, );
// 把工具列表格式化,告诉 LLM 每个工具的参数要求 const toolsList = allTools.map(tool => { const paramsDesc = Object.entries(tool.parameters.properties) .map(([name, prop]) => ` - ${name}: ${prop.description} (${prop.type})`) .join("\n"); return `${tool.name}: ${tool.description}\n参数:\n${paramsDesc}`; }).join("\n\n");
const chain = toolSelectorPrompt.pipe(llm).pipe(parser); const result = await chain.invoke({ currentStep, toolsList, });
let decision: { thought: string; toolName: string; parameters: Record<string, any>; };
try { const jsonMatch = result.match(/\{[\s\S]*\}/); decision = jsonMatch ? JSON.parse(jsonMatch[0]) : { thought: "无法解析,直接回答", toolName: "direct_answer", parameters: {}, }; } catch { decision = { thought: "解析失败,直接回答", toolName: "direct_answer", parameters: {}, }; }
sendStatus(ws, ` 💭 思考:${decision.thought}`); sendStatus(ws, ` 🎯 选择工具:${decision.toolName}`); if (Object.keys(decision.parameters).length > 0) { sendStatus(ws, ` 📋 参数:${JSON.stringify(decision.parameters)}`); }
return { thoughts: [decision.thought], toolCalls: [{ toolName: decision.toolName, parameters: decision.parameters, result: "", // 留空,等 executor 执行后填充 }], };}节点 3:Tool Executor — 执行工具调用
async function toolExecutorNode(state: typeof AgentState.State, ws: WebSocket) { const lastCall = state.toolCalls[state.toolCalls.length - 1]; if (!lastCall || lastCall.result) { return {}; }
// 直接回答,不需要调用工具 if (lastCall.toolName === "direct_answer") { sendStatus(ws, " ✅ 不需要调用工具,直接回答"); return { toolCalls: [{ ...lastCall, result: "直接基于已有信息回答", }], }; }
// 找到对应的工具 const tool = allTools.find(t => t.name === lastCall.toolName); if (!tool) { const error = `工具不存在:${lastCall.toolName}`; sendStatus(ws, ` ❌ ${error}`); return { toolCalls: [{ ...lastCall, result: error }], }; }
sendStatus(ws, ` ⚡ 正在执行 ${tool.name}...`);
try { // 执行工具 const result = await tool.execute(lastCall.parameters); sendStatus(ws, ` ✅ 工具执行完成`); sendStatus(ws, ` 📄 结果:${result.slice(0, 100)}${result.length > 100 ? "..." : ""}`);
return { toolCalls: [{ ...lastCall, result }], }; } catch (error: any) { const errorMsg = `工具执行失败:${error.message}`; sendStatus(ws, ` ❌ ${errorMsg}`); return { toolCalls: [{ ...lastCall, result: errorMsg }], }; }}节点 4:Replanner — 评估决策
const replannerPrompt = PromptTemplate.fromTemplate(`你是一个任务评估专家。请判断当前任务是否已经完成。
原始任务:{task}
已完成的工具调用和结果:{toolResults}
请输出 JSON 格式的决策:{{ "status": "complete" 或 "continue", "reason": "你的判断理由(1句话)", "result": "如果 complete,这里是最终的总结回答;如果 continue,可以为空"}}
注意:- 只有当已有信息足够完整回答用户问题时,才选 complete- 如果信息不足,选 continue,下一步会继续调用工具`);
async function replanNode(state: typeof AgentState.State, ws: WebSocket) { sendStatus(ws, "📋 正在评估执行结果...");
// 格式化工具调用历史 const toolResults = state.toolCalls .map((call, i) => { return `调用 ${i + 1}:${call.toolName}\n参数:${JSON.stringify(call.parameters)}\n结果:${call.result}`; }) .join("\n\n");
const chain = replannerPrompt.pipe(llm).pipe(parser); const response = await chain.invoke({ task: state.task, toolResults, });
let decision: { status: string; reason: string; result?: string }; try { const jsonMatch = response.match(/\{[\s\S]*\}/); decision = jsonMatch ? JSON.parse(jsonMatch[0]) : { status: "complete", reason: "解析失败,默认完成", result: "任务已完成", }; } catch { decision = { status: "complete", reason: "解析失败,默认完成", result: "任务已完成", }; }
sendStatus(ws, ` 💭 评估:${decision.reason}`);
if (decision.status === "complete") { sendStatus(ws, "🎉 任务完成!"); return { result: decision.result || "任务已完成", thoughts: [decision.reason], }; }
// 继续执行下一个步骤 sendStatus(ws, "🔄 继续执行下一步..."); return { currentStepIndex: state.currentStepIndex + 1, thoughts: [decision.reason], };}第四步:条件路由
function shouldSelectTool( state: typeof AgentState.State,): "tool_selector" | "replanner" | typeof END { if (state.result) return END;
// 还有步骤没执行完 → 继续选工具 if (state.currentStepIndex < state.plan.length) { return "tool_selector"; }
// 所有步骤都执行完了 → 去评估 return "replanner";}
function afterExecute( state: typeof AgentState.State,): "replanner" { // 执行完工具后,去 replanner 评估 return "replanner";}
function afterReplan( state: typeof AgentState.State,): "tool_selector" | typeof END { if (state.result) return END; return "tool_selector";}第五步:构建完整的 Agent Graph
function createAgentGraph(ws: WebSocket) { return new StateGraph(AgentState) .addNode("planner", (s) => planNode(s, ws)) .addNode("tool_selector", (s) => toolSelectorNode(s, ws)) .addNode("tool_executor", (s) => toolExecutorNode(s, ws)) .addNode("replanner", (s) => replanNode(s, ws)) .addEdge(START, "planner") .addConditionalEdges("planner", shouldSelectTool) .addEdge("tool_selector", "tool_executor") // 选完工具 → 执行 .addConditionalEdges("tool_executor", afterExecute) // 执行完 → 评估 .addConditionalEdges("replanner", afterReplan) // 评估后 → 继续或结束 .compile();}第六步:Handler 入口
export async function handleAgent( ws: WebSocket, session: Session, content: string,): Promise<void> { if (!content.trim()) { ws.send(JSON.stringify({ type: "error", content: "消息不能为空" })); return; }
addUserMessage(session, content);
try { const start = Date.now(); sendStatus(ws, `🤖 启动 Agent 模式...`);
const graph = createAgentGraph(ws); const result = await graph.invoke({ task: content });
const elapsed = Date.now() - start; addAIMessage(session, result.result);
ws.send( JSON.stringify({ type: "done", mode: "agent", content: result.result, elapsed, summary: { totalTools: result.toolCalls.length, toolCalls: result.toolCalls.map(c => ({ tool: c.toolName, parameters: c.parameters, result: c.result.slice(0, 200), })), }, }), );
logger.info( `[${session.id}] 🤖 Agent 完成 (${elapsed}ms, 调用了 ${result.toolCalls.length} 个工具)`, ); } catch (error: any) { logger.error(`[${session.id}] Agent 调用失败`, error.message); ws.send(JSON.stringify({ type: "error", content: `请求失败: ${error.message}` })); }}三、Agent 运行演示
用户提问:「2024 年北京 100 平米的房子总价大概多少?」
Agent 的完整思考和行动过程:
🤖 启动 Agent 模式...
🧠 正在分析任务并制定计划...📝 已制定计划: 1. 搜索 2024 年北京的平均房价 2. 计算 100 平米房子的总价 3. 整理并给出最终答案
🔧 正在为步骤 [1/3] 选择工具:搜索 2024 年北京的平均房价 💭 思考:房价是时效性数据,需要搜索最新信息 🎯 选择工具:web_search 📋 参数:{"query":"北京房价"} ⚡ 正在执行 web_search... ✅ 工具执行完成 📄 结果:2024 年北京平均房价约 6.8 万元/平方米,海淀约 9.5 万,朝阳约 7.8 万
📋 正在评估执行结果... 💭 评估:已获取房价数据,但还需要计算总价🔄 继续执行下一步...
🔧 正在为步骤 [2/3] 选择工具:计算 100 平米房子的总价 💭 思考:这是数学计算问题,用计算器工具 🎯 选择工具:calculator 📋 参数:{"expression":"6.8 * 100"} ⚡ 正在执行 calculator... ✅ 工具执行完成 📄 结果:计算结果:6.8 * 100 = 680
📋 正在评估执行结果... 💭 评估:已有完整信息,可以回答了🎉 任务完成!
💬 最终回答:根据 2024 年的最新数据,北京平均房价约为 6.8 万元/平方米。因此,100 平米的房子总价大约是 680 万元。
注:不同区域差异较大,海淀区约 9.5 万/㎡(100 平米约 950 万),朝阳区约 7.8 万/㎡(100 平米约 780 万)。四、我的 Agent 工程化经验
经验 1:工具描述是灵魂,写不好 LLM 不会用
错误的工具描述:
// ❌ 太模糊,LLM 不知道什么时候用{ name: "search", description: "搜索信息" }正确的工具描述:
// ✅ 清晰说明适用场景,LLM 知道什么时候该用{ name: "web_search", description: "搜索网络上的最新信息,适合查询新闻、价格、数据等时效性问题,或 LLM 知识库中没有的信息", // ...}我的工具描述模板:
{工具名}: {一句话说明工具是做什么的}。适合:{适用场景1}、{适用场景2}、{适用场景3}。不适合:{不适用场景}经验 2:参数定义要精确,不然 LLM 传错参数
错误的参数定义:
// ❌ 只有类型,没有说明parameters: { query: { type: "string" }}正确的参数定义:
// ✅ 详细说明每个参数的含义、格式、示例parameters: { query: { type: "string", description: "搜索关键词,建议用中文,简洁明确,例如:北京房价 2024" }}经验 3:永远要有「直接回答」的兜底
不是所有问题都需要调用工具——简单问题直接回答就行,浪费时间和钱:
// 在 tool_selector 里加入这个兜底if (不需要工具) { toolName = "direct_answer"}
// 在 tool_executor 里处理if (lastCall.toolName === "direct_answer") { // 直接返回,不调用任何工具}经验 4:工具执行必须有超时和错误处理
外部工具永远是不可靠的:
- 网络请求可能超时
- API 可能挂掉
- 返回格式可能变
我的三层防护:
- 超时控制:每个工具执行最多等 10 秒
- 异常捕获:try-catch 包裹所有 execute 逻辑
- 降级策略:工具调用失败时,告诉 LLM「工具不可用,请换一种方式」
// 带超时的工具执行async function executeWithTimeout( tool: Tool, params: Record<string, any>, timeoutMs = 10000): Promise<string> { try { const result = await Promise.race([ tool.execute(params), new Promise((_, reject) => setTimeout(() => reject(new Error("工具执行超时")), timeoutMs) ), ]); return result as string; } catch (error) { return `工具执行失败:${(error as Error).message},请尝试其他方法或简化问题`; }}经验 5:Agent 必须有「步数限制」,防止死循环
如果不加限制,Agent 可能会无限循环:
- 搜索 → 结果不对 → 再搜索 → 结果还不对 → 再搜索…
我的解决方案:
// 在 State 里加一个步数字段maxSteps: Annotation<number>({ default: () => 10 });
// 在 replanner 里检查if (state.toolCalls.length >= state.maxSteps) { return { result: "已达到最大执行步数,停止执行。已收集的信息如下:...", };}五、全文总结:整个系列的进化之路
六篇博客的完整脉络
| 篇序 | 主题 | 核心能力 | 关键技术 |
|---|---|---|---|
| 第 1 篇 | 四种记忆策略 | 「记住」对话历史 | Buffer / Window / Summary / Vector |
| 第 2 篇 | RAG 检索增强 | 「查资料」回答问题 | 文档分割 + 向量化 + 检索链 |
| 第 3 篇 | Prompt Engineering | 「说人话」输出可控格式 | 角色设定 + 格式约束 + 思维链 |
| 第 4 篇 | 工程模块化 | 「可维护」的后端服务 | 分层架构 + 策略模式 + 限流容错 |
| 第 5 篇 | LangGraph 多步推理 | 「会思考」动态规划 | State + Node + 条件边 + 循环执行 |
| 第 6 篇 | Agent 多工具调用(本文) | 「能行动」自主做事 | Tool Calling + 工具选择 + 执行循环 |
从「工具」到「Agent」的能力跃迁
阶段 0:纯 LLM 只能用训练数据里的知识 ↓阶段 1:有记忆的 ChatBot(第 1 篇) 能记住之前说过什么 → 多轮对话 ↓阶段 2:RAG 问答系统(第 2 篇) 能查资料回答 → 知识扩展到训练数据之外 ↓阶段 3:输出可控(第 3 篇) 能按格式输出 → 机器可解析 ↓阶段 4:可维护的工程(第 4 篇) 能部署到生产环境 → 稳定、可靠 ↓阶段 5:会思考的系统(第 5 篇) 能自己规划步骤 → 从「脚本」到「思考」 ↓阶段 6:能行动的 Agent(第 6 篇) 能调用工具做事 → 从「思考」到「行动」一句话总结整个系列
我们用六篇博客,把 LLM 从一个「只会聊天的玩具」,一步步变成了一个「能思考、会行动、可维护、能落地的工程系统」。
项目源码:learn-langchain.js
上一篇:[LangGraph 多步推理:从「直线执行」到「有思考的 Agent」](LangGraph 多步推理(四).md)
六、系列完结感言
这六篇博客写的是 learn-langchain.js 这个项目,但其实更像是我自己学习 LLM 工程化的完整笔记。
每一篇都是:
- 先写代码——把功能跑起来
- 再踩坑——遇到各种奇葩问题
- 再重构——把代码从「能跑」变成「好维护」
- 再总结——把经验提炼成博客
我相信这就是最好的学习方式:做中学,学中总结。
如果你看完这个系列,能从零搭出一个属于自己的 LLM 后端服务——从 RAG 到 Agent,从单文件到分层架构——那这个系列的目标就达成了。
欢迎在 GitHub 上 Star 和提 Issue: 👉 learn-langchain.js
系列完结! 🎉
感谢你的阅读!如果你觉得这个系列对你有帮助,欢迎分享给更多对 LLM 工程化感兴趣的朋友。
下一个系列预告:LLM 前端工程化实战——从零打造一个媲美 ChatGPT 的 AI 对话界面。
Some information may be outdated