Skip to content

智能体执行引擎:从规划到可恢复循环 ​

本文按 muotai-backend 当前实现说明自主规划 Agent 的执行引擎。重点是 AutoPlanningAgent 的主图、AgenticTaskExecutionNode 的子图,以及子图中的 reasoning、acting、refining、memory 和 human review 节点。代码发生变化时,以以下源码为事实来源:

text
muotai-ai/muotai-ai-biz/src/main/java/com/pig4cloud/pigx/ai/agent/chatbot/AutoPlanningAgent.java
muotai-ai/muotai-ai-biz/src/main/java/com/pig4cloud/pigx/ai/agent/chatbot/node/AgenticTaskExecutionNode.java
muotai-ai/muotai-ai-biz/src/main/java/com/pig4cloud/pigx/ai/agent/chatbot/node/execution/
muotai-ai/muotai-ai-biz/src/main/java/com/pig4cloud/pigx/ai/agent/chatbot/state/TaskExecutionState.java

1. 两层状态图 ​

自主规划 Agent 不是单个模型调用,而是“主图编排 + 子图循环”的两层结构。主图负责确定任务是什么、是否需要联网和是否进入执行;子图负责在一个任务内反复进行模型决策、工具调用和结果评估。

主图中的 ChatbotAgentState 保存请求级上下文,例如用户输入、Agent 配置、意图、计划文本、session 和最终回答。进入 agentic_loop 时,AutoPlanningAgent.executeTaskLoop 将这些字段投影为 TaskExecutionState;子图结束后再把执行历史、TODO、工具结果、HITL 状态和最终结果写回主图状态。这个桥接层使执行子图可以独立 checkpoint,也避免把运行时的 ChatMessage、ChatModel 和工具实例直接塞进可序列化的主状态。

2. 主图阶段 ​

load_config ​

LoadAgentConfigNode 根据 Agent、租户和会话加载模型、工具、Skill、子 Agent 与知识库关系,并写入 ChatbotAgentState。这里还会处理 Agent 绑定知识库但未显式配置 RAG Agent 的兼容注入。后续节点只消费状态,不应再次从 HTTP 上下文猜测配置。

intent_recognition ​

IntentRecognitionNode 使用 LLM 产生结构化意图,至少影响以下决策:

状态字段用途
userIntent给计划节点和执行循环的系统提示提供目标语义
needRag是否进入知识库检索
ragKeywords构造 RAG 查询
targetDataset、targetDatasetIds限定检索数据集
webSearchMode控制联网搜索预取策略

意图识别失败时不能把空意图当成“无需执行”;节点应保留错误并由终态逻辑决定是降级回答还是失败。

web_search_prefetch ​

该节点位于意图识别之后、任务规划之前。它把允许预取的联网搜索结果放入上下文,供规划节点判断是否需要把搜索结果作为任务输入。节点之后有条件路由:无需规划时直接进入 task_end,否则进入 planning。

planning ​

PlanningNode 根据用户目标、意图和历史生成任务计划文本,并设置 nextAction。计划文本是给执行循环使用的工作契约,不是最终答案。计划可以包含检索、Skill、子 Agent、文件操作和业务工具等步骤;真正执行时仍由工具 Schema、权限和 HITL 规则约束,不能因为计划文本中出现某个动作就直接执行。

nextAction 只有两个有效方向:

text
ACTION_EXECUTE_LOOP  -> agentic_loop
ACTION_FINAL_RESPONSE -> task_end

3. 子图拓扑与路由 ​

AgenticTaskExecutionNode.buildGraph() 组装以下节点。所有节点都用 TrackedNodeAction 包装,因此节点开始、完成、迭代编号和异常会进入执行轨迹。

推理路由 ​

LoopReasoningNode 把 LLM 返回的 FinishReason 写入状态,routeAfterReasoning 再决定下一跳:

FinishReason下一节点含义
TOOL_EXECUTIONactingAiMessage 中包含一个或多个工具请求
LENGTHmemory上下文或输出达到模型长度限制,先压缩再重试
STOPEND模型给出最终答案,循环自然结束
其他或空值END安全终止并记录未预期停止原因

STOP 时会把文本写入 lastIterationResult,并将 loopSignal 置为 DONE。因此终止判断依赖结构化状态,不依赖解析“已完成”之类的自然语言。

评估路由 ​

