
凌晨两点,手机突然疯狂震动。
报警群里疯狂刷屏:“RAG 服务 P99 延迟超过 30s!”“GPU 利用率 100% 但请求全在排队!”“客服那边炸了,用户投诉进不来了!”
你揉着惺忪的睡眼打开监控面板,发现白天还顺滑得像德芙巧克力一样的 RAG 服务,此刻正像一辆抛锚在五环路上的破车,堵得水泄不通。
老板的消息紧接着弹出来:“怎么回事?平时不是挺稳的吗?今晚流量也没翻倍啊,赶紧保住链路!”
这是很多做 AI 落地同学都经历过的“至暗时刻”。RAG(检索增强生成)这东西,Demo 的时候跑得欢,真上线一碰流量洪峰,立马原形毕露。
这其实不是你运气不好,而是你从一开始就算错了账。
最近在字节跳动的面试场上,这道关于“RAG 扛量”的题目,刷掉了不少自以为懂架构的候选人。今天我们就把这道题拆开揉碎,聊聊怎么让 RAG 在流量洪峰中站得稳、扛得住。
面试现场:堆机器就能解决问题吗?
先还原一下那个让很多候选人卡壳的面试现场。
面试官推了推眼镜,抛出了那个经典的问题:
★“你们现在的 RAG 服务,白天平稳运行在 200 QPS,没什么压力。但是一旦遇到活动或者晚高峰,流量瞬间冲到 800 QPS,服务就崩了。如果是你,你会怎么优化?”
坐在对面的候选人信心满满,毕竟这也是他之前在项目中遇到过的“实战经验”。
他脱口而出:“这个简单,加机器。横向扩容,再挂几台 GPU 服务器,或者把显卡换成 A100、H800,算力上去了,QPS 自然就扛住了。”
面试官没说话,只是轻轻敲了敲桌子,追了一句:
★“加机器之前,你算过单卡每秒能处理多少 token 吗?你知道这 800 QPS 到了 LLM 那一层,相当于每秒要吞掉多少 token 吗?”
空气突然安静了三秒。
候选人愣住了。他习惯了用“请求数(QPS)”来衡量压力,却忘了在 LLM 这里,真正的货币是 Token,而不是请求次数。
这道题看似在考扩容,实际上是在考你对 RAG 链路的成本结构有没有清晰的认识。
如果只是一刀切地堆 GPU,结果往往是:最贵的 GPU 闲着,因为瓶颈根本不在那儿;或者 GPU 满载了,但排队时间太长,用户早就把网页关了。
正确的解题思路,从来不是“堆硬件”,而是:先分段定位瓶颈、再用 Token 维度限流、最后上多级缓存。
误区:为什么不能只看 QPS?
要理解为什么“堆 GPU”是下策,我们得先把 RAG 的链路拆开看。
很多人把 RAG 当成一个黑盒子,进一个请求,出一个答案。但在工程视角下,RAG 是一条由四个截然不同的“工种”组成的流水线:
看到了吗?
这四段的扩容代价差了两个数量级!最贵的 LLM 段,比最便宜的 Embedding 段,成本高出 100 倍不止。
如果你只看入口的 800 QPS 就去扩容,那就像是“为了解决厕所排队慢,去扩建了停车场”。
更致命的误区在于限流。
很多同学在 Nginx 层面配置了 limit_req,比如限制每秒 100 个请求。但这在 RAG 场景下完全是掩耳盗铃。
因为请求和请求是不一样的。
在 Nginx 眼里,A 和 B 都是“1 个请求”。但在 GPU 眼里,B 的代价是 A 的 500 倍。
如果你按请求数限流,要么把便宜请求挡在外面(浪费资源),要么放进来几个大请求直接把 GPU 队列撑爆(雪崩)。
结论很简单:限流必须按 Token 算,而且必须挡在最贵的 LLM 前面。
深度解析:RAG 扛量的四道防线
要想扛住 800 QPS 甚至更高,我们需要在 RAG 链路上建立四道防线。
当系统报警时,不要先去查 Embedding 慢没慢,也不要先去查数据库锁没锁。
先去看 LLM 的排队队列长度。
因为 LLM 是计算密集型且资源最稀缺的环节。只要流量一涨,第一个饱和的肯定是它。一旦队列积压,延迟就会呈指数级上升。
所以,监控大盘上,最重要的那个指标不是 system_qps,而是 llm_queue_length 和 llm_latency_p99。
怎么落地 Token 限流?
最简单的方案:在调用 LLM 之前,先估算一下这次请求要花多少 Token(Input Token + 预估 Output Token)。
然后去 Redis 里查一下这个用户的“剩余 Token 配额”。
这就叫“Token-based Rate Limiting”。
这就好比去自助餐厅,不是限制“进店的人数”,而是限制“每个人能吃多少克牛肉”。这样不管你是大胃王还是小鸟胃,餐厅的总成本都是可控的。
现在主流的推理框架(如 vLLM)都支持 Continuous Batching(连续批处理)。
它的原理很简单:把多个请求塞进同一个 GPU 里一起跑,大家共享 GPU 的算力。这能显著提升吞吐量(Throughput),理论上能翻 3~5 倍。
但是,天下没有免费的午餐。
Continuous Batching 的代价是:你的请求要等队友。
想象一下你在坐公交车。如果车上有一个大爷要在终点站才下车,那你哪怕只坐两站,也得陪着他一路堵过去。
在 Batching 里也是一样。如果同一个 Batch 里有一个请求要生成 4000 个 token,而你的请求只需要 200 个,你也得等着它把 KV Cache 释放掉,你的请求才能完成。
这就是典型的“长尾效应”。
怎么办?
max_tokens,或者把长请求隔离到独立的 GPU 池子里去,别让它影响普通用户。提到扛量,大家第一反应就是“加缓存”。
但在 RAG 里,缓存是个技术活。因为知识是会更新的,你缓存了旧答案,就是给用户埋雷。
我们可以把缓存分为三级,风险从低到高:
实战:把理论变成代码
光说不练假把式。我们来看一个真实的改造案例。
某大型券商上线了一款“智能投研助手”,帮助分析师快速查询研报、解析公告。
这是性价比最高的一步。
我们在 Redis 里加了一层缓存,Key 是 hash(query_text),Value 是 embedding 向量。
# 伪代码示例
def get_embedding(query):
cache_key = f"emb:{hash(query)}"
vec = redis.get(cache_key)
if vec:
return vec
# 缓存未命中,调用模型
vec = embedding_model.encode(query)
redis.setex(cache_key, 86400, vec) # 缓存 24 小时
return vec
效果:Embedding 段的 CPU 占用直接降了一半。因为分析师们问的问题高度重合(“今天的收盘价是多少”、“XX公司的财报出了吗”),Embedding 缓存命中率轻松达到了 60% 以上。
我们在 LLM Gateway 层加了一个令牌桶算法。
不再限制“每秒多少个请求”,而是限制“每个用户每分钟多少 Token”。
# 伪代码示例
def check_rate_limit(user_id, input_tokens, estimated_output_tokens):
total_tokens = input_tokens + estimated_output_tokens
key = f"limit:{user_id}"
# Redis 令牌桶扣减逻辑
current = redis.decrby(key, total_tokens)
if current < 0:
# 配额不足,回滚并拒绝
redis.incrby(key, total_tokens)
raise RateLimitExceeded("Token 配额不足,请稍后再试")
return True
效果:那些喜欢把几百页 PDF 扔进来的分析师,发几个请求后就会触发限流。这保护了 GPU 队列不会被几个“大胃王”撑爆。LLM 的排队长度从 200+ 降到了 20 以内。
我们给知识库里的每份研报都打了一个 etl_version。
缓存 Key 设计为:retrieval:{hash(query)}:{kb_version}
每次 ETL 任务跑完,更新知识库时,全局版本号自动 +1。所有旧的检索结果缓存瞬间失效。
效果:向量库的压力减少了 30%,而且不用担心分析师看到昨天的旧数据。
对于像“交易时间”、“休市日历”这种万年不变的问题,我们直接开启了 Query -> Answer 缓存。
但为了安全,我们维护了一个“意图白名单”,只有识别出是 FAQ 类型的 Query,才会走这层缓存。
最终数据
经过这一套组合拳,在同样的 600 QPS 流量洪峰下:
面试追问:还能再深一点吗?
如果面试官觉得你答得不错,通常会继续追问三个细节。如果你能接住,那基本就是 Offer 预定了。
追问 1:为什么 Continuous Batching 会增加长尾延迟?
回答要点: 因为 Batching 机制要求同一个 Batch 内的所有请求“同生共死”。只要有一个人还在生成,GPU 的计算资源就被占用,其他人即使生完了也得等着。当请求长度方差很大(有的短,有的巨长)时,P99 延迟就会被那个最长的请求拖垮。
追问 2:怎么解决 Batching 的长尾问题?
回答要点:
max_tokens,强制截断,或者让大模型学会“分多次生成”。追问 3:Query -> Answer 缓存怎么保证一致性?
回答要点:
总结:RAG 工程师的生存法则
RAG 落地,算法决定了上限,但工程决定了下限。
千万别以为 RAG 就是调个 API、接个向量库那么简单。当流量上来的时候,它就是一个复杂的分布式系统工程问题。
记住这张防崩清单:
下次再遇到“白天 200 QPS 稳,晚上 800 QPS 崩”的问题,别急着找老板要钱买显卡。
先看看你的 Redis 缓存热不热,再看看你的限流器是不是在裸奔。
很多时候,救命的方案不是加钱,而是加脑子。