Day 04 / 共 20 天 · 阶段1 全景与 LCEL

一次 invoke 的完整旅程:水流过每节管子时都发生了什么

Day03 我们看清了管道的"静态结构"(first/middle/last),今天看"动态执行":chain.invoke({"topic": "猫"}) 按下之后,RunnableSequence.invoke 的 for 循环怎么逐节放水;②那个从头传到尾的 config 随身包怎么被 ensure_config / patch_config 一路加工;③每一节内部的 _call_with_config 做了哪些"进出手续";④LangSmith 里那棵 seq:step:1/2/3 回调树是怎么长出来的。读完今天,你对任何 LCEL 链的执行都有 X 光视角。

📍 你在 20 天里的位置(阶段1:全景与 LCEL · D01-04)
D01 项目全景 D02 第一个链 D03 Runnable/LCEL D04 invoke 旅程 S2 模型/消息/Prompt S3 数据与 RAG S4 工具/Agent S5 进阶收官
💡 先用两个类比兜住今天 还是水管世界观。类比一:config = 跟着水流走的"工单夹"——水厂放水前,师傅先把工单夹补齐(ensure_config:缺页的补空白页),水每流进一节管子,师傅就在工单上盖一个这节的章(patch_config:换上这一节专属的记录员),这样最后翻工单就知道水走过哪、每段谁负责。类比二:回调树 = 施工监理的层级汇报——总监理(root run)盯整条链,每节管子各派一个子监理(child run,编号 seq:step:1/2/3),子监理干活前喊"开工"(on_chain_start)、干完喊"完工"(on_chain_end)、出事喊"事故"(on_chain_error)——LangSmith 网页上那棵漂亮的执行树,就是这些汇报拼出来的。
L01

痛点:invoke 一按,中间发生了什么全靠猜?

🤔 痛点链跑出来的结果不对,你想知道:是 prompt 拼错了,还是模型抽风了,还是解析器抠错了?如果执行过程是黑箱,你只能在链里到处插 print。更麻烦的是横切需求:想给这次调用打个标签方便检索、想限制并发、想把整个过程送到 LangSmith 可视化——这些"和业务无关但每一节都要配合"的事,难道要每个组件各改一遍?
💡 本质:执行循环只有 20 行,横切需求全塞进 configLangChain 的答案:执行主循环写得极其朴素(真的就是个 for 循环),而所有横切需求(追踪、标签、并发、递归深度)统一装进一个字典 RunnableConfig由框架保证它随水流传遍每一节。组件不需要知道"谁在监听我",它只要在开工/完工时朝 config 里的回调管理器喊一嗓子。关注点分离的教科书:业务走数据流,观测走 config 流。
L02

主循环:RunnableSequence.invoke 全文(真源码第 1 段)

今天的主菜,一行不删(runnables/base.py:3418-3451):

# libs/core/langchain_core/runnables/base.py:3418(完整正文)
def invoke(self, input, config=None, **kwargs):
    # setup callbacks and context
    config = ensure_config(config)                                # ① 补齐工单
    callback_manager = get_callback_manager_for_config(config)    # ② 组装监理团
    # start the root run
    run_manager = callback_manager.on_chain_start(                # ③ 总监理喊开工
        None, input,
        name=config.get("run_name") or self.get_name(),
        run_id=config.pop("run_id", None),
    )
    input_ = input

    # invoke all steps in sequence
    try:
        for i, step in enumerate(self.steps):                     # ④ ★逐节放水
            # mark each step as a child run
            config = patch_config(                                # ⑤ 给这节盖章
                config, callbacks=run_manager.get_child(f"seq:step:{i + 1}")
            )
            with set_config_context(config) as context:           # ⑥ 存进 contextvar
                if i == 0:
                    input_ = context.run(step.invoke, input_, config, **kwargs)
                else:
                    input_ = context.run(step.invoke, input_, config)  # ⑦ 出水=下节进水
    # finish the root run
    except BaseException as e:
        run_manager.on_chain_error(e)                             # ⑧ 出事上报
        raise
    else:
        run_manager.on_chain_end(input_)                          # ⑨ 完工上报
        return cast("Output", input_)
