前段时间有个朋友面腾讯算法岗,简历上写着"RAG系统平均响应时间优化至3秒以内"。
面试官看了一眼,问:
"3秒是端到端延迟还是首字延迟?"
他愣了一下:"端到端......用户拿到完整答案需要3秒。"
面试官接着问:
"那用户发出问题到看到第一个字,要等多久?你们有没有做流式输出?流式RAG和普通流式输出有什么不同,RAG场景里有哪些特有的延迟问题需要处理?检索阶段能不能流式化,还是只有生成阶段能流式?"
四个问题,他只能回答第一个------因为他的系统只做了生成阶段的流式输出,完全没有考虑检索阶段的延迟优化。
面试官说了一句:"端到端3秒,但如果前2秒都在等检索,用户体验还是很差。流式输出解决的是感知延迟,不是实际延迟。"
这个场景说明一个问题:很多人做了流式输出,但没有系统性地思考过RAG场景的延迟来源和优化策略。
今天把流式RAG从头拆透。
先搞清楚两个延迟概念
做延迟优化之前,必须区分两个概念:
TTFT(Time To First Token):首字延迟
用户发出请求到看到第一个输出字符的时间。
这是用户感知最直接的指标------即使答案要10秒才生成完,只要第一个字在1秒内出现,用户的等待焦虑就会大幅降低。
端到端延迟(E2E Latency):完整响应时间
从请求发出到完整答案返回的总时间。
这是系统实际消耗的时间,决定了吞吐量和成本。
RAG场景的TTFT为什么高:
普通LLM调用:请求发出 → LLM开始生成 → 流式输出第一个token。TTFT就是LLM的推理启动时间,通常500ms~1秒。
RAG调用:请求发出 → 查询改写(可能调LLM)→ 向量检索 → Rerank → 拼装prompt → LLM开始生成 → 流式输出第一个token。在LLM开始生成之前,已经有25秒的检索链路耗时,TTFT被拉高到36秒。
用户等了3秒才看到第一个字,即使后续流式输出很流畅,体验也很差。

