# 工作流上下文传递架构方案 > **状态**:待用户确认 > **日期**:2026-06-14 > **作者**:架构设计讨论 > **关联文件**: > - `backend/src/main/java/com/agent/management/engine/WorkflowContext.java` > - `backend/src/main/java/com/agent/management/engine/WorkflowLevelExecutor.java` > - `backend/src/main/java/com/agent/management/engine/NodeExecutionResult.java` > - `backend/src/main/java/com/agent/management/engine/executor/*.java` > - `frontend/src/utils/ioInference.js` --- ## 一、背景与动机 ### 1.1 问题现象 当前工作流引擎存在前后端契约脱节问题: - **前端契约**(`ioInference.js:64-66`):LLM 节点的输出变量名默认为 `result` - **后端实现**(`LlmExecutor.java:59`):`Map.of(nodeId, result)` 把结果写到了 `nodeId` 名下 - **结果**:下游节点 `{{result}}` 模板渲染取不到值,只有 `{{llm_1}}` 能取到——与前端展示给用户的 IO 标签不符 同样的 bug 在 5 个 executor 中重复出现:`LlmExecutor`、`AgentExecutor`、`SmartActionExecutor`、`HermesAgentExecutor`、`HermesSmartActionExecutor`。 ### 1.2 更深层的设计诉求 这次不仅是修 bug,而是把节点间数据流从"变量传递"升级为"上下文传递": | 维度 | 旧契约 | 新契约 | |---|---|---| | 节点可见范围 | 自己 inputs 模板里引用的变量 | 整个工作流截至此时的全部状态 | | 节点产出 | 自己声明的 outputs 变量 | 在原上下文基础上叠加自己的产出 | | 失败判定 | executor 抛异常 | 前置条件检查:缺文件/缺变量即失败,按 failStrategy 处理 | | Debug 能力 | 仅看每节点 output | 看每节点执行前后的完整上下文快照 | | 扩展维度 | variables + workingDir | 还能挂 git 状态、memory 等任意 section | --- ## 二、现状盘点 ### 2.1 已具备的能力(无需重做) | 能力 | 实现位置 | 状态 | |---|---|---| | 工作目录所有节点共享 | `WorkflowContext.workingDir` | ✅ | | 工作流执行时创建临时目录 | `WorkflowRunDirManager.createRunDir` | ✅ | | 输入文件/文件夹上传到工作目录 | `WorkflowController.run()` multipart 支持 + ZIP slip 防护 | ✅ | | 变量累积传递 | `WorkflowContext.variables.putAll(output)` | ✅(但 key 错误) | | 前置条件检查 | `WorkflowLevelExecutor.checkPreconditions` | ✅(缺类型校验) | | failStrategy(abort/skip) | `WorkflowLevelExecutor` | ✅ | | 输出节点输出所有变量 | `OutputExecutor` | ✅ | | 工作目录打包下载 | `GET /api/workflows/{id}/runs/{runId}/download` | ✅ | ### 2.2 待新增的能力 | 能力 | 工作量 | |---|---| | 每节点上下文快照(debug 视图) | 中 | | Context 结构化扩展槽(sections) | 小 | | 变量 key 按 outputs 规范(5 个 executor 修复) | 小 | | 前置条件类型校验 | 小 | | 前端运行历史详情页:上下文快照面板 | 中 | | 前端输出节点:下载工作目录入口 | 小 | --- ## 三、架构设计 ### 3.1 核心数据结构 #### `WorkflowContext`(扩展) ```java public class WorkflowContext { private final Map variables; // 用户变量(扁平累积) private final Path workingDir; // 工作目录(已有) private final List nodeOutputs; // 累积记录(已有) private final Map sections; // 【新增】命名空间扩展槽 private NodeStreamSink streamSink; // 已有 // sections 访问器 public Object getSection(String name) { return sections.get(name); } public void putSection(String name, Object value) { sections.put(name, value); } } ``` **sections 设计约定**: - key 以 `_` 开头表示系统级(如 `_git`、`_memory`) - 用户节点不得直接读写 sections,仅 executor 内部使用 - 未来扩展时按 section 命名空间挂载,例如: - `_git` → `{ branch, commit, dirty }` - `_memory` → Hermes 长期记忆句柄 - `_runtime` → `{ startTime, nodeCount, ... }` #### `NodeExecutionResult`(扩展) ```java public class NodeExecutionResult { private final Map output; // 仅本次输出 private final Map contextSnapshot; // 【新增】执行后完整 variables 快照 // ... 其他字段不变 } ``` #### `WorkflowRunNode` 实体(扩展) ```java public class WorkflowRunNode { // ... 已有字段 private String output; // 已有:本次输出 JSON private String contextSnapshot; // 【新增】执行后完整上下文 JSON } ``` ### 3.2 节点输出辅助方法 #### 3.2.1 单输出场景统一辅助方法(`NodeTypeUtils`) 适用于所有"产出单一文本结果"的节点(Agent/SmartAction/Hermes 系列)。LlmExecutor 不使用此方法,单独走结构化输出路径(见 3.2.2)。 ```java /** * 根据节点 data.outputs 声明解析输出变量名。 * 单输出场景:返回 outputs[0].name * 未声明 outputs:返回 defaultName * 多输出场景:本期仅取 outputs[0].name,并记录警告日志(其他 outputs 被忽略);留下TODO * (LLM 节点不走此路径,由 StructuredOutputHelper 单独处理多输出) */ public static String resolveOutputVarName(JsonNode data, String defaultName) { JsonNode outputs = data.path("outputs"); if (outputs.isArray() && outputs.size() > 0) { String name = outputs.get(0).path("name").asText(""); if (!name.isEmpty()) { if (outputs.size() > 1) { log.warn("[NodeTypeUtils] 节点声明了多个 outputs,本期仅取第一个: {}", name); } return name; } } return defaultName; } /** * 构造单输出的 Map:{ varName: result } */ public static Map singleOutput(String varName, Object result) { return Map.of(varName, result); } ``` #### 3.2.2 LLM 结构化输出辅助类(新建 `StructuredOutputHelper`) LLM 节点声明多个 outputs 时(如 `summary + detail`),通过 Prompt 注入指令让模型按 JSON 格式输出,再解析拆分到各变量。 ```java package com.agent.management.engine; public class StructuredOutputHelper { private static final ObjectMapper MAPPER = new ObjectMapper(); /** * 判断节点是否需要结构化输出。 * 触发条件:data.outputs 是数组且长度 > 1 */ public static boolean needsStructuredOutput(JsonNode data) { JsonNode outputs = data.path("outputs"); return outputs.isArray() && outputs.size() > 1; } /** * 构造结构化输出指令(追加到 userPrompt 末尾)。 * 指令包含:JSON 框架 + 字段说明(name/type/description)。 */ public static String buildInstruction(JsonNode outputs) { StringBuilder sb = new StringBuilder(); sb.append("\n\n---\n请将最终结果按以下 JSON 格式输出,仅输出 JSON," + "不要任何额外文字、解释或代码块标记:\n"); sb.append("{\n"); for (JsonNode o : outputs) { String name = o.path("name").asText(""); String type = o.path("type").asText("string"); sb.append(" \"").append(name).append("\": <").append(type).append(">,\n"); } // 删除最后一个逗号 int lastComma = sb.lastIndexOf(",\n"); if (lastComma == sb.length() - 2) sb.deleteCharAt(lastComma); sb.append("}\n\n字段说明:\n"); for (JsonNode o : outputs) { String name = o.path("name").asText(""); String type = o.path("type").asText("string"); String desc = o.path("description").asText(""); sb.append("- ").append(name).append(" (").append(type).append("): ") .append(desc.isEmpty() ? "(无描述)" : desc).append("\n"); } return sb.toString(); } /** * 解析 LLM 响应,按 outputs 拆分为 Map。 * 解析失败返回 null(调用方降级处理)。 */ public static Map parse(String llmResponse, JsonNode outputs) { if (llmResponse == null || llmResponse.isBlank()) return null; JsonNode parsed = tryParseJson(llmResponse); if (parsed == null || !parsed.isObject()) return null; Map result = new LinkedHashMap<>(); for (JsonNode o : outputs) { String name = o.path("name").asText(""); if (name.isEmpty()) continue; JsonNode value = parsed.get(name); if (value == null || value.isNull()) { log.warn("[StructuredOutput] 字段 {} 未在 LLM 响应中找到", name); result.put(name, null); continue; } result.put(name, convertValue(value, o.path("type").asText("string"))); } return result; } /** 三级 JSON 提取:直接解析 → ```json``` 代码块 → 首尾{}子串 */ private static JsonNode tryParseJson(String text) { ... } /** 按 outputs 声明类型转换值;失败保留字符串 + WARN */ private static Object convertValue(JsonNode value, String targetType) { ... } } ``` **JSON 解析容错策略(三级降级)**: | 级别 | 策略 | 适用场景 | |---|---|---| | 1 | 直接 `MAPPER.readTree(text)` | 模型严格按指令输出 JSON | | 2 | 提取 ` ```json ... ``` ` 代码块内容再解析 | 模型用 markdown 代码块包裹 | | 3 | 提取首个 `{` 到末个 `}` 之间的子串再解析 | 模型在 JSON 前后加了解释文字 | 三级都失败 → 返回 `null`,调用方降级:把完整文本塞到 `outputs[0].name`,并 WARN 日志。 **类型映射表**(`convertValue`): | outputs 声明 type | 转换逻辑 | |---|---| | `string`(默认) | `value.asText()` | | `number` | `value.isNumber() ? numberValue() : Double.parseDouble(asText())` | | `boolean` | `value.isBoolean() ? booleanValue() : Boolean.parseBoolean(asText())` | | `array` / `object` | `MAPPER.treeToValue(value, Object.class)` | | 类型转换失败 | 保留 `value.toString()`,WARN 日志 | --- ## 四、节点 Outputs 契约规范 ### 4.1 统一规则 1. **executor 不再硬编码 nodeId 作为输出 key**——必须从 `data.outputs` 读取声明的变量名 2. **fallback 规则**:声明 outputs 为空时,按节点类型给默认变量名 3. **变量冲突**:同名变量后写者覆盖前者,引擎记录警告日志(便于 debug) 4. **多输出场景**: - **LLM 节点**:本期实现结构化输出(详见 3.2.2)。声明多个 outputs 时,自动注入 JSON 格式指令,按字段拆分到各变量;解析失败降级为整段文本写入 `outputs[0].name` - **Agent / SmartAction / HermesAgent / HermesSmartAction 节点**:本期暂不支持多输出(Skill 和 Hermes 内部已有输出格式控制,叠加结构化指令易冲突)。多输出场景仅取 `outputs[0].name`,并 WARN 日志,留下 TODO 5. **快照时机**:节点执行成功且 `setNodeOutput` 完成后立即快照 variables ### 4.2 各节点 Outputs 规范细则 #### `userInput` 节点 | 项目 | 内容 | |---|---| | 前端契约 | `getNodeOutputs(userInput) → data.variables`(每个变量作为输出) | | 默认 outputs | 无(用户在节点上声明的 variables 即为 outputs) | | 后端实现 | `UserInputExecutor.java:39`:`output.put(name, value)` | | **改动** | ✅ **无需改动**——已符合规范 | #### `llm` 节点 | 项目 | 内容 | |---|---| | 前端契约 | 默认 `[{name:'result', type:'string'}]`,用户可自定义单/多输出 | | 默认 outputs 变量名 | `result`(outputs 长度 ≤ 1 时) | | 后端当前实现 | `Map.of(nodeId, result)` ❌ | | **改动** | 分两种场景:
• **单输出(outputs ≤ 1)**:`Map.of(NodeTypeUtils.resolveOutputVarName(data, "result"), result)`
• **多输出(outputs > 1)**:通过 `StructuredOutputHelper` 注入指令 + 解析 JSON 拆分到各变量;解析失败降级到 `outputs[0].name` | **LlmExecutor 改造后伪代码**: ```java public NodeExecutionResult execute(String nodeId, JsonNode data, WorkflowContext context) { String systemPrompt = TemplateRenderer.render(data.path("systemPrompt").asText(""), context.getVariables()); String userPrompt = TemplateRenderer.render(data.path("userPrompt").asText(""), context.getVariables()); if (userPrompt.isEmpty()) return NodeExecutionResult.failed(nodeId, "用户提示词为空"); JsonNode outputs = data.path("outputs"); boolean structured = StructuredOutputHelper.needsStructuredOutput(data); if (structured) { userPrompt = userPrompt + StructuredOutputHelper.buildInstruction(outputs); } ChatClient client = resolveChatClient(data); String result = client.prompt().user(userPrompt) .system(systemPrompt.isEmpty() ? null : systemPrompt) .call().content(); if (result == null || result.isBlank()) return NodeExecutionResult.failed(nodeId, "LLM 返回空结果"); Map output; if (structured) { Map parsed = StructuredOutputHelper.parse(result, outputs); if (parsed != null) { output = parsed; } else { String firstName = outputs.get(0).path("name").asText("result"); log.warn("[LLM] 节点 {} 结构化输出解析失败,降级到 {}", nodeId, firstName); output = Map.of(firstName, result); } } else { output = Map.of(NodeTypeUtils.resolveOutputVarName(data, "result"), result); } return NodeExecutionResult.success(nodeId, output); } ``` #### `agent` 节点(基于 Skill) | 项目 | 内容 | |---|---| | 前端契约 | `getNodeOutputs(agent) → data.outputs \|\| []`(空数组则前端不显示输出标签) | | 默认 outputs 变量名 | `result`(与 LLM 对齐) | | 后端当前实现 | `Map.of(nodeId, result)` ❌ | | **改动** | 改为:`Map.of(NodeTypeUtils.resolveOutputVarName(data, "result"), result)` | #### `smartAction` 节点 | 项目 | 内容 | |---|---| | 前端契约 | 同 agent | | 默认 outputs 变量名 | `result` | | 后端当前实现 | `Map.of(nodeId, result)` ❌ | | **改动** | 同 agent | #### `HermesAgent` 节点(hermes.enabled=true 时替代 agent) | 项目 | 内容 | |---|---| | 默认 outputs 变量名 | `result`(与 agent 一致) | | 后端当前实现 | `Map.of(nodeId, finalText)` ❌ | | **改动** | 同 agent | #### `HermesSmartAction` 节点(hermes.enabled=true 时替代 smartAction) | 项目 | 内容 | |---|---| | 默认 outputs 变量名 | `result` | | 后端当前实现 | `Map.of(nodeId, finalText)` ❌ | | **改动** | 同 agent | #### `condition` 节点 | 项目 | 内容 | |---|---| | 前端契约 | `getNodeOutputs(condition) → getNodeInputs(node)`(透传输入作为输出) | | 默认 outputs | 透传输入字段 | | 后端当前实现 | `Map.of(nodeId, "branch-N")` + `selectedBranch` | | 现状问题 | 下游激活靠 `selectedBranch`/`sourceHandle`,**没有任何下游会读 `{{nodeId}}` 来获取分支号**,因此 `Map.of(nodeId, ...)` 写入毫无意义 | | **改动** | 改为:`Map.of()`(空输出),`selectedBranch` 仍保留用于 DAG 路由 | #### `output` 节点 | 项目 | 内容 | |---|---| | 前端契约 | `getNodeOutputs(output) → []`(终端节点,无输出) | | 后端当前实现 | 收集所有 variables + `_workingDirFiles` + `_runId` | | **改动** | ✅ **无需改动**——透传上下文的设计本就符合新契约 | ### 4.3 改动汇总表 | Executor | 当前 key | 改后 key | 文件位置 | |---|---|---|---| | `LlmExecutor` | `nodeId` | 单输出:`resolveOutputVarName(data, "result")`
多输出:`StructuredOutputHelper.parse()` 拆分 | `LlmExecutor.java:59` | | `AgentExecutor` | `nodeId` | `resolveOutputVarName(data, "result")` | `AgentExecutor.java:78` | | `SmartActionExecutor` | `nodeId` | `resolveOutputVarName(data, "result")` | `SmartActionExecutor.java:61` | | `HermesAgentExecutor` | `nodeId` | `resolveOutputVarName(data, "result")` | `HermesAgentExecutor.java:79` | | `HermesSmartActionExecutor` | `nodeId` | `resolveOutputVarName(data, "result")` | `HermesSmartActionExecutor.java:63` | | `ConditionExecutor` | `nodeId` | 空输出 `Map.of()` | `ConditionExecutor.java:78,83` | | `UserInputExecutor` | 变量名 | 不变 | - | | `OutputExecutor` | 全部 variables | 不变 | - | --- ## 五、关键设计决策(已确认) | # | 决策点 | 最终选择 | 理由 | |---|---|---|---| | 1 | Context 结构 | **多 section 容器**(B) | 保留扁平 variables,新增 sections 字段隔离扩展 | | 2 | 变量冲突策略 | **后覆盖前 + 警告日志**(A) | 简单直观,日志帮助 debug | | 3 | 上下文快照粒度 | **全量快照**(A) | variables 通常很小(几 KB),N 个节点累加可接受 | | 4 | SSE 推送策略 | **增量推送 + DB 持久化全量**(B) | SSE 维持现状(推 output),全量留作运行历史页面查看 | | 5 | 前置条件 | **仅加变量类型校验**(A) | 不引入硬编码文件名,用 filePath 变量表达更灵活 | | 6 | 实施节奏 | **一次性全做** | 工作量可控,分阶段反而产生中间不一致状态 | | 7 | userInput 节点是否承担上传入口 | **否** | 保留 Controller 上传,userInput 仅声明变量名 | | 8 | 是否本期实现 git section 示范 | **否**(仅预留位置) | YAGNI,未来按需扩展 | | 9 | LLM 多输出实现方式 | **Prompt 注入 + JSON 解析 + 三级降级**(A) | 兼容性最好;不依赖模型 function calling;解析失败有保底 | | 10 | 多输出能力适用范围 | **仅 LLM 节点** | Agent/SmartAction/Hermes 内部已有输出格式控制,叠加结构化指令易冲突,留 TODO | --- ## 六、实施计划 ### Phase 1:契约对齐 + Outputs 规范化 + LLM 结构化输出 **目标**:让所有 executor 按 outputs 规范写键;LLM 节点支持多输出结构化输出 1. `NodeTypeUtils` 新增 `resolveOutputVarName(JsonNode data, String defaultName)` 与 `singleOutput(varName, result)` 方法 2. 新建 `StructuredOutputHelper` 类: - `needsStructuredOutput(JsonNode data)` 判断是否多输出 - `buildInstruction(JsonNode outputs)` 生成 JSON 输出指令 - `parse(String llmResponse, JsonNode outputs)` 解析响应并按字段拆分 - `tryParseJson(String text)` 三级 JSON 提取(直接/代码块/首尾括号) - `convertValue(JsonNode value, String targetType)` 类型转换 3. 改造 `LlmExecutor`: - 单输出(outputs ≤ 1):`Map.of(NodeTypeUtils.resolveOutputVarName(data, "result"), result)` - 多输出(outputs > 1):注入指令 + `StructuredOutputHelper.parse()`;失败降级到 `outputs[0].name` 4. 改造 4 个非 LLM 文本产出型 executor(Agent/SmartAction/HermesAgent/HermesSmartAction): - `Map.of(nodeId, result)` → `Map.of(NodeTypeUtils.resolveOutputVarName(data, "result"), result)` - 多输出场景由 `resolveOutputVarName` 取 outputs[0] + WARN,留 TODO 5. 改造 `ConditionExecutor`:移除无意义的 `Map.of(nodeId, ...)`,改为 `Map.of()` **验证**: - 跑 `userInput → llm_1 → llm_2`(llm_2 引用 `{{result}}`),确认变量能传递 - 跑 `userInput → llm_multi(outputs=[summary, detail]) → output`,确认两个变量都被正确拆分 - 故意让 LLM 输出非 JSON(如修改 prompt 让它返回纯文本),确认降级到 `outputs[0].name`,工作流不报错 - 编译通过 ### Phase 2:Context 扩展槽 + 上下文快照 **目标**:为 debug 视图和未来扩展打基础 4. `WorkflowContext` 新增 `sections: Map` 字段 + getter/setter/`getSection`/`putSection` 5. `NodeExecutionResult` 新增 `contextSnapshot: Map` 字段 6. `WorkflowLevelExecutor.executeByLevel` 在节点 `setNodeOutput` 完成后,计算 `variables` 全量快照塞入 `result.contextSnapshot` 7. `WorkflowContext.setNodeOutput` 增加变量覆盖检测:若 key 已存在,记录 WARN 日志 **验证**: - 单元测试:跑工作流后,每个 NodeExecutionResult 的 contextSnapshot 应包含截至该节点执行后的所有变量 ### Phase 3:持久化 + DB Schema **目标**:让运行历史能回放每个节点的完整上下文 8. `WorkflowRunNode` 实体新增 `contextSnapshot: String` 字段(JSON 字符串) 9. DDL:`ALTER TABLE workflow_run_nodes ADD COLUMN context_snapshot TEXT` 10. `WorkflowLevelExecutor.buildNodeRecord` 序列化 contextSnapshot 写入 11. JSON Repository 适配(项目使用 JSON 文件存储,需确认 Repository 实现是否需要改动) **验证**: - 跑工作流后查 `WorkflowRunNode` 记录,每条都有 `contextSnapshot` 字段 - contextSnapshot 内容为执行后全量 variables ### Phase 4:前置条件强化 **目标**:补变量类型校验 12. `NodeTypeUtils` 新增 `isTypeCompatible(String sourceType, String targetType)` 方法(参考前端 `ioInference.js:127-135`) 13. `WorkflowLevelExecutor.checkPreconditions` 增加类型校验逻辑: - 节点 input 字段声明 type 时,检查 context 中对应变量的实际类型是否兼容 - 不兼容时加入 missing 列表 **验证**: - 测试:llm 节点声明 input `{name:'count', type:'number'}`,但上游传字符串 → 前置条件失败 ### Phase 5:前端 Debug 视图 **目标**:让用户能查看每个节点的完整上下文 14. `RunHistory.vue`(或新增详情页): - 节点列表点击展开 - 显示 `output`(本次输出)+ `contextSnapshot`(执行后完整上下文) - contextSnapshot 以 key-value 表格展示,长值折叠 15. `WorkflowEditor.vue` 运行结果区域: - 现有节点结果卡片增加"查看完整上下文"按钮(点击弹出 contextSnapshot 详情) 16. 输出节点前端补"下载工作目录 zip"按钮(API 已有 `/runs/{runId}/download`) **验证**: - 跑完工作流,点击任意节点能看到当时的完整 variables - 输出节点能下载工作目录 zip --- ## 七、API 与数据契约 ### 7.1 SSE 事件(无变化) | 事件 | 触发时机 | payload | |---|---|---| | `node_running` | 节点开始执行 | `{ runId, nodeId }` | | `node_stream` | 流式增量 | `{ runId, nodeId, kind, content, toolName }` | | `node_status` | 节点完成 | `{ runId, nodeId, status, output, logs }` | | `workflow_complete` | 工作流完成 | `{ runId, output }` | | `workflow_error` | 工作流出错 | `{ runId, error }` | **设计取舍**:SSE 实时仅推送 `output`(增量),完整 `contextSnapshot` 通过运行历史详情 API 查询,避免 SSE 流量爆炸。 ### 7.2 运行历史详情 API(已有,扩展返回字段) ``` GET /api/runs/{runId}/nodes Response: [ { nodeId, nodeType, label, status, sortOrder, output: {...}, // 本次输出 contextSnapshot: {...}, // 【新增】执行后完整 variables error, selectedBranch, logs } ] ``` ### 7.3 工作目录下载 API(已有,无需改动) ``` GET /api/workflows/{id}/runs/{runId}/download Response: application/zip ``` --- ## 八、验证标准(端到端) ### 8.1 契约对齐验证 | 场景 | 预期 | |---|---| | `userInput(x) → llm_1(输出 result) → llm_2(引用 {{result}})` | llm_2 能取到 llm_1 的输出 | | 同上,llm_1 自定义 outputs 名为 `summary` | llm_2 引用 `{{summary}}` 能取到 | | 两个 llm 节点都默认输出 `result` | 后者覆盖前者,日志记录警告 | | `condition` 节点不再向 variables 写入 nodeId | 下游 variables 不含 nodeId key | ### 8.2 LLM 结构化输出验证 | 场景 | 预期 | |---|---| | llm 节点声明 outputs=[summary, detail],模型严格输出 JSON | 两个变量分别赋值 | | 模型用 ` ```json...``` ` 代码块包裹 JSON | 仍能正确解析(二级容错) | | 模型在 JSON 前后加解释文字 | 仍能正确解析(三级容错) | | 模型完全输出纯文本(非 JSON) | 降级:全文写入 `outputs[0].name`,WARN 日志,工作流不报错 | | 模型输出 JSON 缺少某个字段 | 该字段赋值 null,WARN 日志,其他字段正常赋值 | | outputs 声明 number/boolean/array/object 类型 | 按类型转换;转换失败保留字符串 + WARN | ### 8.3 上下文快照验证 | 场景 | 预期 | |---|---| | 跑完 5 节点工作流,查运行历史 | 每个 WorkflowRunNode 都有 contextSnapshot | | 第 3 个节点的 contextSnapshot | 包含节点 1+2+3 的累积输出 | | 最后一个节点的 contextSnapshot | 等于工作流最终 variables | ### 8.4 前置条件验证 | 场景 | 预期 | |---|---| | 节点 input required 变量未提供 | 失败,按 failStrategy 处理 | | 节点 input filePath 变量指向不存在的文件 | 失败 | | 节点 input number 类型变量收到字符串 | 失败(Phase 4 后) | ### 8.5 前端 Debug 视图验证 | 场景 | 预期 | |---|---| | 运行历史详情页点击任意节点 | 显示该节点 output + contextSnapshot | | 输出节点点击"下载工作目录" | 浏览器下载 zip | --- ## 九、风险与缓解 | 风险 | 影响 | 缓解 | |---|---|---| | 存量 graphData 中 outputs 字段缺失 | 节点 key 退化为默认值 `result`,可能改变存量工作流行为 | 默认值与前端 ioInference 一致(`result`),实际行为对齐用户预期,不视为破坏性 | | variables 过大导致 contextSnapshot 消耗 DB 空间 | 长期积累存储压力 | 监控;如需要可加配置开关或定期清理 | | LLM 不严格按 JSON 格式输出 | 多输出场景拆分失败 | 三级 JSON 提取容错(直接/代码块/首尾括号);解析失败降级到 `outputs[0].name` 写整段文本,工作流不中断 | | LLM 输出 JSON 缺字段 | 部分变量未赋值 | 缺失字段赋 null + WARN 日志;其他字段正常赋值;不阻塞工作流 | | 类型转换失败(如 number 收到字符串 "abc") | 变量类型与声明不符 | 保留字符串原值 + WARN 日志;下游模板渲染按字符串处理 | | 变量覆盖静默发生难调试 | 下游取到错误值难定位 | 引擎记录 WARN 日志,包含被覆盖的 key 和两个 nodeId | | Hermes 流式输出与上下文快照混入 SSE | 前端处理混乱 | contextSnapshot 不走 SSE,仅持久化到 DB;SSE 流维持现状 | | Agent/SmartAction 多输出被忽略 | 用户期望 Agent 单节点产出多个变量 | 本期不支持,WARN 提示,留 TODO;用户可改用 LLM 节点或拆成多个节点 | --- ## 十、实施代码量预估 | 模块 | 文件数 | 代码行数(估) | |---|---|---| | `NodeTypeUtils` 新方法(resolveOutputVarName + singleOutput) | 1 | +30 | | **`StructuredOutputHelper` 新建(含三级 JSON 提取 + 类型转换)** | **1** | **+120** | | **`LlmExecutor` 改造(单/多输出分支)** | 1 | +30 | | Executor 改造(4 个非 LLM 文本型 + 1 个 condition) | 5 | ~6 行/文件 | | `WorkflowContext.sections` | 1 | +20 | | `NodeExecutionResult.contextSnapshot` | 1 | +15 | | `WorkflowLevelExecutor` 快照逻辑 | 1 | +20 | | `WorkflowRunNode` 实体 + Repository | 2 | +10 | | `checkPreconditions` 类型校验 | 1 | +25 | | 前端 RunHistory / WorkflowEditor 扩展 | 2-3 | +150 | | **合计** | ~16 | ~430 | --- ## 十一、未决事项(仅记录,本期不实施) 1. **Agent / SmartAction / Hermes 系列节点的多输出支持**:本期仅 LLM 节点支持结构化多输出。Agent 节点的输出受 Skill 内容控制、Hermes 节点的输出受 Bridge 工作流控制,叠加结构化指令易冲突。未来如需要可考虑:(a) 让 SKILL.md 声明 outputs schema;(b) 在 Hermes Bridge 协议中增加 structured output 选项 2. **git section 示范**:仅预留位置,未来按需实现具体的 git 状态采集 3. **section 读写权限**:当前任何 executor 都可读写 sections,未来可能需要权限模型 4. **variables 大小限制**:当前无限制,未来可能需要单变量大小或总 variables 大小限制 5. **contextSnapshot 压缩**:当前直接 JSON 序列化,未来如体积大可考虑 diff 或压缩 6. **LLM 结构化输出强化**:当前用 Prompt 注入方式,未来可考虑切换为 OpenAI 风格的 `response_format: json_schema`(需模型支持),获得更强保证 --- ## 附录 A:现有 Executor 输出 key Bug 列表 | 文件 | 行号 | 当前代码 | 应改为 | |---|---|---|---| | `LlmExecutor.java` | 59 | `Map.of(nodeId, result)` | 单输出:`Map.of(resolveOutputVarName(data, "result"), result)`
多输出:`StructuredOutputHelper.parse()` 拆分,失败降级到 `outputs[0].name` | | `AgentExecutor.java` | 78 | `Map.of(nodeId, result)` | `Map.of(resolveOutputVarName(data, "result"), result)` | | `SmartActionExecutor.java` | 61 | `Map.of(nodeId, result)` | `Map.of(resolveOutputVarName(data, "result"), result)` | | `HermesAgentExecutor.java` | 79 | `Map.of(nodeId, runResult.getFinalText())` | `Map.of(resolveOutputVarName(data, "result"), runResult.getFinalText())` | | `HermesSmartActionExecutor.java` | 63 | `Map.of(nodeId, runResult.getFinalText())` | `Map.of(resolveOutputVarName(data, "result"), runResult.getFinalText())` | | `ConditionExecutor.java` | 78, 83 | `Map.of(nodeId, "branch-N")` | `Map.of()` | ## 附录 B:前端 ioInference.js 默认 outputs 一览 | 节点类型 | `getNodeOutputs` 默认返回 | 后端 fallback 变量名 | |---|---|---| | `userInput` | `data.variables` 数组 | (无 fallback,直接用 variables) | | `llm` | `data.outputs` 或 `[{name:'result'}]` | `result` | | `agent` | `data.outputs` 或 `[]` | `result` | | `skill` | `data.outputs` 或 `[]` | `result` | | `condition` | 透传 inputs | (路由靠 sourceHandle,无输出) | | `output` | `[]` | (终端节点) | **前后端 fallback 一致性**:本方案确保后端 `resolveOutputVarName` 的默认值与前端 `ioInference.getNodeOutputs` 的默认值一致(`result`)。