演示的时候有人提了句:点完之后要愣一下才开始出字。
我拿秒表掐了一下,从点击到屏幕上出现第一个字,六秒出头。回来看服务端日志,请求进来、模型返回第一个 token,两秒都不到。
那四秒去哪了。
分段量一遍
先在几个位置打点:视图函数入口、上游连接建立、收到第一个 chunk、写进响应、浏览器端拿到第一个 data: 事件。
| 位置 | 距请求进入 |
|---|---|
| 上游连接建立 | 0.3 s |
| 模型首个 token | 1.9 s |
| Django 写出首个 chunk | 1.9 s |
浏览器收到首个 data: |
6.2 s |
服务端这一段几乎是实时把 chunk 转出去的,卡点在出网之后。
最常见的原因是 nginx 的代理缓冲。默认 proxy_buffering on,它会先把上游响应攒够一块再发给客户端,这对普通接口是好事,对 SSE 是灾难——你一个字一个字 yield,全被攒在缓冲区里,等攒够了或者连接关了才一次性倒出来,流式就退化成"等全部生成完"。
关掉它:
location /api/stream/ {
proxy_pass http://backend;
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 300s;
chunked_transfer_encoding on;
}
也可以在响应头里给 X-Accel-Buffering: no,让 nginx 对这一条响应单独放行,不动全局配置。我们最后选了响应头,因为这台 nginx 后面还挂着下载转发,动 location 配置影响面大,不敢随便改。
改完再测,1.9 秒变成 2.3 秒,剩下的几毛是网络和浏览器渲染。
顺带查出来另一个:某层开了 gzip,对 text/event-stream 也压,压缩同样要等一块数据,效果跟缓冲差不多。加了 Content-Type: text/event-stream; charset=utf-8 并把它排除出压缩类型列表才好。
服务端那一段
Django 这边用 StreamingHttpResponse 配一个生成器:
def event_stream():
for chunk in upstream:
text = chunk.choices[0].delta.content or ''
if text:
yield f"data: {json.dumps({'delta': text}, ensure_ascii=False)}\n\n"
yield "data: [DONE]\n\n"
return StreamingHttpResponse(event_stream(), content_type='text/event-stream')
\n\n 不能省,SSE 靠空行分事件;ensure_ascii=False 不能省,不然中文全变成 \uXXXX,前端还得解一次。这两条我都是写完跑不通才回去翻协议格式。
还有个不太起眼的设计:连接期间要定时发心跳注释行 : ping\n\n,不然中间那层代理看长时间没数据会把连接掐了。模型思考久一点的时候,光靠 token 是撑不住空闲超时的。
为什么站内客服没用这套
写到这里得说个反面的。项目里其实有 SSE(realtime.py),但在线客服那条链路用的是 3 秒短轮询,不是 SSE。
原因在 views_chat.py 开头那段注释里,当时写得很清楚:gunicorn 现在跑的是 20 个 sync worker,worker_class='gevent' 还是注释状态,每个 worker 同时只能处理一个请求。客服窗口属于"页面打开就建立、整个会话期间一直保持"的长连接,几十个访客同时开着就能把 20 个 worker 占满,整站跟着受影响。
task_events 那边也用 SSE,但那是几秒就断开的短连接,性质不一样。人工客服回复本来就慢,3 秒轮询完全无感,还零部署改动。
所以流式不是"更先进所以该用",它换来的是首字体验,付出的是长连接占用、代理配置、断连处理、以及一整套错误恢复。得看那条链路值不值。我们客服那条不值,演示里这个生成长文本的才值。
真要大规模上 SSE,得先把 worker 换成异步模型,或者把推流挪到单独的服务上。这事记在部署文档里,一直没动。
还没解决的一件事
浏览器那头用户点"停止"或者切走页面,连接断了,但服务端生成器未必立刻知道——Django 在下次 yield 写不进去时才抛异常,中间上游可能还在继续吐 token。那一段是白烧钱的。
我们试过在生成器里检查连接状态,效果一般,不同 WSGI 服务器表现还不一样。目前只能靠上游的超时兜底,谈不上解决了。
浏览器那头也有讲究。EventSource 自带重连,断了自己会再连,但默认不带上次读到哪儿,长文本生成到一半断掉,重连之后是从头再生成还是接着,得自己定。我们在事件里带了序号,重连时通过 URL 参数传回去,服务端从缓存里续。这套只做了一半——缓存留五分钟,超时了还是得重来。
压测的时候还发现 buffering 的问题在并发下更明显。单连接时攒够一块就吐,看着只是慢半拍;二十个连接一起,nginx 那边的缓冲区占用和 flush 时机互相影响,首字延迟能从 6 秒飘到 9 秒。关掉缓冲之后这组波动基本没了,比平均值下降更让我在意。
找 gzip 那层花了最久。我们一开始以为 proxy_buffering off 就够了,改完仍有两秒多解释不了,才想起来还有压缩。翻配置翻了半天,发现是某次为静态资源加的全局 gzip 规则把 text/event-stream 也罩进去了。这类"别人的改动在另一处生效"的问题,盯着自己那段代码永远查不出来。
VidDown 的下载通道也走过流式转发,长连接那部分另写过一篇。
改完配置那天我在群里发了张前后对比图。有人回了句"这不还是两秒吗",我打了个"嗯"就没再解释。