输入输出映射:图的进门与出门
Day 23 看的是"节点内部"的读写,今天看"整张图的边界":你 invoke({"messages": [...]}) 传的字典,怎么变成第一批 channel 写入进门?图跑完又怎么从 channel 读出结果出门?以及 stream 的 values 和 updates 两种模式到底差在哪?全在 _io.py 这 175 行里——map_input、map_output_values、map_output_updates、map_command。图小但极高频,每次运行都从这里进出。
图的两道门:进门写通道,出门读通道
invoke(输入) 和拿 返回值。但引擎内部只认 channel。所以图有两道"翻译门":进门把你的输入拆成"写哪些 channel"(map_input),出门把最终 channel 值拼回你要的形状(map_output_*)。这两道门定义了"图的输入/输出 schema"到底怎么落地。回忆 Day 20 主循环:第一步之前有个 _first() 把输入喂进图,用的就是 map_input;每步 after_tick 里 _emit("values", map_output_values, ...) 产出流式输出,用的就是 map_output_values。今天把这两道门拆开。
map_input,读靠 map_output_values(读全量)或 map_output_updates(读增量)。图的输入 schema 和输出 schema 之所以能不同(Day 11),就是因为进出两道门是独立的两套映射。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 里触发入口节点。messages,但你 invoke({"messages": [...], "user_id": 123}),那个 user_id 会因为"不在 input_channels 里"被 logger.warning 丢掉——它不会进入状态。新手常踩:想通过输入 dict 塞点"运行时上下文"进去,结果发现节点里读不到。正确做法是把它声明进 state schema(成为合法输入通道),或者走 config/context(Day 59 runtime)而不是塞进 state 输入。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 的输出总是干净一致的。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一个特殊标记:强制产出(不检查是否写过输出通道)。比如最后一步收尾时确保把最终状态吐出来。{messages, count},输出通道就是这两个。第 3 步 count 从 2 变 3、messages 没动:map_output_values 检查到 count(输出通道)被写了 → 读全量 → 产出 {"messages": [...全部历史...], "count": 3}。注意 messages 虽没变,也被完整带出——这就是 "values=全量快照" 的含义。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 vs updates:为什么要有两套映射
stream_mode 让你按需选,甚至同时要(传 list)。代价是 _io.py 里维护两套逻辑,但换来的是流式消费的极大灵活性。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。
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)。map_command 全把它们翻译成 (通道, 值) 写入。于是引擎不需要为 Command 单开一套处理逻辑——它和普通输入一样,就是"一批待写入",流进同一套 apply_writes。输入映射、Command 映射、节点写入、Send,殊途同归成了一件事:写通道。今日小结 + 动手 + 明日预告
🧠 今天你应该能回答
- 图的两道门分别做什么?(进门 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 被忽略
"
run_with_retry——今天一直没展开。明天进 _retry.py 和 types.py 的 RetryPolicy,看清一个节点失败后怎么按"初始间隔 × 退避因子 + 抖动"重试、retry_on 怎么决定哪些异常值得重试、以及超时(TimeoutPolicy)和重试怎么配合。生产可靠性的关键一环。