状态:设计草案 关联:workflow-envelope-strict-validation-plan.md、workflow-variable-scope-design.md
工作流节点之间的上下文传递目前存在两个独立诉求:
| 诉求 | 当前现状 | 问题 |
|---|---|---|
| 按需获取工作区/输出变量 | NodeWorkspaceBuilder.build() 把所有可达前驱的 envelope.data 全量塞进 variables(NodeWorkspaceBuilder.java:63-76) |
prompt 模板渲染时 token 浪费、字段污染、隐式四级模糊匹配有歧义 |
| 获取前序节点的思考过程 | thinking / tool_call 只进 NodeExecutionResult.logs 字段(ExecutionLog.java:8),不进 envelope,不进 nodeScopedOutputs |
下游节点完全拿不到前序 Agent 的思考轨迹,只能消费最终结果 |
WorkflowContext(WorkflowContext.java:17)核心字段:
nodeScopedOutputs: Map<nodeId, Map<varName, value>> — 节点命名空间隔离的输出表(核心)initialInputs — 扁平的运行级输入(来自外部调用)nodeOutputs — 按执行顺序累积的 List<NodeOutput>sections — 扩展槽(WorkflowContext.java:32-36 注释提及 _git/_memory/_runtime,目前死代码)NodeOutputEnvelope(NodeOutputEnvelope.java:26):
{ status: 200|400, message: "...", data: { ...画布声明的字段 } }
关键约束:只有 data 字段内的内容会被后继节点消费;status / message 是壳层。
NodeWorkspaceBuilder(NodeWorkspaceBuilder.java:24)是节点执行前的"上下文投影器"——从全局 WorkflowContext 中提取当前节点可见的子集,构造 NodeWorkspace 视图给执行器使用。不修改全局上下文,只读 + 投影。
NodeWorkspace(NodeWorkspace.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)。
入口:build()(NodeWorkspaceBuilder.java:50-82)
步骤 1:初始输入扁平注入(L56)
workspace.getVariables().putAll(context.getInitialInputs());
外部调用工作流时传入的 inputs 以扁平形式塞入 variables,不带前缀。例如外部传入 { "user_query": "..." },则 variables["user_query"] 可直接通过 {{user_query}} 引用。
步骤 2:可达前驱输出注入(L58-76)
WorkflowScopeResolver.getReachablePredecessors 根据 DAG 拓扑 + 运行时活跃边(条件分支)计算可达前驱集合orderByLevels 按 DAG 层级排序(先执行的层级在前,影响后续模糊匹配优先级)scopedOutputs[predecessorId](保留 status/message,便于查状态、做条件分支判断){predecessorId}__{varName} 形式扁平注入 variables步骤 3:扫描工作目录文件(L78-79)
扫描本次 run 的 workingDir,经 .agentignore 过滤后,得到相对路径列表,供节点引用文件类变量。
unwrapDataView(NodeWorkspaceBuilder.java:91-98):
status 与 data),返回 data 内部 Map作用:避免 variables 里出现 node_abc__status、node_abc__message 这种壳字段,只暴露业务字段。
执行器通过三种方式消费 NodeWorkspace:
NodeWorkspace.java:46-85 提供 getVariable / getScopedOutput / getScopedDataView / getScopedStatus,全部做 envelope 透明穿透TemplateRenderer(TemplateRenderer.java:18)支持 {{varName}}(取扁平表)与 {{nodeId.varName}}(取命名空间)两种语法NodeInputResolver(NodeInputResolver.java:57)在 NodeWorkspace 之上再做一层解析,把节点画布声明的 inputs[] 按"显式 mapping → 初始输入 → 四级隐式 fallback"顺序填值,结果写回 variables基于代码客观分析:
variables。20 个前驱 × 5 个字段 = 100 个带前缀 key,但当前节点可能只用到 2 个。prompt 模板渲染时上下文冗长,字段污染可读性差unwrapDataView 只解包 data;thinking / tool_call 存在 NodeExecutionResult.logs 里,NodeWorkspaceBuilder 不会采集,下游任何方式都拿不到NodeInputResolver.java:117-159 的四级 fallback 之所以能工作,前提是 scopedOutputs 里有全部可达前驱的 envelope。第一阶段"按 mapping 注入"的改造需保留兼容期开关WorkflowScopeResolver.getReachablePredecessors 计算的是"从起始节点到当前节点的所有可达前驱",不是"用户声明的前驱",与"按需"理念冲突┌─────────────────────────────────────────────────────────┐
│ 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
▼
节点执行器消费
NodeInputResolver.resolveInputs()(NodeInputResolver.java:57-82)对节点声明的每个 inputs 字段:
sourceNodeId + sourceField + sourcePath?)[-_] 与大小写同名(穿透 envelope.data)HermesBridgeClient.parseSseResponse 把 text 事件转为 ExecutionLog.thinking(delta),同时通过 sink.emit("thinking", delta) 实时推送 SSENodeExecutionResult.logs 序列化到 WorkflowRunNode.logs 列logs 未写入 nodeScopedOutputs,也未进入 envelope;下游 NodeInputResolver 无法通过任何级别匹配读到 thinkingNodeOutputEnvelope {
status, message,
data: { ...声明字段 },
trace: { thinking, tool_calls, intermediate_steps }, // 新增
metadata: { tokens, latency } // 可选
}
节点 inputs 声明支持 sourceField: "trace.thinking"、"trace.tool_calls[0].result" 等路径。
引擎为 LLM/Hermes 节点注入内置工具:
workflow.context.get(nodeId, field, path)
workflow.context.list_predecessors()
workflow.context.get_trace(nodeId, step_index?)
模型/Agent 自己决定何时调用、取什么。
hermes_bridge.py 已经在注册工具)把工作流引擎上下文做成 MCP server,暴露统一查询接口。
节点 inputs 配置时选择"桶":data-only / data+trace / workspace-only,模板渲染按桶展开。
这是基础设施,不做的话后面所有方案都无米下炊。
NodeOutputEnvelope.java:26 增加 trace 与 metadata 字段:
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 类中间步骤
}
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);
扩展 sourceField 解析(NodeInputResolver.java:88-108),允许:
"data.result" — 等价于现 data.result(向后兼容)"trace.thinking" — 取思考过程"trace.tool_calls[0].result" — 取某次工具调用结果实现要点:sourceField 解析为相对路径,根为 envelope 本身(而非 data)。建议保留 data 为默认根以向后兼容。
关键改造点:NodeWorkspaceBuilder.java:63-76 当前把所有可达前驱的 data 全量塞入 variables,应改为:
scopedOutputs 仍保留 envelope 原貌(不变)variables 只在节点声明了显式 mapping 时注入对应字段,不再全量铺平scopedOutputs,但不再"先扁平化、再匹配"注意:这是一次行为破坏性变更,需配套:
workflow.workspace.legacy-flat-inject=true在 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() { ... }
}
适用场景:
注入方式:
StructuredOutputHelper 构造 prompt 时,把工具描述追加到 system prompt(OpenAI function calling)HermesBridgeClient 调用 Bridge 前,通过 register_tool 注册到 hermes-agent如果未来要支持外部 Agent 接入工作流(比如 Claude Code、外部 Hermes 实例查询),再把第一、二阶段的查询能力封装为 MCP server。
短期不建议做,原因:
无论选哪个方案,以下几点必须遵守:
finalText 字符串。否则下游消费不了NodeWorkspaceBuilder.java:24):从"全量预注入"改为"按 mapping 注入",这是 token 节省的最大来源,比加工具更立竿见影WorkflowLevelExecutor.java:539-544):失败时的工具调用轨迹对下游调试极有价值| 阶段 | 内容 | 优先级 | 风险 |
|---|---|---|---|
| 一 | envelope 扩展 trace + Hermes 节点写入 trace + NodeInputResolver 支持 trace 路径 | 高 | 低,向后兼容 |
| 一 | NodeWorkspaceBuilder 改为按 mapping 注入 | 高 | 中,破坏性变更,需兼容期 |
| 二 | 注入 workflow.context.* 内置工具 |
中 | 中,需 prompt 引导 |
| 三 | MCP 化查询能力 | 低 | 高,协议层复杂 |
存储用方案 A(envelope 加 trace 字段),LLM 节点查询用方案 B(注入 workflow.context.* 工具),普通节点继续走显式 mapping。MCP 留到要接外部 Agent 时再做。