一、引言:流式输出不是"接口问题",而是"状态问题"
几乎所有大模型 API 都提供stream=true选项,前端拿到一个text/event-stream响应,逐块渲染,用户体验从"等 8 秒白屏"变成"0.
6 秒开始吐字"。看起来,这只是把requests.post换成httpx.stream的事。
但真正上线后,问题会集中爆发在两个地方:
第一,流式响应是"有状态的长连接",而 HTTP 基础设施默认它是"无状态的短请求"。用户在地铁里切了一次基站、Nginx 的proxy_read_timeout到了 60 秒、公司出口的负载均衡器静默回收了空闲连接——任何一个环节断掉,前端要么卡死不动,要么从第一个字重新开始生成。更糟的是,重连往往意味着重新计费一次完整的生成。
第二,首 token 延迟(TTFT)不是模型推理速度问题,而是链路问题。用户感受到的"卡",很大比例不在 GPU 上,而在排队、鉴权、内容审核、反向代理缓冲、TCP 冷启动这些环节。很多团队花大力气换更快的模型,TTFT 只降了 100ms,因为瓶颈根本不在这里。
先讲一个很典型的线上事故,它能说明这两个问题为什么会纠缠在一起。某个客服机器人在灰度期间收到大量投诉:"回答到一半突然停住,过几秒又从开头重新说一遍。"排查下来是三件事叠加:一是网关前面挂了一层云厂商的七层负载均衡,默认空闲超时 60 秒,而模型在做长回答时,两次 token 之间的间隔偶尔会超过 60 秒(长 prompt 的 prefill 阶段尤其明显);二是前端用的是原生EventSource,断线后浏览器自动重连,但重连请求没有携带任何会话标识,后端把它当成了一个全新请求;三是后端确实"老老实实"重新生成了一遍,于是用户看到了重复的开头,账单上也多了 30% 的 token 消耗。三个环节单独看都没错,组合起来就是一次资损 + 一次体验事故。
这就是本文想强调的核心判断:流式输出的工程量,80% 花在"连接之外"——会话状态怎么存、事件怎么重放、延迟怎么归因。本文围绕这两个问题展开:先讲清 SSE 在 LLM 场景下的语义缺口,再给出一套可续传的 SSE 网关实现,最后拆解 TTFT 的构成并给出可落地的优化清单。
二、核心原理
2.1 SSE 协议的本质:一条永不结束的 HTTP 响应
SSE(Server-Sent Events,text/event-stream)在协议层极其简单:服务端返回Content-Type: text/event-stream,然后持续往响应体里写符合规范的文本块,浏览器/客户端按行解析。每个事件由若干field: value行组成,以空行结束:
id: 42 event: token data: {"t": "你好"} : this is a comment (心跳) retry: 3000四个字段的语义分别是:id用于断线续传的游标,event是事件类型,data是载荷(多行data:会被拼接),retry建议客户端重连间隔。以:开头的行是注释,这是心跳保活的唯一标准手段——它不会触发onmessage,但能让中间设备认为连接活跃。
这里有几个规范细节,踩过坑的人会特别有共鸣:
- 字段名后面必须紧跟冒号,
data:xxx和data: xxx都合法,但data : xxx不合法,冒号前的空格会让整个字段名变成data(带尾空格),客户端解析会静默丢弃。 data字段的值如果以单个空格开头,该空格会被去掉,所以 JSON 里的前导空格不会被破坏,但如果你真的想发送以空格开头的内容,需要写两个空格。- 一个事件里可以有多行
data:,客户端会用\n把它们拼接起来。这意味着你不能在data里直接放未转义的换行,必须走 JSON 序列化(json.dumps会转义成\n)。 id的值不能包含\0(NUL),否则浏览器会忽略该字段。用自增整数或 UUID 最省心。retry的单位是毫秒,且只在同一个事件块里生效,之后所有重连都用最后一次收到的值。- 行结束符支持
\n、\r、\r\n三种,但为了兼容性,服务端统一用\n\n分隔事件最稳妥。
还有两个协议层之外的现实约束,往往比规范本身更致命:
一是 HTTP/1.1 下的连接数限制。浏览器对同一个域名默认只允许 6 个并发连接,而一条 SSE 长连接会长期占用其中一个。如果页面同时开了多个流式对话(比如多标签页、或者一个页面里嵌了多个 AI 组件),很容易把连接池打满,导致其他普通请求(图片、接口)排队。解决办法是:SSE 走独立的子域名(如stream.example.com),或者干脆升级到 HTTP/2——HTTP/2 的多路复用让所有流共享一条 TCP 连接,不再有这个限制。这是很多团队从 HTTP/1.1 迁到 HTTP/2 的直接动机之一。
二是反向代理的缓冲行为。Nginx 默认会开启proxy_buffering,它会把上游响应攒够一定大小(默认 4KB/8KB)再往下发。对于普通接口这是优化,对于 SSE 这是灾难——你的 token 明明 50ms 就产出了,却要等缓冲区填满才到达浏览器,TTFT 凭空多出几百毫秒甚至几秒。所以必须显式关闭:proxy_buffering off;,或者在应用层返回X-Accel-Buffering: no头让 Nginx 识别。
SSE 相对于 WebSocket 的优势在这里非常明确:它是纯 HTTP,天然穿过企业代理和 CDN,支持 HTTP/2 多路复用,服务端实现只需要一个流式响应,不需要维护长连接会话表。对于"服务端单向推、客户端只发一次请求"的 LLM 场景,语义是匹配的。
问题在于:SSE 的重连语义是"重放",而 LLM 的生成语义是"接续"。
2.2 断线重连为什么在 LLM 场景下失效
浏览器原生EventSource在连接断开后会自动重连,并带上Last-Event-ID请求头。这个机制对"股票行情推送"是完美的:重连后服务端从lastEventId之后继续推即可,因为行情是外部世界产生的、可重放的广播。
但 LLM 的 token 流是服务端为这一个请求现场生成的、不可重现的。它带来三个工程约束:
- 生成状态必须与连接解耦。推理进程不能因为客户端断开就停止生成——否则重连只能从头再来。必须有一个"生成任务"独立于 HTTP 请求生命周期运行。
- 事件必须可重放。服务端要把已产出的 token 按序号缓存(环形缓冲 + TTL 即可),重连时根据
Last-Event-ID补发。缓存窗口不需要很长,5~10 分钟足够覆盖绝大多数断线。 - 请求必须幂等。客户端首次请求时生成
session_id并持久化,重连携带同一个session_id,服务端据此关联到已有生成任务,而不是新建一个。这一条直接决定了用户是否会被重复计费。
把"断线"这件事再拆细一点,你会发现不同断线场景的处理策略其实不一样:
| 断线场景 | 典型原因 | 客户端感知 | 处理策略 |
|---|---|---|---|
| 客户端网络切换 | 4G/WiFi 切换、电梯、地铁 | onerror/ fetch 抛异常 | 指数退避重连,带Last-Event-ID |
| 中间设备空闲超时 | LB/Nginx 60s 超时 | 连接被对端关闭 | 心跳 + 服务端保活,重连补发 |
| 服务端实例重启 | 发布、OOM、扩缩容 | 连接重置 | 会话状态外置(Redis),换实例续传 |
| 用户主动停止 | 点"停止生成" | 主动 abort | 调取消接口,释放推理资源 |
| 后端限流/熔断 | 并发打满 | 429/503 | 退避重试,前端提示排队 |
服务端重启这一类最容易被忽略。如果你的Session只存在进程内存里(像下面 3.1 的简化版那样),一旦发布新版本,所有进行中的生成任务全部丢失,用户重连只能从头来。生产环境要么把事件日志落到 Redis(LPUSH+LRANGE,天然按序),要么用一致性哈希把同一session_id路由到固定实例,并在实例优雅下线时先等待存量会话完成(给一个 30~60 秒的 drain 窗口)。
此外还有一个容易被忽略的限制:EventSource不支持自定义请求头,只支持 GET,不能带 body。这意味着你无法传Authorization: Bearer xxx,也没法发一个几百 KB 的 prompt。所以生产环境几乎必然要放弃EventSource,改用fetch+ReadableStream手动解析 SSE,并自己维护Last-Event-ID和退避重连逻辑。
顺带说一句,fetch方案还带来一个额外好处:可以主动 abort。用户点"停止生成"时调用AbortController.abort(),前端立刻停止渲染,同时后端收到request.is_disconnected()后可以取消推理任务、释放 KV Cache。如果用EventSource,你连"优雅停止"都做不到,只能等它自然结束或者粗暴地关页面。
2.3 TTFT 的构成拆解
把用户点击"发送"到屏幕上出现第一个字,拆成可度量的几段:
TTFT = T_queue + T_prefill + T_network + T_gateway + T_render- T_queue:请求在网关/调度器排队,以及推理引擎的 batch 调度等待。高并发下这一项经常是最大项。
- T_prefill:模型对完整 prompt 做前向计算、填充 KV Cache 的时间。近似与 prompt 长度线性相关(attention 部分二次),也受 batch 内其他请求影响。
- T_network:TCP/TLS 握手(冷连接下 2~3 个 RTT)+ 请求上行 + 首包下行。
- T_gateway:鉴权、限流、日志、前置内容审核。同步调用一次审核 API 就是 100~300ms,这是很多团队 TTFT 超标的隐形元凶。
- T_render:前端解析、Markdown 渲染、首屏绘制。
给一组我们实测的典型量级(7B~70B 模型、中等并发、同城机房),帮助你建立直觉:
| 分段 | 空闲时 | 高并发时 | 备注 |
|---|---|---|---|
| T_network | 15~40ms | 15~40ms | 冷连接 TLS 握手可能到 150ms |
| T_gateway | 20~50ms | 50~200ms | 同步审核 API 是最大变量 |
| T_queue | 5~30ms | 300~2000ms | 并发越高越恐怖,且不可预测 |
| T_prefill | 80~300ms | 200~800ms | 与 prompt 长度、batch 大小强相关 |
| T_render | 10~30ms | 10~30ms | Markdown 全量重渲染会显著放大 |
看这张表就能明白为什么"换更快的模型"经常无效:如果瓶颈在 T_queue 和 T_gateway(加起来 500ms+),你把 prefill 从 200ms 优化到 100ms,用户感知到的提升只有 5%。
没有埋点的优化都是猜测。最低要求是在网关、推理引擎、客户端三处打时间戳,用统一的request_id串起来。下面这段代码是服务端侧的最小打点:
importtime,uuid,logging log=logging.getLogger("ttft")asyncdefchat_stream(request,session_id,prompt):t0=time.perf_counter()# 请求进入网关rid=request.headers.get("x-request-id")oruuid.uuid4().hexauth_ms=awaitverify_token(request)# 鉴权t1=time.perf_counter()first=awaitengine.first_token(prompt)# 直到 prefill 完成t2=time.perf_counter()log.info("ttft rid=%s sid=%s auth=%.1fms prefill=%.1fms total=%.1fms",rid,session_id,(t1-t0)*1e3,(t2-t1)*1e3,(t2-t0)*1e3)...只有把auth和prefill分开看,你才知道该去优化网关还是优化模型。
埋点还有三个容易踩的坑:一是别用time.time(),用time.perf_counter(),前者受系统时钟调整影响,跨机器比对会出现负值;二是打点要打 P50/P95/P99 而不是平均值,TTFT 是典型的右偏分布,平均值会被大量快请求拉低,掩盖长尾;三是客户端时间戳要和服务端对齐,可以在响应头里带上服务端的t0,客户端用performance.now()差值估算,或者干脆做一次 NTP 对齐,否则你算出来的 T_network 可能是负的。
三、实战:可续传的 SSE 网关
3.1 服务端:事件日志 + 条件变量
核心设计:生成任务与 HTTP 连接解耦。用session_id索引一个Session对象,内部持有已产出事件列表和asyncio.Condition;生成协程负责 append + notify,任意数量的消费者(包括重连后的新连接)从指定游标开始读取。
importasyncio,json,timefromfastapiimportFastAPI,Requestfromfastapi.responsesimportStreamingResponse app=FastAPI()SESSIONS:dict[str,"Session"]={}classSession:def__init__(self):self.events:list[str]=[]# 已产出的 token 序列,下标即事件 idself.done=Falseself.cond=asyncio.Condition()self.updated=time.monotonic()asyncdefproducer(sess:Session,prompt:str):try:asyncfortokinengine.generate(prompt):# 推理引擎的异步流asyncwithsess.cond:sess.events.append(tok)sess.updated=time.monotonic()sess.cond.notify_all()finally:asyncwithsess.cond:sess.done=Truesess.cond.notify_all()asyncdefevent_stream(sess:Session,cursor:int,request:Request):last_ping=time.monotonic()whileTrue:asyncwithsess.cond:ifcursor>=len(sess.events)andnotsess.done:try:awaitasyncio.wait_for(sess.cond.wait(),timeout=15)exceptasyncio.TimeoutError:passpending=sess.events[cursor:]ifpending:fortokinpending:yieldf"id:{cursor}\ndata:{json.dumps({'t':tok},ensure_ascii=False)}\n\n"cursor+=1last_ping=time.monotonic()eliftime.monotonic()-last_ping>=15:yield": ping\n\n"# 心跳,防止中间设备回收空闲连接last_ping=time.monotonic()ifsess.doneandcursor>=len(sess.events):yield"event: done\ndata: {}\n\n"returnifawaitrequest.is_disconnected():return# 只断开消费者,producer 继续跑@app.get("/v1/chat/stream")asyncdefchat(session_id:str,prompt:str,request:Request):# 重连时浏览器/客户端会把最后收到的事件 id 放在 Last-Event-ID 头里cursor=int(request.headers.get("last-event-id","0")or0)sess=SESSIONS.get(session_id)ifsessisNone:# 首次请求:新建会话并启动独立的生成任务sess=Session()SESSIONS[session_id]=sess# 关键:用 create_task 让 producer 脱离本次 HTTP 请求生命周期asyncio.create_task(producer(sess,prompt))# 若 sess 已存在,说明是重连:绝不重复触发 producer,避免重复计费returnStreamingResponse(event_stream(sess,cursor,request),media_type="text/event-stream",headers={"Cache-Control":"no-cache","X-Accel-Buffering":"no",# 关闭 Nginx 缓冲,否则首 token 会被攒包"Connection":"keep-alive",},)这段代码有几个设计点值得展开:
第一,cursor的取值来源。首次请求没有Last-Event-ID,cursor=0,从头读;重连时客户端带上最后收到的事件 id,比如 37,那么cursor=37,服务端从events[37]开始补发。注意id从 0 开始,客户端收到id: 37后,下一次应该请求Last-Event-ID: 38(即"我已经收到了 37,请从 38 开始")——这个 +1 的边界特别容易写错,写错了会导致重复一个字或者漏一个字。
第二,request.is_disconnected()只结束消费者,不结束 producer。这正是"解耦"的落点:客户端走了,生成继续,事件继续入events,等它回来补发。但这也意味着你必须为 producer 设置上限,否则一个恶意客户端反复建连又断开,会堆积大量孤儿生成任务。常见做法是给Session设一个硬超时(比如 5 分钟),到点强制 cancel producer。
第三,notify_all()允许多消费者。如果用户开了两个标签页看同一个会话,两个连接可以同时挂在同一个Session上,各自维护自己的 cursor,互不干扰。
第四,内存回收。上面的简化版没有清理逻辑,SESSIONS会无限增长。生产实现至少要加一个后台清扫协程:
asyncdefjanitor():whileTrue:awaitasyncio.sleep(60)now=time.monotonic()forsid,sessinlist(SESSIONS.items()):# 已完成且超过 5 分钟没人读,或者空闲超过 10 分钟,回收if(sess.doneandnow-sess.updated>300)ornow-sess.updated>600:SESSIONS.pop(sid,None)这里用time.monotonic()而不是time.time(),同样是为了避免系统时钟回拨导致的误判。
3.2 客户端:fetch + ReadableStream 手动解析
既然EventSource不能带鉴权头、不能发 POST body,客户端就得自己实现一套 SSE 解析器。核心难点有三个:按\n\n切分事件(注意一个 chunk 可能切在事件中间)、解析多行data、断线后带 cursor 重连。
asyncfunctionstreamChat({sessionId,prompt,token,onToken,signal}){letcursor=0;// 已收到的事件 idletretry=0;while(true){try{constresp=awaitfetch("/v1/chat/stream?"+newURLSearchParams({session_id:sessionId,prompt,}),{headers:{Authorization:`Bearer${token}`,"Last-Event-ID":String(cursor),// 断点续传游标},signal,});if(!resp.ok)thrownewError(`HTTP${resp.status}`);constreader=resp.body.getReader();constdecoder=newTextDecoder();letbuf="";while(true){const{done,value}=awaitreader.read();if(done)break;buf+=decoder.decode(value,{stream:true});// 事件之间用空行分隔;chunk 边界可能落在事件中间,所以留残余letidx;while((idx=buf.indexOf("\n\n"))>=0){constraw=buf.slice(0,idx);buf=buf.slice(idx+2);constevt=parseEvent(raw);// {id, event, data}if(evt.id!==undefined)cursor=Number(evt.id)+1;if(evt.event==="done")return;if(evt.data){constobj=JSON.parse(evt.data);if(obj.t)onToken(obj.t);}}}return;// 流正常结束}catch(e){if(signal?.aborted)return;// 用户主动停止,不再重连retry++;constdelay=Math.min(1000*2**retry,15000)+Math.random()*500;awaitnewPromise((r)=>setTimeout(r,delay));// 重连时带上同一个 sessionId + cursor,服务端只补发缺口}}}functionparseEvent(raw){constout={data:""};for(constlineofraw.split("\n")){if(!line||line.startsWith(":"))continue;// 注释/心跳consti=line.indexOf(":");constfield=line.slice(0,i);letval=line.slice(i+1);if(val.startsWith(" "))val=val.slice(1);if(field==="data")out.data+=(out.data?"\n":"")+val;elseout[field]=val;}returnout;}这段客户端代码里,buf.indexOf("\n\n")的循环是关键:网络分片和事件边界没有任何关系,一个read()可能返回半个事件,也可能返回三个半事件。必须维护一个残余缓冲区,每次拼完再按空行切。这是手写 SSE 解析器最常见的 bug 来源——用split("\n\n")一次性切完、然后丢掉最后一段,会导致事件丢失。
退避策略用指数退避 + 抖动(Math.random() * 500),抖动是为了避免大量客户端在同一时刻集中重连,把刚恢复的服务再次打挂(惊群效应)。
3.3 生产环境还要补的三块
会话状态外置。3.1 的SESSIONS是进程内字典,单实例可用,多实例就会出问题:用户重连被负载均衡打到另一台机器,找不到 session。解决办法是事件日志落 Redis:producer 每次RPUSH session:{id}:events tok,同时EXPIRE 600;消费者用LRANGE session:{id}:events {cursor} -1读取,配合PUBLISH/SUBSCRIBE做实时通知。done状态用一个单独的 key 标记。这样任意实例都能服务任意重连。
优雅下线。发布新版本时,实例要先摘流量、停止接受新会话,然后等待存量 producer 跑完(或者至少把事件刷进 Redis),再退出。K8s 里对应preStophook +terminationGracePeriodSeconds给足 60 秒。
取消接口。用户点"停止生成"时,前端 abort 只是断了连接,服务端 producer 还在烧 GPU。需要额外暴露一个POST /v1/chat/cancel,服务端据此task.cancel()并清理 session。否则"停止"按钮只是个心理安慰。
四、常见问题 FAQ
Q1:为什么我本地测试流式正常,一上生产就变成"憋一大段一起出来"?
九成是反向代理缓冲。检查三处:Nginx 的proxy_buffering off;、应用层响应头X-Accel-Buffering: no、以及云厂商 LB 是否支持"关闭响应缓冲"(有些七层 LB 默认开启,且不认这个头,需要提工单)。另外确认Content-Type是text/event-stream,有些框架会因为没设对而走普通响应路径。
Q2:心跳间隔设多少合适?
要小于链路中最短的空闲超时。常见链路里 Nginx 默认proxy_read_timeout 60s、云 LB 常见 60s、CDN 常见 60~120s。所以心跳设 15~20 秒比较安全,既不会太频繁浪费带宽,又能覆盖绝大多数中间设备。注意心跳是: ping\n\n(注释行),不要用event: ping,后者会触发客户端的onmessage,还得在业务里过滤。
Q3:重连后补发的事件,前端会不会重复渲染?
取决于 cursor 边界。客户端收到id: N后应记cursor = N + 1,重连带Last-Event-ID: N+1,服务端从events[N+1]开始发。如果记成N,会重复一个字;记成N+2,会丢一个字。建议在客户端对 token 做一次幂等校验(比如比对已渲染文本的末尾),作为兜底。
Q4:为什么不能用EventSource就算了?
三个硬限制:不能自定义请求头(没法带 token)、只能 GET 不能带 body(prompt 长一点就超 URL 长度)、不能主动 abort。所以只要你的场景需要鉴权和长 prompt(几乎所有 LLM 场景都是),就必须用fetch手写。
Q5:TTFT 已经 300ms 了,还要不要优化?
先看分布。如果 P50 是 300ms 但 P95 是 3 秒,那优化重点不是把 P50 压到 250ms,而是消灭长尾——长尾通常来自排队(T_queue)和 GC/连接池抖动,而不是模型本身。反过来,如果 P50 就 2 秒,那要先查是不是同步审核、是不是冷连接、是不是 prompt 太长导致 prefill 爆炸。
Q6:流式输出的 token 怎么计费才不亏?
按"实际生成"计费,而不是按"实际送达"计费。因为客户端断线后 producer 还在跑,这部分算力是真实消耗的。但如果是因为你的网关 bug 导致重复生成,那必须去重——这就是session_id幂等的价值:同一个 session 只触发一次 producer。
五、踩坑与优化建议
踩坑一:在StreamingResponse里做同步阻塞调用。比如在event_stream生成器里同步读数据库、同步调审核 API,会把整个事件循环卡住,所有并发流的 token 一起延迟。所有 IO 必须await,CPU 密集的解析(比如大 JSON 序列化)考虑丢到线程池。
踩坑二:把asyncio.Condition用错。cond.wait()必须在async with cond里调用,且唤醒后要重新检查条件(用while而不是if),否则会丢通知。上面的event_stream用wait_for+ 超时兜底,就是为了避免极端情况下永久挂起。
踩坑三:忽略request.is_disconnected()的探测成本。在某些 ASGI 实现里,这个调用依赖底层 transport 的状态,如果客户端是"半开连接"(拔网线而不是正常关闭),可能探测不到。所以除了它,还要靠心跳写失败来兜底——写: ping时如果抛异常,说明连接已死,直接退出。
踩坑四:前端 Markdown 全量重渲染。每来一个 token 就innerHTML = render(fullText),文本长到几千字时单帧渲染可能超过 16ms,直接掉帧,用户感知的"卡"其实是前端卡。优化方式是增量渲染:只对最后一个未闭合的块做重渲染,已完成的块缓存成静态节点。
优化建议清单(按性价比排序):
- 关掉所有缓冲(Nginx、LB、CDN),这一条通常能省 200ms~2s,零成本。
- 审核异步化:首 token 先放行,审核结果后置校验,命中违规再撤回。对体验提升巨大。
- 连接预热:客户端在页面加载时就和服务端建好 TLS 连接(
preconnect),省掉冷启动的 2~3 个 RTT。 - prompt 瘦身:prefill 时间和 prompt 长度强相关,砍掉冗余的系统提示词、压缩历史对话,比换模型见效快。
- 排队可见:高并发时主动返回"前方排队 N 位",比让用户对着空白屏干等体验好得多。
- 心跳 + 游标 + 幂等三件套,这是流式输出能上生产的地基,优先级高于任何性能优化。
六、小结
流式输出的工程难点,从来不在"怎么把 token 推出去",而在"连接断了之后怎么办"和"第一个字为什么这么慢"。
更多硬核网安与AI工具包,请扫码获取完整源码!
前者要求你把生成状态从 HTTP 请求里剥离出来,用session_id做幂等、用事件日志做重放、用游标做续传;后者要求你把 TTFT 拆成可度量的分段,先埋点、再看分布、最后才动手优化。把这两件事做扎实,流式输出才算真正"工程化",而不是"demo 能跑"。