05_硬核_LCEL表达式语言深度解析

概述:为什么 LCEL 是 LangChain 的核心能力?

前几篇我们已经多次写过这样的代码:

python 复制代码
chain = prompt | model | parser

result = chain.invoke({"topic": "LangChain"})

这行代码看起来像语法糖,但它其实是 LangChain 很核心的一层抽象:LCEL。

LCEL 全称是 LangChain Expression Language,可以理解成 LangChain 用来声明、组合和执行调用流程的表达式语言。

它要解决的问题是:

  • 如何把 Prompt、Model、Parser、Retriever、Tool、普通函数串成一个流程。
  • 如何让这个流程天然支持 invokebatchstream、异步调用。
  • 如何把顺序执行、并行执行、字段透传、字段追加这些数据流操作表达清楚。
  • 如何让复杂链路可以被追踪、调试、复用和测试。

如果只写一次模型调用,LCEL 的价值不明显。但只要你的应用进入 RAG、工具调用、结构化输出、多模型路由、Agent 工作流,LCEL 就会变成基础设施。

LCEL 的本质,是用统一的 Runnable 抽象,把 LLM 应用里的多个处理步骤组合成可执行、可追踪、可复用的数据流。

先看一个最小链:prompt | model | parser

先从最熟悉的例子开始:

python 复制代码
from dotenv import load_dotenv
from langchain.chat_models import init_chat_model
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate

load_dotenv()

model = init_chat_model(
    "gpt-4o-mini",
    model_provider="openai",
    temperature=0,
)

prompt = ChatPromptTemplate.from_messages([
    ("system", "你是一个严谨的技术导师。"),
    ("human", "请解释这个概念:{topic}"),
])

parser = StrOutputParser()

chain = prompt | model | parser

result = chain.invoke({"topic": "LCEL"})

print(result)

这条链的数据流是:
#mermaid-svg-OPJTCGoFa8EdzM6f{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-OPJTCGoFa8EdzM6f .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-OPJTCGoFa8EdzM6f .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-OPJTCGoFa8EdzM6f .error-icon{fill:#552222;}#mermaid-svg-OPJTCGoFa8EdzM6f .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-OPJTCGoFa8EdzM6f .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-OPJTCGoFa8EdzM6f .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-OPJTCGoFa8EdzM6f .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-OPJTCGoFa8EdzM6f .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-OPJTCGoFa8EdzM6f .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-OPJTCGoFa8EdzM6f .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-OPJTCGoFa8EdzM6f .marker{fill:#333333;stroke:#333333;}#mermaid-svg-OPJTCGoFa8EdzM6f .marker.cross{stroke:#333333;}#mermaid-svg-OPJTCGoFa8EdzM6f svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-OPJTCGoFa8EdzM6f p{margin:0;}#mermaid-svg-OPJTCGoFa8EdzM6f .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-OPJTCGoFa8EdzM6f .cluster-label text{fill:#333;}#mermaid-svg-OPJTCGoFa8EdzM6f .cluster-label span{color:#333;}#mermaid-svg-OPJTCGoFa8EdzM6f .cluster-label span p{background-color:transparent;}#mermaid-svg-OPJTCGoFa8EdzM6f .label text,#mermaid-svg-OPJTCGoFa8EdzM6f span{fill:#333;color:#333;}#mermaid-svg-OPJTCGoFa8EdzM6f .node rect,#mermaid-svg-OPJTCGoFa8EdzM6f .node circle,#mermaid-svg-OPJTCGoFa8EdzM6f .node ellipse,#mermaid-svg-OPJTCGoFa8EdzM6f .node polygon,#mermaid-svg-OPJTCGoFa8EdzM6f .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-OPJTCGoFa8EdzM6f .rough-node .label text,#mermaid-svg-OPJTCGoFa8EdzM6f .node .label text,#mermaid-svg-OPJTCGoFa8EdzM6f .image-shape .label,#mermaid-svg-OPJTCGoFa8EdzM6f .icon-shape .label{text-anchor:middle;}#mermaid-svg-OPJTCGoFa8EdzM6f .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-OPJTCGoFa8EdzM6f .rough-node .label,#mermaid-svg-OPJTCGoFa8EdzM6f .node .label,#mermaid-svg-OPJTCGoFa8EdzM6f .image-shape .label,#mermaid-svg-OPJTCGoFa8EdzM6f .icon-shape .label{text-align:center;}#mermaid-svg-OPJTCGoFa8EdzM6f .node.clickable{cursor:pointer;}#mermaid-svg-OPJTCGoFa8EdzM6f .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-OPJTCGoFa8EdzM6f .arrowheadPath{fill:#333333;}#mermaid-svg-OPJTCGoFa8EdzM6f .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-OPJTCGoFa8EdzM6f .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-OPJTCGoFa8EdzM6f .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-OPJTCGoFa8EdzM6f .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-OPJTCGoFa8EdzM6f .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-OPJTCGoFa8EdzM6f .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-OPJTCGoFa8EdzM6f .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-OPJTCGoFa8EdzM6f .cluster text{fill:#333;}#mermaid-svg-OPJTCGoFa8EdzM6f .cluster span{color:#333;}#mermaid-svg-OPJTCGoFa8EdzM6f div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-OPJTCGoFa8EdzM6f .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-OPJTCGoFa8EdzM6f rect.text{fill:none;stroke-width:0;}#mermaid-svg-OPJTCGoFa8EdzM6f .icon-shape,#mermaid-svg-OPJTCGoFa8EdzM6f .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-OPJTCGoFa8EdzM6f .icon-shape p,#mermaid-svg-OPJTCGoFa8EdzM6f .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-OPJTCGoFa8EdzM6f .icon-shape .label rect,#mermaid-svg-OPJTCGoFa8EdzM6f .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-OPJTCGoFa8EdzM6f .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-OPJTCGoFa8EdzM6f .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-OPJTCGoFa8EdzM6f :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 输入字典: topic
ChatPromptTemplate
PromptValue / Messages
ChatModel
AIMessage
StrOutputParser
字符串

