文档目的:增强 AGENTIC 模式下 Planner 的重访决策能力,让"用户在节点输出中表达新需求""编译/校验失败需要重做""业务需要重新执行节点"等场景能被 Planner 正确识别并主动跳回前序节点,而不是机械地走向 output 终结。
改造范围:
- 后端:
PlannerServiceprompt 重构 +AgenticExecutor死循环防护策略调整- 不动 clarify 通道、不动 WorkflowPause 表、不动节点执行器
状态:待用户确认
关联文档:
docs/plans/agentic-workflow-plan.md(AGENTIC 模式原始实施计划,已实施完成)
我执行了"自由跳转测试"工作流。我在智能操作节点的 clarify 工具调用时,表达我还有新需求。我本意是想让 planner 在节点运行结束后再次执行节点,处理我新增的需求。但 planner 直接选择了结束。
clarify 是 Hermes Agent (Python Bridge) 内部 LLM loop 的事件:
clarify 工具clarify_request → HermesBridgeClient.java:442-464 转发前端 + ClarifyHooks.java:40-84 落库final_responsefinal_response 作为节点 envelope.data → 进入 currentVars(带 {nodeId}__ 前缀)关键事实:用户在 clarify 中表达的新需求,最终会出现在节点的 final_response 中。Hermes Agent 本身的 system prompt 会让它把"用户表达的新需求"作为 output 的一部分回传(如"用户有新的需求:XXX")。
结论:无需新增 clarify → Planner 通道。Planner 只需正确解读节点 output 即可。
来自 PlannerService.java:120-187 buildPrompt:
| 阻碍 | 位置 | 表现 |
|---|---|---|
| 决策规则 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 中"用户新需求"详细描述可能被截断 |
按"触发主体"分类:
| 类别 | 场景 | 是否本次范围 |
|---|---|---|
| A. 用户主动表达 | A1:节点 output 中含"用户新需求"(本次核心) | ✅ 本次 |
| A2:userInput 节点多轮交互(AGENTIC 已天然支持) | 已支持 | |
| B. 业务回退 | B1:编译/校验失败,需要回到前序节点重新生成 | ✅ 本次(不区分对待) |
| B2:依赖缺失,回前序节点补全 | ✅ 本次(不区分对待) | |
| C. 节点自声明信号 | C1:节点声明 requestRevisit=true |
后续扩展(本次不做) |
| D. Planner 自主判断 | D1:Planner 看 currentVars 空字段主动回 userInput | ✅ 本次(顺带受益) |
| E. 外部信号 | E1:定时器、人工干预 | ❌ 超出范围 |
本次设计原则:所有"业务驱动的重走"统一视为合法行为,与"网络重试(maxRetries)"严格分离。
位置:PlannerService.java:177-179(buildPrompt 中的规则段)
当前规则:
3. 避免在同一节点上反复横跳(已访问次数多的节点优先级降低)
改为:
3. 重访已执行过的节点是允许的,但必须基于具体理由。典型合理理由:
- 上一节点 output 中出现"用户新需求""需要修正""请重做"等明确信号
- 编译/校验失败,需要重新生成
- 依赖数据缺失,需要补全
无理由的反复横跳(A→B→A→B→A...)才被压制。
配套:在 prompt 中增加"上一节点 output 摘要"段(独立于 currentVars 预览),让 Planner 直接看到当前节点产出,无需从 currentVars 反推。
位置: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 上下文窗口)。
AGENTIC 模式下"节点再次执行"有两类完全不同的语义:
| 类型 | 触发主体 | 触发原因 | 计数策略 | abort 行为 |
|---|---|---|---|---|
| retry(失败重试) | 节点执行器内部 | attempt 内抛异常 / 业务返回 FAILED | 计入 attempt(已有,单 visit 内 maxRetries 上限) |
attempt 用尽后整个 visit 标记 FAILED,交 Planner 决策 |
| revisit(计划重走) | Planner LLM | 节点成功完成后,Planner 主动决策"再来一次" | 不计入任何 abort 计数器;属于"计划的一部分" | 不触发 abort |
关键洞察(用户原话):
如果是重试,增加重试次数;如果是重走,不仅不应该增加重试次数,反而应该清零,因为这是计划的一部分。
典型合法 revisit 场景:
这些场景下,visit 链中同节点出现 N 次是正常的,不应该被 abort。
放弃"同节点访问次数"作为死循环指标,改用多维防护:
| 防护层 | 触发条件 | 行为 | 备注 |
|---|---|---|---|
| L1 总量兜底 | sortOrder >= MAX_VISITS=50 |
abort | 绝对兜底,任何场景都生效 |
| L2 单 visit 重试 | attempt > maxRetries(默认 3) |
visit 标记 FAILED,交 Planner | 不变,沿用现有逻辑 |
| L3 无进展检测(可选增强) | 同节点连续 3 次访问,且 currentVars hash 完全相同 |
abort | 本次不做,留作后续扩展 |
为什么 L1 单独够用:
为什么不做 L3:
位置:AgenticExecutor.java:55-56 + 122-130
删除:
// 第 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 表用于前端展示)注释更新:
// nodeVisitCounter 仅用于 iterSeq 计算与 Planner prompt 信息展示
// 不再作为死循环 abort 依据 —— 业务重走(Planner 主动决策)是合法行为
// 死循环防护改由 MAX_VISITS=50 总量兜底
// 详见 docs/plans/agentic-revisit-decision-plan.md §4.3
位置:PlannerService.java:120-187 buildPrompt
新增段落(在 currentVars 预览之前):
上一节点({currentNodeId})的输出:
{effectiveResult.output 截断到 800 字符}
工作区变量(部分,按访问顺序累积,同名变量最近覆盖):
{currentVars 预览}
实现:AgenticExecutor.java:228(Planner 调用前)把 effectiveResult.getOutput() 传给 PlannerService。
理由:
{nodeId}__ 前缀,认知负担重| 步骤 | 文件 | 行号 | 内容 |
|---|---|---|---|
| 1 | PlannerService.java |
31 | MAX_VALUE_LEN = 200 → 500 |
| 2 | PlannerService.java |
57 | decideNext 签名加 Map<String,Object> 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+ 次仍正常运行(验证业务重走合法) |
| 风险 | 等级 | 缓解 |
|---|---|---|
| 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) |
| 文件 | 用例 | 期望 |
|---|---|---|
| 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 逻辑不变) |
执行"自由跳转测试"工作流:
构造一个 ReAct 风格工作流,强制 Planner 在同节点重走 15+ 次:
| 维度 | 初版方案(已撤销) | 二版方案(已撤销) | 最终方案 |
|---|---|---|---|
| 信号通道 | 新增 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 + 对应测试) |
__control__,节点 LLM 主动声明 requestRevisit=true,Planner 直接消费等待用户确认是否按本最终方案推进实施。