LCEL 高级组合:分岔、兜底、重试、换件——管道系统的"高级配件"
Day03 学的 | 只能拼"直筒管道"。真实生产还需要:输入 dict 缺字段要补(passthrough/assign)、按条件走不同支路要岔(branch/router)、主模型宕机要备(fallbacks)、偶发失败要重(retry)、不改代码换模型要配(configurable)。这六件配件全在 libs/core/langchain_core/runnables/ 目录里,每个都是一个独立小文件——今天一次配齐,你的 LCEL 就从"能跑"升级到"能上生产"。
| 是直管;今天配的是管件盒:三通阀(branch:水按条件分流)、旁通备用管(fallbacks:主管堵了走副管)、自动复位阀(retry:水锤失败自动再冲一次)、直通加料器(assign:水照流,顺路加点料进去)、可调水龙头(configurable:不拆管子就能调冷热)。类比二:这些配件全是"包装器"——像给快递加的不同包装盒:盒子外面看还是一个"包裹"(Runnable,照样能 | 拼接、照样 invoke/stream),盒子里面才是行为差异。接口不变、行为增强,所以配件之间可以随意嵌套叠加。痛点:直筒管道撑不起生产需求
{"context": 检索结果, "question": 原问题},可上一步只吐了 context,question 丢了;②用户问天气走 A 链、问代码走 B 链,怎么分岔?③OpenAI 限流了,想自动切到 Anthropic 顶上;④网络抖动想指数退避重试三次;⑤同一条链,测试环境用便宜模型、生产用贵模型,不想写两份代码。这些都是"管道拓扑"问题,| 一根直管解决不了。passthrough.py(透传/补字段)、branch.py(条件分岔)、fallbacks.py(兜底)、retry.py(重试)、configurable.py(运行时配置)、router.py(按 key 路由)。每个文件一个主角类,全部继承 Runnable 体系,全部可以互相嵌套。框架没有发明新概念,只是把常见拓扑做成了标准件。Passthrough / Assign:原样传递 + 顺路补货
RunnablePassthrough(libs/core/langchain_core/runnables/passthrough.py:74)是"什么都不做"的管子;它的类方法 assign(passthrough.py:207)才是高频主角:
# libs/core/langchain_core/runnables/passthrough.py:207
@classmethod
def assign(cls, **kwargs) -> RunnableAssign:
"""Merge the Dict input with the output produced by the mapping argument."""
return RunnableAssign(RunnableParallel[dict[str, Any]](kwargs)) # ★委托给 Assign+Parallel
# libs/core/langchain_core/runnables/passthrough.py:484(RunnableAssign 的核心)
def _invoke(self, value, run_manager, config, **kwargs) -> dict[str, Any]:
if not isinstance(value, dict):
msg = "The input to RunnablePassthrough.assign() must be a dict."
raise ValueError(msg)
return {
**value, # ① 原输入 dict 全保留
**self.mapper.invoke( # ② 新字段并行算出来,合并进去
value,
patch_config(config, callbacks=run_manager.get_child()), # 回调树续上(Day17!)
**kwargs,
),
}
RunnablePassthrough()invoke 就是 identity(原样返回,passthrough.py:226)。看似没用,实则是 RunnableParallel 里"保住原输入"的占位符:{"context": retriever, "question": RunnablePassthrough()}——问题原样过,context 由检索器算。assign(**kwargs)"顺路补货":输入 dict 不动,把每个 kwarg(key=Runnable/函数)算出来合并成新 dict。Day16 的 insert_history 就是用它把历史消息补进输入的——学过的东西又见面了。mapper = RunnableParallel多个新字段是并行计算的(复用 Day03 的 RunnableParallel),不是逐个串行——白拿的并发。get_child()注意每个配件的源码都有这句:把回调/追踪树续到子运行上。Day19 你会看到,正因如此追踪图里能看到嵌套结构。{"question": "LCEL 是什么?"},经过 RunnablePassthrough.assign(context=retriever | format_docs) → 输出 {"question": "LCEL 是什么?", "context": "LCEL 是 LangChain 表达式语言,..."}。question 一个字没动,context 是新补的——正好喂给下一步的 prompt。Branch:if/elif/else 的管道版
RunnableBranch(libs/core/langchain_core/runnables/branch.py:43)构造时收若干 (条件, 链) 对 + 最后一个默认链(branch.py:75)。看 invoke(branch.py:185):
# libs/core/langchain_core/runnables/branch.py:185(节选)
def invoke(self, input, config=None, **kwargs) -> Output:
for idx, branch in enumerate(self.branches):
condition, runnable = branch
expression_value = condition.invoke( # ① 条件本身也是 Runnable!
input,
config=patch_config(config,
callbacks=run_manager.get_child(tag=f"condition:{idx + 1}")),
)
if expression_value: # ② 第一个为真的分支胜出
output = runnable.invoke(
input,
config=patch_config(config,
callbacks=run_manager.get_child(tag=f"branch:{idx + 1}")),
**kwargs)
break
else: # ③ for-else:全不中 → 默认分支
output = self.default.invoke(
input,
config=patch_config(config,
callbacks=run_manager.get_child(tag="branch:default")),
**kwargs)
condition.invoke你传的 lambda 会被 coerce_to_runnable 包成 Runnable(branch.py:110)——所以条件判断本身也出现在追踪树里,tag 是 condition:1,调试时能看到"为什么走了这条分支"。第一个为真胜出语义 = Python 的 if/elif:顺序敏感,命中即止。把最特殊的条件放前面、最宽泛的放后面。for-elsePython 冷知识实战:for 正常跑完(没 break)才进 else——全部条件落空就走 default。构造时不给默认分支会直接 ValueError,强制你兜底。branch = RunnableBranch((lambda x: "天气" in x["q"], weather_chain), (lambda x: "代码" in x["q"], code_chain), general_chain)。输入 {"q": "北京天气如何"} → 条件 1 为真 → 走 weather_chain;输入 {"q": "讲个笑话"} → 两个条件都假 → 走 general_chain。Fallbacks:主胎爆了换备胎
RunnableWithFallbacks(libs/core/langchain_core/runnables/fallbacks.py:37),一般通过 runnable.with_fallbacks([...])(runnables/base.py:2176)创建。核心 invoke(fallbacks.py:165):
# libs/core/langchain_core/runnables/fallbacks.py:165(节选)
def invoke(self, input, config=None, **kwargs) -> Output:
first_error = None
last_error = None
for runnable in self.runnables: # ① 主 Runnable + 所有备胎,排队上
try:
if self.exception_key and last_error is not None:
input[self.exception_key] = last_error # ② ★可把上次的异常喂给备胎
child_config = patch_config(config, callbacks=run_manager.get_child())
with set_config_context(child_config) as context:
output = context.run(runnable.invoke, input, config, **kwargs)
except self.exceptions_to_handle as e: # ③ 只接住指定异常类型(默认 Exception)
if first_error is None:
first_error = e # 记住第一个错
last_error = e
except BaseException as e:
run_manager.on_chain_error(e) # ④ 不在白名单的错:立刻上报、原样抛
raise
else:
run_manager.on_chain_end(output)
return output # ⑤ 谁成功就用谁,后面的不再试
run_manager.on_chain_error(first_error)
raise first_error # ⑥ 全军覆没:抛"第一个"错
self.runnables一个迭代序列:[主链, 备胎1, 备胎2...]。顺序即优先级。exception_key★妙用:设了它,上一个失败的异常会以 input["error"]=... 塞进备胎的输入——备胎链的 prompt 可以写"上次尝试报错 {error},请修正",实现自我修复式重试(比如结构化输出解析失败,把报错喂回去让模型改)。exceptions_to_handle只有白名单里的异常才触发换胎;KeyboardInterrupt 这类照样直接抛。可以收紧成只对限流异常兜底。raise first_error全失败时抛第一个错而不是最后一个——因为主链的报错通常最有诊断价值(备胎往往是"顺带试试")。llm = ChatOpenAI(model="gpt-4o").with_fallbacks([ChatAnthropic(model="claude-sonnet-4-5")])。OpenAI 返回 429 RateLimitError → 被 ③ 接住 → 同样的消息喂给 Anthropic → 成功返回,调用方毫无感知(只是这次回答的"口音"变了)。两家都挂才抛错。Retry:优雅重试(tenacity 加持)
RunnableRetry(libs/core/langchain_core/runnables/retry.py:48),入口是 runnable.with_retry(...)(runnables/base.py:2089)。它没有自己造轮子,直接站在重试库 tenacity 肩上:
# libs/core/langchain_core/runnables/retry.py:152
def _sync_retrying(self, **kwargs: Any) -> Retrying:
return Retrying(**self._kwargs_retrying, **kwargs) # tenacity 的重试器
# libs/core/langchain_core/runnables/retry.py:179
def _invoke(self, input_, run_manager, config, **kwargs) -> Output:
for attempt in self._sync_retrying(reraise=True): # ① tenacity 的重试循环
with attempt:
result = super().invoke(
input_,
self._patch_config(config, run_manager, attempt.retry_state), # ② 每次尝试打 tag
**kwargs,
)
if attempt.retry_state.outcome and not attempt.retry_state.outcome.failed:
attempt.retry_state.set_result(result) # ③ 成功:登记结果,循环结束
return result
# retry.py:165(_patch_config 内部)
attempt = retry_state.attempt_number
tag = f"retry:attempt:{attempt}" if attempt > 1 else None # ★第2次起打 retry:attempt:N 标签
Retrying(reraise=True)tenacity 负责所有重试策略:最多几次(stop_after_attempt)、指数退避+抖动(wait_exponential_jitter)、哪些异常才重试(retry_if_exception_type)。这些都是 with_retry() 的参数。retry:attempt:N★第二次及以后的尝试会带上这个 tag——Day19 的追踪界面里一眼看出"这一步重试了 3 次"。可观测性是顺手织进去的。super().invokeRunnableRetry 继承 RunnableBindingBase(和 Day16 的护士同款基座)——重试的是被包住的那个 Runnable。model.with_retry(stop_after_attempt=3).with_fallbacks([backup_model])——先自己重试 3 次,还不行再换备胎。注意顺序反过来(fallbacks 套在里面)语义就变成"每次重试都把主备都试一遍",通常不是你想要的。Configurable / Router:运行时"换零件"
最后两件。configurable_fields/alternatives(runnables/base.py:2843/2901)返回的 DynamicRunnable 子类(libs/core/langchain_core/runnables/configurable.py:50)核心是"先按 config 备好零件,再执行":
# libs/core/langchain_core/runnables/configurable.py:141(DynamicRunnable)
def invoke(self, input, config=None, **kwargs) -> Output:
runnable, config = self.prepare(config) # ① ★按 config 现场"组装"出真正的 Runnable
return runnable.invoke(input, config, **kwargs)
# libs/core/langchain_core/runnables/configurable.py:419(RunnableConfigurableFields._prepare 节选)
def _prepare(self, config=None):
configurable_fields = { # ② 从 config["configurable"] 里挑出声明过的字段
specs_by_id[k][0]: v
for k, v in config.get("configurable", {}).items()
if k in specs_by_id and isinstance(specs_by_id[k][1], ConfigurableField)
}
if configurable:
init_params = {k: v for k, v in self.default.__dict__.items()
if k in type(self.default).model_fields}
return (
self.default.__class__(**{**init_params, **configurable}), # ③ ★用新参数重建一个实例
config,
)
return (self.default, config) # ④ 没配置 → 用默认件,零开销
# libs/core/langchain_core/runnables/router.py:107(RouterRunnable)
def invoke(self, input: RouterInput, config=None, **kwargs) -> Output:
key = input["key"] # 输入自带路由 key
actual_input = input["input"]
if key not in self.runnables:
msg = f"No runnable associated with key '{key}'"
raise ValueError(msg)
runnable = self.runnables[key] # ★查表分发,就这么直白
return runnable.invoke(actual_input, config)
prepare → 重建实例configurable 的本质:不改这条链,改的是调用时的 config。_prepare 拿默认实例的参数 + config 里的覆盖值,new 一个新实例出来用。所以 model.configurable_fields(temperature=ConfigurableField(id="temp")) 之后,chain.invoke(x, config={"configurable": {"temp": 0.9}}) 就临时换了温度。configurable_alternatives兄弟玩法(configurable.py:474):不是改字段,而是整个组件二选一/多选一——prompt | model.configurable_alternatives(ConfigurableField(id="llm"), openai=gpt, anthropic=claude),一条链、config 切换供应商。A/B 测试神器。RouterRunnablebranch 是"算条件",router 是"查字典":输入必须长成 {"key": "...", "input": ...},按 key 直取对应链。适合上游已经算好路由(比如分类模型的输出)的场景,O(1) 不用逐个试条件。config 是"总线"再次看到 Day04 的老朋友:session_id(D16)、callbacks(D17)、configurable 字段(今天)——全部走 config 传递。config 是 LCEL 世界的"随行公文包"。model.configurable_alternatives(ConfigurableField(id="llm"), default_key="openai", anthropic=claude_model)。默认调用 → 走 OpenAI;chain.invoke(x, config={"configurable": {"llm": "anthropic"}}) → 同一条链瞬间换成 Claude。代码零改动,只动 config——测试/生产、灰度/全量都能这么切。六件套地图 + 今日小结
👶 小白:branch 和 router 都是"分岔",我总记混。
👨🏫 老师:记住谁出的判断题。branch:分岔逻辑在自己身上——它拿着输入逐个跑条件函数,像老师依次问"是不是天气题?是不是代码题?";router:判断已经有人做完了——输入自带 key 字段,它只是查字典分发,像快递按面单上的区号直接扔进对应格口。上游有分类器用 router,没有就用 branch。
🧠 今天你应该能回答
RunnablePassthrough.assign干什么?(输入 dict 原样保留,新字段由 RunnableParallel 并行算出后合并)- branch 的分支匹配规则?(顺序执行条件,第一个为真的胜出;全落空走必填的默认分支)
- fallbacks 全失败时抛哪个错?为什么?(第一个——主链的错误最有诊断价值)
exception_key有什么妙用?(把上次异常塞进备胎输入,做"看着报错自我修复"的链)- retry 底层用什么库?重试如何体现在追踪里?(tenacity;第 2 次起打
retry:attempt:Ntag) - configurable 怎么做到"不改代码换模型"?(
_prepare按 config 重建实例;alternatives 整件切换) - retry 和 fallbacks 的选择标准?(瞬时故障重试自己;持续故障换人;常见叠法 retry 在内层)
✋ 10 分钟动手
cd /Users/bitmart/work/codes/github/AI_WORK/langchain/libs/core/langchain_core/runnables
# 1. 补货二人组
sed -n '207,226p' passthrough.py # assign → RunnableAssign(RunnableParallel)
sed -n '484,512p' passthrough.py # _invoke:{**原dict, **新字段}
# 2. 分岔与兜底
sed -n '185,240p' branch.py # 条件也是 Runnable + for-else 默认分支
sed -n '165,212p' fallbacks.py # 换胎循环 + exception_key
# 3. 重试与换件
sed -n '152,203p' retry.py # tenacity + retry:attempt:N tag
sed -n '419,459p' configurable.py # _prepare:按 config 重建实例
sed -n '107,118p' router.py # 查表分发
# 4. 这些包装器的统一入口都挂在 Runnable 基类上
grep -n "def with_retry\|def with_fallbacks\|def configurable_" base.py
run_manager.get_child(tag=...)——这些 tag 和 run_id 最终去了哪?明天看 tracers/:回调事件怎么被组装成一棵 Run 树、LangChainTracer 又怎么把它实时投递到 LangSmith,让你在网页上看到整条链的"手术直播回放"。