W5-D4 · 观察一张图:四种流式看法
关键词:
stream_mode/ updates / values / messages /astream_events/ async / await 前置:W5-D3(ReAct 图 agent ↔ tools 循环);本篇含 Python 异步零基础课 涉及文件:W5/lg_d4_stream_modes.py收尾预告:D5 State Schema 进阶(TypedDict → Pydantic)——为 MemoryManager 图和 W6 打底
零、开场:D3 造了机器,D4 学"看"机器
D3 你搭了一张会自己转圈的 ReAct 图:agent ↔ tools 循环,模型调工具、工具回填、再决策——机器造好了。
D4 学的是怎么观察它运转。同一张图,四种看法,粒度从粗到细:
| 看法 | 粒度 | 你看到的东西 | 类比 |
|---|---|---|---|
updates | 节点级 | 谁干完了、改了哪格 | 车间广播:某工位完工,“我改了这几个字段” |
values | 节点级 | 每一步后的完整 state | 整张工单拍照:每步拍一张全貌 |
messages | token 级 | 模型逐字吐字 | 打字机:一个字一个字蹦 |
astream_events | 事件级 | token + 工具开始/结束全记录 | 导演监视器:每句词、每个动作都带时间戳 |
把一次"图执行"当一部电影:updates 是分镜摘要(谁出场做了什么),values 是每一帧完整截图,messages 是角色逐字念台词,events 是带导演注释的完整记录。
选型口诀(今天最值钱的一句话):
🎯 调结构用 updates,看全貌用 values,做产品用 messages,做监控用 events。
前三种都是你熟悉的同步写法,第四种 astream_events 一上来就是 async——如果你没学过异步,会直接卡死在这一行 async for。所以这篇在讲第四种之前,先补一章异步零基础课(第四节),别跳过。
一、看法① updates:谁改了什么(车间广播)
默认模式。D1–D3 你跑 graph.stream() 看到的就是它:每个节点执行完,吐一个 {节点名: 该节点更新的字段}。
| |
| |
关键点:每个 event 只有本节点的增量——它不携带其他字段,所以轻、干净、一眼能定位"谁干了什么"。
用途:调试图结构。图跑歪了,看 updates 就知道是哪个节点改了不该改的字段、哪个节点压根没执行。
类比:车间广播只在完工时响一声:“3 号工位完成,改了
result字段。” 你不听全程,只听广播就能知道流水线进行到哪、每步动过什么。
二、看法② values:每一步的完整快照(工单拍照)
updates 给你增量,values 给你全量——每一步执行完之后,整个 state 长什么样。
| |
| |
注意第一行是"第 0 步"——values 模式从初始 state 就开始吐,比 updates 多一个起点快照。
用途:理解状态演变。看着 messages 列表怎么从 1 条长到 6 条,add_messages reducer 有没有生效、每次加了什么,用 values 最直观。
updates vs values:一张表说清
| updates | values | |
|---|---|---|
| 内容 | 本节点的增量 | 执行后的完整 state |
| 每条多大 | 小(只含改动字段) | 大(全字段,消息多了会很长) |
| 有几条 | 等于"有更新的节点数" | 等于"节点数 + 1"(含初始) |
| 适合 | 调结构:谁改了什么 | 看演变:状态怎么一步步长 |
类比:updates 是车间广播,values 是整张工单拍照。广播只说改动,照片连没动过的格子都拍下来了——信息全,但张张都大。values 模式打印多了会刷屏,实际用的时候只挑你想看的字段打(比如只打
len(messages))。
三、看法③ messages:逐字念台词(打字机地基)⭐
3.1 它和前两个最大的不同:按 token 吐,不按节点吐
updates / values 都是节点执行完才吐一次。messages 模式在 LLM 生成的过程中就吐——模型每生成一个 token,就 yield 一次 (AIMessageChunk, metadata)。
| |
flush=True 很重要——print 默认有缓冲,不加它,逐字效果会变成一次性蹦出来。
3.2 为什么 chunk 经常是"空的"
模型在工具调用阶段输出的不是文字,是参数 JSON。它"想"调 add(15, 27) 时,流出来的是 {"a": 15, "b": 27} 这种结构——content 是空的,文字在 tool_calls 字段里。
| |
所以循环里必须 if chunk.content: 过滤——不然你会打出一堆空行。
⚠️ 顺带解答一个常见困惑:
stream_mode="messages"只吐 LLM 生成的 token。工具执行阶段(ToolNode在跑代码)没有流——那是代码在算,不是模型在说话。
3.3 metadata["langgraph_node"]:这一句是谁说的
每个 chunk 还带一个 metadata 字典,里面最有用的是 langgraph_node——告诉你这个 token 来自哪个节点:
| |
单 Agent 图里它永远是 "agent",看着没意思。但到了 W7 多 Agent 图(supervisor + 多个 worker),每句话是谁说的全靠它区分——“当前是谁在说话"是个必须能回答的问题。
3.4 🎯 封装成打字机函数(W10 直接搬)
messages 模式是做产品用的——把 print 换成 yield,就是一个生成器;W10 用 FastAPI SSE 把它推给前端,浏览器就能逐字显示了:
| |
| |
类比:updates 是车间广播、values 是拍照,
messages是角色在台上逐字念台词——只有它能还原"说话的过程”,所以打字机、直播、聊天界面全用它。
四、异步第一课(为 astream_events 铺路)
第四种看法 astream_events 是 async 的。如果你没学过异步,async for 就是天书。 这一节从零讲——不深入语言细节,只讲够用、且讲"为什么"。
4.1 先感受问题:同步代码的"干等"
你写的普通 Python 代码是同步的:一行执行完才执行下一行。遇到要等待的操作(网络请求、读文件、API 调用),整个程序就卡住干等:
| |
time.sleep(3) 是"阻塞式等待":进程停摆 3 秒,期间连另一件不相关的事(比如给另一个用户回话)都做不了。对命令行脚本无所谓;但对"一个服务同时服务很多用户"的程序,这是灾难——一个用户慢,所有人排队。
类比:你去银行柜台办业务,柜员全程只服务你,你填表 3 分钟他就在旁边干等 3 分钟——后面排队的全被你堵住了。这是"同步柜台"。
4.2 异步的答案:等的时候,先干别的
异步(async)解决的就是"等待时浪费 CPU":一个任务在等 IO 的时候,先把控制权交出来,让程序去跑别的任务;等的结果回来了,再回来继续。
类比:咖啡馆的做法——下单后给你个号牌,你先找座等着,咖啡好了叫号。咖啡师不会因为你那杯要等 3 分钟就停手,他同时在冲别人的咖啡。一个咖啡师服务很多人,靠的不是分身,是"不等"——每个订单等待时他都在做别的事。
网络 IO(调 API、查数据库、等工具结果)是典型的"咖啡等待"——大部分时间花在网络上,CPU 其实闲着。异步就是把这些"闲着等"的时间利用起来。
4.3 三件套:async def / await / asyncio.run()
Python 异步的最小语法就三个:
| |
逐个拆:
① async def 定义的不是普通函数,是"协程(coroutine)"——一个可以暂停、可以恢复的函数。普通函数一旦开始就必须跑完;协程可以"跑到一半挂起,等会儿再回来"。
| |
② await 是暂停点:await asyncio.sleep(1) 的意思是"我要等 1 秒,这 1 秒里控制权交给事件循环,你去忙别的;1 秒到了叫我回来"。
③ asyncio.run(coro) 是唯一的入口:它创建事件循环 → 把协程放进去跑 → 跑完收尾。普通脚本里所有异步代码,最终都要靠它启动(后面 astream_events 也一样)。
4.4 事件循环:那个"咖啡师"
协程能"暂停/恢复"、多个任务能"交错推进",靠的是一个藏在后台的事件循环(event loop):
| |
asyncio.run() 就是**“开店打烊”**:开店(建事件循环)→ 让里面的任务自己交错跑 → 全部完工 → 打烊(关循环)。事件循环自己不写代码,但你要理解"await 的背后有个调度者在盯着"——这解释了为什么 await 时程序没死,而是在干别的。
4.5 async for:异步迭代
第四个语法点,也是 astream_events 直接用的:async for(异步迭代)。
普通 for 从迭代器里同步取元素——取不到就一直等,期间程序卡住。async for 从异步迭代器里取元素——等下一个元素时,当前协程挂起,事件循环去干别的:
| |
类比:同步
for像在窗口排队领号——前面没人发号你就干站;async for像坐下等叫号——等着的时候你还能刷手机(事件循环去跑别的任务)。
4.6 一个能跑的对照实验
把 4.3 的例子扩展成"两件事同时进行",感受异步的威力:
| |
输出顺序:
| |
看时间线:咖啡任务在第 0 秒启动后 await sleep(2) 挂起 → 主任务趁机跑了 1 秒 → 第 1 秒主任务也挂起等咖啡 → 第 2 秒咖啡完成,唤醒等待者。两件事交错推进,总耗时 2 秒而不是 2+1=3 秒——这就是"等待时不浪费"。
💡 对比同步写法:两个
time.sleep串行就是 3 秒。异步不是"跑得更快",是**“等得更聪明”**——把等待的时间重叠了。
4.7 小结:异步四板斧(够用版)
| |
你没学的部分还多(Task 并发、gather、Future…),但用 astream_events 只需要这四板斧——它把复杂的调度全藏起来了,你要做的只是 async def 包一层 + async for 收事件 + asyncio.run() 启动。
五、看法④ astream_events:导演监视器(站在异步上)
5.1 为什么它没有同步版
看名字:astream_events——开头的 a = async。它是异步 API,没有对应的同步 stream_events。
为什么设计成异步?因为事件流是持续不断产生事件的管道——token 在来、工具在跑、节点在切换。用 async for 接收时,等下一个事件期间协程挂起、不阻塞其他任务——这对"一个服务同时跑多个 Agent 会话"是刚需(W10 的 FastAPI 就是异步服务器)。它天生为"并发场景"设计,所以 API 也是异步的。
5.2 能抓到什么:三类事件
updates / values / messages 都只给你"结果"。astream_events 给你过程里的一切——连工具什么时候开始、什么时候结束都抓得到:
| |
你会看到事件交错出现:
| |
同一个循环里,token 流和工具事件按真实时间顺序混在一起——这就是"导演监视器":全片过程带时间戳地重放。
5.3 两个必写参数 / 概念
version="v2"必须显式写:不写会DeprecationWarning(v1 要被淘汰了,固定 v2)。ev是个大字典,常见字段:event(事件名)、name(谁触发的,如工具名)、data(内容)、metadata(含run_id)。metadata["run_id"]是这次运行的唯一编号——把多个事件串成一条"trace"就靠它。W7 Harness 的 Trace 模块(记录每个节点耗时 / token)的原料就是它。
5.4 🎯 什么时候用它,什么时候别用
| |
选型收口:做产品用 messages(简单够用),做监控用 events(全量可观测)。
六、完整可运行代码(W5/lg_d4_stream_modes.py)
自包含版(复制 D3 构图,避免 import 路径问题)。api_key 换成你的读取方式即可:
| |
看懂最后一段的异步骨架——它就是我们第四节学的四板斧的完整落地:
| |
七、选型总表 + 多模式组合
7.1 四看法对比表(背这张)
| stream_mode | 输出粒度 | 输出内容 | 什么时候用 |
|---|---|---|---|
"updates"(默认) | 节点级 | {节点: 更新dict} | 调结构:哪个节点改了什么 |
"values" | 节点级 | 完整 state 快照 | 看全貌:状态怎么一步步演变 |
"messages" | token 级 | (AIMessageChunk, metadata) | 做产品:打字机、SSE、实时显示 |
astream_events(v2) | 事件级(最细) | 全部事件(token/工具/节点) | 做监控:可观测性、trace、埋点 |
7.2 还想两个都要?多模式组合
stream_mode 可以传一个列表,同时拿到两种粒度——每步 yield 的是 (mode, data) 元组:
| |
⚠️ 组合模式必须解包元组——
for mode, data in ...,只写一个变量会直接TypeError。
7.3 顺带认识两个"了解即可"的参数
| |
八、踩坑记
坑 1:messages 模式 content 全空 🔴
- 现象:打字机打了半天,全是空行
- 原因:模型在 tool_calls 阶段输出的是参数 JSON,不是文字——
content为空 - 解法:
if chunk.content:过滤 - 💡 工具调用阶段本来就没有文本 token,这不是 bug 是模式特点
坑 2:astream_events 在普通函数里跑报错 ⚠️
- 现象:
await/async for在普通函数里直接语法错误或RuntimeError - 原因:它是异步 API,只能在
async函数里用 - 解法:
async def run_events():包一层 +asyncio.run(run_events())
坑 3:astream_events 报 DeprecationWarning ⚠️
- 现象:控制台黄色警告
- 原因:没指定 version,默认走要被淘汰的 v1
- 解法:固定写
version="v2"
坑 4:await 只能在 async 函数里用 ⚠️
- 现象:
SyntaxError: 'await' outside function或RuntimeError - 原因:混淆了"协程"和"普通函数"的边界
- 解法:记住分层——
async def(能 await 的层)→ 普通 def(不能);跨层要用asyncio.run()或包一层 async
坑 5:调 async def 函数但它"没执行" ⚠️
- 现象:函数里的 print 一个都没打
- 原因:调用协程函数只创建协程对象,不执行——必须交给事件循环(
asyncio.run/await/create_task) - 解法:所有异步入口最终都要
asyncio.run(...)启动
坑 6:以为 stream 会自动进浏览器 ⚠️
- 现象:本地跑出打字机,浏览器却啥也没有
- 原因:LangGraph 的 stream 是 Python 侧的输出——它把 token 吐给你了,但没送到前端
- 解法:W10 用 FastAPI SSE 包一层
stream_answer,浏览器才能逐字收到
坑 7:多模式组合忘了解包元组 ⚠️
- 现象:
TypeError: cannot unpack non-iterable ... - 原因:
stream_mode=["updates", "messages"]每步 yield(mode, data) - 解法:
for mode, data in graph.stream(...)
坑 8:values 模式刷屏 ⚠️
- 现象:完整 state 太长,终端被消息列表淹没
- 解法:只打印关心的字段(
len(messages)、最后一条的类名),别整个 state 直接 print
九、速查卡片(复习直接看这)
| |
十、一句话总结
D3 造了机器,D4 学会了"看"机器——同一张 ReAct 图,四种看法粒度从粗到细:updates 是节点级增量(调结构)、values 是完整快照(看演变)、messages 是 token 级流式(做打字机)、astream_events 是事件级全记录(做监控)。
而本篇真正多学的一块,是异步:
| |
astream_events 用到的就这四板斧——你没学的不是"async 语法",是"等待时不浪费"的心智模型。一旦接受"await = 把控制权交出去,好了叫我",异步代码读起来就和同步一样顺了。
回头看这条线的落点:
messages模式 + 今天的stream_answer生成器,是 W10 打字机的第一块地基;metadata["langgraph_node"]是 W7 多 Agent“谁在说话"的答案;run_id是 W7 Trace 模块的原料。今天学的每一种"看法”,都在给后面某个零件备料。
下一篇:D5 State Schema 进阶——从 TypedDict 升级到 Pydantic(带校验)、嵌套字段、Annotated reducer 的原理。为 Day6 的 MemoryManager 图(多字段复杂状态)打底,也为 W6 的 Checkpointer / HITL 做铺垫。
🔴 魔鬼代言人(跑完四种模式,回答自己三个问题)
① 为什么 messages 模式在"调 add 工具"那一段会有一段空白?
提示:模型那时候在输出什么?——它在输出 {"a": 15, "b": 27} 参数 JSON,不是人话。没有文本 token,当然空白。
② 从 values 的输出里,你能数出 messages 一共累加了几条?和 updates 的节点数对得上吗?
对照:values 从"第 0 步初始快照"开始吐,节点每执行一次 +1 条消息。数出来应该是 6 条消息(Human + 2×AI + 2×Tool + 最终 AI)…… 等等,别急着信这个数——去数你自己的输出。
③ 哪个数字是你"算"出来的、哪个是"拍脑袋"的?
这就是今天想让你养成的习惯:跑完程序,先看真实输出,再对照自己的预期。输出和预期对不上时,先怀疑"拍脑袋"的那一半——通常是你对图的理解有盲区,而不是框架出错。