Spring AI + Redis实战:会话缓存与语义搜索完整方案
2026/4/30大约 8 分钟
Spring AI + Redis实战:会话缓存与语义搜索完整方案
适读人群:有1-5年Java开发经验,想向AI工程师方向转型的开发者 阅读时长:约17分钟 文章价值:
- 掌握Spring AI + Redis实现会话记忆的完整方案
- 学会用Redis Stack实现语义缓存,大幅降低LLM调用成本
- 获得生产可用的完整代码实现
用户问了同一个问题三百次
某天我朋友给我发消息,说他们的AI客服系统这个月的API账单快破万美元了,他快被老板骂死了。
我帮他看了一下调用日志,发现一个惊人的现象:"如何开具发票"这一个问题,当天被调用了341次,每次都乖乖调了LLM,每次都生成了差不多的答案。
这不是个例,很多AI应用都有这个问题——用户问的问题是高度重复的,但系统每次都老老实实地打一次LLM的API,白花了大量的钱。
解决方案有两个层次:
- 精确缓存:完全相同的问题直接缓存(简单,但命中率有限)
- 语义缓存:语义相近的问题复用同一个答案(难一点,但命中率高得多)
Spring AI + Redis Stack可以同时做到这两点。今天我来手把手实现一套完整方案。
整体架构
环境搭建
Docker启动Redis Stack
# docker-compose.yml
version: '3.8'
services:
redis-stack:
image: redis/redis-stack:latest
container_name: redis-stack
ports:
- "6379:6379"
- "8001:8001" # Redis Insight Web UI
volumes:
- redis-data:/data
environment:
- REDIS_ARGS=--save 60 1 --loglevel warning
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
volumes:
redis-data:Maven依赖
<dependencies>
<!-- Spring AI OpenAI -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-openai-spring-boot-starter</artifactId>
<version>1.0.0</version>
</dependency>
<!-- Spring AI Redis向量存储 -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-redis-store-spring-boot-starter</artifactId>
<version>1.0.0</version>
</dependency>
<!-- Spring Data Redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- JSON序列化 -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
</dependencies>application.yml
spring:
ai:
openai:
api-key: ${OPENAI_API_KEY}
chat:
options:
model: gpt-4o-mini
embedding:
options:
model: text-embedding-3-small
vectorstore:
redis:
uri: redis://localhost:6379
index: ai-semantic-cache
prefix: "doc:"
initialize-schema: true # 首次运行自动创建索引
data:
redis:
host: localhost
port: 6379
timeout: 5000ms
# 会话相关配置
app:
ai:
session:
ttl-seconds: 3600 # 会话过期时间1小时
max-history: 20 # 最多保留20轮对话历史
cache:
exact-ttl-seconds: 86400 # 精确缓存24小时
semantic-threshold: 0.92 # 语义相似度阈值核心实现一:会话历史管理
package com.laozhang.ai.session;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* 基于Redis的AI会话历史管理
*
* Redis数据结构:
* - Key: session:{sessionId}:messages
* - Type: List(有序,方便获取最近N条)
* - Value: JSON序列化的ChatMessage
* - TTL: 1小时(用户不活跃自动清理)
*/
@Service
@Slf4j
@RequiredArgsConstructor
public class RedisSessionManager {
private final RedisTemplate<String, String> redisTemplate;
private final ObjectMapper objectMapper;
@Value("${app.ai.session.ttl-seconds:3600}")
private long sessionTtlSeconds;
@Value("${app.ai.session.max-history:20}")
private int maxHistory;
private static final String SESSION_KEY_PREFIX = "session:";
private static final String MESSAGES_SUFFIX = ":messages";
/**
* 添加一轮对话到会话历史
*/
@SneakyThrows
public void addMessage(String sessionId, String role, String content) {
String key = buildKey(sessionId);
ChatMessage message = new ChatMessage(role, content,
System.currentTimeMillis());
String json = objectMapper.writeValueAsString(message);
// 从右侧追加(最新消息在右边)
redisTemplate.opsForList().rightPush(key, json);
// 保持最大历史数量
long size = redisTemplate.opsForList().size(key);
if (size > maxHistory * 2L) { // *2是因为每轮有user+assistant两条消息
redisTemplate.opsForList().trim(key, size - maxHistory * 2L, -1);
}
// 刷新TTL(有活动则延长过期时间)
redisTemplate.expire(key, Duration.ofSeconds(sessionTtlSeconds));
log.debug("会话 {} 新增消息,当前历史 {} 条", sessionId, size + 1);
}
/**
* 获取会话历史(最近N轮)
*/
@SneakyThrows
public List<ChatMessage> getHistory(String sessionId, int maxTurns) {
String key = buildKey(sessionId);
// 获取最近 maxTurns*2 条消息(每轮2条)
long size = redisTemplate.opsForList().size(key);
if (size == null || size == 0) {
return new ArrayList<>();
}
long start = Math.max(0, size - maxTurns * 2L);
List<String> jsonList = redisTemplate.opsForList().range(key, start, -1);
List<ChatMessage> messages = new ArrayList<>();
if (jsonList != null) {
for (String json : jsonList) {
messages.add(objectMapper.readValue(json, ChatMessage.class));
}
}
return messages;
}
/**
* 清除会话历史(用户主动退出时调用)
*/
public void clearSession(String sessionId) {
redisTemplate.delete(buildKey(sessionId));
log.info("会话 {} 已清除", sessionId);
}
/**
* 将历史消息格式化为Prompt文本
*/
public String formatHistoryForPrompt(List<ChatMessage> history) {
if (history.isEmpty()) return "";
StringBuilder sb = new StringBuilder("【对话历史】\n");
for (ChatMessage msg : history) {
String role = "user".equals(msg.role()) ? "用户" : "助手";
sb.append(role).append(":").append(msg.content()).append("\n");
}
return sb.toString();
}
private String buildKey(String sessionId) {
return SESSION_KEY_PREFIX + sessionId + MESSAGES_SUFFIX;
}
public record ChatMessage(String role, String content, long timestamp) {}
}核心实现二:语义缓存
这是最有价值的部分,也是最节省成本的地方:
package com.laozhang.ai.cache;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.embedding.EmbeddingModel;
import org.springframework.ai.embedding.EmbeddingResponse;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.ai.document.Document;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.util.DigestUtils;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/**
* 两级缓存服务:精确缓存 + 语义缓存
*
* 精确缓存:MD5(question) -> answer,完全相同的问题
* 语义缓存:question向量存入Redis Vector Store,语义相近时复用答案
*/
@Service
@Slf4j
@RequiredArgsConstructor
public class AICacheService {
private final RedisTemplate<String, String> redisTemplate;
private final VectorStore vectorStore;
private final EmbeddingModel embeddingModel;
@Value("${app.ai.cache.exact-ttl-seconds:86400}")
private long exactCacheTtl;
@Value("${app.ai.cache.semantic-threshold:0.92}")
private double semanticThreshold;
private static final String EXACT_CACHE_PREFIX = "exact_cache:";
/**
* 查询缓存(先精确,再语义)
* 返回Optional,empty表示缓存未命中
*/
public Optional<CacheResult> get(String question) {
// 第一级:精确缓存
String exactKey = buildExactKey(question);
String exactAnswer = redisTemplate.opsForValue().get(exactKey);
if (exactAnswer != null) {
log.debug("[缓存命中-精确] question长度={}", question.length());
return Optional.of(new CacheResult(exactAnswer, CacheType.EXACT, 1.0));
}
// 第二级:语义缓存
Optional<CacheResult> semanticResult = semanticSearch(question);
if (semanticResult.isPresent()) {
log.debug("[缓存命中-语义] 相似度={}",
semanticResult.get().similarity());
return semanticResult;
}
log.debug("[缓存未命中] question前20字={}",
question.substring(0, Math.min(20, question.length())));
return Optional.empty();
}
/**
* 将新的问答对存入缓存
*/
public void put(String question, String answer) {
// 存入精确缓存
String exactKey = buildExactKey(question);
redisTemplate.opsForValue().set(exactKey, answer,
Duration.ofSeconds(exactCacheTtl));
// 存入语义缓存(作为向量存储的文档)
Document doc = new Document(
answer, // content是答案
Map.of(
"question", question,
"answer", answer,
"cached_at", String.valueOf(System.currentTimeMillis())
)
);
// 用问题文本生成向量(不是用答案),这样检索时用问题查问题
// Spring AI会自动用配置的EmbeddingModel来embed document.text
// 但我们想用question来embed,所以创建时content设为question
Document questionDoc = new Document(
question,
Map.of(
"question", question,
"answer", answer,
"cached_at", String.valueOf(System.currentTimeMillis())
)
);
vectorStore.add(List.of(questionDoc));
log.debug("[缓存写入] 问题已缓存,question前20字={}",
question.substring(0, Math.min(20, question.length())));
}
/**
* 语义搜索:找最相似的历史问答
*/
private Optional<CacheResult> semanticSearch(String question) {
try {
SearchRequest searchRequest = SearchRequest.query(question)
.withTopK(1)
.withSimilarityThreshold(semanticThreshold);
List<Document> results = vectorStore.similaritySearch(searchRequest);
if (results.isEmpty()) {
return Optional.empty();
}
Document best = results.get(0);
String cachedAnswer = (String) best.getMetadata().get("answer");
if (cachedAnswer == null) {
return Optional.empty();
}
// 获取相似度分数
double score = best.getMetadata().containsKey("distance")
? 1.0 - (Double) best.getMetadata().get("distance")
: semanticThreshold;
return Optional.of(new CacheResult(cachedAnswer, CacheType.SEMANTIC, score));
} catch (Exception e) {
log.warn("语义缓存查询失败: {}", e.getMessage());
return Optional.empty();
}
}
private String buildExactKey(String question) {
return EXACT_CACHE_PREFIX + DigestUtils.md5DigestAsHex(
question.getBytes()
);
}
public enum CacheType { EXACT, SEMANTIC }
public record CacheResult(
String answer,
CacheType cacheType,
double similarity
) {}
}整合:完整的AI服务
package com.laozhang.ai.service;
import com.laozhang.ai.cache.AICacheService;
import com.laozhang.ai.session.RedisSessionManager;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Optional;
/**
* 整合会话历史 + 两级缓存的完整AI问答服务
*/
@Service
@Slf4j
@RequiredArgsConstructor
public class CachedAIService {
private final ChatClient chatClient;
private final AICacheService cacheService;
private final RedisSessionManager sessionManager;
/**
* 主问答方法
* 流程:缓存检查 -> 构建Prompt(含历史) -> LLM调用 -> 缓存写入 -> 历史更新
*/
public AIResponse chat(String sessionId, String question) {
long startTime = System.currentTimeMillis();
// 1. 检查缓存(仅对不含对话历史的独立问题启用语义缓存)
// 注意:有上下文依赖的问题不能直接用缓存,否则答案可能不准确
boolean isContextDependent = isContextDependentQuestion(question, sessionId);
if (!isContextDependent) {
Optional<AICacheService.CacheResult> cached = cacheService.get(question);
if (cached.isPresent()) {
AICacheService.CacheResult result = cached.get();
log.info("[缓存命中] type={}, 节省一次LLM调用", result.cacheType());
return AIResponse.fromCache(result.answer(), result.cacheType().name());
}
}
// 2. 获取对话历史
List<RedisSessionManager.ChatMessage> history =
sessionManager.getHistory(sessionId, 5);
String historyText = sessionManager.formatHistoryForPrompt(history);
// 3. 构建完整Prompt
String fullPrompt = buildPrompt(question, historyText);
// 4. 调用LLM
String answer = chatClient.prompt()
.system("你是一个专业的AI助手,请基于对话历史回答用户问题。")
.user(fullPrompt)
.call()
.content();
long duration = System.currentTimeMillis() - startTime;
// 5. 写入缓存(独立问题才缓存)
if (!isContextDependent) {
cacheService.put(question, answer);
}
// 6. 更新会话历史
sessionManager.addMessage(sessionId, "user", question);
sessionManager.addMessage(sessionId, "assistant", answer);
log.info("[LLM调用] duration={}ms, sessionId={}", duration, sessionId);
return AIResponse.fromLLM(answer, duration);
}
/**
* 判断问题是否依赖上下文
* 依赖上下文的问题不走语义缓存
*/
private boolean isContextDependentQuestion(String question, String sessionId) {
// 有历史对话才需要判断
List<RedisSessionManager.ChatMessage> history =
sessionManager.getHistory(sessionId, 1);
if (history.isEmpty()) return false;
// 包含指代词的问题很可能依赖上下文
String lower = question.toLowerCase();
return lower.contains("它") || lower.contains("这个")
|| lower.contains("那个") || lower.contains("继续")
|| lower.contains("上面") || lower.contains("刚才");
}
private String buildPrompt(String question, String historyText) {
if (historyText.isEmpty()) {
return question;
}
return historyText + "\n【当前问题】\n" + question;
}
public record AIResponse(
String answer,
boolean fromCache,
String cacheType,
long durationMs
) {
static AIResponse fromCache(String answer, String cacheType) {
return new AIResponse(answer, true, cacheType, 0);
}
static AIResponse fromLLM(String answer, long duration) {
return new AIResponse(answer, false, "NONE", duration);
}
}
}实测效果
在我们的客服场景中,部署两级缓存后的效果:
| 指标 | 部署前 | 部署后 |
|---|---|---|
| LLM调用次数/天 | 12,000次 | 3,200次 |
| 精确缓存命中率 | - | 18% |
| 语义缓存命中率 | - | 49% |
| 总缓存命中率 | 0% | 67% |
| 月均API费用 | ¥8,400 | ¥2,800 |
| 平均响应延迟 | 1,800ms | 650ms |
缓存命中率从0变成67%,费用直接砍掉2/3,延迟也大幅下降。
这个效果主要来自一个特点:用户的问题是高度重复的。客服场景尤为明显,你把所有用户问题聚个类,90%的问题能归入20个话题里。
生产注意事项
缓存污染问题:如果LLM某次给了错误答案,被缓存下来了,后续所有相似问题都会拿到错误答案。建议加入缓存条目的人工审核机制,或者设置较短的TTL(24小时),让错误答案能自动失效。
语义缓存阈值调整:0.92是比较保守的阈值,可以根据业务场景调整。客服FAQ类场景可以调高到0.95,意图识别类场景可以调低到0.88。
Redis Stack容量规划:每个向量条目约6-8KB(1536维float32),10万条缓存约需要600MB-800MB。Redis内存要规划好。