流式RAG的核心思路
流式RAG的目标是:尽可能早地让用户看到第一个字,同时不降低答案质量。
有两个方向可以做:
方向一:优化检索链路,缩短TTFT前的等待时间
让检索更快,LLM更早开始生成。
方向二:流式化中间过程,让用户在等待时有反馈
检索阶段无法避免,但可以把"正在检索..."这样的进度信息实时推送给用户,降低等待焦虑。
两个方向要同时做,缺一不可。
坑一:查询改写,能不能异步化
标准RAG链路里,查询改写(Query Rewriting)是第一步------把用户的问题改写成更适合检索的形式。
这一步通常需要调用LLM,耗时500ms~1秒。
问题:查询改写必须在检索之前同步完成吗?
不一定。有两种优化思路:
思路一:判断是否需要改写,不需要就跳过。
如果用户的问题是简单的单跳问题、没有指代词、语义清晰,直接用原始问题去检索,跳过改写步骤。
可以用规则快速判断(包含"它"、"这个"、"那个"等指代词才触发改写,否则直接检索)。
这样70%80%的查询可以跳过改写,直接进入检索,TTFT降低500ms1秒。
思路二:改写和检索并行。
同时发起两路:用改写后的问题检索 + 用原始问题检索,两路并行,取结果更好的那路。
改写完成之前,原始问题的检索可能已经有了结果,如果结果质量足够好,就直接用;如果改写后的结果更好,再替换。
这样TTFT不受改写延迟拖累,改写成为"可选的质量提升"而不是"必须等待的步骤"。
思路二的实际问题:怎么判断"结果更好"?
两路并行返回后,需要一个质量判断逻辑决定用哪路。通常用Top1的相似度分数做比较,改写后的Top1分数比原始高出一定阈值(比如0.05)才替换,否则直接用原始结果。
阈值怎么定?没有通用值,需要在自己的数据集上标注一批"改写有效/无效"的样本,观察分数差异的分布来确定。在我们的项目里,最终定在0.03------太小了改写几乎总会替换(失去节省延迟的意义),太大了改写的收益又体现不出来。
坑二:检索阶段的并行化
标准RAG的检索链路通常是串行的:
向量检索 → BM25检索 → RRF融合 → Rerank → 送给LLM
哪些步骤可以并行:
向量检索和BM25检索没有依赖关系,完全可以并行发起,等两路都返回结果后再做RRF融合。
如果有多个向量库分片(大知识库常见),分片查询可以并行。
并行化之后,RRF融合的等待策略怎么定?
这里有个实际的权衡:等两路都完成再融合(安全,但慢的那路拖住整体),还是设一个超时时间,超时的那路直接丢弃?
生产里通常选后者:设置一个软超时(比如800ms),在这个时间内没返回的检索路直接放弃,用已有结果做融合。这样最坏情况下检索耗时有上界,不会因为某一路抖动把整体TTFT拖垮。
软超时之外还需要关注并发压力------两路并行意味着向量库和ES同时收到请求,高峰期并发量翻倍。如果下游没有做好限流,并行化反而可能触发降级,耗时比串行更长。我们在向量库侧加了信号量控制,单个请求最多并行发起3路检索,超出的排队等待。
Rerank能不能并行化:
Rerank是对召回结果的精排,必须等向量检索和BM25都完成后才能开始,无法并行化。
但Rerank的截断位置可以动态调整------如果检索结果质量已经很高(Top1分数超过阈值),可以跳过Rerank直接用检索结果,节省Rerank的耗时(通常200~500ms)。
这里同样存在阈值标定问题。向量相似度的绝对值在不同embedding模型下含义完全不同------同样是0.85,ada-002和bge-large给出的语义距离差异很大。跳过Rerank的阈值必须在自己的embedding模型和知识库上标定,不能直接用别人项目里的数字。标定方法:在测试集上对比"跳过Rerank"和"走完Rerank"的答案质量,找到一个不影响质量的最低分数线。
实测数据:
在我们的保险知识库项目里,把向量检索和BM25并行化之后,检索阶段耗时从800ms降到了450ms,降幅约44%。加上软超时保护和并发限流之后,P99延迟从原来的2.1秒降到了1.3秒,长尾抖动明显收敛。
坑三:生成阶段的流式输出,RAG特有的问题
生成阶段的流式输出看起来简单------LLM支持流式API,直接用就行。
但RAG场景里有一个特有的问题:引用来源标注。
标准RAG里,答案生成完成后,系统会在答案末尾标注引用来源("来源:A款产品手册-第3页")。
如果走流式输出,LLM一边生成一边输出token,生成过程中还不知道引用来源标注应该放在哪里、应该标注哪些来源。
解决方案一:来源标注在流式输出结束后追加。
LLM流式生成答案正文,生成结束后,系统统一追加引用来源。
实现简单,但用户要等答案完整生成后才能看到来源,不够优雅。
解决方案二:在prompt里要求LLM在生成时内联标注。
css
请在回答中,每当引用参考资料里的信息时,在该句话后面立即用[来源N]标注,
例如:A款重疾险等待期为90天[来源1],免赔额为5000元[来源2]。
LLM在生成时就内联标注,流式输出时来源随着答案文字一起出现。
实现稍复杂(需要在prompt里维护来源编号和chunk的对应关系),但用户体验更好。
方案二的生产问题:LLM的来源标注不可信
这里有个坑在文章里没提,但在生产里必须处理:LLM有时会幻造来源编号。你传入了3个chunk,它可能写出[来源4]、[来源5],或者把格式写成[来源 1]、[Source1]、(来源1),五花八门。
流式输出的后处理需要专门处理这个问题:
- 合法性验证 :在流式输出结束后,用正则把所有
[来源N]提取出来,过滤掉N超出chunk数量的幻造引用; - 格式归一化 :用正则把各种变体格式统一成标准格式,再渲染给用户;
- 兜底方案:如果检测到来源标注格式混乱(比如同一段里出现多种格式),降级为方案一,在末尾统一追加来源列表。
这部分逻辑写起来不复杂,但不写的话上线必翻车------QA测出来的问题基本就是这些格式问题。
坑四:流式进度反馈,检索阶段的用户体验
检索阶段无法真正流式化(必须等检索完成才能开始生成),但可以把进度信息实时推送给用户。
进度反馈的实现方式:SSE(Server-Sent Events)
vbnet
event: progress
data: {"stage": "query_rewrite", "message": "正在理解您的问题...", "progress": 10}
event: progress
data: {"stage": "retrieval", "message": "正在检索相关资料...", "progress": 40}
event: progress
data: {"stage": "rerank", "message": "正在筛选最相关内容...", "progress": 70}
event: token
data: {"token": "根", "cumulative": "根"}
event: token
data: {"token": "据", "cumulative": "根据"}
用户看到的效果:
css
[正在理解您的问题... 10%]
[正在检索相关资料... 40%]
[正在筛选最相关内容... 70%]
根据A款重疾险产品手册,等待期为...(流式输出)
心理学研究表明,有进度反馈的等待,用户可接受的时间是无进度反馈的2~3倍。即使检索还是需要3秒,用户看到进度条在走,等待感受完全不同。
SSE在生产里有几个容易踩的坑:
坑1:Nginx默认缓冲会让SSE失效。
Nginx默认会缓冲上游响应,等缓冲区满了才转发给客户端。SSE需要的是逐事件实时推送,被缓冲之后就变成了批量发送,进度条效果完全消失。
解决方法是在Nginx配置里关掉对应路由的缓冲:
ini
location /api/sse {
proxy_pass http://backend;
proxy_buffering off;
proxy_cache off;
proxy_set_header X-Accel-Buffering no;
}
漏了这个配置,在本地测试没问题,一上线就发现进度条不动了------因为本地不经过Nginx。
坑2:客户端断线重连时的事件去重。
SSE协议内置了断线重连机制,客户端断线后会自动重连并携带Last-Event-ID头,告诉服务端从哪个事件开始重发。
如果服务端不处理这个头,重连后会重新从头推送,用户看到进度条归零重来,或者收到重复的token。
处理方式是给每个事件加id字段,服务端记录已发送的最大event_id,重连时从该id之后的事件开始续发:
vbnet
id: 42
event: token
data: {"token": "据"}
坑3:负载均衡下的连接粘连。
SSE是长连接,同一个会话的所有事件必须走同一个后端实例(因为事件序列状态在内存里)。
如果负载均衡按请求轮询,断线重连后可能路由到不同实例,找不到原来的会话状态。
解决方案是配置基于会话ID的粘连路由(sticky session),或者把事件序列状态存到Redis,让任意实例都能续发。