④ for i, step in ...★整个 LCEL 的执行核心就这一个循环。self.steps 是 D03 看过的 [first, *middle, last] 平铺列表——D03 的"拆平"设计在这里兑现:一层循环走到底,没有递归下钻。
⑦ input_ = step.invoke(input_)用同一个变量 input_ 滚动:这一节的出水直接当下一节的进水。prompt 吐的 PromptValue 进 model,model 吐的 AIMessage 进 parser——数据"接力棒"就是这行。
i==0 才传 **kwargs细节:调用方传的额外参数只给第一节。因为 kwargs 是"对这次输入的补充说明",从第二节起输入已经是上一节的产物,原始 kwargs 语义不再成立。
⑥ set_config_context把 config 存进 Python 的 contextvars(线程/协程本地变量)再执行。妙处:万一某节内部又调了别的 Runnable 却忘了传 config,ensure_config 能从 contextvar 里捞回来(L03 就能看到这个兜底)——工单不会断传。
⑧⑨ try/except/else成功走 on_chain_end(最终结果),失败走 on_chain_error(异常) 再原样 raise。监理只记录、不吞异常——所以你的 try/except 照常能接到错误。
大白话剥掉监理相关的 6 行,这个函数其实就是:for step in steps: x = step.invoke(x)。LangChain 最核心的执行逻辑就这么朴素——复杂度全在"随行的工单"和"层层的监理"上,这正是接下来两讲的内容。
L03

config 随身包:ensure_config 与 patch_config(真源码第 2 段)

两位工单管理员都住在 runnables/config.py。先看补齐员(config.py:255-292):

# libs/core/langchain_core/runnables/config.py:255(裁剪)
def ensure_config(config: RunnableConfig | None = None) -> RunnableConfig:
    """Ensure that a config is a dict with all keys present."""
    empty = RunnableConfig(
        tags=[], metadata={}, callbacks=None,
        recursion_limit=DEFAULT_RECURSION_LIMIT, configurable={},
    )                                                   # ① 先造一份空白工单
    if var_config := var_child_runnable_config.get():   # ② ★从 contextvar 捞"祖传工单"
        empty.update({k: v.copy() if k in COPIABLE_KEYS else v
                      for k, v in var_config.items() if v is not None})
    if config is not None:                              # ③ 调用方亲手传的,优先级最高
        empty.update({k: ... for k, v in config.items()
                      if v is not None and k in CONFIG_KEYS})
    ...
    return empty

再看盖章员(config.py:357-397):

# libs/core/langchain_core/runnables/config.py:357(裁剪)
def patch_config(config, *, callbacks=None, recursion_limit=None,
                 max_concurrency=None, run_name=None, configurable=None):
    config = ensure_config(config)
    if callbacks is not None:
        # If we're replacing callbacks, we need to unset run_name
        # As that should apply only to the same run as the original callbacks
        config["callbacks"] = callbacks        # ① 换上"这一节专属监理"
        if "run_name" in config: del config["run_name"]   # ② 名字不跟着下传
        if "run_id" in config:   del config["run_id"]     #    id 也不跟
    if recursion_limit is not None: config["recursion_limit"] = recursion_limit
    ...
    if configurable is not None:
        config["configurable"] = {**config.get("configurable", {}), **configurable}
    return config
三层优先级ensure_config 的合并顺序:空白默认 < contextvar 里的祖传工单 < 你亲手传的 config。所以外层链设置的 tags,内层组件自动继承;但你现场指定的永远赢。
v.copy() if COPIABLEtags/metadata 这类可变容器要复制一份再合并——否则内层组件往 tags 里 append,会把外层的列表也改了(可变默认值的经典坑,框架替你防了)。
patch 时删 run_name/run_id★L02 的 ⑤ 每节都 patch callbacks,同时删掉 run_name/run_id——因为"这次运行叫什么/编号多少"只属于根运行;如果跟着下传,LangSmith 树上每一节都会顶着同一个名字,没法看了。源码注释原话就在解释这个。
configurable 合并不覆盖configurable 字典是 merge({**旧, **新})而非替换——运行时配置一路只增不丢,D18 的可配置模型全靠它。
💡 设计取舍:config 为什么是"松散字典"而不是强类型对象?RunnableConfig 是 TypedDict(带类型提示的普通 dict)。好处:可以随手 {"tags": ["实验A"]} 就传、跨版本兼容宽松、序列化零成本;代价:key 拼错("tag" 少个 s)不报错只静默丢弃——所以 ensure_config 里有 k in CONFIG_KEYS 的白名单过滤。用灵活性换严格性,并靠白名单守住底线。
L04

