ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

流式输出与持久连接:AI对话首字延迟降低80%的实战指南

流式输出与持久连接:AI对话首字延迟降低80%的实战指南

前两周处理一条用户反馈,对方问我:“你们是不是偷偷换了个更聪明的模型?感觉回答的字都是直接冒出来的。”其实模型一个都没换,变的只是请求方式。我把那个文档问答Demo从“等全部内容生成完再返回”改成了流式输出,同时把多次请求复用到同一根长连接上。改动落地后,用户第一眼看到回复的时间从平均7.8秒左右降到了1.6秒上下,粗算正好是砍掉了大约80%的感知延迟。

圈内常用一句话形容这种模式:一根“永不挂断的电话线”。它不是玄学,原理也不复杂,但真要把这80%的延迟省下来,从连接方式、流式协议到前端渲染、代理缓冲,每个环节都有讲究。这篇文章我按自己实际改造的顺序来写,先说清楚延迟花在了哪里,再讲持久连接和流式输出怎么把这笔账抵掉,最后附上可复现的代码和那些文档里不会写的坑。

1. 用户感知的“快”,不只是算得快:一份延迟账单里的几笔隐形开销

1.1 模型生成只占端到端耗时的一部分

很多人一聊到AI接口“慢”,第一反应就是“模型推理慢”,但实际上用户感受到的延迟,是一个完整链路叠加的结果:客户端发起请求、DNS解析、TCP建连、TLS握手、请求排队、网关转发、模型推理、响应传输、前端解析渲染。模型推理只是其中一环,而且往往不是最致命的那一环。

拿我那个Demo来说,回复内容大约400字,模型生成速度约为每秒40个token,按中文一个字约等于1到1.5个token算,光生成就得花6到8秒。传统做法是等这8秒全部跑完,才把完整响应体一次性丢给客户端,用户盯着的始终是那个转圈的图标。可如果改成流式,模型生成第一个token只需要几百毫秒,首个文字在1秒左右就能出现在屏幕上。同样是8秒的生成时间,前一种方案让用户干等8秒,后一种方案让用户在等待结束前已经开始阅读内容。

这里有个反直觉的点:流式输出并不会让模型算得更快,它只是让“用户看到第一个字”的时间不再等于“全部内容生成完”的时间。延迟优化的真正分水岭,是首字延迟(TTFT,Time To First Token),而不是总耗时。后面我会专门展开这部分。

1.2 三次握手加上每次都新建连接:短连接的隐藏成本

如果不做任何优化,每次对话请求都是一条全新的HTTP连接。一次标准HTTPS请求,光“建立连接”就要走这些流程:TCP三次握手消耗约1.5个RTT(往返时间),TLS握手再消耗1到2个RTT,加上真正发出HTTP请求的那一个RTT,总共约3到3.5个RTT。

假设客户端到服务端的RTT是120毫秒,连接建好、请求发出去,就已经花掉接近400毫秒,这还没算服务端处理时间。多轮对话场景下这笔开销会被反复收取:用户每追问一次,连接就重建一次,哪怕这一次追问模型只回答了几个字,握手成本也一分不少。

我用一个粗略的表格来展示这笔“短连接税”:

环节消耗备注
TCP三次握手1.5 RTT客户端到服务端往返一次约120ms时,消耗约180ms
TLS握手1~2 RTTTLS 1.3可做到1-RTT,但并非所有链路都支持
发送HTTP请求1 RTT请求头、请求体到达服务端
合计3~4 RTT未算DNS解析和排队时间

DNS、网关排队这些还没算进去,如果再叠上公网波动,一次“什么都没干”的请求,光前置开销就可能在500毫秒以上。对话越短,这笔固定开销占的比重越高,这就是为什么短连接在这种场景下格外浪费。

2. “永不挂断的电话线”的技术原型:持久连接与流式推送

2.1 为什么一次连接能顶十次请求

