# AGENTIC 模式 Planner 重访决策增强计划 > **文档目的**:增强 AGENTIC 模式下 Planner 的重访决策能力,让"用户在节点输出中表达新需求""编译/校验失败需要重做""业务需要重新执行节点"等场景能被 Planner 正确识别并主动跳回前序节点,而不是机械地走向 output 终结。 > > **改造范围**: > - 后端:`PlannerService` prompt 重构 + `AgenticExecutor` 死循环防护策略调整 > - 不动 clarify 通道、不动 WorkflowPause 表、不动节点执行器 > > **状态**:待用户确认 > > **关联文档**:`docs/plans/agentic-workflow-plan.md`(AGENTIC 模式原始实施计划,已实施完成) --- ## 1. 用户需求复述 ### 1.1 触发场景(用户原话) > 我执行了"自由跳转测试"工作流。我在智能操作节点的 clarify 工具调用时,表达我还有新需求。我本意是想让 planner 在节点运行结束后再次执行节点,处理我新增的需求。但 planner 直接选择了结束。 ### 1.2 现象 - 用户在智能操作节点(HermesSmartActionExecutor / Hermes Agent 内部)通过 clarify 工具表达"还有新需求" - Hermes Agent 接收 answer,继续内部 LLM loop,**最终 final_response 中包含"用户有新的需求:XXX"字样** - 节点 output 进入 currentVars - Planner 决策时看不到 clarify Q&A 过程,但能看到节点 output - 结果:Planner 选择 output 节点 → 运行结束,新需求未被处理 ### 1.3 用户反馈(修订要点) 1. **Planner 不需要看 clarify 过程**,节点 final_response 已包含"用户有新的需求:XXX"等信号,让 Planner 看节点 output 即可 2. **重走 ≠ 重试**:Planner 主动决策的重走(如新需求、编译失败回退)是合法的运行,不应让"重试次数"增加,也不应触发死循环兜底 3. **弱化规则 3**:"避免在同一节点上反复横跳(已访问次数多的节点优先级降低)"过于压制合理重访 --- ## 2. 现状梳理(根因 + 链路) ### 2.1 clarify 真实链路(说明为何不需关注) clarify 是 **Hermes Agent (Python Bridge) 内部 LLM loop 的事件**: 1. Hermes Agent 内部 LLM 决定调 `clarify` 工具 2. Python Bridge 推送 `clarify_request` → **HermesBridgeClient.java:442-464** 转发前端 + **ClarifyHooks.java:40-84** 落库 3. 用户提交答案 → **HermesBridgeClient.java:599 submitClarifyAnswer** → Bridge → 唤醒 Hermes Agent loop 4. Hermes Agent 继续 loop,最终产出 `final_response` 5. `final_response` 作为节点 envelope.data → 进入 `currentVars`(带 `{nodeId}__` 前缀) **关键事实**:用户在 clarify 中表达的新需求,**最终会出现在节点的 final_response 中**。Hermes Agent 本身的 system prompt 会让它把"用户表达的新需求"作为 output 的一部分回传(如"用户有新的需求:XXX")。 **结论**:无需新增 clarify → Planner 通道。Planner 只需正确解读节点 output 即可。 ### 2.2 Planner 当前感知的上下文 来自 **PlannerService.java:120-187 buildPrompt**: - 当前节点 id + 已访问次数 - failureContext(仅 lastFailed=true 时附加) - currentVars 预览:Top-30 条,**每条值截断到 MAX_VALUE_LEN=200 字符**(**PlannerService.java:31**) - 可访问节点清单(id / type / label / 已访问次数) ### 2.3 当前阻碍重访的三道墙 | 阻碍 | 位置 | 表现 | |------|------|------| | 决策规则 3 | **PlannerService.java:177-179** | "避免在同一节点上反复横跳(已访问次数多的节点优先级降低)" — 直接在 prompt 层压制重访 | | 死循环兜底 | **AgenticExecutor.java:54-56 + 122-130** | `MAX_SAME_NODE_REVISITS=5`:同节点累计访问 6 次直接 abort,**不区分业务重走 vs 无意义横跳** | | 节点 output 截断 | **PlannerService.java:31** | `MAX_VALUE_LEN=200`:节点 final_response 中"用户新需求"详细描述可能被截断 | ### 2.4 根因总结 1. **Planner prompt 规则 3** 在语义上压制重访,Planner 即便看到"用户新需求"也不敢重走 2. **MAX_SAME_NODE_REVISITS=5** 把"业务重走"和"无意义横跳"一锅端,开发者担心死循环就把阈值设得太严 3. **节点 output 截断到 200 字符**可能丢关键信号 --- ## 3. 跳回前序节点的典型场景(用户确认已基本覆盖) 按"触发主体"分类: | 类别 | 场景 | 是否本次范围 | |------|------|--------| | **A. 用户主动表达** | A1:节点 output 中含"用户新需求"(**本次核心**) | ✅ 本次 | | | A2:userInput 节点多轮交互(AGENTIC 已天然支持) | 已支持 | | **B. 业务回退** | B1:编译/校验失败,需要回到前序节点重新生成 | ✅ 本次(不区分对待) | | | B2:依赖缺失,回前序节点补全 | ✅ 本次(不区分对待) | | **C. 节点自声明信号** | C1:节点声明 `requestRevisit=true` | 后续扩展(本次不做) | | **D. Planner 自主判断** | D1:Planner 看 currentVars 空字段主动回 userInput | ✅ 本次(顺带受益) | | **E. 外部信号** | E1:定时器、人工干预 | ❌ 超出范围 | **本次设计原则**:所有"业务驱动的重走"统一视为合法行为,与"网络重试(maxRetries)"严格分离。 --- ## 4. 最终方案 ### 4.1 改动一:弱化 Planner prompt 规则 3 **位置**:`PlannerService.java:177-179`(buildPrompt 中的规则段) **当前规则**: ``` 3. 避免在同一节点上反复横跳(已访问次数多的节点优先级降低) ``` **改为**: ``` 3. 重访已执行过的节点是允许的,但必须基于具体理由。典型合理理由: - 上一节点 output 中出现"用户新需求""需要修正""请重做"等明确信号 - 编译/校验失败,需要重新生成 - 依赖数据缺失,需要补全 无理由的反复横跳(A→B→A→B→A...)才被压制。 ``` **配套**:在 prompt 中增加"上一节点 output 摘要"段(独立于 currentVars 预览),让 Planner 直接看到当前节点产出,无需从 currentVars 反推。 ### 4.2 改动二:放宽 MAX_VALUE_LEN **位置**:`PlannerService.java:31` **当前**:`private static final int MAX_VALUE_LEN = 200;` **改为**:`private static final int MAX_VALUE_LEN = 500;` **理由**:节点 final_response 中"用户新需求"详细描述常超过 200 字符,500 字符能覆盖大部分场景,对 Planner token 占用也可接受(30 * 500 = 15k 上限,远低于现代 LLM 上下文窗口)。 ### 4.3 改动三:语义重构 —— 区分 retry vs revisit,**移除 MAX_SAME_NODE_REVISITS 硬 abort** #### 4.3.1 语义模型 AGENTIC 模式下"节点再次执行"有两类完全不同的语义: | 类型 | 触发主体 | 触发原因 | 计数策略 | abort 行为 | |------|---------|---------|---------|-----------| | **retry(失败重试)** | 节点执行器内部 | attempt 内抛异常 / 业务返回 FAILED | 计入 `attempt`(已有,单 visit 内 maxRetries 上限) | attempt 用尽后整个 visit 标记 FAILED,交 Planner 决策 | | **revisit(计划重走)** | Planner LLM | 节点成功完成后,Planner 主动决策"再来一次" | **不计入任何 abort 计数器**;属于"计划的一部分" | **不触发 abort** | **关键洞察(用户原话)**: > 如果是重试,增加重试次数;如果是重走,不仅不应该增加重试次数,反而应该清零,因为这是计划的一部分。 **典型合法 revisit 场景**: - **ReAct 模式**:根据上一节点 output 调整实现,可能需要 20+ 次重走,每次都是"计划的一部分" - **用户新需求**:clarify 中用户追加需求,节点 output 标记"用户有新的需求",Planner 决策重走 - **编译/校验失败回退**:节点 A 产出不合格,回到 A 重新生成 - **依赖补全**:B 节点发现 A 的产出缺失字段,回 A 补全 这些场景下,visit 链中同节点出现 N 次是**正常**的,不应该被 abort。 #### 4.3.2 死循环防护策略 放弃"同节点访问次数"作为死循环指标,改用多维防护: | 防护层 | 触发条件 | 行为 | 备注 | |--------|---------|------|------| | **L1 总量兜底** | `sortOrder >= MAX_VISITS=50` | abort | 绝对兜底,任何场景都生效 | | **L2 单 visit 重试** | `attempt > maxRetries`(默认 3) | visit 标记 FAILED,交 Planner | 不变,沿用现有逻辑 | | **L3 无进展检测**(可选增强) | 同节点连续 3 次访问,且 `currentVars` hash 完全相同 | abort | **本次不做**,留作后续扩展 | **为什么 L1 单独够用**: - 真正的死循环(A↔B 反复横跳无变化)会快速耗满 50 次总量 - 合法的 ReAct 模式虽然多次重走单节点,但总量仍在 50 内 - 50 次的上限对单 run 的 token 消耗是可接受的硬约束 **为什么不做 L3**: - 检测"无进展"需要维护 visit signature 历史,增加复杂度 - "无进展"的语义边界模糊(部分场景下相同 input + 不同 output 也是合法的) - 用户原意是"重走即合法",L3 与原意存在张力 - 留作后续扩展点(基于实际运行数据再决定是否需要) #### 4.3.3 具体改动 **位置**:`AgenticExecutor.java:55-56 + 122-130` **删除**: ```java // 第 55-56 行 /** 同节点连续访问 5 次仍找不到 Output,强制终止 */ private static final int MAX_SAME_NODE_REVISITS = 5; // 第 122-130 行 int revisits = nodeVisitCounter.merge(currentNodeId, 1, Integer::sum); if (revisits > MAX_SAME_NODE_REVISITS) { String msg = "节点 " + currentNodeId + " 连续访问超过 " + MAX_SAME_NODE_REVISITS + " 次,疑似死循环"; log.warn("[Agentic] runId={} {}", runId, msg); return abort(context, emitter, runId, msg, sortOrder, currentNodeId, fromVisitUuid, fromNodeId, currentVars); } ``` **保留**: - `nodeVisitCounter` 仍维护,但仅用于: - `iterSeq` 字段(同节点第几次访问,写入 Visit 表用于前端展示) - Planner prompt 中"已访问 N 次"信息展示(让 Planner 自主判断) - **不再作为 abort 依据** **注释更新**: ```java // nodeVisitCounter 仅用于 iterSeq 计算与 Planner prompt 信息展示 // 不再作为死循环 abort 依据 —— 业务重走(Planner 主动决策)是合法行为 // 死循环防护改由 MAX_VISITS=50 总量兜底 // 详见 docs/plans/agentic-revisit-decision-plan.md §4.3 ``` ### 4.4 改动四:Planner prompt 增加"上一节点 output 摘要"段 **位置**:`PlannerService.java:120-187 buildPrompt` **新增段落**(在 currentVars 预览之前): ``` 上一节点({currentNodeId})的输出: {effectiveResult.output 截断到 800 字符} 工作区变量(部分,按访问顺序累积,同名变量最近覆盖): {currentVars 预览} ``` **实现**:`AgenticExecutor.java:228`(Planner 调用前)把 `effectiveResult.getOutput()` 传给 PlannerService。 **理由**: - Planner 通过 currentVars 反推"上一节点产出"需要先 strip `{nodeId}__` 前缀,认知负担重 - 独立段落直接展示原 output,更易识别"用户新需求"等信号 - 800 字符足以覆盖大部分 final_response(截断阈值与 MAX_VALUE_LEN 解耦,因为 output 是核心信号) ### 4.5 不改动的部分(明确列出) - ❌ WorkflowPause 表结构不动(不加 answer_text 字段) - ❌ WorkflowController.resume 不动 - ❌ HermesBridgeClient.submitClarifyAnswer 不动 - ❌ ClarifyHooks 不动 - ❌ 节点执行器不动(SmartAction / HermesAgent 等) - ❌ 前端不动 --- ## 5. 实施步骤 | 步骤 | 文件 | 行号 | 内容 | |------|------|------|------| | 1 | `PlannerService.java` | 31 | `MAX_VALUE_LEN = 200` → `500` | | 2 | `PlannerService.java` | 57 | decideNext 签名加 `Map lastNodeOutput` 参数 | | 3 | `PlannerService.java` | 120-187 | buildPrompt 加"上一节点 output 摘要"段(截断到 800 字符) | | 4 | `PlannerService.java` | 177-179 | 规则 3 改写为"基于理由判断"版本 | | 5 | `AgenticExecutor.java` | 55-56 | **删除** `MAX_SAME_NODE_REVISITS` 常量 | | 6 | `AgenticExecutor.java` | 122-130 | **删除** 同节点访问次数 abort 兜底逻辑;保留 `nodeVisitCounter` 用于 iterSeq 与 Planner prompt | | 7 | `AgenticExecutor.java` | 228-237 | Planner 调用前传入 `effectiveResult.getOutput()` | | 8 | `PlannerServiceTest.java` | - | 加测试:lastNodeOutput 包含"用户新需求"时 prompt 含该文本 | | 9 | `AgenticExecutorTest.java` | - | 更新原"死循环兜底"用例:移除 MAX_SAME_NODE_REVISITS 相关断言;保留 MAX_VISITS=50 兜底用例 | | 10 | `AgenticExecutorTest.java` | - | 加测试:同节点连续 revisit 10+ 次仍正常运行(验证业务重走合法) | --- ## 6. 风险评估 | 风险 | 等级 | 缓解 | |------|------|------| | Planner LLM 仍选择 output(不理解"新需求"信号) | 中 | prompt 中给出明确示例;规则 3 已改写不压制重访;MAX_VISITS=50 终极兜底 | | 移除 MAX_SAME_NODE_REVISITS 后真正死循环耗满 50 次浪费 token | 中 | L1 总量兜底仍生效;实际运行中真死循环很少见;可基于运行数据后续加 L3 无进展检测 | | ReAct 模式下 token 消耗显著上升 | 中-高 | 设计预期内的成本,由用户主动选择;可后续加 run 级 token 上限 | | Planner 理解错信号,无意义重访拉爆 token | 低-中 | 规则 3 改写后仍保留"无理由横跳压制"语义;reason 中要求给出理由 | | 800 字符 output 摘要仍不够 | 低 | 实际场景中"用户新需求"通常在 output 开头,800 字符足够 | | lastNodeOutput 为 null(节点失败时) | 低 | PlannerService 端 null 检查,段落降级为"(节点无输出)" | | 历史已运行的 Visit 不受影响 | 无 | 本次只改 Planner 决策与上限阈值,Visit 数据结构不变 | | 移除常量后老测试用例失败 | 低 | 实施时同步更新 AgenticExecutorTest 中 MAX_SAME_NODE_REVISITS 相关用例(步骤 9) | --- ## 7. 测试用例(验证目标) ### 7.1 单元测试 | 文件 | 用例 | 期望 | |------|------|------| | PlannerServiceTest | lastNodeOutput 含"用户新需求:补充 XXX" 时 | prompt 文本含该字符串;规则 3 文本已更新 | | PlannerServiceTest | lastNodeOutput 为 null 时 | prompt 段落降级为"(节点无输出)",不抛异常 | | PlannerServiceTest | MAX_VALUE_LEN=500 截断验证 | 超过 500 字符的 var value 被截断到 500 + "..." | | AgenticExecutorTest | 同节点连续 revisit 10+ 次(每次 Planner 都返回同节点) | **运行正常**,不 abort(验证业务重走合法) | | AgenticExecutorTest | 总 visit 数达到 50 次 | abort,errorMsg 含"超过最大访问次数"(MAX_VISITS 兜底仍生效) | | AgenticExecutorTest | 单 visit 内 attempt 用尽 | visit 标记 FAILED,Planner 接管(retry 逻辑不变) | ### 7.2 手工验证(用户操作) 执行"自由跳转测试"工作流: 1. 在智能操作节点的 clarify 中输入"还有新需求:补充 YYY" 2. Hermes Agent 完成节点,final_response 应含"用户有新的需求:补充 YYY" 3. 观察后端日志:Planner prompt 应含上一节点 output 摘要 + 规则 3 新版本 4. 期望 Planner 决策为重访该智能操作节点 5. 第二次执行该节点处理"补充 YYY" 6. 最终到 output 节点结束 ### 7.3 边界验证(可选) 构造一个 ReAct 风格工作流,强制 Planner 在同节点重走 15+ 次: - 期望:运行正常完成,不触发任何 abort - 验证:nodeVisitCounter 不再作为 abort 依据 --- ## 8. 与原计划的差异(修订点对比) | 维度 | 初版方案(已撤销) | 二版方案(已撤销) | **最终方案** | |------|---------|---------|---------| | 信号通道 | 新增 clarify Q&A 注入 Planner | 同左 | **删除**。Planner 看节点 output 即可 | | WorkflowPause 表 | 加 `answer_text` 字段 | 同左 | **不动** | | WorkflowController.resume | 写入 answer_text | 同左 | **不动** | | Planner prompt 规则 3 | 改为"用户意图豁免" | 改为"基于理由判断" | 同二版 | | MAX_VALUE_LEN | 200 不变 | 200 → 500 | 同二版 | | MAX_SAME_NODE_REVISITS | 5 不变 | **5 → 10**(放宽) | **完全移除**该常量与 abort 逻辑 | | retry vs revisit 语义区分 | 无 | 无 | **新增**:retry 计 attempt 内;revisit 不计入任何 abort 指标 | | 新增"上一节点 output 摘要"段 | 无 | 新增(800 字符) | 同二版 | | 死循环防护层 | L1 + L2 + MAX_SAME_NODE_REVISITS | 同左 | **L1(MAX_VISITS=50)+ L2(maxRetries)**;L3 无进展检测留作扩展 | | 改动文件数 | 6 个 | 3 个 | **3 个**(PlannerService + AgenticExecutor + 对应测试) | --- ## 9. 后续扩展点(不在本次范围) 1. **节点声明 control 信号**:envelope.data 增加可选字段 `__control__`,节点 LLM 主动声明 `requestRevisit=true`,Planner 直接消费 2. **Planner reason 监控**:累计统计每次 Planner 决策的 reason,识别"业务重走"模式用于优化 prompt 3. **MAX_SAME_NODE_REVISITS 自适应**:按节点类型差异化(如 llm 节点容忍度高、skill 节点容忍度低) 4. **重走原因可视化**:前端 Visit 卡片展示 Planner 决策原因(已持久化在 decisionJson,前端可读) --- ## 10. 待用户确认 - **Q1**:MAX_VALUE_LEN 从 200 放宽到 **500**,是否同意? - **Q2**:**完全移除** MAX_SAME_NODE_REVISITS 常量与 abort 逻辑(不再用同节点访问次数作为死循环指标),死循环仅靠 MAX_VISITS=50 总量兜底,是否同意? - **Q3**:新增"上一节点 output 摘要"段(截断 800 字符),是否同意? - **Q4**:规则 3 改写为"基于理由判断"版本,是否同意? - **Q5**:本次完全不动 clarify 通道 / WorkflowPause 表 / 节点执行器,是否同意? - **Q6**:L3 无进展检测(同节点连续 3 次访问且 currentVars hash 相同 → abort)作为后续扩展,本次不做,是否同意? --- **等待用户确认是否按本最终方案推进实施。**