LangGraph 的流式输出与中断恢复
前文分析了 LangGraph 的 Graph、Channel、Pregel 调度和 Checkpoint。本篇继续回答两个运行时问题:
- LangGraph 在哪里产生事件,如何收集并输出事件流
interrupt()暂停时发生什么,用户如何获得上下文,Command(resume=...)又如何把输入交还给节点
其中 messages 模式依赖 LangChain Chat Model 的流式 Callback,因此还会单独分析 BaseChatModel、Provider 的 _stream() 和 LangGraph 的 StreamMessagesHandler 如何连接。
1. LangGraph 如何实现流式输出
调用 graph.stream() 时,Graph 并不是在执行结束后把结果拆成多块返回,而是执行过程中不断产生事件并放入一个队列,调用方对生成器的迭代再不断从队列中取出事件。
先看最常用的接口:
|
|
stream_mode 决定订阅哪些事件。常见模式包括:
| 模式 | 产生位置 | Payload |
|---|---|---|
values |
每个 Superstep 完成、State Channel 更新后 | 当前完整 State |
updates |
一个节点完成并提交 Writes 时 | 节点名称及其 Partial State 更新 |
messages |
Chat Model 的流式 Callback | AIMessageChunk 和节点元数据 |
custom |
节点主动调用 StreamWriter 时 |
用户传给 writer 的任意值 |
tasks |
Task 开始和结束时 | Task、结果、错误等调试信息 |
checkpoints |
Checkpoint 创建时 | 与 get_state() 接近的状态快照 |
这里的“事件”在 v1 内部统一表示为一个三元组:
|
|
namespace 表示事件来自顶层 Graph 还是某个 Subgraph,mode 表示事件类型,payload 是该事件的数据。因此,State 更新、LLM Token 和用户自定义数据虽然来源不同,进入流以后都使用相同的信封结构。
1.1 事件在哪里定义和产生
LangGraph 没有一个“所有事件类的枚举”负责产生全部事件。更准确地说,StreamMode 定义允许订阅的类别,不同运行时组件在事件真正发生的位置调用统一的 Stream 接口。
Pregel Loop 产生运行时事件
PregelLoop._emit() 是 values、updates、tasks 和 checkpoints 等运行时事件的统一出口。它先检查调用方有没有订阅该 mode,再把数据规范化为 (checkpoint_ns, mode, payload):
|
|
事件的产生时机并不相同:
- Task 准备好时产生
tasks开始事件 PregelRunner完成节点、得到task.writes后,output_writes()产生updates和 Task 结束事件after_tick()调用apply_writes()后产生values事件,此时看到的是 Superstep 更新后的完整 State- 创建 Checkpoint 时产生
checkpoints事件
这也解释了 updates 和 values 的语义差异:前者来自单个 Task 的 Writes,可以在并行节点陆续完成时输出;后者来自 Update 阶段之后的 Channel 快照,表示本轮统一提交后的 State。
Chat Model Callback 产生 Token 事件
messages 不需要等节点返回。graph.stream() 发现订阅了 messages 后,会向 LangChain Callback Manager 注册 StreamMessagesHandler。Chat Model 每产生一个消息块,Handler 就调用:
|
|
因此 LLM Token 能在节点尚未完成、还没有 State Write 时先流出来。它不是从 State Channel 中拆出来的,而是直接截获 Chat Model 的流式 Callback。
节点产生自定义事件
custom 模式会创建一个 stream_writer,并通过 Runtime 注入节点。节点调用 writer 时,数据直接进入流:
|
|
|
|
因此自定义进度也不需要先写入 State。
2. Chat Model 如何定义流式输出
Chat Model 并不是 LangGraph 定义的,而是 LangChain Core 中的 BaseChatModel。LangGraph 不负责调用具体模型服务,它只是注册 Callback Handler,监听模型产生的消息块。
完整调用链如下:
|
|
2.1 BaseChatModel 的接口
基础类定义在 langchain_core/language_models/chat_models.py:
|
|
具体模型至少实现非流式生成接口和模型类型:
|
|
支持流式响应的 Provider 还应该实现 _stream():
|
|
用户调用公开接口,Provider 实现底层接口:
| 公开接口 | Provider 接口 | 返回值 |
|---|---|---|
invoke() |
_generate() |
完整 AIMessage |
ainvoke() |
_agenerate() |
完整 AIMessage |
stream() |
_stream() |
Iterator[AIMessageChunk] |
astream() |
_astream() |
AsyncIterator[AIMessageChunk] |
基础类的 _stream() 只抛出 NotImplementedError。OpenAI、Anthropic、Ollama 等 Provider 的子类负责把 SSE、WebSocket 或其他协议转换成 LangChain 的统一消息块。
2.2 Message 示例与对应类型
LangGraph 没有重新定义一套 Message,它直接使用 langchain_core.messages 中的类型。下面左列是实例化示例,右列是对象在 LangGraph State 或 Stream 中实际对应的 Python 类型。
| Message 示例 | LangGraph 中的类型 |
|---|---|
SystemMessage(content="你是翻译助手") |
SystemMessage,完整的 System 指令消息,序列化类型为 "system" |
HumanMessage(content="把 A 翻译成中文") |
HumanMessage,完整的用户输入消息,序列化类型为 "human" |
AIMessage(content="B") |
AIMessage,完整的模型输出消息,序列化类型为 "ai" |
AIMessage(content="", tool_calls=[{"name": "search", "args": {"q": "LangGraph"}, "id": "call_1", "type": "tool_call"}]) |
AIMessage,Tool Call 是 AIMessage.tool_calls 中的 ToolCall,不是独立 Message |
ToolMessage(content="搜索结果", tool_call_id="call_1") |
ToolMessage,工具执行结果,通过 tool_call_id 对应前面的 Tool Call,序列化类型为 "tool" |
ChatMessage(role="critic", content="需要修改") |
ChatMessage,允许自定义 role 的完整消息,序列化类型为 "chat" |
FunctionMessage(name="search", content="搜索结果") |
FunctionMessage,旧版函数调用结果,没有 tool_call_id;新代码通常使用 ToolMessage,序列化类型为 "function" |
SystemMessageChunk(content="你是") |
SystemMessageChunk,可拼接的 System 消息分块 |
HumanMessageChunk(content="把 A") |
HumanMessageChunk,可拼接的 Human 消息分块 |
AIMessageChunk(content="你") |
AIMessageChunk,Chat Model 最常见的流式输出分块 |
AIMessageChunk(content="", tool_call_chunks=[{"name": "search", "args": "{\"q\":", "id": "call_1", "index": 0, "type": "tool_call_chunk"}]) |
AIMessageChunk,流式 Tool Call 位于 tool_call_chunks,元素类型为 ToolCallChunk,不是独立 Message Chunk |
ToolMessageChunk(content="部分结果", tool_call_id="call_1") |
ToolMessageChunk,可拼接的工具结果分块 |
ChatMessageChunk(role="critic", content="需要") |
ChatMessageChunk,带自定义 role 的消息分块 |
FunctionMessageChunk(name="search", content="部分结果") |
FunctionMessageChunk,旧版函数结果分块 |
RemoveMessage(id="message-id") |
RemoveMessage,供 LangGraph Message Reducer 删除指定 ID 的已有消息;它是状态更新指令,不会发送给 Chat Model |
完整消息的联合类型 AnyMessage 可以简化为:
|
|
其中最常见的对话顺序是:
|
|
带工具调用时则是:
|
|
ToolCall、ToolCallChunk、ContentBlock 和 ChatGenerationChunk 都不是 Message:前几者是 Message 内部字段,ChatGenerationChunk 则是模型生成层的包装对象。LangGraph 的 stream_mode="messages" 最终对外输出的是包装对象中的 message,通常为 AIMessageChunk。
2.3 _stream() 产生的是消息块
_stream() 每次 Yield 一个 ChatGenerationChunk:
|
|
其类型关系为:
|
|
Chunk 不只包含文本,还可以携带 Tool Call 增量、Reasoning Content、Usage Metadata、Provider Metadata 和消息 ID。所以 Callback 虽然名为 on_llm_new_token,事件单位并不一定严格等于 tokenizer 的一个 Token,更准确地说是 Provider 返回的一个消息 Chunk。
本地 Fake Model 的实现最容易看清这个约定:
|
|
假设模型回复“你好”,它会依次产生:
|
|
2.4 BaseChatModel.stream() 如何触发 Callback
公开的 stream() 建立 Callback 生命周期、调用 Provider 的 _stream(),再将 Chunk 同时发送给 Callback 和直接调用方:
|
|
一个模型 Chunk 同时进入两条路径:
|
|
CallbackManagerForLLMRun.on_llm_new_token() 遍历本次模型调用的 Handler:
|
|
2.5 LangGraph 如何监听 Chat Model
订阅 messages 时,LangGraph 创建 StreamMessagesHandler,并添加到 Graph Callback Manager 的 inheritable_handlers:
|
|
节点内的 Chat Model 是 Graph 下层的 Runnable,它创建 Callback Manager 时会继承这个 Handler,所以节点无须显式传入 LangGraph Handler。
模型产生 Chunk 后,Handler 收到回调:
|
|
这里使用 chunk.message 而不是字符串 token,以保留 Tool Call 和 Metadata 等结构化数据。_emit() 最终写入:
|
|
2.6 为什么节点调用 invoke() 也能流式输出
节点经常使用非流式接口:
|
|
但 Graph 外部仍然可以订阅 messages。原因是 BaseChatModel 会检查 Callback Handler:如果 Provider 实现了 _stream(),且存在 Streaming Callback Handler,invoke() 内部也会改走 _stream():
|
|
LangGraph 的 StreamMessagesHandler 正是一个 _StreamingCallbackHandler:
|
|
因此同一次模型调用有两个观察结果:
|
|
核心结论是:
Provider 的
_stream()定义如何产生ChatGenerationChunk;BaseChatModel为每个 Chunk 触发on_llm_new_token();LangGraph 通过可继承的StreamMessagesHandler监听 Callback,再将chunk.message转换成自己的messages事件。
3. 事件如何被收集和输出
3.1 事件如何被收集
同步 stream() 会先创建一个线程安全的 SyncQueue,然后把它包装成 StreamProtocol:
|
|
StreamProtocol 只有两个核心成员:
|
|
其中 modes 是供事件生产者判断是否需要生成某类事件的订阅集合,__call__ 最终指向 SyncQueue.put。于是所有生产者共享同一条路径:
|
|
Queue 是事件的汇合点,但它不负责推导 State 或生成 Token;它只按生产者放入的顺序暂存已经生成的 StreamChunk。
3.2 Queue 中的事件如何变成调用方看到的流
Pregel 主循环仍按照 Plan -> Execution -> Update 执行,但 PregelRunner.tick() 在等待并行 Task 的过程中会不断把控制权交还给 stream()。每次交还时,_output() 都会持续调用 stream.get(),直到当前 Queue 被取空:
|
|
_output() 根据调用参数把内部三元组转换成公开输出。比如同时订阅多个 mode 时,v1 通常输出:
|
|
如果设置 subgraphs=True,输出中还会保留 namespace。v2 则使用带字段名的统一信封:
|
|
所以一条流式事件的完整路径是:
|
|
这里还有一个重要区别:messages 和 custom 可以在节点执行中途到达 Queue,因此是真正的节点内增量输出;updates 必须等节点形成 Writes,values 必须等本轮 apply_writes() 完成。它们都叫 Streaming,但粒度和产生时机不同。
4. interrupt() 与 Command 如何实现中断和恢复
interrupt_before/after 是在节点边界暂停;interrupt(value) 则可以在节点函数内部动态暂停,并把业务上下文交给用户。后者的关键语义是:
interrupt()第一次执行时抛出GraphInterrupt;恢复时节点从头重新执行,同一个interrupt()再次执行时不再抛异常,而是返回Command(resume=...)携带的值。
它不是保存 Python 调用栈,也不是恢复到函数的下一行。
4.1 用户如何拿到 A 和 B
interrupt(value) 的 value 就是节点发给用户的 Payload。比如翻译节点生成 B 后,可以把原文 A、译文 B 和操作说明一起传出:
|
|
第一次执行:
|
|
调用方会收到类似事件:
|
|
服务端把 Interrupt.value 返回给前端,前端就能同时展示 A 和 B。A、B 并不是 LangGraph 自动从节点局部变量中提取出来的;节点必须显式把希望用户看到的信息放入 interrupt(value)。
4.2 interrupt() 第一次执行时发生什么
节点进入 interrupt(value) 后会读取当前 Task 的 PregelScratchpad:
|
|
第一次执行还没有 Resume Value,所以它创建 Interrupt 并抛出 GraphInterrupt。接下来:
PregelRunner.commit()捕获GraphInterrupt- Runner 不把它当成普通失败,而是给当前 Task 保存一条
(__interrupt__, Interrupt(...))Pending Write PregelLoop.output_writes()把这条 Pending Write 转换成updates或values流事件- Loop 退出时保存 Checkpoint 和 Pending Writes,并在顶层吞掉
GraphInterrupt,所以用户看到的是“正常结束但包含待处理 Interrupt”,而不是未处理异常
由于节点没有正常返回,节点最后的 return {"translation": ..., ...} 尚未发生,因而这部分 State Update 也没有提交。Checkpoint 保存的是节点执行前的 State、调度位置以及这次 Interrupt Pending Write。
4.3 Command(resume=...) 如何把用户输入交回节点
假设用户点击“通过”,应用使用相同的 thread_id 恢复:
|
|
内部过程如下:
|
|
因此节点拿到用户信息的方式就是普通的函数返回值:
|
|
多个 interrupt() 按节点内的调用顺序匹配 Resume Value,计数器和 Resume 列表都属于当前 Task。若同时存在多个并行 Interrupt,则应使用 Interrupt ID 精确恢复:
|
|
4.4 为什么翻译和审核最好拆成两个节点
上面的单节点示例容易说明机制,但不适合直接用于生产。恢复会从节点开头重跑,所以 LLM 会再次生成翻译:
|
|
这不仅重复消耗 Token,而且用户确认的内容和最终写入的内容可能不一致。更可靠的 Graph 应把“产生待审核结果”和“等待审核”拆开:
|
|
translate 正常结束后,A 和 B 已经进入 State Channel 并保存到 Checkpoint。review 被恢复时虽然仍会从头重跑,但它只会重新读取同一份 A、B,再让 interrupt() 返回用户意见,不会重新调用 LLM。
完整时序如下:
|
|
因此,动态中断的本质不是“冻结函数”,而是 Checkpoint 保存可重建的 Graph/Task 状态,Interrupt Pending Write 保存待回答的问题,Command 写入 Resume Value,节点重放时由 interrupt() 把 Resume Value 还原成普通返回值。
本文的源码核对基于本地安装的 LangGraph 1.2.11 和 LangChain Core 1.5.4,关键位置包括:
langchain_core/language_models/chat_models.py:BaseChatModel、stream()、_stream()以及 Callback 的触发langchain_core/callbacks/manager.py:分发on_llm_new_token()langchain_core/outputs/chat_generation.py:ChatGenerationChunk的定义langgraph/pregel/main.py:创建 Stream Queue、注册消息 Callback、驱动 Loop 并输出 Queuelanggraph/pregel/_loop.py:产生运行时事件、处理中断 Pending Writes、接收 Resume Commandlanggraph/pregel/_runner.py:捕获GraphInterrupt并保存 Interruptlanggraph/pregel/_messages.py:把 Chat Model 消息块转换成messages事件langgraph/pregel/_io.py:把Command转换成 Pending Writeslanggraph/pregel/_algo.py:恢复 Task 并构造包含 Resume Values 的PregelScratchpadlanggraph/types.py:StreamChunk、Command和interrupt()的公开定义