源码
3.1 本章目标
学完本章,你能实现:
- 用户输入一个复杂任务,Agent 自动规划步骤并逐步执行
- 前端实时展示每一步:工具调用 → 执行参数 → 执行结果
- 6 个实用工具:搜索、知识库、计算、日期、生成报告、发通知
- 步骤可展开/折叠,支持查看完整的入参出参
本章对应项目功能:模块三 — 任务执行 Agent
3.2 Agent 和普通对话的区别
普通对话:一问一答
用户:Vue3 最新版本是什么?
AI:Vue 3.4.21
结束
Agent:多步执行
用户:对比 Vue3 和 React 的最新状态,生成报告发给我
Agent 规划:需要 3 步
步骤1 → 调用 web_search("Vue3最新版本") → 获取 Vue3 信息
步骤2 → 调用 web_search("React最新状态") → 获取 React 信息
步骤3 → 调用 write_report("对比报告", ...) → 生成最终报告
最终 → 输出综合回答
Agent 的核心是 ReAct 循环(Reason + Act):
思考(Reason)→ 行动(Act)→ 观察结果 → 继续思考 → ...直到完成
3.3 工具定义
工具是 Agent 的"手",每个工具必须有 3 个要素:
import { tool } from '@langchain/core/tools'
import { z } from 'zod'
export const searchTool = tool(
// 1. 执行函数:实际做事的代码
async ({ query }) => {
const result = await callSearchAPI(query)
return result
},
{
// 2. 工具名:模型通过这个名字调用工具
name: 'web_search',
// 3. 描述:非常重要!模型靠这个决定什么时候用这个工具
// 写清楚:这个工具能做什么、什么场景用
description: '搜索互联网获取最新技术资讯、版本信息、最佳实践。当需要了解某个技术的最新状态时使用。',
// 4. 参数 Schema:用 Zod 定义,模型会生成符合这个格式的参数
schema: z.object({
query: z.string().describe('搜索关键词,尽量精确,如"Vue3最新版本"'),
}),
}
)
description 的写法很关键
// 差的 description(太模糊)
description: '搜索信息'
// 好的 description(明确适用场景 + 使用时机)
description: '搜索互联网获取最新技术资讯。当需要了解技术最新状态、版本更新、或不确定某个信息时使用。'
3.4 构建 LangGraph Agent
import { StateGraph, END, START, Annotation, messagesStateReducer } from '@langchain/langgraph'
import { ToolNode } from '@langchain/langgraph/prebuilt'
// 1. 定义状态
const State = Annotation.Root({
messages: Annotation({ reducer: messagesStateReducer, default: () => [] }),
steps: Annotation({ reducer: (_, n) => n, default: () => 0 }), // 步数计数
})
// 2. Agent 节点:让模型决定下一步
async function agentNode(state) {
const response = await model.bindTools(allTools).invoke([
new SystemMessage(AGENT_SYSTEM), // 告诉模型有哪些工具、如何工作
...state.messages,
])
return { messages: [response], steps: state.steps + 1 }
}
// 3. 路由:有工具调用 → 执行工具;无工具调用 → 结束
function shouldContinue(state) {
const last = state.messages[state.messages.length - 1]
if (state.steps >= 8) return '__end__' // 防止无限循环!
return last.tool_calls?.length ? 'tools' : '__end__'
}
// 4. 构建图:agent ↔ tools 循环
const graph = new StateGraph(State)
.addNode('agent', agentNode)
.addNode('tools', new ToolNode(allTools)) // ToolNode 自动执行所有工具调用
.addEdge(START, 'agent')
.addConditionalEdges('agent', shouldContinue, {
tools: 'tools',
__end__: END,
})
.addEdge('tools', 'agent') // 工具执行完,回到 agent 继续思考
.compile()
为什么必须有最大步数限制?
如果 Agent 陷入循环(比如工具A的结果触发工具B,工具B的结果又触发工具A), 没有限制会一直执行下去,花掉大量 token 和时间。 实践中设 8-10 步通常够用,超出后用已有信息给出回答。
3.5 streamEvents:拿到每一步的精细事件
LangGraph 的 streamEvents 是本章的关键 API, 它能让你拿到 Agent 执行过程中每个步骤的详细事件:
for await (const event of graph.streamEvents(
{ messages: [new HumanMessage(task)], steps: 0 },
{ version: 'v2' }
)) {
const { event: eventType, name, data } = event
// 工具开始执行(模型决定调用某个工具)
if (eventType === 'on_tool_start') {
console.log('工具名:', name) // 'web_search'
console.log('入参:', data?.input) // { query: 'Vue3最新版本' }
}
// 工具执行完毕
if (eventType === 'on_tool_end') {
console.log('工具名:', name)
console.log('结果:', data?.output) // 工具的返回值
}
// 模型流式输出 token(最终回答阶段)
if (eventType === 'on_chat_model_stream') {
const chunk = data?.chunk
// 注意:工具调用决策阶段也会有这个事件,要过滤掉
if (!chunk.tool_call_chunks?.length && chunk.content) {
process.stdout.write(chunk.content) // 逐 token 输出
}
}
}
事件类型总结
| 事件 | 含义 | 有用数据 |
|---|---|---|
on_tool_start |
工具开始执行 | name(工具名), data.input(入参) |
on_tool_end |
工具执行完毕 | name, data.output(结果) |
on_chat_model_stream |
模型流式输出 | data.chunk.content(token) |
on_chain_start |
某个节点开始 | name(节点名) |
on_chain_end |
某个节点结束 |
3.6 服务端:推送步骤给前端
// routes/agent.js
agentRouter.post('/run', async (req, res) => {
const { task } = req.body
// SSE 响应头
res.setHeader('Content-Type', 'text/event-stream')
res.setHeader('Cache-Control', 'no-cache')
const send = (event, data) =>
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)
send('start', { task })
// 执行 Agent,每一步通过回调推送给前端
await runAgent(task, (type, data) => {
send(type, data)
})
})
runAgent 的回调把 streamEvents 转成业务事件:
// tool_call 事件:工具被调用
onEvent('tool_call', {
step: stepCount,
toolName: name, // 'web_search'
args: data?.input, // { query: 'Vue3最新版本' }
label: '联网搜索', // 中文标签
})
// tool_result 事件:工具执行完
onEvent('tool_result', {
toolName: name,
result: data?.output, // 搜索结果字符串
})
// token 事件:最终回答的流式 token
onEvent('token', { token: chunk.content })
3.7 前端:接收并可视化步骤
agent store 中的状态更新
// stores/agent.js
await fetchStream('/api/agent/run', { task }, {
onEvent: (event, data) => {
if (event === 'tool_call') {
// 添加一个新的"执行中"步骤
task.steps.push({
toolName: data.toolName,
label: data.label,
args: data.args,
result: null,
status: 'running', // 这一步还在执行
startMs: Date.now(),
})
}
if (event === 'tool_result') {
// 找到对应的步骤,更新为"完成"
const step = [...task.steps].reverse()
.find(s => s.toolName === data.toolName && s.status === 'running')
if (step) {
step.result = data.resultText
step.status = 'done'
step.durationMs = Date.now() - step.startMs
}
}
}
})
ToolCallCard 组件
每个步骤渲染成一个卡片,有 3 种状态:
执行中(running):蓝色边框 + 发光效果 + "正在执行..."
完成(done): 绿色边框 + 展示入参出参
失败(error): 红色边框 + 错误信息
展开/折叠用 Vue 的 <Transition> 做动画:
<Transition name="slide">
<div v-if="expanded" class="card-body">
<!-- 入参 -->
<pre class="args">{{ argsText }}</pre>
<!-- 出参 -->
<pre class="result">{{ resultText }}</pre>
</div>
</Transition>
.slide-enter-active,
.slide-leave-active { transition: all .25s ease; overflow: hidden; }
.slide-enter-from,
.slide-leave-to { max-height: 0; opacity: 0; }
.slide-enter-to,
.slide-leave-from { max-height: 500px; opacity: 1; }
3.8 System Prompt 的设计
Agent 的 System Prompt 要告诉模型:
- 有哪些工具可用(列出来)
- 工作原则(按最少工具完成任务)
- 何时停止(获取到足够信息就给出回答)
const AGENT_SYSTEM = `你是 WorkMind AI 任务助手。
可用工具:
- web_search:搜索最新技术资讯
- read_doc:从公司知识库检索文档
- calculate:数学计算
- get_date:日期查询和计算
- write_report:生成并保存分析报告
- send_notify:发送通知
工作原则:
1. 先理解任务的完整需求,想好需要哪些步骤
2. 按最少工具调用完成任务,避免重复查询
3. 获取到足够信息后立刻生成最终回答,不要继续无谓的工具调用`
关键原则:告诉模型"何时停止"
不写明停止条件的话,模型可能在拿到答案后继续调工具, 浪费 token,增加延迟。
3.9 工具安全性
工具会执行代码,要做安全过滤:
// calculate 工具:只允许数学运算,防止代码注入
async ({ expression }) => {
// 只保留数字和运算符,过滤掉其他字符
const safeExpr = expression.replace(/[^0-9+\-*/().,\s%]/g, '').trim()
if (!safeExpr) return '无效的数学表达式'
// eslint-disable-next-line no-new-func
const result = Function(`"use strict"; return (${safeExpr})`)()
return `计算结果:${expression} = ${result}`
}
生产环境的工具安全建议
- 计算类工具:白名单过滤,只允许安全的运算符
- 文件读写工具:限制目录范围,不允许读取系统文件
- 外部 API 工具:设置超时和重试,避免挂起
- 所有工具:用
try/catch包裹,失败时返回结构化错误信息(不要抛出异常)
3.10 本章作业
✅****基础功能
- 输入任务,能看到 Agent 执行步骤一步步出现
- 每个步骤卡片能展开查看入参和出参
- 最终回答正确显示(支持 Markdown)
- 点击示例任务能自动填入
✅****进阶功能
- 执行中的步骤卡片显示蓝色发光边框 + 动画
- 完成后步骤显示执行耗时(xx ms)
- 复制最终回答功能可用
- 超过最大步数时,Agent 仍能给出回答
✅****测试场景
- 输入:
搜索 Vue3 最新版本,然后计算 1500 + 800 的总和→ 应该调用 web_search 和 calculate 两个工具 - 输入:
今天是几号?从今天起 30 天后是什么日期?→ 应该只调用 get_date 工具 - 输入:
介绍一下 Vue3(不需要工具的问题) → Agent 应该直接回答,不调任何工具
3.11 常见问题
Q:Agent 总是调用很多不必要的工具?
优化 System Prompt,明确告诉模型"按最少工具调用原则"。 也可以适当降低 temperature(我们已设为 0),让工具选择更确定。
Q:工具执行报错,整个 Agent 崩溃?
每个工具函数必须用 try/catch,失败时返回错误描述,不要抛出异常:
async ({ query }) => {
try {
return await searchAPI(query)
} catch (e) {
return `搜索失败:${e.message}` // 返回错误信息,让 Agent 决定下一步
}
}
Q:前端步骤展示顺序错乱?
确认 tool_result 事件用 toolName + status=running 来匹配步骤, 不要用索引匹配(因为并行工具调用时顺序可能不固定)。
Q:最终回答的 token 和工具调用决策的 token 混在一起?
on_chat_model_stream 事件在两种情况下都会触发:
- 模型决定调用工具时(有
tool_call_chunks) - 生成最终回答时(没有
tool_call_chunks)
过滤方法:
if (!chunk.tool_call_chunks?.length && chunk.content) {
// 这才是最终回答的 token
onEvent('token', { token: chunk.content })
}