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 包装后,它获得了与 ChatPromptTemplateChatOpenAIStrOutputParser 完全相同的接口,可以通过 | 直接插入链的末尾。parser 输出的字符串会原封不动地传入 to_uppercase_with_prefix,处理后的结果作为整个 chain 的最终返回值。如果需要传递额外参数,可以用 functools.partial 先绑定好,再把绑定后的函数交给 RunnableLambda,灵活度非常高。

练习任务

  • 实现一个包含 parallel 分支的 LCEL chain
  • 编写自定义 Runnable 并集成到 chain 中
  • 尝试 stream 模式观察流式输出

考核点 ✅

  1. 代码验收:提交包含 RunnableParallel 的 chain 代码,输出至少 2 路并行结果
  2. 自定义组件:提交一个自定义 RunnableLambda 的 chain,能正常 invoke 返回结果
  3. 流式体验:演示 stream 模式输出,与 invoke 模式对比说明差异
  4. 原理阐述:口头解释 | 运算符在 LCEL 中的实际作用
posted @ 2026-07-04 23:45  喵叔哟  阅读(1)  评论(0)    收藏  举报