Skip to content

流式响应的生命周期:用户关掉页面之后,模型还在生成吗 ​

问答页面改成了流式输出,首字延迟明显下降。之后推理服务的账单却比预期高,日志里很多请求生成了完整的回答,而用户早就关掉了页面。还有一类投诉:回答说到一半就停了,页面上没有任何错误提示,用户以为这就是全部答案。

流式输出改变的不只是体验。一个普通的 HTTP 响应要么成功、要么失败,状态码在开头就确定了;流式响应的状态码也在开头发送,之后发生的一切都只能在流里表达:中间出错、客户端离开、事件边界。服务端的生成器和连接的生命周期也不再一致,连接断了,生成器未必知道。

本文用进程内启动的 FastAPI 0.156.2 + uvicorn 0.54.0 和一个按固定间隔吐出 token 的假生成器,验证分帧、断开和中途失败三件事。不调用任何模型。实验见文末。

客户端异步生成器同步生成器200 + 头部事件 1事件 2事件 3断开生成 3 个CancelledError → finally:取消上游生成 3 个第 4 个停在 yield,finally 未执行中途失败状态码已是 200连接中断:客户端得到 RemoteProtocolError显式的 error / done 事件收到 done 才算完整,否则按部分结果展示
图 1 · 实测(FastAPI 0.156.2、uvicorn 0.54.0、httpx 0.28.1):客户端读到 3 个事件后断开,异步生成器收到 CancelledError 并执行 finally,共生成 3 个;同步生成器在线程池里多生成 1 个后停住,finally 没有执行。生成到一半抛异常时状态码仍是 200,客户端只得到协议错误;写成 error 事件后可以区分部分结果。

一、分帧:空行才是事件的边界 ​

Server-Sent Events(SSE)的格式很简单:data: 开头的行是数据,空行结束一个事件,同一个事件里的多行 data: 用换行拼接。

text
服务端写了 4 段,客户端按 SSE 规则解析出 3 个事件:message:'第一段\n第二段' | message:'多行内容第一行\n多行内容第二行' | done:'{}'

第一段少写了一个换行,于是和第二段合成了一个事件。这种错误在测试里不容易发现:客户端拼出来的文字可能看起来是对的,只是事件的数量和边界变了,而按事件计数、按事件做增量渲染的代码就会出错。服务端最好用库提供的 SSE 编码而不是手拼字符串;模型输出的文本本身可能包含换行,要按多行 data: 编码,不能直接写进一行。

二、客户端断开:异步生成器会被取消,同步的不会 ​

text
异步生成器,客户端读到 3 个后断开:断开时服务端已生成 3 个,1.5 秒后共 3 个(共 20 个);finally 执行=True,收到 CancelledError=True
同步(线程池)生成器,客户端读到 3 个后断开:断开时服务端已生成 3 个,1.5 秒后共 4 个(共 20 个);finally 执行=False

两个生成器做同样的事:每 50 毫秒产生一个 token,共 20 个。客户端读到第 3 个就断开。

  • 异步生成器:服务端在下一次发送时发现连接已关闭,取消了生成器任务,CancelledError 在 await 处抛出,finally 执行,后续的 token 不再生成。真实场景里,finally 是取消上游推理请求、释放并发名额的地方。
  • 同步生成器:Starlette 把它放进线程池逐个取值。断开之后又多生成了 1 个(已经在计算中的那个),之后生成器停在 yield 处,既没有继续,也没有被关闭,finally 在观察期内一直没有执行。它持有的资源,例如一个上游的流式推理连接,就不会在这里释放。

这个结果依赖服务器实现:其他 ASGI 服务器检测断开的时机不同,上游推理服务是否在客户端断开后停止生成,也要看它自己的实现。可以确定的是:断开之后要停止生成并释放资源,这件事需要显式设计和测试,不能假设框架会替你完成。可靠的做法是使用异步生成器、在 finally 里取消上游请求,并用一个「读几个就断开」的测试验证上游确实停了。

三、生成到一半出错:状态码已经是 200 ​

text
生成到一半抛异常:状态码 200,客户端收到 ['data: token-0', 'data: token-1'],读取时的异常 RemoteProtocolError
把失败写成一个错误事件:状态码 200,事件 [('message', 'token-0'), ('message', 'token-1'), ('error', '{"code": "backend_failed", "partial": true}')]

状态码在第一个字节之前就发出去了,后面的失败无法再改成 500。服务端异常时,连接被中断,客户端得到的是一个底层的协议错误(这里是 httpx.RemoteProtocolError)。浏览器里的 EventSource 遇到断开还会自动重连,可能把同一个问题再问一遍。

更好的做法是把流的结束也设计成协议的一部分:

事件含义
message一段增量内容
error中途失败,带错误码,说明已发送的内容是部分结果
done正常结束,可以带用量、结束原因(例如达到长度上限)

客户端只有收到 done 才把回答标记为完整;收到 error 或连接在 done 之前断开,都按「部分结果」展示并提示用户。开头那种「说到一半就停了、页面却没有提示」的问题,根源就是没有区分这两种结束。

四、在流式接口外面还要考虑的 ​

  • 超时:普通请求的总超时对流式响应不适用,要分开设置首字超时和两次事件之间的间隔超时。见超时的分层。
  • 代理缓冲:反向代理默认可能缓冲响应,流式效果会消失,需要关闭对应路径的缓冲。
  • 用量与计费:客户端断开时,已生成的 token 仍然产生了费用;用量要在服务端记录,而不是依赖客户端读到的内容。
  • 重试:中途失败后能否从断点继续取决于上游是否支持;不支持时,客户端重试就是重新生成,应该让用户决定。

五、检查清单 ​

  • 事件按 SSE 规则编码:空行结束事件,多行内容用多行 data:。
  • 用异步生成器,在 finally 里取消上游请求;用「读几个就断开」的测试验证上游确实停了。
  • 中途失败写成显式的 error 事件,正常结束写 done;客户端只把收到 done 的回答视为完整。
  • 首字超时与事件间隔超时分开设置;检查代理缓冲。
  • 用量在服务端记录,包含客户端断开前已经生成的部分。

配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权