《LangGraph 从入门到实战》走到第 9 篇,图的骨架已经齐装满员:条件边管路由,create_agent 出成品代理,子图组多代理,checkpointer 落档,Store 管长期记忆。但驱动它们的始终是同一个动作——invoke。invoke 把整张图当一个黑盒:喂进去输入,等到底,等回一份最终 State。演示无妨,真实产品里用户盯着空转的界面等三十秒,体验当场判死刑;工程师这边更被动——图卡住了不知道卡在哪个节点,答非所问定位不到是哪一步的锅。本篇是第 10 篇,兑现第 1 篇预告的第四类超能力"可观测":把执行过程从黑盒变成一等输出。
版本口径与全系列一致:langgraph 1.2.x,PyPI 独立发布,流式接口以官方 Streaming 文档为准,LangSmith 部分对照官方 Tracing quickstart。全文按"黑盒之痛 → stream_mode 全景 → 状态级流式 → token 级打字机 → LangSmith 自动追踪 → custom 本地观测 → 审稿流水线实战 → 总结"展开。
1、为什么图要流式
invoke 的问题拆开是三层。用户层:LLM 生成慢是物理事实,多步 Agent 跑半分钟很正常,黑盒等到底,用户唯一能做的只剩刷新页面。工程层:图一旦不按预期走——循环多转一圈、路由走错分支——invoke 只交出最终黑板,中间过程全靠脑补。运维层:生产上要回答"这条请求哪一步最慢、烧了多少 token",invoke 一个都答不了。
答案埋在第 1 篇的论断里:结构显式化之后,框架获得了在结构上做事的位置。图的执行天然按超步推进,每个超步边界就是一个可以对外广播的切分点——框架看得见流程,就能把"跑到哪了、产出了什么"实时吐出来。stream 干的就是这件事:把 invoke 的一次性终点输出,换成一路走一路吐的片段流。对用户它是进度条与打字机,对工程师它是执行轨迹,对运维它是延迟与计费的原始数据。执行过程透出的通道全景画出来是这样:
解读:三条流式通道在运行中实时消费,LangSmith 在运行后归档回查——同一份执行事实,四种吃法。三件观测兵器的分工对照如下:
| 手段 | 看什么 | 时机 |
|---|---|---|
| stream | 执行过程逐片段吐出 | 运行中 |
| get_state | 某 thread 的当前快照 | 运行前后,第 8 篇 |
| LangSmith | 全历史 trace 与指标 | 事后回查,本篇 |
一句话收束:get_state 是拍照片,stream 是放录像,LangSmith 是档案馆——本篇把后两件补齐。
2、stream_mode 全景
stream 与 invoke 同参数同配置,区别只在返回:stream 返回生成器,逐片段产出过程。片段长什么样,由 stream_mode 参数决定,官方当前口径共七种模式,各自输出形态对照如下:
| 模式 | 输出形态 | 典型用途 |
|---|---|---|
values |
每个超步后的完整 State | 看黑板逐步演化 |
updates |
每步的状态增量,键为节点名 | 轻量调试与执行轨迹 |
messages |
模型消息分片加元数据的二元组 | token 级打字机 |
custom |
节点内手动写入的自定义数据 | 进度条与业务事件 |
debug |
任务与快照加元数据,信息最全 | 深度排查 |
checkpoints |
每超步的存档事件,同 get_state | 持久化观测 |
tasks |
任务开始结束、结果与报错 | 定位卡死与失败 |
表后交代三条边界。其一,前五种模式随时可用,checkpoints 与 tasks 要先挂第 8 篇的 checkpointer,debug 近似它们的打包加强版。其二,stream_mode 可以传列表,如 ["messages", "updates"],此时片段变成(模式,片段)二元组,打字机与进度条一条流全拿。其三,嵌套图的片段默认不透出,要加 subgraphs=True 才穿透子图,第 7 篇埋的伏笔本篇兑现。版本演进顺带一句:langgraph 1.1 起支持统一格式的 version="v2",1.2 又引入更高层的 event streaming 接口,本系列沿用经典默认格式,与第 2 篇以来的口径一致。
3、values 与 updates 状态级流式
把第 2 篇的两节点写作图(make_outline → write_article)请回来当实验台。两种状态级模式的差别一句话:values 吐全量,updates 吐增量。
3.1、values 每步完整状态
values 模式下,每个超步结束吐一份当时的完整黑板,第一个片段是初始输入。运行代码如下:
for chunk in graph.stream({"topic": "LangGraph 流式"},
stream_mode="values"):
print(chunk)
输出如下:
{'topic': 'LangGraph 流式'}
{'topic': 'LangGraph 流式', 'outline': 'LangGraph 流式:现状、原理、风险'}
{'topic': 'LangGraph 流式', 'outline': 'LangGraph 流式:现状、原理、风险', 'article': '按提纲[LangGraph 流式:现状、原理、风险]扩写 600 字'}
解读:三个片段对应输入落板、提纲写完、成稿写完三个时刻,黑板怎么长起来的全程可回放,验证 reducer 合并结果也方便。缺点同样明显——State 一大,每个片段都是全量拷贝,日志体积与阅读负担都线性上涨。
3.2、updates 只看增量
updates 只吐每个节点写回的那部分,片段的键就是节点名——第 2 篇正是用它验证执行顺序。同一张图仅把 stream_mode 换成 updates,输出如下:
{'make_outline': {'outline': 'LangGraph 流式:现状、原理、风险'}}
{'write_article': {'article': '按提纲[LangGraph 流式:现状、原理、风险]扩写 600 字'}}
解读:信息密度高得多——"哪个节点写了什么"一眼即知,片段数就是超步数,这就是它被当成默认调试模式的原因。再补一个第 6 篇的联动:图在 interrupt 处挂起时,updates 流会多吐一个 __interrupt__ 键的片段,载荷正是那张审批单——流式轨迹与中断恢复在同一套接口里闭环。
4、messages 模式与打字机
状态级片段再细,也得到节点跑完才吐;用户最想要的反馈是模型正在生成的那个字。messages 模式专干这件事:图里凡是挂了聊天模型——哪怕节点内部用的是普通 invoke——模型产生的消息分片都会被逐段透出,片段是(消息分片,元数据)二元组。第 5 篇留的尾巴在这里收掉,用它的 create_agent 直接演示:
from langchain.agents import create_agent
agent = create_agent(model="openai:gpt-6", tools=[count_words])
for chunk, meta in agent.stream(
{"messages": [{"role": "user",
"content": "用一句话介绍 LangGraph"}]},
stream_mode="messages",
):
print(chunk.content, end="", flush=True) # 打字机
输出如下:
LangGraph 是把 Agent 工作流表达成图、以共享状态驱动的编排框架。
解读三件事。其一,chunk 是 AIMessageChunk,content 是一小段文本——一个 token 或几个字符,print 不换行接龙,前端拿到的就是打字机效果。其二,meta 里最值钱的是 langgraph_node,它标明哪个节点在说话,前端能区分"正在生成"与"正在调工具"。其三,嵌套图(create_agent 本身也是一张图)的消息默认不透出,加 subgraphs=True 后片段再带一层命名空间,变成三元组。
打字机只覆盖"模型在说话","图跑到哪了"还得靠 updates——两个模式可以同开。双通道写法如下:
for mode, chunk in agent.stream(
{"messages": [{"role": "user",
"content": "用一句话介绍 LangGraph"}]},
stream_mode=["messages", "updates"], # 列表即双通道
):
if mode == "messages":
print(chunk[0].content, end="", flush=True)
else:
print(f"\n[{list(chunk)} 完成]") # 节点跑完的进度条
输出如下:
LangGraph 是把 Agent 工作流表达成图、以共享状态驱动的编排框架。
['model'] 完成
解读:一条流里 token 与节点进度交错到达,前端各取所需——messages 喂打字机,updates 喂步骤列表。若问题涉及工具,model 与 tools 交替出现的完整轨迹第 5 篇已经走过一遍,此处不重复。
5、LangSmith 自动追踪
流式解决"过程看得见",还有一类问题它管不了:事后追查。昨天那批请求为什么慢、哪个节点 token 超支、三天前那次答非所问走的哪条路——运行时片段吐完就没了。这类账交给 LangSmith:LangChain 官方的可观测平台,第 8 篇提过一句,官方自家生产环境跑的就是它。
接入是零代码三个字:注册账号拿 Key,设环境变量,图一行不改。配置如下:
export LANGSMITH_TRACING=true # 总开关:开追踪
export LANGSMITH_API_KEY=lsv2_... # 控制台复制的 Key
export LANGSMITH_PROJECT=aiblog-demo # 可选:项目归组,默认 default
解读:LANGSMITH_TRACING 一开,LangGraph 的每次 invoke 与 stream 都自动上报,本地脚本、测试、生产进程同一套开关。费用口径:免费 Developer 档 1 个席位、每月 5k 条 base trace、保留 14 天,开发期与小型生产够用,超出按量计费见 LangChain 定价页。
trace 界面打开是树状结构,LangGraph 的每个节点是一层,最值钱的三处:其一,每一步的输入输出与延迟都按超步排好,把"这图好慢"变成"tech_edit 平均 4 秒";其二,模型调用的完整提示词、回复与 token 计数落在节点之下,账单可以按节点归因;其三,threads 视图按 thread_id 把同一条会话的历次运行归组——正是第 8 篇"档案抽屉"的可视化版。注意 trace 要等运行结束才完整落库,想边跑边看得靠本篇的 stream,两者是互补不是替代。
内网或涉密场景不想让数据出域,这两个变量就别开——本地替代方案在下一节。
6、custom 模式本地观测
不上 LangSmith,过程数据照样要落:合规限制、离线环境、或者只想把结构化日志写进自家日志系统。custom 模式给图开了一条自定义事件通道:节点里拿 writer 把任意数据写进流,外面用 stream_mode="custom" 收——写什么、什么格式全由你定。官方入口是 langgraph.config 的 get_stream_writer,节点与接收端如下:
from langgraph.config import get_stream_writer
def make_outline(state: State) -> dict:
writer = get_stream_writer() # 取当前流的事件写入器
writer({"node": "make_outline", "进度": "提纲完成"})
return {"outline": f"{state['topic']}:现状、原理、风险"}
for chunk in graph.stream({"topic": "LangGraph 流式"},
stream_mode="custom"):
print(chunk)
输出如下:
{'node': 'make_outline', '进度': '提纲完成'}
解读:writer 收到的数据原样出现在流里,字典、字符串、进度百分比都行,而且不进黑板、不改 State。落地两式:前端进度条——长任务节点里 writer({"进度": 0.3}),配一条百分比条;后端结构化日志——把 chunk 直接 json.dumps 落盘,就是本地版的自定义 trace。另一条更朴素的路是手动日志节点或在节点里 print,能用,但 print 混在标准输出里没有结构;custom 把事件收进流的同一条管道,与 updates、messages 统一消费。顺带澄清一个易混点:langchain_core.callbacks 里的 dispatch_custom_event 服务于第 6 篇提过的 stream_events 事件流,custom 模式认的是本节的 writer,两条线别接错。
7、实战:审稿流水线可观测
把全套家伙装回第 7 篇的审稿流水线:主编 chief 依次派活,内容编辑与技术编辑各交一条意见,技术编辑背后还藏着一张校对子图。当时整张图靠 invoke 一把跑完,现在给它加全链路观测——updates 轨迹加子图穿透,业务代码一行不动:
run = graph.stream({"draft": "LangGraph 流式与可观测",
"stage": 0, "notes": []},
stream_mode="updates", subgraphs=True)
for chunk, ns in run: # subgraphs=True:片段带命名空间
print(f"{ns or '(父图)'} {chunk}")
输出如下(Command 与消息体较长,摘其骨架):
(父图) {'chief': Command(goto='content_edit', update={'stage': 1})}
(父图) {'content_edit': {'notes': ['内容编辑:标题可更抓人']}}
(父图) {'chief': Command(goto='tech_edit', update={'stage': 2})}
('tech_edit', 'proof_graph') {'check_terms': {'issues': []}}
('tech_edit', 'proof_graph') {'check_length': {'issues': ['篇幅过短']}}
(父图) {'tech_edit': {'notes': ['技术编辑:篇幅过短']}}
(父图) {'chief': Command(goto='__end__', update={'stage': 3})}
解读三件事。其一,不加 subgraphs=True,中间两行根本不会出现——子图内部对父图的流是隐身的;加上后命名空间标明事件来自 tech_edit 里的 proof_graph,父图与子图轨迹拼成完整链路。其二,返回 Command 的节点在 updates 里原样吐出 goto 与 update,主编每次派活的去向一目了然,第 7 篇"想看子图内部事件"的伏笔就此落地。其三,再补 LangSmith 那条腿:设好第 5 节的两个环境变量重跑同一张图,trace 里 chief、两位编辑、校对子图各成一层,延迟与 token 按层汇总——本地 print 轨迹管开发调试,LangSmith 管线上追查,一条链路两种留痕。
8、总结
流式把图从黑盒变成过程可见的执行体:stream_mode 七种模式各管一段——values 看黑板全量演化,updates 看节点增量(第 2 篇的老朋友),messages 透 token 级打字机,custom 开自定义事件通道,checkpoints 与 tasks 面向持久化观测,debug 全量打包;模式可传列表同开,嵌套图记得 subgraphs=True。可观测的另一翼是 LangSmith:两个环境变量零代码接入,每节点的输入输出、延迟、token 全落 trace,免费档每月 5k 条起步;不上云就用 custom 加结构化日志兜底。观测三件套至此凑齐:stream 是录像,get_state 是照片,LangSmith 是档案馆。
练习两道。练习一:给第 4 节的双通道再加 custom——在 count_words 工具体里 writer 一条"正在统计"事件,stream_mode 传三元列表,观察三种片段交错到达的顺序。练习二:给第 7 节的审稿流水线挂上 SqliteSaver(第 8 篇口径),分别用 stream_mode="checkpoints" 与 get_state_history 跑同一张图,对比"事件流"与"翻档案"两种观测的输出差异。对于准备上线的读者,建议今天就把 LANGSMITH_TRACING 设上——免费档零成本,第一批真实 trace 会立刻告诉你最慢的节点藏在哪。