坑五:流式输出的错误处理
流式输出开始后,如果中途出错(网络中断、LLM超时、检索结果为空),处理比普通请求复杂。
错误场景一:检索阶段出错。
还没开始流式输出,处理和普通错误一样,返回错误信息给用户。
错误场景二:生成阶段中途出错。
流式输出已经开始,用户看到了部分答案,这时LLM报错或超时。
不能直接返回错误,因为用户已经看到了部分内容,突然中断体验很差。
处理方式:在已输出内容后追加"回答生成中断,请重试",同时在前端提供"继续生成"按钮触发重试。
需要注意的是,"从中断点续写"在生产里几乎不可行------LLM生成是非确定性的,重试大概率走不回原来的路径,强行拼接会产生语义断裂。实际做法是从头重新生成,但把已输出的部分内容作为上下文传给LLM,让它尽量保持一致性,而不是真正的断点续传。
错误场景三:流式输出内容被检测到有问题。
内容安全检测通常是对完整答案做的,流式输出时答案还在生成中,如何处理?
方案一:先缓冲完整答案做安全检测,通过后再流式发送(损失了流式的TTFT优势)。
方案二:用轻量级实时内容过滤(关键词过滤),生成过程中实时检测,触发时立即中断并追加提示。
两个方案本质是安全和体验的权衡,没有通用答案。风险敏感型业务(金融、医疗)通常选方案一,宁可损失一点TTFT,也不能让问题内容在检测完成前就到达用户;内容风险较低的场景可以选方案二,用轻量过滤兜底,全量安全检测异步进行,发现问题后在后续会话里介入。
端到端延迟优化的优先级排序
结合前面讲的所有优化点,按收益/成本比排序:
第一优先:生成阶段流式输出
实现成本低,TTFT收益极大,几乎所有系统都应该做。
第二优先:检索并行化(向量+BM25并行)
实现成本中等,检索阶段耗时降低40%+,收益明显。注意同步加软超时保护和并发限流,否则高峰期可能适得其反。
第三优先:流式进度反馈
实现成本中等,不改变实际延迟,但大幅改善用户体验感知,值得做。记得处理Nginx缓冲、断线重连、负载均衡这三个坑,否则测试环境没问题、生产环境翻车。
第四优先:查询改写条件触发
实现成本低,对简单查询节省500ms~1秒,对复杂查询无影响。
第五优先:动态跳过Rerank
实现成本低,对高质量检索结果场景节省200~500ms,但阈值必须在自己的数据集上标定,不能套用别人的数字。
面试被问到,这样答
面试官问"流式输出是怎么做的"或者"怎么优化用户的等待体验",这样展开:
先区分TTFT和端到端延迟。说清楚两者的区别,RAG场景里TTFT高的原因是检索链路在LLM生成之前,体现你对延迟问题有系统认知。
再说检索阶段的优化。向量检索和BM25并行化,并行的等待策略(软超时+并发限流),查询改写条件触发,动态跳过Rerank,说具体的收益数据。
然后说生成阶段的流式化。RAG场景特有的问题------引用来源标注怎么处理,两种方案各自的优缺点,以及方案二的生产坑(来源幻造、格式不稳定的后处理)。
再说进度反馈。SSE的实现方式,重点说Nginx缓冲、断线重连、负载均衡三个生产细节,这些说出来加分明显。
最后说错误处理。流式断点续传为什么在生产里行不通,内容安全检测的两种方案和各自适用场景,体现你考虑过真实的生产约束。
最后说一句
流式RAG这块,很多人以为"加个stream=True参数"就算做了流式输出。
实际上,RAG场景的延迟优化是一个系统性问题------检索并行化、查询改写条件化、进度反馈、错误处理,每一个环节都有优化空间,也都有自己的坑。
但更重要的是,把这些说出来的时候,要能说清楚每个决策背后的权衡:软超时的阈值为什么这样定,跳过Rerank的分数线怎么标定,SSE为什么要关Nginx缓冲。说清楚权衡,面试官才知道你真的做过,而不是把文章里的结论背了一遍。