工智能> 大数据技术实践者 | LangChain Agent 实战系列 · 17
一、十秒黑盒:Agent 体验的头号杀手
前面几篇把 Agent 的能力链路补齐了:提示词定人格、工具做事、结构化输出交付、异步扛并发。但还有一个体验问题始终悬着——慢。
一次典型的 Agent 运行,内部要走完"模型 → 工具 → 模型 → 工具 → 模型"的循环,大模型生成完整响应通常要几秒,长输出能到 10~20 秒。同步调用意味着用户盯着一个不动的界面干等十几秒,不知道后台是在思考、卡死,还是已经挂了。等待本身不可怕,不知道要等多久才可怕。
流式输出(streaming)解决的就是这个问题:把"等全部生成完再一次性返回"改成"生成一点就推一点"。用户的界面上文字逐渐出现、进度条持续推进,等待焦虑大幅下降。ChatGPT 的打字机效果是它的最佳代言。
这里有个值得记住的指标:TTFT(Time To First Token,首字延迟)。同步调用下,用户看到第一个字要等完整响应生成完,TTFT 等于总耗时;流式调用下,模型吐出第一个 token 就能上屏,TTFT 从 10 秒级压到 1 秒级。注意总耗时一点没变——该算的 token 一个都没少。流式改变的不是"多快做完",而是"多快让用户看见动静"。评估流式收益时盯 TTFT 和用户弃用率,别盯 QPS。
在 LangChain Agent 里,这件事的入口只有一行差别:invoke 换成 stream:
for chunk in customer_service_agent.stream(
{"messages": [{"role": "user", "content": "查询客户 CUST123456 的完整信息和可用优惠"}]}
):
print(chunk)
但真正的功夫都在这一行之后:chunk 里到底是什么?怎么按需取用? 这就是本文的主题——七种 stream_mode。
二、先看懂默认输出:updates 模式的 chunk 结构
stream() 不带参数时,默认走 updates 模式。它返回一个迭代器,Agent 每执行完一个步骤,就吐出一个 dict。对内置的 create_agent 来说,dict 的 key 只有两种:model 或 tools——分别对应"模型节点刚说话了"和"工具节点刚执行完了"。
for chunk in customer_service_agent.stream(
{"messages": [{"role": "user", "content": "查询客户 CUST123456 的完整信息"}]}
):
for step, data in chunk.items():
if step == "model":
msg = data["messages"][-1]
if msg.content:
print(f"[模型] {msg.content[:80]}")
if msg.tool_calls:
print(f"[模型] 决定调用工具: {[tc['name'] for tc in msg.tool_calls]}")
elif step == "tools":
result = data["messages"][-1]
print(f"[工具] {result.name} -> {result.content[:80]}")
这段代码的输出会是这样交替出现的:
[模型] 决定调用工具: ['query_customer_data']
[工具] query_customer_data -> {"name": "张三", "level": "VIP", ...}
[模型] 决定调用工具: ['check_order_history']
[工具] check_order_history -> {"total_orders": 15, ...}
[模型] 客户 CUST123456 是 VIP 会员,累计 15 笔订单……
这就是 ReAct 循环在流式视角下的真实样子:模型和工具的产出交叉流出,谁完成谁先说话。updates 模式只包含状态里发生变化的部分,所以每条 chunk 都很小,适合做执行进度的监控面板。
三、七种模式:同一根水管,七个不同的阀门
stream_mode 参数决定"从哪个视角看这次运行"。七种模式对比:
| 模式 | 输出内容 | 典型场景 |
|---|---|---|
| values | 每步之后输出完整状态 | 每步都要全量状态、状态持久化 |
| updates(默认) | 每步只输出变化的部分 | 监控执行进度:调了什么工具、结果如何 |
| messages | token 级流式 + 元数据 | 打字机效果,聊天交互首选 |
| tasks | 任务 id、结果与错误信息 | 监控任务生命周期 |
| debug | tasks 基础上加步骤、时间戳、类型 | 调试、排障 |
| checkpoints | 检查点被创建时的完整状态 | 状态持久化、断点恢复、分布式跟踪 |
| custom | 工具/节点内自定义上报的数据 | 业务进度:"已处理 10/100 条" |
几个容易混淆的边界值得单独说清:
values vs updates:全量还是增量。 values 每次都把整个 messages 列表从头到尾给你一遍,第三步时你会收到前三步的所有消息;updates 只给这一步新增的。做进度条用 updates,做"随时可存档的全量快照"用 values。代价不对称:values 模式下消费端要做去重合并,数据量也大得多。
拿同一次两步运行举例。updates 模式吐两条:
{'model': {'messages': [AIMessage(tool_calls=[查天气])]}}
{'tools': {'messages': [ToolMessage('北京:晴')]}}
values 模式吐两份快照,第二条里包含第一条的内容:
{'messages': [HumanMessage('北京天气'), AIMessage(tool_calls=[查天气])]}
{'messages': [HumanMessage('北京天气'), AIMessage(tool_calls=[查天气]), ToolMessage('北京:晴')]}
所以 values 的消费端通常按"最后一条快照"或消息 id 去重,而 updates 的消费端只需要 append。选错模式,最常见的症状就是界面上把同一段话重复渲染了好几遍。
tasks vs debug:监控还是解剖。 tasks 输出任务的 id 和错误,用于程序化监控;debug 在此之上加时间戳和 task/task_result 类型标注,是给人看的解剖图。生产环境开 tasks,排查问题时临时切 debug。
checkpoints:需要先启用 checkpointer。 这个模式不是"免费的旁观",它要求创建 Agent 时传入 checkpointer,并且调用时带 thread_id——因为检查点本身就是按会话持久化的状态快照:
from langgraph.checkpoint.memory import InMemorySaver
agent = create_agent(model=llm, tools=[...], checkpointer=InMemorySaver())
config = {"configurable": {"thread_id": "session-01"}}
for chunk in agent.stream({"messages": [...]}, config=config, stream_mode="checkpoints"):
print("检查点已创建,当前消息数:", len(chunk["messages"]) if "messages" in chunk else "?")
四、messages:打字机效果的正解
七种模式里出镜率最高的就是 messages。它输出的是 token 级的消息块(AIMessageChunk)附带元数据(比如来自哪个节点),是目前实现类 ChatGPT 体验的标准答案:
for chunk, meta in agent.stream(
{"messages": [{"role": "user", "content": "介绍下VIP客户的权益"}]},
stream_mode="messages",
):
# 文本片段:直接上屏
if chunk.content and meta.get("langgraph_node") == "model":
print(chunk.content, end="", flush=True)
# 工具调用片段:解析参数流
if chunk.tool_call_chunks:
pass # 前端可展示"正在查询客户信息…"
两个实战要点。第一,打字机只该吃模型节点的文本块。 工具节点也会产生消息块,不区分节点就会把工具结果和正文混着往上打。第二,工具调用是"渐进可见"的。 模型决定调用工具时,工具名和参数也是一块块流出来的(tool_call_chunks),前端可以实时展示"正在调用 查询订单…",而不是等参数拼完才知道要干嘛——这十秒里用户至少看到了"它在干活"。
五、custom:把业务进度从工具内部吐出来
前六种模式看的都是框架层面的东西:状态、消息、任务、检查点。但用户真正关心的进度往往是业务级的——"报表生成到 60% 了"、"已处理 10/100 条记录"。这就要用 custom 模式 + get_stream_writer:
from langgraph.config import get_stream_writer
@tool
def generate_sales_report() -> str:
"""生成销售报告"""
writer = get_stream_writer()
writer({"type": "进度", "message": "开始生成销售报告"})
for i in range(1, 4):
time.sleep(0.5) # 模拟数据处理
writer({"type": "进度", "message": f"销售报告进度:{i * 25}%"})
return "销售报告:总收入 150 万元,同比增长 12%"
agent = create_agent(model=llm, tools=[generate_sales_report])
for chunk in agent.stream(
{"messages": [{"role": "user", "content": "生成销售报告"}]},
stream_mode="custom",
):
print(chunk) # {'type': '进度', 'message': '销售报告进度:50%'}
get_stream_writer() 拿到的 writer 可以在工具函数内部的任意位置调用,写出去的数据直接出现在消费端的迭代器里,格式完全由你定义。它填上了其他六种模式都够不着的空白:框架不知道的业务语义,只有你的代码知道。
一个并发场景下的注意点:如果 Agent 一轮里并行执行多个工具,两个工具的 writer 上报会交错到达——上一条是"销售报告 50%",下一条可能插进"库存分析完成"。这不是 bug,是并发的自然结果。所以 custom 事件里务必带上 type 字段(如上面的"生成销售报告"),前端按类型分流到各自的进度条,而不是假设它们按序到达。
对大数据场景这一条尤其重要:工具里跑的是一个 Spark 作业、一次 Hive 查询,"map 阶段 80%"这种进度,只有通过 custom 通道才可能出现在用户界面上。
六、多模式组合:一个循环,两路信号
模式不是单选题。stream_mode 传列表即可同时订阅多路信号:
for mode, chunk in agent.stream(
{"messages": [{"role": "user", "content": "查询客户完整信息"}]},
stream_mode=["messages", "updates"],
):
if mode == "messages":
... # token 上屏(打字机)
elif mode == "updates":
... # 步骤事件(侧栏进度:模型思考中 / 正在查询订单)
注意组合模式下迭代器产出的是 (mode, chunk) 元组,先判别模式再处理数据。一个典型的聊天界面会同时用到两路:正文区域吃 messages 打字机,侧边的过程面板吃 updates 列出"查了哪些工具、各花了多久"。two signals, one loop,不需要跑两次 stream。
七、出后端:SSE 通道与 Java 对照
Agent 的 stream 是 Python 进程内的迭代器,要到达浏览器还差最后一公里。生产上绝大多数场景选 **SSE(Server-Sent Events)**就够了:单向推送、基于 HTTP、自动重连、穿透代理友好。
from fastapi.responses import StreamingResponse
@app.get("/chat/stream")
async def chat_stream(q: str, session_id: str):
async def gen():
async for mode, chunk in agent.astream(
{"messages": [{"role": "user", "content": q}]},
config={"configurable": {"thread_id": session_id}},
stream_mode=["messages", "updates"],
):
if mode == "messages" and chunk.content:
yield f"data: {json.dumps({'t': 'token', 'v': chunk.content})}\n\n"
elif mode == "updates":
yield f"data: {json.dumps({'t': 'step', 'v': str(list(chunk.keys()))})}\n\n"
yield "data: [DONE]\n\n"
return StreamingResponse(gen(), media_type="text/event-stream")
两个工程细节:一是用 astream 而不是 stream(上一篇讲过的原因:同步迭代会堵住事件循环);二是给每个 chunk 打上类型标记(token / step / custom),前端按类型分发渲染,别在前端解析 chunk 的内部结构。
再补两个上线才会撞到的问题。断线重连:SSE 会自动重连,但流不可重放。 浏览器断线重连后,从断点继续读的是"新生成的 chunk",断线前错过的那几条不会重发——生成中的流没有持久化,重放无从谈起。对聊天场景通常无所谓(前端已渲染的文字还在),但要拿到"断点续传",得回到 checkpoints 模式的思路上:把检查点持久化,重连后从检查点恢复。慢客户端:消费端渲染跟不上时会产生背压。 FastAPI 的异步生成器天然背压(yield 会等对端收完),但若中间加了消息队列或广播层,就要自己控制缓冲区上限,否则慢客户端会把内存拖爆。
| 通道 | 方向 | 适用 |
|---|---|---|
| SSE | 服务端 → 浏览器,单向 | 打字机、进度推送(绝大多数场景) |
| WebSocket | 双向 | 需要中断、多用户协同编辑 |
| 轮询 | 请求/响应 | 内网工具、不想维护长连接 |
对 Java 工程师,SSE 在 Spring 里有现成对应物:
@GetMapping(value = "/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter chat(@RequestParam String q) {
SseEmitter emitter = new SseEmitter(60_000L);
executor.submit(() -> {
agent.stream(buildRequest(q)).forEach(chunk -> {
try {
emitter.send(SseEmitter.event().data(renderChunk(chunk)));
} catch (IOException e) { /* 客户端已断开 */ }
});
emitter.complete();
});
return emitter;
}
SseEmitter 对应 FastAPI 的 StreamingResponse,emitter.send 对应 yield,超时和断连处理两边都要显式写。做过实时数仓的同学还可以再对应一层:Kafka consumer 逐条吐到 WebSocket 的链路,和 Agent stream 逐 chunk 吐到 SSE 的链路,结构上完全同构——都是"生产端逐条产出、通道保序推送、消费端增量渲染"。
八、小结:一张选型速查表
七种模式看着多,按目标反查其实很清晰:
| 你要什么 | 用什么 |
|---|---|
| 打字机效果 | messages(只吃 model 节点文本块) |
| 执行进度面板 | updates(默认) |
| 全量状态快照 | values |
| 业务进度(报表百分比) | custom + get_stream_writer |
| 生产监控 / 排障 | tasks / debug |
| 断点恢复、分布式跟踪 | checkpoints(先启用 checkpointer) |
| 同时要两路信号 | stream_mode=["messages", "updates"] |
三条经验收尾:
- 流式不是为了快,是为了"可见"。 总耗时一点没少,但用户不再面对黑盒。
- 消费端别耦合 chunk 内部结构。 在出口处转成自己的事件协议(token/step/custom),后端换框架、前端不用动。
- 组合模式 + astream + SSE 是生产聊天界面的标准三件套,缺一个都会在某个场景掉链子。
Agent 的能力决定它能做什么,流式输出决定用户感觉它能做什么。十秒的活儿,让用户看见十秒里每一秒在干嘛,就是体验的全部差距。