workflow-envelope-strict-validation-plan.md 17 KB

工作流节点输出严格校验与重试策略分离实施计划

文档目的:解决两个生产反馈问题——(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 仅用英文 nameparsedget(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 关键观察

所有需要结构化校验的执行器(LlmExecutorSmartActionExecutorHermesAgentExecutorHermesSmartActionExecutor 等)都通过 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<String, Object> 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 在重试时精准修正

接口签名

public static ValidationResult validate(
    Map<String, Object> parsed,
    JsonNode outputsDecl,
    boolean strictAllRequired
);

// 旧入口保留
public static ValidationResult validate(
    Map<String, Object> parsed,
    JsonNode outputsDecl
);

验证方式:单元测试覆盖 4 个场景(中文字段名拒绝、英文字段名齐全通过、缺失部分字段拒绝、类型不匹配仍走转换)。

阶段 2:RetryPolicy / WorkflowProperties 拆分计数

目标:将 maxRetries 拆分为 runtimeMaxRetriesformatMaxRetries

改动点

  • RetryPolicy.java

    • 新增字段:
    • int runtimeMaxRetries(默认 2):运行异常时重试次数(含首次)
    • int formatMaxRetries(默认 2):格式错误时重试次数(仅重组 JSON)
    • 保留 retryOnParseErrorretryOnMissingRequiredretryOnTypeMismatch(仍然决定是否触发对应类型的重试)
    • 新增 retryOnKeyNameMismatch(默认 true,受 strict 模式产生的新失败类型)
    • @Deprecated 标记 maxRetries 字段(保留过渡期,运行期仍可用,但优先取 runtimeMaxRetries)
    • 构造器与 fromNodeData 同步更新;defaultPolicy() 改为返回 runtimeMaxRetries=2, formatMaxRetries=2
  • WorkflowProperties.Retry

    • 新增 runtimeMaxRetriesformatMaxRetriesretryOnKeyNameMismatch
    • 保留 maxRetries(标注 @Deprecated,过渡期仍读,未配置时回退到 runtimeMaxRetries
    • RetryPolicy.fromNodeData 读取顺序:节点级 → 全局 → 默认

验证方式RetryPolicyTest 新增 3 个用例(默认值、节点级覆盖、节点级缺失走全局)。

阶段 3:StructuredOutputHelper 双循环重写

目标:实现"外层运行重试 + 内层格式重试"。

核心结构

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<String, Object> data, String errorMessage, FailReason failReason)
  • FailReason 枚举:PARSE_ERROR / MISSING_REQUIRED / TYPE_MISMATCH / KEY_NAME_MISMATCH / OTHER

适配调用方LlmExecutorSmartActionExecutor 等执行器的调用代码完全不变(对外接口未改)。

验证方式: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_backwardCompatiblevalidate(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

    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