# 节点输出 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": ` 占位符,**无示例值** - ✅ `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` 仅做单字段透传 - ❌ **模板无示例值**:`` 占位符对小模型不够友好 - ❌ **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 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 兼容期开关