# 工作流上下文传递架构方案

> **状态**：待用户确认
> **日期**：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<String, Object> variables;     // 用户变量（扁平累积）
    private final Path workingDir;                   // 工作目录（已有）
    private final List<NodeOutput> nodeOutputs;      // 累积记录（已有）
    private final Map<String, Object> 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<String, Object> output;         // 仅本次输出
    private final Map<String, Object> 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<String, Object> 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<varName, value>。
     * 解析失败返回 null（调用方降级处理）。
     */
    public static Map<String, Object> parse(String llmResponse, JsonNode outputs) {
        if (llmResponse == null || llmResponse.isBlank()) return null;
        JsonNode parsed = tryParseJson(llmResponse);
        if (parsed == null || !parsed.isObject()) return null;

        Map<String, Object> 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)` ❌ |
| **改动** | 分两种场景：<br>• **单输出（outputs ≤ 1）**：`Map.of(NodeTypeUtils.resolveOutputVarName(data, "result"), result)`<br>• **多输出（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<String, Object> output;
    if (structured) {
        Map<String, Object> 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")`<br>多输出：`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<String,Object>` 字段 + getter/setter/`getSection`/`putSection`
5. `NodeExecutionResult` 新增 `contextSnapshot: Map<String,Object>` 字段
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)`<br>多输出：`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`）。
