一次 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 光视角。
ensure_config:缺页的补空白页),水每流进一节管子,师傅就在工单上盖一个这节的章(patch_config:换上这一节专属的记录员),这样最后翻工单就知道水走过哪、每段谁负责。类比二:回调树 = 施工监理的层级汇报——总监理(root run)盯整条链,每节管子各派一个子监理(child run,编号 seq:step:1/2/3),子监理干活前喊"开工"(on_chain_start)、干完喊"完工"(on_chain_end)、出事喊"事故"(on_chain_error)——LangSmith 网页上那棵漂亮的执行树,就是这些汇报拼出来的。痛点:invoke 一按,中间发生了什么全靠猜?
RunnableConfig,由框架保证它随水流传遍每一节。组件不需要知道"谁在监听我",它只要在开工/完工时朝 config 里的回调管理器喊一嗓子。关注点分离的教科书:业务走数据流,观测走 config 流。主循环: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 照常能接到错误。for step in steps: x = step.invoke(x)。LangChain 最核心的执行逻辑就这么朴素——复杂度全在"随行的工单"和"层层的监理"上,这正是接下来两讲的内容。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 的可配置模型全靠它。RunnableConfig 是 TypedDict(带类型提示的普通 dict)。好处:可以随手 {"tags": ["实验A"]} 就传、跨版本兼容宽松、序列化零成本;代价:key 拼错("tag" 少个 s)不报错只静默丢弃——所以 ensure_config 里有 k in CONFIG_KEYS 的白名单过滤。用灵活性换严格性,并靠白名单守住底线。每一节内部:以 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.invoke(language_models/chat_models.py:463)不走 _call_with_config,而是把 config 拆开(callbacks/tags/metadata/run_name 各自取出)传给 generate_prompt——因为模型的"过场"复杂得多(缓存、限流、流式判断),明天 D05 专门拆它。回调树:seq:step:N 是怎么长成 LangSmith 那棵树的
把 L02-L04 拼起来,一次 chain.invoke({"topic": "猫"}) 的完整时序:
patch_config(callbacks=run_manager.get_child("seq:step:N")) 长出的子运行。LangSmith 的树 = 右列的可视化。get_child() 生成子监理、子监理随 config 传给子组件、子组件的所有汇报天然挂在父节点下。嵌套链(链里套链)也一样成立——每层都重复这个"生子下传"动作,树就自动多一层。这套机制同时服务 D17 的流式事件和 D19 的 LangSmith 追踪。some_chain.invoke(x)(不传 config),得益于 L02 ⑥ 的 contextvar 兜底,多数情况树还连着;但如果你自己开线程/进程去调,contextvar 传不过去,那一支就会从树上断开,LangSmith 里变成孤儿 run。跨线程调用时记得手动把 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_start 用 run_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': '试验链'})"
language_models/chat_models.py:BaseChatModel 的 invoke → generate → _generate_with_cache 主线,缓存怎么查怎么存、"要不要流式"怎么判断、厂商子类到底只需要写哪两个方法。