workflow-context-passing-design.md 30 KB

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

状态:待用户确认 日期: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 中重复出现:LlmExecutorAgentExecutorSmartActionExecutorHermesAgentExecutorHermesSmartActionExecutor

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(扩展)

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(扩展)

public class NodeExecutionResult {
    private final Map<String, Object> output;         // 仅本次输出
    private final Map<String, Object> contextSnapshot; // 【新增】执行后完整 variables 快照
    // ... 其他字段不变
}

WorkflowRunNode 实体(扩展)

public class WorkflowRunNode {
    // ... 已有字段
    private String output;             // 已有:本次输出 JSON
    private String contextSnapshot;    // 【新增】执行后完整上下文 JSON
}

3.2 节点输出辅助方法

3.2.1 单输出场景统一辅助方法(NodeTypeUtils

适用于所有"产出单一文本结果"的节点(Agent/SmartAction/Hermes 系列)。LlmExecutor 不使用此方法,单独走结构化输出路径(见 3.2.2)。

/**
 * 根据节点 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 格式输出,再解析拆分到各变量。

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:39output.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 改造后伪代码

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")
多输出: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 视图和未来扩展打基础

  1. WorkflowContext 新增 sections: Map<String,Object> 字段 + getter/setter/getSection/putSection
  2. NodeExecutionResult 新增 contextSnapshot: Map<String,Object> 字段
  3. WorkflowLevelExecutor.executeByLevel 在节点 setNodeOutput 完成后,计算 variables 全量快照塞入 result.contextSnapshot
  4. WorkflowContext.setNodeOutput 增加变量覆盖检测:若 key 已存在,记录 WARN 日志

验证

  • 单元测试:跑工作流后,每个 NodeExecutionResult 的 contextSnapshot 应包含截至该节点执行后的所有变量

Phase 3:持久化 + DB Schema

目标:让运行历史能回放每个节点的完整上下文

  1. WorkflowRunNode 实体新增 contextSnapshot: String 字段(JSON 字符串)
  2. DDL:ALTER TABLE workflow_run_nodes ADD COLUMN context_snapshot TEXT
  3. WorkflowLevelExecutor.buildNodeRecord 序列化 contextSnapshot 写入
  4. JSON Repository 适配(项目使用 JSON 文件存储,需确认 Repository 实现是否需要改动)

验证

  • 跑工作流后查 WorkflowRunNode 记录,每条都有 contextSnapshot 字段
  • contextSnapshot 内容为执行后全量 variables

Phase 4:前置条件强化

目标:补变量类型校验

  1. NodeTypeUtils 新增 isTypeCompatible(String sourceType, String targetType) 方法(参考前端 ioInference.js:127-135
  2. WorkflowLevelExecutor.checkPreconditions 增加类型校验逻辑:
    • 节点 input 字段声明 type 时,检查 context 中对应变量的实际类型是否兼容
    • 不兼容时加入 missing 列表

验证

  • 测试:llm 节点声明 input {name:'count', type:'number'},但上游传字符串 → 前置条件失败

Phase 5:前端 Debug 视图

目标:让用户能查看每个节点的完整上下文

  1. RunHistory.vue(或新增详情页):
    • 节点列表点击展开
    • 显示 output(本次输出)+ contextSnapshot(执行后完整上下文)
    • contextSnapshot 以 key-value 表格展示,长值折叠
  2. WorkflowEditor.vue 运行结果区域:
    • 现有节点结果卡片增加"查看完整上下文"按钮(点击弹出 contextSnapshot 详情)
  3. 输出节点前端补"下载工作目录 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)。