Agent 工具调用状态机

本文档描述 Agent 模式下,从 LLM 流式输出到工具执行再到前端展示的完整状态流转。

1. 整体 Agent 循环

Agent 采用「规划轮 + 意图识别 + 多轮工具循环」架构。

第 0 轮(规划轮):不传 tools,强制 LLM 先用语言回应用户。planning_prompt 作为临时 system 消息插入末尾,指导 LLM 判断是否需要工具。LLM 必须在回复开头输出意图标签二选一:

  • <no_tool>(不需要工具,闲聊/问答):后端检测到后跳过整个 Agent 循环,省掉一次 API 调用。
  • <need_tool>(需要工具):后端检测到后追加 assistant 回复 + [continue] 过渡消息,进入工具循环。进入循环前会清理 full_messages 中所有 assistant 消息的意图标签,避免 Agent 循环的 LLM 模仿标签模式。

意图标签保留在 raw_text 中(与 <voice> 标签同理),由 stream_parser.strip_tags() 统一从 text 中剥离。

第 1 轮起:正常 Agent 循环,每轮向 LLM 发起流式请求(带 tools),LLM 可能返回文本或工具调用;如果有工具调用则先等待用户审批(ACK),审批通过后执行工具、将结果追加到上下文后进入下一轮,直到 LLM 纯文本输出(无工具调用)为止。

flowchart TD START([用户请求]) --> PLAN_INSERT["插入 planning_prompt"] PLAN_INSERT --> PLAN_CALL["向 LLM 发起请求\n(不传 tools,不含工具描述)"] PLAN_CALL --> PLAN_STREAM["流式输出\n(意图标签检测)"] PLAN_STREAM --> PLAN_ROUND_END["yield round_end chunk"] PLAN_ROUND_END --> PLAN_CLEAN["拔出 planning_prompt"] PLAN_CLEAN --> TAG_CHECK{"检测到哪个\n意图标签?"} TAG_CHECK -- "<no_tool>" --> DONE_DIRECT([直接结束\n无需工具调用]) TAG_CHECK -- "<need_tool> 或无标签" --> CLEAN_TAGS["清理 full_messages 中\nassistant 消息的意图标签"] CLEAN_TAGS --> APPEND_CTX["补上 assistant 回复\n+ [continue] 过渡消息"] APPEND_CTX --> SEND["向 LLM 发起流式请求\n(带 tools)"] SEND --> STREAM[流式接收 delta chunks] STREAM --> HAS_TC{流结束后<br/>有工具调用?} HAS_TC -- 否 --> FLUSH[输出剩余 buffer] FLUSH --> ROUND_END2[yield round_end chunk] ROUND_END2 --> DONE([结束, 返回完整回复]) HAS_TC -- 是 --> APPROVAL{"用户审批\n(ACK)"} APPROVAL -- 批准 --> EXEC[依次执行工具] APPROVAL -- 拒绝/超时 --> REJECT_END([推送 tool_rejected\n中断会话]) EXEC --> APPEND[工具结果追加到 messages] APPEND --> ROUND_END1[最后一个工具的 result chunk\n带 round_end=true] ROUND_END1 --> STEP_CHECK{达到最大<br/>Agent 步数?} STEP_CHECK -- 否 --> SEND STEP_CHECK -- 是 --> WARN[输出警告] --> DONE style PLAN_ROUND_END fill:#e3f2fd style PLAN_INSERT fill:#e8f5e9 style PLAN_CALL fill:#e8f5e9 style PLAN_STREAM fill:#e8f5e9 style PLAN_CLEAN fill:#e8f5e9 style TAG_CHECK fill:#fff9c4 style DONE_DIRECT fill:#e3f2fd style CLEAN_TAGS fill:#e8f5e9 style APPEND_CTX fill:#e8f5e9 style APPROVAL fill:#fff3e0 style REJECT_END fill:#ffebee style ROUND_END1 fill:#e3f2fd style ROUND_END2 fill:#e3f2fd

2. 规划轮意图标签检测状态机

规划轮流式输出过程中,通过一个 3 状态的状态机检测开头的意图标签(<no_tool><need_tool>),实现零额外延迟的意图识别。

标签采用字典驱动:intent_tags = {"<no_tool>": True, "<need_tool>": False},key 为标签字符串,value 为 skip_agent_loop 的值。

