# 节点输出 JSON Schema 动态约束方案

> 状态：已确认，待实施
> 关联：[workflow-envelope-strict-validation-plan.md](./workflow-envelope-strict-validation-plan.md)、[node-context-ondemand-plan.md](./node-context-ondemand-plan.md)

## 一、背景与目标

### 1.1 问题陈述

LLM 类节点（LLM、智能操作、技能、Hermes 智能体、Hermes 智能操作）需要根据节点画布声明的 outputs 字段，**动态生成 JSON 输出约束**注入到 prompt，让 LLM 严格按 schema 产出结构化结果，并能优雅处理失败重试。

### 1.2 目标

1. **模板格式带示例值**：让 LLM 直观理解每种类型的输出形式
2. **Hermes 系列接入**：所有基于 Hermes 的节点（HermesAgent / HermesSmartAction / Agent / Skill）与 LLM 节点对齐结构化能力
3. **strict 模式只校验必需字段**：不报多余字段，只校验 `required=true` 字段是否存在且类型匹配
4. **支持 LLM 自由扩展**：允许输出额外的辅助字段（confidence / run_error / user_notice 等），snake_case 命名，最小化原则

---

## 二、当前现状（代码基线）

### 2.1 已实现

- ✅ `StructuredOutputHelper.buildInstruction`（`StructuredOutputHelper.java:326-356`）已根据 `data.outputs` 动态拼接 JSON 模板，但格式是 `"name": <type>` 占位符，**无示例值**
- ✅ `OutputValidator.validate`（`OutputValidator.java:100-152`）已实现 strict 校验 + 自动类型转换
- ✅ 双层重试（runtime + format）已实现（`StructuredOutputHelper.java:91-146`）
- ✅ `LlmExecutor`（`LlmExecutor.java:60-75`）、`SmartActionExecutor`（`SmartActionExecutor.java:66-83`）已完整接入

### 2.2 缺失

- ❌ **Hermes 系列未接入**：`HermesAgentExecutor.java:87-91`、`HermesSmartActionExecutor.java:74-76`、`AgentExecutor.java:83-85`、`SkillExecutor` 仅做单字段透传
- ❌ **模板无示例值**：`<type>` 占位符对小模型不够友好
- ❌ **strict 模式过严**：当前 `strictAllRequired=true`，所有声明字段一律视为必填，无法区分 required/optional
- ❌ **无扩展字段机制**：strict 模式下 LLM 无法自由添加辅助字段

---

## 三、已确认的决策点

| 决策点 | 选择 | 说明 |
|---|---|---|
| 模板格式 | 带示例值 | `"output1": "XXX"` 形式 |
| Hermes 内层重组 LLM 来源 | **节点 data 中独立字段** `reformat_model_id` | 区别于节点主模型，允许配置更便宜的重组模型 |
| Hermes 外层重跑 session 策略 | 复用同一 session | 与 Bridge 现有缓存机制一致 |
| strict 兼容期开关 | 不保留，直接改 | 修订后更宽松，存量节点不会因此失败 |
| 扩展字段下游过滤 | 暂不做 | 与 `node-context-ondemand-plan.md` 解耦 |
| object 子字段约束 | 暂不做 | 后续单独实现 |

---

## 四、Hermes 系列接入方案（核心设计）

### 4.1 关键挑战

Hermes 与普通 LLM 的本质差异：

| 维度 | 普通 LLM | Hermes Agent |
|---|---|---|
| 调用方式 | 单次 prompt→response | 多轮工具调用 + SSE 流 |
| 内层重试代价 | 低（再调一次 LLM） | **极高**（重跑整个 Agent 链） |
| 重试可控性 | 高 | 低（Agent 决策路径不可控） |

**结论**：内层"格式重组"重试**不能用 Hermes 重跑**，否则一次格式错误就触发完整 Agent 流程，成本不可接受。

### 4.2 混合双层重试

```
外层（runtime retry）  ──→  Hermes 完整重跑（同一 session）
                                ↓ finalText
内层（format retry）   ──→  普通 LLM 重组（用 reformat_model_id 配置的模型）
                                ↓ 符合 schema 的 JSON
                          OutputValidator 校验
```

**职责分离**：
- **Hermes**：业务推理、工具调用、产出 finalText（昂贵，少跑）
- **普通 LLM**：把 finalText 整理成符合 schema 的 JSON（廉价，可多次）

