LangGraph 实战 07:Event Streaming 事件流式——类型化投影与频道机制

📖 导读:这篇文档怎么读

  • 不需要任何流式的前置知识。本文从"为什么需要它"讲起,一层层往上垒。
  • 遇到看不懂的名词(投影、频道、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)就是"给工程师看的原始信号" ------每一路原始信号叫一个频道,名字如 valuesmessagestoolslifecycle

  • 原始协议事件(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.messagesstream.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(问题 + 草稿)。

第二段:三个要素缺一不可------

  1. Command(resume={...}):把"人类的批复"送回去(review_nodeinterrupt(...) 会直接返回这个值,于是继续往下跑);
  2. 同一个 config(thread_id):告诉系统"找哪个暂停现场";
  3. 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 这一课要解决的问题

两个补漏点:

  1. reasoning(思考过程) :DeepSeek 有"思考模式",回答前先打草稿。v3 能把这段草稿也流出来(message.reasoning)------调试/学习/审计的利器;
  2. 异步并发消费 :多个投影可以同时 读(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 逐段讲解(零基础版)

① reasoningmessage.reasoningmessage.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
官方建议 需要底层事件时用 新应用推荐

相关推荐
半糖程序员1 小时前
从零构建 Agent(3):接入阿里云百炼
agent
武子康1 小时前
SGLang 回答慢,时间究竟花在了哪里?
人工智能·llm·agent
超级无敌霹雳大狗熊1 小时前
以最小demo讲解Eino中的graph编排
go·agent
mldong1 小时前
AI Agent 不能自己签字:用 Python 工作流引擎给 AI 加一道人类审批闸门
后端·python·agent
ShallWeL2 小时前
【Agent工程】(15)—— 评测集与回归门禁
人工智能·agent·工作流
七夜zippoe2 小时前
从 Prompt Engineering 到 Agent Engineering:开发范式的代际跃迁
android·ai·prompt·agent·engineering
七夜zippoe2 小时前
AI Agent 的三位一体架构:模型(大脑)+ 工具(双手)+ 记忆(海马体)的深度解析
人工智能·ai·架构·agent·三位一体