LoopRefiningNode 在工具执行后更新失败计数、执行历史、TODO 进度和重规划次数。routeAfterRefining 的优先级如下:

  1. loopSignal 为 DONE 或 FAILED 时结束。
  2. iterationCount >= max(maxTaskIterations, 100) 时强制结束,防止配置过小或异常状态造成死循环。
  3. hitlPending=true 时进入 human_review。
  4. 其他情况回到 reasoning。

编译图时还设置了 LangGraph 的 recursionLimit:

text
max(25, 1 + max(maxTaskIterations, 100) * 4 + 24)

这个限制按图步数计算,业务迭代上限按循环轮数计算,二者不能混为一个参数。

4. 状态桥接与运行时对象 ​

可持久化状态 ​

TaskExecutionState 是子图的序列化边界,主要字段可以按职责分为四组:

组字段说明
输入配置agentConfig、llmModelConfig、datasetList、userInput、userIntent本次运行的配置快照与用户目标
计划控制taskPlanText、taskTodoList、iterationCount、maxTaskIterations、loopSignal当前计划、进度和循环终态
执行记录executionHistoryText、toolResultStore、runtimeMessages、lastIterationResult供下一轮模型和恢复流程使用的摘要/结果
保护状态consecutiveFailures、replanCount、contextCompressionCount、hitlPending、hitlRequest、hitlResponse、error失败、压缩、审批和错误控制

executionHistoryText、toolResultStore 和 messages 使用 appender reducer,节点返回增量时不会覆盖此前记录。扩展状态字段时必须同时更新 schema、serializer、checkpoint codec 和恢复测试。

非持久化运行时 ​

LoopInitNode 为每个 session 创建 LoopRuntime,其中包含 ChatModel、消息列表、工具规格、VFS/Sandbox、TaskTodoManager、ToolResultStore、当前 Skill 和 StreamHandler。这些对象不应直接序列化到 checkpoint:

  1. checkpoint 只保存可重建的配置和消息投影。
  2. 恢复时 LoopInitNode.restoreRuntime 根据状态重新创建模型、工具和工作区。
  3. runtimeMessages 用 canonical message DTO 保存 User、System、AI、Tool 消息,恢复后再还原成 LangChain4j 消息对象。

这一区分是恢复能否跨进程工作的关键。把供应商 ChatModel 或线程对象写入状态会导致 JSON 序列化失败,也会把旧租户上下文带入新运行。

5. 一轮循环的实际执行 ​

init:建立执行上下文 ​

初始化节点绑定 AgentExecutionContext,加载系统提示和历史消息,创建工具规格与 TODO 管理器,建立 Sandbox/VFS 会话,并向前端开启结构化执行块。若本次是恢复运行,则优先从 runtimeMessages、toolResultStore 和 TODO 快照重建内存对象,而不是重复执行已经完成的工具。

reasoning:让模型选择下一步 ​

节点向 ChatModel 发送当前消息与工具规格,将返回的 AiMessage 追加到 runtime 消息列表,再把停止原因和迭代计数写回 state。存在以下两个运行时提示机制:

  • 连续三轮没有 TODO 更新时,追加一次性的 [TODO-NAG] UserMessage,本轮调用完成后移除;它不会修改稳定的系统提示,也不会污染下一轮上下文。
  • Skill 激活时追加一次性 Skill 提醒,保证模型知道当前 Skill 的入口和工作目录。

节点会把符合规则的思考内容通过 thinking_start、thinking_content、thinking_end 事件投递;思考事件和最终答案是两类不同的可见事件,客户端不能把它们拼成同一段回答。

acting:解析、审批和分发工具 ​

节点遍历 ToolExecutionRequest,解析 JSON 参数后按以下顺序处理:

  1. 读取 ToolExecutionJournal。如果同一 toolCallId 已是 COMPLETED,直接复用结果;如果上次进程退出时状态为 STARTED 且工具有副作用,进入恢复审批,不自动重试。
  2. 检查 hitlResponse 是否针对当前 toolCallId 明确批准。
  3. 检查高风险参数。当前内置规则覆盖递归删除、DROP TABLE、TRUNCATE、强制 push、git reset --hard 等模式;命中时写入 hitlRequest 并在 human_review 前挂起。
  4. 对 TODO 工具更新 TaskTodoManager;其他工具先查内存 ToolResultStore,命中则记录为 cached 调用。
  5. 新工具调用先写 journal 的 STARTED,执行成功写 COMPLETED,异常写 FAILED,再把结果存入 ToolResultStore 并追加 ToolExecutionResultMessage。

