LLM服务缓存与连接池管理:三层缓存架构与ChatClient池化
LLM 服务的调用成本远高于传统服务------单次调用延迟 1-10 秒,费用 $0.01-0.1。传统 Web 服务的缓存体系(基于精确键匹配)在 LLM 场景下部分失效,因为用户的输入是自然语言,同样的语义可以用无数种表述方式。
今天聊聊我在 nvc-guide里做的三层缓存架构,以及 ChatClient 的池化管理。
三层缓存架构
LLM 服务的缓存分为三个层次,每层解决不同的问题:
用户请求
│
▼
Layer 1: 语义缓存(LLM 响应级)
│ · pgvector cosine similarity >= 0.95
│ · 命中率 30-40%,直接跳过 LLM 调用
│ 未命中
▼
Layer 2: 工具缓存(工具结果级)
│ · ConcurrentHashMap 内存缓存
│ · TTL 5-30 分钟
▼
Layer 3: 配置缓存(Agent 配置级)
· Redis Cache-Aside,TTL 5 分钟
Layer 1:语义缓存
语义缓存是 LLM 服务特有的缓存形式。核心思路是将用户查询通过 Embedding 模型转换为高维向量,用向量相似度替代字符串精确匹配。
"怎么做 NVC 倾听" 和 "NVC 倾听的步骤是什么" 语义完全相同,但字符串完全不同。传统精确匹配完全失效,语义缓存可以命中。
相似度阈值:设为 0.95。阈值过高(>0.98)命中率极低;阈值过低(<0.90)容易误命中------"NVC 的四个步骤"和"NVC 的四个要素"答案完全不同。宁可少命中也不能答错。
场景白名单:只缓存知识问答类场景(7 种 Coach),不缓存评估和反思------这些任务的输出高度依赖用户的具体表现。
异常降级:所有缓存操作都 try-catch 包裹,任何异常都降级为"缓存未命中",不影响主流程。
Layer 2:工具缓存
Agent 调用的工具(RAG 检索、用户档案查询等)结果在短时间内不会变化。工具缓存与语义缓存的关键区别是:工具输入是结构化的 JSON 参数,可以用精确匹配,不需要语义相似度。
两种缓存 key 策略:
- 用户级 :
{toolName}:{userId},适用于同一用户结果相同的场景(仪表盘、档案) - 参数级 :
{toolName}:{userId}:{paramHash},适用于结果依赖具体参数的场景(RAG、Wiki)
分层 TTL:dashboard 5 分钟、profile 10 分钟、rag_search 30 分钟、wiki_search 30 分钟。
为什么用 JVM 内存而不是 Redis? 工具缓存在 LLM 推理的关键路径上,JVM 内存访问 0.001ms vs Redis 网络往返 1-2ms,差距 1000 倍。短 TTL、高频访问、允许重启后丢失------完全适合内存缓存。
只缓存成功结果。工具调用失败(如数据库超时)时不缓存错误,避免后续请求持续返回错误。
Layer 3:配置缓存
Agent 配置(system prompt、模型参数、温度等)存储在数据库中,但每次 LLM 调用都需要读取。用 Cache-Aside 模式:先查 Redis,miss 则查 DB 并写入 Redis(TTL 5 分钟)。
热更新机制:更新配置时先写 DB,再主动删除 Redis key,保证下次读取回源到最新数据。
java
public NvcAgentConfigEntity updateConfig(NvcAgentScene scene, AgentConfigUpdateRequest request) {
NvcAgentConfigEntity saved = agentConfigRepository.save(config);
redisService.delete(CACHE_KEY_PREFIX + scene.name()); // 主动清除缓存
return saved;
}
三层缓存对比
| 维度 | 语义缓存 | 工具缓存 | 配置缓存 |
|---|---|---|---|
| 存储介质 | pgvector | JVM 内存 | Redis |
| 匹配方式 | 向量相似度 | 精确匹配 | 精确匹配 |
| 典型 TTL | 30 天 | 5-30 分钟 | 5 分钟 |
| 典型命中率 | 30-40% | 60-80% | 99%+ |
缓存带来的收益
| 指标 | 无缓存 | 有缓存 | 改善 |
|---|---|---|---|
| LLM 调用次数/天 | ~1000 | ~600-700 | 降低 30-40% |
| 工具调用平均延迟 | 300ms | 60ms | 降低 80% |
| 配置读取延迟 | 5ms | 0.5ms | 降低 90% |
| 缓存命中响应耗时 | 2-10s | ~55ms | 提速 40-180 倍 |
ChatClient 连接池管理
为什么需要"连接池"
传统连接池(如 HikariCP)管理的是 TCP 长连接的复用。LLM 服务场景的瓶颈不同------ChatClient 对象本身的构建成本很高:
| 成本项 | 说明 |
|---|---|
| OpenAiChatModel 构建 | 创建 OpenAiApi、ChatOptions 等 |
| Advisor 链组装 | ToolCallAdvisor、MemoryAdvisor 等逐一实例化 |
| Tool 注册 | Function Calling 的 ToolCallback 绑定 |
| EmbeddingModel 构建 | 独立的 OpenAiApi 实例和维度配置 |
如果每次请求都 new ChatClient(...),会产生大量重复的对象创建和 GC 压力。
ConcurrentHashMap + computeIfAbsent
对于"一旦创建就长期有效"的缓存场景,ConcurrentHashMap.computeIfAbsent 是最优选择:
java
private final Map<String, ChatClient> clientCache = new ConcurrentHashMap<>();
public ChatClient getChatClient(String providerId) {
return clientCache.computeIfAbsent(providerId, id -> createChatClient(id));
}
为什么不用 Guava Cache / Caffeine?Provider 数量有限(3-5 个),不需要 LRU 淘汰和 TTL。ConcurrentHashMap 零依赖,天然适合。
多类型 Client 共存
同一个 Provider 可能需要多种类型的 ChatClient。通过在 key 上加后缀区分:
"dashscope" → 默认 ChatClient(带 Memory + ToolCall + SafeGuard)
"dashscope:plain" → 纯净 ChatClient(仅 SafeGuard,无工具)
"dashscope:voice" → 语音 ChatClient(ToolCall,无 Memory)
三种类型互不干扰,各自独立缓存和复用。
ChatClient 是无状态的
ChatClient 本身是无状态的模板对象 ,包含的是 Model 配置 + Advisor 链 + Tool 绑定。对话上下文(ChatMemory)由 MessageChatMemoryAdvisor 在每次请求时从独立的 ChatMemory 存储中加载/保存,按 conversationId 隔离。
多个用户同时使用同一个缓存的 ChatClient 实例,各自的对话历史互不干扰。
配置热更新
ChatClient 缓存一般是 write-once, read-many 模式。需要清空缓存的场景:管理员修改了 Provider 的 API Key 或模型配置、切换了默认 Provider。
java
public void reload() {
clientCache.clear();
embeddingModelCache.clear();
}
clear() 后,下次访问时自动懒加载重建。已经持有旧 ChatClient 引用的请求不会受影响------旧对象仍然可用直到请求结束。
EmbeddingModel 防误配
EmbeddingModel 也用同样的 ConcurrentHashMap 缓存策略。内置了防误配校验------如果用户把 Chat 模型名(如 glm-4)填到了 Embedding Model 配置,会抛出明确的错误提示。
小结
LLM 服务缓存的核心思路是分层:语义缓存处理 LLM 响应(向量相似度匹配),工具缓存处理 Function Calling 结果(精确 key 匹配),配置缓存处理 Agent 配置(Redis Cache-Aside)。每层的存储、匹配方式、TTL 都不同。
ChatClient 管理的核心是池化:ConcurrentHashMap 缓存已构建的实例,按 Provider + 类型后缀区分,配置变更时 clear + 懒加载重建。
三层缓存整体将 LLM 调用成本降低了 30-40%,工具调用延迟降低了 80%。