# 工作流节点输出统一包装（`{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<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()` 方法**：

```java
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);
}
```

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<String> missingRequired;  // 缺失的必填字段
        public final List<String> typeMismatched;    // 类型不匹配的字段
    }

    public static ValidationResult validate(Map<String, Object> 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<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 一致的结构化输出。

```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<String, Object> data = Map.of("selectedBranch", sourceHandle);
return NodeExecutionResult.success(nodeId,
    NodeOutputEnvelope.success("条件路由完成：" + sourceHandle, data).toMap(), sourceHandle);
```

#### 2.6.6 OutputExecutor

合并所有前置 envelope.data：

```java
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` 失败处理

```java
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
```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 徽章
