流式 5 模式:同一次运行,五种"看戏"姿势
前面 Agent 都用 invoke——跑完才拿全部结果。但产品里你要边跑边看:给用户逐字打字、给运维实时日志、给前端节点进度。LangGraph 的 stream() 用 stream_mode 提供五种"看戏"姿势:values(看整个舞台)、updates(看谁动了)、messages(看台词逐字)、custom(看你自己埋的点)、debug(看后台运行日志)。今天看这些模式在 Pregel 引擎里各自从哪冒出来。
values 是每一幕结束后拍一张全景剧照(当前完整状态);updates 是节目单上标"这一幕哪个演员上台做了什么"(只有增量);messages 是字幕机逐字滚动台词(LLM token);custom 是你在剧本里自己插的旁白纸条;debug 是后台场记本记录每个走位。同一场戏,五台摄像机各拍各的。而且——你可以同时开好几台。为什么要流式:invoke 的痛
invoke,用户对着转圈等 30 秒、啥反馈没有,体验极差。而且你也看不到"它现在在调哪个工具、想到哪一步了",出问题无从排查。stream() 把"一次性返回终值"改成"每产生一点结果就 yield 一点"。它是个生成器:图在 Pregel 引擎里每走一个超步(D19-20)、每个节点每产出一次输出,就往一个队列里 put 一条数据,stream() 这头就 yield 出来。你 for chunk in graph.stream(...) 就能实时接住。关键认知:invoke 底层就是 stream 收集完。流式不是附加功能,而是引擎的原生工作方式——invoke 只是"把流收集成最终值"的特例。
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 主角) |
custom | 你 writer(x) 塞的任意数据 | 工具里报进度 "已处理 3/10" |
debug | checkpoints + tasks 事件 | 排查执行细节 |
values 每步吐完整状态——信息全,但 20 轮对话里 messages 越滚越大,每步都全量重发,流量爆炸。updates 只吐本步增量(哪个节点写了什么)——省流量,适合"我只想知道进度"。前者适合"状态不大但要全量",后者适合"状态大只要变化"。让用户按需选,而不是强塞一种。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 推进流。(namespace, mode) 标签。消费端看标签分拣。这样"同时要多种模式""区分主图子图"都用同一套机制搞定——用标签换掉多路复用的复杂度。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,不订阅就 连函数都不调用。这是"把昂贵计算推迟到确认需要时"的惰性求值,让未订阅模式真正零成本。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 数据,一字不改。type 字段能精确标出"这条到底是啥"。tasks 数据本身由 map_debug_tasks/map_debug_task_results 生成(debug.py:41、debug.py:106),checkpoints 由 map_debug_checkpoint(debug.py:144)生成——它们才是"原始数据源",debug 只是转包。
custom 与 messages:靠"挂 handler / 给 writer"接入
values/updates/debug 是引擎在超步循环里主动吐的。但 custom 和 messages 数据源在节点内部,得用不同机制接。看 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(...) 不报错、只是没人接。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 通道)。多模式并存 + 今日小结
你可以一次订多个模式。stream() 开头把参数归一成集合(main.py:2571):
# main.py:2571
if isinstance(stream_mode, str):
stream_modes = {stream_mode} # 单个字符串 → 单元素集合
else:
stream_modes = set(stream_mode) # 传 list → 集合
...
stream_mode="values")时,stream() yield 的是裸 data。订多个(stream_mode=["updates","messages"])时,yield 的是 (mode, data) 二元组——你靠第一个元素分辨这条是哪种。这正是 L03 那个 (ns, mode, data) 三元组在出口处按需"脱壳"的结果。StreamProtocol.modes 集合决定了哪些 chunk 会被留下。🧠 今天你应该能回答
- 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
_messages.py 的 StreamMessagesHandler。看它如何挂在模型回调上、on_llm_new_token 如何把每个 token 变成流、去重(seen)怎么防止"流式的 token"和"节点返回的完整消息"重复计数、以及 v2 的 content-block 事件。