LangChain · v1.0 · 01 概念
Runnable 与 LCEL 的词汇表
上一章 index 给了 Runnable 的心智地图——一个统一接口撑起整个框架。这章把那张地图上的每个核心概念逐个建起来:Runnable 是什么、| 怎么把组件串成 pipeline、五个常用 Runnable 各干嘛、为什么 .stream / .batch / 重试不用自己写。基于 LangChain v1.0(2025-10-22),写于 2026-06。下文代码用来演示分界判断,均未在本机执行。
本章你要建立的心智模型
- Runnable 是什么:一份统一的调用契约——同样的
invoke/stream/batch签名,所有组件都长这个形状。 |怎么把组件串成 pipeline:竖线把两个 Runnable 拼成一条RunnableSequence,构造出的是「管道描述」,调用时才真正执行。- 五个常用 Runnable 各干嘛:透传、包函数、并行、追加字段、按条件分支——它们是搭链时的接插件。
- 为什么
.stream/.batch/ 重试不用自己写:能力定义在接口层,串好链就一起继承下来,不必逐组件实现。
1.1Runnable 接口
Runnable 是一份统一调用契约:每个组件都实现同样的 invoke / stream / batch 签名。
没有它的世界长这样:prompt 模板用 .format(),模型客户端用 .generate(),输出解析用 .parse()——每个组件一套自己的方法名、自己的入参出参形状。想把它们接起来,得在中间手写一层一层胶水:把 prompt 的输出整理成模型要的入参,再把模型的输出整理成解析器要的入参。换一个模型供应商,胶水重写一遍。
Runnable 把这套乱象压成一份契约:不管是 prompt、模型还是解析器,对外都暴露同一组方法、同一种"吃一个输入、吐一个输出"的形状。形状一致,组件之间就能直接对接,胶水消失了。
Runnable[Input, Output] 是一个带两个类型参数的抽象基类(英文直译是"可运行物",理解成"一个统一了调用方式的可执行单元"即可)。它约定的核心方法有四个,签名彼此对齐:
# 1 │ Runnable 约定的四个核心方法(签名示意,非源码)
# 2 │ 关键:四个方法的入参出参形状彼此对齐,只在"同步/异步""单条/多条"上区分
# 3 │
# 4 │ invoke(input) -> output # 单条、同步、一次性返回
# 5 │ stream(input) -> Iterator[chunk] # 单条、同步、逐块吐出
# 6 │ batch([in1, in2]) -> [out1, out2] # 多条、同步、可并发
# 7 │ ainvoke(input) -> output # 单条、异步(await)
# 8 │
# 9 │ 任意组件只要实现了 invoke,框架就能为它兜底另外三个
class MyStep(Runnable):
def invoke(self, input, config=None):
return transform(input) # 你只需定义这一个核心动作
step = MyStep()
step.invoke("x") # 直接可用
list(step.stream("x")) # stream 也可用 —— 来自接口兜底
step.batch(["x", "y"]) # batch 也可用 —— 同理
第 4-7 行四个方法不是四种不同的东西,而是同一个"输入→输出"动作的四种调用姿势:单条还是多条、同步还是异步、一次性还是逐块。这正是"统一签名"的含义。
第 10-11 行这就是定义里"每个组件都实现同样的签名"落到代码上的样子:只定义一个 invoke。
第 14-16 行没写 stream / batch 却能调用——它们由基类兜底实现。这一点是 §1.4 的全部分量所在,这里先埋下。
文档会说"Runnable 是 LangChain 组件的标准接口"。深一层的事实是:这个接口在基类里给了 invoke 之外所有方法的默认实现。batch 默认就是"对每条输入并发调用 invoke";stream 默认就是"调一次 invoke、把整个结果当作仅有的一个 chunk 吐出来"。
所以代价随之而来:一个只实现了 invoke 的组件,它的 stream 是"假流式"——你拿到的不是逐字增量,而是憋到算完一次性给你的单块。真正能逐字流式的组件(如聊天模型)会覆盖 stream 的默认实现。这条区别在 §1.3 的 RunnableLambda 上会变成一个具体陷阱。
Runnable 像电源插座的国标接口:灯、风扇、充电器内部完全不同,但插脚形状统一,于是同一条排插能串起任意组合。类比失效处:插座只传电、不改变"内容";而 Runnable 链上每一步都会把数据变形成下一种类型(文本 → 消息 → 模型输出 → 字符串)。所以别把它想成"透明传递",它是"统一形状的变形管线"。
与下一个概念的关系:契约统一带来的第一个红利,就是组件能用一个运算符直接拼起来。那个运算符是 |。
1.2| 与 LCEL
| 把两个 Runnable 拼成一条 RunnableSequence;它构造的是管道描述,调用时才执行。
有了统一契约,理论上可以手动嵌套:parser.invoke(model.invoke(prompt.invoke(x)))。能跑,但读起来是从里往外的倒序,加一步要在中间塞一层括号,而且整条链没有一个"对象"可以拿在手里去 .stream() 或加重试。LCEL(LangChain Expression Language,"LangChain 表达式语言",理解成"用 | 声明链的写法")就是为了把这串嵌套写成从左到右的一行,并让整条链本身成为一个 Runnable。
LCEL 不是一门新语言,它就是给 Runnable 重载了 Python 的 | 运算符。Python 在看到 a | b 时会调用 a.__or__(b);Runnable 把这个钩子实现成"把 a 和 b 包进一条 RunnableSequence"。
# 1 │ from langchain_core.prompts import ChatPromptTemplate
# 2 │ from langchain_core.output_parsers import StrOutputParser
# 3 │ from langchain.chat_models import init_chat_model
# 4 │
prompt = ChatPromptTemplate.from_template("用一句话解释 {topic}")
model = init_chat_model("gpt-4o-mini") # 返回一个 Runnable
parser = StrOutputParser() # 也是一个 Runnable
# 8 │ 关键一行:三个竖线,构造一条管道描述
chain = prompt | model | parser
# 10 │ 此刻没有任何网络请求发生 —— chain 只是一个 RunnableSequence 对象
print(type(chain).__name__) # RunnableSequence
# 13 │ 直到 invoke,数据才真正从左流到右
answer = chain.invoke({"topic": "向量数据库"})
print(answer) # 一句话解释(str)
第 9 行prompt | model | parser 先算 prompt | model 得到一条序列,再 | parser 把它延长。竖线只是在拼装结构,不触发任何调用。
第 11 行类型是 RunnableSequence——它本身又是一个 Runnable,所以整条链也带 invoke / stream / batch。链能再被 | 接进更大的链,这就是组合可以无限嵌套的原因。
第 14 行到这里才发生真实执行:{"topic": ...} 进 prompt 变成消息列表,进 model 变成模型输出对象,进 parser 变成纯字符串。
文档会说"用 | 把组件连成链"。深一层的关键是声明与执行分离:| 阶段构造的是一张有向无环图(DAG,"有向无环图",这里就是一条线性的数据流向描述),.invoke() 阶段才按这张图把数据推过去。
这件事为什么要紧:因为"链是一个静态结构对象",框架才能在执行之前对整条链做统一处理——为它绑配置、加重试包装、生成可观测的 trace、推断输入输出类型。如果 | 当场就执行,这些就无处下手了。这也解释了一个边界:LCEL 描述的是固定的数据流向;一旦你需要"根据中间结果决定下一步走哪条边"这种运行期才知道的动态分支与循环,线性 DAG 就不够用,得下沉到 LangGraph(见 §2.4)。
把上面第 9 行写成 chain = prompt | model | parser 之后,第 11 行 print 之前,已经发起了几次对模型 API 的网络请求?
展开答案(先停 10 秒再点)
零次。| 只构造结构,不执行。模型 API 第一次被真正调用,是在 chain.invoke(...) 那一行。
这道题指向的设计要点是"声明与执行分离":你可以放心地用 | 搭很长的链、传来传去、加各种包装,整个过程零成本零副作用——直到你主动 invoke / stream。
与下一个概念的关系:能用 | 串起来的不只是 prompt / model / parser 这种"主干"组件。还有一批专门用来在链里做透传、并行、分支的小 Runnable,它们是搭复杂链的接插件。
1.3五个常用 Runnable
透传、包函数、并行、追加字段、按条件分支——五个接插件,覆盖搭链时的大半结构需求。
真实的链很少是一条直线。常见的需求是:把原始输入保留着往后传(不被某一步吃掉)、在链里塞一个普通 Python 函数、让几条子链并行跑、给流过的字典追加一个新字段、根据输入选不同的子链。这五个 Runnable 就是这五种结构需求的现成零件,省得你为每种都手写一个 Runnable 子类。
| Runnable | 解决的结构需求 |
|---|---|
RunnablePassthrough | 原样透传输入,不做任何改动——常作占位,把原始输入带到后面。 |
RunnableLambda | 把一个普通函数包成 Runnable,让它能进链。 |
RunnableParallel | 对同一份输入并行跑多条子链,把结果汇成一个字典。 |
RunnablePassthrough.assign | 在透传原字典的基础上,追加若干由子链算出的新键。 |
RunnableBranch | 按条件判断把输入路由到不同的子链(if / elif / else)。 |
# 1 │ from langchain_core.runnables import (
# 2 │ RunnablePassthrough, RunnableLambda,
# 3 │ RunnableParallel, RunnableBranch,
# 4 │ )
# 5 │
# 6 │ —— RunnableParallel:对同一输入并行跑两条子链,汇成 dict
parallel = RunnableParallel(
upper=RunnableLambda(lambda s: s.upper()), # 子链 A
length=RunnableLambda(lambda s: len(s)), # 子链 B
)
parallel.invoke("hi") # {"upper": "HI", "length": 2}
# 12 │ —— assign:透传原 dict,再追加一个新键 "len"
add_len = RunnablePassthrough.assign(
length=RunnableLambda(lambda d: len(d["text"]))
)
add_len.invoke({"text": "hi"}) # {"text": "hi", "length": 2}
# ↑ 原键还在 ↑ 新键追加
# 19 │ —— Branch:按条件选子链(条件, 子链)...最后一个是 else 兜底
route = RunnableBranch(
(lambda x: len(x) > 10, RunnableLambda(lambda s: "long")),
RunnableLambda(lambda s: "short"), # else
)
route.invoke("hello") # "short"
第 7-11 行RunnableParallel 接收一个输入、扇出给每个子链、把它们的返回按键名收成字典。注意输出形状从"一个值"变成了"一个字典"。
第 14-17 行.assign 和 RunnableParallel 的关键差别:parallel 丢弃原输入只留计算结果,.assign 保留原字典再往上加键。RAG 里"既要原问题、又要检索到的上下文"就靠它。
第 20-23 行RunnableBranch 按 (条件函数, 子链) 元组从上往下匹配,命中第一个为真的就走那条;都不中走最后的兜底。这是 LCEL 里做"静态可枚举分支"的工具——条件在搭链时就写死了。
文档会说"RunnableLambda 把任意函数变成 Runnable"。深一层、也是最容易翻车的一点:RunnableLambda 包一个普通函数(返回一个完整值的函数)时,它的 .stream() 走的是 §1.1 说的那个默认兜底——把函数算完的整个结果当成仅有的一个 chunk 吐出来。也就是说,链里只要插了这样一个 RunnableLambda,流式到这一步就断流、退化成一次性返回。
要让它真流式,被包的函数本身得是生成器(用 yield 逐块产出),RunnableLambda 会把这些块逐个透传下去。所以这不是 RunnableLambda 的 bug,而是"接口默认实现 vs 覆盖实现"那条规则在这里的具体后果——你包进去的函数决定了这一步能不能流式。
这五个 Runnable 像编程语言里的控制流原语:透传是 pass、并行是 zip 后并发、分支是 if/elif/else。类比失效处:普通控制流是命令式、立即执行的;这些 Runnable 是声明式的——你拼出来的同样只是结构(§1.2),invoke 时才跑。而且它们只能表达无环的流向:能分支,不能回跳成循环。循环要 LangGraph。
有人在 prompt | model | parser 之后追加一步做后处理:chain = prompt | model | parser | RunnableLambda(clean_text),其中 clean_text(s) 是个普通函数,return s.strip()。然后他用 chain.stream(...) 想看到逐字打印的效果。能看到吗?
展开答案(先停 10 秒再点)
看不到逐字效果。前面 model 确实在逐块产出 token,但流一旦经过 RunnableLambda(clean_text) 这个包着普通函数的步骤,就被它的默认 stream 兜底拦下:clean_text 必须拿到完整字符串才能 strip,于是它等齐所有块、算完、一次性吐出单个结果。流式在这一步断了。
修法是把后处理写成生成器(def clean_text(chunks): for c in chunks: yield c.strip(...),对每个流入的块增量处理),或者把它移出流式链、只在最终结果上做。这道题指向的就是 §1.1 那条"默认实现是假流式、要真流式得覆盖"的规则在实战里的样子。
与下一个概念的关系:前面反复说"接口兜底""默认就有 stream / batch"。下一节正面把这件事讲透——为什么 .stream() / .batch() / 重试 / fallback 是串好链就免费拥有的,而不是每个组件各写一遍。
1.4为什么 stream · batch · 重试 · fallback 默认就有
这四样定义在 Runnable 接口层;串好的链本身是 Runnable,于是一并继承,无需逐组件实现。
设想没有这层统一:想给链加流式,得让链里每个组件都各自实现一遍流式;想批量跑,每个组件各写一遍并发;想重试,每个组件各包一层 try/except 退避;想在主模型挂掉时切备用模型,又各写一遍切换逻辑。组件越多、链越长,这些横切能力的重复实现就越失控。统一接口的目的,就是把这四样横切能力一次性放进接口层。
机制非常直接:因为 所有组件都实现 Runnable 协议,框架就把这四样能力做进协议本身——
| 没有统一接口,逐组件要手写 | 接口层的统一回应 |
|---|---|
| 每个组件各实现流式产出 | .stream() 定义在接口;能真流式的组件覆盖它,其余走兜底 |
| 每个组件各写并发批处理 | .batch() 定义在接口,默认对多条输入并发跑 invoke |
| 每个组件各包重试退避 | .with_retry() 返回一个包装后的新 Runnable,对失败自动重试 |
| 每个组件各写主备切换 | .with_fallbacks([...]) 返回一个新 Runnable,主链失败时依次尝试备链 |
# 1 │ chain 是 §1.2 串好的 prompt | model | parser,本身是一个 Runnable
# 2 │ 下面四样,没有一行是在 prompt / model / parser 内部单独实现的
# 3 │
# 4 │ —— 流式:逐块打印,无需链里任何组件"支持流式"的额外代码
for chunk in chain.stream({"topic": "B 树"}):
print(chunk, end="", flush=True)
# 8 │ —— 批量:一次跑多条输入,框架并发调度
chain.batch([{"topic": "B 树"}, {"topic": "跳表"}])
# 11 │ —— 重试:返回一个包装后的新链,对失败自动退避重试
robust = chain.with_retry(stop_after_attempt=3)
# 14 │ —— fallback:主链失败时自动切到备用链
backup = prompt | init_chat_model("claude-haiku-4") | parser
safe = chain.with_fallbacks([backup])
safe.invoke({"topic": "B 树"}) # 主链异常时,自动改走 backup
第 5 行chain.stream 调的是 RunnableSequence 的 stream——它会把流式请求沿链传导,只要链里有能真流式的组件(这里是 model),就能逐块出。
第 12、15 行.with_retry() / .with_fallbacks() 都不修改原链,而是返回一个把原链裹在里面的新 Runnable。新对象照样是 Runnable,所以还能继续 | 进更大的链、再被 .stream()——能力可叠加。
文档会列出"Runnable 支持 stream / batch / retry / fallback"。深一层要看清两种不同的"获得方式":
① stream / batch 是接口自带方法——直接挂在每个 Runnable 上,调用即用。② retry / fallback 是组合子(combinator)——.with_retry() / .with_fallbacks() 不改原对象,而是套一层新 Runnable(分别叫 RunnableRetry / RunnableWithFallbacks)。这是函数式的"装饰"思路:能力通过包裹叠加,而不是写进组件内部。
代价也在这里:因为是统一兜底,.batch() 的默认并发对一个本身就慢的同步组件并不会变快本质(只是并行 IO);.with_retry() 对"确定性失败"(如输入格式永远错)只是白白重试三次。统一接口给的是"默认能用",不是"默认最优"——什么时候该覆盖默认、什么时候该下沉到更可控的执行引擎,是 02 章的主题。
index 那张概念地图里,从 Runnable 发散出的".stream / .batch / 重试 / fallback 默认就有"那条箭头,到这里就有了机制解释:它不是某个组件的功能,而是"所有东西都是 Runnable"这一个前提的直接推论。统一形状 → 能力可以定义在形状上 → 凡是这个形状的东西都白拿这些能力。整个框架的省力感,根子在这一句。
给 chain 加了重试:robust = chain.with_retry(stop_after_attempt=3)。现在 robust 还能不能 .stream()?还能不能再 | another_step 接到更长的链里?
展开答案(先停 10 秒再点)
都能。.with_retry() 返回的是一个新的 Runnable(一个把原链裹起来的 RunnableRetry)。既然它还是 Runnable,接口约定的 .stream() / .batch() / __or__ 就一应俱全。
这道题点的是"组合的封闭性":对 Runnable 做的各种包装(重试、fallback、绑配置)产物仍是 Runnable,于是能无限叠加、无限再组合。这正是为什么这套接口能撑起整个框架——它对自己的操作是封闭的。
与下一个概念的关系:前四节一直把 prompt / model / parser 当作"已经存在的 Runnable"在用。最后一节补上它们各自的真身:消息、ChatPromptTemplate、输出解析器——链的三类主干组件到底是什么形状的数据和对象。
1.5消息 / ChatPromptTemplate / 输出解析器
消息是带角色的对话单元;ChatPromptTemplate 产出消息列表;解析器把模型输出收成你要的类型。
现代聊天模型的输入不是一串裸文本,而是一个带角色的消息列表(system / human / ai)。模型的输出也不是裸字符串,而是一个带元数据的消息对象(含 token 用量、工具调用等)。链的首尾因此需要两个适配器:把"模板 + 变量"变成消息列表的 ChatPromptTemplate,和把"消息对象"收成下游好用类型的输出解析器。
# 1 │ from langchain_core.prompts import ChatPromptTemplate
# 2 │ from langchain_core.output_parsers import StrOutputParser
# 3 │
# 4 │ —— ChatPromptTemplate:把"模板 + 变量"渲染成带角色的消息列表
prompt = ChatPromptTemplate.from_messages([
("system", "你是简洁的技术讲师,只用一句话回答"),
("human", "解释 {topic}"),
])
msgs = prompt.invoke({"topic": "幂等"})
# 10 │ msgs 是消息列表:[SystemMessage(...), HumanMessage(content="解释 幂等")]
# 12 │ —— 模型输出是一个消息对象(AIMessage),不是裸 str
# ai_msg = model.invoke(msgs)
# ai_msg.content -> "幂等是指..."(真正的文本在 .content 里)
# ai_msg.usage_metadata -> token 用量等元数据
# 17 │ —— StrOutputParser:把 AIMessage 收成纯字符串,喂给下游
parser = StrOutputParser()
chain = prompt | model | parser
chain.invoke({"topic": "幂等"}) # -> "幂等是指..."(str,元数据已剥离)
第 5-9 行from_messages 用 (角色, 模板) 元组声明多角色 prompt;.invoke 把变量填进去,产出的是消息列表——这正是 §1.2 数据流图里 prompt 之后那条"消息列表"边。
第 13-15 行模型吐出的是 AIMessage,文本藏在 .content,旁边还挂着用量等元数据。下游若直接当字符串用会报错——这就是要 parser 的原因。
第 18-20 行StrOutputParser 做最常见的一件事:从 AIMessage 里取出 .content 返回纯 str,把元数据剥掉。它也是 Runnable,所以能直接 | 在链尾。
早期做法是让模型"按 JSON 格式回答",再用一个解析器去 json.loads 它的文本——这一步天然脆:模型多句寒暄、少个引号、Markdown 包了代码块,解析就崩。深一层的当前做法是用模型原生的结构化输出:model.with_structured_output(Schema)(这里的 schema 特指 JSON Schema / 一个 Pydantic 数据模型)。它把结构约束下推到模型 API 那一层(OpenAI 的 strict structured outputs、Anthropic 的工具调用等),让模型在解码阶段就只能产出合法结构,而不是先自由生成再事后补救。
所以分界是:StrOutputParser 这类解析器负责"取文本 / 做轻量格式收尾";要稳定拿到结构化数据,优先用 with_structured_output 的原生 strict 模式,把脆弱的字符串解析挡在门外。
消息列表像寄信时的信封——角色(system / human / ai)是信封上"谁写给谁"的标注,.content 才是信纸内容。类比失效处:信封只标投递信息、不影响内容;而消息的角色会实质改变模型行为——同一句话放 system 是设定、放 human 是提问,模型的反应完全不同。所以角色不是元数据装饰,它是输入语义的一部分。
把模型输出直接当字符串用——例如写 model.invoke(msgs).upper()。AIMessage 没有 .upper(),文本在 .content 里,会抛 AttributeError。链里之所以几乎总跟一个 StrOutputParser,就是为了把这层"输出是消息对象、不是字符串"的事实在管道末端统一抹平。
§本章 self-check
先合上教程,把能想到的答案写在纸上或编辑器里。 写完再点开答案对照——直接点开等于把这一节当再读一遍。
- 用一句话说清 Runnable 接口"统一"的到底是什么。
invoke和stream是两种不同的东西吗? chain = a | b | c执行后、调用chain.invoke之前,chain是什么类型的对象?此刻有没有发生任何真实计算?为什么这种"延迟"是设计上的优点而非缺陷?RunnableParallel和RunnablePassthrough.assign都能往后传一个字典,二者的关键差别是什么?给一个"必须用.assign而不能用RunnableParallel"的场景。- (设计题)某团队抱怨"给每个自定义组件都得重写一遍重试逻辑,太重复"。结合 Runnable 接口的设计,指出他们多半用错了什么,以及正确做法为什么能把重试从"逐组件"变成"逐链一次"。
答案(先做完再展开)
- 统一的是调用契约 / 方法签名的形状——所有组件都暴露同一组
invoke/stream/batch/ainvoke,入参出参形状对齐。invoke和stream不是两种不同的东西,而是同一个"输入→输出"动作的两种调用姿势(一次性返回 vs 逐块吐出);正因如此 §1.1 里只实现invoke就能让stream兜底可用。 chain是一个RunnableSequence对象。此刻没有任何真实计算——|只构造了一张数据流向的 DAG(声明),真正执行发生在invoke(执行)。这种延迟是优点:链作为静态结构对象存在,框架才能在执行前对整条链统一绑配置、加重试包装、生成 trace、推断类型;若|当场执行,这些都无处下手。- 关键差别:
RunnableParallel丢弃原输入,输出只含各子链的计算结果;.assign保留原字典再追加新键。必须用.assign的场景:RAG 里下游 prompt 既要原始问题、又要检索到的上下文——用.assign(context=retriever)在保住question的同时加上context;若用RunnableParallel只算 context,原问题就丢了。 - 他们多半把重试逻辑写进了每个组件内部,没意识到重试是接口层的组合子。正确做法:先用
|把组件串成一条链(链本身是 Runnable),再对整条链调一次chain.with_retry(...)——它返回一个把整条链裹起来的新 Runnable,重试在链这一层统一生效。能从"逐组件"变"逐链一次"的根因是:重试不依赖任何组件的内部细节,只依赖"这是个 Runnable、可以重新invoke"这一个统一前提。
给"逐链一次"的重试加一道"按异常类型分流"
你已经知道 chain.with_retry() 能给整条链一次性加重试,chain.with_fallbacks([backup]) 能加主备切换。现在的需求更细:模型限流(RateLimitError)时应当退避重试同一条链,而模型彻底不可用(如 APIConnectionError)时应当立刻切到备用模型,不要在死路上重试。只用 Runnable 接口提供的组合子,怎么把这两种处置拼出来?拼出来的东西还是不是一个 Runnable?
提示(卡住再展开)
两个方向想:其一,.with_retry() 能不能只对特定异常类型重试(找它有没有形如"指定重试哪些异常"的参数);其二,组合子返回的仍是 Runnable,意味着它们能嵌套叠加——"先重试、再 fallback"和"先 fallback、再重试"是两条不同的包裹顺序,画出各自的执行流,想清楚哪种顺序才让"限流→重试本链、断连→直接切备链"成立。无需写完整代码,能说清包裹顺序和它对应的执行语义即可。