流式响应的生命周期:用户关掉页面之后,模型还在生成吗
问答页面改成了流式输出,首字延迟明显下降。之后推理服务的账单却比预期高,日志里很多请求生成了完整的回答,而用户早就关掉了页面。还有一类投诉:回答说到一半就停了,页面上没有任何错误提示,用户以为这就是全部答案。
流式输出改变的不只是体验。一个普通的 HTTP 响应要么成功、要么失败,状态码在开头就确定了;流式响应的状态码也在开头发送,之后发生的一切都只能在流里表达:中间出错、客户端离开、事件边界。服务端的生成器和连接的生命周期也不再一致,连接断了,生成器未必知道。
本文用进程内启动的 FastAPI 0.156.2 + uvicorn 0.54.0 和一个按固定间隔吐出 token 的假生成器,验证分帧、断开和中途失败三件事。不调用任何模型。实验见文末。
一、分帧:空行才是事件的边界
Server-Sent Events(SSE)的格式很简单:data: 开头的行是数据,空行结束一个事件,同一个事件里的多行 data: 用换行拼接。
服务端写了 4 段,客户端按 SSE 规则解析出 3 个事件:message:'第一段\n第二段' | message:'多行内容第一行\n多行内容第二行' | done:'{}'第一段少写了一个换行,于是和第二段合成了一个事件。这种错误在测试里不容易发现:客户端拼出来的文字可能看起来是对的,只是事件的数量和边界变了,而按事件计数、按事件做增量渲染的代码就会出错。服务端最好用库提供的 SSE 编码而不是手拼字符串;模型输出的文本本身可能包含换行,要按多行 data: 编码,不能直接写进一行。
二、客户端断开:异步生成器会被取消,同步的不会
异步生成器,客户端读到 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
生成到一半抛异常:状态码 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的回答视为完整。 - 首字超时与事件间隔超时分开设置;检查代理缓冲。
- 用量在服务端记录,包含客户端断开前已经生成的部分。
配套实验
- codesphere-labs/ai/streaming-response-lifecycle:Python 3.14.8、FastAPI 0.156.2、uvicorn 0.54.0、httpx 0.28.1:SSE 分帧、异步与同步生成器在客户端断开后的行为、中途失败的两种表达(验证记录)
参考资料