对应关系:

步骤 输入 输出
prompt {"topic": "LCEL"} messages
model messages AIMessage
parser AIMessage str

| 的含义不是"字符串拼接",而是把左边组件的输出交给右边组件作为输入。

也就是说:

python 复制代码
chain = prompt | model | parser

可以近似理解成:

python 复制代码
prompt_output = prompt.invoke({"topic": "LCEL"})
model_output = model.invoke(prompt_output)
result = parser.invoke(model_output)

但 LCEL 不只是帮你少写几行代码。它真正的价值是:组合之后的 chain 自己也是一个 Runnable,因此它也能继续被组合、批处理、流式输出和追踪。

Runnable:LCEL 的统一接口

官方参考文档里,Runnable 被定义为一个可以被调用、批处理、流式处理、转换和组合的工作单元。

你可以把 Runnable 理解成 LangChain 世界里的"可执行组件"。

常见 Runnable 包括:

  • ChatPromptTemplate
  • ChatModel
  • StrOutputParser
  • Retriever
  • RunnableLambda
  • RunnableParallel
  • RunnablePassthrough
  • 由多个 Runnable 组合出来的 RunnableSequence

它们看起来功能不同,但都有相似的调用方式:

python 复制代码
output = runnable.invoke(input_data)

很多 Runnable 还支持:

python 复制代码
runnable.batch(list_of_inputs)
runnable.stream(input_data)
await runnable.ainvoke(input_data)
await runnable.abatch(list_of_inputs)

只要一个组件实现了 Runnable 接口,它就可以进入 LCEL 管道。

RunnableSequence:顺序执行的数据管道

最常见的 LCEL 链是顺序执行:

python 复制代码
chain = prompt | model | parser

这背后构造的是 RunnableSequence

RunnableSequence 的规则很简单:

上一步的输出,作为下一步的输入。

例如:

text 复制代码
input -> step1 -> step2 -> step3 -> output

用纯函数演示会更直观:

python 复制代码
from langchain_core.runnables import RunnableLambda


def add_one(x: int) -> int:
    return x + 1


def multiply_by_two(x: int) -> int:
    return x * 2


chain = RunnableLambda(add_one) | RunnableLambda(multiply_by_two)

print(chain.invoke(3))

输出:

text 复制代码
8

执行过程:

text 复制代码
3 -> add_one -> 4 -> multiply_by_two -> 8

这和 LLM 没有关系,但它说明了 LCEL 的核心:每个步骤只关心自己的输入输出,组合逻辑交给 RunnableSequence。

RunnableLambda:把普通函数放进链里

很多时候,我们需要在链中间做一些轻量处理:

  • 清洗输入。
  • 提取字段。
  • 转换格式。
  • 追加默认值。
  • 对模型输出做后处理。

这时可以用 RunnableLambda 把普通 Python 函数包装成 Runnable。

示例:把用户输入转成统一字典。

python 复制代码
from langchain_core.runnables import RunnableLambda


def normalize_input(question: str) -> dict:
    return {
        "question": question.strip(),
        "language": "中文",
    }


normalizer = RunnableLambda(normalize_input)

print(normalizer.invoke("  LangChain 是什么?  "))

输出:

python 复制代码
{"question": "LangChain 是什么?", "language": "中文"}

放进 chain:

python 复制代码
from langchain_core.prompts import ChatPromptTemplate

prompt = ChatPromptTemplate.from_messages([
    ("system", "请使用 {language} 回答。"),
    ("human", "{question}"),
])

chain = normalizer | prompt

数据流:

text 复制代码
"  LangChain 是什么?  "
    -> normalize_input
{"question": "LangChain 是什么?", "language": "中文"}
    -> prompt
messages

RunnableLambda 的适用边界

RunnableLambda 很方便,但不要滥用。

适合:

  • 纯数据转换。
  • 字段提取。
  • 小型业务判断。
  • 简单格式化。

不适合:

  • 大量复杂业务逻辑。
  • 有副作用的写数据库操作。
  • 需要完整流式转换的场景。
  • 应该封装成工具、服务或 LangGraph 节点的复杂流程。

官方参考中也提醒,RunnableLambda 默认不实现流式 transform,所以如果你把它放在流式链路中间,可能会影响后续流式输出开始的时机。

RunnableLambda 是把普通函数接入 LCEL 的桥,但复杂业务不要都塞进 lambda。

RunnableParallel:并行执行多个分支

顺序链解决的是"一步接一步"。

但很多场景需要"一份输入,同时走多个分支"。

例如:

  • 同一个问题,同时生成摘要和关键词。
  • 同一个主题,同时生成标题、简介和大纲。
  • 同一个查询,同时走向量检索和关键词检索。
  • 同一个用户问题,同时做意图识别和情绪识别。

这时可以用 RunnableParallel

纯函数示例:

python 复制代码
from langchain_core.runnables import RunnableLambda, RunnableParallel


def add_one(x: int) -> int:
    return x + 1


def multiply_by_two(x: int) -> int:
    return x * 2


parallel = RunnableParallel(
    plus_one=RunnableLambda(add_one),
    times_two=RunnableLambda(multiply_by_two),
)

print(parallel.invoke(3))

输出:

python 复制代码
{"plus_one": 4, "times_two": 6}

