Day 24 / 共 60 天 · 阶段 4 Pregel 执行引擎(核心深水区)

输入输出映射:图的进门与出门

Day 23 看的是"节点内部"的读写,今天看"整张图的边界":你 invoke({"messages": [...]}) 传的字典,怎么变成第一批 channel 写入进门?图跑完又怎么从 channel 读出结果出门?以及 streamvaluesupdates 两种模式到底差在哪?全在 _io.py 这 175 行里——map_inputmap_output_valuesmap_output_updatesmap_command。图小但极高频,每次运行都从这里进出。

📍 你在 60 天里的位置
①入门 D1-6· ②状态 D7-12· ③控制流 D13-18· ④Pregel D19-26· ⑤通道 D27-32· ⑥持久化 D33-40· ⑦中断 D41-46· ⑧函数式 D47-52· ⑨预制件 D53-58· ⑩收官 D59-60
D19 BSP模型 D20 主循环 D21 任务准备 D22 执行器 D23 读写 D24 IO映射 D25 重试超时 D26 调试画图
L01

图的两道门:进门写通道,出门读通道

🤔 痛点:我 invoke 传的是普通字典,节点世界里却是一个个 channel——中间是谁在翻译? 你对图的全部交互就两句:invoke(输入) 和拿 返回值。但引擎内部只认 channel。所以图有两道"翻译门":进门把你的输入拆成"写哪些 channel"(map_input),出门把最终 channel 值拼回你要的形状(map_output_*)。这两道门定义了"图的输入/输出 schema"到底怎么落地。

回忆 Day 20 主循环:第一步之前有个 _first() 把输入喂进图,用的就是 map_input;每步 after_tick 里 _emit("values", map_output_values, ...) 产出流式输出,用的就是 map_output_values。今天把这两道门拆开。

💡 本质:输入=一批"待写入",输出=一次"读快照" 记住这个不对称:输入被翻译成"写入动作"(往 START/入口通道写值,触发第一批节点);输出被翻译成"读取动作"(从输出通道读当前值拼成结果)。写靠 map_input,读靠 map_output_values(读全量)或 map_output_updates(读增量)。图的输入 schema 和输出 schema 之所以能不同(Day 11),就是因为进出两道门是独立的两套映射。
类比:海关的入境单和出境单 入境(map_input):把你带的行李(输入字典)按种类分别登记进不同仓库(channel);出境(map_output):从指定几个仓库把货取出来打包给你。你不用知道仓库怎么编号,海关这两张单子替你转换。而且入境登记的物品种类和出境领取的物品种类,可以是两套清单(输入/输出 schema 不同)。
控制流:输入进门 → 引擎超步 → 出门返回 invoke(输入) dict map_input → 通道写入 Pregel 超步循环 (D19-23) 读写通道 map_output_values / _updates 返回/流 给你 进门 = 写通道 出门 = 读通道 两道门相互独立 → 输入 schema 与输出 schema 可以不同
图注:输入经 map_input 写进通道触发引擎,跑完经 map_output 读通道返回;两道门独立,故进出 schema 可不同。
L02

map_input:把输入字典拆成通道写入

进门映射 map_input_io.py:81)非常直白:

# _io.py:81
def map_input(input_channels, chunk) -> Iterator[tuple[str, Any]]:
    """Map input chunk to a sequence of pending writes in the form (channel, value)."""
    if chunk is None:
        return                                       # 87 输入是 None → 不写任何东西(恢复场景)
    elif isinstance(input_channels, str):
        yield (input_channels, chunk)                # 89 单输入通道:整个 chunk 写进去
    else:
        if not isinstance(chunk, dict):
            raise TypeError(f"Expected chunk to be a dict, got {type(chunk).__name__}")  # 92
        for k in chunk:                              # 93 多输入通道:按 key 分发
            if k in input_channels:
                yield (k, chunk[k])                  # 95 key 是合法输入通道 → 写它
            else:
                logger.warning(f"Input channel {k} not found in {input_channels}")  # 97