### 4.3 数据流

```
HermesAgentExecutor.execute(nodeId, data, context)
    │
    ├─ 1. 构造 userMessage = task + buildInstruction(outputsDecl)
    │
    ├─ 2. hermesBridgeClient.run(userMessage, ...)
    │     → SSE 流式（thinking + tool_calls）
    │     → finalText（Agent 最终回复）
    │
    ├─ 3. needsStructuredOutput(data)?
    │     ├─ 否 → 单字段透传（保持现状）
    │     └─ 是 → 进入步骤 4
    │
    └─ 4. StructuredOutputHelper.extractWithRetryFromText(
              finalText,
              outputsDecl,
              policy,
              reformatClient,           // 内层重组用，按 reformat_model_id 解析
              runtimeRetrySupplier      // 外层重试用：hermesBridgeClient 重跑
          )
          → envelope.success(data) 或 envelope.failure(...)
```

### 4.4 API 设计

`StructuredOutputHelper` 新增方法：

```java
public static ExtractionResult extractWithRetryFromText(
        String rawText,
        JsonNode outputsDecl,
        RetryPolicy policy,
        LlmClient reformatClient,            // 内层用，可空（空则跳过内层）
        Supplier<String> runtimeRetrySupplier // 外层用，可空（空则不重跑）
) { ... }
```

**复用原则**：内部子方法（`tryParseJson`、`buildReformatPrompt`、`buildInstruction`、`OutputValidator.validate`）与 LLM 路径完全复用，**只换"原始文本来源"**。

### 4.5 各 Executor 改动

| Executor | 当前 | 改动 |
|---|---|---|
| `LlmExecutor` | 已接入 | 模板格式跟随升级（无逻辑改动） |
| `SmartActionExecutor` | 已接入 | 同上 |
| `HermesAgentExecutor` | 未接入 | 调用 `extractWithRetryFromText`，传入 `hermesBridgeClient::runAgain` 作为外层 supplier |
| `HermesSmartActionExecutor` | 未接入 | 同上 |
| `AgentExecutor` | 未接入 | 根据 `hermesProperties.enabled` 分流到 Hermes 路径或 LLM 路径 |
| `SkillExecutor` | 间接委托 | 委托链不变，跟随上游自动获得能力 |

### 4.6 外层重试边界

- **默认 `runtimeMaxRetries=1`**（Hermes 系列专用）
- 普通 LLM 节点保持 `runtimeMaxRetries=2`
- `RetryPolicy.fromNodeData` 根据节点类型设置默认值
- 用户可在节点 data 中覆盖

---

## 五、Prompt 模板（带示例值）

`StructuredOutputHelper.buildInstruction` 重写后产出（以 output1/output2/output3 为例）：

```
{原用户 prompt / 技能执行逻辑输出}

---

**必须按如下 JSON 格式输出最终结果**（仅输出 JSON 本身，不要任何 Markdown 包裹、解释或额外文字）：

{
  "output1": "XXX",                       // string 类型
  "output2": 0,                            // number 类型
  "output3": {"key": "value"}              // object 类型
}

**字段说明**：
- output1 (string, required): {description 或 "（无描述）"}
- output2 (number, required): {description 或 "（无描述）"}
- output3 (object, required): {description 或 "（无描述）"}

**扩展字段**（可选）：
- 你可以自由添加业务必要的辅助字段（如 confidence、run_error、user_notice 等）
- 命名规则：snake_case
- 最小化原则：只放置真正必需的辅助信息，不要堆砌
- 不要添加与上述已声明字段语义重叠的字段

**约束**：
- 必须严格使用上述声明的字段名，区分大小写、区分分隔符（禁止中文字段名）
- required 字段缺失或类型不符将触发重试
- 输出必须能被 JSON 直接解析（首尾不能有非 JSON 字符）
```

### 类型示例值映射

| 声明类型 | 模板示例值 | 注释 |
|---|---|---|
| `string` | `"XXX"` | string 类型 |
| `number` | `0` | number 类型 |
| `boolean` | `true` | boolean 类型 |
| `array` | `["item1", "item2"]` | array 类型 |
| `object` | `{"key": "value"}` | object 类型 |
| `filePath` / `directoryPath` / `file` | `"path/to/file"` | 视为 string |

---

## 六、Strict 模式修订

### 6.1 核心规则