状态定义

状态 含义 行为
detecting 正在缓冲开头内容 累积到 detect_buffer,不进入分句流程,等待判定
found 确认检测到某个意图标签 标签保留在 buffer 中(流入 raw_text),skip_agent_loop 按字典取值,后续内容正常分句输出
not_found 确认无标签 detect_buffer 转入主 buffer,后续内容正常分句输出

标签处理方式

意图标签与 <voice> 标签采用统一处理模式(在 stream_parser 中):

  • raw_text:保留标签原文(进入对话历史,帮助 LLM 学习标签用法)
  • text:由 strip_tags() 统一剥离所有控制标签(<voice><no_tool><need_tool>
  • voice 标志has_voice_tag() 会跳过意图标签后检测 <voice>,正确处理 <need_tool><voice>... 的情况

状态转换图

stateDiagram-v2 [*] --> detecting : 规划轮流式开始 detecting --> found : stripped 匹配某个意图标签<br/>设置 skip_agent_loop detecting --> not_found : 不是任何标签的前缀<br/>确认无标签 detecting --> detecting : 仍可能是某标签前缀<br/>(如只收到 "< n")继续缓冲 found --> [*] : 流结束 not_found --> [*] : 流结束

上下文清理

进入 Agent 循环前(skip_agent_loop=False 时),遍历 full_messages 清理所有 assistant 消息中的意图标签。这防止 Agent 循环的 LLM 看到标签模式后模仿输出。

3. 单轮流式输出中的状态机

在每一轮 LLM 流式输出中,对工具调用事件维护一个 4 状态的有限状态机(正则文法),在不同时机 yield 不同的 LLMChunk 通知下游。

状态定义

状态 触发时机 ToolCallInfo 字段 含义
start 流式 delta 中首次收到 function.name name 有值, arguments=None, result=None LLM 决定调用工具,名称已知
approval_required 流结束,arguments 拼接完整,等待用户审批 name 有值, arguments 有值, approval_required=True 参数就绪,等待用户批准/拒绝
executing 用户批准,工具开始执行 name 有值, arguments 有值, result=None 审批通过,工具正在执行
result _execute_tool() 返回 name 有值, arguments 有值, result 有值 工具执行完毕

状态转换图

stateDiagram-v2 [*] --> idle idle --> start : delta 中首次收到<br/>tool_call.function.name note right of start : yield ToolCallInfo(name) start --> start : 后续 delta 拼接<br/>arguments 碎片<br/>(不 yield) start --> approval_required : stream 结束<br/>json.loads(arguments) 成功 note right of approval_required : yield ToolCallInfo(name, args,<br/>approval_required=True)<br/>generator 挂起 approval_required --> executing : 用户批准 (ACK)<br/>ChatHandler 调用 __anext__() note right of executing : yield ToolCallInfo(name, args) approval_required --> rejected : 用户拒绝 / 超时 / 中断 note right of rejected : session cancel<br/>generator return executing --> result : _execute_tool() 返回 note right of result : yield ToolCallInfo(name, args, result) result --> idle : 处理下一个 tool_call<br/>或进入下一轮 Agent 循环 idle --> [*] : 无工具调用,<br/>输出剩余文本并结束 rejected --> [*]

4. 审批(ACK)机制

架构

审批通过 HTTP 接口 + asyncio.Event 实现异步阻塞/唤醒,与 WebSocket 通道解耦。

AgentClient yield approval_required
    → ChatHandler: approval_manager.register() → push 前端 → create_task(approval_manager.wait())
        → 前端显示审批 UI(或 auto-approve 命中时立即 POST)
            → POST /api/tool-approve {session_id, task_id, approved}
                → ApprovalManager.respond() → Event.set()
                    → task.done() → ChatHandler 继续或中断

关键组件

组件 位置 职责
ApprovalManager managers/approval_manager.py 管理 pending Event,提供 register/wait/respond/cancel_session
SessionManager managers/session_manager.py 会话生命周期,cancel 时联动唤醒 ApprovalManager
POST /api/tool-approve websocket_server.py HTTP 审批接口,reject 时同时触发 session cancel
前端 auto-approve 列表 chatfrontend.html (localStorage) 工具白名单,命中时自动 POST approve

竞态保护

  • 先 register 再推送approval_manager.register() 在 WebSocket push 之前调用,保证 Event 在前端回复前已存在
  • cancel 联动session_manager.cancel() 通过 on_cancel 回调调用 approval_manager.cancel_session(),唤醒所有 pending Event
  • 超时保护wait() 使用 asyncio.wait_for(timeout=300),超时视为拒绝
  • 非阻塞等待ChatHandler 使用 asyncio.create_task() 启动等待任务,主循环继续收集和推送 TTS 结果,避免阻塞音频推送
  • finally 清理handle_chatfinally 块中取消未完成的 approval_wait_task 并强制 respond(False) 清理残留 Event

拒绝后的上下文处理

用户拒绝工具调用时,后端先推送 tool_rejected 消息(含工具名、参数、拒绝原因),再推送 interrupted。前端收到 tool_rejected 后写入 historySegments(而非直接写入 conversationHistory),由后续 interrupted 处理器中的 flushHistorySegments() 统一按序写入,确保拒绝记录排在已执行的工具调用之后:

// tool_rejected → 写入 historySegments
historySegments.push({ type: 'tool_rejected', name, arguments, reason });

// interrupted → flushHistorySegments() 统一处理,写入 conversationHistory:
// assistant 消息(记录 LLM 的意图)
{ role: "assistant", content: textBuffer || null, tool_calls: [{ id, type: "function", function: { name, arguments } }] }
// tool 结果消息(记录拒绝)
{ role: "tool", tool_call_id: id, content: "用户拒绝了此工具调用" }

这确保下一轮对话时 LLM 能看到拒绝记录,避免重复尝试相同的工具调用。

5. 数据流全链路

agent_client yield 到前端 WebSocket 展示的完整数据流:

sequenceDiagram participant LLM as LLM API (stream) participant AC as agent_client participant CH as chat_handler participant AM as ApprovalManager participant WS as WebSocket participant FE as 前端 UI Note over LLM, FE: === 阶段 1: LLM 流式输出 === LLM ->> AC: delta: tool_calls[0].function.name = "get_weather" AC ->> CH: yield LLMChunk(tool_call={name, args=None, result=None}) CH ->> WS: {"type":"tool_call", "state":"start", "tool_name":"get_weather"} WS ->> FE: 显示卡片: 🔧 get_weather 准备中... (spinner) LLM ->> AC: delta: arguments 碎片 Note over AC: 拼接 arguments, 不 yield LLM ->> AC: stream 结束 (finish_reason=tool_calls) Note over AC: json.loads(arguments) 成功 Note over LLM, FE: === 阶段 2: 等待用户审批 === AC ->> CH: yield LLMChunk(tool_call={name, args, approval_required=True}) CH ->> AM: register(session_id, task_id) CH ->> WS: {"type":"tool_call", "state":"approval_required", "tool_name":"get_weather", "arguments":{...}} WS ->> FE: 显示审批 UI: 🔧 get_weather 需要授权 [批准] [拒绝] CH ->> AM: create_task(wait(key)) Note over CH: 非阻塞,主循环继续收集 TTS 结果 alt 用户批准(或 auto-approve 命中) FE ->> WS: POST /api/tool-approve {approved: true} WS ->> AM: respond(key, True) AM ->> CH: Event.set() → task.done() 返回 True Note over CH: 继续调用 generator.__anext__() else 用户拒绝 FE ->> WS: POST /api/tool-approve {approved: false} WS ->> AM: session_manager.cancel() → cancel_session() AM ->> CH: Event.set() → task.done() 返回 False CH ->> WS: {"type":"tool_rejected", ...} CH ->> WS: {"type":"interrupted"} Note over CH: 中断会话 end Note over LLM, FE: === 阶段 3: 工具执行(批准后) === AC ->> CH: yield LLMChunk(tool_call={name, args, result=None}) CH ->> WS: {"type":"tool_call", "state":"executing", ...} WS ->> FE: 更新卡片: 🔧 get_weather 执行中... Note over AC: await _execute_tool("get_weather", args) AC ->> CH: yield LLMChunk(tool_call={name, args, result="晴天 25°C"}) CH ->> WS: {"type":"tool_call", "state":"result", ...} WS ->> FE: 替换为详情卡片: 🔧 get_weather ▶ 可展开查看参数和结果 Note over LLM, FE: === 阶段 4: 轮次结束信号 === AC ->> CH: yield LLMChunk(round_end=true) 或附在上一个 chunk 上 CH ->> WS: {"type":"round_end"} WS ->> FE: historySegments 插入 separator,用于对话历史分段

6. 前端 UI 状态对应

工具调用事件通过 audioQueue 按序显示,与文本和音频消息共享同一个队列,确保显示顺序与后端推送顺序一致。

stateDiagram-v2 direction LR state "🔧 get_weather 准备中..." as S1 state "🔧 get_weather 需要授权\n[批准] [拒绝] [□自动批准]" as S1_5 state "🔧 get_weather 执行中...\ncity: 北京" as S2 state "🔧 get_weather ▶\n(可展开详情)" as S3 state "❌ get_weather 已拒绝\n(可展开查看参数)" as S_REJ state "❌ get_weather 已失效\n(有参数时可展开)" as S_INV [*] --> S1 : state=start<br/>addToolCallLoading() S1 --> S1_5 : state=approval_required<br/>showToolApprovalUI() S1_5 --> S2 : 用户批准 / auto-approve<br/>updateToolCallExecuting() S1_5 --> S_REJ : 用户拒绝<br/>updateToolCallTerminated('已拒绝') S2 --> S3 : state=result<br/>updateToolCallResult() S3 --> [*] : removeAttribute('id')<br/>允许同名工具再次调用 S_REJ --> [*] S1 --> S_INV : 中断/会话失效<br/>updateToolCallTerminated('已失效') S1_5 --> S_INV : 中断/会话失效<br/>updateToolCallTerminated('已失效') S2 --> S_INV : 后端重启/审批丢失<br/>updateToolCallTerminated('已失效') S_INV --> [*]

工具卡片终止态 (updateToolCallTerminated)

统一处理已拒绝/已失效两种终止态的 UI 展示:

  • 有参数(approval_required 阶段及之后):用 <details> 可展开查看参数
  • 无参数(准备中阶段被中断):不可展开,仅显示文本

三种触发场景:

  1. 用户拒绝approveTool(false)updateToolCallTerminated(div, '已拒绝')
  2. 用户中断 / 后端推 interruptedinterruptSession()interrupted 处理器 → updateToolCallTerminated(div, '已失效')
  3. 后端重启后审批丢失sendToolApproval 收到 success:falseinvalidateAllPendingToolCalls()updateToolCallTerminated(div, '已失效')

前端审批队列行为

  • approval_required 状态设置 approvalPendingTaskId,阻塞 playNextAudio() 继续消费队列
  • auto-approve:前端维护 autoApproveTools 列表(持久化到 localStorage),命中时立刻 POST approve 并显示执行中样式,用户无感
  • 手动审批:显示批准/拒绝按钮 + "自动批准此工具" checkbox
  • 批准后:清除 approvalPendingTaskId,恢复队列消费
  • 中断清理interruptSession() 调用 updateToolCallTerminated() 标记所有 pending 卡片为"已失效",重置 approvalPendingTaskId
  • interrupted 清理:后端推送 interrupted 时,处理器同样清理残留的 loading/approval 卡片并调用 flushHistorySegments() 保留已执行的对话历史
  • 后端重启保护POST /api/tool-approve 返回 success:false(审批任务不存在)时,invalidateAllPendingToolCalls() 将所有 pending 卡片标记为"已失效"并恢复前端可操作状态

7. 关键设计决策

为什么用 4 状态而不是 3 状态?

原始 3 状态(start → executing → result)中工具自动执行,用户无法控制。新增 approval_required 状态在 executing 前插入审批环节,让用户决定是否允许工具执行。auto-approve 列表保证常用工具不影响交互流畅度。

为什么审批用 HTTP 接口而不是 WebSocket?

  1. 与中断接口 /api/interrupt 风格统一
  2. cancel 联动更自然:拒绝时直接调 session_manager.cancel(),通过 on_cancel 回调唤醒 pending Event
  3. 前端实现简单:auto-approve 只需一个 fetch 调用
  4. 后续可扩展:审批记录可独立持久化

为什么 start 不等 arguments 完整?

OpenAI streaming 中 function.name 在一个 delta 中完整到达,但 arguments 是逐 token 拼接的。如果等 arguments 完整才通知前端,那整个 LLM 流式输出阶段前端都是黑屏的,失去了实时反馈的意义。

为什么工具调用要走 audioQueue?

工具调用事件和文本/音频消息共享同一个 audioQueue 队列,通过 playNextAudio() 按序消费。这保证了显示顺序与后端推送顺序严格一致——不会出现工具调用卡片跳到前面语音内容之前的问题。工具调用项在 playNextAudio 中被即时处理(不占播放时间),然后立即继续下一个队列项。审批状态是例外——它会阻塞队列直到用户响应。

为什么需要规划轮?

规划轮同时承担两个职责:

  1. 体验保障:LLM 在 Agent 模式下可能直接发起工具调用而不先输出文本,导致用户看到工具在执行但不知道为什么。规划轮通过不传 tools 参数强制 LLM 先用语言回应用户。
  2. 意图识别:通过 <no_tool> / <need_tool> 双标签机制,LLM 在规划轮中判断是否需要工具。不需要工具时直接返回,跳过整个 Agent 循环,省掉一次 API 调用。双标签比单标签更可靠——LLM 必须明确二选一,而不是靠"有没有输出标签"来判断。

planning_prompt 作为临时 system 消息插入 full_messages 末尾,仅包含意图判断指令,不包含工具描述——这样 LLM 不会在规划轮中"预演"工具调用细节(否则 Round 1 可能误以为工具已调用而跳过真正执行)。规划轮结束后 planning_prompt 被移除,后续轮次看到的是干净的消息历史。

同名工具多次调用如何处理?

通过 id="tool-call-${toolName}" 定位 DOM 元素进行原地更新。当 result 状态完成后立即 removeAttribute('id'),这样下次同名工具调用会创建新的卡片,不会覆盖已完成的卡片。

轮次边界(round_end)与前端历史分段

每次 LLM API 调用的最后一个 chunk 带 round_end=true 标记。ChatHandler 检测到后单独推送 {type: "round_end"} 消息。前端收到后在 historySegments 中插入 {type: 'separator'}

flushHistorySegments() 的合并逻辑:

  • 连续的 text 段累积到 textBuffer
  • 遇到 separator → 将 textBuffer flush 为独立 assistant 消息(分隔不同 LLM 调用轮次的文本)
  • 遇到 tool 段 → 将 textBuffer 合并为同一个 assistant 消息的 content,并附带 tool_calls
  • 尾部剩余 text → flush 为独立 assistant 消息

这样前端不需要知道哪一轮是规划轮——规划轮和 agent 循环的文本由 separator 自然分隔,而同一轮内的文本和工具调用自然合并。

输出格式示例:

assistant: {content: "规划轮文字"}                              // separator 触发 flush
assistant: {content: "好的让我查一下", tool_calls: [{...}]}     // tool 段合并
tool: {content: "result"}                                       // 工具结果
assistant: {content: "最终回复"}                                 // 尾部 flush

中断时的行为:round_end 通过 audioQueue 按序消费。中断时 audioQueue 被清空,未消费的 round_end 随之丢弃。这是正确的——未播放/未显示的内容不应进入对话历史,因此也不需要 separator 来分隔它们。

MCP 客户端架构

MCP 相关代码位于 mcp_engine/ 包中,支持 SSE 和 stdio 两种传输方式:

文件 职责
mcp_engine/mcp_config.py MCPServerConfig 数据类(SSE: host/port, stdio: command/args)+ DEFAULT_MCP_SERVERS 列表
mcp_engine/mcp_client.py MCPClient(connect/call_tool/close)+ ToolDefinition,connect() 按 transport 字段分支
mcp_engine/__init__.py 统一导出

AgentClient 通过 from mcp_engine import MCPClient, ToolDefinition 使用。config.pyAgentConfig 通过 DEFAULT_MCP_SERVERS 获取默认服务器列表。

图片类工具返回(如 Playwright 截图的 ImageContent)在 MCPClient.call_tool() 中被替换为文本占位符 [图片已截取, mimeType=...],避免 base64 数据撑爆 LLM 上下文。