Day 57 / 共 60 天 · 阶段 9 预制件与流式

流式 5 模式:同一次运行,五种"看戏"姿势

前面 Agent 都用 invoke——跑完才拿全部结果。但产品里你要边跑边看:给用户逐字打字、给运维实时日志、给前端节点进度。LangGraph 的 stream()stream_mode 提供五种"看戏"姿势:values(看整个舞台)、updates(看谁动了)、messages(看台词逐字)、custom(看你自己埋的点)、debug(看后台运行日志)。今天看这些模式在 Pregel 引擎里各自从哪冒出来。

📍 阶段 9 · 预制件与流式(6 天)你在这里
D53 总览 D54 逐行① D55 ToolNode D56 校验 D57 流式5模式 D58 流式底层
💡 用一个类比先兜住今天 把图的一次运行想象成一场话剧values 是每一幕结束后拍一张全景剧照(当前完整状态);updates 是节目单上标"这一幕哪个演员上台做了什么"(只有增量);messages字幕机逐字滚动台词(LLM token);custom 是你在剧本里自己插的旁白纸条debug 是后台场记本记录每个走位。同一场戏,五台摄像机各拍各的。而且——你可以同时开好几台。
L01

为什么要流式:invoke 的痛

🤔 痛点Agent 调三次工具、想了 30 秒才出答案。用 invoke,用户对着转圈等 30 秒、啥反馈没有,体验极差。而且你也看不到"它现在在调哪个工具、想到哪一步了",出问题无从排查。
💡 本质stream() 把"一次性返回终值"改成"每产生一点结果就 yield 一点"。它是个生成器:图在 Pregel 引擎里每走一个超步(D19-20)、每个节点每产出一次输出,就往一个队列里 put 一条数据,stream() 这头就 yield 出来。你 for chunk in graph.stream(...) 就能实时接住。

关键认知:invoke 底层就是 stream 收集完。流式不是附加功能,而是引擎的原生工作方式——invoke 只是"把流收集成最终值"的特例。

👶 一句话invoke = 等做完端一大盘上桌;stream = 做好一道先端一道。同一个厨房,出菜方式不同。
L02

StreamMode 全家福

所有模式定义在一个 Literal 里 types.py:120

# types.py:120
StreamMode = Literal[
    "values", "updates", "checkpoints", "tasks", "debug", "messages", "custom"
]
"""How the stream method should emit outputs.
- "values":   每步后 emit 整个 state(含 interrupt)
- "updates":  只 emit 节点名 + 该节点返回的增量更新
- "custom":   节点内用 StreamWriter 手动 emit 的自定义数据
- "messages": LLM 消息 token-by-token + metadata
- "checkpoints": 每次生成检查点时 emit(格式同 get_state())
- "tasks":    任务开始/结束事件(含结果和错误)
- "debug":    emit checkpoints + tasks(调试用)
"""
模式你看到什么典型用途
values每步后完整状态快照需要每步全量数据(如实时同步整个 state)
updates{节点名: 该节点的增量}看"哪个节点动了、写了啥"(最常用于进度)
messages(消息chunk, metadata)给用户逐字打字(D58 主角)
customwriter(x) 塞的任意数据工具里报进度 "已处理 3/10"
debugcheckpoints + tasks 事件排查执行细节
💡 设计取舍①:为什么 values 和 updates 都要有,不能只留一个?看似冗余,实则是带宽与信息量的权衡values 每步吐完整状态——信息全,但 20 轮对话里 messages 越滚越大,每步都全量重发,流量爆炸。updates 只吐本步增量(哪个节点写了什么)——省流量,适合"我只想知道进度"。前者适合"状态不大但要全量",后者适合"状态大只要变化"。让用户按需选,而不是强塞一种。
L03

StreamChunk:流里流的是什么

不管哪种模式,塞进流的都是同一种结构——一个三元组 protocol.py:272

