当AI智能体遇上高并发:我是怎么用Redis+负载均衡干掉推理超时的
结合实际踩坑过程聊聊大模型推理服务在高并发场景下的调优思路。一、先说问题推理超时到底有多烦做过AI智能体服务的同学应该都遇到过这个场景压测一跑QPS刚上去告警就炸了。日志里全是TimeoutError: inference request exceeded 30s Connection pool exhausted upstream timed out (110: Operation timed out)单次推理本来只要2~5秒并发一高就直接超时。更难受的是这种问题不稳定有时候复现有时候又好好的排查起来非常头疼。问题根源在哪大模型推理和传统API服务有本质区别对比项传统API大模型推理单次耗时毫秒级秒级甚至十几秒资源消耗CPU轻量GPU显存独占并发瓶颈数据库IO推理队列满载超时特征随机偶发高并发必现所以你不能用对待普通微服务的思路来处理这个问题。二、架构全景我们的解决方案长什么样先上整体架构图文字版用户请求 │ ▼ API Gateway限流 鉴权 │ ▼ Load BalancerNginx / 自研调度层 │ ├──────────────────────────┐ ▼ ▼ 推理节点 A 推理节点 B (vLLM / TGI) (vLLM / TGI) │ │ └──────────┬───────────────┘ ▼ Redis 缓存层 语义缓存 结果缓存 │ ▼ 业务逻辑层两个核心模块Redis缓存层和负载均衡调度层缺一不可但职责完全不同。三、Redis缓存不是简单的KV存储很多人一听到缓存推理结果第一反应是把prompt做key把response做value存Redis完事。这个思路方向对但实际落地会踩很多坑。3.1 精确匹配缓存基础方案最简单的实现importhashlibimportjsonimportredis rredis.Redis(hostlocalhost,port6379,decode_responsesTrue)defget_cache_key(prompt:str,model:str,params:dict)-str:生成缓存key注意要把模型参数也纳入payload{prompt:prompt,model:model,temperature:params.get(temperature,0.7),max_tokens:params.get(max_tokens,512)}rawjson.dumps(payload,sort_keysTrue,ensure_asciiFalse)returnfllm:cache:{hashlib.sha256(raw.encode()).hexdigest()}defquery_with_cache(prompt:str,model:str,params:dict):keyget_cache_key(prompt,model,params)# 先查缓存cachedr.get(key)ifcached:returnjson.loads(cached),True# True表示命中缓存# 缓存未命中走推理resultcall_inference_api(prompt,model,params)# 写缓存TTL根据业务设定r.setex(key,3600,json.dumps(result,ensure_asciiFalse))returnresult,False这个方案的命中率很低只有完全相同的请求才能命中。在智能体场景下用户输入千变万化基本没什么效果。3.2 语义缓存进阶方案问用户问今天天气怎么样和现在天气如何语义上是一样的为什么不能复用同一个缓存答可以但需要引入向量相似度检索。fromsentence_transformersimportSentenceTransformerimportnumpyasnpimportredisfromredis.commands.search.queryimportQuery# 使用Redis Vector Search需要RedisSearch模块model_embedSentenceTransformer(paraphrase-multilingual-MiniLM-L12-v2)defsemantic_cache_lookup(prompt:str,threshold:float0.92): 语义相似度缓存查找 threshold: 相似度阈值越高越严格 query_vecmodel_embed.encode(prompt).astype(np.float32).tobytes()# 使用Redis向量检索找最近邻q(Query(*[KNN 3 embedding $vec AS score]).sort_by(score).return_fields(prompt,response,score).paging(0,3).dialect(2))resultsr.ft(idx:llm_cache).search(q,query_params{vec:query_vec})fordocinresults.docs:similarity1-float(doc.score)# 余弦距离转相似度ifsimilaritythreshold:print(f语义缓存命中相似度:{similarity:.4f})returndoc.responsereturnNone实测数据对比缓存策略缓存命中率平均响应时间GPU利用率无缓存0%4.2s85%精确匹配缓存8%3.9s79%语义缓存(0.95)31%1.8s56%语义缓存(0.90)47%1.1s41%阈值调低会提升命中率但可能返回语义相近但不完全准确的答案这个trade-off需要根据业务场景决定。3.3 缓存预热冷启动问题怎么解智能体刚上线时缓存是空的所有请求都会穿透到推理层很容易打崩。importasyncio# 高频问题列表从历史日志分析得出HOT_PROMPTS[你好请介绍一下你自己,帮我写一份工作总结,解释一下什么是机器学习,# ... 更多高频prompt]asyncdefwarm_up_cache():启动时异步预热缓存tasks[]forpromptinHOT_PROMPTS:tasks.append(asyncio.create_task(preload_single(prompt)))# 限制并发别把推理层压垮semaphoreasyncio.Semaphore(5)asyncwithsemaphore:awaitasyncio.gather(*tasks)print(f缓存预热完成共预热{len(HOT_PROMPTS)}条)四、负载均衡GPU节点的调度不能照搬CPU那套问直接用Nginx轮询不行吗不是不行是不够好。Nginx的轮询/加权轮询是基于连接数的它不知道每个推理节点当前的GPU显存占用、推理队列深度、平均响应时间。结果就是有的节点队列已经堆满了新请求还在往里怼有的节点闲着没人分配。4.1 基于节点健康度的动态调度importasyncioimportaiohttpfromdataclassesimportdataclassfromtypingimportListimporttimedataclassclassInferenceNode:host:strport:intweight:float1.0queue_depth:int0# 当前队列深度avg_latency:float0.0# 近期平均延迟秒gpu_memory_used:float0.0# GPU显存占用率is_healthy:boolTruelast_check:float0.0classSmartLoadBalancer:def__init__(self,nodes:List[InferenceNode]):self.nodesnodes self.check_interval5# 秒defcompute_score(self,node:InferenceNode)-float: 综合打分分数越低越优先分配 综合考虑队列深度、延迟、显存占用 ifnotnode.is_healthy:returnfloat(inf)score(node.queue_depth*0.5node.avg_latency*0.3node.gpu_memory_used*0.2)returnscoredefpick_node(self)-InferenceNode:选出当前最优节点healthy_nodes[nforninself.nodesifn.is_healthy]ifnothealthy_nodes:raiseRuntimeError(所有推理节点不可用)returnmin(healthy_nodes,keyself.compute_score)asyncdefhealth_check_loop(self):后台定期拉取各节点指标whileTrue:awaitasyncio.gather(*[self._check_node(node)fornodeinself.nodes])awaitasyncio.sleep(self.check_interval)asyncdef_check_node(self,node:InferenceNode):urlfhttp://{node.host}:{node.port}/metricstry:asyncwithaiohttp.ClientSession()assession:asyncwithsession.get(url,timeoutaiohttp.ClientTimeout(total3))asresp:ifresp.status200:metricsawaitresp.json()node.queue_depthmetrics.get(queue_depth,0)node.avg_latencymetrics.get(avg_latency_seconds,0)node.gpu_memory_usedmetrics.get(gpu_memory_utilization,0)node.is_healthyTrueelse:node.is_healthyFalseexceptException:node.is_healthyFalsenode.last_checktime.time()4.2 请求重试与熔断节点偶尔抖动是正常的不能因为一次超时就放弃整个请求importasynciofromfunctoolsimportwrapsdefwith_retry(max_retries3,backoff_base0.5): 带指数退避的重试装饰器 注意重试要换节点不能打同一个节点 defdecorator(func):wraps(func)asyncdefwrapper(self,prompt,*args,**kwargs):last_exceptionNonetried_nodesset()forattemptinrange(max_retries):nodeself.pick_node_excluding(tried_nodes)ifnodeisNone:breaktried_nodes.add(node.host)try:returnawaitfunc(self,prompt,node,*args,**kwargs)exceptasyncio.TimeoutErrorase:last_exceptione wait_timebackoff_base*(2**attempt)print(f节点{node.host}超时{wait_time}s 后重试第{attempt1}次)awaitasyncio.sleep(wait_time)# 临时降低该节点权重node.weightmax(0.1,node.weight*0.5)raiselast_exceptionorRuntimeError(所有重试均失败)returnwrapperreturndecorator五、两者协同请求进来后完整链路是这样的收到请求 │ ▼ ① 语义缓存查询Redis 50ms │ ├── 命中 ──────────────────► 直接返回结束 │ └── 未命中 │ ▼ ② 限流检查令牌桶 │ ├── 超限 ──────────► 返回 429排队或拒绝 │ └── 通过 │ ▼ ③ 负载均衡选节点综合评分 │ ▼ ④ 推理请求带超时重试 │ ▼ ⑤ 结果写入Redis缓存 │ ▼ ⑥ 返回结果这条链路里缓存是第一道防线能挡掉30%~50%的请求负载均衡是第二道保证推理层不被压垮。六、上线前后的数据对比在某智能客服项目中接入方案前后的对比指标优化前优化后提升幅度P99延迟28.4s6.1s↓ 78%超时率QPS20023%1.2%↓ 94%GPU节点利用率均衡度差异40%差异8%显著改善每日GPU算力成本基准-38%节省显著七、几个容易忽视的细节1. 缓存Key要包含系统提示词system prompt很多同学只对用户输入做hash忘了system prompt不同会导致完全不同的输出这是低级错误。2. 流式输出Streaming的缓存策略流式返回时不能直接缓存需要在服务端收全响应后再写入缓存对用户侧保持流式体验。3. 多模态请求不要无脑缓存图片、文件类请求缓存意义不大还占显存建议只对纯文本prompt做语义缓存。4. Redis内存要监控设好淘汰策略推荐使用allkeys-lru策略避免缓存把Redis内存撑爆。八、总结推理超时本质上是资源供给和请求压力的错配Redis缓存解决的是重复请求的无效消耗负载均衡解决的是有效请求的分发不均。两者一起上才能在不无限堆卡的前提下把推理服务的吞吐和稳定性都做起来。如果你的场景QPS还不高比如 50优先把语义缓存做好性价比最高。QPS上来之后再考虑推理节点的动态调度和熔断机制。有问题欢迎评论区交流踩过的坑越多越值得聊。— 本文由喜爱AIclaude-sonnet- 4.6 辅助完成

相关新闻