# 节点上下文按需获取方案 > 状态:设计草案 > 关联:[workflow-envelope-strict-validation-plan.md](./workflow-envelope-strict-validation-plan.md)、[workflow-variable-scope-design.md](./workflow-variable-scope-design.md) ## 一、背景与目标 ### 1.1 当前痛点 工作流节点之间的上下文传递目前存在**两个独立诉求**: | 诉求 | 当前现状 | 问题 | |---|---|---| | **按需获取工作区/输出变量** | `NodeWorkspaceBuilder.build()` 把**所有可达前驱**的 envelope.data 全量塞进 `variables`(`NodeWorkspaceBuilder.java:63-76`) | prompt 模板渲染时 token 浪费、字段污染、隐式四级模糊匹配有歧义 | | **获取前序节点的思考过程** | thinking / tool_call **只进 `NodeExecutionResult.logs` 字段**(`ExecutionLog.java:8`),**不进 envelope,不进 `nodeScopedOutputs`** | 下游节点**完全拿不到**前序 Agent 的思考轨迹,只能消费最终结果 | ### 1.2 设计目标 - 节点编辑时能**精确声明**需要的字段(强依赖) - LLM/Agent 类节点运行时能**按需探索**前序数据(弱依赖) - 思考过程 / 工具调用轨迹能被下游消费(trace 可读) - 不破坏现有 envelope 规范与 NodeInputResolver 显式 mapping 能力 --- ## 二、当前架构现状(代码基线) ### 2.1 上下文容器 `WorkflowContext`(`WorkflowContext.java:17`)核心字段: - `nodeScopedOutputs: Map>` — 节点命名空间隔离的输出表(核心) - `initialInputs` — 扁平的运行级输入(来自外部调用) - `nodeOutputs` — 按执行顺序累积的 `List` - `sections` — 扩展槽(`WorkflowContext.java:32-36` 注释提及 `_git/_memory/_runtime`,**目前死代码**) ### 2.2 节点输出信封 `NodeOutputEnvelope`(`NodeOutputEnvelope.java:26`): ``` { status: 200|400, message: "...", data: { ...画布声明的字段 } } ``` **关键约束**:只有 `data` 字段内的内容会被后继节点消费;`status` / `message` 是壳层。 ### 2.3 节点工作区构建 `NodeWorkspaceBuilder`(`NodeWorkspaceBuilder.java:24`)是**节点执行前的"上下文投影器"**——从全局 `WorkflowContext` 中提取当前节点可见的子集,构造 `NodeWorkspace` 视图给执行器使用。**不修改全局上下文,只读 + 投影。** #### 2.3.1 核心数据结构 `NodeWorkspace`(`NodeWorkspace.java:19`)持有两张表: | 字段 | 类型 | 用途 | |---|---|---| | `variables` | `Map` | 扁平变量表,可直接通过 `{{varName}}` 在模板中引用 | | `scopedOutputs` | `Map>` | 按节点命名空间隔离的输出,envelope 原貌保留 | 分隔符(`NodeWorkspaceBuilder.java:27`):`NODE_FIELD_SEPARATOR = "__"`,双下划线避免 nodeId 自身含下划线时产生歧义(如 `node_1__field` 不会被解析为 `node` + `1__field`)。 #### 2.3.2 注入流程(3 步) 入口:`build()`(`NodeWorkspaceBuilder.java:50-82`) **步骤 1:初始输入扁平注入**(`L56`) ```java workspace.getVariables().putAll(context.getInitialInputs()); ``` 外部调用工作流时传入的 `inputs` 以扁平形式塞入 `variables`,不带前缀。例如外部传入 `{ "user_query": "..." }`,则 `variables["user_query"]` 可直接通过 `{{user_query}}` 引用。 **步骤 2:可达前驱输出注入**(`L58-76`) 1. `WorkflowScopeResolver.getReachablePredecessors` 根据 DAG 拓扑 + 运行时活跃边(条件分支)计算可达前驱集合 2. `orderByLevels` 按 DAG 层级排序(先执行的层级在前,影响后续模糊匹配优先级) 3. 遍历每个可达前驱: - envelope **原貌**存入 `scopedOutputs[predecessorId]`(保留 status/message,便于查状态、做条件分支判断) - envelope.data **解包**后以 `{predecessorId}__{varName}` 形式扁平注入 `variables` **步骤 3:扫描工作目录文件**(`L78-79`) 扫描本次 run 的 `workingDir`,经 `.agentignore` 过滤后,得到相对路径列表,供节点引用文件类变量。 #### 2.3.3 envelope 解包逻辑 `unwrapDataView`(`NodeWorkspaceBuilder.java:91-98`): - 若输出是 envelope 结构(同时含 `status` 与 `data`),返回 **data 内部 Map** - 否则原样返回(legacy 兼容) **作用**:避免 `variables` 里出现 `node_abc__status`、`node_abc__message` 这种壳字段,只暴露业务字段。 #### 2.3.4 注入后的下游消费 执行器通过三种方式消费 `NodeWorkspace`: 1. **直接 API 访问**:`NodeWorkspace.java:46-85` 提供 `getVariable` / `getScopedOutput` / `getScopedDataView` / `getScopedStatus`,全部做 envelope 透明穿透 2. **模板渲染**:`TemplateRenderer`(`TemplateRenderer.java:18`)支持 `{{varName}}`(取扁平表)与 `{{nodeId.varName}}`(取命名空间)两种语法 3. **输入解析器**:`NodeInputResolver`(`NodeInputResolver.java:57`)在 NodeWorkspace 之上再做一层解析,把节点画布声明的 `inputs[]` 按"显式 mapping → 初始输入 → 四级隐式 fallback"顺序填值,结果写回 `variables` #### 2.3.5 当前注入方式的核心问题 基于代码客观分析: 1. **全量铺平 → token 浪费**:所有可达前驱的 data 字段全部塞入 `variables`。20 个前驱 × 5 个字段 = 100 个带前缀 key,但当前节点可能只用到 2 个。prompt 模板渲染时上下文冗长,字段污染可读性差 2. **思考过程不可达**:`unwrapDataView` 只解包 `data`;thinking / tool_call 存在 `NodeExecutionResult.logs` 里,NodeWorkspaceBuilder 不会采集,下游任何方式都拿不到 3. **隐式四级模糊匹配依赖全量铺平**:`NodeInputResolver.java:117-159` 的四级 fallback 之所以能工作,前提是 `scopedOutputs` 里有全部可达前驱的 envelope。第一阶段"按 mapping 注入"的改造需保留兼容期开关 4. **可达前驱集 = 全局可达**:`WorkflowScopeResolver.getReachablePredecessors` 计算的是"从起始节点到当前节点的所有可达前驱",不是"用户声明的前驱",与"按需"理念冲突 #### 2.3.6 数据流图 ``` ┌─────────────────────────────────────────────────────────┐ │ WorkflowContext │ │ nodeScopedOutputs: │ │ "nodeA" -> {status, message, data:{x:1,y:2}} │ │ "nodeB" -> {status, message, data:{z:3}} │ │ initialInputs: { user_query: "..." } │ └─────────────────────────────────────────────────────────┘ │ │ build("nodeC", dag, ctx) ▼ ┌─────────────────────────────────────────────────────────┐ │ NodeWorkspace(nodeC) │ │ variables (扁平): │ │ user_query -> "..." │ │ nodeA__x -> 1 │ │ nodeA__y -> 2 │ │ nodeB__z -> 3 │ │ scopedOutputs (envelope 原貌): │ │ "nodeA" -> {status:200, message, data:{x,y}} │ │ "nodeB" -> {status:200, message, data:{z}} │ │ files: ["a.txt", "b.txt"] │ └─────────────────────────────────────────────────────────┘ │ │ NodeInputResolver + TemplateRenderer ▼ 节点执行器消费 ``` ### 2.4 输入解析 `NodeInputResolver.resolveInputs()`(`NodeInputResolver.java:57-82`)对节点声明的每个 inputs 字段: 1. 显式 mapping(`sourceNodeId + sourceField + sourcePath?`) 2. 初始输入扁平取值 3. 四级隐式 fallback(拓扑逆序,最近前驱优先): - 3.1 精确同名 - 3.2 忽略 `[-_]` 与大小写同名(穿透 envelope.data) - 3.3 JSON 嵌套路径精确匹配 - 3.4 JSON 嵌套路径模糊匹配 ### 2.5 思考过程的存储现状 - **Hermes 节点**:`HermesBridgeClient.parseSseResponse` 把 `text` 事件转为 `ExecutionLog.thinking(delta)`,同时通过 `sink.emit("thinking", delta)` 实时推送 SSE - **持久化**:`NodeExecutionResult.logs` 序列化到 `WorkflowRunNode.logs` 列 - **关键限制**:`logs` **未写入** `nodeScopedOutputs`,也未进入 envelope;下游 NodeInputResolver 无法通过任何级别匹配读到 thinking --- ## 三、候选方案对比 ### 方案 A:扩展 envelope + 显式声明引用 ``` NodeOutputEnvelope { status, message, data: { ...声明字段 }, trace: { thinking, tool_calls, intermediate_steps }, // 新增 metadata: { tokens, latency } // 可选 } ``` 节点 inputs 声明支持 `sourceField: "trace.thinking"`、`"trace.tool_calls[0].result"` 等路径。 - ✅ 与现有 NodeInputResolver 兼容、精确可控 - ✅ 平滑升级,envelope 是单点扩展 - ❌ 仍是"声明式预注入",节点运行时不能动态决策 - ❌ 单独无法解决"prompt 太长"问题 ### 方案 B:LLM 工具注入(Function Calling 形式) 引擎为 LLM/Hermes 节点注入内置工具: ``` workflow.context.get(nodeId, field, path) workflow.context.list_predecessors() workflow.context.get_trace(nodeId, step_index?) ``` 模型/Agent 自己决定何时调用、取什么。 - ✅ 真正"按需",模型自主探索 - ✅ 与 Hermes 已有的工具调用机制天然契合(`hermes_bridge.py` 已经在注册工具) - ✅ 与 Dify Agent V2 的 ask_human、deferred_tool_call 一脉相承 - ❌ 消耗一次 LLM 调用决策 - ❌ 仅适用于 LLM 类节点,普通节点(条件/输出)用不上 - ❌ 模型可能"忘了调",需要 prompt 引导 ### 方案 C:MCP Server 形态 把工作流引擎上下文做成 MCP server,暴露统一查询接口。 - ✅ 标准协议、可被外部 Agent 复用 - ✅ 与 Hermes / 未来接入的其他 Agent 完全解耦 - ❌ 协议层复杂度大幅上升 - ❌ 当前项目 0 处 MCP 代码,等于从零搭协议栈 - ❌ 对单进程内的工作流场景,HTTP/SSE 开销过大 ### 方案 D:分桶 + 选择性注入 节点 inputs 配置时选择"桶":`data-only` / `data+trace` / `workspace-only`,模板渲染按桶展开。 - ✅ 与现有机制平滑升级 - ❌ 本质还是声明式,能力上限低 --- ## 四、推荐方案:A + B 混合(分阶段实施) ### 核心判断 - **节点类型不同,需求不同**:LLM/Agent 类节点适合工具形式,普通节点(条件/输出/输入)只能声明式 - envelope + 显式 mapping + 模糊回退已经做了,方案 A 是顺水推舟 - Hermes 节点本质就在调用工具,再加几个上下文查询工具成本极低 - MCP 留到要接外部 Agent 时再做,短期复杂度收益不匹配 ### 第一阶段(必须做):方案 A — 让思考过程可被消费 这是基础设施,不做的话后面所有方案都无米下炊。 #### 4.1 扩展 NodeOutputEnvelope `NodeOutputEnvelope.java:26` 增加 `trace` 与 `metadata` 字段: ```java public final class NodeOutputEnvelope { private final Map trace; // 结构化执行轨迹 private final Map metadata; // token 用量、耗时等 // 工厂方法: public static NodeOutputEnvelope success(String message, Map data, Map trace, Map metadata) { ... } // 序列化 / 反序列化同步扩展 toMap() / fromObject() / isEnvelope() } ``` trace 的标准字段约定: ```json { "thinking": "...", // 思考片段(可分段拼接) "tool_calls": [ // 工具调用列表 { "name": "terminal.exec", "args": {...}, "result": "...", "success": true } ], "reasoning": "...", // 推理过程(与 thinking 二选一或并存) "intermediate_steps": [...] // ReAct 类中间步骤 } ``` #### 4.2 Hermes 节点写入 trace `HermesAgentExecutor.java:87-91` 当前只写单字段 `finalText`,应改造为: ```java Map data = Map.of("result", finalText); Map trace = Map.of( "thinking", accumulatedThinking, "tool_calls", toolCallHistory ); return NodeOutputEnvelope.success("调用成功", data, trace, metadata); ``` #### 4.3 NodeInputResolver 支持 trace 路径 扩展 `sourceField` 解析(`NodeInputResolver.java:88-108`),允许: - `"data.result"` — 等价于现 `data.result`(向后兼容) - `"trace.thinking"` — 取思考过程 - `"trace.tool_calls[0].result"` — 取某次工具调用结果 实现要点:`sourceField` 解析为相对路径,根为 envelope 本身(而非 data)。建议保留 `data` 为默认根以向后兼容。 #### 4.4 NodeWorkspaceBuilder 改为按 mapping 注入 **关键改造点**:`NodeWorkspaceBuilder.java:63-76` 当前把所有可达前驱的 data 全量塞入 `variables`,应改为: - `scopedOutputs` 仍保留 envelope 原貌(不变) - `variables` 只在节点声明了显式 mapping 时注入对应字段,**不再全量铺平** - 隐式四级 fallback 仍可使用 `scopedOutputs`,但不再"先扁平化、再匹配" **注意**:这是一次行为破坏性变更,需配套: - 兼容期保留开关 `workflow.workspace.legacy-flat-inject=true` - 单元测试覆盖现有依赖隐式 fallback 的工作流 - 文档同步更新 [workflow-variable-scope-design.md](./workflow-variable-scope-design.md) ### 第二阶段(按需做):方案 B — LLM 节点工具化探索 在 LLM/Hermes 节点执行前,向其注入内置工具: ```java public class WorkflowContextTool { @Tool(name = "workflow.context.get", desc = "按 nodeId 和字段路径查询前驱节点的输出或思考过程;field 可为 data/trace/metadata") public Object get(String nodeId, String field, String path) { // 从 WorkflowContext.nodeScopedOutputs 取值 } @Tool(name = "workflow.context.list", desc = "列出所有可达前驱节点及其可读字段(含 data/trace/metadata)") public List list() { ... } } ``` 适用场景: - 节点不知道该引用哪个前驱时(如"判断条件"前的探索) - Agent 需要"翻看"前序 Agent 的思考轨迹做决策 - 长链路工作流中,避免 prompt 里堆满所有中间结果 **注入方式**: - LLM 节点:在 `StructuredOutputHelper` 构造 prompt 时,把工具描述追加到 system prompt(OpenAI function calling) - Hermes 节点:在 `HermesBridgeClient` 调用 Bridge 前,通过 `register_tool` 注册到 hermes-agent ### 第三阶段(远期):方案 C — MCP 化 如果未来要支持**外部 Agent 接入工作流**(比如 Claude Code、外部 Hermes 实例查询),再把第一、二阶段的查询能力封装为 MCP server。 **短期不建议做**,原因: - 当前项目 0 处 MCP 代码 - 单进程内工作流场景 HTTP/SSE 开销过大 - 优先级低于第一阶段的基础能力建设 --- ## 五、关键设计原则 无论选哪个方案,以下几点必须遵守: 1. **trace 必须结构化**,不能只存 `finalText` 字符串。否则下游消费不了 2. **按需 ≠ 完全工具化**。普通节点(条件、输出)没有 LLM 决策能力,必须保留声明式入口 3. **NodeWorkspaceBuilder 改造是关键**(`NodeWorkspaceBuilder.java:24`):从"全量预注入"改为"按 mapping 注入",这是 token 节省的最大来源,比加工具更立竿见影 4. **失败 envelope 的 trace 也要写**(`WorkflowLevelExecutor.java:539-544`):失败时的工具调用轨迹对下游调试极有价值 5. **trace 应支持节流**:Hermes 长 thinking 可能几千 tokens,建议在 envelope 落盘时做长度限制或分段存储 --- ## 六、实施阶段总览 | 阶段 | 内容 | 优先级 | 风险 | |---|---|---|---| | 一 | envelope 扩展 trace + Hermes 节点写入 trace + NodeInputResolver 支持 trace 路径 | 高 | 低,向后兼容 | | 一 | NodeWorkspaceBuilder 改为按 mapping 注入 | 高 | 中,破坏性变更,需兼容期 | | 二 | 注入 `workflow.context.*` 内置工具 | 中 | 中,需 prompt 引导 | | 三 | MCP 化查询能力 | 低 | 高,协议层复杂 | --- ## 七、一句话总结 > **存储用方案 A(envelope 加 trace 字段),LLM 节点查询用方案 B(注入 workflow.context.* 工具),普通节点继续走显式 mapping。MCP 留到要接外部 Agent 时再做。**