Runnable 与 LCEL:一根竖线背后的 6713 行地基
Day02 我们拧了三节水管却没解释竖线为什么灵。今天打开全框架最重要的文件 libs/core/langchain_core/runnables/base.py(6713 行),回答三个问题:①Runnable 协议到底规定了什么(invoke/stream/batch 三件套);②Python 的 | 怎么被 __or__ 劫持成"拧管子";③拧出来的 RunnableSequence 肚子里长什么样。读懂这一个文件,LangChain 就懂了一半。
invoke),就能和任何其他管件互拧;而且国标还"买一送二":你只要实现单件处理(invoke),标准库自动送你流式(stream)和批量(batch)两种模式。类比二:RunnableSequence = 把几节管子拧好后套上的一层外壳——外壳自己也是一根标准管(也有国标螺纹),所以"管中管"可以无限套娃:(a | b) | (c | d) 还是一根管。拧的时候外壳还会做一件贴心事:发现你递过来的已经是"管中管",就拆壳取管、接平了再封装(扁平化),避免俄罗斯套娃越套越深。痛点:几十种积木,凭什么能随便拼?
.format()、那个叫 .generate()、还有的叫 .run()),那"组合"就是灾难:每两种组件之间都要写胶水代码,还得分别为同步/异步/流式/批量写四套。0.x 时代的 LangChain 就吃过这个苦头(各种 Chain 类各有各的接口)。Runnable,规定"任何组件都是输入 Input、输出 Output 的一步变换",统一叫 invoke。组合逻辑(串行/并行/重试/回退)全部写在协议层,与具体组件无关——这样 N 种组件 × M 种组合方式,只需要 N + M 份代码而不是 N × M。这个协议 + 一套组合语法,就叫 LCEL(LangChain Expression Language)。Runnable 基类:三件套协议(真源码第 1 段)
开门见山,runnables/base.py:133,类的 docstring 就是官方设计说明书(裁剪):
# libs/core/langchain_core/runnables/base.py:133
class Runnable(ABC, Generic[Input, Output]):
"""A unit of work that can be invoked, batched, streamed, transformed and composed.
Key Methods
===========
- invoke/ainvoke: Transforms a single input into an output.
- batch/abatch: Efficiently transforms multiple inputs into outputs.
- stream/astream: Streams output from a single input as it's produced.
Built-in optimizations:
- Batch: By default, batch runs invoke() in parallel using a thread pool ...
- Async: Methods with 'a' prefix are asynchronous ...
The main composition primitives are RunnableSequence and RunnableParallel.
...
"""
# base.py:874 唯一的抽象方法——子类必须实现的就它一个
def invoke(self, input: Input, config: RunnableConfig | None = None,
**kwargs: Any) -> Output:
"""Transform a single input into an output."""
Generic[Input, Output]泛型:每个 Runnable 声明自己"吃什么、吐什么"。比如 prompt 是 Runnable[dict, PromptValue]、parser 是 Runnable[AIMessage, str]。管口对不上(前者的 Output ≠ 后者的 Input),类型检查器就会报警——水管口径检查。invoke 是唯一必修课★整个基类 2700 多行方法里,只有 invoke 是 @abstractmethod。写一个自定义组件,实现 invoke 一个方法就完事,其余 stream/batch/ainvoke 全部"免费继承"(L06 看免费的实现长什么样)。config 参数每个方法都接收可选的 RunnableConfig——一个装着 tags/metadata/callbacks/递归深度等的"随身包"。它是 D04 的主角,今天先记住"每站都收它"。a 前缀方法异步版默认实现 = 把同步版扔进线程池跑(base.py:917 的 run_in_executor(config, self.invoke, ...))。厂商包想要真异步(如原生 aiohttp)可以覆写——又是"基类兜底、子类优化"。__or__:一根竖线是怎么变成管道的(真源码第 2 段)
Python 规定:写 a | b 时,解释器实际调用 a.__or__(b);如果 a 不会算,再试 b.__ror__(a)(reverse-or,反向兜底)。Runnable 把这两个钩子都实现了(base.py:648-667 与 base.py:691,前面 :628-646 是几个类型重载声明):
# libs/core/langchain_core/runnables/base.py:648(真身,裁掉类型标注)
def __or__(self, other):
"""Runnable "or" operator.
Compose this Runnable with another object to create a RunnableSequence."""
return RunnableSequence(self, coerce_to_runnable(other)) # base.py:667
# base.py:691 反向版:other | self(other 不是 Runnable 时轮到我兜底)
def __ror__(self, other):
return RunnableSequence(coerce_to_runnable(other), self)
# base.py:712 不喜欢运算符?还有等价的普通方法
def pipe(self, *others, name=None):
"""Equivalent to RunnableSequence(self, *others) or self | others[0] | ..."""
return RunnableSequence(self, *others, name=name)
return RunnableSequence(...)★谜底揭晓:竖线什么都不执行,只是 new 了一个 RunnableSequence 对象,把左右两件东西装进去。这就是 Day02 说的"拧管子那一刻不放水"。coerce_to_runnable(other)右边那件东西不一定是 Runnable——可能是普通函数、可能是 dict。先"强制转换成 Runnable"再入管(L04 拆它)。__ror__ 的用处写 {"context": retriever} | prompt 时,左边是普通 dict,dict 不认识 | 接 Runnable 的玩法,Python 就去问右边的 prompt:prompt.__ror__(dict) ——于是 dict 被 coerce 成 RunnableParallel 后照样入管。RAG 链(D12)天天用这招。pipe和 | 完全等价的具名方法,适合链很长想换行、或不喜欢运算符重载的人。docstring 里官方原话:"Equivalent to RunnableSequence(self, *others)"。prompt | model | parser 读起来就是 Unix 管道,声明式、极简。代价:新手第一次看到会懵("竖线是啥?")、调试栈里会出现 __or__ 这种"看不出业务含义"的帧。LangChain 的补偿是:①保留 pipe() 具名等价物;②__or__ 身体只有一行、无任何副作用——魔法越薄越安全。对比 LangGraph 干脆放弃运算符改用 add_edge,是同一个问题的另一种取舍。coerce_to_runnable:函数和 dict 混进管道的门票(真源码第 3 段)
文件末尾的守门员(base.py:6628-6652):
# libs/core/langchain_core/runnables/base.py:6628
def coerce_to_runnable(thing: RunnableLike) -> Runnable:
"""Coerce a Runnable-like object into a Runnable."""
if isinstance(thing, Runnable):
return thing # ① 已是标准管件 → 直接放行
if is_async_generator(thing) or inspect.isgeneratorfunction(thing):
return RunnableGenerator(thing) # ② 生成器函数 → 流式管件
if callable(thing):
return RunnableLambda(cast(..., thing)) # ③ 普通函数 → Lambda 管件
if isinstance(thing, dict):
return RunnableParallel(thing) # ④ 字典 → 并行管件
msg = (f"Expected a Runnable, callable or dict."
f"Instead got an unsupported type: {type(thing)}")
raise TypeError(msg) # ⑤ 其他 → 拒收,报清楚类型
① Runnable 直接放行标准螺纹不用再加工。② 生成器函数写了 yield 的函数被包成 RunnableGenerator——它天然懂"一块块处理",入管后不破坏流式(水流经过它不用蓄满再放)。③ 普通函数 → RunnableLambda★最常用的暗门:chain = prompt | model | (lambda m: m.content.upper()) 能跑,就是因为 lambda 在这里被包成了标准管件。注意坑:RunnableLambda 默认不支持流式透传(RunnableSequence 的 docstring 特意警告过,base.py:3092-3098)——水流到它这就得蓄满才放。④ dict → RunnableParallel字典变"并行三通管":同一份输入复制给每个 value 各跑一路,结果按 key 收集成 dict。{"context": retriever, "question": passthrough} 这种 RAG 经典开头就是它。RunnableSequence 的肚子:first / middle / last(真源码第 4 段)
竖线拧出来的东西长什么样?base.py:3063 类定义 + base.py:3150-3154 三个字段 + base.py:3157-3196 构造器:
# libs/core/langchain_core/runnables/base.py:3063
class RunnableSequence(RunnableSerializable[Input, Output]):
"""Sequence of Runnables, where the output of one is the input of the next.
RunnableSequence is the most important composition operator in LangChain
as it is used in virtually every chain.""" # ←官方原话:最重要的组合子
first: Runnable[Input, Any] # base.py:3150
middle: list[Runnable[Any, Any]] = Field(default_factory=list) # :3152
last: Runnable[Any, Output] # base.py:3154
def __init__(self, *steps, name=None, first=None, middle=None, last=None):
steps_flat: list[Runnable] = []
for step in steps:
if isinstance(step, RunnableSequence):
steps_flat.extend(step.steps) # ★套娃拆平:管中管 → 一排管
else:
steps_flat.append(coerce_to_runnable(step))
if len(steps_flat) < 2:
raise ValueError("RunnableSequence must have at least 2 steps, ...")
super().__init__(first=steps_flat[0],
middle=list(steps_flat[1:-1]),
last=steps_flat[-1], name=name)
@property
def steps(self): # base.py:3209
"""All the Runnables that make up the sequence in order."""
return [self.first, *self.middle, self.last]
first/middle/last肚子里就三个字段:头一节、中间一串、末一节。为什么不直接存一个 list?因为泛型要精确:first 的 Input 就是整链的 Input,last 的 Output 就是整链的 Output——头尾类型钉死,中间随意。套娃拆平★prompt | model 生成 Seq1,再 Seq1 | parser 时,构造器发现 Seq1 是 RunnableSequence,就 extend(Seq1.steps) 把它拆开摊平——最终肚子里是 [prompt, model, parser] 三节平铺,而不是 [[prompt, model], parser] 套娃。好处:执行时一层循环搞定(D04 就看这个循环)、回调树里每节都是平级兄弟、调试时 chain.steps 一目了然。至少 2 节一节的"序列"没有意义(那就是组件本身),直接 ValueError 拒绝。防御性检查前置,坏结构活不到 invoke。RunnableSerializable它继承的这个中间类(base.py:2815)= Runnable + 可序列化(能存成 JSON 再复原)。所以链可以被保存、传输、在 LangSmith 里可视化。__or__(base.py:648)只 new 对象;构造器(base.py:3157)负责 coerce + 拆平。两步都零执行、零 IO。免费送的 stream 和 batch:默认实现长什么样(真源码第 5 段)
L02 说"实现 invoke 就白送 stream/batch",白送的东西什么成色?看基类默认实现(base.py:1182-1201 与 base.py:919-966):
# base.py:1182 默认 stream:其实是"假流式"
def stream(self, input, config=None, **kwargs):
"""Default implementation of stream, which calls invoke.
Subclasses must override this method if they support streaming output."""
yield self.invoke(input, config, **kwargs) # base.py:1201 一次全吐
# base.py:919 默认 batch:线程池并发跑 invoke
def batch(self, inputs, config=None, *, return_exceptions=False, **kwargs):
if not inputs:
return []
configs = get_config_list(config, len(inputs))
def invoke(input_, config): ... # 内部小函数:调 self.invoke
if len(inputs) == 1:
return [invoke(inputs[0], configs[0])] # 单个输入不开线程池
with get_executor_for_config(configs[0]) as executor:
return list(executor.map(invoke, inputs, configs)) # base.py:966 ★并发
yield self.invoke(...)默认 stream = 调一次 invoke、把完整结果当成"只有一块的流"吐出来。接口达标但体验是假的——所以聊天模型(D05)、解析器都覆写了它做真流式;而 RunnableSequence 的 stream(base.py:3815)会把各节的 transform 接力起来,只要每节都支持流式,整链就真流式。executor.map默认 batch = 开线程池把每份输入各跑一次 invoke。对 IO 密集(等 API 回包)很有效;厂商如果有原生批量 API 可以覆写做得更省钱。len==1 不开池只有一份输入就直接调,省掉线程池开销——和 Dify D05 见过的"常见情况不为不常见情况付代价"同一个原则。return_exceptions批量里某份失败,默认整个 batch 抛异常;传 True 则把异常当结果返回,成功的照常给你——批处理刚需。chain.stream() 发现前半段逐字出、后半段一大坨蹦出来,八成是链中间夹了个普通函数。解法:把函数写成生成器(走 coerce 的 ② 分支变 RunnableGenerator),或自定义 Runnable 实现 transform。串起来 + 今日小结
from langchain_core.runnables import RunnableLambdaseq = RunnableLambda(lambda x: x + 1) | RunnableLambda(lambda x: x * 2)①
type(seq) → RunnableSequence(竖线造的);② seq.steps → 两节平铺;③ seq.invoke(1) → 4(1+1=2,2×2=4);④ seq.batch([1, 2, 3]) → [4, 6, 8](免费的批量);⑤ 再拧一节 dict:seq2 = seq | {"半": lambda x: x // 2, "倍": lambda x: x * 2},seq2.invoke(1) → {'半': 2, '倍': 8}(dict 被 coerce 成 RunnableParallel)。👶 小白:LCEL、Runnable、RunnableSequence 三个词老混,一句话分清?
👨🏫 老师:Runnable 是"国标螺纹"(接口协议:invoke 三件套);RunnableSequence / RunnableParallel 是两种"标准管件"(按协议实现的串行管、并行三通);LCEL 是"安装手法"(用 | 和 dict 字面量这套语法把管件拧起来的表达方式)。你写 LCEL 表达式 → Python 运算符触发 __or__ → 产出 RunnableSequence → 它本身又符合 Runnable 协议。三个词是"语法 → 产物 → 协议"的关系。
🧠 今天你应该能回答
- Runnable 唯一的抽象方法是哪个?(invoke;stream/batch/异步全有默认实现)
a | b在 Python 层面发生了什么?(a.__or__(b)→RunnableSequence(a, coerce_to_runnable(b)),零执行)- 普通函数 / dict 混进管道靠谁?(coerce_to_runnable:函数→RunnableLambda、dict→RunnableParallel、生成器→RunnableGenerator)
- RunnableSequence 肚子里的三个字段?(first/middle/last;构造时把嵌套 Sequence 拆平)
- 默认 stream 是真流式吗?(假的,
yield self.invoke(...)一次全吐;子类覆写才是真的) - 链中间夹普通函数为什么会卡流式?(RunnableLambda 默认不支持 transform,水流在它那蓄满才放)
✋ 10 分钟动手
cd /Users/bitmart/work/codes/github/AI_WORK/langchain
# 1. 协议本体
sed -n '133,200p' libs/core/langchain_core/runnables/base.py # Runnable docstring
sed -n '874,890p' libs/core/langchain_core/runnables/base.py # 唯一抽象方法 invoke
# 2. 竖线三兄弟
sed -n '648,712p' libs/core/langchain_core/runnables/base.py # __or__ / __ror__ / pipe
sed -n '6628,6655p' libs/core/langchain_core/runnables/base.py # coerce_to_runnable
# 3. Sequence 的肚子 + 免费三件套
sed -n '3150,3212p' libs/core/langchain_core/runnables/base.py # 字段/构造器/steps
sed -n '1182,1205p' libs/core/langchain_core/runnables/base.py # 默认 stream(假流式)
sed -n '919,970p' libs/core/langchain_core/runnables/base.py # 默认 batch(线程池)
# 4. 无 API key 实验(python 交互式贴 L07 例子)
python3 -c "
from langchain_core.runnables import RunnableLambda
seq = RunnableLambda(lambda x: x + 1) | RunnableLambda(lambda x: x * 2)
print(type(seq).__name__, seq.invoke(1), seq.batch([1,2,3]))"
RunnableSequence.invoke(base.py:3418)那个 for 循环逐节执行、RunnableConfig 怎么被 ensure_config/patch_config 一路加工随行、回调树(seq:step:1/2/3)怎么长出来。一次 invoke 的完整旅程。