每一节内部:以 prompt 为例看 _call_with_config(真源码第 3 段)

主循环调 step.invoke(...) 后,每一节自己还要办"进出手续"。看第一节 prompt 的 invoke(prompts/base.py:210-236):

# libs/core/langchain_core/prompts/base.py:210(完整正文)
def invoke(self, input, config=None, **kwargs) -> PromptValue:
    config = ensure_config(config)                       # 又 ensure 一次(幂等,白捡兜底)
    if self.metadata:
        config["metadata"] = {**config["metadata"], **self.metadata}
    if self.tags:
        config["tags"] += self.tags                      # 组件自带的标签也上工单
    return self._call_with_config(                       # ★交给基类的"标准过场"
        self._format_prompt_with_error_handling,         #   真正的业务:填模板
        input, config,
        run_type="prompt",                               #   在追踪里标记为 prompt 类型
        serialized=self._serialized,
    )
_call_with_config定义在 runnables/base.py:2256,是所有"单步组件"共用的标准过场:开子运行(on_chain_start)→ 执行传进来的业务函数 → 成功报 end / 失败报 error → 返回。组件作者只写业务函数,进出手续基类全包——和 L02 主循环的 try/else 结构一模一样,只是粒度小一层。
run_type="prompt"告诉追踪系统"我这一步是填提示词的"。LangSmith 里不同 run_type 显示不同图标/颜色:prompt/llm/parser/tool/retriever……D19 会看到 tracer 怎么消费它。
再 ensure 一次不浪费吗?ensure_config 是幂等的(补齐过的工单再补一次不变)。多这一道是防御:prompt 也可能被单独 invoke(不在链里),这时没人替它 ensure。每个组件自守门户,才能既独立可用又可组合。
第二节 model 呢?BaseChatModel.invokelanguage_models/chat_models.py:463)不走 _call_with_config,而是把 config 拆开(callbacks/tags/metadata/run_name 各自取出)传给 generate_prompt——因为模型的"过场"复杂得多(缓存、限流、流式判断),明天 D05 专门拆它。
L05

回调树:seq:step:N 是怎么长成 LangSmith 那棵树的

把 L02-L04 拼起来,一次 chain.invoke({"topic": "猫"}) 的完整时序:

一次 invoke 的 X 光片:左边数据流,右边回调树 数据流(input_ 接力棒) {"topic": "猫"} step1 prompt.invoke → PromptValue step2 model.invoke → AIMessage step3 parser.invoke → str '我家猫把我当...实习生。' 回调树(run 层级) RunnableSequence(root run) seq:step:1 · prompt(run_type=prompt) seq:step:2 · model(run_type=llm) seq:step:3 · parser(run_type=parser) run_manager.get_child("seq:step:N") = 给这节生成子监理(patch_config 装进工单)
图注:左列是 L02 的 for 循环,右列是每次 patch_config(callbacks=run_manager.get_child("seq:step:N")) 长出的子运行。LangSmith 的树 = 右列的可视化。
💡 本质:树不是"事后分析"出来的,是执行时天然长出来的很多系统的调用树靠事后解析日志拼接,容易断线。LCEL 的树是结构性的:父 run_manager 调 get_child() 生成子监理、子监理随 config 传给子组件、子组件的所有汇报天然挂在父节点下。嵌套链(链里套链)也一样成立——每层都重复这个"生子下传"动作,树就自动多一层。这套机制同时服务 D17 的流式事件和 D19 的 LangSmith 追踪。
⚠️ 坑:自定义组件"跳出"了树如果你在 RunnableLambda 里手写 some_chain.invoke(x)(不传 config),得益于 L02 ⑥ 的 contextvar 兜底,多数情况树还连着;但如果你自己开线程/进程去调,contextvar 传不过去,那一支就会从树上断开,LangSmith 里变成孤儿 run。跨线程调用时记得手动把 config 传进去。
L06

