workflow-output-envelope-design.md 16 KB

工作流节点输出统一包装({status, message, data})实施方案 v2

修订说明:本版在 v1(机械包装)基础上新增"灵活产出 + 重试机制"。已确认设计决策:

  • 严格按画布声明:data 内字段 = 节点 outputs/variables 声明,多余字段丢弃
  • 重试触发:仅结构问题(JSON 解析失败/必填字段缺失)+ 类型问题(无法转换)
  • SmartAction 与 LLM 节点统一逻辑:用户在画布声明多个变量,LLM 按描述填充
  • 配置粒度:全局默认 + 节点级覆盖

一、需求总览

1.1 统一输出格式

{
  "status": 200,
  "message": "调用成功",
  "data": {
    "fieldA": "XXXX", //(字段A输出结果)
    "fieldB": 15.83 //(字段B输出结果)
  }
}
  • status:200 成功 / 400 失败
  • message:人类可读结果/失败原因
  • data:节点声明的输出变量集合(严格按画布声明,不收纳多余字段)

1.2 后继节点解析规则

  • 从前序节点 data 字段内部取变量(不再读扁平 Map 顶层)
  • 支持读取 data 内的子字段(如 data.profile.name
  • 失败节点(status=400)写入上下文,下游可感知失败状态

1.3 灵活产出能力(v2 新增)

能力 适用节点 说明
结构化输出 LLM / SmartAction / Agent / Hermes* 即使只声明 1 个字段也走 JSON 模式,让 LLM 按字段定义产出
自动重试 同上 JSON 解析失败、必填字段缺失、类型转换失败时自动重试
字段填充校验 同上 必填字段缺失则触发重试或失败

1.4 SmartAction 升级

  • 画布上支持声明 outputs 列表(与 LLM 节点一致)
  • 执行器读取 actionPrompt(操作要求)作为 user prompt
  • 走与 LLM 节点完全一致的结构化输出 + 重试流程

二、核心设计

2.1 NodeOutputEnvelope 值对象

文件backend/.../engine/NodeOutputEnvelope.java

public class NodeOutputEnvelope {
    public static final int STATUS_SUCCESS = 200;
    public static final int STATUS_FAILED = 400;

    private final int status;
    private final String message;
    private final Map<String, Object> data;

    public static NodeOutputEnvelope success(String message, Map<String, Object> data);
    public static NodeOutputEnvelope failure(String message);
    public static NodeOutputEnvelope of(int status, String message, Map<String, Object> data);

    public Map<String, Object> toMap();
    public static NodeOutputEnvelope fromObject(Object raw);
    public static boolean isEnvelope(Object raw);
}

2.2 StructuredOutputHelper 增强(核心)

文件backend/.../engine/StructuredOutputHelper.java

改造点

  1. 强制 JSON 输出needsStructuredOutput() 改为只要 outputs 非空就返回 true(移除 size > 1 判断),让所有 LLM 类节点统一走结构化模式。

  2. 新增 extractWithRetry() 方法

    public static class ExtractionResult {
    public final Map<String, Object> data;
    public final String errorMessage;  // null 表示成功
    public final int attempts;         // 实际尝试次数
    }
    
    public static ExtractionResult extractWithRetry(
    ChatClient client,
    String userPrompt,
    String systemPrompt,
    JsonNode outputsDecl,
    RetryPolicy retryPolicy
    );
    

流程

for (attempt = 1; attempt <= maxRetries; attempt++) {
    String prompt = userPrompt + buildInstruction(outputsDecl, lastError);
    String response = client.prompt().user(prompt).system(systemPrompt).call().content();
    Map<String, Object> parsed = parse(response, outputsDecl);
    ValidationResult validation = OutputValidator.validate(parsed, outputsDecl);
    if (validation.ok) return ExtractionResult.success(parsed, attempt);
    lastError = validation.errorMessage;
}
return ExtractionResult.failure(lastError, attempt);
}
  1. buildInstruction() 增强:接受 lastError 参数,重试时在指令中追加"上一次返回错误:xxx,请修正"。

  2. 指令强化:在 JSON 模板前增加"严格按要求输出,字段缺失或类型不符将导致流程失败"的强调。