数据流:
#mermaid-svg-8Yh8LdWfPQir0LrU{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-8Yh8LdWfPQir0LrU .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-8Yh8LdWfPQir0LrU .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-8Yh8LdWfPQir0LrU .error-icon{fill:#552222;}#mermaid-svg-8Yh8LdWfPQir0LrU .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-8Yh8LdWfPQir0LrU .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-8Yh8LdWfPQir0LrU .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-8Yh8LdWfPQir0LrU .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-8Yh8LdWfPQir0LrU .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-8Yh8LdWfPQir0LrU .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-8Yh8LdWfPQir0LrU .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-8Yh8LdWfPQir0LrU .marker{fill:#333333;stroke:#333333;}#mermaid-svg-8Yh8LdWfPQir0LrU .marker.cross{stroke:#333333;}#mermaid-svg-8Yh8LdWfPQir0LrU svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-8Yh8LdWfPQir0LrU p{margin:0;}#mermaid-svg-8Yh8LdWfPQir0LrU .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-8Yh8LdWfPQir0LrU .cluster-label text{fill:#333;}#mermaid-svg-8Yh8LdWfPQir0LrU .cluster-label span{color:#333;}#mermaid-svg-8Yh8LdWfPQir0LrU .cluster-label span p{background-color:transparent;}#mermaid-svg-8Yh8LdWfPQir0LrU .label text,#mermaid-svg-8Yh8LdWfPQir0LrU span{fill:#333;color:#333;}#mermaid-svg-8Yh8LdWfPQir0LrU .node rect,#mermaid-svg-8Yh8LdWfPQir0LrU .node circle,#mermaid-svg-8Yh8LdWfPQir0LrU .node ellipse,#mermaid-svg-8Yh8LdWfPQir0LrU .node polygon,#mermaid-svg-8Yh8LdWfPQir0LrU .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-8Yh8LdWfPQir0LrU .rough-node .label text,#mermaid-svg-8Yh8LdWfPQir0LrU .node .label text,#mermaid-svg-8Yh8LdWfPQir0LrU .image-shape .label,#mermaid-svg-8Yh8LdWfPQir0LrU .icon-shape .label{text-anchor:middle;}#mermaid-svg-8Yh8LdWfPQir0LrU .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-8Yh8LdWfPQir0LrU .rough-node .label,#mermaid-svg-8Yh8LdWfPQir0LrU .node .label,#mermaid-svg-8Yh8LdWfPQir0LrU .image-shape .label,#mermaid-svg-8Yh8LdWfPQir0LrU .icon-shape .label{text-align:center;}#mermaid-svg-8Yh8LdWfPQir0LrU .node.clickable{cursor:pointer;}#mermaid-svg-8Yh8LdWfPQir0LrU .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-8Yh8LdWfPQir0LrU .arrowheadPath{fill:#333333;}#mermaid-svg-8Yh8LdWfPQir0LrU .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-8Yh8LdWfPQir0LrU .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-8Yh8LdWfPQir0LrU .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-8Yh8LdWfPQir0LrU .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-8Yh8LdWfPQir0LrU .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-8Yh8LdWfPQir0LrU .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-8Yh8LdWfPQir0LrU .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-8Yh8LdWfPQir0LrU .cluster text{fill:#333;}#mermaid-svg-8Yh8LdWfPQir0LrU .cluster span{color:#333;}#mermaid-svg-8Yh8LdWfPQir0LrU div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-8Yh8LdWfPQir0LrU .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-8Yh8LdWfPQir0LrU rect.text{fill:none;stroke-width:0;}#mermaid-svg-8Yh8LdWfPQir0LrU .icon-shape,#mermaid-svg-8Yh8LdWfPQir0LrU .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-8Yh8LdWfPQir0LrU .icon-shape p,#mermaid-svg-8Yh8LdWfPQir0LrU .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-8Yh8LdWfPQir0LrU .icon-shape .label rect,#mermaid-svg-8Yh8LdWfPQir0LrU .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-8Yh8LdWfPQir0LrU .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-8Yh8LdWfPQir0LrU .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-8Yh8LdWfPQir0LrU :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 输入 3
add_one
multiply_by_two
plus_one: 4
times_two: 6
输出字典

在 LCEL 里,字典字面量也经常被自动解释为并行结构:

python 复制代码
chain = RunnableLambda(add_one) | {
    "times_two": RunnableLambda(multiply_by_two),
    "as_text": RunnableLambda(lambda x: f"当前值是 {x}"),
}

这等价于先执行 add_one,再把结果同时送到两个分支。

LLM 示例:同一主题生成多个结果

python 复制代码
from dotenv import load_dotenv
from langchain.chat_models import init_chat_model
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.runnables import RunnableParallel

load_dotenv()

model = init_chat_model(
    "gpt-4o-mini",
    model_provider="openai",
    temperature=0,
)

title_chain = (
    ChatPromptTemplate.from_template("为主题《{topic}》生成 3 个技术文章标题。")
    | model
    | StrOutputParser()
)

summary_chain = (
    ChatPromptTemplate.from_template("用 100 字介绍主题《{topic}》。")
    | model
    | StrOutputParser()
)

outline_chain = (
    ChatPromptTemplate.from_template("为主题《{topic}》生成 5 点文章大纲。")
    | model
    | StrOutputParser()
)

chain = RunnableParallel(
    titles=title_chain,
    summary=summary_chain,
    outline=outline_chain,
)

result = chain.invoke({"topic": "LangChain LCEL"})

print(result["titles"])
print(result["summary"])
print(result["outline"])

返回值是一个字典:

python 复制代码
{
    "titles": "...",
    "summary": "...",
    "outline": "..."
}

RunnableParallel 让同一份输入同时进入多个 Runnable 分支,并把分支结果合并成字典。

RunnablePassthrough:保留原始输入

很多链路中,我们不只是要处理后的结果,还要保留原始输入。

例如:

text 复制代码
输入问题 -> 检索文档 -> 生成答案

生成答案时,Prompt 同时需要:

  • 原始问题 question
  • 检索上下文 context

这时就会用到 RunnablePassthrough

一个典型写法:

python 复制代码
from langchain_core.runnables import RunnablePassthrough

chain = {
    "question": RunnablePassthrough(),
    "context": retriever,
} | prompt | model | parser