# protocol.py:272
StreamChunk = tuple[tuple[str, ...], str, Any]
#              (   namespace   ,  mode ,  data)

# protocol.py:275  流本身是一个"可调用 + 带 modes 集合"的对象
class StreamProtocol:
    __slots__ = ("modes", "__call__")
    modes: set[StreamMode]
    __call__: Callable[[Self, StreamChunk], None]
    def __init__(self, __call__, modes):
        self.__call__ = __call__
        self.modes = modes
namespace: tuple[str,...]第一元素:命名空间——这条数据来自主图还是哪个子图(D50)。主图是空元组 ()
mode: str第二元素:模式名——"values"/"updates"/"messages"...。消费端靠它区分这条数据是哪种。
data: Any第三元素:真正的载荷——values 模式就是状态 dict,messages 模式就是 (message, metadata)
StreamProtocol.modes流对象自己带一个 modes 集合:记录"用户要了哪些模式"。emit 时先查这个集合,不要的模式直接丢弃(L04 会看到)。
__call__流对象是可调用的:引擎内部 self.stream((ns, mode, data)) 就是把一条 chunk 推进流。
💡 本质:一条通道传所有模式,靠 (ns, mode) 分拣不给每种模式开一条独立管道,而是所有模式共用一条流,每条数据自带 (namespace, mode) 标签。消费端看标签分拣。这样"同时要多种模式""区分主图子图"都用同一套机制搞定——用标签换掉多路复用的复杂度
L04

values / updates 在引擎哪里被 emit

核心在 PregelLoop 的 _emit_loop.py:1380)——所有模式都经过这道闸:

# _loop.py:1380
def _emit(self, mode, values, *args, **kwargs):
    if self.stream is None:
        return
    debug_remap = mode in ("checkpoints", "tasks") and "debug" in self.stream.modes
    if mode not in self.stream.modes and not debug_remap:
        return                                            # ★ 用户没要这个模式 → 直接不干活
    for v in values(*args, **kwargs):                     # values 是个"惰性生成"函数
        if mode in self.stream.modes:
            self.stream((self.checkpoint_ns, mode, v))    # ★ 推进流:(ns, mode, data)
        if debug_remap:
            self.stream((self.checkpoint_ns, "debug", {...}))  # L05

谁来调 _emit?以 updates 为例,在 output_writes_loop.py:1415)里:

# _loop.py:1441 (节点写完后)
if "updates" in stream_modes:
    self._emit("updates", lambda: iter(interrupts))       # 中断也当 update 吐
if "values" in stream_modes:
    current_values = read_channels(self.channels, self.output_keys)
    ...
    self._emit("values", lambda: iter([current_values]))  # 读当前全部通道值 → 吐整个状态
# :1450 普通写(非中断)
self._emit("updates", map_output_updates, self.output_keys, [(task, writes)], cached)
mode not in self.stream.modes → return惰性核心:用户没要这个模式,_emit 直接返回、连数据都不生成。所以"没订阅的模式零开销"。
values 是个函数(lambda / map_output_*)传的是"怎么算这条数据"的函数,不是算好的数据。只有确认要吐时才 values(*args) 求值——不订阅就不计算
values 模式: read_channels(...)吐 values 时,从所有通道读出完整当前状态再吐。这就是"每步全量"的来源——也解释了它为什么费流量。
updates 模式: map_output_updates吐 updates 时用 map_output_updates_io.py:118)只算本步各任务写了啥,包成 {节点名: 增量}
💡 设计取舍②:为什么 _emit 收的是"生成数据的函数"而不是"数据本身"?如果直接传算好的数据,那么无论用户订不订阅,都得先把数据算出来——values 模式要 read_channels 整个状态,很贵。改成传 lambda: ... / map_output_* 这样的惰性函数后,_emit 先判断 mode in self.stream.modes,不订阅就 连函数都不调用。这是"把昂贵计算推迟到确认需要时"的惰性求值,让未订阅模式真正零成本。
L05