串起来 + 今日小结

📝 真实值:带着 config 跑一次,看工单怎么变 chain.invoke({"topic": "猫"}, config={"tags": ["实验A"], "run_name": "笑话链"}) 的工单演变:
① 进 Sequence.invoke,ensure_config 补齐 → {"tags": ["实验A"], "metadata": {}, "callbacks": None, "recursion_limit": 25, "configurable": {}, "run_name": "笑话链"}
on_chain_startrun_name="笑话链" 建 root run;
③ 进 step1 前 patch_config:callbacks 换成 root 的子监理 seq:step:1,同时删掉 run_name(只属于根);tags 里的 "实验A" 保留下传;
④ prompt.invoke 内部再把自己的 tags/metadata 并进工单,以 run_type="prompt" 记录子运行;
⑤ step2、step3 重复 ③④,编号变成 seq:step:2、seq:step:3;
⑥ 全部成功,root 的 on_chain_end('我家猫把我当...') 收尾。LangSmith 里你会看到名为"笑话链"的树、三个子节点、全树都带 "实验A" 标签。

👶 小白:为什么我在链中间的组件上单独设的 run_name 没显示?还有 recursion_limit 是防什么的?

👨‍🏫 老师:第一问正是 L03 的细节——patch_config 在给每节换监理时会删掉 config 里的 run_name(它只属于当前这层 run),组件想固定名字应该用 .with_config(run_name="...") 把名字绑在组件自己身上。第二问:recursion_limit(默认 25)防的是"Runnable 调 Runnable"无限套娃——比如 Agent 循环里链自己调自己,每下一层计数 +1,超限抛错,防止栈溢出或无限烧钱。这个值也在工单里,所以可以每次调用单独放宽。

🧠 今天你应该能回答

  • Sequence.invoke 的核心是什么结构?(一个 for 循环,input_ = step.invoke(input_) 滚动接力)
  • ensure_config 的三层优先级?(默认空白 < contextvar 祖传 < 亲手传入)
  • patch_config 换 callbacks 时为什么删 run_name/run_id?(它们只属于当前层的 run,下传会让每节重名)
  • seq:step:N 从哪来?(主循环里 run_manager.get_child(f"seq:step:{i+1}"),每节一个子监理)
  • 组件单独 invoke 也能被追踪吗?(能,每个组件自己也 ensure_config + _call_with_config 走标准过场)
  • kwargs 为什么只传给第一节?(它是对原始输入的补充,第二节起输入已是上节产物)

✋ 10 分钟动手

cd /Users/bitmart/work/codes/github/AI_WORK/langchain

# 1. 主循环(今天的主菜,值得逐行再读一遍)
sed -n '3418,3452p' libs/core/langchain_core/runnables/base.py

# 2. 工单双雄
sed -n '255,295p' libs/core/langchain_core/runnables/config.py   # ensure_config
sed -n '357,398p' libs/core/langchain_core/runnables/config.py   # patch_config

# 3. 单节的标准过场
sed -n '210,240p' libs/core/langchain_core/prompts/base.py       # prompt.invoke
grep -n "def _call_with_config" libs/core/langchain_core/runnables/base.py  # :2256

# 4. 无 key 观察回调树:挂一个打印所有事件的 handler
python3 -c "
from langchain_core.runnables import RunnableLambda
from langchain_core.callbacks import BaseCallbackHandler
class Spy(BaseCallbackHandler):
    def on_chain_start(self, s, inputs, *, run_id, parent_run_id=None, **kw):
        print('start', kw.get('name'), 'parent=', parent_run_id is not None)
seq = RunnableLambda(lambda x: x+1) | RunnableLambda(lambda x: x*2)
seq.invoke(1, config={'callbacks': [Spy()], 'run_name': '试验链'})"
明日预告 · Day 05:主循环里的 step2(model.invoke)今天被我们一笔带过,其实它是最厚的一节——进入阶段 2,打开 language_models/chat_models.py:BaseChatModel 的 invoke → generate → _generate_with_cache 主线,缓存怎么查怎么存、"要不要流式"怎么判断、厂商子类到底只需要写哪两个方法。
← Day 03 Runnable 与 LCEL 管道 Day 05 · ChatModel 抽象 →