LangChain LCEL 链

QuibblerAgentQuibblerAgent 2026-11-06 约 20 分钟 1 次阅读

第 2 篇讲提示模板时埋了个伏笔:模板本身就是 Runnable,能用管道符与模型直接串联,| 背后的组装规则是第 5 篇的主场;第 4 篇收尾又预告,见 agent 之前先学链式编排。本篇是《LangChain 从入门到实战》第 5 篇,主角是 LCEL(LangChain Expression Language,LangChain 表达式语言,见官方文档):先看 Runnable 统一接口如何让所有组件长成同一形状,再用管道符把前四篇的组件串成三行链,然后引入函数、并行、透传三种积木,最后用一条并行双译的翻译流水线收束,并说清 1.x 时代 LCEL 与 agent 框架的分工。

1、Runnable 统一接口

前四篇的组件其实共用着同一套调用协议。第 1 篇调模型的 invoke、第 2 篇渲染模板的 invoke、第 4 篇执行工具的 invoke——不是各组件恰好都起了这个名字,而是它们都实现了 langchain-core 的 Runnable 协议:一组约定的方法与类型,核心三个方法的语义如下:

方法 语义 输入与输出
invoke 单次调用,阻塞到出结果 一个输入换一个输出
stream 流式调用,逐段产出 一个输入换迭代器多段输出
batch 批量调用,内部并发 输入列表换等长输出列表

模板在前四篇只露过 invoke,三件套其实一个不少,示例如下:

from langchain_core.prompts import ChatPromptTemplate

prompt = ChatPromptTemplate.from_messages([
    ("human", "用一句话解释 {term}")
])

prompt.invoke({"term": "管道符"})                     # 渲染一条提示
prompt.batch([{"term": "管道符"}, {"term": "RAG"}])   # 并发渲染两条
list(prompt.stream({"term": "管道符"}))               # 模板不产流,整块产出

解读:同一个对象上三个方法全部可用——模板不联网、渲染极快,batch 的并发在这里感知不强,但接口形状与模型完全一致。这正是 Runnable 设计的用意:接口统一后,调用方不必关心手里拿的是模板、模型还是工具。已出场组件的 Runnable 形状对照如下:

组件 输入 输出 出场篇目
ChatPromptTemplate 变量字典 提示值对象 第 2 篇
聊天模型 消息列表或字符串 AIMessage 第 1 篇
结构化包装器 消息列表或字符串 Pydantic 实例 第 3 篇
工具对象 参数字典 字符串或对象 第 4 篇
StrOutputParser AIMessage 字符串 本篇

表里五行输入输出两两不同,方法名却完全一致——能在管道里自由拼接的前提,就是这张表。第 4 篇的 bind_tools 返回的绑定模型也在此列:前四篇出场过的调用单元,本篇全部接得上。另外三件套各有一个异步孪生:ainvoke、astream、abatch,Web 服务改写 async 时接口一一对应,本系列示例保持同步写法。

2、管道组合三行成链

先定口径:LCEL 的积木全部来自 langchain-core(当前 1.6.x,随主包安装,见 PyPI 主页):Runnable 系列在 langchain_core.runnables,字符串解析器在 langchain_core.output_parsers。模型返回的 AIMessage 给程序用时通常只要正文,StrOutputParser 负责剥壳。三行成链的写法如下:

from langchain.chat_models import init_chat_model
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser

model = init_chat_model("openai:gpt-6.1-terra", temperature=0)
prompt = ChatPromptTemplate.from_messages([
    ("system", "你是严谨的技术讲师,用中文回答。"),
    ("human", "{question}"),
])

chain = prompt | model | StrOutputParser()
print(chain.invoke({"question": "用一句话说明 LCEL 是什么"}))

| 是 Python 位或运算符的重载(Runnable 实现了 __or__),等价于 prompt.pipe(model).pipe(parser),习惯方法调用链的写法可以互换。数据在链里的流转如下:

flowchart LR A[变量字典] --> B[提示模板] B --> C[消息列表] C --> D[模型] D --> E[AI 消息] E --> F[字符串解析器] F --> G[纯文本]

