📖 导读:这篇文档怎么读
- 不需要任何流式的前置知识。本文从"为什么需要它"讲起,一层层往上垒。
- 遇到看不懂的名词(投影、频道、transformer......),先看【白话解释】框------那里全是比喻,用日常经验理解。
- 每节结构固定:要解决的问题 → 完整代码 → 实测输出 → 逐段讲解 → 知识点。想快速回顾就只看【白话解释】和各节的"知识点"框。
- 读不懂的地方随时回看,所有代码都可在本机原样重跑 (复制到 py 文件、
python 文件名即可)。
零、开篇必读:先认识 4 个"角色"
第 07 篇你学的是 v2 流式 (graph.stream() + stream_mode)。第 08 篇(本篇)学的是 v3 事件流式 (graph.stream_events()),它把流式能力做成了一个更完整的系统。
在 v3 的世界里,有 4 个反复出现的"角色",先用白话认识它们:
【白话解释】① 流(Stream)与投影(Projection)
想象图(Graph)在"干活"时像一家直播厨房------里面的每一步操作都在"往外播"。
- 流(Stream):这一整条"播出去的信号",像电视台的信号线。
- 投影(Projection) :一条信号里其实混着很多路内容(厨师动作、菜品状态、烤箱温度......)。投影 = 从这条信号里"分路"出来的一个专属频道 。
stream.messages= "所有'说话'的内容"这一路(模型吐字)stream.values= "状态变化"这一路(每个节点的状态快照)stream.output= "最终结果"这一路stream.subgraphs= "嵌套子图干了啥"这一路
关键好处 :多路投影可以同时被不同的消费者读------前端在读"字"(messages)的同时,后台在读"状态"(values),互不打扰。
【白话解释】② 频道(Channel)与原始协议事件(ProtocolEvent)
-
如果投影是"给普通用户看的精修频道",那频道(Channel)就是"给工程师看的原始信号" ------每一路原始信号叫一个频道,名字如
values、messages、tools、lifecycle。 -
原始协议事件(ProtocolEvent) = 信号线上流动的每一个小包裹 。每个包裹长这样:
{"seq": 5, "method": "messages", "params": {"namespace": [], "data": ...}}seq:第几号包裹(顺序号,严格递增)method:这是哪一路频道("messages"频道)params.namespace:这个包裹从哪一层发出([]=最外层根图)params.data:包裹里的具体内容
【白话解释】③ StreamTransformer(流式变形器)
- Transformer = 挂在信号线上的"智能分拣插件" 。它盯着每一个流过的包裹,做自己的事:计数、过滤、变换、累加......最后产出一个自定义投影 (挂在
stream.extensions下)。 - 官方内置了好几个(处理 messages、values、subgraphs......),你也能自己写(本篇压轴练习就是这个)。
- 还有个很重要的特性:插件可以"点名要货" ------它的
required_stream_modes声明"我需要 tools 频道的包裹",系统才会把 tools 频道打开。"没人要的频道,根本不发车"(这点后面有实测)。
【白话解释】④ 为什么要有 v3?(零基础解惑)
v2 已经能流式了,为什么还要 v3?三个升级:
| 问题(v2 时代) | v3 的答案 |
|---|---|
多路内容混在一条流里,得手写 if chunk["type"] == ... 手动分流 |
投影分开给 :stream.messages、stream.values 各读各的 |
| 想同时读两路?只能自己在一个循环里手动拆 | interleave / asyncio.gather:多路并发读,还保持真实顺序 |
| 嵌套子图的内容难拿(要解析 namespace 字符串) | stream.subgraphs :直接给你 graph_name / path / 内部消息 |
| 想给应用定制一路特殊的流?没门 | 自己写 transformer:想加几路加几路 |
一句总结:v2 是"一条混装扁担",v3 是"多路快递柜"------内容更多、取件更方便、还能自己加柜子。
一、本篇学习地图
#mermaid-svg-oUC6qrlJrIP0F6qa{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-oUC6qrlJrIP0F6qa .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-oUC6qrlJrIP0F6qa .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-oUC6qrlJrIP0F6qa .error-icon{fill:#552222;}#mermaid-svg-oUC6qrlJrIP0F6qa .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-oUC6qrlJrIP0F6qa .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-oUC6qrlJrIP0F6qa .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-oUC6qrlJrIP0F6qa .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-oUC6qrlJrIP0F6qa .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-oUC6qrlJrIP0F6qa .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-oUC6qrlJrIP0F6qa .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-oUC6qrlJrIP0F6qa .marker{fill:#333333;stroke:#333333;}#mermaid-svg-oUC6qrlJrIP0F6qa .marker.cross{stroke:#333333;}#mermaid-svg-oUC6qrlJrIP0F6qa svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-oUC6qrlJrIP0F6qa p{margin:0;}#mermaid-svg-oUC6qrlJrIP0F6qa .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-oUC6qrlJrIP0F6qa .cluster-label text{fill:#333;}#mermaid-svg-oUC6qrlJrIP0F6qa .cluster-label span{color:#333;}#mermaid-svg-oUC6qrlJrIP0F6qa .cluster-label span p{background-color:transparent;}#mermaid-svg-oUC6qrlJrIP0F6qa .label text,#mermaid-svg-oUC6qrlJrIP0F6qa span{fill:#333;color:#333;}#mermaid-svg-oUC6qrlJrIP0F6qa .node rect,#mermaid-svg-oUC6qrlJrIP0F6qa .node circle,#mermaid-svg-oUC6qrlJrIP0F6qa .node ellipse,#mermaid-svg-oUC6qrlJrIP0F6qa .node polygon,#mermaid-svg-oUC6qrlJrIP0F6qa .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-oUC6qrlJrIP0F6qa .rough-node .label text,#mermaid-svg-oUC6qrlJrIP0F6qa .node .label text,#mermaid-svg-oUC6qrlJrIP0F6qa .image-shape .label,#mermaid-svg-oUC6qrlJrIP0F6qa .icon-shape .label{text-anchor:middle;}#mermaid-svg-oUC6qrlJrIP0F6qa .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-oUC6qrlJrIP0F6qa .rough-node .label,#mermaid-svg-oUC6qrlJrIP0F6qa .node .label,#mermaid-svg-oUC6qrlJrIP0F6qa .image-shape .label,#mermaid-svg-oUC6qrlJrIP0F6qa .icon-shape .label{text-align:center;}#mermaid-svg-oUC6qrlJrIP0F6qa .node.clickable{cursor:pointer;}#mermaid-svg-oUC6qrlJrIP0F6qa .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-oUC6qrlJrIP0F6qa .arrowheadPath{fill:#333333;}#mermaid-svg-oUC6qrlJrIP0F6qa .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-oUC6qrlJrIP0F6qa .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-oUC6qrlJrIP0F6qa .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-oUC6qrlJrIP0F6qa .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-oUC6qrlJrIP0F6qa .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-oUC6qrlJrIP0F6qa .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-oUC6qrlJrIP0F6qa .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-oUC6qrlJrIP0F6qa .cluster text{fill:#333;}#mermaid-svg-oUC6qrlJrIP0F6qa .cluster span{color:#333;}#mermaid-svg-oUC6qrlJrIP0F6qa div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-oUC6qrlJrIP0F6qa .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-oUC6qrlJrIP0F6qa rect.text{fill:none;stroke-width:0;}#mermaid-svg-oUC6qrlJrIP0F6qa .icon-shape,#mermaid-svg-oUC6qrlJrIP0F6qa .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-oUC6qrlJrIP0F6qa .icon-shape p,#mermaid-svg-oUC6qrlJrIP0F6qa .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-oUC6qrlJrIP0F6qa .icon-shape .label rect,#mermaid-svg-oUC6qrlJrIP0F6qa .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-oUC6qrlJrIP0F6qa .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-oUC6qrlJrIP0F6qa .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-oUC6qrlJrIP0F6qa :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} stream_events()
version=v3
stream.messages
消息投影
stream.values
状态投影
stream.subgraphs
子图投影
stream.output
终值投影
stream.interrupts
中断投影
原始协议事件
频道+生命周期
二、练习概述表
| ID | 文件 | 覆盖文档 | 一句话 |
|---|---|---|---|
| 1 | ev_01_basics | §1-4.3 | messages/values/output 三投影 + content blocks 坑 |
| 2 | ev_02_interleave | §4.4 | 多投影按真实顺序交错消费 |
| 3 | ev_03_subgraphs | §4.2 | 子图投影(graph_name/path/内部消息) |
| 4 | ev_04_interrupt | §4.5 | 中断恢复(interrupted/interrupts + resume) |
| 5a | ev_05a_raw_events | §5+§6.1 | 原始协议事件信封 + messages 内容块 |
| 5b | ev_05b_tools_lifecycle | §6.2+§6.3 | tools 频道 + lifecycle 频道 |
| 6 | ev_06_transformers | §7(压轴) | 亲手写 transformer(4 种变体) |
| 7 | ev_07_reasoning_async | §4.1+§4.4 | reasoning 推理增量 + 异步并发 |
三、练习 1:三投影(messages / values / output)
3.1 这一课要解决的问题
你有一段"节点调 LLM 写笑话"的图。现在你想:
- 一个词一个词地 把模型的话"流"出来(网页上那种打字机效果)→
stream.messages - 每次状态变化时看一眼完整状态 →
stream.values - 流全部结束后拿最终结果 →
stream.output
3.2 完整代码
python
# 练习1:v3 快速开始------messages/values/output 三投影 + content blocks 坑
from dotenv import load_dotenv
load_dotenv()
from typing import TypedDict
from langchain_deepseek import ChatDeepSeek
from langgraph.graph import StateGraph, START, END
model = ChatDeepSeek(
model="deepseek-v4-flash",
extra_body={"thinking": {"type": "disabled"}}, # 禁思考:输出干净、快
)
class State(TypedDict):
topic: str
joke: str
def write_joke(state: State):
r = model.invoke([{"role": "user", "content": f"讲个关于{state['topic']}的冷笑话,一句话"}])
print(" [节点内观察] content 类型:", type(r.content).__name__) # ★ v3 下是 list!
print(" [节点内观察] r.text:", str(r.text)[:50])
return {"joke": r.text} # ★ v3 下用 r.text 拿纯文本
g = (
StateGraph(State)
.add_node("write_joke", write_joke)
.add_edge(START, "write_joke")
.add_edge("write_joke", END)
.compile()
)
# ---------- ① stream.messages:逐 token 迭代(打字机) ----------
print("=== ① stream.messages 逐 token ===")
stream = g.stream_events({"topic": "程序员"}, version="v3")
for message in stream.messages:
print(f" (来自节点: {message.node})")
for token in message.text:
print(token, end="", flush=True)
print()
# ---------- ② stream.output:终值(await 属性) ----------
print("\n=== ② stream.output 终值 ===")
stream2 = g.stream_events({"topic": "猫"}, version="v3")
print("joke:", stream2.output["joke"])
# ---------- ③ stream.values:状态快照遍历 ----------
print("\n=== ③ stream.values 快照 ===")
stream3 = g.stream_events({"topic": "狗"}, version="v3")
for snapshot in stream3.values:
print(" 快照 keys:", list(snapshot.keys()))
print("最终 output:", str(stream3.output["joke"])[:60], "...")
3.3 实测输出
=== ① stream.messages 逐 token ===
(来自节点: write_joke)
程序员最讨厌的两件事:写注释,以及别人不写注释。 [节点内观察] content 类型: list
[节点内观察] r.text: 程序员最讨厌的两件事:写注释,以及别人不写注释。
=== ② stream.output 终值 ===
[节点内观察] content 类型: list
[节点内观察] r.text: 猫为什么总是坐在电脑前?因为它想捉鼠标。
joke: 猫为什么总是坐在电脑前?因为它想捉鼠标。
=== ③ stream.values 快照 ===
快照 keys: ['topic']
[节点内观察] content 类型: list
[节点内观察] r.text: 狗为什么要咬自己的尾巴?因为它想让自己转起来。
快照 keys: ['topic', 'joke']
最终 output: 狗为什么要咬自己的尾巴?因为它想让自己转起来。 ...
3.4 逐段讲解(零基础版)
① stream.messages 怎么用
python
for message in stream.messages: # 每次拿到"一条消息对象"
print(message.node) # 消息来自哪个节点
for token in message.text: # 文字可以"一个片段一个片段"迭代
print(token, end="", flush=True) # 不换行 + 立刻显示 = 打字机
message.text既能逐片段迭代 (for 循环),也能str(message.text)一次拿全文。end=""表示"打印完不换行",flush=True表示"立刻显示、别攒着"------这两招合起来就是打字机效果的秘诀。
② stream.output 怎么用
python
stream2 = g.stream_events(...) # 启动流
print(stream2.output["joke"]) # 读它 = 等流全部跑完,返回最终状态
- 它是一个**"等待型"属性**:读它的时候如果图还没跑完,程序会在这里等;跑完了就给你最终状态的字典。
③ stream.values 怎么用
python
for snapshot in stream3.values: # 每一次状态变化都吐一个"快照"
print(list(snapshot.keys())) # 快照就是个 dict(本图有 topic/joke 两个字段)
- 初始快照只有
['topic'](joke 还没生成);结束时['topic', 'joke']都有了。
3.5 ⚠️ 知识点:v3 的"content 变列表"坑(重要!)
实测现象 :节点里 r = model.invoke(...) 之后------
| 运行方式 | r.content 的类型 |
|---|---|
graph.invoke()(旧方式) |
字符串("程序员最讨厌的两件事:......") |
graph.stream_events(version="v3") |
列表 ([{'type': 'text', 'text': '......'}]) |
为什么 :v3 的内容模型是"内容块(content blocks)"------一段消息由若干"块"组成(文字块、思考块、工具调用块......)。所以在 v3 里,模型的返回会用"块列表"来表达。
怎么办 :用 r.text------它会自动把文字块拼成纯文本(LangChain 消息的便捷属性)。
【记忆口令】v3 下要文字 →
r.text;直接翻.content会得到一堆花括号。这个坑在文档里对应 §4.1 的 "message.text" 说明。
四、练习 2:多投影并发------stream.interleave
4.1 这一课要解决的问题
上面三个投影是"分开读"的(先读完 messages,再读 values)。但真实需求常常是:"我想在一条时间线上,同时看到'状态什么时候变、字什么时候出'"------就像看比赛时"画面 + 解说"要同步。
stream.interleave("values", "messages") 就是把几路投影按真实发生的顺序揉成一条流。
4.2 完整代码
python
# 练习2:多投影并发------stream.interleave 按真实到达顺序交错消费
from dotenv import load_dotenv
load_dotenv()
from typing import TypedDict
from langchain_deepseek import ChatDeepSeek
from langgraph.graph import StateGraph, START, END
model = ChatDeepSeek(
model="deepseek-v4-flash",
extra_body={"thinking": {"type": "disabled"}},
)
class State(TypedDict):
topic: str
joke: str
def refine_topic(state: State):
"""纯 Python 节点:加工主题"""
return {"topic": state["topic"] + "(程序员版)"}
def write_joke(state: State):
r = model.invoke([{"role": "user", "content": f"讲个关于{state['topic']}的冷笑话,一句话"}])
return {"joke": r.text}
g = (
StateGraph(State)
.add_node("refine_topic", refine_topic)
.add_node("write_joke", write_joke)
.add_edge(START, "refine_topic")
.add_edge("refine_topic", "write_joke")
.add_edge("write_joke", END)
.compile()
)
# ---------- 交错消费:一个循环 + 真实到达顺序 ----------
print("=== interleave('values', 'messages') 真实顺序 ===")
stream = g.stream_events({"topic": "程序员"}, version="v3")
for name, item in stream.interleave("values", "messages"):
if name == "values":
print(f"[values] 快照 keys={list(item.keys())}")
elif name == "messages":
print(f"[messages] 来自节点={item.node} | 内容={str(item.text)[:36]!r}")
print(f"\n最终 output: {stream.output['joke']}")
4.3 实测输出
=== interleave('values', 'messages') 真实顺序 ===
[values] 快照 keys=['topic']
[values] 快照 keys=['topic']
[messages] 来自节点=write_joke | 内容='程序员最讨厌的两件事:写注释,和别人的代码没写注释。'
[values] 快照 keys=['topic', 'joke']
最终 output: 程序员最讨厌的两件事:写注释,和别人的代码没写注释。
4.4 逐段讲解(零基础版)
用法 :stream.interleave("投影A", "投影B", ...) 返回 (名字, 内容) 二元的迭代器------name 告诉你"这条是哪个投影的",item 是那个投影的内容。
为什么说"真实顺序":看实测输出的顺序------
① [values] 初始快照 ← 图启动
② [values] refine_topic 后 ← 第一个节点干完
③ [messages] write_joke 说话 ← 第二个节点在"说话"
④ [values] 最终快照 ← 全部结束
这就是真实发生的时间线 :先启动、再改主题、再写字、最后落幕。在一个循环里就把整场"事件顺序"看全了。
和 v2 的对比 :v2 里想同时看两路,需要一个个 chunk 手动判断 chunk["type"];v3 的 interleave 直接给出交错好的时间线。
【知识点】跑完
interleave后,stream.output依然可读------多个投影/消费者是互不消耗的(都从同一条底层流复制各自的份)。
五、练习 3:子图投影------stream.subgraphs
5.1 这一课要解决的问题
"子图" = 把一个编译好的图当节点塞进另一个图 (像把一台小机器装进大机器)。问题来了:小机器在里面干活,从外面看得见吗?
v3 的答案:用 stream.subgraphs 专门看子图。
5.2 完整代码
python
# 练习3:子图投影------stream.subgraphs(graph_name/path/子图内消息)
from dotenv import load_dotenv
load_dotenv()
from typing import TypedDict
from langchain_deepseek import ChatDeepSeek
from langgraph.graph import StateGraph, START, END
model = ChatDeepSeek(
model="deepseek-v4-flash",
extra_body={"thinking": {"type": "disabled"}},
)
# ---------- 子图:内部调用 LLM ----------
class SubState(TypedDict):
topic: str
joke: str
def sub_llm(state: SubState):
r = model.invoke([{"role": "user", "content": f"讲个关于{state['topic']}的冷笑话,一句话"}])
return {"joke": r.text}
sub_builder = StateGraph(SubState)
sub_builder.add_node("sub_llm", sub_llm)
sub_builder.add_edge(START, "sub_llm")
sub_builder.add_edge("sub_llm", END)
sub = sub_builder.compile(name="joke_writer") # 给子图起个名试试
# ---------- 父图(子图当节点) ----------
class ParentState(TypedDict):
topic: str
joke: str
def prepare(state: ParentState):
return {"topic": state["topic"] + "(程序员版)"}
parent_b = StateGraph(ParentState)
parent_b.add_node("prepare", prepare)
parent_b.add_node("joke_agent", sub)
parent_b.add_edge(START, "prepare")
parent_b.add_edge("prepare", "joke_agent")
parent_b.add_edge("joke_agent", END)
parent = parent_b.compile()
# ---------- ① stream.subgraphs:子图级观察 ----------
print("=== ① stream.subgraphs 子图投影 ===")
stream = parent.stream_events({"topic": "程序员", "joke": ""}, version="v3")
for sg in stream.subgraphs:
print(f"子图: graph_name={sg.graph_name} | path={sg.path}")
for m in sg.messages: # 子图内部消息!
print(f" 子图内消息: {str(m.text)[:50]}")
# ---------- ② 对比:父流 messages 能看到子图消息吗? ----------
print("\n=== ② 父 stream.messages(对比观察)===")
stream2 = parent.stream_events({"topic": "猫", "joke": ""}, version="v3")
count = 0
for m in stream2.messages:
count += 1
print(f"父流 messages 条数 = {count}(猜猜是几条?)")
5.3 实测输出
=== ① stream.subgraphs 子图投影 ===
子图: graph_name=joke_agent | path=('joke_agent:6dd4929d-e6a3-d559-6c2d-b4c88a4429f5',)
子图内消息: 程序员最讨厌的两件事:写代码时别人打断他,以及写代码时别人不打断他------...
=== ② 父 stream.messages(对比观察)===
父流 messages 条数 = 0(猜猜是几条?)
5.4 逐段讲解(零基础版)
① 子图三件套:
python
for sg in stream.subgraphs: # 每"个小机器"一个对象
sg.graph_name # 名字(这里显示父图里的使用名 joke_agent)
sg.path # 它在哪(('joke_agent:运行时ID',))
for m in sg.messages: # ★ 直接读子图内部的"说话"
str(m.text)
- 对比一下:07 篇 v2 时代看子图要给父图
stream(..., subgraphs=True),还要手动解析 namespace 字符串 判断"这条是谁发的"。v3 直接结构化成graph_name/path/messages------不用再拆字符串了。
② 反直觉的"0 条":
父图的 stream.messages 不包含子图里的 LLM 消息 (实测 0 条)!这不是 bug,是分层设计:
- 父流 = "根作用域"的消息;
- 子图的内部消息,要去
stream.subgraphs → sg.messages里拿。
【记忆口令】要看嵌套里的东西,先去对应的投影里"开门"------这跟 v2 时代"必须 subgraphs=True"是同一个思想的 v3 版本。
六、练习 4:中断恢复(human-in-the-loop)
6.1 这一课要解决的问题
有些流程需要人在中途拍板 ("这条标语能发布吗?")。v3 提供了正规姿势:流程跑到一半暂停 ,等人批准后从暂停点接着跑。
6.2 完整代码
python
# 练习4:中断恢复------v3 的 interrupted/interrupts 投影 + Command(resume)
from dotenv import load_dotenv
load_dotenv()
from typing import TypedDict
from langchain_deepseek import ChatDeepSeek
from langgraph.graph import StateGraph, START, END
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.types import interrupt, Command
model = ChatDeepSeek(
model="deepseek-v4-flash",
extra_body={"thinking": {"type": "disabled"}},
)
class State(TypedDict):
topic: str
draft: str
status: str
def draft_node(state: State):
"""起草标语(LLM)"""
r = model.invoke([{"role": "user", "content": f"用一句话写个关于{state['topic']}的标语"}])
return {"draft": r.text}
def review_node(state: State):
"""审核点:暂停等人类批准"""
decision = interrupt({"question": "这条标语可以发布吗?", "draft": state["draft"]})
return {"status": f"审核结果: {decision}"}
def publish_node(state: State):
return {"status": state["status"] + " | 已发布"}
g = (
StateGraph(State)
.add_node("draft_node", draft_node)
.add_node("review_node", review_node)
.add_node("publish_node", publish_node)
.add_edge(START, "draft_node")
.add_edge("draft_node", "review_node")
.add_edge("review_node", "publish_node")
.add_edge("publish_node", END)
.compile(checkpointer=InMemorySaver()) # ★ 中断必须挂 checkpointer
)
config = {"configurable": {"thread_id": "v3-hitl-1"}} # ★ 必须 thread_id
# ---------- 第一段:跑到中断就停 ----------
print("=== 第一段(跑到中断)===")
stream = g.stream_events({"topic": "程序员", "draft": "", "status": ""}, config, version="v3")
for message in stream.messages:
print("draft:", str(message.text)[:60])
print("interrupted?", stream.interrupted) # ★ v3 中断检查(布尔)
print("interrupts:", str(stream.interrupts)[:150]) # ★ 中断载荷(列表)
# ---------- 第二段:审批恢复 ----------
print("\n=== 第二段(Command(resume) 恢复)===")
stream2 = g.stream_events(
Command(resume={"decisions": [{"type": "approve"}]}),
config, # ★ 同一个 thread_id!
version="v3",
)
final_state = stream2.output
print("final status:", final_state["status"])
6.3 实测输出
=== 第一段(跑到中断)===
draft: **"代码改变世界,逻辑驱动未来。"**
interrupted? True
interrupts: [Interrupt(value={'question': '这条标语可以发布吗?', 'draft': '**"代码改变世界,逻辑驱动未来。"**'}, id='831aa6934419f9cf9d28c1d327730e54')]
=== 第二段(Command(resume) 恢复)===
final status: 审核结果: {'decisions': [{'type': 'approve'}]} | 已发布
6.4 逐段讲解(零基础版)
流程 :draft_node(写草稿)→ review_node(暂停 ,等批准)→ publish_node(发布)。
第一段:
- 跑起来后,只有
draft_node的消息流出(审核还没发生); stream.interrupted=True------"这场直播暂停了,等人类回话";stream.interrupts= 暂停详情列表,里面Interrupt.value就是interrupt(...)里传的那个 dict(问题 + 草稿)。
第二段:三个要素缺一不可------
Command(resume={...}):把"人类的批复"送回去(review_node的interrupt(...)会直接返回这个值,于是继续往下跑);- 同一个
config(thread_id):告诉系统"找哪个暂停现场"; version="v3"。
为什么必须挂 checkpointer :暂停意味着"要存档 "(不然程序关了、现场没了)------InMemorySaver 就是存档器(05/06 篇学过,这里是它的用武之地)。
【知识点】生产环境中,第一段和第二段可以是隔了几天、两台机器:现场存在数据库(PostgresSaver),人类审批后就地恢复。
七、练习 5a/5b:原始协议事件与三大频道
7.1 这一课要解决的问题
前面用的全是"精修投影"(开箱即用的路)。现在把镜头拉近:信号线上原始的"小包裹"到底长什么样? 认识它们,你才能(在练习 6 里)自己造分拣插件。
7.2 完整代码(5a:信封 + messages 块)
python
# 练习5a:原始协议事件信封 + messages 内容块(§5+§6.1)
from dotenv import load_dotenv
load_dotenv()
from typing import TypedDict
from langchain_deepseek import ChatDeepSeek
from langgraph.graph import StateGraph, START, END
model = ChatDeepSeek(
model="deepseek-v4-flash",
extra_body={"thinking": {"type": "disabled"}},
)
class State(TypedDict):
topic: str
joke: str
def wj(state: State):
r = model.invoke([{"role": "user", "content": f"一句话冷笑话,关于{state['topic']}"}])
return {"joke": r.text}
g = (StateGraph(State).add_node("wj", wj).add_edge(START, "wj").add_edge("wj", END).compile())
# ---------- ① 原始事件信封:seq / method / namespace ----------
print("=== ① 原始协议事件(信封三件套)===")
methods = {}
first_three = []
stream = g.stream_events({"topic": "猫"}, version="v3")
for ev in stream: # ★ 直接迭代 run 对象 = 原始事件流
m = ev["method"]
methods[m] = methods.get(m, 0) + 1
if len(first_three) < 3:
first_three.append((ev["seq"], m, ev["params"]["namespace"]))
print("频道分布:", methods)
print("前 3 条信封(seq, method, ns):")
for t in first_three:
print(" ", t)
# ---------- ② messages 内容块:text-delta 打字机 ----------
print("\n=== ② messages 频道的原子块(text-delta 过滤)===")
stream2 = g.stream_events({"topic": "狗"}, version="v3")
for ev in stream2:
if ev["method"] != "messages":
continue
data = ev["params"]["data"]
if not isinstance(data, tuple): # messages 的 data 是 (事件, 元数据) 元组
continue
d = data[0]
if isinstance(d, dict) and d.get("event") == "content-block-delta":
delta = d.get("delta") or {}
if delta.get("type") == "text-delta": # 只放行文本增量
print(delta.get("text", ""), end="", flush=True)
print()
7.3 完整代码(5b:tools + lifecycle 频道)
python
# 练习5b:tools 频道 + lifecycle 频道(§6.2 + §6.3)
from dotenv import load_dotenv
load_dotenv()
from typing import TypedDict
from langchain_deepseek import ChatDeepSeek
from langchain.tools import tool
from langchain.messages import SystemMessage, ToolMessage
from langgraph.graph import StateGraph, START, END, MessagesState
from langgraph.prebuilt import ToolCallTransformer
model = ChatDeepSeek(
model="deepseek-v4-flash",
extra_body={"thinking": {"type": "disabled"}},
)
# ---------- 工具图:算数小 agent ----------
@tool
def add(a: int, b: int) -> int:
"""计算 a+b"""
return a + b
model_with_tools = model.bind_tools([add])
def llm_call(state: MessagesState):
return {"messages": [model_with_tools.invoke([SystemMessage(content="用工具算数")] + state["messages"])]}
def tool_node(state: MessagesState):
tc = state["messages"][-1].tool_calls[0]
res = add.invoke(tc["args"])
return {"messages": [ToolMessage(content=str(res), tool_call_id=tc["id"])]}
def should_go(state: MessagesState):
return "tool_node" if state["messages"][-1].tool_calls else END
gb = StateGraph(MessagesState)
gb.add_node("llm_call", llm_call)
gb.add_node("tool_node", tool_node)
gb.add_edge(START, "llm_call")
gb.add_conditional_edges("llm_call", should_go, ["tool_node", END])
gb.add_edge("tool_node", "llm_call")
gt = gb.compile()
# ---------- ① tools 频道原始事件 ----------
print("=== ① tools 频道(注册 ToolCallTransformer 后可见)===")
stream = gt.stream_events(
{"messages": [{"role": "user", "content": "算 3+4"}]},
version="v3",
transformers=[ToolCallTransformer], # ★ 不注册它,tools 频道不发射!
)
for ev in stream:
if ev["method"] == "tools":
print(" tools 事件:", str(ev["params"]["data"])[:110])
# ---------- ② stream.tool_calls 投影(便利视图) ----------
print("\n=== ② stream.tool_calls 投影 ===")
stream2 = gt.stream_events(
{"messages": [{"role": "user", "content": "算 5+6"}]},
version="v3",
transformers=[ToolCallTransformer],
)
for tc in stream2.tool_calls:
print(" tool_call:", tc.tool_name, tc.input)
# ---------- ③ lifecycle 频道(子图场景) ----------
print("\n=== ③ lifecycle 频道(子图 started/completed)===")
class SubState(TypedDict):
x: str
def sub_node(state: SubState):
return {"x": "done"}
sub = (StateGraph(SubState)
.add_node("sub_node", sub_node)
.add_edge(START, "sub_node")
.add_edge("sub_node", END)
.compile())
class PState(TypedDict):
x: str
pb = StateGraph(PState)
pb.add_node("child", sub)
pb.add_edge(START, "child")
pb.add_edge("child", END)
gp = pb.compile()
for ev in gp.stream_events({"x": ""}, version="v3"):
if ev["method"] == "lifecycle":
print(" lifecycle:", str(ev["params"]["data"])[:150])
7.4 实测输出
5a:
=== ① 原始协议事件(信封三件套)===
频道分布: {'values': 2, 'messages': 15}
前 3 条信封(seq, method, ns):
(1, 'values', [])
(2, 'messages', [])
(3, 'messages', [])
=== ② messages 频道的原子块(text-delta 过滤)===
狗为什么总是精神那么好?因为它有"狗"精神。
5b:
=== ① tools 频道(注册 ToolCallTransformer 后可见)===
tools 事件: {'event': 'tool-started', 'tool_call_id': '01a08a1a-...', 'tool_name': 'add', 'input':
tools 事件: {'event': 'tool-finished', 'tool_call_id': '01a08a1a-...', 'output': 7}
=== ② stream.tool_calls 投影 ===
tool_call: add {'a': 5, 'b': 6}
=== ③ lifecycle 频道(子图 started/completed)===
lifecycle: {'event': 'started', 'namespace': ['child:73af2b63-...'], 'graph_name': 'child', 'trigger_call_id': '73af2b63-...'}
lifecycle: {'event': 'completed', 'namespace': ['child:73af2b63-...']}
7.5 逐段讲解(零基础版)
① 信封三件套(每个包裹的"快递单"):
| 字段 | 含义 | 零基础比喻 |
|---|---|---|
seq |
第几号包裹(1,2,3...严格递增) | 快递单号顺序------排序用它 |
method |
哪个频道 | 快递的"品类"(生鲜/文件) |
params.namespace |
从哪层发出([]=根图) |
发件地址 |
params.data |
具体内容 | 箱子里的东西 |
② messages 频道的"原子块":
-
data是一个元组(事件dict, 元数据dict)------所以要data[0]。 -
事件dict 的
event字段是块的生命周期 :message-start → content-block-start → content-block-delta(很多条) → content-block-finish → message-finish一个块从"开始"到"结束",中间是很多条 delta(增量)。
-
delta 里
type: 'text-delta'是文字增量(还有 reasoning-delta 思考增量等)------这就是练习 1 里message.text背后流动的原料。
③ tools 频道(工具事件):
- 必须注册一个点名要 tools 的插件 (这里用了内置的
ToolCallTransformer)才看得到------因为"没人要的频道不发车"。 - 事件成对:
tool-started(谁开始、参数是什么)→tool-finished(结果是什么);都用tool_call_id配对("这次调用"的身份证)。
④ lifecycle 频道(谁生谁死):
- 跟踪"嵌套执行"的生命:
started/completed(还有 running/failed/interrupted); - 只在有嵌套(子图/子 agent)时出现------根图简单场景不发射(5a 的分布里就没有它);
graph_name是嵌套者的名字、trigger_call_id是"谁触发的"。
八、练习 6(压轴):亲手写自定义 transformer
8.1 这一课要解决的问题
内置投影不够用时(比如"我想把工具活动做成自己的一路流"),你可以自己写一个分拣插件,产出自己的投影。
8.2 完整代码
python
# 练习6(压轴):亲手写 transformer------命名频道/编译时注册/终值投影/未命名频道(§7)
from dotenv import load_dotenv
load_dotenv()
from typing import TypedDict
from langchain_deepseek import ChatDeepSeek
from langchain.tools import tool
from langchain.messages import SystemMessage, ToolMessage
from langgraph.graph import StateGraph, START, END, MessagesState
from langgraph.config import get_stream_writer
from langgraph.stream import ProtocolEvent, StreamChannel, StreamTransformer
model = ChatDeepSeek(
model="deepseek-v4-flash",
extra_body={"thinking": {"type": "disabled"}},
)
# ════════ 共用的工具图 ════════
@tool
def add(a: int, b: int) -> int:
"""计算 a+b"""
return a + b
mwt = model.bind_tools([add])
def llm_call(state: MessagesState):
return {"messages": [mwt.invoke([SystemMessage(content="用工具算数")] + state["messages"])]}
def tool_node(state: MessagesState):
tc = state["messages"][-1].tool_calls[0]
res = add.invoke(tc["args"])
return {"messages": [ToolMessage(content=str(res), tool_call_id=tc["id"])]}
def should_go(state: MessagesState):
return "tool_node" if state["messages"][-1].tool_calls else END
gb = StateGraph(MessagesState)
gb.add_node("llm_call", llm_call)
gb.add_node("tool_node", tool_node)
gb.add_edge(START, "llm_call")
gb.add_conditional_edges("llm_call", should_go, ["tool_node", END])
gb.add_edge("tool_node", "llm_call")
# ════════ ① 命名频道 transformer(文档 7.5)════════
class ToolActivity(TypedDict):
name: str
status: str
class ToolActivityTransformer(StreamTransformer):
required_stream_modes = ("tools",) # ★ 声明:打开 tools 发射
def __init__(self, scope: tuple[str, ...] = ()) -> None:
super().__init__(scope)
self.activity = StreamChannel[ToolActivity]("tool_activity") # ★ 命名频道
def init(self) -> dict:
return {"tool_activity": self.activity}
def process(self, event: ProtocolEvent) -> bool:
if event["method"] != "tools": # process 收到所有事件,自己过滤
return True
data = event["params"]["data"]
if isinstance(data, dict) and data.get("tool_name") and data.get("event"):
status = "error" if data["event"] == "tool-error" else "started"
self.activity.push({"name": data["tool_name"], "status": status})
return True
gt = gb.compile()
print("=== ① 命名频道:extensions['tool_activity'] ===")
stream = gt.stream_events(
{"messages": [{"role": "user", "content": "算 10+20"}]},
version="v3",
transformers=[ToolActivityTransformer], # ★ 调用时注册
)
for item in stream.extensions["tool_activity"]:
print(" activity:", item)
# ════════ ② 编译时注册(文档 7.8)════════
print("\n=== ② 编译时注册:compile(transformers=[...]) ===")
gt2 = gb.compile(transformers=[ToolActivityTransformer]) # ★ 注册进图
stream2 = gt2.stream_events({"messages": [{"role": "user", "content": "算 7+8"}]}, version="v3")
for item in stream2.extensions["tool_activity"]:
print(" activity(编译时注册):", item)
# ════════ ③ 终值投影(文档 7.7):finalize 里推一次 ════════
class StatsTransformer(StreamTransformer):
required_stream_modes = ("messages",)
def __init__(self, scope: tuple[str, ...] = ()) -> None:
super().__init__(scope)
self.total = 0
self.total_log = StreamChannel[int]()
def init(self) -> dict:
return {"total_events": self.total_log}
def process(self, event: ProtocolEvent) -> bool:
if event["method"] == "messages":
self.total += 1
return True
def finalize(self) -> None: # ★ 流结束后推终值
self.total_log.push(self.total)
self.total_log.close()
print("\n=== ③ 终值投影(统计 messages 事件数)===")
stream3 = gt.stream_events(
{"messages": [{"role": "user", "content": "算 1+1"}]},
version="v3",
transformers=[StatsTransformer],
)
vals = list(stream3.extensions["total_events"])
print(" extensions['total_events'] =", vals)
# ════════ ④ 未命名频道(文档 7.6):收集 custom 事件 ════════
class S4(TypedDict):
x: str
def progress_node(state: S4):
writer = get_stream_writer()
writer({"kind": "progress", "message": "第一步:读取数据"})
writer({"kind": "progress", "message": "第二步:处理完成"})
return {"x": "ok"}
g4 = (StateGraph(S4).add_node("progress", progress_node).add_edge(START, "progress").add_edge("progress", END).compile())
class CustomCollector(StreamTransformer):
required_stream_modes = ("custom",) # ★ 声明 custom 才收得到
def __init__(self, scope: tuple[str, ...] = ()) -> None:
super().__init__(scope)
self.log = StreamChannel() # ★ 未命名:进程内旁路
def init(self) -> dict:
return {"custom_log": self.log}
def process(self, event: ProtocolEvent) -> bool:
if event["method"] == "custom":
self.log.push(event["params"]["data"])
return True
print("\n=== ④ 未命名频道:收集 custom 事件 ===")
stream4 = g4.stream_events({"x": ""}, version="v3", transformers=[CustomCollector])
for item in stream4.extensions["custom_log"]:
print(" custom_log:", item)
8.3 实测输出
=== ① 命名频道:extensions['tool_activity'] ===
activity: {'name': 'add', 'status': 'started'}
=== ② 编译时注册:compile(transformers=[...]) ===
activity(编译时注册): {'name': 'add', 'status': 'started'}
=== ③ 终值投影(统计 messages 事件数)===
extensions['total_events'] = [30]
=== ④ 未命名频道:收集 custom 事件 ===
custom_log: {'kind': 'progress', 'message': '第一步:读取数据'}
custom_log: {'kind': 'progress', 'message': '第二步:处理完成'}
8.4 逐段讲解(零基础版)
Transformer 的"生命周期四方法"(一个插件的完整一生):
| 方法 | 什么时候被调用 | 干什么 |
|---|---|---|
init() |
流开始时 | 创建/交出你的投影({"名字": 频道}) |
process(event) |
每个包裹路过时 | 观察/累加/过滤(返回 False 会扣下包裹,默认 True 放行) |
finalize() |
流成功结束时 | 善后(如推最终统计值) |
fail(err) |
流出错时 | 把错误传播给投影 |
① 命名频道 :StreamChannel[ToolActivity]("tool_activity") ------给它起名 "tool_activity"。实测里从 stream.extensions["tool_activity"] 消费到 {'name': 'add', 'status': 'started'}。
注意:同文档 7.5------这个 transformer 自己声明了要 tools,所以它一注册,tools 频道就开了车。
② 两种注册方式:
- 调用时:
stream_events(..., transformers=[X])------ 实验用; - 编译时:
gb.compile(transformers=[X])------ 注册进图,以后每次运行都带 (生产用)。实测两者都能拿到activity。
③ 终值投影 :process 里默默计数(收到多少 messages 包裹),finalize 里推一次最终值 ------典型用法:"跑完给我一个汇总"。实测 [30]。
④ 未命名频道 :StreamChannel() 不带名字 = "进程内旁路" ------值只在 extensions 里可读,不会混进主事件流 。什么时候用它?文档说得很清楚:要装"不可序列化的东西"(promise、迭代器、类实例)时用未命名 ;命名频道的值会转发成主流的 custom:<名字> 事件,必须可序列化(比如这里的 dict)。
【进阶对照表(命名 vs 未命名)】
命名 StreamChannel("x")未命名 StreamChannel()extensions 可读 ✅ ✅ 混入主事件流(成为 custom:x)✅(必须可序列化) ❌(旁路) 适用 要给别的系统/日志看的数据 进程内对象(promise/迭代器)
九、练习 7:推理增量 + 异步并发(补漏)
9.1 这一课要解决的问题
两个补漏点:
- reasoning(思考过程) :DeepSeek 有"思考模式",回答前先打草稿。v3 能把这段草稿也流出来(
message.reasoning)------调试/学习/审计的利器; - 异步并发消费 :多个投影可以同时 读(
asyncio.gather),不用一个一个排队。
9.2 完整代码
python
# 练习7(补漏):reasoning 推理增量 + 异步并发消费(§4.1/§4.4)
from dotenv import load_dotenv
load_dotenv()
import asyncio
from typing import TypedDict
from langchain_deepseek import ChatDeepSeek
from langgraph.graph import StateGraph, START, END
# ════════ 两个模型:开思考(看推理) / 关思考(跑得快)════════
model_think = ChatDeepSeek(model="deepseek-v4-flash") # 默认:开思考
model_fast = ChatDeepSeek(
model="deepseek-v4-flash",
extra_body={"thinking": {"type": "disabled"}}, # 关思考
)
class State(TypedDict):
topic: str
joke: str
def think_node(state: State):
r = model_think.invoke([{"role": "user", "content": f"用一句话讲个关于{state['topic']}的冷笑话"}])
return {"joke": r.text}
g = (StateGraph(State).add_node("think_node", think_node).add_edge(START, "think_node").add_edge("think_node", END).compile())
# ════════ 第一部分:message.reasoning(看模型"怎么想")════════
print("=== ① message.reasoning 推理增量 ===")
stream = g.stream_events({"topic": "猫"}, version="v3")
reasoning_parts, text_parts = [], []
for message in stream.messages:
for r in message.reasoning: # ★ 推理增量(可迭代,像 message.text 一样)
reasoning_parts.append(str(r))
for t in message.text: # 最终答案增量
text_parts.append(str(t))
print("推理片段数:", len(reasoning_parts))
print("推理过程前 100 字:", "".join(reasoning_parts)[:100])
print("最终答案:", "".join(text_parts)[:80])
# ════════ 第二部分:异步并发消费(asyncio.gather)════════
def fast_node(state: State):
r = model_fast.invoke([{"role": "user", "content": f"一句话冷笑话,关于{state['topic']}"}])
return {"joke": r.text}
g2 = (StateGraph(State).add_node("fast_node", fast_node).add_edge(START, "fast_node").add_edge("fast_node", END).compile())
async def main():
"""异步入口:astream_events 拿异步流,两个消费者并发读"""
stream = await g2.astream_events({"topic": "鱼"}, version="v3") # ★ await
async def consume_messages():
n = 0
async for message in stream.messages: # ★ async for
n += 1
return f"messages 消费完成:{n} 条"
async def consume_values():
n = 0
async for snap in stream.values:
n += 1
return f"values 消费完成:{n} 条快照"
# ★ 关键:gather = 两个消费者"同时开工",各读各的投影
results = await asyncio.gather(consume_messages(), consume_values())
return results
results = asyncio.run(main()) # 启动异步世界(跑完自动关闭)
for r in results:
print(" ", r)
print("异步 gather 成功")
9.3 实测输出
=== ① message.reasoning 推理增量 ===
推理片段数: 529
推理过程前 100 字: 我们需要回答用户中文请求:"用一句话讲个关于猫的冷笑话"。需要一句话,关于猫,冷笑话。需要幽默冷...
最终答案: 猫不去上班,因为它有"猫病"。
messages 消费完成:1 条
values 消费完成:2 条快照
异步 gather 成功
9.4 逐段讲解(零基础版)
① reasoning :message.reasoning 和 message.text 是一对镜像------前者装"思考草稿"(529 个片段!模型碎碎念很多),后者装"最终答案"(一两句)。打开思考时能看到模型"怎么想的",这是排查"它为什么答歪了"的利器。
② 异步三件套(新手记住这三样就够):
async def/await:声明并等待"异步操作";async for:异步版 for 循环;asyncio.gather(任务A, 任务B):两个消费者同时开工,都完成时汇总各自的结果;asyncio.run(main()):把异步世界"点着"(跑完自动收尾)。
为什么需要:真实产品里"推字给前端"和"更新状态面板"是两个消费者------同步只能排队,异步能并行。
十、概念速查表(零基础版·随时回查)
10.1 投影一览
| 投影 | 内容 | 类似什么 |
|---|---|---|
stream.messages |
模型消息/逐字增量(.text/.reasoning/.node) |
直播的字幕 |
stream.values |
状态快照流 | 游戏存档自动快照 |
stream.output |
终值(读它=等结束) | 下课铃后拿到的成绩单 |
stream.subgraphs |
嵌套子图(graph_name/path/内部 messages) |
小机器的工作监控 |
stream.interrupts / stream.interrupted |
中断载荷 / 是否暂停 | 暂停键 + 暂停原因条 |
stream.extensions |
自定义 transformer 的投影 | 自己装的仪表盘 |
10.2 频道一览(原始事件)
| 频道 | 内容 | 触发条件 |
|---|---|---|
values / updates |
状态/增量 | 默认 |
messages |
内容块(message-start/block-delta/finish...) | 默认(有 LLM 调用) |
tools |
工具开始/输出/结束/错误 | 需注册声明 tools 的 transformer |
lifecycle |
嵌套执行 started/completed/failed/interrupted | 有子图/子 agent 时 |
custom / custom: |
用户自定义载荷 | 图里 writer 发出 / 命名频道转发 |
10.3 常用代码模板
python
# 打字机
for message in stream.messages:
for token in message.text:
print(token, end="", flush=True)
# 等终值
final = stream.output
# 多路交错
for name, item in stream.interleave("values", "messages"):
...
# 看子图
for sg in stream.subgraphs:
for m in sg.messages: ...
# 中断恢复
if stream.interrupted:
print(stream.interrupts)
stream2 = graph.stream_events(Command(resume=...), config, version="v3")
# 原始事件
for ev in stream:
ev["seq"], ev["method"], ev["params"]["namespace"], ev["params"]["data"]
十一、踩坑清单
| # | 坑 | 现象 | 解决 |
|---|---|---|---|
| 1 | v3 下 content 是列表 | r.content 返回 [{'type': 'text', ...}] |
用 r.text 拿纯文本 |
| 2 | 父流看不见子图消息 | 父 stream.messages 为 0 条 |
去 stream.subgraphs → sg.messages |
| 3 | tools 频道"不注册就看不到" | 裸迭代没有 tools 事件 | 注册声明 required_stream_modes=("tools",) 的 transformer(如内置 ToolCallTransformer) |
| 4 | messages 的 data 是元组 | 直接 data["event"] 报错 |
data[0] 才是事件 dict |
| 5 | 中断必须 checkpointer + thread_id | 报错/无法恢复 | compile(checkpointer=...) + config={"configurable": {"thread_id": ...}} |
| 6 | 命名频道必须可序列化 | 推 promise/迭代器报错 | 不可序列化的东西放未命名频道 |
| 7 | 声明只管"发车"不管"过滤" | 以为声明了就不收别的 | process() 会收到所有 事件,自己按 event["method"] 过滤 |
| 8 | 别用 timestamp 排序 | 顺序偶发混乱 | 用 seq(严格递增) |
| 9 | v3 是实验特性 | 有 LangChainBetaWarning | 忽略即可(学习/实验阶段正常) |
| 10 | Windows 控制台中文/特殊字符 | GBK 报错 | 避免在 print 里用 ✓/emoji 等非 GBK 字符 |
十二、复盘与学习路线
能力全景(学完 08 篇)
✅ 三投影:messages(含 text/reasoning 增量)/ values / output
✅ 多路消费:interleave 交错 + asyncio.gather 异步并发
✅ 子图投影:graph_name / path / 内部消息(不用解析 ns 字符串)
✅ 中断恢复:interrupted / interrupts / Command(resume)
✅ 原始事件:信封(seq/method/namespace/data)+ 三大频道(messages 块/tools/lifecycle)
✅ 自定义 transformer:命名/未命名频道、终值投影、两种注册方式
✅ 完整理解了 v3 两层架构:Pregel 引擎 → 事件路由 → transformers → 类型化投影
两条流式路线对照(07 篇 vs 08 篇)
| v2(stream/stream_mode) | v3(stream_events/投影) | |
|---|---|---|
| 定位 | 底层图运行时事件 | 应用层类型化投影 |
| 分流方式 | chunk["type"] 手动分支 |
投影各自独立读 |
| 多路 | 手动拆 | interleave / gather |
| 嵌套 | subgraphs=True + 解析 ns | stream.subgraphs 结构化 |
| 扩展 | 无 | 自己写 transformer |
| 官方建议 | 需要底层事件时用 | 新应用推荐 |