所谓“永不挂断的电话线”,翻译成技术语言就是持久连接(keep-alive connection)。传统短连接像每次打电话都先拨号、等接通、说完挂掉,下次再拨;持久连接则像保持通话不挂断,想说什么直接说。底层就是WebSocket或HTTP长连接方案,连接建立一次,之后所有对话数据都在这条连接上持续传输。

持久连接最直接的好处是省掉了反复建连的成本。假设一次对话要来回8轮,短连接模式下每轮都要付3到4个RTT的建连开销,8轮就是24到32个RTT;持久连接模式下,这笔开销全程只付一次。建连时间占比越高,持久连接的收益越大。

另一个容易被忽略的好处是避免连接风暴。短连接模式下,同一时刻有大量用户发起请求,网关层的并发连接数会瞬间飙升;长连接复用之后,单位时间内的新建连接数大幅下降,中间层负载压力也明显变小。对于需要接入多个业务方、共用一套网关的场景来说,这比单纯省几个毫秒更有价值。

2.2 轮询、长轮询、SSE、WebSocket:一次性讲清该选谁

持久连接并不是只有一种实现方式,我常被问到“为什么不用轮询”“为什么不直接上WebSocket”。这里放一张对比表,把常见方案摊开看:

方案连接方式实时性服务端开销适用场景
普通轮询每次新建HTTP连接取决于轮询间隔高,大量空转请求不适合对话场景
长轮询HTTP连接挂起直到有数据较好中,大量挂起连接早期兼容方案,已较少用
SSEHTTP长连接,单向推送好,毫秒级低,复用一条连接非常适合流式文本返回
WebSocketTCP长连接,双向好,毫秒级低,需管理握手和帧协议需要双向实时交互时

流式对话场景天然是“服务端不断往客户端推文字”,SSE这种单向流协议就已经足够,而且它更轻:直接基于HTTP,不需要额外握手协议,中间代理和负载均衡设备通常也认识它。

当然,如果应用本身需要同时上报用户语音、上下文状态这类双向数据,那WebSocket更合适。就我这次改造的文档问答Demo而言,SSE是性价比最高的选择。好在这类接口的底层协议大同小异,理解透SSE之后切换到WebSocket也不难。

3. 80%延迟的去向:首字延迟才是真正的分水岭

3.1 完整返回和边生成边返回:用户感知差了整整一轮

我见过很多接入AI能力的产品,接口文档里明明写了“是否流式返回”这个参数,但很多团队默认不开,理由是“后端好写,前端好接”。结果就是用户按下发送键之后,面对一个没有反馈的空白页面,一直等到第8秒,整段回复突然整块蹦出来。

这种体验的问题在于,它把用户的等待时间拉满到“模型完整生成时间 + 一次传输时间”。而流式输出把等待逻辑彻底改变了:服务端每生成一小段内容就立刻推给客户端,用户看到的整个过程是“第一个字在第1秒出现,然后句子不断延续”。哪怕最后全部接收完的时间只缩短了一点点,用户主观上也会觉得“快了很多”,因为等待焦虑在第一个字出现的那一刻就已经开始消解。

我举一个具体例子。模型生成一段400字回复,速度是每秒40个token,网络RTT约100ms:

  • 非流式:第8秒生成完,加上建连400ms、传输响应体约500ms,用户看到第一个字的时间约等于8.9秒;
  • 流式:建连只需一次,第一个token在第600ms左右就生成并发出,算上网络时间,用户看到第一个字约在第900ms到1.2秒;
  • 同样409字全部接收完,流式也只增加“边生成边传输”的少量额外时间,总量和非流式差不多。

所以流式没有把模型的总推理时间变短,它改变的是“那段时间里用户能看见什么”。从产品体验角度,这是从“8.9秒无反馈”到“1秒内开始反馈”的本质差异。

3.2 实测链路对比:从接近8秒压到1.6秒