```java
for (JsonNode decl : outputsDecl) {
    String name = decl.path("name").asText();
    boolean required = decl.path("required").asBoolean(false);
    String type = decl.path("type").asText("string");

    // 规则 1：只校验 required 字段是否存在
    if (strict && required && !parsed.containsKey(name)) {
        result.addMissing(name);
        continue;
    }

    // 规则 2：字段存在则校验类型（无论 required）
    if (parsed.containsKey(name)) {
        Object converted = checkAndConvertType(parsed.get(name), type);
        if (converted == TYPE_MISMATCH_SENTINEL) {
            result.addTypeMismatch(name, type);
        }
    }
}
// 规则 3：多余字段（含 snake_case 扩展字段）完全不校验
```

### 6.2 行为对比

| 场景 | 修订前 | 修订后 |
|---|---|---|
| required 字段缺失 | ❌ 报错 | ❌ 报错（不变） |
| 非 required 字段缺失 | ❌ strict 下报错 | ✅ 不报错 |
| 多余字段（如 `confidence`） | 不报错 | ✅ 不报错（明确化） |
| 类型不匹配 | ❌ 报错 | ❌ 报错（不变） |
| required 全存在 + 多余扩展 | ❌ 可能报错 | ✅ 通过 |

### 6.3 兼容性

- 移除 `strictAllRequired` 语义
- 不保留兼容期开关
- 单元测试 `OutputValidatorStrictTest` 同步更新

---

## 七、实施阶段

### 阶段 1：模板格式 + strict 语义

**改动文件**：
- `engine/StructuredOutputHelper.java`：重写 `buildInstruction`（示例值 + 扩展字段说明）
- `engine/OutputValidator.java`：修订 strict 规则（只校验 required）
- 测试：`StructuredOutputHelperDualLoopTest`、`OutputValidatorStrictTest`

**验证标准**：
- 5 种类型的示例值正确渲染
- 扩展字段提示存在
- strict 下非 required 缺失不报错
- strict 下多余字段不报错

### 阶段 2：新增"从文本提取"重试路径

**改动文件**：
- `engine/StructuredOutputHelper.java`：新增 `extractWithRetryFromText`
- 提取公共子方法 `invokeReformatOnly`（内层重组，复用 `buildReformatPrompt`）

**验证标准**：
- 单元测试覆盖：内层重组成功、内层耗尽升级外层、外层 supplier 为 null 时直接失败

### 阶段 3：Hermes 系列接入

**改动文件**：
- `engine/executor/HermesAgentExecutor.java`：调用 `extractWithRetryFromText`
- `engine/executor/HermesSmartActionExecutor.java`：同上
- `engine/executor/AgentExecutor.java`：根据 hermes 启用状态分流
- `engine/RetryPolicy.java`：增加节点类型感知，Hermes 系列默认 `runtimeMaxRetries=1`
- `engine/hermes/HermesBridgeClient.java`：可能需要"复用 session 重跑"接口

**验证标准**：
- 集成测试：声明多输出的 Hermes 节点能产出正确 envelope.data
- 失败场景：Hermes finalText 自由文本时，内层 LLM 能重组为 JSON

---

## 八、风险与缓解

| 风险 | 等级 | 缓解 |
|---|---|---|
| Hermes 内层重组需要 LLM 客户端 | 中 | 节点 data 配置 `reformat_model_id`，缺失时回退到 `AiModelService.getDefaultModel()` |
| Hermes 外层重跑成本高 | 高 | 默认 `runtimeMaxRetries=1`；监控告警 |
| strict 语义变化影响存量 | 低 | 修订后更宽松，已通过的节点不会失败 |
| 扩展字段污染下游 NodeInputResolver | 低 | YAGNI，本阶段不处理，由 `node-context-ondemand-plan.md` 后续覆盖 |

---

## 九、复杂度估计

| 阶段 | 工时 |
|---|---|
| 阶段 1 | 3-4 小时 |
| 阶段 2 | 3-4 小时 |
| 阶段 3 | 6-8 小时 |
| **总计** | **12-16 小时** |

---

## 十、不做的事（YAGNI）

- ❌ object 子字段约束（children 递归）
- ❌ array 元素类型声明（itemType）
- ❌ 枚举值 / 正则 pattern / 默认值约束
- ❌ JSON Schema 标准化
- ❌ 扩展字段下游过滤
- ❌ strict 兼容期开关