内置工具的分发边界如下:

工具类别执行节点/服务
RAG_SEARCHRagNode
SKILL_CALLSkillCallNode
SUB_AGENT_CALL、SUBMIT_SUBAGENT_TASKSubAgentCallNode
CHECK_TASK_STATUSCheckTaskStatusNode
READ_TOOL_RESULTToolResultStore,支持按 ID 分页读取大结果
PUBLISH_FILEChatArtifactService
业务工具、Shell、Sandbox、VFSToolCallNode 及其执行器

工具结果同时存在三种表示:原始结果用于后续读取,截断摘要用于执行历史,ToolExecutionResultMessage 用于给模型提供上下文。不要把所有原始结果直接拼进下一轮 prompt,否则会加速触发 memory 节点。

refining:评估进度和重规划 ​

本轮工具结果会写成 [迭代N][已完成/失败] 历史条目,并更新前端任务进度。连续失败达到阈值前会触发一次重规划;重规划最多两次。重规划调用独立的 LLM 提示,输入失败原因、TODO、旧计划,输出新的计划文本并追加一条 [系统重规划] 消息。重规划耗尽且仍失败时置 loopSignal=FAILED。

TODO 没有更新时 roundsSinceTodoUpdate 递增,达到阈值后由下一轮 reasoning 注入催促。工具成功会清零连续失败计数;工具失败不会立即终止,以便在上限内修复参数、切换工具或重规划。

6. 上下文压缩和大结果读取 ​

LoopMemoryNode 同时处理 LLM 返回 LENGTH 和运行时 token 预警。按 token 使用率分档:

使用率策略
<50%NONE,不压缩
50% - 80%SOFT,保留最近三个工具结果全文,更早结果变为指针和约 200 字预览
80% - 95%MIDDLE,仅保留最近一个工具结果全文,其余改为占位信息
>=95%HARD,调用 LLM 对历史消息做摘要压缩

硬压缩最多两次。达到上限仍无法容纳上下文时,系统会尝试重规划或以失败终止,不能无限压缩同一份历史。大工具结果应让模型使用 READ_TOOL_RESULT 按 toolCallId 读取片段,而不是撤销压缩策略。

压缩会更新 contextCompressionCount、消息投影和执行事件。恢复时必须读取压缩后的 canonical messages;只恢复 executionHistoryText 会导致模型看到的上下文与日志不一致。

7. 人在回路(HITL)、暂停、恢复和取消 ​

工具审批 ​

高风险工具和“上次已开始但结果未知”的副作用工具都通过 hitlRequest 描述待审批动作,内容包含工具名、参数、toolCallId、触发原因和迭代编号。编译图使用 interruptBefore("human_review"),因此审批节点本身不会在无人工输入时执行。

当前 Controller 的 ResumeAgentRequest 只有 clientMessageId 和补充 message,没有单独的 approved 字段。普通工具审批由 LoopHumanReviewNode 在恢复时生成 approved=true,并从原 hitlRequest 带回 toolCallId;checkpoint 暂停恢复不会生成工具批准。节点清理请求后,普通工具审批回到 acting,checkpoint 暂停恢复回到 reasoning。审批不是重新提交一条普通聊天消息,必须走 dispatcher 的 resume 命令,才能保持 session 串行。

安全暂停 ​

/pause 只登记 PauseCoordinator 请求,不强行中断正在进行的 LLM 或工具调用。LoopCheckpointNode 在一轮工具结果完成、refining 之后消费请求,写入 checkpoint pause 类型的 hitlRequest;此时 ChatSession、Agent checkpoint 和 Sandbox 生命周期一起进入暂停流程。这样暂停不会留下“工具已执行但状态还没保存”的窗口。

恢复和取消 ​

/resume 进入同一 session 的单线程执行器,读取最新 checkpoint 后从下一个图节点继续。/cancel 写入 (sessionId, runVersion) 取消 tombstone,并中断活动 worker;已完成的工具结果和 session 历史仍然保留。旧 run 的迟到消息必须因 runVersion 不匹配而被丢弃,不能覆盖新运行状态。

8. 检查点、JSONL 与恢复校验 ​

