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] 是一个带两个类型参数的抽象基类(英文直译是"可运行物",理解成"一个统一了调用方式的可执行单元"即可)。它约定的核心方法有四个,签名彼此对齐:

runnable_protocol.py Python
# 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 链上每一步都会把数据变形成下一种类型(文本 → 消息 → 模型输出 → 字符串)。所以别把它想成"透明传递",它是"统一形状的变形管线"。

prompt 内部:模板填充 model 内部:调 API parser 内部:取 content 实现 实现 同一 Runnable 契约 invoke · stream · batch · ainvoke 可互换 换模型不改胶水 可拼接 能被 | 串起来
图 1.1三个组件内部做的事完全不同,却都收敛到中间那条同一契约。 注意:可互换与可拼接这两个红利不是组件各自的功能,而是"都长成同一形状"这一件事的直接结果——这正是后面 §1.4 默认能力的同一个根。

与下一个概念的关系:契约统一带来的第一个红利,就是组件能用一个运算符直接拼起来。那个运算符是 |。

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"。

pipe_builds_sequence.py Python
# 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)。

prompt 模板 model 聊天模型 parser 输出解析 dict 消息列表 模型输出 str 整体是一个 RunnableSequence 所以它自己也有 invoke / stream / batch
图 1.2同一份输入沿竖线从左流到右,每过一个组件就变一次类型。 注意:底部那条虚线框住的"整体也是一个 Runnable"——竖线的产物不是松散三件套,而是一个可以再被调用、再被组合的单一对象。
想一想

把上面第 9 行写成 chain = prompt | model | parser 之后,第 11 行 print 之前,已经发起了几次对模型 API 的网络请求?

展开答案(先停 10 秒再点)

零次。| 只构造结构,不执行。模型 API 第一次被真正调用,是在 chain.invoke(...) 那一行。

这道题指向的设计要点是"声明与执行分离":你可以放心地用 | 搭很长的链、传来传去、加各种包装,整个过程零成本零副作用——直到你主动 invoke / stream。

与下一个概念的关系:能用 | 串起来的不只是 prompt / model / parser 这种"主干"组件。还有一批专门用来在链里做透传、并行、分支的小 Runnable,它们是搭复杂链的接插件。

1.3五个常用 Runnable

透传、包函数、并行、追加字段、按条件分支——五个接插件,覆盖搭链时的大半结构需求。

为什么需要它

真实的链很少是一条直线。常见的需求是:把原始输入保留着往后传(不被某一步吃掉)、在链里塞一个普通 Python 函数、让几条子链并行跑、给流过的字典追加一个新字段、根据输入选不同的子链。这五个 Runnable 就是这五种结构需求的现成零件,省得你为每种都手写一个 Runnable 子类。

表 1.1 · 五个常用 Runnable 各自解决的结构需求
Runnable解决的结构需求
RunnablePassthrough原样透传输入,不做任何改动——常作占位,把原始输入带到后面。
RunnableLambda把一个普通函数包成 Runnable,让它能进链。
RunnableParallel对同一份输入并行跑多条子链,把结果汇成一个字典。
RunnablePassthrough.assign在透传原字典的基础上,追加若干由子链算出的新键。
RunnableBranch按条件判断把输入路由到不同的子链(if / elif / else)。
five_runnables.py Python
# 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 的流式陷阱

文档会说"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 协议,框架就把这四样能力做进协议本身——

表 1.2 · 没有统一接口时手写什么 vs 接口层如何一次性回应
没有统一接口,逐组件要手写接口层的统一回应
每个组件各实现流式产出.stream() 定义在接口;能真流式的组件覆盖它,其余走兜底
每个组件各写并发批处理.batch() 定义在接口,默认对多条输入并发跑 invoke
每个组件各包重试退避.with_retry() 返回一个包装后的新 Runnable,对失败自动重试
每个组件各写主备切换.with_fallbacks([...]) 返回一个新 Runnable,主链失败时依次尝试备链
free_capabilities.py Python
# 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,和把"消息对象"收成下游好用类型的输出解析器。

messages_and_parsers.py Python
# 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

先合上教程,把能想到的答案写在纸上或编辑器里。 写完再点开答案对照——直接点开等于把这一节当再读一遍。

  1. 用一句话说清 Runnable 接口"统一"的到底是什么。invoke 和 stream 是两种不同的东西吗?
  2. chain = a | b | c 执行后、调用 chain.invoke 之前,chain 是什么类型的对象?此刻有没有发生任何真实计算?为什么这种"延迟"是设计上的优点而非缺陷?
  3. RunnableParallel 和 RunnablePassthrough.assign 都能往后传一个字典,二者的关键差别是什么?给一个"必须用 .assign 而不能用 RunnableParallel"的场景。
  4. (设计题)某团队抱怨"给每个自定义组件都得重写一遍重试逻辑,太重复"。结合 Runnable 接口的设计,指出他们多半用错了什么,以及正确做法为什么能把重试从"逐组件"变成"逐链一次"。
答案(先做完再展开)
  1. 统一的是调用契约 / 方法签名的形状——所有组件都暴露同一组 invoke / stream / batch / ainvoke,入参出参形状对齐。invoke 和 stream 不是两种不同的东西,而是同一个"输入→输出"动作的两种调用姿势(一次性返回 vs 逐块吐出);正因如此 §1.1 里只实现 invoke 就能让 stream 兜底可用。
  2. chain 是一个 RunnableSequence 对象。此刻没有任何真实计算——| 只构造了一张数据流向的 DAG(声明),真正执行发生在 invoke(执行)。这种延迟是优点:链作为静态结构对象存在,框架才能在执行前对整条链统一绑配置、加重试包装、生成 trace、推断类型;若 | 当场执行,这些都无处下手。
  3. 关键差别:RunnableParallel 丢弃原输入,输出只含各子链的计算结果;.assign 保留原字典再追加新键。必须用 .assign 的场景:RAG 里下游 prompt 既要原始问题、又要检索到的上下文——用 .assign(context=retriever) 在保住 question 的同时加上 context;若用 RunnableParallel 只算 context,原问题就丢了。
  4. 他们多半把重试逻辑写进了每个组件内部,没意识到重试是接口层的组合子。正确做法:先用 | 把组件串成一条链(链本身是 Runnable),再对整条链调一次 chain.with_retry(...)——它返回一个把整条链裹起来的新 Runnable,重试在链这一层统一生效。能从"逐组件"变"逐链一次"的根因是:重试不依赖任何组件的内部细节,只依赖"这是个 Runnable、可以重新 invoke"这一个统一前提。
进阶挑战 · 刚好够不着

给"逐链一次"的重试加一道"按异常类型分流"

你已经知道 chain.with_retry() 能给整条链一次性加重试,chain.with_fallbacks([backup]) 能加主备切换。现在的需求更细:模型限流(RateLimitError)时应当退避重试同一条链,而模型彻底不可用(如 APIConnectionError)时应当立刻切到备用模型,不要在死路上重试。只用 Runnable 接口提供的组合子,怎么把这两种处置拼出来?拼出来的东西还是不是一个 Runnable?

提示(卡住再展开)

两个方向想:其一,.with_retry() 能不能只对特定异常类型重试(找它有没有形如"指定重试哪些异常"的参数);其二,组合子返回的仍是 Runnable,意味着它们能嵌套叠加——"先重试、再 fallback"和"先 fallback、再重试"是两条不同的包裹顺序,画出各自的执行流,想清楚哪种顺序才让"限流→重试本链、断连→直接切备链"成立。无需写完整代码,能说清包裹顺序和它对应的执行语义即可。