数据流是:
#mermaid-svg-HI0j3LsZRwtf3F6D{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-HI0j3LsZRwtf3F6D .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-HI0j3LsZRwtf3F6D .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-HI0j3LsZRwtf3F6D .error-icon{fill:#552222;}#mermaid-svg-HI0j3LsZRwtf3F6D .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-HI0j3LsZRwtf3F6D .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-HI0j3LsZRwtf3F6D .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-HI0j3LsZRwtf3F6D .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-HI0j3LsZRwtf3F6D .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-HI0j3LsZRwtf3F6D .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-HI0j3LsZRwtf3F6D .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-HI0j3LsZRwtf3F6D .marker{fill:#333333;stroke:#333333;}#mermaid-svg-HI0j3LsZRwtf3F6D .marker.cross{stroke:#333333;}#mermaid-svg-HI0j3LsZRwtf3F6D svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-HI0j3LsZRwtf3F6D p{margin:0;}#mermaid-svg-HI0j3LsZRwtf3F6D .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-HI0j3LsZRwtf3F6D .cluster-label text{fill:#333;}#mermaid-svg-HI0j3LsZRwtf3F6D .cluster-label span{color:#333;}#mermaid-svg-HI0j3LsZRwtf3F6D .cluster-label span p{background-color:transparent;}#mermaid-svg-HI0j3LsZRwtf3F6D .label text,#mermaid-svg-HI0j3LsZRwtf3F6D span{fill:#333;color:#333;}#mermaid-svg-HI0j3LsZRwtf3F6D .node rect,#mermaid-svg-HI0j3LsZRwtf3F6D .node circle,#mermaid-svg-HI0j3LsZRwtf3F6D .node ellipse,#mermaid-svg-HI0j3LsZRwtf3F6D .node polygon,#mermaid-svg-HI0j3LsZRwtf3F6D .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-HI0j3LsZRwtf3F6D .rough-node .label text,#mermaid-svg-HI0j3LsZRwtf3F6D .node .label text,#mermaid-svg-HI0j3LsZRwtf3F6D .image-shape .label,#mermaid-svg-HI0j3LsZRwtf3F6D .icon-shape .label{text-anchor:middle;}#mermaid-svg-HI0j3LsZRwtf3F6D .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-HI0j3LsZRwtf3F6D .rough-node .label,#mermaid-svg-HI0j3LsZRwtf3F6D .node .label,#mermaid-svg-HI0j3LsZRwtf3F6D .image-shape .label,#mermaid-svg-HI0j3LsZRwtf3F6D .icon-shape .label{text-align:center;}#mermaid-svg-HI0j3LsZRwtf3F6D .node.clickable{cursor:pointer;}#mermaid-svg-HI0j3LsZRwtf3F6D .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-HI0j3LsZRwtf3F6D .arrowheadPath{fill:#333333;}#mermaid-svg-HI0j3LsZRwtf3F6D .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-HI0j3LsZRwtf3F6D .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-HI0j3LsZRwtf3F6D .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-HI0j3LsZRwtf3F6D .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-HI0j3LsZRwtf3F6D .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-HI0j3LsZRwtf3F6D .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-HI0j3LsZRwtf3F6D .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-HI0j3LsZRwtf3F6D .cluster text{fill:#333;}#mermaid-svg-HI0j3LsZRwtf3F6D .cluster span{color:#333;}#mermaid-svg-HI0j3LsZRwtf3F6D div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-HI0j3LsZRwtf3F6D .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-HI0j3LsZRwtf3F6D rect.text{fill:none;stroke-width:0;}#mermaid-svg-HI0j3LsZRwtf3F6D .icon-shape,#mermaid-svg-HI0j3LsZRwtf3F6D .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-HI0j3LsZRwtf3F6D .icon-shape p,#mermaid-svg-HI0j3LsZRwtf3F6D .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-HI0j3LsZRwtf3F6D .icon-shape .label rect,#mermaid-svg-HI0j3LsZRwtf3F6D .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-HI0j3LsZRwtf3F6D .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-HI0j3LsZRwtf3F6D .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-HI0j3LsZRwtf3F6D :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 用户问题
RunnablePassthrough
Retriever
question
context
Prompt
Model
Parser

意思是:

  • RunnablePassthrough() 原样保留输入,作为 question
  • retriever 使用同一个输入检索文档,作为 context
  • 两个字段合并成字典,交给 prompt

如果不用 RunnablePassthrough,你可能会丢掉原始问题,只剩检索结果。

用 assign 追加字段

RunnablePassthrough.assign(...) 常用来在已有字典上追加字段。

例如输入已经是:

python 复制代码
{
    "question": "LCEL 是什么?",
    "context": "LCEL 是 LangChain 的表达式语言..."
}

你想追加一个字段 question_length

python 复制代码
from langchain_core.runnables import RunnableLambda, RunnablePassthrough

chain = RunnablePassthrough.assign(
    question_length=RunnableLambda(lambda x: len(x["question"]))
)

result = chain.invoke({
    "question": "LCEL 是什么?",
    "context": "LCEL 是 LangChain 的表达式语言..."
})

print(result)

输出类似:

python 复制代码
{
    "question": "LCEL 是什么?",
    "context": "LCEL 是 LangChain 的表达式语言...",
    "question_length": 8
}

RunnablePassthrough 用来保留输入,assign 用来在原有输入字典上追加新字段。

数据流视角:LCEL 里最重要的是输入输出形状

学 LCEL,不能只盯着 API 名字。

最重要的是看每一步的输入输出形状。

例如:

python 复制代码
chain = prompt | model | parser

输入输出形状:

text 复制代码
dict -> PromptValue/messages -> AIMessage -> str

再看 RAG 链:

python 复制代码
chain = (
    {
        "context": retriever,
        "question": RunnablePassthrough(),
    }
    | prompt
    | model
    | parser
)

输入输出形状:

text 复制代码
str
  -> {
       "context": list[Document],
       "question": str
     }
  -> PromptValue/messages
  -> AIMessage
  -> str

如果某一步报错,通常就是输入输出形状没接上。

例如 Prompt 需要 {question}

python 复制代码
prompt = ChatPromptTemplate.from_template("问题:{question}")

