Appearance
智能体执行引擎:从规划到可恢复循环
本文按 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.java1. 两层状态图
自主规划 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_end3. 子图拓扑与路由
AgenticTaskExecutionNode.buildGraph() 组装以下节点。所有节点都用 TrackedNodeAction 包装,因此节点开始、完成、迭代编号和异常会进入执行轨迹。
推理路由
LoopReasoningNode 把 LLM 返回的 FinishReason 写入状态,routeAfterReasoning 再决定下一跳:
FinishReason | 下一节点 | 含义 |
|---|---|---|
TOOL_EXECUTION | acting | AiMessage 中包含一个或多个工具请求 |
LENGTH | memory | 上下文或输出达到模型长度限制,先压缩再重试 |
STOP | END | 模型给出最终答案,循环自然结束 |
| 其他或空值 | END | 安全终止并记录未预期停止原因 |
STOP 时会把文本写入 lastIterationResult,并将 loopSignal 置为 DONE。因此终止判断依赖结构化状态,不依赖解析“已完成”之类的自然语言。
评估路由
LoopRefiningNode 在工具执行后更新失败计数、执行历史、TODO 进度和重规划次数。routeAfterRefining 的优先级如下:
loopSignal为DONE或FAILED时结束。iterationCount >= max(maxTaskIterations, 100)时强制结束,防止配置过小或异常状态造成死循环。hitlPending=true时进入human_review。- 其他情况回到
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:
- checkpoint 只保存可重建的配置和消息投影。
- 恢复时
LoopInitNode.restoreRuntime根据状态重新创建模型、工具和工作区。 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 参数后按以下顺序处理:
- 读取
ToolExecutionJournal。如果同一toolCallId已是COMPLETED,直接复用结果;如果上次进程退出时状态为STARTED且工具有副作用,进入恢复审批,不自动重试。 - 检查
hitlResponse是否针对当前toolCallId明确批准。 - 检查高风险参数。当前内置规则覆盖递归删除、
DROP TABLE、TRUNCATE、强制 push、git reset --hard等模式;命中时写入hitlRequest并在human_review前挂起。 - 对 TODO 工具更新
TaskTodoManager;其他工具先查内存ToolResultStore,命中则记录为 cached 调用。 - 新工具调用先写 journal 的
STARTED,执行成功写COMPLETED,异常写FAILED,再把结果存入ToolResultStore并追加ToolExecutionResultMessage。
内置工具的分发边界如下:
| 工具类别 | 执行节点/服务 |
|---|---|
RAG_SEARCH | RagNode |
SKILL_CALL | SkillCallNode |
SUB_AGENT_CALL、SUBMIT_SUBAGENT_TASK | SubAgentCallNode |
CHECK_TASK_STATUS | CheckTaskStatusNode |
READ_TOOL_RESULT | ToolResultStore,支持按 ID 分页读取大结果 |
PUBLISH_FILE | ChatArtifactService |
| 业务工具、Shell、Sandbox、VFS | ToolCallNode 及其执行器 |
工具结果同时存在三种表示:原始结果用于后续读取,截断摘要用于执行历史,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. 扩展和排障不变量
新增节点或工具时至少保持以下不变量:
- 每次状态更新都能被 serializer 和 checkpoint codec 表达。
- 工具有稳定的
toolCallId,副作用执行前后都写ToolExecutionJournal。 - 可能改变外部世界的工具有明确的 side-effect level 和 HITL 策略。
- 节点失败会写执行轨迹和可读错误,不把异常转换为空结果。
- 新的终止路径设置
loopSignal,不会只依赖自然语言或 HTTP 返回码。 - 任何 pause/resume/cancel 操作都通过 session dispatcher 串行化。
- 新增可见事件时同时更新 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。