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

> **文档目的**：解决两个生产反馈问题——(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<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 在重试时精准修正

**接口签名**：
```java
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` 拆分为 `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<String, Object> 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** |
