node-context-ondemand-plan.md 17 KB

节点上下文按需获取方案

状态:设计草案 关联:workflow-envelope-strict-validation-plan.mdworkflow-variable-scope-design.md

一、背景与目标

1.1 当前痛点

工作流节点之间的上下文传递目前存在两个独立诉求

诉求 当前现状 问题
按需获取工作区/输出变量 NodeWorkspaceBuilder.build()所有可达前驱的 envelope.data 全量塞进 variablesNodeWorkspaceBuilder.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 上下文容器

WorkflowContextWorkflowContext.java:17)核心字段:

  • nodeScopedOutputs: Map<nodeId, Map<varName, value>> — 节点命名空间隔离的输出表(核心)
  • initialInputs — 扁平的运行级输入(来自外部调用)
  • nodeOutputs — 按执行顺序累积的 List<NodeOutput>
  • sections — 扩展槽(WorkflowContext.java:32-36 注释提及 _git/_memory/_runtime目前死代码

2.2 节点输出信封

NodeOutputEnvelopeNodeOutputEnvelope.java:26):

{ status: 200|400, message: "...", data: { ...画布声明的字段 } }

关键约束:只有 data 字段内的内容会被后继节点消费;status / message 是壳层。

2.3 节点工作区构建

NodeWorkspaceBuilderNodeWorkspaceBuilder.java:24)是节点执行前的"上下文投影器"——从全局 WorkflowContext 中提取当前节点可见的子集,构造 NodeWorkspace 视图给执行器使用。不修改全局上下文,只读 + 投影。

2.3.1 核心数据结构

NodeWorkspaceNodeWorkspace.java:19)持有两张表:

字段 类型 用途
variables Map<String, Object> 扁平变量表,可直接通过 {{varName}} 在模板中引用
scopedOutputs Map<nodeId, Map<varName, value>> 按节点命名空间隔离的输出,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

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 解包逻辑

unwrapDataViewNodeWorkspaceBuilder.java:91-98):

  • 若输出是 envelope 结构(同时含 statusdata),返回 data 内部 Map
  • 否则原样返回(legacy 兼容)

作用:避免 variables 里出现 node_abc__statusnode_abc__message 这种壳字段,只暴露业务字段。

2.3.4 注入后的下游消费

执行器通过三种方式消费 NodeWorkspace

  1. 直接 API 访问NodeWorkspace.java:46-85 提供 getVariable / getScopedOutput / getScopedDataView / getScopedStatus,全部做 envelope 透明穿透
  2. 模板渲染TemplateRendererTemplateRenderer.java:18)支持 {{varName}}(取扁平表)与 {{nodeId.varName}}(取命名空间)两种语法
  3. 输入解析器NodeInputResolverNodeInputResolver.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.parseSseResponsetext 事件转为 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 增加 tracemetadata 字段:

public final class NodeOutputEnvelope {
    private final Map<String, Object> trace;     // 结构化执行轨迹
    private final Map<String, Object> metadata;  // token 用量、耗时等

    // 工厂方法:
    public static NodeOutputEnvelope success(String message, Map<String, Object> data,
                                             Map<String, Object> trace, Map<String, Object> metadata) { ... }

    // 序列化 / 反序列化同步扩展 toMap() / fromObject() / isEnvelope()
}

trace 的标准字段约定:

{
  "thinking": "...",              // 思考片段(可分段拼接)
  "tool_calls": [                 // 工具调用列表
    { "name": "terminal.exec", "args": {...}, "result": "...", "success": true }
  ],
  "reasoning": "...",             // 推理过程(与 thinking 二选一或并存)
  "intermediate_steps": [...]     // ReAct 类中间步骤
}

4.2 Hermes 节点写入 trace

HermesAgentExecutor.java:87-91 当前只写单字段 finalText,应改造为:

Map<String, Object> data = Map.of("result", finalText);
Map<String, Object> 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

第二阶段(按需做):方案 B — LLM 节点工具化探索

在 LLM/Hermes 节点执行前,向其注入内置工具:

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<NodeInfo> 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 时再做。