对照第 2、3 篇的管道图,只是把末端延长了一站:字典进、纯文本出,中间的消息形态对调用方完全不可见。两个要点值得点透。其一,链本身也是 Runnable——它由 RunnableSequence 实现,invoke、stream、batch 三件套原样可用,链还能再进管道,组成更大的链。其二,第 3 篇手工写的重试循环在这里一行了结:chain.with_retry(stop_after_attempt=2) 返回带重试的新链,退避与异常筛选参数见 Runnable 接口文档。

链的构造还有一层工程红利:管道是纯声明,拼接那一刻不发任何请求;chain.input_schema 与 chain.output_schema 随时可查,各站的变量、类型在测试里全部可见。第 2 篇说模板可以免 token 进单元测试,这个结论在链上升了一级:整条链的形状都能静态验证,只有真的 invoke 才花钱。

老教程的 LLMChain(llm=..., prompt=...) 在 1.x 主包已不存在——0.x 的 chains 模块整体迁往 langchain-classic。见到这类导入,直接把等价逻辑改写成上面的管道语法。

3、RunnableLambda 入链

管道里的每一站都得是 Runnable,普通函数想入链,用 RunnableLambda 包一层。单独看它就是把函数升级成 Runnable,数据整形是它在实际项目里的主业:

from langchain_core.runnables import RunnableLambda

RunnableLambda(len).invoke("runnable")   # 8,函数立即获得三件套

def with_chars(answer: str) -> dict:     # 给输出补上字数字段
    return {"answer": answer, "chars": len(answer)}

chain2 = prompt | model | StrOutputParser() | RunnableLambda(with_chars)
print(chain2.invoke({"question": "一句话解释管道符"})["chars"])

解读:with_chars 收字符串、吐字典,链的输出形状因此改变——LCEL 不要求各站同构,只要求相邻两站的输出与输入对得上。这个约束与 Unix 管道的精神一致:每站做一件小事,形状对了就能任意拼接。裸函数不建议直接写在管道里,显式包一层 RunnableLambda 意图更清晰,出错时的报错也直指函数本身。RunnableLambda 同样接受异步函数,协程写法在 Web 服务里可以与链的异步三件套无缝对接。

4、并行与上下文注入

两块积木解决"多路分发"与"原样透传"。RunnableParallel 让多个 Runnable 收到同一份输入并发执行,结果按键汇成字典;RunnablePassthrough 把输入原样传出。两者组合最常见的场景是 RAG 的上下文注入——问题与检索结果要同传给提示模板:

from langchain_core.runnables import RunnableLambda, RunnablePassthrough

docs = {"lcel": "LCEL 用管道符组合 Runnable,链本身仍是 Runnable。"}

def retrieve(topic: str) -> str:
    return docs.get(topic, "未找到资料")

rag = (
    {"context": RunnableLambda(retrieve), "question": RunnablePassthrough()}
    | ChatPromptTemplate.from_messages([
        ("system", "只依据参考资料回答,资料不足就明说。"),
        ("human", "参考资料:{context}\n问题:{question}"),
    ])
    | model
    | StrOutputParser()
)

print(rag.invoke("lcel"))   # 输入是裸字符串,问题被透传进模板

三点解读。输入是一个裸字符串,两条支路各取所需:retrieve 拿它查资料,RunnablePassthrough 把它原样放进 question 键——这正是透传的价值:让原始输入与加工产物同框出现。对照裸 SDK 的做法更能看出差别:调用点手工查资料、手工拼 dict、手工塞问题,三件事缠在一处;这里每条支路只声明"输入是什么、输出放哪个键"。字典字面量在管道里会被自动转成 RunnableParallel,与显式构造等价。RunnablePassthrough.assign(key=...) 是它的变体,在透传的同时追加新键,细节见 RunnablePassthrough 参考。把 retrieve 换成真检索器,这段骨架就是完整的 RAG 链——埋线至此,检索器细节留给后面的 RAG 篇。分支路由另有 RunnableBranch 积木,按输入挑一条支路执行,思路与并行同源,篇幅所限不展开。