但你传进去的是字符串:

python 复制代码
prompt.invoke("LCEL 是什么?")

就不如传字典清晰:

python 复制代码
prompt.invoke({"question": "LCEL 是什么?"})

LCEL 调试的第一原则,是画清楚每一步的输入类型和输出类型。

invoke:单次调用

invoke 是最基础的执行方式。

python 复制代码
result = chain.invoke({"topic": "LCEL"})

它表示:给链一个输入,等待完整输出。

适合:

  • 普通问答。
  • 数据抽取。
  • 分类。
  • 单次摘要。
  • 后端同步任务。

invoke 的返回值取决于链最后一个步骤。

例如:

python 复制代码
chain = prompt | model

返回通常是 AIMessage

如果加了:

python 复制代码
chain = prompt | model | StrOutputParser()

返回就是字符串。

所以不要机械地认为 chain.invoke() 一定返回字符串。要看链的最后一步是什么。

batch:批量处理

batch 可以一次处理多个输入:

python 复制代码
inputs = [
    {"topic": "LCEL"},
    {"topic": "Runnable"},
    {"topic": "RAG"},
]

results = chain.batch(inputs)

for result in results:
    print(result)

如果你的链是:

python 复制代码
chain = prompt | model | StrOutputParser()

那么 results 就是字符串列表。

适合:

  • 批量生成摘要。
  • 批量分类。
  • 批量生成标签。
  • 批量清洗文本。

控制并发

批量调用模型时要小心限流。可以通过 config 控制并发:

python 复制代码
results = chain.batch(
    inputs,
    config={"max_concurrency": 5},
)

不要把几千条数据直接一次性扔给 batch。生产环境还需要考虑:

  • 供应商限流。
  • 失败重试。
  • 断点续跑。
  • 成本控制。
  • 日志和追踪。
  • 输出落库。

stream:流式输出

如果你做聊天界面,通常不希望用户等完整答案生成完才看到内容。

可以使用 stream

python 复制代码
for chunk in chain.stream({"topic": "LCEL"}):
    print(chunk, end="", flush=True)

如果链最后是 StrOutputParser(),chunk 通常就是逐步解析出的字符串片段。

流式输出的数据流可以理解为:

text 复制代码
输入 -> Prompt -> Model 持续吐 token -> Parser 持续解析 -> 前端逐步显示

但要注意:不是所有 Runnable 都天然保持流式。

如果中间插入了一个普通 RunnableLambda

python 复制代码
chain = prompt | model | RunnableLambda(post_process) | parser

它可能会等上游完整输出结束后才执行,从而让流式效果变差。

所以设计流式链路时,要格外关注中间组件是否支持流式 transform。

ainvoke / abatch / astream:异步调用

异步版本适合 Web 服务、并发任务和异步框架。

python 复制代码
result = await chain.ainvoke({"topic": "LCEL"})

批量异步:

python 复制代码
results = await chain.abatch([
    {"topic": "LCEL"},
    {"topic": "Runnable"},
])

异步流:

python 复制代码
async for chunk in chain.astream({"topic": "LCEL"}):
    print(chunk, end="", flush=True)

如果你在 FastAPI、Starlette、异步任务队列里使用 LangChain,异步接口会更自然。

但不要为了异步而异步。简单脚本和学习 Demo,用同步 invoke 更容易调试。

with_config:给链加运行配置

Runnable 支持运行配置。最常见的是:

  • run_name
  • tags
  • metadata
  • max_concurrency

示例:

python 复制代码
result = chain.invoke(
    {"topic": "LCEL"},
    config={
        "run_name": "explain_lcel_chain",
        "tags": ["tutorial", "lcel"],
        "metadata": {
            "article": "05",
            "env": "dev",
        },
    },
)

如果你接入 LangSmith,这些信息会帮助你追踪和过滤运行记录。

也可以提前绑定配置:

python 复制代码
named_chain = chain.with_config(
    run_name="explain_lcel_chain",
    tags=["tutorial", "lcel"],
)

result = named_chain.invoke({"topic": "LCEL"})

with_config 不改变业务逻辑,但会让链更容易观测和调试。

with_retry:给链加重试

模型调用可能因为网络抖动、限流、服务端错误失败。

可以给 Runnable 添加重试:

python 复制代码
reliable_chain = chain.with_retry()

result = reliable_chain.invoke({"topic": "LCEL"})

更完整的重试策略要结合具体错误类型、次数和业务幂等性。

适合重试的场景:

  • 临时网络错误。
  • 服务端 5xx。
  • 供应商限流后等待重试。
  • 偶发超时。

不适合盲目重试的场景:

  • API Key 错误。
  • 模型名写错。
  • Prompt 变量缺失。
  • 输入数据格式错误。
  • 触发外部副作用的工具调用。

重试只能提高临时故障下的成功率,不能修复代码错误和业务设计错误。

with_fallbacks:主链失败时走备用链

如果主模型失败,可以切备用模型。

python 复制代码
primary_chain = prompt | primary_model | StrOutputParser()
backup_chain = prompt | backup_model | StrOutputParser()

chain = primary_chain.with_fallbacks([backup_chain])

result = chain.invoke({"topic": "LCEL"})

这比自己写一个简单 try-except 更贴近 Runnable 体系。

但 fallback 也要谨慎:

  • 主模型和备用模型输出格式要一致。
  • 如果链里有工具调用,要确认备用模型也支持工具调用。
  • 需要记录最终命中了哪个模型。
  • 只应该对可恢复错误 fallback。
  • 不要把 prompt 变量错误这种开发期问题隐藏掉。

fallback 的目的是提高服务可用性,不是掩盖系统缺陷。

bind:给模型绑定固定参数

有些参数你希望在某条链里固定。

例如给模型绑定停止词:

python 复制代码
bound_model = model.bind(stop=["\n\n"])

chain = prompt | bound_model | StrOutputParser()

bind 会返回一个新的 Runnable,不会修改原始模型对象。