2.3 新增 OutputValidator

文件backend/.../engine/OutputValidator.java

public class OutputValidator {
    public static class ValidationResult {
        public final boolean ok;
        public final String errorMessage;
        public final List<String> missingRequired;  // 缺失的必填字段
        public final List<String> typeMismatched;    // 类型不匹配的字段
    }

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

校验规则

  • 必填字段outputs[i].required == trueparsed 中无值或为 null → 加入 missingRequired
  • 类型校验:声明为 number 但值为字符串且无法转换、声明为 array 但非数组、声明为 object 但非 Map → 加入 typeMismatched
  • 类型自动转换:声明 number + 字符串数字 → 静默转换(不算 mismatch)

2.4 新增 RetryPolicy (默认重试次数在application.yml中设置,同步更新application.yml.example)

文件backend/.../engine/RetryPolicy.java

public class RetryPolicy {
    public final int maxRetries;             // 默认 2,节点级可覆盖
    public final boolean retryOnParseError;  // JSON 解析失败重试,默认 true
    public final boolean retryOnMissingRequired;  // 必填字段缺失重试,默认 true
    public final boolean retryOnTypeMismatch;     // 类型不匹配重试,默认 true

    public static RetryPolicy fromNodeData(JsonNode data, WorkflowProperties props);
    public static RetryPolicy defaultPolicy();
}

配置读取优先级