if chunk is None: return输入是 None 就啥也不写。这正是 Day 20 讲的恢复姿势 invoke(None, config)——不喂新输入,接着 checkpoint 往下跑。
isinstance(input_channels, str)如果图声明了单一输入通道(少见,多用于底层 Pregel),整个输入直接写进那一个通道。
for k in chunk: if k in input_channels常态:输入是 dict,逐个字段检查"这个 key 是不是合法输入通道",是就产出一条 (通道, 值) 写入。不合法的 key 只警告——和 Day 23 写未知通道一样的容错风格。
yield (k, chunk[k])产出的这些 (通道, 值) 会被当成"第 0 步的写入"喂给 apply_writes,升起入口通道的版本号,从而在第一次 tick 里触发入口节点。
⚠️ 边界/坑:输入里多写的字段被静默忽略,schema 之外的东西进不来 如果你的 state schema 只有 messages,但你 invoke({"messages": [...], "user_id": 123}),那个 user_id 会因为"不在 input_channels 里"被 logger.warning 丢掉——它不会进入状态。新手常踩:想通过输入 dict 塞点"运行时上下文"进去,结果发现节点里读不到。正确做法是把它声明进 state schema(成为合法输入通道),或者走 config/context(Day 59 runtime)而不是塞进 state 输入。
L03

read_channel:所有"读"的底层动作

出门要先会"读通道"。两个基础函数 read_channel/read_channels_io.py:23-53):

# _io.py:23
def read_channel(channels, chan, *, catch=True) -> Any:
    try:
        return channels[chan].get()                  # 30 调通道的 get() 取当前值
    except EmptyChannelError:                         # 31 通道还没被写过 → 空
        if catch:
            return None                               # 33 默认吞掉,返回 None
        else:
            raise                                     # 35 catch=False 时把空当错误抛

# _io.py:38
def read_channels(channels, select, *, skip_empty=True) -> dict | Any:
    if isinstance(select, str):
        return read_channel(channels, select)         # 45 单个:直接读
    else:
        values: dict[str, Any] = {}
        for k in select:                              # 48 多个:逐个读拼成 dict
            try:
                values[k] = read_channel(channels, k, catch=not skip_empty)
            except EmptyChannelError:
                pass                                  # 52 skip_empty=True 时跳过空通道
        return values
channels[chan].get()真正读值的地方——调通道自己的 get()(阶段 5 讲每种通道的 get 语义:LastValue 返回最后写的、Topic 返回累积列表……)。
except EmptyChannelError通道从没被写过时 get() 抛这个异常。catch=True(默认)把它转成 None——所以你读一个还没赋值的 state 字段得到 None 而非报错。
skip_empty读多通道拼 dict 时,空通道直接不出现在结果里(而不是给个 None)。这样输出字典只含"确实有值"的字段,干净。
💡 本质:读永远是"读通道当前提交值",不读中途状态 所有输出映射最终都落到 read_channel().get(),读的是通道当前已提交的值。结合 BSP:一个超步内节点的写入要等 after_tick 的 apply_writes 才合并进通道,所以输出永远读到的是"完整超步结束后的一致快照",绝不会读到"某个节点写了一半、另一个还没写"的中间态。这就是为什么 LangGraph 的输出总是干净一致的。
L04

map_output_values:读全量状态快照

values 模式的输出映射(_io.py:100)——每步产出"整个状态现在长啥样":

# _io.py:100
def map_output_values(output_channels, pending_writes, channels) -> Iterator[dict | Any]:
    """Map pending writes (a sequence of tuples (channel, value)) to output chunk."""
    if isinstance(output_channels, str):
        if pending_writes is True or any(
            chan == output_channels for chan, _ in pending_writes  # 108 本步是否写了这个输出通道
        ):
            yield read_channel(channels, output_channels)          # 110 写了 → 读它的全量值产出
    else:
        if pending_writes is True or {
            c for c, _ in pending_writes if c in output_channels   # 112 本步写的通道里有输出通道吗
        }:
            yield read_channels(channels, output_channels)         # 115 有 → 读全部输出通道拼 dict
any(chan == output_channels ...)先看"这一步有没有写到输出通道"。没写就不产出——避免每步都吐一份没变化的状态。只有输出通道被动过才发一次全量快照。
read_channels(channels, output_channels)关键:读的是所有输出通道的当前完整值拼成 dict。所以 values 模式每次给你的是"此刻完整的 state",不是"这步改了啥"。
pending_writes is True一个特殊标记:强制产出(不检查是否写过输出通道)。比如最后一步收尾时确保把最终状态吐出来。
📝 真实值走一遍 state 有 {messages, count},输出通道就是这两个。第 3 步 count 从 2 变 3、messages 没动:map_output_values 检查到 count(输出通道)被写了 → 读全量 → 产出 {"messages": [...全部历史...], "count": 3}。注意 messages 虽没变,也被完整带出——这就是 "values=全量快照" 的含义。
L05

map_output_updates:读增量(谁改了什么)

updates 模式(_io.py:118)反过来——只产出"这一步哪个节点改了什么",按节点名分组:

# _io.py:118
def map_output_updates(output_channels, tasks, cached=False) -> Iterator[dict]:
    output_tasks = [
        (t, ww) for t, ww in tasks
        if (not t.config or TAG_HIDDEN not in t.config.get("tags", EMPTY_SEQ))
        and ww[0][0] != ERROR and ww[0][0] != INTERRUPT   # 129 过滤隐藏/报错/中断的任务
    ]
    if not output_tasks:
        return                                            # 132 这步没有可输出的任务
    updated: list[tuple[str, Any]] = []
    for task, writes in output_tasks:
        rtn = next((value for chan, value in writes if chan == RETURN), MISSING)  # 135
        if rtn is not MISSING:
            updated.append((task.name, rtn))              # 137 节点用 RETURN 显式指定返回值
        elif isinstance(output_channels, str):
            updated.extend((task.name, value) for chan, value in writes if chan == output_channels)
        elif any(chan in output_channels for chan, _ in writes):   # 142 该任务写了输出通道
            ...把该任务写的输出通道打包成 {通道: 值} ...
    grouped: dict[str, Any] = {t.name: [] for t, _ in output_tasks}  # 164 按节点名分组
    for node, value in updated:
        grouped[node].append(value)
    ...
    yield grouped                                         # 174 产出 {节点名: 该节点这步的更新}
过滤 ERROR / INTERRUPT / TAG_HIDDEN报错的、中断的、被标记隐藏的任务不进 updates 输出。所以 stream updates 你看到的都是"正常节点的正常产出",脏活被过滤掉。
chan == RETURN节点可以写一个特殊 RETURN 通道来显式指定"我这步的更新长这样"(覆盖默认按通道分组)。函数式 API 的 @task 返回值就走这个(Day 47)。
grouped = {节点名: [...]}核心区别:updates 按节点名分组。所以 stream(mode="updates") 得到的是 {"agent": {...agent这步写的...}}——你能知道"是谁改的",这正是 values 模式丢失的信息。
数据结构:同一步,values 与 updates 的两种视角 values 模式(读通道全量) {"messages":[...全部], "count":3} updates 模式(按节点分组增量) {"agent": {"count":3}} 同一个超步:agent 节点把 count 从 2 写成 3 values 看"现在全貌",updates 看"谁改了啥"
图注:values 读通道全量快照、丢失"谁改的";updates 按节点名分组、只给增量、保留来源。
L06

values vs updates:为什么要有两套映射

🎯 设计取舍①:一套输出映射不够吗,为什么拆成 values / updates? 因为两种消费场景根本对立。values(全量快照)适合"我要当前完整状态"——比如 UI 每步重渲染整个对话、或者你只关心最终结果。updates(按节点增量)适合"我要知道刚才是哪个节点做了什么"——比如日志、审计、给用户显示"正在调用工具 X"。用一套映射没法同时满足:全量丢了"谁改的"来源,增量又没有"完整现状"。所以 LangGraph 干脆提供两套,stream_mode 让你按需选,甚至同时要(传 list)。代价是 _io.py 里维护两套逻辑,但换来的是流式消费的极大灵活性。
🎯 设计取舍②:为什么 values 读通道、updates 读 task.writes? 注意两个函数的入参不同:map_output_values(..., channels)channels 读(已合并的全量),map_output_updates(..., tasks)tasks 的 writes 读(合并前每个任务各自的原始写入)。这不是随意的——全量必须读"合并后"的通道才一致;而"谁改了什么"这个信息在合并后就丢了(apply_writes 把多个任务的写入揉进了通道),只能在合并前从各 task 的 writes 里保留。所以 updates 必须在 apply_writes 之前、拿着原始 task.writes 算。数据在哪个阶段有,就在哪个阶段取——这决定了两个映射的调用时机和数据源。

👶 小白:那 invoke()(不是 stream)返回的是 values 还是 updates?

👨‍🏫 老师:是 values 的最后一份invoke 内部其实就是跑 stream(mode="values"),把流吐出的每一份全量快照都接住,返回最后一份——也就是图跑完时的完整最终状态。所以 invoke 给你的是"终态全貌",不是"每步增量"。想看过程就得用 stream + updates。

L07

map_command:Command 作为特殊输入

除了普通 dict,图还能接受 Command 作为输入(人在环恢复、跨节点跳转)。它由 map_command_io.py:56)翻译成写入:

# _io.py:56
def map_command(cmd: Command) -> Iterator[tuple[str, str, Any]]:
    if cmd.graph == Command.PARENT:
        raise InvalidUpdateError("There is no parent graph")   # 59 顶层图没有父图
    if cmd.goto:                                               # 60 要跳到某节点/发 Send
        sends = cmd.goto if isinstance(cmd.goto, (tuple, list)) else [cmd.goto]
        for send in sends:
            if isinstance(send, Send):
                yield (NULL_TASK_ID, TASKS, send)              # 67 Send → 写 TASKS(Day 23 那招)
            elif isinstance(send, str):
                yield (NULL_TASK_ID, f"branch:to:{send}", START)  # 69 跳到节点名 → 写它的入边通道
    if cmd.resume is not None:
        yield (NULL_TASK_ID, RESUME, cmd.resume)               # 74 恢复值 → 写 RESUME 通道
    if cmd.update:
        for k, v in cmd._update_as_tuples():
            yield (NULL_TASK_ID, k, v)                          # 77 状态更新 → 逐字段写
cmd.goto → branch:to:{send}跳到某个节点,本质是往那个节点的"入边通道"(命名规则 branch:to:节点名)写一个 START 值,从而在下一步触发它。跳转又一次被编码成写通道。
cmd.resume → RESUME 通道人在环恢复:把你 Command(resume=...) 里的值写进 RESUME 通道,被中断的节点下次重跑时从这里取到人类的答复(阶段 7)。
NULL_TASK_ID这些写入都挂在"空任务 id"下——因为它们不是某个节点产生的,而是从图外部(你的 Command)注入的。apply_writes 里对 NULL_TASK_ID 有特殊处理(不 bump_step)。
💡 本质:连"外部指令"都被统一成通道写入 又是同一个主题:goto(跳转)、resume(恢复)、update(改状态)三种看似不同的"控制指令",map_command 全把它们翻译成 (通道, 值) 写入。于是引擎不需要为 Command 单开一套处理逻辑——它和普通输入一样,就是"一批待写入",流进同一套 apply_writes。输入映射、Command 映射、节点写入、Send,殊途同归成了一件事:写通道。
L08

今日小结 + 动手 + 明日预告

🧠 今天你应该能回答

  • 图的两道门分别做什么?(进门 map_input 把输入拆成通道写入;出门 map_output 读通道拼结果)
  • 输入里多写的字段会怎样?(不在 input_channels 就 warning 丢弃,进不了状态)
  • read_channel 读到从没写过的通道返回什么?(catch=True 时返回 None,不报错)
  • values 和 updates 的核心区别?(values=读通道全量快照;updates=按节点名分组的增量)
  • 为什么 values 读 channels、updates 读 task.writes?(全量要合并后一致;"谁改的"只在合并前存在)
  • invoke 返回的是什么?(values 流的最后一份=终态全貌)
  • Command 怎么被处理?(map_command 把 goto/resume/update 都翻译成通道写入)

✋ 10 分钟动手

# 1. 读 _io.py 全貌(就 175 行,值得通读)
sed -n '1,175p' libs/langgraph/langgraph/pregel/_io.py

# 2. 对比 values 与 updates 两种流
python -c "
from langgraph.graph import StateGraph, START, END
from typing import TypedDict
class S(TypedDict): count: int
g = StateGraph(S)
g.add_node('a', lambda s: {'count': s['count']+1})
g.add_node('b', lambda s: {'count': s['count']+10})
g.add_edge(START,'a'); g.add_edge('a','b'); g.add_edge('b',END)
app = g.compile()
print('== values =='); [print(c) for c in app.stream({'count':0}, stream_mode='values')]
print('== updates =='); [print(c) for c in app.stream({'count':0}, stream_mode='updates')]
"

# 3. 试试多写一个 schema 外字段,观察 warning
python -c "
import logging; logging.basicConfig(level=logging.WARNING)
from langgraph.graph import StateGraph, START, END
from typing import TypedDict
class S(TypedDict): x: int
g=StateGraph(S); g.add_node('n', lambda s:{'x':1}); g.add_edge(START,'n'); g.add_edge('n',END)
print(g.compile().invoke({'x':0,'ghost':9}))   # ghost 被忽略
"
明天预告 · Day 25:Day 22 快路径和调度路径都调了 run_with_retry——今天一直没展开。明天进 _retry.pytypes.pyRetryPolicy,看清一个节点失败后怎么按"初始间隔 × 退避因子 + 抖动"重试、retry_on 怎么决定哪些异常值得重试、以及超时(TimeoutPolicy)和重试怎么配合。生产可靠性的关键一环。
← Day 23 PregelNode 读写 Day 25 · 重试与超时 →