5、批量与流式

三件套的后两个在链上同样开箱即用。batch 一次提交多个输入,内部并发请求:

questions = [
    {"question": "什么是 Runnable 协议"},
    {"question": "管道符与 pipe 方法等价吗"},
    {"question": "batch 会并发请求吗"},
]

answers = chain.batch(questions, config={"max_concurrency": 3})
print(len(answers))         # 3,顺序与输入列表一致

max_concurrency 控制并发上限,输出顺序与输入严格对应——并发不乱序。对二十条评论跑第 3 篇的分析器,batch 一行替代 for 循环逐个 invoke,总耗时接近单条的量级。流式则贯穿全链,stream 让每站的输出逐段流向下一站:

for chunk in chain.stream({"question": "什么是 LCEL"}):
    print(chunk, end="", flush=True)   # 每段是可直接打印的字符串

模型 SDK 系列讲过裸 SDK 的流式要自己解析分片、手工拼接增量;这里从模型到 StrOutputParser 逐站透传,到手每段已是最终形态的字符串,打印逻辑零拼装。模板这类不支持流式的站也不碍事——它会一次性产出一整块,链整体照样流式。要把链内每一站的进度事件逐级上报时,还有 astream_events 可用,产出带事件类型与站名的结构化日志,服务端监控常用它接进度条。

6、LCEL 与 agent 的分界

代理时代还要不要学 LCEL?官方口径值得单独摆出来。1.x 的文档把 LCEL 定位为轻量的组件编排语言,agent 的构建路径另有其人(以下为大意概括,原文见 LCEL 文档与 agent 文档):

LCEL 适合用声明式管道把提示、模型、解析器等组件组合成链,轻量、天生支持流式与并发;它不为构建 agent 设计。需要循环、状态与工具编排的 agent,请使用 LangGraph 及其上的 create_agent。

分界线在控制流的结构。LCEL 的管道是无环的:数据从左到右流一遍就结束;agent 的本质是第 4 篇那个"提议、执行、回填"的循环,还要挂状态与断点续跑。两类需求的对比如下:

需求形态 推荐工具 理由
固定流程,跑一遍出结果 LCEL 管道 声明式三行成型,流式批量免费
循环与条件分支为主 LangGraph 图结构原生支持环与状态
工具自主决策循环 create_agent 第 6 篇主角,一行收编执行循环

所以不是"LCEL 过时了",而是分工收窄了:组件级编排——模板接模型接解析器、并行扇出、函数整形——仍是最顺手的写法,第 3 篇的结构化分析器、本篇的翻译流水线都属此类;循环级编排交给 agent 框架。回到第 1 篇的架构图,langgraph 是编排运行时、主包的 create_agent 是标准 agent 入口,说的正是这条线;LCEL 不在那条线上,它铺在 langchain-core 地基里,是所有组件共享的组装语法——agent 内部的节点拼装用的也是同一套 Runnable 形状。判断口诀一句话:链是声明出来的,agent 是循环出来的。

7、实战翻译流水线

把全部积木组装成一条三段流水线:同一句原文并行跑直译与意译,再由译审合并出终稿。先建两路风格链,风格差异写进各自的系统提示:

def style_prompt(rule: str):
    return ChatPromptTemplate.from_messages([
        ("system", f"你是翻译家。风格要求:{rule}。只输出译文。"),
        ("human", "{source}"),
    ])

literal = style_prompt("直译,忠实原文结构") | model | StrOutputParser()
free = style_prompt("意译,地道自然") | model | StrOutputParser()

并行段把两路译文与随行的原文汇成一个字典,评审段再合成终稿:

def pick_source(payload: dict) -> str:
    return payload["source"]              # 原文随行,评审要用

first = RunnableParallel(
    source=RunnableLambda(pick_source),   # 第 3 节的积木在此复用
    literal=literal,
    free=free,
)

