Day 18 / 共 20 天 · 阶段5 进阶与收官

LCEL 高级组合:分岔、兜底、重试、换件——管道系统的"高级配件"

Day03 学的 | 只能拼"直筒管道"。真实生产还需要:输入 dict 缺字段要(passthrough/assign)、按条件走不同支路要(branch/router)、主模型宕机要(fallbacks)、偶发失败要(retry)、不改代码换模型要(configurable)。这六件配件全在 libs/core/langchain_core/runnables/ 目录里,每个都是一个独立小文件——今天一次配齐,你的 LCEL 就从"能跑"升级到"能上生产"。

📍 你在 20 天里的位置(阶段5:进阶与收官 · D17-20)
S1 全景/LCEL S2 模型/消息 S3 数据/RAG S4 工具/Agent D17 回调/流式 D18 LCEL 高级 D19 追踪 D20 收官
💡 先用两个类比兜住今天 类比一:把 LCEL 想成家装水管系统。Day03 的 | 是直管;今天配的是管件盒:三通阀(branch:水按条件分流)、旁通备用管(fallbacks:主管堵了走副管)、自动复位阀(retry:水锤失败自动再冲一次)、直通加料器(assign:水照流,顺路加点料进去)、可调水龙头(configurable:不拆管子就能调冷热)。类比二:这些配件全是"包装器"——像给快递加的不同包装盒:盒子外面看还是一个"包裹"(Runnable,照样能 | 拼接、照样 invoke/stream),盒子里面才是行为差异。接口不变、行为增强,所以配件之间可以随意嵌套叠加。
L01

痛点:直筒管道撑不起生产需求

🤔 痛点写个真实应用你马上会撞墙:①prompt 需要 {"context": 检索结果, "question": 原问题},可上一步只吐了 context,question 丢了;②用户问天气走 A 链、问代码走 B 链,怎么分岔?③OpenAI 限流了,想自动切到 Anthropic 顶上;④网络抖动想指数退避重试三次;⑤同一条链,测试环境用便宜模型、生产用贵模型,不想写两份代码。这些都是"管道拓扑"问题,| 一根直管解决不了。
💡 本质:每种拓扑一个 Runnable 子类,一个文件一件事看目录就懂了——passthrough.py(透传/补字段)、branch.py(条件分岔)、fallbacks.py(兜底)、retry.py(重试)、configurable.py(运行时配置)、router.py(按 key 路由)。每个文件一个主角类,全部继承 Runnable 体系,全部可以互相嵌套。框架没有发明新概念,只是把常见拓扑做成了标准件。
L02

Passthrough / Assign:原样传递 + 顺路补货

RunnablePassthroughlibs/core/langchain_core/runnables/passthrough.py:74)是"什么都不做"的管子;它的类方法 assignpassthrough.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。
L03

Branch:if/elif/else 的管道版

RunnableBranchlibs/core/langchain_core/runnables/branch.py:43)构造时收若干 (条件, 链) 对 + 最后一个默认链(branch.py:75)。看 invokebranch.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。
进阶:现在更常见的做法是用 LLM 自己当路由——一个小模型先分类(输出 "weather"/"code"/"other"),再接 branch 或 L06 的 RouterRunnable 分发。条件从"关键词"升级成"语义"。
L04

Fallbacks:主胎爆了换备胎

RunnableWithFallbackslibs/core/langchain_core/runnables/fallbacks.py:37),一般通过 runnable.with_fallbacks([...])runnables/base.py:2176)创建。核心 invokefallbacks.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 → 成功返回,调用方毫无感知(只是这次回答的"口音"变了)。两家都挂才抛错。
L05

Retry:优雅重试(tenacity 加持)

RunnableRetrylibs/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。
💡 取舍:retry 和 fallbacks 怎么选、怎么叠?retry = 同一个人再试几次(适合瞬时故障:网络抖动、偶发超时);fallbacks = 换个人上(适合持续故障:服务宕机、配额耗尽)。生产惯用法是叠起来:model.with_retry(stop_after_attempt=3).with_fallbacks([backup_model])——先自己重试 3 次,还不行再换备胎。注意顺序反过来(fallbacks 套在里面)语义就变成"每次重试都把主备都试一遍",通常不是你想要的。
L06

Configurable / Router:运行时"换零件"

最后两件。configurable_fields/alternativesrunnables/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——测试/生产、灰度/全量都能这么切。
L07

六件套地图 + 今日小结

LCEL 高级配件六件套:都在 runnables/ 目录,各管一种拓扑 passthrough.py 直通/补货 Passthrough:原样过 assign:dict 保留 + 并行补新字段 branch.py 三通阀 (条件,链)×N + 默认链 第一个为真的分支胜出 fallbacks.py 备胎 主挂了按序换备 exception_key 可喂错给备胎 retry.py 自动复位阀 tenacity:退避+抖动+次数 重试打 retry:attempt:N 标签 configurable.py 可调龙头 fields:config 改参数重建实例 alternatives:整件二选一 router.py 查表分发 输入 {key, input} 按 key 直取对应链 O(1) 共同点:都是 Runnable → 可 | 拼接、可互相嵌套、可 invoke/stream/batch 都用 run_manager.get_child() 续回调树 → Day19 的追踪里全都看得见
图注:六件配件 = 六种管道拓扑。接口统一是嵌套自由的前提:retry 套 fallbacks、branch 里放 configurable,随便组合。

👶 小白:branch 和 router 都是"分岔",我总记混。

👨‍🏫 老师:记住谁出的判断题。branch:分岔逻辑在自己身上——它拿着输入逐个跑条件函数,像老师依次问"是不是天气题?是不是代码题?";router:判断已经有人做完了——输入自带 key 字段,它只是查字典分发,像快递按面单上的区号直接扔进对应格口。上游有分类器用 router,没有就用 branch。

🧠 今天你应该能回答

  • RunnablePassthrough.assign 干什么?(输入 dict 原样保留,新字段由 RunnableParallel 并行算出后合并)
  • branch 的分支匹配规则?(顺序执行条件,第一个为真的胜出;全落空走必填的默认分支)
  • fallbacks 全失败时抛哪个错?为什么?(第一个——主链的错误最有诊断价值)
  • exception_key 有什么妙用?(把上次异常塞进备胎输入,做"看着报错自我修复"的链)
  • retry 底层用什么库?重试如何体现在追踪里?(tenacity;第 2 次起打 retry:attempt:N tag)
  • 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
明日预告 · Day 19:今天每段源码都出现了 run_manager.get_child(tag=...)——这些 tag 和 run_id 最终去了哪?明天看 tracers/:回调事件怎么被组装成一棵 Run 树LangChainTracer 又怎么把它实时投递到 LangSmith,让你在网页上看到整条链的"手术直播回放"。
← Day 17 回调与流式 Day 19 · 追踪与可观测 →