为什么 AI 流式输出会让前端「永久转圈」:LangGraph + WebSocket 排障复盘
LangGraph + WebSocket 流式输出导致前端永久转圈的排障复盘:断连兜底、终态帧与流式开关三件事缺一不可。
为什么你的 AI 流式输出会让前端"永久转圈":一次 LangGraph + WebSocket 排障复盘
适用场景:用 LangGraph / LCEL 做 AI 生成,前端用 WebSocket 接收逐字 token 的项目。
一、现象
某天线上反馈:用户点"续写"后,前端只收到一条 run.node.start: generation 事件,随后再无任何 token / run.end / error 帧,spinner 永久转圈;服务端日志却显示生成其实成功,但紧接着抛:
RuntimeError: WebSocket is not connected. Need to call "accept" first.
也就是说——模型明明算完了,前端却永远卡在"生成中"。
二、两个根因(多因子叠加)
1. 流式被悄悄关掉了
模型工厂最初对所有 openai_compatible 端点无条件 disable_streaming=True(一开始是为规避某个不兼容 SSE 的云厂商)。但我们的主力模型走的是另一家兼容端点,本可以流式;更糟的是生成节点用了 llm.ainvoke() 而非 llm.astream(),于是整个过程零中间 token:
# 修复前:一刀切禁用流式 + 整段返回
llm = ChatOpenAI(base_url=base_url, model=model, disable_streaming=True)
return await llm.ainvoke(messages) # 整段生成完才返回,前端一个字节都收不到
前端在收到"开始生成"之后的十几秒里,一个字节都收不到——即便网关把 WS 掐了,前端也无感知。
2. 断连异常没被接住
Graph 在独立的 asyncio.create_task 里跑,断连时 _safe_send 把 RuntimeError 静默吞掉,graph 继续跑完;外层 while 循环下一轮 receive_text() 在 socket 已死后抛 RuntimeError,而此处只 except WebSocketDisconnect,于是异常逃逸成 uvicorn 崩溃,前端更收不到任何终态帧。
三、解决方案
- 流式收窄为仅对真正不兼容 SSE 的端点禁用;
- 生成节点改
llm.astream(messages)逐 chunk 累积; - 外层
except (WebSocketDisconnect, RuntimeError); - 断连即
return终止 graph,不再空跑生成 + 校验。
# 修复后:仅对不兼容 SSE 的 MaaS 端点禁用流式
if is_maas_endpoint(base_url):
llm = ChatOpenAI(base_url=base_url, model=model, disable_streaming=True)
else:
llm = ChatOpenAI(base_url=base_url, model=model, streaming=True)
async for chunk in llm.astream(messages):
ok = await _safe_send(ws, {"type": "token", "content": chunk.content})
if not ok:
return # 客户端已断,立即终止 graph
后端统一用 graph.astream_events(version="v2") 监听 on_chat_model_stream 推 token;前端把 token 累积进数组、join("") 增量渲染,终态 run.end / error 收尾。
四、复盘:流式健壮性三件事缺一不可
- 断连兜底:客户端断开要干净退出,不能让
RuntimeError逃逸成进程崩溃; - 终态帧:无论成功/失败/中断,都必须发
run.end/error,否则前端假死; - 多 worker 状态:流式任务引用若存进程内 dict,多副本部署时要迁 Redis。
把"永久转圈"最准确的定位就是一句话:前端没收到终态帧。流式不是"调个 astream 就完事",断连、异常、终态这三件事才是工程重点。