当前会话持久化有三种模式:MIRROR、PRIMARY、JSONL_ONLY。JSONL 事件账本保存完整执行轨迹,传统会话表/缓存保存页面查询所需的投影,checkpoint 保存图状态和运行时可重建信息。

恢复前必须校验:

校验项目的
schemaVersion防止状态字段结构不兼容
graphVersion防止旧图的节点/路由被新图错误解释
toolsetHash防止工具 Schema、权限或工具集合已经改变
tenant、agent、session、branch防止跨租户、跨 Agent 或错误分支读取
runId、事件序号和 inbox cursor防止重复消费消息或覆盖新运行

SessionLeaseProvider 为恢复过程加租约,同一个 session 在当前 JVM 内同时只能有一个恢复者;当前实现的租约存放在 ConcurrentHashMap,多实例部署若需要跨节点互斥,必须替换为共享租约实现。checkpoint 文件按 session/run 组织并原子写入;路径必须通过存储层的安全路径检查,不能由请求参数直接拼接文件名。

恢复顺序应保持以下依赖关系:

text
读取 session header
  -> 获取 SessionLease
  -> 校验版本、租户、Agent、branch
  -> 读取最新 Agent checkpoint
  -> 恢复 TaskExecutionState 与 canonical messages
  -> 恢复 Sandbox/workspace 引用
  -> 重建 LoopRuntime 和工具规格
  -> 从 checkpoint 的 nextNode 继续图执行
  -> 释放 lease

如果版本或工具集校验失败,应进入 RECOVERY_BLOCKED 并要求人工处理或新建运行;不能静默降级为空状态重新执行,因为这可能重复外部副作用。

9. 调度器与流式事件的外层保证 ​

图的正确性依赖外层调度器提供顺序和输出通道:

  • CentralizedAgentMessageDispatcher 有一个取队列线程,再按 sessionId 路由到独占的单线程虚拟线程执行器。同一 session FIFO,不同 session 可以并行。
  • submitLocal 用于外部 IM 等目标保存在当前节点内存的场景,避免消息经过共享 Redis/RocketMQ 后找不到回复目标。
  • dispatcher 根据 Agent 类型选择 CHATBOT、GENERATION、TOOL_CALL 或 DOMAIN_EXPERT 实现;WORKFLOW 当前会明确抛出不支持异常。
  • active worker 记录线程、runVersion 和 runId,取消时通过 tombstone 和线程中断双重保护。

SseStreamHandler 的 token 事件按约 200ms 批量发送,同时发送节点、RAG、reference、thinking、structured block、错误和终态事件。终态处理先刷新可见事件和响应快照,再更新运行状态,以避免客户端先看到 COMPLETED 却收不到最后一段文本。普通聊天 SSE 断线时读取 pending 事件和回答快照;需要按游标重放 JSONL 的场景使用轨迹 SSE,并通过 afterSequence 或 Last-Event-ID,不能简单把重连后的全部 token 再拼接一次。

10. 扩展和排障不变量 ​

新增节点或工具时至少保持以下不变量:

  1. 每次状态更新都能被 serializer 和 checkpoint codec 表达。
  2. 工具有稳定的 toolCallId,副作用执行前后都写 ToolExecutionJournal。
  3. 可能改变外部世界的工具有明确的 side-effect level 和 HITL 策略。
  4. 节点失败会写执行轨迹和可读错误,不把异常转换为空结果。
  5. 新的终止路径设置 loopSignal,不会只依赖自然语言或 HTTP 返回码。
  6. 任何 pause/resume/cancel 操作都通过 session dispatcher 串行化。
  7. 新增可见事件时同时更新 SSE 客户端和历史轨迹读取方。

排查一个“任务卡住”问题时,按以下顺序定位:

text
run-status / runVersion
  -> dispatcher active worker
  -> 最近一个 graph node 和 checkpoint
  -> hitlPending / PauseCoordinator
  -> ToolExecutionJournal 状态
  -> ToolResultStore 与 runtimeMessages
  -> SSE channel、pending event 和 JSONL cursor

如果最后节点是 acting,先看 journal 是否为 STARTED;如果是 human_review,检查客户端是否提交了对应 toolCallId 的审批;如果是 memory,检查压缩次数和模型上下文限制;如果只有服务端已完成而客户端无内容,检查 SSE 快照刷新和断线补发,而不要重复启动 Agent。