← Back to Lab
// AI2026.07.224 min read

为什么 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_sendRuntimeError 静默吞掉,graph 继续跑完;外层 while 循环下一轮 receive_text() 在 socket 已死后抛 RuntimeError,而此处只 except WebSocketDisconnect,于是异常逃逸成 uvicorn 崩溃,前端更收不到任何终态帧

三、解决方案

  1. 流式收窄为仅对真正不兼容 SSE 的端点禁用;
  2. 生成节点改 llm.astream(messages) 逐 chunk 累积;
  3. 外层 except (WebSocketDisconnect, RuntimeError)
  4. 断连即 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 就完事",断连、异常、终态这三件事才是工程重点。