workflow-output-schema-constraint-plan.md 12 KB

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

状态:已确认,待实施 关联:workflow-envelope-strict-validation-plan.mdnode-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.buildInstructionStructuredOutputHelper.java:326-356)已根据 data.outputs 动态拼接 JSON 模板,但格式是 "name": <type> 占位符,无示例值
  • OutputValidator.validateOutputValidator.java:100-152)已实现 strict 校验 + 自动类型转换
  • ✅ 双层重试(runtime + format)已实现(StructuredOutputHelper.java:91-146
  • LlmExecutorLlmExecutor.java:60-75)、SmartActionExecutorSmartActionExecutor.java:66-83)已完整接入

2.2 缺失

  • Hermes 系列未接入HermesAgentExecutor.java:87-91HermesSmartActionExecutor.java:74-76AgentExecutor.java:83-85SkillExecutor 仅做单字段透传
  • 模板无示例值<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 新增方法:

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

复用原则:内部子方法(tryParseJsonbuildReformatPromptbuildInstructionOutputValidator.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 核心规则

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)
  • 测试:StructuredOutputHelperDualLoopTestOutputValidatorStrictTest

验证标准

  • 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 兼容期开关