《LangGraph 设计与实现》完整目录
- 前言
- 第1章 为什么需要理解 LangGraph
- 第2章 架构总览
- 第3章 StateGraph 图构建 API
- 第4章 Channel 状态管理与 Reducer
- 第5章 图编译:从 StateGraph 到 CompiledStateGraph
- 第6章 Pregel 执行引擎
- 第7章 任务调度与并行执行
- 第8章 Checkpoint 持久化
- 第9章 中断与人机协作
- 第10章 Command 与高级控制流
- 第11章 子图与嵌套
- 第12章 Send 与动态并行
- 第13章 流式输出与调试(当前)
- 第14章 Runtime 与 Context
- 第15章 Store 与长期记忆
- 第16章 预构建 Agent 组件
- 第17章 多 Agent 模式实战
- 第18章 设计模式与架构决策
第13章 流式输出与调试
13.1 引言
在构建 LLM 应用时,流式输出(streaming)不仅是用户体验的刚需,更是生产系统的可观测性基石。用户期望看到 AI 回复的"打字机效果",而开发者需要实时监控图执行的每一步——哪个节点正在运行、状态如何变化、中间结果是什么。LangGraph 通过七种 StreamMode 和精心设计的 StreamPart 类型体系,提供了从粗粒度到细粒度的完整流式输出方案。
LangGraph 1.1.6 引入了 v2 Stream API,它将所有流式数据统一为带类型标签的 StreamPart 字典,���底解决了 v1 中多种 stream_mode 混合使用时类型模糊的问题。v2 API 使得每个流式事件都是自描述的:你可以通过 part["type"] 直接判断事件类型,无需依赖上下文推断。
本章将从 StreamMode 的枚举定义出发,逐一分析七种模式的语义和实现,深入 StreamProtocol 和 StreamMessagesHandler 的源码,揭示流式输出在 Pregel 循环中的注入点和数据流转路径。
:::tip 本章要点
- 七种 StreamMode 的语义区别——values/updates/custom/messages/checkpoints/tasks/debug
- v2 Stream API 的 StreamPart 类型体系——带类型标签的联合类型
- StreamWriter 的注入机制——如何在节点中发射自定义流式数据
- StreamMessagesHandler 的回调实现——LLM token 级别的流式捕获
- StreamProtocol 在 Pregel 循环中的集成方式 :::
13.2 StreamMode 枚举
13.2.1 七种模式定义
StreamMode 定义在 langgraph/types.py 中,是一个字面量联合类型:
StreamMode = Literal[
"values", "updates", "checkpoints", "tasks", "debug", "messages", "custom"
]
每种模式对应一种输出粒度和视角:
| StreamMode | 输出内容 | 触发时机 | 典型消费者 |
|---|---|---|---|
values | 完整状态快照 | 每个超步结束后 | 前端展示完整状态 |
updates | 节点名 + 该节点的输出 | 每个节点执行后 | 调试、日志 |
custom | 用户自定义数据 | 节点内调用 StreamWriter | 进度条、中间结果 |
messages | LLM 消息 token | LLM 流式生成时 | 打字机效果 |
checkpoints | 检查点快照 | 检查点创建时 | 持久化监控 |
tasks | 任务开始/结束事件 | 任务生命周期变化 | 执行监控 |
debug | checkpoints + tasks | 同上两者 | 开发调试 |
13.2.2 模式组合
stream 方法支持同时订阅多种模式:
# v1 API:多模式返回元组
for mode, data in graph.stream(input, stream_mode=["values", "messages"]):
if mode == "values":
print(f"State: {data}")
elif mode == "messages":
print(f"Token: {data}")
# v2 API:统一的 StreamPart
for part in graph.stream(input, version="v2"):
if part["type"] == "values":
print(f"State: {part['data']}")
elif part["type"] == "messages":
msg, meta = part["data"]
print(f"Token: {msg.content}")
13.3 StreamPart 类型体系
13.3.1 v2 API 的核心创新
v2 Stream API 将每个流式事件包装为一个带 type 字段的 TypedDict,使得类型判别成为可能:
StreamPart = TypeAliasType(
"StreamPart",
ValuesStreamPart[OutputT]
| UpdatesStreamPart
| MessagesStreamPart
| CustomStreamPart
| CheckpointStreamPart[StateT]
| TasksStreamPart
| DebugStreamPart[StateT],
type_params=(StateT, OutputT),
)
每个具体的 StreamPart 类型都包含三个公共字段:
classDiagram
class StreamPart {
<<TypedDict>>
+type: str
+ns: tuple[str, ...]
+data: Any
}
class ValuesStreamPart {
+type: "values"
+ns: tuple[str, ...]
+data: OutputT
+interrupts: tuple[Interrupt, ...]
}
class UpdatesStreamPart {
+type: "updates"
+ns: tuple[str, ...]
+data: dict[str, Any]
}
class MessagesStreamPart {
+type: "messages"
+ns: tuple[str, ...]
+data: tuple[AnyMessage, dict]
}
class CustomStreamPart {
+type: "custom"
+ns: tuple[str, ...]
+data: Any
}
class TasksStreamPart {
+type: "tasks"
+ns: tuple[str, ...]
+data: TaskPayload | TaskResultPayload
}
class CheckpointStreamPart {
+type: "checkpoints"
+ns: tuple[str, ...]
+data: CheckpointPayload
}
class DebugStreamPart {
+type: "debug"
+ns: tuple[str, ...]
+data: DebugPayload
}
StreamPart <|-- ValuesStreamPart
StreamPart <|-- UpdatesStreamPart
StreamPart <|-- MessagesStreamPart
StreamPart <|-- CustomStreamPart
StreamPart <|-- TasksStreamPart
StreamPart <|-- CheckpointStreamPart
StreamPart <|-- DebugStreamPart
13.3.2 ns 字段:命名空间追踪
每个 StreamPart 都包含 ns(namespace)字段,它是一个字符串元组,标识事件来自图的哪个层级:
():来自顶层图("subgraph_name",):来自名为 subgraph_name 的子图("outer", "inner"):来自嵌套两层的子图
这使得在启用 subgraphs=True 时,消费者可以精确识别每个事件的来源。
13.3.3 ValuesStreamPart
class ValuesStreamPart(TypedDict, Generic[OutputT]):
type: Literal["values"]
ns: tuple[str, ...]
data: OutputT
interrupts: tuple[Interrupt, ...]
values 模式在每个超步结束后发射完整的状态快照。data 的类型与图的输出类型一致。interrupts 字段记录了该步中发生的中断——这是 v2 API 特有的增强,v1 中中断信息需要通过 __interrupt__ 键间接获取。
13.3.4 UpdatesStreamPart
class UpdatesStreamPart(TypedDict):
type: Literal["updates"]
ns: tuple[str, ...]
data: dict[str, Any]
updates 模式在每个节点执行后发射该节点的输出。data 是一个字典,键是节点名,值是节点返回的更新。如果多个节点在同一超步并行执行,每个节点的更新会单独发射。
13.3.5 MessagesStreamPart
class MessagesStreamPart(TypedDict):
type: Literal["messages"]
ns: tuple[str, ...]
data: tuple[AnyMessage, dict[str, Any]]
messages 模式捕获 LLM 的 token 级流式输出。data 是一个二元组:消息对象(通常是 AIMessageChunk)和元数据字典。元数据包含 langgraph_step、langgraph_node、langgraph_triggers 等上下文信息。
13.3.6 CustomStreamPart
class CustomStreamPart(TypedDict):
type: Literal["custom"]
ns: tuple[str, ...]
data: Any
custom 模式承载用户通过 StreamWriter 发射的任意数据。这是最灵活的模式——你可以用它发送进度百分比、中间计算结果、或任何自定义结构。
13.4 StreamWriter 注入机制
13.4.1 定义与签名
StreamWriter = Callable[[Any], None]
StreamWriter 的类型定义极为简洁——它就是一个接收任意参数、无返回值的可调用对象。它总是作为关键字参数注入到节点函数中:
def my_node(state: State, writer: StreamWriter) -> dict:
writer({"progress": 0.5}) # 发射自定义流式数据
# ... 执行业务逻辑 ...
writer({"progress": 1.0})
return {"result": "done"}
在 LangGraph 1.1.6 中,StreamWriter 也通过 Runtime 对象提供:
def my_node(state: State, runtime: Runtime) -> dict:
runtime.stream_writer({"progress": 0.5})
return {"result": "done"}
13.4.2 注入流程
StreamWriter 的注入发生在任务准备阶段。当创建 PregelExecutableTask 时,框架会检查节点函数的参数签名,如果发现 writer 或 stream_writer 参数,就注入一个绑定到当前流式管道的写入器:
sequenceDiagram
participant Node as 节点函数
participant SW as StreamWriter
participant SP as StreamProtocol
participant Queue as 输出队列
Node->>SW: writer({"progress": 0.5})
SW->>SP: __call__((ns, "custom", data))
SP->>Queue: 写入 StreamChunk
Queue-->>Client: 消费者读取
13.4.3 无订阅时的空操作
当调用方没���订阅 custom 模式时,StreamWriter 会变成一个空操作(no-op):
def _no_op_stream_writer(_: Any) -> None: ...
这个设计确保了节点代码不需要检查 stream_mode 是否包含 "custom"——写入操作总是安全的,只是在没有订阅者时数据会被静默丢弃。
13.5 StreamProtocol 与 StreamChunk
13.5.1 StreamProtocol
StreamProtocol 是流式输出的传输层抽象,定义在 langgraph/pregel/protocol.py 中:
StreamChunk = tuple[tuple[str, ...], str, Any]
# (命名空间元组, 模式字符串, 数据)
class StreamProtocol:
__slots__ = ("modes", "__call__")
modes: set[StreamMode]
__call__: Callable[[Self, StreamChunk], None]
def __init__(
self,
__call__: Callable[[StreamChunk], None],
modes: set[StreamMode],
) -> None:
self.__call__ = cast(Callable[[Self, StreamChunk], None], __call__)
self.modes = modes
StreamProtocol 只有两个属性:
modes:订阅的模式集合,用于过滤__call__:接收StreamChunk的回调函数
13.5.2 DuplexStream
在子图场景中,子图的流式输出需要同时发送到子图自己的消费者和父图的流式管道。DuplexStream 实现了这种多路复用:
def DuplexStream(*streams: StreamProtocol) -> StreamProtocol:
def __call__(value: StreamChunk) -> None:
for stream in streams:
if value[1] in stream.modes: # value[1] 是模式字符串
stream(value)
return StreamProtocol(__call__, {mode for s in streams for mode in s.modes})
graph LR
Node[节点输出] --> Duplex[DuplexStream]
Duplex -->|"mode in modes_A"| StreamA[子图 Stream]
Duplex -->|"mode in modes_B"| StreamB[父图 Stream]
13.5.3 流式数据在 PregelLoop 中的注入
PregelLoop 的 tick 方法在多个关键时刻发射流式事件:
class PregelLoop:
def tick(self) -> bool:
# 准备任务
self.tasks = prepare_next_tasks(...)
# 发射 checkpoints 事件
self._emit("checkpoints", map_debug_checkpoint, ...)
# 发射 tasks 开始事件
self._emit("tasks", map_debug_tasks, self.tasks.values())
# ... 执行任务 ...
# 发射 tasks 结束事件
self._emit("tasks", map_debug_task_results, ...)
# 发射 values 和 updates 事件
for chunk in map_output_values(...):
self.stream((..., "values", chunk))
for chunk in map_output_updates(...):
self.stream((..., "updates", chunk))
13.6 StreamMessagesHandler:LLM Token 捕获
13.6.1 回调机制
messages 模式的实现依赖于 LangChain 的回调系统。StreamMessagesHandler 同时继承了 BaseCallbackHandler 和 _StreamingCallbackHandler,在 LLM 调用的各个生命周期阶段捕获消息:
class StreamMessagesHandler(BaseCallbackHandler, _StreamingCallbackHandler):
run_inline = True # 在主线程运行,避免排序问题
def __init__(self, stream, subgraphs, *, parent_ns=None):
self.stream = stream
self.subgraphs = subgraphs
self.metadata: dict[UUID, Meta] = {}
self.seen: set[int | str] = set()
self.parent_ns = parent_ns
13.6.2 Token 流的捕获链路
sequenceDiagram
participant LLM as ChatModel
participant Handler as StreamMessagesHandler
participant Stream as StreamProtocol
participant Client as 消费者
LLM->>Handler: on_chat_model_start(metadata)
Note over Handler: 记录 run_id -> (ns, metadata)
loop 每个 token
LLM->>Handler: on_llm_new_token(chunk)
Handler->>Handler: 检查 metadata[run_id]
Handler->>Stream: _emit(meta, chunk.message)
Stream->>Client: ("messages", (AIMessageChunk, metadata))
end
LLM->>Handler: on_llm_end(response)
Note over Handler: 发射最终完整消息(去重)
Handler->>Handler: 清理 metadata[run_id]
关键实现细节:
- 元数据追踪:
on_chat_model_start时记录run_id到(ns, metadata)的映射 - token 发射:
on_llm_new_token时根据run_id找到元数据,构造(message, metadata)元组发射 - 去重:使用
seen集合跟踪已发射的消息 ID,避免在on_llm_end时重复发射 - 子图过滤:如果
subgraphs=False,跳过命名空间深度 > 0 的事件
13.6.3 节点输出中的消息捕获
StreamMessagesHandler 不仅捕获 LLM 的 token,还捕获节点返回值中的消息对象:
def on_chain_end(self, response, *, run_id, **kwargs):
if meta := self.metadata.pop(run_id, None):
if isinstance(response, Command):
self._find_and_emit_messages(meta, response.update)
elif isinstance(response, Sequence) and any(
isinstance(value, Command) for value in response
):
for value in response:
if isinstance(value, Command):
self._find_and_emit_messages(meta, value.update)
else:
self._find_and_emit_messages(meta, value)
else:
self._find_and_emit_messages(meta, response)
_find_and_emit_messages 方法递归扫描返回值,从字典、BaseModel、dataclass 等结构中提取 BaseMessage 对象。这使得 messages 模式能够捕获所有经过节点的消息,而不仅是 LLM 直接输出的 token。
13.7 各 StreamMode 的输出映射函数
13.7.1 map_output_values
def map_output_values(
output_channels: str | Sequence[str],
pending_writes: Literal[True] | Sequence[tuple[str, Any]],
channels: Mapping[str, BaseChannel],
) -> Iterator[dict[str, Any] | Any]:
if isinstance(output_channels, str):
if pending_writes is True or any(
chan == output_channels for chan, _ in pending_writes
):
yield read_channel(channels, output_channels)
else:
if pending_writes is True or {
c for c, _ in pending_writes if c in output_channels
}:
yield read_channels(channels, output_channels)
values 模式读取所有输出 Channel 的当前值,构成完整的状态快照。只有当相关 Channel 在本步被写入时才发射。
13.7.2 map_output_updates
def map_output_updates(
output_channels: str | Sequence[str],
tasks: list[tuple[PregelExecutableTask, Sequence[tuple[str, Any]]]],
cached: bool = False,
) -> Iterator[dict[str, Any | dict[str, Any]]]:
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
]
...
updates 模式将每个任务的写入按节点名分组。隐藏节点(标记了 TAG_HIDDEN)和错误/中断写入被过滤掉。输出格式为 {node_name: update_value}。
13.7.3 Debug 输出
debug 模式组合了 checkpoints 和 tasks 的输出,包裹在带时间戳和步数的外层结构中:
class _DebugTaskPayload(TypedDict):
step: int
timestamp: str # ISO 8601 格式
type: Literal["task"]
payload: TaskPayload
class _DebugTaskResultPayload(TypedDict):
step: int
timestamp: str
type: Literal["task_result"]
payload: TaskResultPayload
class _DebugCheckpointPayload(TypedDict, Generic[StateT]):
step: int
timestamp: str
type: Literal["checkpoint"]
payload: CheckpointPayload[StateT]
graph TB
subgraph debug 模式输出
D[DebugPayload] --> DC[type: checkpoint]
D --> DT[type: task]
D --> DTR[type: task_result]
DC --> CP[CheckpointPayload<br/>config, metadata, values, next, tasks]
DT --> TP[TaskPayload<br/>id, name, input, triggers]
DTR --> TRP[TaskResultPayload<br/>id, name, error, interrupts, result]
end
13.8 v2 Stream API 的使用
13.8.1 基本用法
# v2 API:version="v2" 参数
async for part in graph.astream(input, version="v2"):
match part["type"]:
case "values":
state = part["data"]
interrupts = part["interrupts"]
case "updates":
for node, update in part["data"].items():
print(f"{node}: {update}")
case "messages":
message, metadata = part["data"]
print(message.content, end="", flush=True)
case "custom":
handle_custom(part["data"])
13.8.2 v1 vs v2 对比
graph LR
subgraph "v1 API"
V1S["stream(mode='values')"] --> V1O["Iterator[dict]"]
V1M["stream(mode=['values','messages'])"] --> V1T["Iterator[tuple[str, Any]]"]
end
subgraph "v2 API"
V2["stream(version='v2')"] --> V2O["Iterator[StreamPart]"]
V2O --> V2D["part.type 判别"]
end
v2 的主要优势:
- 类型安全:每个 StreamPart 都有明确的
type字段,可用于类型窄化 - 统一接口:无论订阅多少种模式,输出格式一致
- 中断信息内联:
ValuesStreamPart直接包含interrupts字段 - 泛型支持:
StreamPart[StateT, OutputT]携带完整的类型信息
13.8.3 GraphOutput:invoke 的 v2 返回值
v2 API 还改进了 invoke 的返回类型:
@dataclass(frozen=True)
class GraphOutput(Generic[OutputT]):
value: OutputT
interrupts: tuple[Interrupt, ...] = ()
不再返回裸字典,而是一个带类型的容器:
result = graph.invoke(input, version="v2")
print(result.value) # 输出值
print(result.interrupts) # 中断信息
13.9 TaskPayload 与 TaskResultPayload
13.9.1 任务生命周期事件
tasks 模式提供了细粒度的任务执行监控:
class TaskPayload(TypedDict):
"""任务开始事件"""
id: str # 唯一任务 ID
name: str # 节点名
input: Any # 任务输入
triggers: list[str] # 触发原因
class TaskResultPayload(TypedDict):
"""任务结束事件"""
id: str # 唯一任务 ID
name: str # 节点名
error: str | None # 错误信息
interrupts: list[dict] # 中断列表
result: dict[str, Any] # 输出结果
stateDiagram-v2
[*] --> TaskStart: tasks 事件 (TaskPayload)
TaskStart --> TaskEnd: tasks 事件 (TaskResultPayload)
TaskEnd --> [*]
state TaskStart {
id: 任务ID
name: 节点名
input: 输入数据
triggers: 触发器列表
}
state TaskEnd {
id: 任务ID
name: 节点名
result: 输出数据
error: 错误信息
interrupts: 中断列表
}
13.9.2 实际应用
for part in graph.stream(input, stream_mode="tasks", version="v2"):
payload = part["data"]
if "input" in payload:
# 任务开始
print(f"Starting task {payload['name']} (id={payload['id']})")
else:
# 任务结束
if payload.get("error"):
print(f"Task {payload['name']} failed: {payload['error']}")
else:
print(f"Task {payload['name']} completed: {payload['result']}")
13.10 设计决策
13.10.1 为什么有七种模式而不是一种?
设计多种流式模式的核心原因是消费者的多样性:
- 前端 UI 只关心 LLM 文本流(
messages) - 管理面板需要完整状态(
values) - 日志系统需要增量更新(
updates) - 监控系统需要任务级事件(
tasks) - 开发者调试需要全部信息(
debug)
一种模式无法同时满足所有需求,而如果把所有信息都塞进一种模式,消费者需要过滤大量不相关的数据。
13.10.2 回调 vs 返回值
messages 模式使用回调(StreamMessagesHandler)而非返回值来捕获 LLM token。这是因为 LLM 的流式输出发生在节点执行过程中,而不是执行完成后。回调机制允许在不修改节点代码的前提下拦截中间数据。
13.10.3 StreamWriter 作为 Callable 而非类
StreamWriter 被定义为 Callable[[Any], None] 而非一个类,这是刻意的简约设计。节点代码只需要调用 writer(data) 即可,不需要了解任何框架内部细节。这种设计也使得测试更容易——你可以用任意 lambda 替代:
# 测试时
collected = []
my_node(state, writer=collected.append)
assert collected == [{"progress": 0.5}, {"progress": 1.0}]
13.10.4 run_inline = True 的必要性
StreamMessagesHandler 设置了 run_inline = True,这意味着回调在主线程中同步执行。这个选择是为了避免两个问题:
- 顺序保证:token 必须按生成顺序到达消��者
- 锁竞争:异步回调可能导致与 Channel 写入的竞争条件
代价是 LLM 生成的速度会略微受到回调处理时间的影响,但对于 token 级别的事件,这个开销可以忽略。
13.11 小结
本章系统分析了 LangGraph 的流式输出架构。七种 StreamMode 覆盖了从完整状态快照到 LLM token 的各个粒度层级。v2 Stream API 通过 StreamPart 类型体系实现了类型安全的流式消费,每个事件都是自描述的。StreamWriter 以极简的函数签名为节点提供了自定义流式输出的能力,而 StreamMessagesHandler 通过 LangChain 回调系统在不侵入业务代码的前提下捕获 LLM 的 token 流。
整个流式系统的设计理念可以归结为:分层解耦,按需订阅。生产者(节点、LLM、框架内部)不关心消费者订阅了什么;消费者通过 stream_mode 声明自己感兴趣的事件类型;StreamProtocol 作为中间层负责路由和过滤。这种设计使得新增流式模式只需定义新的 StreamPart 类型和对应的映射函数,对现有代码零影响。
下一章我们将探讨 LangGraph 的 Runtime 与 Context 机制,了解如何将运行时依赖(如用户身份、数据库连接)安全地注入到图的执行过程中。