debug:不是新数据,是 checkpoints/tasks 的"套壳"

回看 _emit 里的 debug_remap_loop.py:1389):

# _loop.py:1389
debug_remap = mode in ("checkpoints", "tasks") and "debug" in self.stream.modes
...
if debug_remap:
    self.stream((self.checkpoint_ns, "debug", {
        "step": self.step - 1 if mode == "checkpoints" else self.step,
        "timestamp": datetime.now(timezone.utc).isoformat(),
        "type": "checkpoint" if mode == "checkpoints"
                else "task_result" if "result" in v else "task",
        "payload": v,                                     # ★ 原样包住 checkpoints/tasks 数据
    }))
debug = checkpoints + tasks关键理解:debug 不产生独立数据。当引擎要吐 checkpoints 或 tasks 数据、而用户订了 debug 时,就额外把同一份数据套一层壳(加 step/timestamp/type)吐成 "debug"。
step / timestamp / type套壳加的元信息:第几步、什么时间、是 checkpoint 还是 task 还是 task_result。调试时看时间线用。
payload: v壳里的 payload 就是原始 checkpoints/tasks 数据,一字不改。
💡 本质:debug 是"聚合视图"用户订 debug,其实是"我懒得分别订 checkpoints 和 tasks,给我一个统一加了时间戳的调试流"。源码没为 debug 单独产数据,而是在吐 checkpoints/tasks 时顺手多吐一份带壳版复用已有数据、只加一层视图——避免重复计算。这也是为什么 debug 模式的 type 字段能精确标出"这条到底是啥"。

tasks 数据本身由 map_debug_tasks/map_debug_task_results 生成(debug.py:41debug.py:106),checkpoints 由 map_debug_checkpointdebug.py:144)生成——它们才是"原始数据源",debug 只是转包。

L06

custom 与 messages:靠"挂 handler / 给 writer"接入

values/updates/debug 是引擎在超步循环里主动吐的。但 custommessages 数据源在节点内部,得用不同机制接。看 stream() 的准备段(main.py:2810):

# main.py:2810  messages 模式:挂一个回调 handler
if "messages" in stream_modes:
    ns_ = config[CONF].get(CONFIG_KEY_CHECKPOINT_NS)
    messages_handler_cls = (StreamMessagesHandlerV2 if use_stream_messages_v2
                            else StreamMessagesHandler)
    run_manager.inheritable_handlers.append(
        messages_handler_cls(stream.put, subgraphs,       # ← 把 stream.put 交给 handler
            parent_ns=tuple(ns_.split(NS_SEP)) if ns_ else None))

# main.py:2841  custom 模式:给节点一个 stream_writer
if "custom" in stream_modes:
    def stream_writer(c):
        stream.put((tuple(get_config()[CONF][CONFIG_KEY_CHECKPOINT_NS].split(NS_SEP)[:-1]),
                    "custom", c))                          # ← 把 c 包成 custom chunk
messages: 挂 StreamMessagesHandlermessages 数据来自模型 token 回调。引擎挂一个回调 handler(D58 主角),模型每吐一个 token,handler 就 stream.put((ns,"messages",(chunk,meta)))
inheritable_handlers.appendhandler 是可继承的:子图、工具里的模型调用也能被同一个 handler 捕获。
custom: 给节点 stream_writercustom 数据是你在节点里主动写的。引擎造一个 stream_writer(c) 注入进 runtime,你在节点里 writer("进度 3/10"),它就包成 custom chunk 推进流。
没订 custom 时 writer 是 no-op若没订 custom,stream_writer 会是个空函数(见 main.py 后续 else 分支)——你节点里的 writer(...) 不报错、只是没人接。
⚠️ 边界:custom 的 writer 在不订阅时是"哑的",别依赖它做副作用源码里 stream_writer 在没订 custom 时被赋成 def stream_writer(c): pass。所以如果你在节点里写 writer(重要数据) 却指望它一定生效——一旦调用方没传 stream_mode="custom",这些调用静默丢弃StreamWriter 的文档(types.py:136)明确说它"not using stream_mode='custom' 时是 no-op"。custom 只用于"锦上添花的进度提示",不能拿它传关键状态(关键状态要走 state 通道)。
L07

