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 纯文本输出(无工具调用)为止。
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>...的情况
状态转换图
上下文清理
进入 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 有值 |
工具执行完毕 |
状态转换图
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_chat的finally块中取消未完成的 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 展示的完整数据流:
6. 前端 UI 状态对应
工具调用事件通过 audioQueue 按序显示,与文本和音频消息共享同一个队列,确保显示顺序与后端推送顺序一致。
工具卡片终止态 (updateToolCallTerminated)
统一处理已拒绝/已失效两种终止态的 UI 展示:
- 有参数(approval_required 阶段及之后):用
<details>可展开查看参数 - 无参数(准备中阶段被中断):不可展开,仅显示文本
三种触发场景:
- 用户拒绝 →
approveTool(false)→updateToolCallTerminated(div, '已拒绝') - 用户中断 / 后端推 interrupted →
interruptSession()或interrupted处理器 →updateToolCallTerminated(div, '已失效') - 后端重启后审批丢失 →
sendToolApproval收到success:false→invalidateAllPendingToolCalls()→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?
- 与中断接口
/api/interrupt风格统一 - cancel 联动更自然:拒绝时直接调
session_manager.cancel(),通过on_cancel回调唤醒 pending Event - 前端实现简单:auto-approve 只需一个
fetch调用 - 后续可扩展:审批记录可独立持久化
为什么 start 不等 arguments 完整?
OpenAI streaming 中 function.name 在一个 delta 中完整到达,但 arguments 是逐 token 拼接的。如果等 arguments 完整才通知前端,那整个 LLM 流式输出阶段前端都是黑屏的,失去了实时反馈的意义。
为什么工具调用要走 audioQueue?
工具调用事件和文本/音频消息共享同一个 audioQueue 队列,通过 playNextAudio() 按序消费。这保证了显示顺序与后端推送顺序严格一致——不会出现工具调用卡片跳到前面语音内容之前的问题。工具调用项在 playNextAudio 中被即时处理(不占播放时间),然后立即继续下一个队列项。审批状态是例外——它会阻塞队列直到用户响应。
为什么需要规划轮?
规划轮同时承担两个职责:
- 体验保障:LLM 在 Agent 模式下可能直接发起工具调用而不先输出文本,导致用户看到工具在执行但不知道为什么。规划轮通过不传
tools参数强制 LLM 先用语言回应用户。 - 意图识别:通过
<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 → 将
textBufferflush 为独立 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.py 的 AgentConfig 通过 DEFAULT_MCP_SERVERS 获取默认服务器列表。
图片类工具返回(如 Playwright 截图的 ImageContent)在 MCPClient.call_tool() 中被替换为文本占位符 [图片已截取, mimeType=...],避免 base64 数据撑爆 LLM 上下文。
评论区