返回笔记首页

第三章:任务执行 Agent

主题配置

源码

workmind-03-agent.zip


3.1 本章目标

学完本章,你能实现:

  • 用户输入一个复杂任务,Agent 自动规划步骤并逐步执行
  • 前端实时展示每一步:工具调用 → 执行参数 → 执行结果
  • 6 个实用工具:搜索、知识库、计算、日期、生成报告、发通知
  • 步骤可展开/折叠,支持查看完整的入参出参

本章对应项目功能:模块三 — 任务执行 Agent


3.2 Agent 和普通对话的区别

普通对话:一问一答

javascript
用户:Vue3 最新版本是什么?
AI:Vue 3.4.21
结束

Agent:多步执行

javascript
用户:对比 Vue3 和 React 的最新状态,生成报告发给我

Agent 规划:需要 3 步
  步骤1 → 调用 web_search("Vue3最新版本")     → 获取 Vue3 信息
  步骤2 → 调用 web_search("React最新状态")     → 获取 React 信息
  步骤3 → 调用 write_report("对比报告", ...)   → 生成最终报告
  最终  → 输出综合回答

Agent 的核心是 ReAct 循环(Reason + Act):

javascript
思考(Reason)→ 行动(Act)→ 观察结果 → 继续思考 → ...直到完成

3.3 工具定义

工具是 Agent 的"手",每个工具必须有 3 个要素:

javascript
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 的写法很关键

plain
// 差的 description(太模糊)
description: '搜索信息'

// 好的 description(明确适用场景 + 使用时机)
description: '搜索互联网获取最新技术资讯。当需要了解技术最新状态、版本更新、或不确定某个信息时使用。'

3.4 构建 LangGraph Agent

javascript
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 执行过程中每个步骤的详细事件:

javascript
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 服务端:推送步骤给前端

javascript
// 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 转成业务事件:

javascript
// 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 中的状态更新

javascript
// 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 种状态:

plain
执行中(running):蓝色边框 + 发光效果 + "正在执行..."
完成(done):    绿色边框 + 展示入参出参
失败(error):   红色边框 + 错误信息

展开/折叠用 Vue 的 <Transition> 做动画:

html
<Transition name="slide">
  <div v-if="expanded" class="card-body">
    <!-- 入参 -->
    <pre class="args">{{ argsText }}</pre>
    <!-- 出参 -->
    <pre class="result">{{ resultText }}</pre>
  </div>
</Transition>
css
.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 要告诉模型:

  1. 有哪些工具可用(列出来)
  2. 工作原则(按最少工具完成任务)
  3. 何时停止(获取到足够信息就给出回答)
javascript
const AGENT_SYSTEM = `你是 WorkMind AI 任务助手。

可用工具:
- web_search:搜索最新技术资讯
- read_doc:从公司知识库检索文档
- calculate:数学计算
- get_date:日期查询和计算
- write_report:生成并保存分析报告
- send_notify:发送通知

工作原则:
1. 先理解任务的完整需求,想好需要哪些步骤
2. 按最少工具调用完成任务,避免重复查询
3. 获取到足够信息后立刻生成最终回答,不要继续无谓的工具调用`

关键原则:告诉模型"何时停止"

不写明停止条件的话,模型可能在拿到答案后继续调工具, 浪费 token,增加延迟。


3.9 工具安全性

工具会执行代码,要做安全过滤:

javascript
// 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 仍能给出回答
✅****测试场景
  1. 输入:搜索 Vue3 最新版本,然后计算 1500 + 800 的总和 → 应该调用 web_search 和 calculate 两个工具
  2. 输入:今天是几号?从今天起 30 天后是什么日期? → 应该只调用 get_date 工具
  3. 输入:介绍一下 Vue3(不需要工具的问题) → Agent 应该直接回答,不调任何工具

3.11 常见问题

Q:Agent 总是调用很多不必要的工具?

优化 System Prompt,明确告诉模型"按最少工具调用原则"。 也可以适当降低 temperature(我们已设为 0),让工具选择更确定。

Q:工具执行报错,整个 Agent 崩溃?

每个工具函数必须用 try/catch,失败时返回错误描述,不要抛出异常:

javascript
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 事件在两种情况下都会触发:

  1. 模型决定调用工具时(有 tool_call_chunks
  2. 生成最终回答时(没有 tool_call_chunks

过滤方法:

javascript
if (!chunk.tool_call_chunks?.length && chunk.content) {
  // 这才是最终回答的 token
  onEvent('token', { token: chunk.content })
}