我这边回到自己的Demo来看改造前后的实测数据。测试环境是本地开发机连公网接口,网络有波动,单轮对话回复长度约300到400字,各跑20次取中位数:

指标改造前(短连接+完整返回)改造后(持久连接+流式返回)
首字出现时间约7.8s约1.6s
全部内容接收完约7.9s约5.2s
多轮对话平均每轮建连时间约0.5s/轮首轮约0.5s,之后约0s

首字延迟从7.8秒降到1.6秒,正好接近80%的提升。完整接收时间是5.2秒,比原来缩短约34%——注意这里不是80%,因为总生成时间没有变。对外宣传时喜欢引用“砍掉80%延迟”,严谨一点说,是“用户首屏等待时间下降了约80%”,搞懂这一点,和别人沟通时就不容易被数据误导。

拆解这1.6秒的来源:约1.2秒是首个token生成时间,约0.2到0.3秒是首轮建连+网络传输,剩下的零头是网关和前端解析。后续每一轮对话的建连时间基本为0,因为连接已经在复用。

4. 接上这根“电话线”:流式输出的服务端与前端落地实操

4.1 服务端:开启流式返回并正确处理SSE分片

现在的AI接口普遍支持流式参数,最标准的形态就是SSE。SSE协议简洁到有点朴素:每段数据以data:开头,以两个换行符\n\n结束。服务端只需要不断按这个格式往外写,客户端就能持续收到增量内容。

如果用官方Python SDK,开启流式基本就一步:把请求里的stream参数置为True,然后遍历返回的迭代器,而不是直接拿完整结果。伪代码大致是这样:

from sseclient import SSEClient def stream_chat(messages): response = some_sdk.chat.completions.create( model="conversation-model", messages=messages, stream=True, # 关键参数 ) for chunk in response: delta = chunk.choices[0].delta if delta and delta.content: yield delta.content

注意几个容易踩的点。第一,这个函数必须写成生成器,也就是用yield而不是return,这样调用方才能边拿边推。第二,解析SSE时不要只看data字段,某些平台会在一条流里混入[DONE]标记、注释行和心跳包,解析逻辑必须跳过这些非数据内容。第三,迭代过程中要捕获连接中断异常,否则一个网络抖动就会让整个生成过程静默失败。

如果不想依赖额外库,直接用requests的流式响应也可以,把stream=True传进去后逐行读取response.iter_lines(),一样能解析出SSE片段。区别在于官方SDK帮你处理好了重试、超时和数据截断,能少写不少胶水代码。

4.2 前端:接收流式数据并做“打字机”效果,但别逐字渲染

前端接收流式数据,最常用的是fetch配合ReadableStream。这里给一段能直接跑的示例:

const resp = await fetch('/api/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prompt: '讲一个三体人的冷笑话' }), }); const reader = resp.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const parts = buffer.split('\n\n'); buffer = parts.pop(); // 最后一段可能不完整,留到下一轮 for (const part of parts) { if (!part.startsWith('data:')) continue; const data = part.slice(5).trim(); if (data === '[DONE]') continue; const json = JSON.parse(data); appendContent(json.choices?.[0]?.delta?.content || ''); } }

原理是:浏览器通过fetch拿到的是一个可读流,reader.read()每次返回一段二进制数据;用TextDecoder把二进制转字符串,再按SSE的分隔符\n\n切成一条条消息。

最重要的经验是:拿到每个增量不要立刻setState渲染,否则性能会崩。一次完整的回复可能有几十甚至上百个token,如果你每个token都触发一次React渲染,DOM更新频率会远超浏览器帧率,页面会明显卡顿,流式效果反而变成“卡顿打字机”。

我的做法是在前端维护一个pendingText变量,把每次收到的增量累加进去,然后用requestAnimationFrame做节流,每帧最多同步一次到界面。这样渲染频率被限制在60帧以内,既流畅又不会丢内容。同理,上图中的JSON.parse也可能在部分平台返回不完整数据时报错,所以前面那段“把最后一段留到下一轮”的操作是必须的,别图省事直接解析整个buffer。

5. 电话线接通之后:代理缓冲、心跳重连与连接池的坑

5.1 代理层把流“攒”成批:最常见也最隐蔽的坑

如果说前三章的优化是让延迟降下来,那这一章的内容就是“为什么很多团队改了流式却感觉没效果”。我经历过的真实案例是这样:后端已经开启了流式,前端代码也严格按照SSE解析了,但用户看到的依然是“长时间空白,然后整段文字突然出现”——流式似乎完全没生效。

排查链路是这样的:先用命令直接请求后端服务,确认流式输出正常,内容是一个字一个字打印的;再从线上域名请求,发现只有完整响应到达后前端才收到数据。问题定位到反向代理层。

大多数反向代理默认开启了响应缓冲。响应体会被代理层完整缓存起来,等后端全部写完才一股脑转给客户端。流式数据在代理这一层就被“攒”成了一整包,前端拿到的自然不是流。

解法是在代理配置里关掉响应缓冲,以常见配置为例:

proxy_buffering off; proxy_cache off; proxy_read_timeout 300s; proxy_send_timeout 300s;

这里proxy_buffering off是关键,同时把读超时和发送超时调大,避免长连接因为长时间没有新数据被代理掐断。如果你用的是其他中间件,也去查对应“buffering”开关,原理完全相同。

5.2 心跳、断线重连与连接池:让“永不挂断”更接近字面意思

代理缓冲之外,第二类坑来自连接本身不稳定。长连接挂在那里好几个小时,如果没有心跳机制,中间的任何一层(网络运营商、代理、负载均衡)都可能把这条空闲连接偷偷断开。断掉之后客户端和服务端都还不知道,直到下一次发送请求时才发现连接已经死了。

解决方案分两层。一是服务端/传输层加心跳:有些平台内置了ping帧,没有的话自己定期发一个空注释行data: ping\n\n来维持连接活跃。二是客户端做断线自动恢复:监听error和异常关闭事件,用指数退避的方式重连,比如第一次等1秒、第二次2秒、第三次4秒,避免所有客户端同时重连造成“重连风暴”。

连接池配置也同样重要。长连接不是无限多的,很多服务端对单IP有并发连接数上限。我遇到过一次奇怪的现象:测试环境总共只有几个人用,但接口时不时报连接数超限。后来排查发现是客户端每次新建连接后没有正确复用,旧连接也未释放,连接池里的连接越积越多。调大连接池的确能临时缓解,但根本解法是让请求真正复用同一条连接,而不是每次都创建一个新对象。

5.3 一个收尾的实操建议

聊了这么多,最后分享两个我在实际改造中最受用的习惯。第一,无论服务商宣传“砍掉多少延迟”,上线前一定要在自己真实网络环境下测量首字延迟。网络距离、机房位置、代理策略都会让数字剧烈波动,别人报的1秒不代表你的用户也有1秒。第二,流式方案上线后,把首字延迟、平均生成速度、断线重连次数这三个指标单独埋点。这不是给监控系统看的,而是未来你和别人争论“到底优化有没有效”时,能直接拿出来拍桌子的依据。

我当时把这三个指标加进看板后,才发现一个此前完全没想到的现象:国内用户的首字延迟均值比欧美用户快40%,但断线率高出两倍多——网络层差异远比应用层算法差异更影响体验。这让我把重心从“调模型参数”换成了“优化传输链路”,效果反而立竿见影。

一根“永不挂断的电话线”,听起来像一句广告语,真正落地后才知道,它的价值不在于技术名词有多高级,而在于用户按下发送键之后,能更快看到一个正在认真回答的AI,而不是一个仿佛死掉的白屏。这80%的延迟差价,买的不是算力,是用户那几秒钟的安心感。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进