缓存机制
🗄️ LangChain 支持哪些类型的缓存?InMemoryCache, RedisCache, GPTCache 等。¶
LangChain 为了降低 LLM 调用成本、提升响应速度,提供了一套灵活的缓存抽象。核心接口是 BaseCache,开发者可以选用不同的后端实现,也可以自定义。目前官方和社区支持的主流缓存类型如下:
-
InMemoryCache:基于 Python 字典的内存缓存。读写极快,适合开发调试或单进程低负载场景。重启后数据丢失,无法跨进程共享。 -
RedisCache/RedisSemanticCache:基于 Redis 的分布式缓存。前者使用 Prompt 字符串的哈希作为 Key,进行精确匹配;后者利用向量相似度进行语义匹配(将查询转换为向量,搜索历史相似查询并返回缓存结果)。Redis 支持持久化、高可用、跨进程/跨实例共享,是生产环境首选。 -
SQLiteCache:基于 SQLite 的轻量级磁盘缓存。不需要额外服务,持久化,适合单机小规模应用,但并发能力弱。 -
GPTCache:由 GPTcache 社区提供的第三方缓存库,功能极为强大。它支持精确匹配、相似度匹配(基于向量)、多级缓存(热/温/冷)、缓存预加载、缓存刷新策略等。与 LangChain 的集成通过GPTCache的LangChainCache适配器实现。适合对缓存有精细控制需求的生产场景。 -
AstraDBCache:基于 DataStax AstraDB 的缓存,适合在 AstraDB 上构建的云原生应用。 -
MomentoCache:基于 Momento 的无服务器缓存,特点是低延迟、自动扩缩、无需管理基础设施,适合现代云架构。
选用原则:开发时用 InMemoryCache;单机持久化用 SQLiteCache;分布式、高可用、多进程共享用 RedisCache;需要语义缓存或精细化控制用 GPTCache。
⚙️ 如何为 LLM 调用添加缓存?写出示例代码。¶
添加缓存非常简单,只需在创建 LLM 实例时传入 cache 参数,或者在全局设置缓存。
方式一:为特定 LLM 实例设置缓存
from langchain.globals import set_llm_cache
from langchain.cache import InMemoryCache
from langchain_openai import ChatOpenAI
# 创建缓存实例
cache = InMemoryCache()
# 全局设置(对所有后续LLM生效)
set_llm_cache(cache)
llm = ChatOpenAI(model="gpt-3.5-turbo")
# 第一次调用,未命中,会请求API
response1 = llm.invoke("什么是量子计算?")
# 第二次相同调用,命中缓存,不再请求API
response2 = llm.invoke("什么是量子计算?")
方式二:仅对特定 LLM 实例生效(不全局)
使用 RedisCache 示例:
from langchain.cache import RedisCache
import redis
r = redis.Redis(host="localhost", port=6379, db=0)
cache = RedisCache(redis_=r)
set_llm_cache(cache)
对于 Redis 语义缓存 RedisSemanticCache,需要提供 Embeddings 模型,它会根据相似度返回缓存结果,即使 Prompt 不完全相同。
from langchain.cache import RedisSemanticCache
from langchain_openai import OpenAIEmbeddings
cache = RedisSemanticCache(
redis_url="redis://localhost:6379",
embeddings=OpenAIEmbeddings(),
score_threshold=0.9 # 相似度阈值
)
set_llm_cache(cache)
设置缓存后,LangChain 内部在每次 LLM 调用前会检查缓存,命中则直接返回 Generation 对象,跳过 API 调用。
🔑 缓存的 key 是怎么生成的?默认策略是什么?¶
默认情况下,LangChain 使用 Prompt 字符串的哈希值 作为缓存的 key。具体策略是:
-
将传入的 Prompt(
messages或字符串)标准化为字符串表示。 -
结合 LLM 的
model_name(或标识)和 Prompt 字符串,计算 SHA256 哈希。 -
这个哈希值作为 Redis / 内存字典的 Key,对应的 Value 是序列化后的
LLMResult(包含生成文本、token 使用量等)。
默认 Key 生成源码逻辑(简化):
def _key(self, prompt: str, llm_string: str) -> str:
return hashlib.sha256(f"{prompt}:{llm_string}".encode()).hexdigest()
这里的 llm_string 是一个能唯一标识模型和其关键参数的字符串(包含模型名、temperature、max_tokens 等),由 LLM._get_identifying_params() 生成。这意味着,只有当 Prompt 和模型参数完全一致时,缓存才会命中。如果 temperature 不同,llm_string 就不同,Key 也不同,因此不会命中。这正是问题 4 的答案。
局限性:默认哈希策略只支持精确匹配,无法处理同义改写或微小差异。此时需要使用 RedisSemanticCache 或 GPTCache 这类支持语义匹配的缓存。
🌡️ 如果 Prompt 相同但 Temperature 不同,缓存会命中吗?¶
不会。 如前所述,LangChain 在生成缓存 Key 时,不仅使用了 Prompt 字符串,还拼接了 llm_string。llm_string 中包含了模型的关键参数,其中就有 temperature。因此,Prompt 相同但 temperature 不同,生成的两个 Key 不同,缓存无法命中。这是合理的,因为 temperature 直接影响输出的随机性,必须视为不同的调用。
如果你想忽略 temperature 差异,可以自定义缓存 Key 生成逻辑。你可以继承 RedisCache 并重写 _key 方法,或者使用 GPTCache 等支持更灵活匹配策略的缓存。但通常不建议这么做,因为缓存的初衷就是加速“完全相同的请求”,改变参数理应生成新结果。
💡 在开发时,你如何利用缓存避免重复调用 LLM 以节省成本?¶
开发阶段,反复调试 Prompt 会导致大量重复的 API 调用,成本快速累积。我会采取以下策略:
-
开启
InMemoryCache并保持常驻 在 Jupyter Notebook 或调试脚本开头就设置set_llm_cache(InMemoryCache())。这样同一进程内,相同 Prompt 只调用一次。 -
使用
SQLiteCache持久化到本地文件
这样即使重启程序,之前的调用结果仍然有效。开发几天下来,能避免大量重复请求。
-
配合 LangChain 的
llm_cache上下文管理器 可以在特定代码块中临时启用缓存,而不是全局开启,避免影响其他测试。 -
在 CI/CD 中使用共享 Redis 缓存
我们团队的 CI 流水线会部署一个 Redis 实例,专门用于测试期间的缓存。每次提交代码,所有测试用例的 LLM 调用优先走 Redis 缓存,大幅缩减测试时间和费用。
- 利用
GPTCache的预加载能力 把常用的 Prompt 和期望的输出预先缓存,开发新功能时直接从缓存读取,不消耗 API。
实践:我习惯在 pytest 的 conftest.py 中全局设置 SQLiteCache,并确保数据库文件被版本控制忽略但保留在本地,这样运行测试时自动缓存。
🔄 生产环境中,缓存失效策略如何制定?比如新版模型发布后需要清空缓存。¶
生产环境缓存失效策略需兼顾数据新鲜度、成本和一致性。
-
基于版本的 Key 前缀 在缓存的 Key 中加入模型版本号。例如,使用
v1、v2作为前缀。当模型升级时,只需更换前缀,旧缓存自然失效,无需删除。LangChain 的llm_string已经包含了部分版本信息,但对于业务层面的大版本,可以自己封装。 -
主动清空 在模型部署流水线中,触发缓存清理任务。例如,通过 Redis 的
FLUSHDB或扫描匹配特定前缀的 Key 并删除。适合 Redis 等可操控后端。 -
设置 TTL(生存时间) 对于实时性要求高的场景(如新闻摘要),为缓存条目设置 TTL。LangChain 的
RedisCache支持ttl参数(需自定义或使用RedisSemanticCache的ttl)。GPTCache内置了 TTL 支持。 -
差异化失效
不是所有 Prompt 都同等对待。对于知识密集型、时效性低的查询(如“勾股定理”),可以设置较长 TTL 甚至永久缓存;对于实时数据查询,设置短 TTL 或不缓存。
- 基于业务事件的失效
当知识库更新、数据库变更时,主动通知缓存服务清除相关 Prompt 的缓存。这需要更复杂的事件驱动架构。
实践中:我们使用 RedisSemanticCache 并设置 ttl=3600(1小时)用于一般问答,对于产品文档类 Prompt,TTL 设为 24 小时;在 CI/CD 中,每当发布新模型时,执行一条 Redis SCAN + DEL 命令清理包含旧版本号的 Key。
🧰 RedisCache 的配置和使用经验:连接池、超时、序列化等。¶
生产中使用 RedisCache 需注意以下细节:
连接池:不要为每个请求创建新的 Redis 连接。应该创建全局的 redis.ConnectionPool,然后传递给 RedisCache。
import redis
pool = redis.ConnectionPool(host="localhost", port=6379, max_connections=20)
r = redis.Redis(connection_pool=pool)
cache = RedisCache(redis_=r)
超时设置:避免因 Redis 故障导致请求阻塞。设置 socket_connect_timeout 和 socket_timeout。
序列化:LangChain 默认使用 pickle 序列化 LLMResult,但 pickle 不安全,且跨语言兼容性差。生产环境建议改为 JSON 序列化(RedisCache 不支持,需要自定义)。RedisSemanticCache 则使用 JSON。
容错与降级:当 Redis 不可用时,不应影响主业务流程。可以包装缓存逻辑,捕获异常后跳过缓存,直接调用 LLM。LangChain 内部在缓存失败时默认会 fallback 到无缓存调用,但建议监控异常。
内存管理:设置 maxmemory 策略(如 allkeys-lru)防止 Redis 内存溢出,同时设置 TTL。
监控:通过 Prometheus Redis Exporter 监控缓存命中率、内存使用、延迟等。
实战经验:早期我们直接用默认的 RedisCache,结果在并发场景下出现 ConnectionError,原因是连接池耗尽。调整 max_connections 并增加重试机制后解决。同时,我们切换到 RedisSemanticCache 以获得语义匹配能力,但在使用中发现其 Embedding 计算开销较大,因此又结合了本地 InMemoryCache 做一级缓存,Redis 做二级缓存。
📊 你如何监控缓存的命中率和效果?¶
监控缓存命中率是评估缓存价值的关键。可以通过以下方式实现:
- 在应用层埋点
自定义一个包装
BaseCache的装饰器,记录每次lookup和update的调用,计算命中率。
class MonitoredCache(BaseCache):
def __init__(self, underlying_cache):
self.cache = underlying_cache
self.hits = 0
self.misses = 0
def lookup(self, prompt, llm_string):
result = self.cache.lookup(prompt, llm_string)
if result:
self.hits += 1
else:
self.misses += 1
return result
# update同理
-
通过回调收集 在
on_llm_start和on_llm_end回调中无法直接感知缓存,因为缓存命中时on_llm_start都不会触发(缓存直接返回)。因此需要在缓存层面埋点。 -
Redis 内置监控 如果使用 Redis,可以通过
INFO stats命令获取keyspace_hits和keyspace_misses,但这反映的是整个 Redis 实例的命中率,不局限于 LangChain。 -
集成到 Prometheus 编写自定义的 Counter,如
langchain_cache_hits_total和langchain_cache_misses_total,在缓存访问时递增。然后通过 Grafana 展示命中率曲线和趋势。 -
评估缓存效果
除了命中率,还应监控:
-
延迟降低:对比有缓存和无缓存的端到端延迟。
-
成本节省:根据命中次数和 token 单价计算节省金额。
-
错误率:缓存错误(如序列化失败)的频率。
实践:我们使用了 GPTCache,其内置了 stats 接口,可以直接暴露命中率。我们将这些指标导出到 Prometheus,并在 Grafana 中搭建了缓存效率看板。
🔗 如何在 LCEL 链中为特定组件(如 Embedding)单独配置缓存?¶
LCEL 链本身没有“链级缓存”的概念,缓存通常是针对 LLM 或 Embedding 等底层组件单独配置的。你可以为不同的组件创建独立的缓存实例。
为 Embedding 缓存:LangChain 的 CacheBackedEmbeddings 就是专门为 Embedding 设计的缓存层。它内部使用一个 BaseStore 来缓存文本的 Embedding 向量。
from langchain.embeddings import CacheBackedEmbeddings
from langchain.storage import LocalFileStore
from langchain_openai import OpenAIEmbeddings
store = LocalFileStore("./cache/")
embeddings = OpenAIEmbeddings()
cached_embeddings = CacheBackedEmbeddings.from_bytes_store(embeddings, store)
对于 LLM,在链外创建带有缓存的 LLM 实例,然后传入链中。不同 LLM 实例可以指向不同的缓存后端。例如,关键业务链用 RedisCache,非关键链用 InMemoryCache。
important_llm = ChatOpenAI(model="gpt-4", cache=RedisCache(...))
normal_llm = ChatOpenAI(model="gpt-3.5-turbo", cache=InMemoryCache())
chain1 = prompt1 | important_llm
chain2 = prompt2 | normal_llm
注意:Embedding 模型和 LLM 的缓存 Key 生成策略不同,Embedding 是文本本身,LLM 是 Prompt + 模型参数,它们互不冲突。
🚀 使用 GPTCache 时,它和 LangChain 的集成是如何实现的?有什么独特优势?¶
集成方式:GPTCache 提供了 LangChainCache 适配器,该适配器实现了 LangChain 的 BaseCache 接口。初始化 GPTCache 后,通过 set_llm_cache 将其设置为全局缓存即可。
from gptcache import cache
from gptcache.manager import get_data_manager, CacheBase, VectorBase
from gptcache.adapter.langchain_models import LangChainCache
# 配置GPTCache的数据存储和向量存储
data_manager = get_data_manager(CacheBase("sqlite"), VectorBase("faiss", dimension=...))
cache.init(data_manager=data_manager, pre_embedding_func=...)
langchain_cache = LangChainCache()
set_llm_cache(langchain_cache)
独特优势:
-
多级缓存:支持精确匹配和语义匹配两级。先用精确匹配(Prompt 哈希),未命中则用向量相似度搜索历史查询,返回相似度高于阈值的缓存结果。这极大提高了命中率。
-
灵活的存储后端:缓存数据可以存 SQLite、MySQL、PostgreSQL;向量可以存 FAISS、Milvus、Chroma 等。
-
预加载与预热:可以将高频 Prompt 提前导入缓存,服务启动即可用。
-
缓存刷新:支持基于时间、计数、LLM 返回内容过滤等策略自动刷新缓存。
-
详细的监控和统计:内置命中率、延迟等指标。
-
跨 LLM 共享:GPTCache 是独立的缓存层,可以被多种 LLM 客户端共享,不限于 LangChain。
使用场景:当你有大量相似但不完全相同的用户查询(如客服、FAQ),GPTCache 的语义匹配能显著提高缓存命中率。我们之前在一个法律咨询系统中,使用 GPTCache + Milvus,将缓存命中率从 30%(精确匹配)提升到 70%,节省了大量 GPT-4 费用。
🔒 缓存是否会导致数据安全问题?(比如不同用户的查询互相命中)¶
是的,绝对会导致。 尤其是当缓存 Key 仅基于 Prompt 字符串,而不考虑用户身份时。用户 A 的查询结果可能被用户 B 的相同查询命中并返回,造成数据泄露。例如,用户 A 查询“我的订单详情”,如果另一个用户 B 也输入完全相同的“我的订单详情”,B 可能看到 A 的订单信息(如果 LLM 的回复中包含具体内容)。这种情况在 LangChain 默认缓存中极有可能发生。
解决方案:
-
隔离缓存 Key:在生成缓存 Key 时,加入
user_id或其他租户标识。可以通过自定义缓存实现,重写_key方法,拼接用户 ID。 -
使用语义缓存时谨慎:语义缓存会根据相似度匹配,即使 Prompt 不完全相同也可能命中,风险更大。建议对涉及个人数据的查询关闭语义缓存。
-
缓存内容过滤:在返回缓存结果前,检查是否包含敏感信息(例如用正则匹配身份证号、手机号),如果包含则不返回,重新生成。
-
混合策略:对公共知识类查询(如“什么是黑洞”)使用全局缓存;对个性化查询(如“我的订单”)使用用户级缓存或直接不走缓存。
-
加密缓存内容:对缓存数据加密存储,但这会增加计算开销。
实践:我们在多租户 SaaS 中,为每个租户分配独立的 Redis DB 编号,并将租户 ID 加入缓存 Key 前缀,从物理和逻辑上隔离。同时,所有包含用户输入的 Prompt 都禁止缓存,除非明确标注为公共问题。
🏢 在多租户场景下,缓存应该如何隔离?¶
多租户隔离核心是保证租户 A 的数据不会通过缓存泄露给租户 B。隔离策略从强到弱:
-
物理隔离:为每个租户提供独立的缓存实例(如独立的 Redis DB 或内存表)。最安全,但资源开销大。
-
逻辑隔离(Key 前缀):所有缓存 Key 加上
tenant_id前缀。这样不同租户的相同 Prompt 会生成不同的 Key。这是成本和安全之间的平衡点。
class TenantAwareRedisCache(RedisCache):
def __init__(self, tenant_id, redis_):
super().__init__(redis_)
self.tenant_id = tenant_id
def _key(self, prompt, llm_string):
base = super()._key(prompt, llm_string)
return f"{self.tenant_id}:{base}"
-
基于会话的缓存:将
session_id加入 Key,但会话结束后要清理,否则资源浪费。 -
请求级注入:通过
per_req_config_modifier在 LangServe 中注入tenant_id,链内部读取并传递给缓存。适合无状态服务。 -
禁止跨租户的语义缓存:语义缓存极易导致跨租户污染,在多租户场景下应禁用或严格限制相似度阈值。
额外考量:缓存的数据过期后,是否已彻底删除?需要考虑 Redis 的持久化备份是否也会泄露。
经验:我们的处理方式是:公共知识缓存全局共享,个性化缓存添加 tenant_id 前缀,并且对于包含敏感关键词的 Prompt 直接跳过缓存。
🛡️ 你如何防止缓存被恶意刷写导致成本增加?有没有限流措施?¶
恶意刷写是指攻击者不断用新的、无意义的 Prompt 来填充缓存,导致缓存空间被占满,并且由于未命中而频繁调用 LLM,成本飙升。防御策略:
-
限流(Rate Limiting):在网关或应用层限制单个 IP/用户的请求频率。Nginx、FastAPI 中间件、或者 API 网关均可实现。超过阈值返回 429。
-
缓存写入控制:
-
只对成功响应缓存:检查 LLM 返回状态,对错误或异常响应不缓存。
-
设置缓存 Value 最大体积:防止超大响应撑爆 Redis 内存。
-
限制写入频率:为每个用户维护一个写入计数器,短时间内写入超过阈值则暂停缓存写入(降级为直接调用 LLM 但不缓存)。
-
缓存内容过滤:如果返回内容被标记为有害、违规,不写入缓存。
-
使用专用缓存层:让缓存服务独立,并配备资源限制(Redis 的
maxmemory、maxclients)。 -
监控告警:监控缓存写入速率和键数量,异常飙升时触发告警。
-
认证授权:只有合法用户请求才启用缓存。未认证用户的请求不缓存,也不读取缓存。
实践:我们曾经遇到竞争对手通过脚本大量发送随机查询试图耗尽我们的 LLM 配额。我们在 WAF 层增加了基于行为分析的限流,同时在应用层加入了“同一 IP 每分钟写入缓存次数上限 20”的逻辑,超过后自动对该 IP 禁用缓存 30 分钟。
🤔 你认为 LangChain 的缓存机制在功能上还缺失什么?¶
尽管 LangChain 的缓存已经相当灵活,但在生产化应用中仍有一些不足:
-
缺乏原生的多租户支持:默认 Key 生成不包含用户/租户信息,需要手动封装。
-
缓存更新通知:没有内置的缓存驱逐通知机制,当底层数据更新时,无法主动失效相关缓存,只能靠 TTL 或手动清空。
-
分层缓存(L1/L2):缺少像 GPTCache 那样内置的多级缓存(内存 + Redis + 磁盘),需要自己组合。
-
更好的缓存策略:缺少基于内容类型的差异化缓存策略(例如,摘要可缓存,创意写作不可缓存),需要开发者自行判断。
-
与 LangServe 集成:希望在
add_routes时能轻松配置缓存策略,而不需要侵入链代码。 -
监控和可观测性:没有内建的缓存命中率、延迟等指标暴露,需要自己实现。
-
安全性:对敏感 Prompt 的过滤和缓存内容的加密没有内建支持。
-
自动缓存预热:没有提供根据历史数据或日志自动预加载缓存的能力。
期待的未来:LangChain 可以将 BaseCache 扩展为更强大的 CacheManager,支持条件缓存、动态 TTL、多级缓存、租户隔离、以及 Prometheus 集成。
⏳ 你是否遇到过因为缓存而导致模型输出过时的问题?如何解决?¶
遇到过。典型场景是:系统 Prompt 中包含“当前日期”或“最新新闻”,这些信息每天变化,但由于 Prompt 的主体部分(问题)未变,缓存一直命中,导致用户连续几天看到过时的回复。
解决方案:
-
为时效性强的 Prompt 设置短 TTL:例如新闻类查询缓存 10 分钟,天气缓存 1 小时。
-
在 Prompt 中显式加入时间戳或唯一标识:强迫缓存 Key 变化。比如在 System Prompt 中加入
{current_date}变量,每次请求都会变化,自然不会命中旧缓存。 -
标记不可缓存:对于必须实时生成的查询,通过自定义缓存逻辑,匹配到某些关键词(如“今天”、“最新”)时跳过缓存。
-
使用语义缓存时严格设置相似度阈值:避免因为微小差异而命中不准确的旧缓存。
-
事件驱动缓存失效:当新闻源或数据库更新时,通过消息队列通知缓存服务清除相关的 Key。
-
分层缓存 + 主动刷新:对于知识库类型,设置较长的 TTL,但后台定期刷新热点 Key。
实践:我们开发的财务助手有一个“今日大盘”功能,用户输入“今天大盘怎么样”,最初总是返回昨天的结果。后来我们在 Prompt 中加入了 datetime.now().strftime("%Y-%m-%d"),并且将缓存 TTL 设为 5 分钟,问题解决。