学 LangChain 表达式语言(LCEL)的时候,我踩的第一个坑不是 API 不熟,而是数据莫名其妙"丢"了:明明上游传下来一个字典,过了我写的中间函数,下游就只剩一个新字段了。排查半天才发现,这不是 bug,是 LCEL 的设计哲学——而理解这个哲学,对写惯了 Java 的人来说反而是件轻松事,因为它就是我们在后端里天天念叨的那套东西:管道、不可变、显式状态。
这篇笔记把 LCEL 的数据流机制、RunnableLambda和RunnablePassthrough.assign的核心区别整理成对照,代码示例全部保留原样。
一、|的本质:一次运算符重载撑起来的管道
在标准 Python 语法里,|通常用于按位或或字典合并。但在 LCEL 中,LangChain 框架通过运算符重载(实现__or__和__ror__方法),赋予了|全新的含义:管道(Pipe)。
当你写下A | B时,其底层逻辑是:将 A 的运行结果(返回值),作为参数传递给 B 去运行。
Java 没有运算符重载(当年写BigDecimal只能.add()的怨言,就是这个设计决定的代价),但这个机制本身不难理解——它就是让自定义类型的对象支持+、|这类符号运算。真正值得记住的是它背后的思想:A | B串起来的链路遵循一条核心定律:
上一个节点的
return结果,就是下一个节点的唯一输入。
在 LCEL 链路中,没有全局变量或上下文缓存的概念。数据像水流一样顺着管道单向流动,下一个节点能看到的唯一数据,完全取决于上一个节点返回了什么。
这句话后端的人应该觉得眼熟:这就是stream().map(...).map(...)的链式调用哲学,每一步的输出是下一步的输入,中间没有一个"共享工作区"让节点偷偷去读。区别只是 Stream 的管道符写成了.,LCEL 写成了|。
二、数据"丢失"的真相:被替换,不是被删除
RunnableLambda的作用是把任意普通的 Python 函数包装成可执行的 Runnable 节点。它的核心行为是:只输出该函数的返回值。
困惑就从这里来:为什么用了RunnableLambda之后,之前的数据不见了?
答案的一句话版本:数据并不是被"删除"了,而是被"替换"了。RunnableLambda本身不会主动删除任何数据,但只要你的函数返回了一个全新的字典,这个新字典就会完全替换掉上游传下来的旧字典,成为下一个节点的输入。这是纯函数式的行为——节点的输出完全由返回值决定,它不会"在原数据上修改",只会"交出新数据"。
用代码看最直观。假设上游节点传递下来的输入是:
x = {"name": "小N", "answer": "我喜欢小狗"}
情况 A:只返回新内容(旧数据在数据流中丢失)
defadd_new_data(x):# 只返回了一个全新的字典return{"new_field":"这是新数据"}# 链路:上游 | RunnableLambda(add_new_data) | 下游结果:下游节点收到的输入只有{"new_field": "这是新数据"}。之前的name和answer彻底消失——如果你不带上它们,它们就被替换了。
情况 B:手动带上旧数据(数据得以保留)
defadd_new_data(x):# 使用 **x 把旧数据解包,和新数据合并后返回return{**x,"new_field":"这是新数据"}# 链路:上游 | RunnableLambda(add_new_data) | 下游结果:下游节点收到{"name": "小N", "answer": "我喜欢小狗", "new_field": "这是新数据"},数据流完整延续。
Java 同行看情况 B 的{**x, ...}可能会愣一下:这是 Python 的字典解包合并语法,把x的所有键值摊平进新字典。Java 里没有这个糖,等价写法要老实拷贝——Map<String, Object> merged = new HashMap<>(x); merged.put("new_field", ...); return merged;。注意关键点:也是先拷贝再改,不是直接改原 Map。两条语言殊途同归地选择了"返回新状态"而不是"修改旧状态",这就是函数式管道的通用生存法则。
三、RunnablePassthrough.assign:官方替你写好的"情况 B"
情况 B 没问题,但每次都要手写{**x, ...}很繁琐,而且极易出错——忘写一次**x,数据流就断一次。于是 LangChain 官方提供了RunnablePassthrough.assign。
它的本质,就是LangChain 帮你封装好的"情况 B"。当你写:
RunnablePassthrough.assign(new_field=lambdax:"这是新数据")LangChain 在底层自动帮你执行了:
RunnableLambda(lambdax:{**x,"new_field":"这是新数据"})为什么官方推荐用assign来"加字段"?原文总结了四条,我照单全收,并补一个 Java 视角的注脚:
- 绝对安全:框架自动合并旧数据,绝不会因疏忽弄丢上游上下文。
- 语义清晰:
assign这个词明确传达"我要给现有字典分配一个新字段",而不是替换整个字典。 - 代码简洁:省去
lambda x: {**x, ...}的样板代码。 - 支持并行计算:可以方便地同时挂载多个计算任务,例如
assign(a=func_a, b=func_b)。
Java 视角的注脚是:Stream 里其实一直存在同样的痛点——想在一趟map里既算出新值又保留原上下文,只能手写"拷贝 Map 再 put"那套模板代码,标准库没有内置的"enrich"算子。LCEL 把这个高频动作直接做成了官方算子,这一点上它比 Java Stream 想得更周到。语义上它有点像 Java Record 的 wither 方法:record的withX()也是"基于旧值生成带新字段的新实例",旧实例原封不动。
四、场景对决:怎么选,只看一个问题
不是RunnableLambda不好,而是两者有各自明确的适用场景。判断用哪个,只需要问自己一个问题:
“这个函数处理完后,我还需要保留上游传下来的其他数据吗?”
场景一:用RunnableLambda(转换 / 替换 / 提取)——函数要把输入数据变成另一种形态,且下一步只需要这个新形态:
defformat_mapping(x):# 映射处理:将字典映射为特定格式的字符串returnf"用户{x['name']}说:{x['answer']}"# 使用 RunnableLambda 包装chain=...|RunnableLambda(format_mapping)|next_node结果:下一个节点收到的是纯字符串,旧字典被彻底替换。
场景二:用RunnablePassthrough.assign(增强 / 附加特征)——函数要计算出一个新值,并作为新字段加进原字典,同时保留原有字段:
defget_emotion_mapping(x):# 映射处理:根据文本映射出情感标签if"喜欢"inx["answer"]:return"积极"return"中性"# 使用 RunnablePassthrough.assign 包装# 注意:这里的函数只需要返回新字段的值,不需要返回整个字典chain=...|RunnablePassthrough.assign(emotion=get_emotion_mapping)|next_node结果:下一个节点收到{"name": "...", "answer": "...", "emotion": "积极"}。旧数据保留,新数据增加。注意细节:assign 挂载的函数只返回新字段的值,合并旧数据的活儿框架干了——这正是它和RunnableLambda在写法上的分水岭。
原文对"映射处理"还有一条特别说明,值得单独拎出来,因为"映射"这个词在数据处理里太容易混淆:
- 破坏性/替换性映射(JSON 映射为纯文本、复杂对象映射为单一 ID)→ 用
RunnableLambda - 建设性/附加性映射(根据用户 ID 映射出用户画像,作为新字段加进上下文)→ 用
RunnablePassthrough.assign
还有一种结合用法:映射逻辑复杂,写成了接收整个字典、在函数内部自己完成合并的普通函数——
defcomplex_mapping(x):# 内部处理new_val="计算结果"return{**x,"new_val":new_val}# 自己带上了旧数据# 这时候你可以用 RunnableLambda 包装它chain=...|RunnableLambda(complex_mapping)|next_node这种写法完全可行,函数内部保证了数据不丢。但 LCEL 的最佳实践是:如果仅仅为了加字段,把计算逻辑写成小函数直接用assign挂载,"管道"语义更清晰。用后端的话说:别把 enrich 逻辑藏在一个看不出"它会不会保留上下文"的大函数里,让算子的类型替你表达意图。
五、最佳实践与口诀
构建复杂 LCEL 链路时,维持一个不断扩充的"单一状态字典"是标准做法。原文给了一句话口诀:
“要换数据用 Lambda,要加字段用 assign。”
- 要换数据(转换格式、提取内容、不再需要旧数据)→ 用
RunnableLambda - 要加字段(保留旧数据、计算新特征、扩充上下文)→ 用
RunnablePassthrough.assign
写在最后:几个后端视角的感受
LCEL 的"没有全局变量",是老朋友而不是新知识。Java 后端这些年一直在跟隐式上下文搏斗:ThreadLocal传值、RequestContextHolder拿请求信息,都知道这类东西好用但难查——数据从哪来、改了谁,调用链上一目了然的只有方法参数。LCEL 干脆立法禁止隐式上下文:下一个节点能看见什么,只由上一个节点返回什么决定。初学时觉得"丢数据"很反直觉,理解之后会觉得这是把依赖注入回退到了最朴素的函数参数传递,反而更可控。
"单一状态字典逐步扩充"就是 reduce 的形态。最佳实践里那条"维持一个不断扩充的状态字典",翻译成 Java 就是:管道的每一步都接收旧状态、返回新状态,整条链路相当于一次 reduce,上下文是演进出来的,不是共享出来的。理解了这一点,RunnableLambda替换状态、assign扩充状态,就是同一个机制的两种操作方向,不需要死记。
口诀能背,但要知道口诀背后是哪个机制。"要换数据用 Lambda,要加字段用 assign"好记好用,但它真正压缩的是一句话:LCEL 里数据的生死完全由你函数的返回值决定——想留旧数据,要么自己合并,要么让 assign 替你合并。这句想通了,口诀就不需要背了。
这篇是学习笔记性质的整理,LCEL 我还在爬坡阶段;文中机制描述以 LangChain 官方文档为准,如果后续在项目里真用起来踩到新坑,再来补一篇实战向的。