工作流节点之间怎么传数据?变量替换有哪些坑?
简化版
最常用的做法是一次运行维护一个共享上下文(一个 JSON 对象):每个节点从上下文里按变量名取输入,执行完把结果按自己的「输出变量名」写回上下文,后面的节点再用 {变量名} 引用它。节点之间不直接传参,只通过上下文解耦。变量替换有四个常见坑:模板本身是 JSON 时,变量值要先做 JSON 转义,否则模型输出里的换行和引号会让整段 JSON 不合法;取不到的变量是替换成空串还是报错要想清楚;接口返回的大对象只按路径取需要的字段写回,别把整包塞进上下文;流程需要用户提供哪些变量可以用「所有节点用到的变量 − 节点会产出的变量」推算出来。
详细版
上下文(一次运行一份)
customerQuestion ← 开始节点:用户输入
knowledgeContext ← 知识检索节点输出
replyDraft ← 大模型节点输出(Prompt 里引用了 {customerQuestion} 和 {knowledgeContext})
cleanReplyDraft ← 工具节点输出(入参模板 {"text":"{replyDraft}"})
needCreateTicket ← 条件节点输出
ticketId ← HTTP 节点输出(按路径 data.ticketId 取值)
| 环节 | 做法 | 坑 |
|---|---|---|
| 写入 | 节点输出按输出变量名写回上下文 | 两个节点用同一个输出名会互相覆盖 |
| 读取 | 模板里的 {变量} 用上下文的值替换 | 变量不存在时静默替换成空串,问题被掩盖 |
| JSON 模板 | 值先转义换行、引号、反斜杠再替换 | 不转义时整段 JSON 解析失败 |
| 大对象 | 按路径只取需要的字段 | 整包写入让上下文膨胀、日志难读 |
| 入口参数 | 用到的变量减去产出的变量 = 用户要填的 | 漏算 Prompt 和配置里的占位符 |
完整版教学
一、为什么用共享上下文,而不是节点之间直接传参
节点之间传数据有两种思路。一种是「管道」:上一个节点的输出直接作为下一个节点的输入。另一种是「共享上下文」:所有节点读写同一个字典,按名字取值。
| 思路 | 优点 | 缺点 |
|---|---|---|
| 管道(上一步输出即下一步输入) | 简单直观 | 第 4 步想用第 1 步的数据,要一路透传;插入一个节点会打断链条 |
| 共享上下文(按变量名读写) | 任意节点能用之前任意节点的产出;插入节点不影响其他节点 | 变量名要约定清楚,否则容易覆盖或拼错 |
工作流里一个大模型节点常常同时需要用户的原始问题和检索到的资料,它们来自不同的上游节点,所以共享上下文更合适。节点只关心「我读哪几个变量、我写哪个变量」,不关心是谁产出的、谁会使用,这让节点可以被自由地重排和复用。节点表里为什么把输入变量、输出变量做成列,见「可视化工作流引擎的数据模型怎么设计?流程复用时连线怎么复制?」;工具节点调哪个工具由配置写死、入参从上下文取,和模型自己选工具的做法不同,后者见「如何为 AI Agent 设计可靠的工具调用机制?」。
二、写入:每个节点只写一个有名字的变量
每个节点配一个输出变量名,执行结果写进上下文的这个键。这里有两个容易出问题的地方:
- 重名覆盖:两个节点都把输出写到
result,后执行的会覆盖先执行的,前面那个值就丢了。输出名应当表达含义,比如replyDraft、cleanReplyDraft,而不是result1、result2。 - 把上下文写进上下文:有的节点(比如结束节点没指定输出时)直接返回整个上下文对象。如果再把它按输出名写回上下文,上下文里就包含了它自己,序列化成 JSON 时会无限递归。写回之前要判断「输出是不是上下文本身」。
记忆钩子:上下文是一块黑板,每个节点只在自己名字下面写字;写之前看一眼,别把整块黑板抄到黑板上。
三、读取:占位符替换与缺失值
节点配置里的 {变量名} 在执行前被替换成上下文里的值,通常用正则匹配占位符逐个替换:
模板:请根据客户问题 {customerQuestion} 和资料 {knowledgeContext} 生成回复
上下文:customerQuestion = 订单多久到;knowledgeContext = 【发货时效】现货 24 小时内发货…
结果:请根据客户问题 订单多久到 和资料 【发货时效】现货 24 小时内发货… 生成回复
变量在上下文里不存在时怎么办?常见处理是替换成空串,这样流程不会中断,但问题被掩盖了:Prompt 少了一段资料,模型照样生成,只是答得更差。关键的入口变量应该在流程一开始就校验,缺了直接报错并说明缺哪个;非关键变量替换成空串时,最好在步骤日志里能看到替换后的完整内容,出了问题可以对照。
四、JSON 模板的转义陷阱
最隐蔽的坑出现在模板本身是 JSON 的时候,比如工具节点的入参、HTTP 请求体:
模板:{"text":"{replyDraft}"}
replyDraft 是模型输出的两行文本:
您好,订单"已发货"
预计 3 天送达
直接替换:{"text":"您好,订单"已发货"
预计 3 天送达"} ← 引号提前闭合、字符串里有裸换行,JSON 不合法
转义后替换:{"text":"您好,订单\"已发货\"\n预计 3 天送达"} ← 合法
模型输出几乎一定带换行,也经常带引号和反斜杠。所以要区分两种渲染:模板是普通文本(Prompt、URL)时原样替换;模板是 JSON 时,先把值里的反斜杠、双引号、换行、回车、制表符转成 JSON 转义序列,其余不可见的控制字符按 \uXXXX 输出,再替换进去。不做这一步,接收方解析失败时往往只会拿到空值,报错信息离真正的原因很远。
五、只取需要的字段写回
HTTP 接口常常返回一大包数据,而后续节点只需要其中一个字段:
接口返回:{"code":200,"data":{"ticketId":"T20240601","status":"CREATED","detail":{…500 行…}}}
配置路径:data.ticketId
写回上下文:ticketId = "T20240601"
按点分隔的路径逐层取值,只把需要的值写回。这样做有三个好处:上下文保持精简,后面的 Prompt 引用时不会把无关数据塞给模型;步骤日志更容易读;接口结构变了只影响取值路径,不影响下游节点。路径中间某层不存在时应返回空,而不是抛异常让整个节点失败。
六、用户要填哪些参数,可以算出来
一条流程运行时需要哪些初始变量,不必让配置者手写清单,可以从节点配置里推算:
needed = 所有节点的输入变量 ∪ 所有 Prompt 和配置里出现的 {占位符}
produced = 所有节点的输出变量(开始节点除外)
用户要填 = needed − produced
以一条 7 个节点的客服流程为例:needed 里有 customerQuestion、knowledgeContext、replyDraft、cleanReplyDraft、needCreateTicket 等;produced 里有除 customerQuestion 以外的所有这些变量,因为开始节点的输出不算「节点产出」。相减只剩 customerQuestion,运行面板上就只出现一个输入框。开始节点要单独处理:它的输入变量就是整条流程的入口,不能因为它也把这个变量「输出」了一次,就从用户要填的列表里减掉。
七、上下文膨胀与敏感信息
上下文会随着节点增加不断变大,而且每一步都完整写进步骤日志。一条流程 10 个节点、每个节点执行前的上下文平均 20KB,一次运行光日志就是 200KB,每天跑 1000 次就是约 200MB。控制办法有三个:大对象按路径只取需要的字段;中间产物用完后不必一直保留在上下文里;包含密钥、令牌的配置不要写进上下文,而是在节点执行时从配置里读取。
八、常见误区与追问
- 误区:节点之间直接传参更简单。 远处的节点要用前面的数据时要一路透传,插入节点会打断链条,共享上下文更适合编排。
- 误区:所有模板都用同一种替换方式。 JSON 模板必须先转义值,否则带换行和引号的模型输出会让 JSON 不合法。
- 误区:变量取不到时替换成空串就没事了。 流程不中断但结果变差,问题被掩盖;关键入口变量要提前校验。
- 误区:接口返回什么就整包写进上下文。 只按路径取需要的字段,避免上下文膨胀、Prompt 被无关数据污染。
- 误区:用户要填的参数就是开始节点的输入变量。 各节点 Prompt 和配置里的占位符也可能引用了没有任何节点产出的变量,要一起算。
- 追问:输出名重复会怎样? 后执行的节点覆盖先执行的,前一个值丢失;输出名应当表达含义并在流程内唯一。
- 追问:为什么结束节点返回整个上下文时不能再写回上下文? 上下文会包含它自己,序列化成 JSON 时无限递归。
九、加强记忆
节点传数据记「一块黑板、四个坑」:一次运行一份共享上下文,节点按输入变量读、按输出变量写,节点之间不直接传参,所以能自由重排复用;输出名要表达含义避免覆盖,返回整个上下文时不能再写回自己。四个坑是:JSON 模板要先转义值(反斜杠、引号、换行、控制字符),普通文本模板才原样替换;取不到的变量替换成空串会掩盖问题,入口变量要提前校验;接口大对象按点路径只取需要的字段;用户要填的参数等于「用到的变量 − 产出的变量」,开始节点的输入单独算进去。
项目实战落地
项目里怎么做的
《AI企业流程编排系统》的执行引擎用一个 JSONObject 作为流程上下文,从初始输入一直传到结束:
- 写回:
executeStep拿到节点输出后,配了output_key就写进上下文;输出等于上下文本身时不写回,注释写明否则序列化会无限递归; - 两种渲染:
renderTemplate原样替换{变量},用于大模型 Prompt 和 HTTP 节点的 url;renderJsonTemplate先用escapeJsonValue把反斜杠、引号、换行、回车、制表符转成转义序列、其余控制字符按\uXXXX输出,再替换,用于工具节点的inputMapping和 HTTP 请求头;取不到的变量都替换成空串; - 按路径取值:HTTP 节点配了
resultPath(如data.ticketId),就由getByPath按点逐层下钻,只把这个值写回上下文; - 推算运行参数:画布的
runVariables把各节点的inputKey和 Prompt、扩展配置里扫出来的{变量}当作「用到的」,把开始节点以外的outputKey当作「产出的」,相减得到要用户填的参数;示例客服流程算下来只剩customerQuestion。
为什么这样取舍
- JSON 模板单独转义:工具节点入参写成
{"text":"{replyDraft}"},replyDraft是多行文本,原样拼进去整段 JSON 不合法,后面解析不出text,工具只能拿到空值。 - 只写回 resultPath 的值:只把需要的值写回上下文,下游节点直接用
{ticketId}引用。
面试官还会追问
- 知识检索节点把召回的片段拼成什么格式写进上下文?没有配置向量模型时靠什么召回?
- 工作台只给用户展示一段回复,它是从最终上下文的哪个变量里取的?取不到时怎么兜底?
- 画布的运行详情弹窗里,每个变量旁边标着产出它的节点名,这是怎么查出来的?
学完《AI企业流程编排系统》,上面这些追问你都会迎刃而解。