# 工作流节点输出统一包装(`{status, message, data}`)实施方案 v2 > 修订说明:本版在 v1(机械包装)基础上新增"灵活产出 + 重试机制"。已确认设计决策: > - **严格按画布声明**:data 内字段 = 节点 outputs/variables 声明,多余字段丢弃 > - **重试触发**:仅结构问题(JSON 解析失败/必填字段缺失)+ 类型问题(无法转换) > - **SmartAction 与 LLM 节点统一逻辑**:用户在画布声明多个变量,LLM 按描述填充 > - **配置粒度**:全局默认 + 节点级覆盖 ## 一、需求总览 ### 1.1 统一输出格式 ```json { "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` ```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 data; public static NodeOutputEnvelope success(String message, Map data); public static NodeOutputEnvelope failure(String message); public static NodeOutputEnvelope of(int status, String message, Map data); public Map 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()` 方法**: ```java public static class ExtractionResult { public final Map 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 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); } ``` 3. **`buildInstruction()` 增强**:接受 `lastError` 参数,重试时在指令中追加"上一次返回错误:xxx,请修正"。 4. **指令强化**:在 JSON 模板前增加"严格按要求输出,字段缺失或类型不符将导致流程失败"的强调。 ### 2.3 新增 `OutputValidator` **文件**:`backend/.../engine/OutputValidator.java` ```java public class OutputValidator { public static class ValidationResult { public final boolean ok; public final String errorMessage; public final List missingRequired; // 缺失的必填字段 public final List typeMismatched; // 类型不匹配的字段 } public static ValidationResult validate(Map parsed, JsonNode outputsDecl); } ``` **校验规则**: - **必填字段**:`outputs[i].required == true` 但 `parsed` 中无值或为 null → 加入 `missingRequired` - **类型校验**:声明为 `number` 但值为字符串且无法转换、声明为 `array` 但非数组、声明为 `object` 但非 Map → 加入 `typeMismatched` - **类型自动转换**:声明 `number` + 字符串数字 → 静默转换(不算 mismatch) ### 2.4 新增 `RetryPolicy` (默认重试次数在application.yml中设置,同步更新application.yml.example) **文件**:`backend/.../engine/RetryPolicy.java` ```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.yml` 中 `workflow.llm.retry.*` 3. 代码内默认值(maxRetries=2,全部 retryOn=true) ### 2.5 全局配置(`application.yml`) ```yaml 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) **统一调用模式**: ```java 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 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 一致的结构化输出。 ```java @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: ```java return NodeExecutionResult.success(nodeId, NodeOutputEnvelope.success("知识检索完成", output).toMap()); ``` #### 2.6.4 UserInputExecutor 按 variables 取值后包装: ```java return NodeExecutionResult.success(nodeId, NodeOutputEnvelope.success("用户输入已收集", collected).toMap()); ``` #### 2.6.5 ConditionExecutor ```java Map data = Map.of("selectedBranch", sourceHandle); return NodeExecutionResult.success(nodeId, NodeOutputEnvelope.success("条件路由完成:" + sourceHandle, data).toMap(), sourceHandle); ``` #### 2.6.6 OutputExecutor 合并所有前置 envelope.data: ```java Map aggregatedData = new LinkedHashMap<>(); int worstStatus = STATUS_SUCCESS; List 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>` 的 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` 失败处理 ```java NodeExecutionResult result = executor.execute(...); Map output = result.getOutput(); if (output != null) { context.setNodeOutput(nodeId, output); } else if (result.getStatus() == FAILED) { Map failEnv = NodeOutputEnvelope.failure(result.getError()).toMap(); context.setNodeOutput(nodeId, failEnv); } ``` 保留 failStrategy=abort/skip 行为。 ### 2.11 前端适配 #### `useWorkflowRunner.js` 新增 helper ```js 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:质保组件(独立工具类) 4. 新建 `OutputValidator` 5. 新建 `RetryPolicy`(含 fromNodeData 解析) 6. 新建 `WorkflowProperties` 配置类 7. 改造 `StructuredOutputHelper`:强制 JSON 输出 + `extractWithRetry` + lastError 注入 8. 新增 `StructuredOutputHelperTest`(覆盖重试、降级、类型转换) 9. 验证:`mvn compile` + 单元测试 ### 阶段 3:LLM 类执行器改造 10. `LlmExecutor`:使用 `extractWithRetry` + envelope 包装 11. `SmartActionExecutor`:新增 outputs 声明支持,复用 `extractWithRetry` 12. `AgentExecutor`:按 Skill outputs 走结构化(若 skill 已声明 outputs) 13. `HermesAgentExecutor` / `HermesSmartActionExecutor`:同步改造 14. 验证:`mvn compile` ### 阶段 4:非 LLM 节点改造 15. `KnowledgeRetrievalExecutor`:envelope 包装(固定 4 字段) 16. `UserInputExecutor`:envelope 包装 17. `ConditionExecutor`:envelope 包装 + selectedBranch 放入 data 18. `OutputExecutor`:聚合所有前置 envelope.data 19. 验证:`mvn compile` ### 阶段 5:消费端适配 20. `WorkflowContext.snapshotAllOutputs`:解包 envelope.data 扁平化 21. `NodeWorkspace.getScopedOutput`:穿透 envelope 22. `NodeWorkspaceBuilder.build`:注入 variables 时解包 23. `NodeInputResolver`:五级匹配穿透 data 24. `WorkflowLevelExecutor.executeOneNode`:失败兜底为 envelope 25. 验证:`mvn compile` + NodeInputResolverTest 调整 ### 阶段 6:前端适配 26. `useWorkflowRunner.js`:新增 unwrap helper 27. `WorkflowEditor.vue` 运行结果 Tab:适配 envelope + status 徽章 + 重试次数展示 28. `WorkflowEditor.vue` SmartAction 配置面板:新增 outputs 编辑器 + 重试配置面板 29. `WorkflowEditor.vue` LLM 配置面板:新增重试配置面板 30. `RunHistory.vue`:适配 envelope 31. 验证:`npm run build` ### 阶段 7:回归测试 32. 更新 `NodeInputResolverTest`:mock 数据改为 envelope 结构 33. 新增 `NodeOutputEnvelopeTest`、`OutputValidatorTest`、`RetryPolicyTest` 34. `mvn test` 全量通过 35. `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 徽章