# 工作流节点输出严格校验与重试策略分离实施计划 > **文档目的**:解决两个生产反馈问题——(1) LLM 返回中文字段名未被拒绝;(2) 重试未区分"运行失败"与"格式失败",导致不必要的全量重跑。 > > **改造范围**: > - `backend/src/main/java/com/agent/management/engine/OutputValidator.java` > - `backend/src/main/java/com/agent/management/engine/StructuredOutputHelper.java` > - `backend/src/main/java/com/agent/management/engine/RetryPolicy.java` > - `backend/src/main/java/com/agent/management/config/WorkflowProperties.java` > - `backend/src/main/resources/application.yml.example`(仅配置段) > - 新增 2 个单元测试类 > > **状态**:待实施 ## 1. 问题确认 ### 1.1 用户反馈 ``` 1. 部分节点的输出,输出为 Json 格式,但与我定义的字段名不一致。我的定义: {"status": 200/400, "message": "...", "data": {"fieldA": ...}} 它的输出: {"状态": "成功", "信息": "XXXX成功", "数据": {"字段A": ...}} 为什么 validator 没有将这种格式认定为非法,并让该节点重试,重新生成? 2. 如果某个节点输出的格式未通过校验,不应重新运行节点逻辑。应分两种情况: (1) 运行失败(如"模型访问量过大")→ 重新运行节点 (2) 运行成功但输出结构错误 → 把 JSON 模板 + 上次响应回喂给 LLM,仅重组 JSON ``` ### 1.2 用户进一步约束 > 除"用户输入"节点和"条件分支"节点外,其他节点的用户定义输出均为必填,需严格满足每个字段均存在,键名严格校验。 ### 1.3 根因定位 | 问题 | 代码位置 | 根因 | |---|---|---| | 中文键名未拒绝 | `OutputValidator.java:79-103` | 仅用英文 `name` 在 `parsed` 中 `get(name)`,找不到就视为 null,但默认 `retryOnMissingRequired=true` 时虽会重试,**重试 prompt 未明确告诉 LLM "你的字段名错了"**——只在 instruction 末尾追加 lastError,LLM 可能仍按中文返回 | | 中文键名被解析为 null | `StructuredOutputHelper.java:213-223` | `parse()` 用英文 `name` 从 LLM JSON 中 `parsed.get(name)`,中文响应 → 全部 null | | 未区分失败类型 | `StructuredOutputHelper.java:94-138` | 单层 for 循环,每次都重建完整 prompt 调用 LLM,**格式失败也会重新触发完整推理**(浪费 token、放大限流概率) | | RetryPolicy 单计数 | `RetryPolicy.java:23-26` | 仅有 `maxRetries`,无 runtime/format 区分 | ### 1.4 关键观察 所有需要结构化校验的执行器(`LlmExecutor`、`SmartActionExecutor`、`HermesAgentExecutor`、`HermesSmartActionExecutor` 等)都通过 `StructuredOutputHelper.extractWithRetry` 调用,因此**只需改造 `extractWithRetry` 一处,所有节点自动受益**。 `userInput` 节点不调用 `extractWithRetry`(用户直接填值),`condition` 节点仅输出 `selectedBranch`,二者天然不受影响。 ## 2. 改造目标 ### 2.1 行为目标 | 场景 | 当前行为 | 目标行为 | |---|---|---| | LLM 返回英文字段名齐全 | ✅ 通过 | ✅ 通过 | | LLM 返回中文字段名 | ❌ 解析为 null,silent 通过(retryOnMissingRequired 默认 false 时) | ❌ 拒绝,进入内层重试 | | LLM 运行异常(限流/超时) | 整个节点重试 | **外层重试**(重新完整推理) | | LLM 运行成功但 JSON 结构错 | 整个节点重试 | **内层重试**(仅重组 JSON,不重跑逻辑) | | 内层重试耗尽 | N/A | 升级为外层重试 | | 外层重试耗尽 | 返回失败 envelope | 返回失败 envelope(保持不变) | ### 2.2 字段必填规则(按用户约束) | 节点类型 | 校验策略 | |---|---| | `userInput` | 保持原有 required/optional 语义(不走校验器) | | `condition` | 仅输出 `selectedBranch`,不需要 envelope data 校验 | | **其他节点**(llm / agent / smartAction / hermesAgent / hermesSmartAction / knowledgeBase / output) | **所有声明字段视为必填**,严格键名校验(精确匹配,区分大小写、区分分隔符) | ### 2.3 必须保留的语义 | 编号 | 现有行为 | 保留方式 | |---|---|---| | S1 | `NodeOutputEnvelope` 三段式结构(status/message/data) | 不动 | | S2 | 类型自动转换(number/boolean/string) | 不动,仍由 OutputValidator 完成静默转换 | | S3 | `extractWithRetry` 对外接口签名 | 保持不变(内部实现重写) | | S4 | `RetryPolicy.fromNodeData` 节点级覆盖全局 | 拆分后保留覆盖语义 | | S5 | 所有执行器调用方式(LlmExecutor/SmartActionExecutor/...) | 零改动 | ## 3. 设计选型 ### 方案 A:OutputValidator 新增 strict 模式(**已选**) 新增重载方法 `validate(parsed, outputsDecl, strictAllRequired)`: - `strictAllRequired = false`:保持现有行为(按 `decl.required` 字段判定) - `strictAllRequired = true`:所有声明字段视为必填,且每个字段必须在 parsed 中**精确存在** 精确存在的定义:`parsed.containsKey(name) && parsed.get(name) != null && !isBlankString(parsed.get(name))` 中文同义字段(如 `状态` vs `status`)不会通过 `containsKey("status")`,自动落入 missing 列表。 **优点**:改动聚焦、向后兼容、不引入新的字段名归一化逻辑(保持精确匹配符合用户"键名严格校验"要求)。 ### 方案 B:模糊匹配 + 字段名映射表(未选) 引入"中英文同义词典",LLM 返回 `状态` 时映射到 `status`。 **否决理由**:违反用户"键名严格校验"的要求;维护同义词典成本高;LLM 偶尔返回的中英文混杂会难以处理。 ### 方案 C:JSON Schema 校验(未选) 用 strict JSON Schema 强制 LLM 输出格式。 **否决理由**:依赖 Spring AI 的 `entity(Class)` 或 `toolCalling` 机制,改动面大;目前 `extractWithRetry` 是字符串 prompt + 解析的方式,改造成本太高;用户需要的是"出错时让 LLM 修正",而非"硬约束 LLM"。 ## 4. 实施阶段 ### 阶段 1:OutputValidator 严格键名校验 **目标**:新增 strict 模式重载,对声明字段执行精确键存在校验。 **改动点**: - `OutputValidator.java`: - 新增重载 `validate(Map parsed, JsonNode outputsDecl, boolean strictAllRequired)` - 旧方法 `validate(parsed, outputsDecl)` 内部转调 `validate(parsed, outputsDecl, false)` - `strictAllRequired = true` 时: - 遍历每个声明字段 - 若 `!parsed.containsKey(name)` → 加入 `missingRequired` 并标注 "键名缺失" - 若 `parsed.get(name) == null` → 加入 `missingRequired` 并标注 "值为 null" - 若值存在 → 走类型校验(与现有逻辑一致) - 错误信息细化:`missingRequired` 元素从 `"fieldName"` 升级为 `"fieldName (键名缺失)"` 或 `"fieldName (值为空)"`,便于 LLM 在重试时精准修正 **接口签名**: ```java public static ValidationResult validate( Map parsed, JsonNode outputsDecl, boolean strictAllRequired ); // 旧入口保留 public static ValidationResult validate( Map parsed, JsonNode outputsDecl ); ``` **验证方式**:单元测试覆盖 4 个场景(中文字段名拒绝、英文字段名齐全通过、缺失部分字段拒绝、类型不匹配仍走转换)。 ### 阶段 2:RetryPolicy / WorkflowProperties 拆分计数 **目标**:将 `maxRetries` 拆分为 `runtimeMaxRetries` 和 `formatMaxRetries`。 **改动点**: - `RetryPolicy.java`: - 新增字段: - `int runtimeMaxRetries`(默认 2):运行异常时重试次数(含首次) - `int formatMaxRetries`(默认 2):格式错误时重试次数(仅重组 JSON) - 保留 `retryOnParseError`、`retryOnMissingRequired`、`retryOnTypeMismatch`(仍然决定是否触发对应类型的重试) - 新增 `retryOnKeyNameMismatch`(默认 true,受 strict 模式产生的新失败类型) - `@Deprecated` 标记 `maxRetries` 字段(保留过渡期,运行期仍可用,但优先取 runtimeMaxRetries) - 构造器与 `fromNodeData` 同步更新;`defaultPolicy()` 改为返回 `runtimeMaxRetries=2, formatMaxRetries=2` - `WorkflowProperties.Retry`: - 新增 `runtimeMaxRetries`、`formatMaxRetries`、`retryOnKeyNameMismatch` - 保留 `maxRetries`(标注 `@Deprecated`,过渡期仍读,未配置时回退到 `runtimeMaxRetries`) - `RetryPolicy.fromNodeData` 读取顺序:节点级 → 全局 → 默认 **验证方式**:`RetryPolicyTest` 新增 3 个用例(默认值、节点级覆盖、节点级缺失走全局)。 ### 阶段 3:StructuredOutputHelper 双循环重写 **目标**:实现"外层运行重试 + 内层格式重试"。 **核心结构**: ```java public static ExtractionResult extractWithRetry( ChatClient client, String userPrompt, String systemPrompt, JsonNode outputsDecl, RetryPolicy retryPolicy) { String runtimeError = null; // ===== 外层:运行重试(重新完整推理) ===== for (int runtimeAttempt = 1; runtimeAttempt <= retryPolicy.runtimeMaxRetries; runtimeAttempt++) { // 1. 调用 LLM 完整推理 LlmCallOutcome call; try { call = invokeLlm(client, userPrompt, systemPrompt, outputsDecl, null); } catch (Exception e) { runtimeError = "LLM 调用异常: " + e.getMessage(); if (!retryPolicy.retryOnParseError) break; continue; // 外层重试 } if (call.response == null || call.response.isBlank()) { runtimeError = "LLM 返回空结果"; if (!retryPolicy.retryOnParseError) break; continue; } // 2. 内层:格式重试(仅重组 JSON,不重跑逻辑) FormatResult fmt = reformatLoop( client, call.response, outputsDecl, retryPolicy); if (fmt.ok) { return ExtractionResult.ok(fmt.data, runtimeAttempt + fmt.formatAttempts - 1); } // 3. 内层耗尽 → 外层重试(重新完整推理) runtimeError = fmt.errorMessage; if (!shouldRuntimeRetry(fmt.failReason, retryPolicy)) break; } return ExtractionResult.fail(runtimeError, retryPolicy.runtimeMaxRetries); } ``` **新增内部方法**: - `invokeLlm(client, userPrompt, systemPrompt, outputsDecl, previousResponse)`: - `previousResponse == null` → 构造完整 prompt(userPrompt + JSON 模板指令) - `previousResponse != null` → 构造重组 prompt(见下方) - 返回包含原始响应的 `LlmCallOutcome` - `reformatLoop(client, originalResponse, outputsDecl, retryPolicy)`: - `formatAttempts = 0` - `currentResponse = originalResponse` - 循环 `1..retryPolicy.formatMaxRetries`: - `formatAttempts++` - 解析 + strict 校验 - 通过 → 返回 `FormatResult.ok(data)` - 失败 → 构造重组 prompt,调用 `invokeLlm(..., previousResponse=currentResponse, error=...)`,更新 `currentResponse` - 耗尽 → 返回 `FormatResult.fail(reason)` **重组 prompt 模板**(关键:明确告知 LLM 字段名错了): ``` 你之前的响应: {原始响应} 但需要的字段名是(必须精确匹配,区分大小写): {声明的字段列表,含 type/description/required} 检测到的问题: {具体错误,如 "字段 'status' 键名缺失;字段 'message' 键名缺失;..."} 请仅根据你之前响应中的语义内容,**重新组织**为符合字段名要求的 JSON: {"status": ..., "message": ..., "data": {...}} 仅输出 JSON 本身,不要任何额外文字、Markdown 标记。 ``` **关键设计**: - 重组 prompt 只包含"上次响应 + JSON 模板 + 错误说明",**不包含原始 userPrompt** - LLM 只需做 key 重命名 + 结构调整,不重新执行业务逻辑 - token 消耗远低于完整推理 **新增内部数据类**(私有静态内部类): - `LlmCallOutcome(String response)` - `FormatResult(boolean ok, Map data, String errorMessage, FailReason failReason)` - `FailReason` 枚举:`PARSE_ERROR / MISSING_REQUIRED / TYPE_MISMATCH / KEY_NAME_MISMATCH / OTHER` **适配调用方**:`LlmExecutor`、`SmartActionExecutor` 等执行器的调用代码**完全不变**(对外接口未改)。 **验证方式**:mock ChatClient,验证两种 prompt 的调用次数与内容。 ### 阶段 4:单元测试 **新增测试类**: #### `OutputValidatorStrictTest.java` 1. `strictMode_allFieldsPresent_ok` — 全部字段精确存在 → 通过 2. `strictMode_chineseKeyNameRejected` — LLM 返回中文键名 → missingRequired 含所有声明字段 3. `strictMode_partialMissing_fails` — 缺失 1 个字段 → missingRequired 含该字段 4. `strictMode_nullValue_treatedAsMissing` — parsed 中 value=null → 视为缺失 5. `strictMode_typeMismatch_stillConverts` — 类型不符但可转换 → 通过(含转换后值) 6. `strictMode_extraFields_ignored` — LLM 返回额外字段 → 不影响通过 7. `legacyMode_backwardCompatible` — `validate(parsed, decl)` 等价于 `validate(parsed, decl, false)` #### `StructuredOutputHelperDualLoopTest.java` 1. `firstCallOk_noRetry` — LLM 首次返回合法 JSON → 直接返回,attempts=1 2. `chineseKeys_triggerFormatRetry` — 首次中文,重组后英文 → ok,runtimeAttempts=1,formatAttempts=2 3. `runtimeException_triggersRuntimeRetry` — 首次抛异常 → 外层重试一次成功 4. `formatRetryExhausted_upgradesToRuntime` — formatMaxRetries 耗尽 → 触发外层重试 5. `allRetriesExhausted_returnsFail` — 全部耗尽 → 返回 fail + errorMessage 6. `reformatPrompt_doesNotContainUserPrompt` — 捕获重组 prompt,验证不含原始 userPrompt 7. `reformatPrompt_listsAllMissingKeys` — 验证重组 prompt 明确列出所有缺失键名 ### 阶段 5:application.yml + prompt.md **改动点**: - `application.yml.example`: ```yaml workflow: llm: retry: # 运行异常重试(重新完整推理) runtime-max-retries: 2 # 格式错误重试(仅重组 JSON) format-max-retries: 2 # JSON 解析失败是否重试 retry-on-parse-error: true # 必填字段缺失是否重试 retry-on-missing-required: true # 类型不匹配是否重试 retry-on-type-mismatch: true # 键名不匹配(如返回中文键名)是否重试 retry-on-key-name-mismatch: true # 已弃用:被 runtime-max-retries 替代,未配置时回退 # max-retries: 2 ``` - `application.yml`(实际配置):同步更新 - `prompt.md`:追加本次变更条目(用户视角) ## 5. 关键陷阱 | # | 陷阱 | 处理阶段 | |---|---|---| | 1 | strict 模式误伤:LLM 偶尔返回额外字段 | 忽略额外字段,只检查声明字段是否全部精确存在 | | 2 | 类型自动转换与 strict 共存 | 类型转换逻辑在 strict 之后执行,不冲突 | | 3 | 重组 prompt 长度爆炸(上次响应太长) | formatMaxRetries 默认 2;截断上次响应到前 2000 字(避免超 token) | | 4 | 内层重组再失败时是否再走外层 | 是,但通过 `shouldRuntimeRetry(failReason, policy)` 判定,避免无限循环 | | 5 | legacy `maxRetries` 字段兼容 | @Deprecated + 过渡期映射为 runtimeMaxRetries | | 6 | `retryOnMissingRequired=false` 时 strict 检测出的 missing 是否触发重试 | 新增独立 `retryOnKeyNameMismatch`,与 `retryOnMissingRequired` 解耦 | | 7 | 重组响应解析失败(LLM 又返回非 JSON) | 内层循环继续重试,直到 formatMaxRetries 耗尽 | ## 6. 风险评估 ### 高风险(人工 review) - **H1 双循环复杂度**:嵌套循环 + 状态传递容易出错 → 缓解:把内层循环抽到独立方法 `reformatLoop()`,单元测试单独覆盖 - **H2 重组 prompt 设计**:若指令不清晰,LLM 仍可能返回中文 → 缓解:prompt 中明确"必须精确匹配字段名"+ 列出所有缺失键名 ### 中风险(测试覆盖) - **M1 重组 prompt 长度**:通过截断上次响应缓解 - **M2 重组响应仍非法**:通过内层循环 + 升级到外层缓解 ### 低风险 - 节点级配置覆盖全局:保留现有 `fromNodeData` 模式,仅增加字段 ## 7. 不在本次范围内 1. ❌ 不修改 `NodeExecutor` 接口 2. ❌ 不修改 `NodeOutputEnvelope` 三段式结构 3. ❌ 不引入 JSON Schema 硬约束 4. ❌ 不引入中英文同义词典 5. ❌ 不修改前端 6. ❌ 不修改 `WorkflowContext` 数据结构 7. ❌ 不修改 `userInput` / `condition` 节点执行器(它们不走 `extractWithRetry`) ## 8. 回滚预案 - 改造集中 4 个核心文件 → `git revert` 原子回滚 - 无 DB schema 改动 - 配置项新增,旧配置 `max-retries` 仍可读取,回滚后立即生效 ## 9. 复杂度估算 | 阶段 | 内容 | 估算(小时) | |---|---|---| | 1 | OutputValidator strict 模式 | 1.5 | | 2 | RetryPolicy / WorkflowProperties 拆分 | 1.5 | | 3 | StructuredOutputHelper 双循环重写 | 4 - 5 | | 4 | 单元测试(14 用例) | 3 - 4 | | 5 | application.yml + prompt.md | 0.5 | | **合计** | | **10.5 - 12.5** |