工作流引擎里的图是怎么构建的?节点之间的参数又是怎么传递的?
在做工作流引擎、智能体编排、AI Agent Pipeline 的时候,经常会遇到两个非常核心的问题:
第一,节点之间的图是怎么构建出来的?
第二,A 节点执行完之后,B 节点是怎么拿到 A 节点输出结果的?
这两个问题看起来简单,但其实正好戳中了工作流引擎的核心。
因为一个工作流不是简单地“按顺序执行几个方法”,而是要把多个节点组织成一张图,然后让数据沿着这张图流动起来。
我们可以把它想象成一条生产线。
每个节点都是一个加工站点。
边就是站点之间的传送带。
State 就是整个生产线上的“流转单”,上面记录着当前输入、历史输出、当前节点等执行过程中的上下文信息。
这篇文章就围绕两个点展开:
- 图是怎么构建的;
- 参数是怎么从一个节点传到另一个节点的。
一、先理解:工作流本质上是一张图
在工作流系统里,用户通常会配置一批节点和一批边。
比如有这样一个流程:
用户输入
↓
意图识别节点
↓
资料检索节点
↓
大模型总结节点
↓
最终输出
从程序角度看,这其实就是一张有向图:
START
↓
node_intent
↓
node_search
↓
node_llm
↓
END
每个节点负责一件具体的事情。
比如:
node_intent:识别用户意图
node_search:检索资料
node_llm:调用大模型生成回答
每条边负责描述节点之间的执行顺序。
比如:
node_intent -> node_search
node_search -> node_llm
所以,构建工作流的第一步,就是把用户配置里的节点和边,转换成程序内部真正可以执行的图结构。
这个事情通常就由 GraphBuilder 来做。
二、GraphBuilder 到底做了什么?
GraphBuilder 的职责可以概括成三句话:
1. 加节点
2. 加边
3. 设置入口和出口
听起来很简单,但这是整个工作流能跑起来的基础。
一个典型的构建过程大概是这样的:
public CompiledGraph<AgentState> buildGraph(
WorkflowConfig config,
Consumer<ExecutionEvent> eventCallback
) throws Exception {
StateGraph<AgentState> graph = new StateGraph<>(AgentState::new);
addNodes(graph, config.getNodes(), eventCallback);
addEdges(graph, config.getEdges());
setEntryAndExit(graph, config.getNodes(), config.getEdges());
return graph.compile();
}
这段代码可以拆成几步来看。
三、第一步:创建 StateGraph
首先创建一张图:
StateGraph<AgentState> graph = new StateGraph<>(AgentState::new);
这里的 StateGraph<AgentState> 有两个关键点。
第一个是 StateGraph。
它表示这不是一张普通的图,而是一张带状态流转能力的图。
普通的图只关心节点和边。
而工作流图不仅关心节点和边,还要关心执行过程中的状态,比如:
当前输入是什么?
当前执行到哪个节点?
每个节点的历史输出是什么?
有没有错误?
有没有事件需要回调?
第二个是 AgentState。
它就是整个工作流执行时共享的上下文状态。
你可以把它理解成一张“任务流转表”。
工作流从开始到结束,很多关键数据都会存在这张表里。
四、第二步:把配置里的节点注册到图里
用户配置里的节点,一般只是一个描述。
比如:
{
"id": "node_llm1",
"type": "llm",
"name": "分析节点",
"config": {
"prompt": "请分析用户输入:{{input}}"
}
}
这个配置本身不能直接执行。
它只是告诉系统:
这里有一个节点
节点 ID 是 node_llm1
节点类型是 llm
节点配置是这些
但是图引擎真正需要的是一个可以执行的动作,也就是类似:
AsyncNodeAction<AgentState>
所以这里就需要一个适配过程。
通常会由 NodeAdapter 把 WorkflowNode 转换成图引擎能识别的执行单元。
可以理解为:
WorkflowNode 配置节点
↓
NodeAdapter 适配
↓
AsyncNodeAction 可执行节点
伪代码大概是这样:
private void addNodes(
StateGraph<AgentState> graph,
List<WorkflowNode> nodes,
Consumer<ExecutionEvent> eventCallback
) {
for (WorkflowNode node : nodes) {
AsyncNodeAction<AgentState> action =
nodeAdapter.adapt(node, eventCallback);
graph.addNode(node.getId(), action);
}
}
这一步完成后,图里面就有了一批真正可以执行的节点。
但是此时它们还只是“孤零零地存在”。
还没有顺序关系。
接下来就要加边。
五、第三步:把配置里的边注册到图里
边描述的是节点之间的流转关系。
比如配置里有:
{
"source": "node_intent",
"target": "node_search"
}
意思就是:
node_intent 执行完之后,继续执行 node_search
注册到图里的过程通常类似这样:
private void addEdges(
StateGraph<AgentState> graph,
List<WorkflowEdge> edges
) {
for (WorkflowEdge edge : edges) {
graph.addEdge(edge.getSource(), edge.getTarget());
}
}
这样,原来孤立的节点就被边串起来了。
假设有这些边:
node_a -> node_b
node_b -> node_c
图结构就变成:
node_a
↓
node_b
↓
node_c
但是这时候还有一个问题。
图引擎怎么知道从哪里开始执行?
又在哪里结束?
这就涉及入口和出口。
六、第四步:动态识别入口节点
一个不太好的做法是硬编码入口节点。
比如直接规定:
id = start 的节点就是入口
或者:
第一个配置节点就是入口
但这种方式不够灵活。
更通用的方式是根据边的拓扑关系动态推导。
什么是入口节点?
很简单:
没有任何入边的节点,就是入口节点。
比如:
node_a -> node_b -> node_c
这里 node_a 没有任何节点指向它。
所以 node_a 就是入口。
代码可以这样写:
private WorkflowNode findEntryNode(
List<WorkflowNode> nodes,
List<WorkflowEdge> edges
) {
for (WorkflowNode node : nodes) {
boolean hasIncomingEdge = edges.stream()
.anyMatch(edge -> edge.getTarget().equals(node.getId()));
if (!hasIncomingEdge) {
return node;
}
}
return null;
}
这段代码的逻辑非常直白。
遍历所有节点。
对每个节点检查一遍:
有没有边的 target 等于当前节点 ID?
如果没有,说明没有任何节点指向它。
那它就是入口节点。
找到入口节点之后,需要给图加一条特殊边:
StateGraph.START -> entryNode
也就是说,工作流不是直接从业务节点开始,而是从框架内置的 START 节点进入,再流转到真正的业务入口节点。
例如:
START
↓
node_a
↓
node_b
↓
node_c
这样图引擎就知道该从哪里开始跑了。
七、第五步:动态识别出口节点
出口节点的识别逻辑和入口节点刚好相反。
什么是出口节点?
没有任何出边的节点,就是出口节点。
还是这个例子:
node_a -> node_b -> node_c
node_c 后面没有任何节点。
所以 node_c 就是出口节点。
代码可以这样理解:
private WorkflowNode findExitNode(
List<WorkflowNode> nodes,
List<WorkflowEdge> edges
) {
for (WorkflowNode node : nodes) {
boolean hasOutgoingEdge = edges.stream()
.anyMatch(edge -> edge.getSource().equals(node.getId()));
if (!hasOutgoingEdge) {
return node;
}
}
return null;
}
找到出口节点之后,也需要补一条特殊边:
exitNode -> StateGraph.END
最终图就会变成这样:
START
↓
node_a
↓
node_b
↓
node_c
↓
END
这样,图引擎不仅知道从哪里开始,也知道在哪里结束。
八、完整图构建流程总结
到这里,GraphBuilder 的完整职责就非常清楚了。
它不是负责执行具体业务逻辑的。
它负责把一份工作流配置,转换成一张可以执行的图。
整个过程可以总结为:
WorkflowConfig
↓
读取 nodes
↓
NodeAdapter 把每个节点适配成 AsyncNodeAction
↓
graph.addNode 注册节点
↓
读取 edges
↓
graph.addEdge 注册边
↓
根据拓扑关系找到入口节点
↓
START -> entryNode
↓
根据拓扑关系找到出口节点
↓
exitNode -> END
↓
graph.compile()
↓
得到 CompiledGraph
换成一张简单的图就是:
配置文件
↓
GraphBuilder
↓
StateGraph<AgentState>
↓
CompiledGraph<AgentState>
↓
执行引擎运行
这就是图的构建过程。
九、节点之间的参数是怎么传递的?
图构建好了之后,下一个问题就来了:
A 节点执行完之后,B 节点怎么拿到 A 节点的输出?
这是工作流引擎里非常关键的一点。
一种常见的设计是,不让节点之间直接互相调用,也不让 A 节点直接把参数塞给 B 节点。
而是让所有节点都通过一个共享的 State 来传递数据。
也就是说:
A 节点不直接调用 B 节点
A 节点只负责把输出写入 State
B 节点执行时再从 State 里读取输入
这有点像接力赛。
A 跑完之后,把接力棒放到一个固定位置。
B 不需要知道 A 是谁,也不需要直接跟 A 通信。
B 只要去固定位置拿接力棒就行。
这个固定位置,就是 AgentState。
十、核心字段:currentInput
在这个设计里,最重要的字段之一就是:
currentInput
它表示当前节点要处理的输入。
比如执行顺序是:
node_a -> node_b -> node_c
那么数据流大概是:
用户原始输入
↓
node_a 的 currentInput
↓
node_a 输出 outputA
↓
写回 currentInput
↓
node_b 的 currentInput = outputA
↓
node_b 输出 outputB
↓
写回 currentInput
↓
node_c 的 currentInput = outputB
也就是说:
上一个节点的输出,就是下一个节点的 currentInput。
这就是最基本的链式参数传递。
十一、NodeAdapter 里如何更新 State?
每个节点执行完成之后,通常会做三件事:
newStateData.put("nodeOutputs", nodeOutputs);
newStateData.put("currentInput", output);
newStateData.put("currentNodeId", node.getId());
这三行代码非常关键。
我们一行一行拆开看。
十二、第一行:保存所有节点的历史输出
newStateData.put("nodeOutputs", nodeOutputs);
nodeOutputs 用来存储每个节点的输出结果。
它可以理解成一个 Map:
Map<String, Object> nodeOutputs
结构大概是:
{
"node_a": {
"intent": "search",
"query": "GraphBuilder 参数传递"
},
"node_b": {
"documents": [
"doc1",
"doc2"
]
},
"node_c": {
"answer": "最终总结结果"
}
}
它的作用是存档。
每个节点执行完,不仅会把结果传给下一个节点,还会把自己的输出按节点 ID 保存起来。
这样后面的节点如果想引用更早之前某个节点的结果,也可以通过 nodeOutputs 拿到。
比如当前执行到了 node_c,它不只想拿 node_b 的输出,还想拿 node_a 的意图识别结果。
那就可以这样取:
nodeOutputs["node_a"]
或者进一步取字段:
nodeOutputs["node_a"]["intent"]
所以,nodeOutputs 解决的是:
跨节点、跨层级、跨步骤引用历史输出的问题。
十三、第二行:更新 currentInput
newStateData.put("currentInput", output);
这一行决定了链式传递。
当前节点执行完成后,它的输出 output 会被设置成新的 currentInput。
这样下一个节点执行时,拿到的 currentInput 就是上一个节点的输出。
举个例子。
用户输入:
{
"input": "帮我分析一下 GraphBuilder 的作用"
}
第一个节点 node_a 做意图识别,输出:
{
"intent": "explain",
"topic": "GraphBuilder"
}
执行完后,State 变成:
{
"currentInput": {
"intent": "explain",
"topic": "GraphBuilder"
},
"nodeOutputs": {
"node_a": {
"intent": "explain",
"topic": "GraphBuilder"
}
},
"currentNodeId": "node_a"
}
接下来 node_b 执行时,它拿到的 currentInput 就是:
{
"intent": "explain",
"topic": "GraphBuilder"
}
所以 node_b 不需要知道 node_a 是怎么执行的。
它只需要关心当前输入是什么。
十四、第三行:记录当前节点 ID
newStateData.put("currentNodeId", node.getId());
这一行主要是为了记录执行进度。
它可以用于:
日志追踪
事件回调
异常排查
调试定位
前端展示当前执行节点
比如前端想展示:
当前执行到了“大模型总结节点”
后端就可以通过 currentNodeId 知道当前状态停留在哪个节点上。
如果某个节点执行失败,也可以快速知道是哪个节点出了问题。
十五、为什么不让节点直接传参?
有人可能会问:
为什么不直接让 A 节点调用 B 节点?
比如:
Object outputA = nodeA.run(input);
Object outputB = nodeB.run(outputA);
这样不是更简单吗?
在非常简单的线性流程里,这当然可以。
但是一旦工作流变复杂,就会有很多问题。
比如:
1. 节点之间耦合太强
2. 不方便动态编排
3. 不方便插入、删除、替换节点
4. 不方便做分支流程
5. 不方便做并行流程
6. 不方便追踪每个节点的输出
7. 不方便失败重试和恢复执行
工作流引擎追求的是“节点解耦”。
每个节点只需要做到:
从 State 里读输入
执行自己的逻辑
把输出写回 State
至于下一个节点是谁,不是当前节点关心的事情。
那是图引擎根据边来决定的。
这就把两个职责拆开了:
节点:负责做事
边:负责决定下一个做谁
State:负责传递数据
GraphBuilder:负责把节点和边组装成图
这个拆分非常重要。
它让整个工作流系统变得灵活、可扩展、可维护。
十六、第一个节点没有上游,currentInput 从哪来?
链式传递里还有一个特殊情况:
第一个节点没有上游节点,那它的 currentInput 是什么?
答案是:
StateManager 初始化时就会把用户原始输入放进去。
比如用户请求是:
帮我分析一下这段代码
初始化 State 时,可以设置:
{
"currentInput": {
"input": "帮我分析一下这段代码"
}
}
所以第一个业务节点拿到的 currentInput 就是用户原始输入。
从这个角度看,整个数据流是这样的:
用户原始输入
↓
StateManager 初始化 currentInput
↓
入口节点读取 currentInput
↓
入口节点执行完成,输出写回 currentInput
↓
下一个节点继续读取 currentInput
↓
不断向后传递
↓
最终节点输出结果
这就形成了一条完整的数据链路。
十七、currentInput 和 nodeOutputs 的区别
这里非常容易混淆。
currentInput 和 nodeOutputs 都跟节点输出有关,但它们的定位不一样。
可以这样理解:
currentInput:给下一个节点用的“当前接力棒”
nodeOutputs:保存所有节点结果的“历史档案”
举个例子:
node_a -> node_b -> node_c
执行完 node_a 后:
{
"currentInput": {
"aResult": "A 的输出"
},
"nodeOutputs": {
"node_a": {
"aResult": "A 的输出"
}
}
}
执行完 node_b 后:
{
"currentInput": {
"bResult": "B 的输出"
},
"nodeOutputs": {
"node_a": {
"aResult": "A 的输出"
},
"node_b": {
"bResult": "B 的输出"
}
}
}
可以看到:
currentInput 会不断被覆盖。
它永远表示“当前要传给下一个节点的输入”。
而 nodeOutputs 不会简单覆盖之前节点的数据。
它会把每个节点的输出都保存下来。
所以:
如果只关心上一个节点的输出,用 currentInput。
如果要引用任意历史节点的输出,用 nodeOutputs。
这是参数传递设计里最核心的一点。
十八、Prompt 模板里的变量是怎么填充的?
除了节点之间的链式传递,工作流里还有一种常见需求:
在 Prompt 模板里引用变量。
比如一个 LLM 节点配置了这样的 Prompt:
请根据以下分析结果生成总结:
分析结果:{{analysis}}
用户问题:{{input}}
这里的 {{analysis}} 和 {{input}} 就是模板变量。
真正调用大模型之前,需要把这些变量替换成具体值。
这个事情通常由 PromptTemplateService 来做。
十九、变量来源一般分两种
Prompt 模板里的变量,通常有两种来源:
1. input:静态输入值
2. reference:引用上游节点输出
先看第一种。
二十、input:静态值填充
如果某个参数类型是 input,说明它的值是直接配置好的。
比如:
{
"name": "role",
"type": "input",
"value": "你是一个资深架构师"
}
模板里有:
{{role}}
替换之后就是:
你是一个资深架构师
这种方式适合配置一些固定内容,比如:
角色设定
输出语言
格式要求
固定提示词
默认参数
二十一、reference:引用上游节点输出
第二种更重要。
如果某个参数类型是 reference,说明它的值不是写死的,而是来自某个节点的输出。
比如配置:
{
"name": "analysis",
"type": "reference",
"referenceNode": "node_llm1.analysis"
}
这表示:
我要把 node_llm1 节点输出里的 analysis 字段,填充到模板变量 {{analysis}} 里。
假设 node_llm1 的输出是:
{
"analysis": "GraphBuilder 主要负责把配置转换成可执行图。"
}
模板是:
请基于以下分析继续总结:
{{analysis}}
替换之后就变成:
请基于以下分析继续总结:
GraphBuilder 主要负责把配置转换成可执行图。
这就是引用上游节点输出的过程。
二十二、referenceNode 为什么可以写成 node_llm1.analysis?
node_llm1.analysis 这种写法一般可以拆成两部分:
node_llm1:节点 ID
analysis:节点输出里的字段名
也就是说:
从 nodeOutputs 里找到 node_llm1 的输出
再从这个输出里取 analysis 字段
逻辑类似这样:
Object nodeOutput = nodeOutputs.get("node_llm1");
Object value = getFieldValue(nodeOutput, "analysis");
如果引用路径更深,也可以继续扩展。
比如:
node_search.result.documents
可以理解为:
从 node_search 节点输出里取 result
再从 result 里取 documents
对应的数据可能是:
{
"result": {
"documents": [
"文档 1",
"文档 2"
]
}
}
这样模板就可以非常灵活地引用任意上游节点的输出。
二十三、把整个参数流转串起来看
假设我们有一个三节点流程:
node_input_cleaner -> node_analyzer -> node_writer
用户输入是:
{
"input": "帮我写一篇关于 GraphBuilder 的博客"
}
初始化 State:
{
"currentInput": {
"input": "帮我写一篇关于 GraphBuilder 的博客"
},
"nodeOutputs": {}
}
1. 执行 node_input_cleaner
node_input_cleaner 拿到:
{
"input": "帮我写一篇关于 GraphBuilder 的博客"
}
输出:
{
"cleanInput": "写一篇介绍 GraphBuilder 构建流程的技术博客"
}
State 更新为:
{
"currentInput": {
"cleanInput": "写一篇介绍 GraphBuilder 构建流程的技术博客"
},
"nodeOutputs": {
"node_input_cleaner": {
"cleanInput": "写一篇介绍 GraphBuilder 构建流程的技术博客"
}
},
"currentNodeId": "node_input_cleaner"
}
2. 执行 node_analyzer
node_analyzer 的 currentInput 是上一个节点输出:
{
"cleanInput": "写一篇介绍 GraphBuilder 构建流程的技术博客"
}
它输出:
{
"analysis": "文章应该重点解释图构建、入口出口识别、State 参数传递和模板变量替换。"
}
State 更新为:
{
"currentInput": {
"analysis": "文章应该重点解释图构建、入口出口识别、State 参数传递和模板变量替换。"
},
"nodeOutputs": {
"node_input_cleaner": {
"cleanInput": "写一篇介绍 GraphBuilder 构建流程的技术博客"
},
"node_analyzer": {
"analysis": "文章应该重点解释图构建、入口出口识别、State 参数传递和模板变量替换。"
}
},
"currentNodeId": "node_analyzer"
}
3. 执行 node_writer
node_writer 如果只需要上一个节点的结果,直接读:
currentInput.analysis
如果它还需要第一个节点的清洗结果,可以从历史输出里拿:
nodeOutputs.node_input_cleaner.cleanInput
如果 Prompt 模板是:
请根据用户需求和分析结果写一篇博客。
用户需求:{{cleanInput}}
分析结果:{{analysis}}
参数配置可以类似这样:
[
{
"name": "cleanInput",
"type": "reference",
"referenceNode": "node_input_cleaner.cleanInput"
},
{
"name": "analysis",
"type": "reference",
"referenceNode": "node_analyzer.analysis"
}
]
模板替换后就是:
请根据用户需求和分析结果写一篇博客。
用户需求:写一篇介绍 GraphBuilder 构建流程的技术博客
分析结果:文章应该重点解释图构建、入口出口识别、State 参数传递和模板变量替换。
然后 node_writer 再调用大模型生成最终内容。
二十四、整体架构可以这样理解
整个流程里,其实有四个核心角色。
1. GraphBuilder:负责搭图
它负责把配置变成可执行图。
读取节点
读取边
注册节点
注册边
识别入口
识别出口
编译图
它不关心每个节点具体怎么干活。
它只关心图怎么搭起来。
2. NodeAdapter:负责把配置节点变成可执行节点
配置里的节点只是描述。
真正执行时,需要把它转成 AsyncNodeAction。
WorkflowNode
↓
NodeAdapter
↓
AsyncNodeAction
所以 NodeAdapter 是配置和执行之间的桥梁。
3. AgentState:负责保存执行上下文
所有节点之间不直接传参,而是通过 State 传递。
State 里至少会有:
currentInput:当前节点输入
nodeOutputs:所有节点历史输出
currentNodeId:当前节点 ID
它是整个工作流的数据中转站。
4. PromptTemplateService:负责模板变量替换
LLM 节点通常会有 Prompt 模板。
模板里的变量需要在执行前填充。
变量可能来自:
静态 input
上游 reference
所以 PromptTemplateService 负责把:
{{variable}}
替换成真正的值。
二十五、为什么这种设计比较通用?
这种设计有几个明显好处。
1. 节点之间解耦
A 节点不需要知道 B 节点是谁。
B 节点也不需要知道 A 节点内部怎么实现。
大家都只和 State 打交道。
这让节点可以自由组合。
2. 支持动态编排
因为入口、出口、边关系都是从配置里推导出来的,所以流程不是写死在代码里的。
只要改配置,就可以改变执行流程。
比如原来是:
A -> B -> C
后来想改成:
A -> D -> C
理论上只需要调整配置,不一定要改核心执行引擎。
3. 方便扩展节点类型
新增一个节点类型时,只需要让 NodeAdapter 支持新的类型。
比如:
LLM 节点
HTTP 节点
代码执行节点
条件判断节点
数据库查询节点
知识库检索节点
图构建逻辑不用大改。
4. 方便调试和观测
因为每个节点的输出都会被保存到 nodeOutputs,所以调试时可以清楚看到:
哪个节点输入了什么
哪个节点输出了什么
流程执行到哪里
哪里出现了异常
这对工作流系统非常重要。
没有这些信息,排查问题会非常痛苦。
5. 支持复杂引用
有了 nodeOutputs 之后,后面的节点不只能拿上一个节点的输出,还能拿任意历史节点的输出。
这就让复杂 Prompt 组装变得可控。
例如:
最终总结节点同时引用:
- 用户原始问题
- 意图识别结果
- 检索结果
- 中间分析结果
这些都可以从 State 里取出来。
二十六、最后总结
工作流引擎里的图构建和参数传递,可以用一句话概括:
GraphBuilder 负责把配置搭成图,State 负责让数据在节点之间流动。
再展开一点就是:
GraphBuilder 读取配置里的节点和边;
NodeAdapter 把每个配置节点适配成可执行动作;
GraphBuilder 把节点和边注册到 StateGraph;
系统根据拓扑关系动态识别入口节点和出口节点;
入口节点前面补 START;
出口节点后面补 END;
图编译后得到可执行的 CompiledGraph;
执行过程中,每个节点从 State 里读取 currentInput;
节点执行完成后,把输出写入 nodeOutputs;
同时把输出更新为新的 currentInput;
下一个节点再读取这个 currentInput;
如果需要引用更早的节点输出,就从 nodeOutputs 里按节点 ID 查;
Prompt 模板里的变量,则通过 input 和 reference 两种方式完成填充。
所以,一个工作流之所以能跑起来,靠的不是节点之间互相硬调用,而是三件事配合完成:
图决定执行顺序;
State 负责参数流转;
模板服务负责变量填充。
把这三层理解清楚,基本就抓住了工作流编排系统的核心。
简单说:
节点是一个个工位;
边是工位之间的路线;
State 是流转单;
currentInput 是当前接力棒;
nodeOutputs 是历史档案;
GraphBuilder 是施工队;
NodeAdapter 是转换器;
PromptTemplateService 是填空器。
这样一套设计,既能支持简单的线性流程,也能为后续的分支、并行、条件路由、失败重试、事件回调、可视化调试打下基础。