多模式并存 + 今日小结

你可以一次订多个模式。stream() 开头把参数归一成集合(main.py:2571):

# main.py:2571
if isinstance(stream_mode, str):
    stream_modes = {stream_mode}          # 单个字符串 → 单元素集合
else:
    stream_modes = set(stream_mode)       # 传 list → 集合
...
💡 本质:传 list 就变多模式,输出自动带上 mode 标签订单个模式(如 stream_mode="values")时,stream() yield 的是裸 data。订多个(stream_mode=["updates","messages"])时,yield 的是 (mode, data) 二元组——你靠第一个元素分辨这条是哪种。这正是 L03 那个 (ns, mode, data) 三元组在出口处按需"脱壳"的结果。StreamProtocol.modes 集合决定了哪些 chunk 会被留下。
数据结构:一条流,(ns, mode, data) 分拣 StreamProtocol modes = {updates, messages} ((),"values",…)✗ 丢弃 ((),"updates",…)✓ 留 ((),"messages",…)✓ 留 ((),"debug",…)✗ 丢弃 for chunk in stream(): → (mode, data)
图注:只有 mode 命中 modes 集合的 chunk 才被 yield,其余丢弃。
控制流:五种模式的数据从哪来 Pregel 超步循环 values_emit读通道 updatesmap_output debug套壳ckpt/task custom节点writer messages回调handler 实线=引擎主动吐;虚线=节点内部/模型回调注入
图注:三种由引擎循环直接 emit,两种从节点内/模型回调"倒灌"进流。

🧠 今天你应该能回答

  • invoke 和 stream 的关系?(invoke 是 stream 收集完的特例)
  • StreamChunk 三元组是什么?(namespace, mode, data)
  • values 和 updates 差别?(全量状态 vs 只本步增量;后者省流量)
  • _emit 为什么收"生成函数"而非数据?(惰性求值,未订阅模式零计算成本)
  • debug 模式的数据从哪来?(不独立产生,是 checkpoints/tasks 套壳加时间戳)
  • custom 和 messages 怎么接入?(custom 给节点 writer;messages 挂回调 handler)
  • 为什么 custom writer 不能传关键状态?(没订 custom 时它是 no-op,静默丢弃)

✋ 10 分钟动手

# 1. 核心 emit 逻辑逐行读
sed -n '1380,1462p' libs/langgraph/langgraph/pregel/_loop.py     # _emit + output_writes
sed -n '2841,2860p' libs/langgraph/langgraph/pregel/main.py      # custom writer

# 2. 亲手对比五种模式
python - <<'PY'
from langgraph.graph import StateGraph, START, END
from typing import TypedDict
class S(TypedDict):
    n: int
g = StateGraph(S)
g.add_node("a", lambda s: {"n": s["n"]+1})
g.add_edge(START,"a"); g.add_edge("a",END)
app = g.compile()
print("== values =="); [print(c) for c in app.stream({"n":0}, stream_mode="values")]
print("== updates =="); [print(c) for c in app.stream({"n":0}, stream_mode="updates")]
print("== 多模式 =="); [print(c) for c in app.stream({"n":0}, stream_mode=["updates","debug"])]
PY
明天预告 · Day 58:钻到 messages 模式的底层——_messages.pyStreamMessagesHandler。看它如何挂在模型回调上、on_llm_new_token 如何把每个 token 变成流、去重(seen)怎么防止"流式的 token"和"节点返回的完整消息"重复计数、以及 v2 的 content-block 事件。
← Day 56 校验 Day 58 · 流式底层 →