Week 1 -- Day 3:LCEL 深度理解
Day 3:LCEL 深度理解
LCEL 是什么——声明式组合的哲学
在早期版本的 LangChain 中,构建一条处理链需要命令式地依次调用每个组件,先调用 Prompt 生成消息,再把消息传给 LLM 得到响应,最后调用 Parser 解析输出。这种写法直观,但随着链路变长,嵌套调用和中间变量会迅速积累,代码可读性和可维护性都会下降。更麻烦的是,流式输出、批量处理、异步执行这些能力无法自动复用,每条链都需要单独处理。
LCEL(LangChain Expression Language)用声明式的思路解决了这个问题。你只需要描述"这些组件按什么顺序组合",而不需要关心每一步如何传递数据。LCEL 的核心思想是组合本身就是值,你用 | 把几个组件串在一起,得到的是一个新的可执行对象,流式、批量、异步等能力由框架统一提供,无需重复实现。这与函数式编程中的管道(pipeline)概念一脉相承,让链的结构一眼就能看清楚。
Runnable:万物皆可组合的统一接口
LCEL 能够把不同类型的组件自由组合,依赖的是一个统一的抽象 Runnable 接口。LangChain 中几乎所有的核心组件等都实现了这个接口,因此它们在 LCEL 眼中是等价的积木,可以任意拼接。
Runnable 对外暴露三种主要的调用方式,分别对应不同的使用场景。invoke 是最基础的同步调用,接收一个输入,等待整个链执行完毕后返回最终结果,适合单次请求。stream 则以迭代器的方式逐块返回结果,LLM 每生成一个 token 就立刻推送出来,用户不需要等到全部生成完才看到响应,非常适合对话类应用。batch 接收一个输入列表,在内部并发执行多个 invoke,比顺序循环调用效率高得多,适合需要批量处理文本的离线任务。
三种方式共用同一条 chain 定义,切换成本极低。下面的示例以"解释一个概念"的简单链为例,依次演示这三种调用:
from dotenv import load_dotenv
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_openai import ChatOpenAI
# 读取 .env 文件并注入环境变量,之后 LangChain 会自动从环境变量中获取密钥
load_dotenv()
prompt = ChatPromptTemplate.from_template("用一句话解释:{concept}")
llm = ChatOpenAI(
model="Qwen/Qwen3.6-35B-A3B", temperature=0,
base_url="https://api.siliconflow.cn/v1"
)
parser = StrOutputParser()
chain = prompt | llm | parser
# invoke:同步等待完整结果
result = chain.invoke({"concept": "递归"})
print(result)
# stream:逐 token 打印,无需等待全部完成
for chunk in chain.stream({"concept": "递归"}):
print(chunk, end="", flush=True)
# batch:并发处理多个输入
results = chain.batch([
{"concept": "递归"},
{"concept": "闭包"},
{"concept": "协程"}
])
print(results)
这段代码先用 prompt | llm | parser 定义了一条简单的三步骤链。invoke 是最常见的调用方式,传入一个字典返回最终文本,整个过程是同步阻塞的。stream 则把链中的 LLM 调用切换为流式输出模式,每次只返回一个 token,用 for 循环边接收边打印,用户能实时看到文字逐字出现。batch 接收的是一个列表,内部对列表中的每个元素发起一次独立的 invoke,并且这些调用是并发执行的,三项概念的解释会在大致相同的时间内完成,而不是按顺序逐个等待。
管道运算符 | 的秘密
| 在 Python 里是位运算的"按位或"运算符,但 LangChain 通过重载 __or__ 和 __ror__ 魔术方法,赋予了它全新的含义。当你写 step1 | step2 时,Python 实际上调用的是 step1.__or__(step2),而 Runnable 的这个方法会返回一个 RunnableSequence 对象,把两个步骤封装在一起。
关键在于 | 只是声明,不是执行。chain = prompt | llm | parser 这行代码执行完之后,没有任何网络请求发出,没有任何 token 被生成,chain 只是一个描述"先走 prompt,再走 llm,最后走 parser"的配置对象。真正的执行发生在你调用 .invoke()、.stream() 或 .batch() 的时候。这种惰性设计让你可以把链的构建和链的执行完全分离,也让链本身可以被序列化、复用和传递。
下面用一条简单的翻译链来验证这一点,注意代码中注释标注的"声明"与"执行"两个阶段:
from dotenv import load_dotenv
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_openai import ChatOpenAI
# 读取 .env 文件并注入环境变量,之后 LangChain 会自动从环境变量中获取密钥
load_dotenv()
# 声明三步串联的链——此刻什么都没有执行
step1 = ChatPromptTemplate.from_template("将以下内容翻译成英文:{text}")
step2 = ChatOpenAI(
model="Qwen/Qwen3.6-35B-A3B", temperature=0,
base_url="https://api.siliconflow.cn/v1"
)
step3 = StrOutputParser()
chain = step1 | step2 | step3 # 返回 RunnableSequence,尚未调用任何 API
# 直到这里才真正执行
output = chain.invoke({"text": "今天天气很好"})
print(output)
这段代码把 | 的惰性特征表现得非常清楚。chain = step1 | step2 | step3 发生在 Python 进程内部,只是创建了一个 RunnableSequence 对象,不涉及任何 I/O 操作。直到 chain.invoke(...) 被调用,框架才开始依次执行 prompt 模板填充、LLM API 请求和 parser 后处理,三个步骤一气呵成但只有调用方自己控制时机。这种设计还有一个重要的工程收益是链对象可以被持久化、跨函数传递,甚至在多个请求之间反复复用,不必每次重新构建。
并行处理:RunnableParallel
有些场景下,同一份输入需要经过多条独立的分析路径,而这些路径之间没有依赖关系。例如,对一篇文章同时生成摘要、提取关键词、判断情感倾向,三条路径可以完全并行,没有理由顺序等待。RunnableParallel 正是为这种场景设计的。
RunnableParallel 接收若干个具名子链,将同一个输入分发给所有子链并发执行,最终把所有结果汇总成一个字典返回,字典的键就是你定义时指定的名称。这样既节省了等待时间,又让结果结构一目了然。
下面是一个文章三路分析的实际例子,用 RunnableParallel 把摘要、关键词和情感判断同时发起:
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_core.runnables import RunnableParallel
from langchain_openai import ChatOpenAI
from dotenv import load_dotenv
# 读取 .env 文件并注入环境变量,之后 LangChain 会自动从环境变量中获取密钥
load_dotenv()
llm = ChatOpenAI(
model="Qwen/Qwen3.6-35B-A3B", temperature=0.5,
base_url="https://api.siliconflow.cn/v1"
)
parser = StrOutputParser()
summary_chain = (
ChatPromptTemplate.from_template("用一句话总结:{article}") | llm | parser
)
keywords_chain = (
ChatPromptTemplate.from_template("提取3个关键词,逗号分隔:{article}") | llm | parser
)
sentiment_chain = (
ChatPromptTemplate.from_template(
"判断情感倾向(正面/负面/中性):{article}") | llm | parser
)
parallel = RunnableParallel(
summary=summary_chain,
keywords=keywords_chain,
sentiment=sentiment_chain,
)
result = parallel.invoke({"article": "今天发布了新款手机,性能大幅提升,价格却比上一代更低。"})
# result 是一个 dict:{"summary": "...", "keywords": "...", "sentiment": "..."}
print(result)
这里的 parallel 是一个顶层 chain,同样可以用 | 与上下游组件串联。调用 .invoke() 时,框架会将同一篇 article 同时发送给三条子链,LLM 三次请求几乎是同时发出的,最终等待最慢的那条返回后汇总为字典。如果你的业务需要更细粒度的控制,RunnableParallel 还支持传入匿名函数和 RunnableLambda,让你对同一条输入做任意维度的拆分处理。
条件分支:RunnableBranch
真实业务往往需要根据输入的内容决定走哪条处理路径。比如,客服系统需要把标记为"紧急"的工单路由给更快的处理流程,普通工单则走常规流程。RunnableBranch 提供了这种动态路由能力。
RunnableBranch 接收一系列 (谓词, 处理链) 元组,外加最后一个作为默认分支的处理链。执行时,它依次检查每个谓词(谓词是一个接收输入并返回布尔值的函数),第一个返回 True 的谓词对应的处理链会被调用。如果所有谓词都不匹配,则使用默认分支。这与 if/elif/else 的逻辑完全一致,只是写成了可以嵌入 LCEL chain 的形式。
下面的客服路由例子,根据输入话题中是否包含"紧急"字样,分别走不同的 prompt 链:
from dotenv import load_dotenv
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_core.runnables import RunnableBranch
from langchain_openai import ChatOpenAI
# 读取 .env 文件并注入环境变量,之后 LangChain 会自动从环境变量中获取密钥
load_dotenv()
llm = ChatOpenAI(
model="Qwen/Qwen3.6-35B-A3B", temperature=0,
base_url="https://api.siliconflow.cn/v1"
)
parser = StrOutputParser()
urgent_chain = (
ChatPromptTemplate.from_template("【紧急处理】请立即回复此问题:{topic}") | llm | parser
)
normal_chain = (
ChatPromptTemplate.from_template("请回复此问题:{topic}") | llm | parser
)
branch = RunnableBranch(
(lambda x: "紧急" in x["topic"], urgent_chain), # 谓词匹配则走紧急链
normal_chain, # 默认分支
)
print(branch.invoke({"topic": "紧急:服务器无法访问"}))
print(branch.invoke({"topic": "如何重置密码"}))
这个例子中,两条 .invoke() 调用分别触发了不同的分支。第一个输入匹配到 lambda 谓词,使用了带 【紧急处理】 前缀的 prompt。第二个输入不匹配任何条件,自然落入默认的 normal_chain。实际项目中你可以在分支上接入完全不同的模型、工具链甚至外部 API,RunnableBranch 本身也是一个 Runnable,照样能用 | 串在更大的 chain 中。
自定义组件:RunnableLambda
LCEL 内置的组件覆盖了大多数常见需求,但总有一些业务特定的处理逻辑,例如格式转换、数据清洗、自定义聚合等,需要用普通 Python 函数来实现。RunnableLambda 就是连接普通函数与 LCEL 世界的桥梁。
只需要把任意一个可调用对象(函数、lambda、带 __call__ 的类实例)传给 RunnableLambda,它就变成了一个合法的 Runnable,可以用 | 自由拼接到任何 chain 中。函数的输入就是上游组件的输出,函数的返回值就是传给下游组件的输入,行为与其他 Runnable 完全一致。
下面演示一个常见的后处理需求,将 LLM 返回的描述词转为大写并添加标记前缀,然后用 | 无缝拼入 chain:
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_core.runnables import RunnableLambda
from langchain_openai import ChatOpenAI
# 读取 .env 文件并注入环境变量,之后 LangChain 会自动从环境变量中获取密钥
load_dotenv()
llm = ChatOpenAI(
model="Qwen/Qwen3.6-35B-A3B", temperature=0,
base_url="https://api.siliconflow.cn/v1"
)
parser = StrOutputParser()
# 定义一个普通函数:将文本转为大写并添加前缀
def to_uppercase_with_prefix(text: str) -> str:
return f"[PROCESSED] {text.upper()}"
# 用 RunnableLambda 包装,使其可以接入 chain
postprocess = RunnableLambda(to_uppercase_with_prefix)
chain = (
ChatPromptTemplate.from_template("用一个词描述:{thing}")
| llm
| parser
| postprocess # 无缝接入自定义逻辑
)
result = chain.invoke({"thing": "太阳"})
print(result) # 例如:[PROCESSED] BRIGHT
这段代码的关键在于 to_uppercase_with_prefix 虽然只是一个普通函数,但经过 RunnableLambda 包装后,它获得了与 ChatPromptTemplate、ChatOpenAI、StrOutputParser 完全相同的接口,可以通过 | 直接插入链的末尾。parser 输出的字符串会原封不动地传入 to_uppercase_with_prefix,处理后的结果作为整个 chain 的最终返回值。如果需要传递额外参数,可以用 functools.partial 先绑定好,再把绑定后的函数交给 RunnableLambda,灵活度非常高。
练习任务
- 实现一个包含 parallel 分支的 LCEL chain
- 编写自定义 Runnable 并集成到 chain 中
- 尝试 stream 模式观察流式输出
考核点 ✅
- 代码验收:提交包含
RunnableParallel的 chain 代码,输出至少 2 路并行结果 - 自定义组件:提交一个自定义
RunnableLambda的 chain,能正常 invoke 返回结果 - 流式体验:演示
stream模式输出,与invoke模式对比说明差异 - 原理阐述:口头解释
|运算符在 LCEL 中的实际作用

浙公网安备 33010602011771号