review = (
    ChatPromptTemplate.from_messages([
        ("system", "你是译审。对比两个译文,各取所长合成终稿,只输出终稿。"),
        ("human", "原文:{source}\n直译版:{literal}\n意译版:{free}"),
    ])
    | model
    | StrOutputParser()
)

pipeline = first | review
print(pipeline.invoke({"source": "这代码读起来像诗,跑起来像考古现场。"}))

一次典型运行的输出如下:

This code reads like poetry and runs like an archaeological dig.

整条流水线的结构如下:

flowchart LR A[原文] --> B[直译链] A --> C[意译链] A --> D[原文随行] B --> E[译审合并] C --> E D --> E E --> F[终稿]

四点解读。两路翻译并发执行,总耗时约等于一路——RunnableParallel 的收益直接落在总耗时上。pick_source 是第 3 节 RunnableLambda 的实战复用:并行段的输出必须带上 source 键,评审提示才拿得到原文,上下文要显式随行是并行编排最常见的坑。评审段不过是又一组 prompt | model | parser,三段拼接用的全是本篇讲过的语法——没有新概念,只有旧积木的重排。把 init_chat_model 的字符串换成 anthropic:claude-sonnet-4-5,三段链同时换引擎、结构零改动,统一模型抽象的收益顺着管道传导到了整条流水线。要加第三种风格,在 RunnableParallel 里加一行、评审提示里加一行,改动即完成——声明式组合的扩展成本是线性的。

8、总结与练习

回顾全文:Runnable 协议让模板、模型、解析器、工具长成同一形状,invoke、stream、batch 三件套全组件通用;管道符等价于 pipe 方法,串出的链由 RunnableSequence 实现且仍是 Runnable,with_retry 一行接上重试;RunnableLambda 让普通函数入链做数据整形,RunnableParallel 并发分发、RunnablePassthrough 原样透传,三者拼出 RAG 的上下文注入骨架;官方口径把 LCEL 定位在组件级编排,循环级的 agent 交给 create_agent 与 LangGraph——链是声明出来的,agent 是循环出来的。对于手头的应用,建议把散落的"模板、调用、剥壳"三段代码收成一条三行链,再按需长出并行与函数站;出现循环决策需求的那天,再带着这条链迁移进第 6 篇的 agent。

练习两道:

  • 给第 7 节流水线加一条"注释体"支线,数一数需要改动几处
  • 用 batch 并发跑十条评论,再换 for 循环逐个 invoke,对比总耗时

相关推荐

获取系统SDK版本、判断手机ROM
精选Code

获取系统SDK版本、判断手机ROM

Build获取系统SDK版本Android中部分API的使用,需要在特定的SDK版本之后才能使用,因此在兼容老版本SDK的时候,经常需要判断API的版本。各种Android版本的对应关系参考《Android各版本对应的SDK版本》判断手机ROM有时候需要判断手机系统的ROM,检测ROM是MIUI、EMUI还是Flyme,可以使用getprop命令,去系统build.prop文件(关于build.p

4.2k
AndroidStudio中各种中文乱码问题
精选Code

AndroidStudio中各种中文乱码问题

AndroidStudio中各种中文乱码问题1、编译Java错误信息乱码在出现这个Annotation processors must be explicitly declared now...问题的时候,正好也发现AndroidStudio Build Output错误信息都乱码。经常遇到各种问题,习惯了,现在遇到的问题,都是以后的答案。 1.1、修改项目build.gradle(无效)在整个p

2.8k
PydanticAI 详解:类型安全的 Python AI SDK
精选AI

PydanticAI 详解:类型安全的 Python AI SDK

PydanticAI 详解:类型安全的 Python AI SDK写 LLM 应用最疼的两件事:模型返回的 JSON 不合预期、换一家模型商要改一串代码。pydantic/pydantic-ai(PydanticAI)用 Pydantic 团队的老本行回答了第一件——输出用 BaseModel 定义、运行前先校验,错误从运行时提前到编码时;第二件则用"模型即字符串"回答:换个厂商只改一个参数。如今

83