LECL用法指南

7 阅读7分钟

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": ...},并将它同时传递给 jokepoem 两条子链,最后输出一个包含对应键的新字典。你可以轻松地把它接到其他管道里:

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 的同学可能习惯用 LLMChainSequentialChain 等类。它们的缺点是:

  • 构造时需定义大量参数,易出错
  • 数据流不透明,输出键名需要手动指定
  • 扩展和组合很麻烦

而 LCEL 管道:

  • | 符号直观地展示数据流向
  • 每一步都是自包含的 Runnable,可复用
  • 借助 RunnableParallelRunnablePassthrough 等轻松实现复杂拓扑
  • 所有链条自动获得追踪、调试、序列化能力(比如可以输出为 LangSmith 追踪图)

从面向对象的“配置式”到函数式的“组合式”,这是 LangChain 设计上的一次重要升级。


9. 实践建议与常见陷阱

  • 输入输出要匹配:管道中前一个 Runnable 的输出类型,必须能作为后一个的输入。如果不匹配,运行时才会报错。使用类型提示可以帮助验证。
  • 善用 RunnableParallel 拆分与合并:处理多路输入输出时,用字典形式的 RunnableParallel 比写多个自定义函数更清晰。
  • 惰性执行prompt | model 只是构建了蓝图,实际执行发生在 .invoke() 等调用时。所以你可以重复使用同一个 chain 对象。
  • 调试技巧:可以在管道中临时插入一个 RunnableLambda 用于打印中间结果,比如 RunnableLambda(lambda x: print(x) or x)
  • 配置灵活切换:利用 bindconfigurable_fields 可以实现参数在运行时的动态覆盖。

10. 结语

LCEL 的管道操作符 |,本质上是一种把“步骤”抽象成“可组合单元”的理念落地。它让构建 LLM 应用像写 Unix 管道命令一样自然:从简单组件开始,一步一步组合出复杂行为,而不失去灵活性和可读性。

当你开始习惯用 prompt | model | parser 的思维去理解数据流,你会发现 LangChain 不再是一堆难记的类名,而是一套真正好用的表达语言。希望你在这条管道上玩得开心,构建出更优雅、更强大的 LLM 应用。