适合:

  • 绑定供应商特定参数。
  • 绑定停止词。
  • 绑定某些调用配置。

但供应商参数差异较大,生产环境要确认目标模型是否支持对应参数。

pick 和 assign:操作字典字段

LCEL 里经常处理字典。

假设输入是:

python 复制代码
data = {
    "question": "LCEL 是什么?",
    "user_id": "u_001",
    "trace_id": "t_001",
}

如果 Prompt 只需要 question,可以用 pick

python 复制代码
question_chain = RunnablePassthrough().pick("question")

print(question_chain.invoke(data))

输出:

text 复制代码
LCEL 是什么?

如果要在原始字典上追加字段,用 assign

python 复制代码
chain = RunnablePassthrough.assign(
    question_length=RunnableLambda(lambda x: len(x["question"]))
)

print(chain.invoke(data))

输出:

python 复制代码
{
    "question": "LCEL 是什么?",
    "user_id": "u_001",
    "trace_id": "t_001",
    "question_length": 8,
}

这类字段操作在 RAG、日志、权限、引用来源处理里非常常见。

RAG 预演:用 LCEL 串起检索和生成

虽然 RAG 会在下一篇详细讲,这里先看一个 LCEL 视角的 RAG 骨架。

python 复制代码
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.runnables import RunnablePassthrough

prompt = ChatPromptTemplate.from_messages([
    (
        "system",
        "你是一个知识库问答助手。只能根据提供的资料回答。"
        "如果资料中没有答案,请说无法确定。"
    ),
    (
        "human",
        "资料:\n{context}\n\n"
        "问题:{question}"
    ),
])

rag_chain = (
    {
        "context": retriever,
        "question": RunnablePassthrough(),
    }
    | prompt
    | model
    | StrOutputParser()
)

answer = rag_chain.invoke("LangChain 的 Runnable 是什么?")

print(answer)

这条链最关键的是第一段:

python 复制代码
{
    "context": retriever,
    "question": RunnablePassthrough(),
}

同一个字符串输入会分成两路:

  • 交给 retriever 检索文档,得到 context
  • 原样透传,得到 question

然后合并成:

python 复制代码
{
    "context": [...],
    "question": "LangChain 的 Runnable 是什么?"
}

再送给 Prompt。

这就是 LCEL 的威力:RAG 看起来是复杂流程,但在数据流层面就是"分支、合并、顺序执行"。

多步骤处理:先改写问题,再检索,再回答

真实 RAG 里,用户问题可能不适合直接检索。

例如多轮对话中:

text 复制代码
用户:LangChain 的 Runnable 是什么?
用户:那它和 Chain 有什么区别?

第二句里的"它"需要被改写成完整问题。

LCEL 可以这样组织:

python 复制代码
rewrite_prompt = ChatPromptTemplate.from_messages([
    (
        "system",
        "你负责把用户问题改写成适合检索的独立问题。只输出改写后的问题。"
    ),
    ("human", "历史对话:{history}\n当前问题:{question}"),
])

rewrite_chain = rewrite_prompt | model | StrOutputParser()

然后把改写结果送入检索:

python 复制代码
retrieval_chain = (
    {
        "standalone_question": rewrite_chain,
        "original_question": RunnablePassthrough(),
    }
    | RunnablePassthrough.assign(
        context=RunnableLambda(lambda x: retriever.invoke(x["standalone_question"]))
    )
)

再接回答 Prompt:

python 复制代码
answer_prompt = ChatPromptTemplate.from_messages([
    ("system", "你只能根据资料回答问题。"),
    (
        "human",
        "资料:\n{context}\n\n"
        "原始问题:{original_question}\n"
        "改写问题:{standalone_question}"
    ),
])

answer_chain = retrieval_chain | answer_prompt | model | StrOutputParser()

这个例子不一定是最优实现,但它展示了 LCEL 的表达力:

  • 先生成中间字段。
  • 再基于中间字段执行检索。
  • 保留原始问题。
  • 最后组合上下文生成答案。

当流程继续变复杂,或者需要循环、条件边、人工审批、持久状态时,就应该考虑 LangGraph。

LCEL 和普通 Python 函数有什么区别?

你当然可以不用 LCEL,直接写 Python:

python 复制代码
messages = prompt.invoke(input_data)
response = model.invoke(messages)
result = parser.invoke(response)

这没有错。

但 LCEL 提供了一些额外收益:

