LangChain 表达式语言(LCEL)是 LangChain 生态中的一颗明珠。它用一种近乎“函数式组合”的方式,让我们能像搭积木一样构建复杂的 LLM 应用流水线。而在 LCEL 里,最直观也最核心的符号,就是那根小小的竖线——管道操作符 |。
很多人第一次看到类似这样的代码时会觉得有点陌生:
chain = prompt | model | output_parser
明明 LangChain 之前用的是 LLMChain,怎么现在变成各种对象用 | 连起来了?这篇文章就带你从零开始,理解管道操作符在 LCEL 中的用法,并掌握它背后的设计思想。
1. LCEL 的设计初衷:让一切成为“可运行的东西”
在理解管道操作符之前,必须先认识一个概念:Runnable(可运行对象)。
在 LCEL 的世界里,Prompt 模板、模型、输出解析器、甚至你自己写的函数,只要满足特定接口,都被抽象成 Runnable。
一个 Runnable 对象可以:
- 接收一个输入,返回一个输出
- 被异步调用
- 支持流式输出
- 拥有
.invoke()、.ainvoke()、.stream()、.batch()等方法
而管道操作符 | 的作用,就是把两个 Runnable 按顺序拼接,形成一个新的 Runnable。如果你熟悉 Linux 的管道或 RxJS/Java Stream 中的 pipe,这个概念几乎是完全一致的:前一个的输出,自动成为后一个的输入。
换言之:
chain = a | b
等价于我们逻辑上定义了一个新的函数:先执行 a,将其结果传给 b,最后返回 b 的结果。而且这个 chain 本身依然是一个 Runnable——这意味着你可以继续用 | 把它再和其他组件拼接。
2. 基本语法:从 Prompt 到模型再到解析一气呵成
先看一个最经典的 LCEL 示例——问答链:
from langchain_core.prompts import ChatPromptTemplate
from langchain_openai import ChatOpenAI
from langchain_core.output_parsers import StrOutputParser
# 定义组件
prompt = ChatPromptTemplate.from_template("给我讲一个关于{topic}的冷笑话")
model = ChatOpenAI(model="gpt-3.5-turbo")
output_parser = StrOutputParser()
# 用管道拼接成链条
chain = prompt | model | output_parser
# 运行
result = chain.invoke({"topic": "程序员"})
print(result)
在这一行 prompt | model | output_parser 中:
prompt接收字典输入(例如{"topic": "程序员"}),输出格式化后的ChatPromptValue;model接收ChatPromptValue,调用大模型,输出AIMessage对象;output_parser接收AIMessage,抽取纯文本字符串输出。
原本需要手动传递参数、多步调用的流程,变成了一个干净的数据流。当你调用 chain.invoke(...) 时,数据就像在水管中流动一样依次经过每个组件。
3. 不只是串联:管道下的 RunnableParallel 与分支合并
管道操作符远不止于一条直线。假设你想同时问模型两个不同的问题,然后把答案合并起来,就可以用到 RunnableParallel(也常写成 RunnableMap 的字面量形式)。
比如构建一个“笑话 + 诗”的并行生成链路:
from langchain_core.runnables import RunnableParallel
joke_prompt = ChatPromptTemplate.from_template("讲一个关于{topic}的冷笑话")
poem_prompt = ChatPromptTemplate.from_template("写一首关于{topic}的短诗")
joke_chain = joke_prompt | model | StrOutputParser()
poem_chain = poem_prompt | model | StrOutputParser()
# 构建一个并行映射:输入 topic,同时执行两条链
map_chain = RunnableParallel(joke=joke_chain, poem=poem_chain)
# 整体链路:先提取用户输入,然后并行生成,最后合并
final_chain = map_chain
result = final_chain.invoke({"topic": "程序员"})
print(result["joke"])
print(result["poem"])
这里 RunnableParallel(joke=..., poem=...) 本身也是一个 Runnable,它接收一个字典输入 {"topic": ...},并将它同时传递给 joke 和 poem 两条子链,最后输出一个包含对应键的新字典。你可以轻松地把它接到其他管道里:
chain = (
RunnableParallel(joke=joke_chain, poem=poem_chain)
| some_combine_runnable
)
这使我们在构建复杂逻辑时,仍能保持清晰的数据流向。
4. 管道里的“透明转发”:RunnablePassthrough
很多场景下,我们希望保留原始输入,或者只对输入的一部分进行处理,其他部分原样传下去。此时 RunnablePassthrough 就是管道中最优雅的“透明管道”。
例如,当你只想从输入字典中提取某个字段,同时又保留全部原始数据时:
from langchain_core.runnables import RunnablePassthrough
chain = (
{"original": RunnablePassthrough(), "upper": RunnablePassthrough() | (lambda x: x.upper())}
)
chain.invoke("hello")
# 输出:{'original': 'hello', 'upper': 'HELLO'}
结合业务更常见的例子:带检索的问答 RAG 管道中,我们需要先检索文档,然后把问题和文档一起送给大模型:
from langchain_community.vectorstores import FAISS
from langchain_core.runnables import RunnablePassthrough
retriever = vectorstore.as_retriever()
def format_docs(docs):
return "\n\n".join(doc.page_content for doc in docs)
context_chain = retriever | format_docs # 检索并拼接文档
full_chain = (
{"context": context_chain, "question": RunnablePassthrough()}
| prompt
| model
| StrOutputParser()
)
full_chain.invoke("什么是LCEL?")
注意 RunnablePassthrough() 放在 "question" 键下面,这样用户的原始问题可以原封不动地流入 prompt 的 "question" 变量里,同时 context_chain 又从同一个输入(用户问题)中检索出了上下文。整个结构不仅简洁,而且逻辑一目了然。
5. 随时插入自定义逻辑:RunnableLambda
管道里当然可以放入你自己的函数。RunnableLambda 可以将普通 Python 函数或 lambda 包装成 Runnable,方便接入管道。
from langchain_core.runnables import RunnableLambda
def add_exclamation(text: str) -> str:
return text + "!"
chain = (
prompt | model | StrOutputParser()
| RunnableLambda(add_exclamation)
| (lambda x: x.replace(" ", "_"))
)
这里无论是 RunnableLambda 还是直接写 lambda,LCEL 都会自动将其转换为 Runnable。不过要注意:对复杂逻辑,建议使用明确命名的函数并通过 RunnableLambda 包装,这样在调试、追踪和序列化时更友好。
6. 配置与运行时绑定:bind 的魅力
LCEL 还允许我们在管道中间对模型组件进行参数绑定,而不必重新定义整个模型对象。这在需要动态切换温度、工具调用等配置时非常有用。
# 基础模型
base_model = ChatOpenAI(model="gpt-4")
# 绑定工具或特定参数
model_with_tools = base_model.bind(tools=[...])
model_low_temp = base_model.bind(temperature=0.1)
chain = prompt | model_low_temp | StrOutputParser()
尤其在 Agent 或工具调用场景,你会频繁看到 bind 和管道结合在一起:
chain = prompt | model.bind_tools(tools) | ...
这种方式让我们可以在一条长管道中,为不同步骤配置不同参数,而不必打断整体结构。
7. 不仅仅是同步:流式、异步、批处理的天然支持
管道操作符构建出来的 Runnable 对象,完整继承了所有运行接口。你无需额外工作,就可以直接使用:
chain.stream(input)—— 流式输出,逐 token 返回chain.ainvoke(input)—— 异步调用chain.astream(input)—— 异步流式chain.batch(inputs)—— 批处理
例如,流式打印回答:
for chunk in chain.stream({"topic": "Python"}):
print(chunk, end="", flush=True)
这在传统 Chain 时代需要写大量适配代码。LCEL 通过管道的声明式构建,让所有能力开箱即用。
8. 管道操作符与传统 Chain 的区别
使用过老版本 LangChain 的同学可能习惯用 LLMChain、SequentialChain 等类。它们的缺点是:
- 构造时需定义大量参数,易出错
- 数据流不透明,输出键名需要手动指定
- 扩展和组合很麻烦
而 LCEL 管道:
- 用
|符号直观地展示数据流向 - 每一步都是自包含的
Runnable,可复用 - 借助
RunnableParallel、RunnablePassthrough等轻松实现复杂拓扑 - 所有链条自动获得追踪、调试、序列化能力(比如可以输出为 LangSmith 追踪图)
从面向对象的“配置式”到函数式的“组合式”,这是 LangChain 设计上的一次重要升级。
9. 实践建议与常见陷阱
- 输入输出要匹配:管道中前一个
Runnable的输出类型,必须能作为后一个的输入。如果不匹配,运行时才会报错。使用类型提示可以帮助验证。 - 善用
RunnableParallel拆分与合并:处理多路输入输出时,用字典形式的RunnableParallel比写多个自定义函数更清晰。 - 惰性执行:
prompt | model只是构建了蓝图,实际执行发生在.invoke()等调用时。所以你可以重复使用同一个chain对象。 - 调试技巧:可以在管道中临时插入一个
RunnableLambda用于打印中间结果,比如RunnableLambda(lambda x: print(x) or x)。 - 配置灵活切换:利用
bind和configurable_fields可以实现参数在运行时的动态覆盖。
10. 结语
LCEL 的管道操作符 |,本质上是一种把“步骤”抽象成“可组合单元”的理念落地。它让构建 LLM 应用像写 Unix 管道命令一样自然:从简单组件开始,一步一步组合出复杂行为,而不失去灵活性和可读性。
当你开始习惯用 prompt | model | parser 的思维去理解数据流,你会发现 LangChain 不再是一堆难记的类名,而是一套真正好用的表达语言。希望你在这条管道上玩得开心,构建出更优雅、更强大的 LLM 应用。