  1. 节点 data.maxRetries / data.retryOn*
  2. 全局 application.ymlworkflow.llm.retry.*
  3. 代码内默认值(maxRetries=2,全部 retryOn=true)

2.5 全局配置(application.yml

workflow:
  llm:
    retry:
      max-retries: 2
      retry-on-parse-error: true
      retry-on-missing-required: true
      retry-on-type-mismatch: true

新增 WorkflowProperties 配置类(@ConfigurationProperties("workflow")),通过 Spring 注入。

2.6 各节点改造明细

2.6.1 LLM 类节点(LLM / SmartAction / Agent / HermesAgent / HermesSmartAction)

统一调用模式

RetryPolicy policy = RetryPolicy.fromNodeData(data, workflowProperties);
StructuredOutputHelper.ExtractionResult extResult = StructuredOutputHelper.extractWithRetry(
    client, userPrompt, systemPrompt, outputsDecl, policy);

if (extResult.errorMessage != null) {
    return NodeExecutionResult.success(nodeId,
        NodeOutputEnvelope.failure(extResult.errorMessage).toMap());
}

Map<String, Object> data = new LinkedHashMap<>(extResult.data);
data.put("elapsed_time", elapsed);  // LLM 节点特有
String msg = "LLM 调用成功" + (extResult.attempts > 1 ? "(重试 " + (extResult.attempts - 1) + " 次)" : "");
return NodeExecutionResult.success(nodeId,
    NodeOutputEnvelope.success(msg, data).toMap());

2.6.2 SmartActionExecutor 升级

关键改动:支持 outputs 声明,走与 LLM 一致的结构化输出。

@Override
public NodeExecutionResult execute(...) {
    String actionPrompt = TemplateRenderer.render(data.path("actionPrompt").asText(""), workspace.getVariables());
    if (actionPrompt.isEmpty()) {
        return NodeExecutionResult.success(nodeId,
            NodeOutputEnvelope.failure("智能操作节点的操作要求为空").toMap());
    }

    JsonNode outputsDecl = data.path("outputs");
    if (outputsDecl.isArray() && outputsDecl.size() > 0) {
        // 结构化模式:走 LLM 节点的统一流程
        return executeStructured(nodeId, data, actionPrompt, workspace, outputsDecl);
    }

    // 降级模式:无 outputs 声明,按单变量透传(兼容旧工作流)
    String result = callLlm(...);
    String varName = NodeTypeUtils.resolveOutputVarName(data, "result");
    return NodeExecutionResult.success(nodeId,
        NodeOutputEnvelope.success("智能操作执行成功", Map.of(varName, result)).toMap());
}

2.6.3 KnowledgeRetrievalExecutor

固定 4 字段,无需 LLM 重试。直接组装 envelope:

return NodeExecutionResult.success(nodeId,
    NodeOutputEnvelope.success("知识检索完成", output).toMap());

2.6.4 UserInputExecutor

按 variables 取值后包装:

return NodeExecutionResult.success(nodeId,
    NodeOutputEnvelope.success("用户输入已收集", collected).toMap());

2.6.5 ConditionExecutor

Map<String, Object> data = Map.of("selectedBranch", sourceHandle);
return NodeExecutionResult.success(nodeId,
    NodeOutputEnvelope.success("条件路由完成:" + sourceHandle, data).toMap(), sourceHandle);

2.6.6 OutputExecutor

合并所有前置 envelope.data:

Map<String, Object> aggregatedData = new LinkedHashMap<>();
int worstStatus = STATUS_SUCCESS;
List<String> messages = new ArrayList<>();

for (entry : context.getAllNodeScopedOutputs()) {
    NodeOutputEnvelope env = NodeOutputEnvelope.fromObject(entry.getValue());
    if (env.getStatus() == STATUS_FAILED) {
        worstStatus = STATUS_FAILED;
        messages.add("[" + entry.getKey() + "] " + env.getMessage());
    }
    if (env.getData() != null) aggregatedData.putAll(env.getData());
}

// 工作目录文件
aggregatedData.put("_workingDirFiles", files);
aggregatedData.put("_runId", context.getRunId());

String message = worstStatus == STATUS_SUCCESS
    ? "工作流执行成功"
    : "部分节点失败:" + String.join("; ", messages);
return NodeExecutionResult.success(nodeId,
    NodeOutputEnvelope.of(worstStatus, message, aggregatedData).toMap());

2.7 WorkflowContext 改造

nodeScopedOutputs: Map<nodeId, Map<String, Object>> 的 value 改为 envelope.toMap()。

snapshotAllOutputs() 改为解包 envelope.data 后扁平化(保持 key 形如 {nodeId}__{varName},value 是业务字段值)。

2.8 NodeWorkspaceBuilder 改造

scopedOutputs 保留 envelope 原貌;variables 扁平表注入解包后的 data 字段。

2.9 NodeInputResolver 改造

五级匹配全部穿透 envelope.data:

  • workspace.getScopedOutput(nodeId, fieldName) 先解包 envelope
  • 模糊匹配遍历 envelope.data.entrySet()
  • JsonPath 提取以 envelope.data 为根

2.10 WorkflowLevelExecutor 失败处理

NodeExecutionResult result = executor.execute(...);
Map<String, Object> output = result.getOutput();
if (output != null) {
    context.setNodeOutput(nodeId, output);
} else if (result.getStatus() == FAILED) {
    Map<String, Object> failEnv = NodeOutputEnvelope.failure(result.getError()).toMap();
    context.setNodeOutput(nodeId, failEnv);
}

保留 failStrategy=abort/skip 行为。

2.11 前端适配

useWorkflowRunner.js 新增 helper

function isEnvelope(o) { return o && typeof o === 'object' && 'status' in o && 'data' in o }
function unwrapNodeOutput(o) { return isEnvelope(o) ? (o.data || {}) : o }
function nodeOutputStatus(o) { return isEnvelope(o) ? o.status : null }
function nodeOutputMessage(o) { return isEnvelope(o) ? o.message : '' }

SmartAction 节点配置面板(WorkflowEditor.vue

  • 添加 outputs 列表编辑器(与 LLM 节点结构一致)
  • 添加"重试配置"折叠面板(maxRetries 数字输入、retryOn 复选框组)

LLM 节点配置面板

  • 添加"重试配置"折叠面板(同上)

运行结果展示

  • v-for 遍历 unwrapNodeOutput(r.output) 而非 r.output
  • 失败(status=400)时显示红色徽章 + message
  • 重试次数 > 0 时在 message 末尾显示"(重试 N 次)"

三、实施阶段

阶段 1:核心抽象(无破坏性)

  1. 新建 NodeOutputEnvelope
  2. 新建 NodeOutputEnvelopeTest 单元测试
  3. 验证:mvn compile + 测试通过

阶段 2:质保组件(独立工具类)

  1. 新建 OutputValidator
  2. 新建 RetryPolicy(含 fromNodeData 解析)
  3. 新建 WorkflowProperties 配置类
  4. 改造 StructuredOutputHelper:强制 JSON 输出 + extractWithRetry + lastError 注入
  5. 新增 StructuredOutputHelperTest(覆盖重试、降级、类型转换)
  6. 验证:mvn compile + 单元测试

阶段 3:LLM 类执行器改造

  1. LlmExecutor:使用 extractWithRetry + envelope 包装
  2. SmartActionExecutor:新增 outputs 声明支持,复用 extractWithRetry
  3. AgentExecutor:按 Skill outputs 走结构化(若 skill 已声明 outputs)
  4. HermesAgentExecutor / HermesSmartActionExecutor:同步改造
  5. 验证:mvn compile

阶段 4:非 LLM 节点改造

  1. KnowledgeRetrievalExecutor:envelope 包装(固定 4 字段)
  2. UserInputExecutor:envelope 包装
  3. ConditionExecutor:envelope 包装 + selectedBranch 放入 data
  4. OutputExecutor:聚合所有前置 envelope.data
  5. 验证:mvn compile

阶段 5:消费端适配

  1. WorkflowContext.snapshotAllOutputs:解包 envelope.data 扁平化
  2. NodeWorkspace.getScopedOutput:穿透 envelope
  3. NodeWorkspaceBuilder.build:注入 variables 时解包
  4. NodeInputResolver:五级匹配穿透 data
  5. WorkflowLevelExecutor.executeOneNode:失败兜底为 envelope
  6. 验证:mvn compile + NodeInputResolverTest 调整

阶段 6:前端适配

  1. useWorkflowRunner.js:新增 unwrap helper
  2. WorkflowEditor.vue 运行结果 Tab:适配 envelope + status 徽章 + 重试次数展示
  3. WorkflowEditor.vue SmartAction 配置面板:新增 outputs 编辑器 + 重试配置面板
  4. WorkflowEditor.vue LLM 配置面板:新增重试配置面板
  5. RunHistory.vue:适配 envelope
  6. 验证:npm run build

阶段 7:回归测试

  1. 更新 NodeInputResolverTest:mock 数据改为 envelope 结构
  2. 新增 NodeOutputEnvelopeTestOutputValidatorTestRetryPolicyTest
  3. mvn test 全量通过
  4. npm run build 通过

四、风险与缓解

风险 等级 缓解
LLM 强制 JSON 模式可能拒绝遵循(小模型) 重试 + 类型转换容错;保留单输出降级路径
重试导致工作流耗时翻倍 全局默认 2 次;用户可节点级关闭;监控 attempts 指标
NodeInputResolver 五级匹配改造引入 bug 保留 legacy 兼容分支 + 完整单元测试
SmartAction 新增 outputs 声明兼容旧工作流 检测 data.outputs 是否存在,无声明走降级模式(旧工作流不破坏)
Hermes 执行器与内置执行器双路径需同步 提取共享逻辑到工具类

五、不变项(无需改动)

  • ContextPromptHelper(取 workspace.getVariable,已由 NodeWorkspaceBuilder 解包)
  • TemplateRenderer(同上)
  • JsonPathExtractor / VariableConverter(纯工具)
  • 前端 ioInference.js / useNodeFields.js(静态推断)
  • 画布节点组件(基于 data.outputs/inputs 静态渲染)

六、验收标准

  1. 单元测试:NodeOutputEnvelopeTest / OutputValidatorTest / RetryPolicyTest / StructuredOutputHelperTest 全部通过
  2. 单元测试:NodeInputResolverTest 在 envelope 结构下正确解析
  3. 单元测试:mvn test 全量通过
  4. 构建:mvn compile 通过
  5. 构建:npm run build 通过
  6. 端到端(用户手动测试):
    • LLM 多输出声明:LLM 按字段产出 JSON,下游能解析 data 字段
    • LLM 故意返回错误 JSON:自动重试,最终失败时 status=400 + message 包含原因
    • SmartAction 声明 outputs:按字段产出
    • RunHistory 详情:节点输出展示 data 字段,失败节点显示 status 徽章