能力 普通 Python 串调用 LCEL
顺序组合 手写中间变量 `prompt
并行分支 手写并发和合并 RunnableParallel / dict
批处理 自己写循环和并发 batch / abatch
流式输出 自己处理每层 stream stream / astream
重试 自己封装 with_retry
fallback 自己写 try-except with_fallbacks
追踪 自己打日志 与 LangSmith / callbacks 体系集成
复用 需要手动封装函数 组合结果仍是 Runnable

如果只是两三行脚本,普通 Python 更直接。

如果你在构建一个会持续演进的 LLM 应用,LCEL 会让流程结构更清晰。

LCEL 和 LangGraph 怎么选?

LCEL 适合线性或有少量分支的无状态流程。

典型场景:

  • Prompt -> Model -> Parser。
  • 检索 -> Prompt -> Model。
  • 多个分支并行生成结果。
  • 批量文本处理。
  • 简单模型路由。

LangGraph 适合更复杂的状态图和循环控制。

典型场景:

  • Agent 多轮工具调用。
  • 条件跳转。
  • 循环反思。
  • 人工审批。
  • 长时间运行任务。
  • 持久化状态。
  • 多 Agent 协作。

可以这样判断:

问题 更适合
流程基本固定,只是串几个步骤 LCEL
有并行分支,但没有复杂状态机 LCEL
需要循环、条件边、状态持久化 LangGraph
需要人工中断和恢复 LangGraph
需要多 Agent 协作 LangGraph

LCEL 是数据管道,LangGraph 是状态图引擎。

调试技巧一:逐段 invoke

链出问题时,不要只看最终错误。

逐段调试:

python 复制代码
input_data = {"topic": "LCEL"}

prompt_output = prompt.invoke(input_data)
print(prompt_output)

model_output = model.invoke(prompt_output)
print(model_output)

parser_output = parser.invoke(model_output)
print(parser_output)

确认每一步的输出都符合下一步的输入要求。

如果逐段都没问题,再组合:

python 复制代码
chain = prompt | model | parser
print(chain.invoke(input_data))

这种方式对排查 Prompt 变量缺失、模型输出格式异常、Parser 解析失败特别有用。

调试技巧二:打印中间数据形状

可以插入 RunnableLambda 打印中间值:

python 复制代码
from langchain_core.runnables import RunnableLambda


def debug_print(x):
    print("DEBUG:", type(x), x)
    return x


debug = RunnableLambda(debug_print)

chain = prompt | debug | model | parser

这适合本地调试。

生产环境不要用 print 打敏感数据,应该接入日志系统,并注意脱敏。

调试技巧三:给关键步骤命名

with_config 给链或子链命名:

python 复制代码
prompt_step = prompt.with_config(run_name="build_prompt")
model_step = model.with_config(run_name="call_model")
parser_step = parser.with_config(run_name="parse_output")

chain = prompt_step | model_step | parser_step

如果使用 LangSmith,命名清楚的步骤更容易定位问题。

不要等链路复杂到看不懂时才开始命名。关键步骤从一开始就应该有语义化名称。

常见错误一:Prompt 输入变量对不上

Prompt 定义:

python 复制代码
prompt = ChatPromptTemplate.from_template("解释一下:{topic}")

错误调用:

python 复制代码
chain.invoke({"question": "LCEL"})

这里传的是 question,但 Prompt 需要的是 topic

正确调用:

python 复制代码
chain.invoke({"topic": "LCEL"})

排查方法:

python 复制代码
print(prompt.input_variables)

先确认 Prompt 到底需要哪些变量。

常见错误二:并行分支输出字段名和 Prompt 不一致

例如:

python 复制代码
chain = {
    "docs": retriever,
    "query": RunnablePassthrough(),
} | prompt

但 Prompt 写的是:

python 复制代码
prompt = ChatPromptTemplate.from_template(
    "资料:{context}\n问题:{question}"
)

这就对不上。

要么改分支字段:

python 复制代码
chain = {
    "context": retriever,
    "question": RunnablePassthrough(),
} | prompt

要么改 Prompt 变量:

python 复制代码
prompt = ChatPromptTemplate.from_template(
    "资料:{docs}\n问题:{query}"
)

LCEL 调试时,字段名比你想象中更重要。

常见错误三:把 RunnableLambda 写得太重

错误倾向:

python 复制代码
chain = (
    prompt
    | model
    | RunnableLambda(lambda x: save_to_db(call_api(parse_json(x))))
)

这类链很难测试、很难追踪,也容易把副作用藏起来。

更好的方式:

  • 解析逻辑单独写函数。
  • 外部 API 调用封装成 Tool 或服务。
  • 写数据库操作放在明确的业务层。
  • 对副作用操作做幂等设计。

LCEL 适合表达数据流,不应该变成隐藏复杂业务副作用的地方。

常见错误四:过早追求一行链

很多人喜欢把所有东西写成一行:

python 复制代码
chain = {"context": retriever, "question": RunnablePassthrough()} | prompt | model | StrOutputParser()

这没错,但链复杂后可读性会下降。

可以拆开:

python 复制代码
prepare_inputs = {
    "context": retriever,
    "question": RunnablePassthrough(),
}

generation_chain = prompt | model | StrOutputParser()

rag_chain = prepare_inputs | generation_chain

拆开之后更容易:

  • 单独测试输入准备。
  • 单独替换生成链。
  • 给每段加 with_config
  • 定位错误。

LCEL 不是为了炫一行代码,而是为了清晰表达数据流。

常见错误五:以为 batch 一定更快

batch 通常比自己简单循环更方便,也可能更快。

但它不保证在所有场景都更快。

影响因素包括:

  • 模型供应商限流。
  • 网络延迟。
  • max_concurrency 设置。
  • 每条输入长度。
  • 链中是否有阻塞组件。
  • 是否触发重试。

如果你要处理大批量数据,建议小批次实验:

python 复制代码
for i in range(0, len(inputs), 20):
    batch_inputs = inputs[i:i + 20]
    results = chain.batch(
        batch_inputs,
        config={"max_concurrency": 5},
    )

生产环境还要记录失败项,支持重跑。

完整 Demo:LCEL 多分支文章助手

下面给一个完整 Demo:输入一个技术主题,同时生成标题、简介、提纲,最后组装成一个字典。

目录:

text 复制代码
lcel-demo/
  .env
  main.py

.env

bash 复制代码
OPENAI_API_KEY=sk-xxxx

main.py

python 复制代码
from dotenv import load_dotenv
from langchain.chat_models import init_chat_model
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.runnables import RunnableLambda, RunnableParallel, RunnablePassthrough

load_dotenv()

model = init_chat_model(
    "gpt-4o-mini",
    model_provider="openai",
    temperature=0,
)

parser = StrOutputParser()

title_chain = (
    ChatPromptTemplate.from_template(
        "请为技术主题《{topic}》生成 3 个 CSDN 文章标题,只输出标题列表。"
    )
    | model
    | parser
)

intro_chain = (
    ChatPromptTemplate.from_template(
        "请为技术主题《{topic}》写一段 120 字以内的文章简介。"
    )
    | model
    | parser
)

outline_chain = (
    ChatPromptTemplate.from_template(
        "请为技术主题《{topic}》生成 5 个二级标题。"
    )
    | model
    | parser
)

parallel_chain = RunnableParallel(
    topic=RunnablePassthrough(),
    titles=title_chain,
    intro=intro_chain,
    outline=outline_chain,
)


def add_metadata(data: dict) -> dict:
    return {
        **data,
        "series": "LangChain 从入门到源码",
        "stage": "核心能力篇",
    }


chain = parallel_chain | RunnableLambda(add_metadata)

result = chain.invoke("LCEL 表达式语言")

print(result["topic"])
print(result["titles"])
print(result["intro"])
print(result["outline"])
print(result["series"])
print(result["stage"])

这条链的数据流:
#mermaid-svg-EVCx1w0YL3nvmXfp{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-EVCx1w0YL3nvmXfp .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-EVCx1w0YL3nvmXfp .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-EVCx1w0YL3nvmXfp .error-icon{fill:#552222;}#mermaid-svg-EVCx1w0YL3nvmXfp .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-EVCx1w0YL3nvmXfp .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-EVCx1w0YL3nvmXfp .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-EVCx1w0YL3nvmXfp .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-EVCx1w0YL3nvmXfp .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-EVCx1w0YL3nvmXfp .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-EVCx1w0YL3nvmXfp .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-EVCx1w0YL3nvmXfp .marker{fill:#333333;stroke:#333333;}#mermaid-svg-EVCx1w0YL3nvmXfp .marker.cross{stroke:#333333;}#mermaid-svg-EVCx1w0YL3nvmXfp svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-EVCx1w0YL3nvmXfp p{margin:0;}#mermaid-svg-EVCx1w0YL3nvmXfp .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-EVCx1w0YL3nvmXfp .cluster-label text{fill:#333;}#mermaid-svg-EVCx1w0YL3nvmXfp .cluster-label span{color:#333;}#mermaid-svg-EVCx1w0YL3nvmXfp .cluster-label span p{background-color:transparent;}#mermaid-svg-EVCx1w0YL3nvmXfp .label text,#mermaid-svg-EVCx1w0YL3nvmXfp span{fill:#333;color:#333;}#mermaid-svg-EVCx1w0YL3nvmXfp .node rect,#mermaid-svg-EVCx1w0YL3nvmXfp .node circle,#mermaid-svg-EVCx1w0YL3nvmXfp .node ellipse,#mermaid-svg-EVCx1w0YL3nvmXfp .node polygon,#mermaid-svg-EVCx1w0YL3nvmXfp .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-EVCx1w0YL3nvmXfp .rough-node .label text,#mermaid-svg-EVCx1w0YL3nvmXfp .node .label text,#mermaid-svg-EVCx1w0YL3nvmXfp .image-shape .label,#mermaid-svg-EVCx1w0YL3nvmXfp .icon-shape .label{text-anchor:middle;}#mermaid-svg-EVCx1w0YL3nvmXfp .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-EVCx1w0YL3nvmXfp .rough-node .label,#mermaid-svg-EVCx1w0YL3nvmXfp .node .label,#mermaid-svg-EVCx1w0YL3nvmXfp .image-shape .label,#mermaid-svg-EVCx1w0YL3nvmXfp .icon-shape .label{text-align:center;}#mermaid-svg-EVCx1w0YL3nvmXfp .node.clickable{cursor:pointer;}#mermaid-svg-EVCx1w0YL3nvmXfp .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-EVCx1w0YL3nvmXfp .arrowheadPath{fill:#333333;}#mermaid-svg-EVCx1w0YL3nvmXfp .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-EVCx1w0YL3nvmXfp .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-EVCx1w0YL3nvmXfp .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-EVCx1w0YL3nvmXfp .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-EVCx1w0YL3nvmXfp .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-EVCx1w0YL3nvmXfp .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-EVCx1w0YL3nvmXfp .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-EVCx1w0YL3nvmXfp .cluster text{fill:#333;}#mermaid-svg-EVCx1w0YL3nvmXfp .cluster span{color:#333;}#mermaid-svg-EVCx1w0YL3nvmXfp div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-EVCx1w0YL3nvmXfp .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-EVCx1w0YL3nvmXfp rect.text{fill:none;stroke-width:0;}#mermaid-svg-EVCx1w0YL3nvmXfp .icon-shape,#mermaid-svg-EVCx1w0YL3nvmXfp .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-EVCx1w0YL3nvmXfp .icon-shape p,#mermaid-svg-EVCx1w0YL3nvmXfp .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-EVCx1w0YL3nvmXfp .icon-shape .label rect,#mermaid-svg-EVCx1w0YL3nvmXfp .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-EVCx1w0YL3nvmXfp .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-EVCx1w0YL3nvmXfp .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-EVCx1w0YL3nvmXfp :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 主题字符串
RunnableParallel
topic 原样透传
title_chain
intro_chain
outline_chain
结果字典
RunnableLambda: add_metadata
最终字典

这个 Demo 覆盖了:

  • RunnablePassthrough 保留原始输入。
  • RunnableParallel 并行执行多个分支。
  • prompt | model | parser 构造子链。
  • RunnableLambda 做后处理。
  • 组合后的链继续使用 invoke

这就是 LCEL 的典型使用方式。

总结

LCEL 到底解决了什么?如果只记住一句话:

LCEL 是 LangChain 用来声明和组合数据流的表达式语言,Runnable 是其中最核心的统一执行接口。

再具体一点:

  • Runnable 是可调用、可批处理、可流式、可组合的工作单元。
  • RunnableSequence 表示顺序执行,上一步输出接下一步输入。
  • | 运算符是构造顺序链的常见写法。
  • RunnableParallel 表示并行分支,同一份输入进入多个 Runnable。
  • 字典字面量在 LCEL 链里经常被用来表达并行字段构造。
  • RunnablePassthrough 用来保留原始输入。
  • assign 用来在原始字典上追加字段。
  • RunnableLambda 可以把普通 Python 函数接入链路。
  • invoke 用于单次调用,batch 用于批量处理,stream 用于流式输出。
  • with_configwith_retrywith_fallbacks 让链更容易观测和增强可靠性。
  • 学 LCEL 最重要的是画清楚每一步的输入输出形状。