040 缓存策略:减少API调用成本与延迟的实用技巧
从一次半夜的告警说起
凌晨两点,手机震得我直接从床上弹起来。生产环境的LLM API调用量在十分钟内飙到了平时的三倍,账单数字像坐火箭一样往上窜。我一边骂骂咧咧地打开电脑,一边查日志——原来是某个用户写了个脚本,用并发请求疯狂刷我们的对话接口。更尴尬的是,同一个prompt在五分钟内被重复调用了四百多次,每次返回的结果一模一样。
那次之后,我花了整整一周重构缓存层。今天这篇笔记,就是那次事故的血泪总结。
缓存什么,不缓存什么
LLM API的调用成本主要来自两个维度:Token消耗和延迟。缓存策略的核心目标就是减少重复计算,但不是什么都能往缓存里塞。
适合缓存的场景:
- 固定prompt的摘要生成(比如每天凌晨的日报摘要)
- 知识库问答中的高频问题(“公司年假政策是什么”这种)
- 代码补全中的常见模式(for循环模板、try-catch结构)
- 翻译任务中的固定术语表
千万别缓存的情况:
- 涉及用户隐私的对话内容(GDPR会让你吃不了兜着走)
- 需要实时性的场景(股票价格、天气查询)
- 带随机参数的prompt(temperature>0时每次输出都不同)
我见过有人把带随机种子的请求也缓存了,结果用户发现每次问“讲个笑话”都得到同一个冷笑话,直接投诉到产品经理那里。
缓存键的设计:别让哈希坑了你
缓存键是缓存系统的命门。最简单的做法是把整个prompt做MD5,但这里面坑很多。
# 别这样写——太粗糙了cache_key=hashlib.md5(prompt.encode()).hexdigest()为什么不行?因为同一个prompt配上不同的model参数(比如temperature、max_tokens),返回结果完全不同。正确的做法是把所有影响输出的参数都拼进去:
# 这才是正经做法defbuild_cache_key(prompt,model,temperature,max_tokens,top_p):raw=f"{prompt}|{model}|{temperature}|{max_tokens}|{top_p}"returnhashlib.sha256(raw.encode()).hexdigest()这里踩过一个大坑:一开始我用MD5,后来发现生产环境有哈希碰撞——两个不同的prompt生成了相同的key,导致用户A看到了用户B的回复。换成SHA256之后问题解决,虽然计算开销大了点,但安全第一。
还有一个容易被忽略的点:prompt里的空格和换行符。用户从不同设备发来的请求,可能末尾多一个空格或少一个换行,导致缓存失效。建议在构建key之前做一次标准化:
# 标准化处理,避免空格差异导致缓存missnormalized_prompt=" ".join(prompt.split())缓存层级:从内存到分布式
单机缓存是最快的,但生产环境通常需要多级缓存。我现在的架构分三层:
L1:进程内缓存(比如lru_cache)
延迟在微秒级,适合高频重复请求。但进程重启就丢了,而且多实例部署时每个实例各自为政。
fromfunctoolsimportlru_cache@lru_cache(maxsize=1024)defget_completion_cached(prompt_hash):# 这里只做缓存命中,实际调用在外部pass注意maxsize别设太大,否则内存会爆炸。我一般控制在几千条以内,每条缓存数据大概几KB,总内存占用控制在几十MB。
L2:本地Redis
延迟毫秒级,跨进程共享。适合中等频率的请求,比如同一个用户短时间内重复问同一个问题。
# Redis缓存,带过期时间defget_from_redis(key):data=redis_client.get(key)ifdata:returnjson.loads(data)returnNonedefset_to_redis(key,value,ttl=3600):redis_client.setex(key,ttl,json.dumps(value))TTL(过期时间)怎么设?我根据业务场景来:知识库问答设24小时,代码补全设1小时,对话历史设30分钟。别设永久,否则缓存膨胀到Redis内存溢出,我经历过一次,恢复数据花了半天。
L3:分布式缓存(比如Memcached或Redis Cluster)
跨机房共享,适合全局高频请求。比如公司内部的FAQ机器人,所有用户问“怎么请假”都应该命中同一个缓存。
层级之间怎么配合?先查L1,miss了查L2,再miss查L3,最后才调API。调完API后逐级回填缓存。这个流程看起来简单,但实现时要注意缓存穿透和雪崩。
缓存穿透、击穿、雪崩:三个要命的场景
缓存穿透:请求的key在缓存和数据库里都不存在,每次都要查API。比如用户传了个乱码prompt,缓存里没有,API返回错误,但下次同样的乱码又来一遍。
解决方案:布隆过滤器。把所有合法的prompt模式存到布隆过滤器里,请求来了先判断是否可能存在,不存在直接返回,不查缓存也不调API。
# 布隆过滤器示例frompybloom_liveimportBloomFilter bloom=BloomFilter(capacity=100000,error_rate=0.001)# 初始化时把常见prompt模式加进去bloom.add("公司政策")bloom.add("代码示例")defis_valid_prompt(prompt):# 这里只做快速过滤,误判率0.1%returnpromptinbloom缓存击穿:某个热点key在过期瞬间,大量请求同时涌入,全部打到API上。比如每天早上9点,所有人同时问“今天有什么会议”,缓存刚好在8:59过期。
解决方案:互斥锁。只让一个请求去调API,其他请求等待。
# 互斥锁防止缓存击穿defget_with_mutex(key):data=get_from_redis(key)ifdata:returndata# 尝试获取锁lock_key=f"lock:{key}"ifredis_client.setnx(lock_key,"1"):# 拿到锁的线程去调APIredis_client.expire(lock_key,10)# 防止死锁result=call_llm_api(key)set_to_redis(key,result,ttl=3600)redis_client.delete(lock_key)returnresultelse:# 没拿到锁的线程等待time.sleep(0.1)returnget_with_mutex(key)# 递归重试这里注意setnx的过期时间一定要设,否则线程崩溃了锁永远不释放,整个系统就卡死了。
缓存雪崩:大量key在同一时间过期,导致API负载瞬间飙升。比如我把所有缓存都设成1小时过期,整点时刻所有请求都miss。
解决方案:过期时间加随机偏移。
# 过期时间加随机偏移,避免雪崩ttl=3600+random.randint(0,600)# 1小时±5分钟set_to_redis(key,value,ttl=ttl)语义缓存:比精确匹配更聪明
精确匹配缓存有个硬伤:用户问“公司年假政策”和“年假怎么休”,语义上是一回事,但prompt字符串不同,缓存无法命中。
语义缓存的做法是把prompt转成向量,存到向量数据库里,新请求来了先做相似度检索,找到语义相似的缓存直接返回。
# 语义缓存伪代码defsemantic_cache_get(prompt):prompt_vec=embedding_model.encode(prompt)# 在向量数据库中检索相似度>0.95的缓存results=vector_db.search(prompt_vec,threshold=0.95)ifresults:returnresults[0].responsereturnNone这个方案我试过,效果很好但代价不小:每次请求都要做一次embedding计算,如果embedding模型本身也调API,那成本反而更高。建议只在本地部署embedding模型时用,或者对高频prompt做预计算。
缓存淘汰策略:LRU还是LFU
缓存空间有限,满了之后要淘汰旧数据。LRU(最近最少使用)和LFU(最不经常使用)是两种主流策略。
LRU适合访问模式有局部性的场景,比如用户短时间内反复问同一个问题。LFU适合有稳定热点的场景,比如公司政策问答。
我一般用LRU,因为实现简单,而且大多数场景下够用。Redis默认的淘汰策略是noeviction(不淘汰,写满报错),生产环境一定要改成allkeys-lru或volatile-lru。
# Redis配置maxmemory 512mb maxmemory-policy allkeys-lru缓存一致性:什么时候该失效
缓存和API返回的数据不一致,是缓存系统最头疼的问题。LLM模型更新后,同样的prompt可能得到不同的回答,但缓存里还是旧数据。
我的做法是给每个缓存key打一个版本号,模型更新时全局版本号+1,所有缓存自动失效。
# 版本号控制缓存失效CACHE_VERSION=3# 模型更新时手动+1defbuild_cache_key(prompt,model):raw=f"{CACHE_VERSION}|{prompt}|{model}"returnhashlib.sha256(raw.encode()).hexdigest()另外,对于知识库类的缓存,如果知识库内容更新了,需要主动删除相关缓存。比如公司更新了年假政策,就要把包含“年假”关键词的缓存全部清掉。
监控与告警:别等出事了再查
缓存系统不是部署完就完事了,需要持续监控几个关键指标:
- 缓存命中率:低于80%说明缓存设计有问题,要么key设计不合理,要么TTL太短
- 缓存大小:超过内存限制会导致淘汰频繁,命中率下降
- API调用量:缓存生效后,API调用量应该明显下降
我写了个简单的监控脚本,每天发邮件报告缓存状态:
# 缓存监控示例defreport_cache_stats():hits=redis_client.info()['keyspace_hits']misses=redis_client.info()['keyspace_misses']hit_rate=hits/(hits+misses)*100print(f"缓存命中率:{hit_rate:.2f}%")ifhit_rate<80:send_alert("缓存命中率过低,请检查缓存配置")个人经验总结
缓存不是银弹,用不好反而增加复杂度。我的建议是:
先测量,再优化。别一上来就搞三级缓存,先看看API调用日志里哪些prompt重复率最高,针对性地缓存。我见过有人花两周搭了个分布式缓存,结果发现90%的请求都是唯一的,缓存命中率不到5%。
缓存要有降级方案。Redis挂了怎么办?缓存服务不可用时,直接调API,别让缓存故障导致整个服务不可用。我习惯在缓存层加一个熔断器,连续失败超过阈值就跳过缓存。
成本意识。缓存本身也有成本:Redis服务器、内存占用、维护人力。算清楚账,别为了省几块钱API费用,花了几千块买Redis集群。
测试缓存失效场景。模拟Redis宕机、缓存穿透、雪崩,看看系统能不能扛住。我每次上线前都会做混沌工程实验,故意让缓存层出问题,验证降级逻辑是否正常。
那次半夜告警之后,我把缓存系统重构了一遍,API调用成本降了60%,平均延迟从2秒降到200毫秒。但最让我欣慰的是,再也没在凌晨被电话吵醒过。
缓存这东西,看起来简单,但每个细节都可能让你在半夜爬起来。希望这篇笔记能帮你少踩几个坑。