你的客服系统里,用户每天都在问"怎么退款""退款流程是什么""钱能退吗"------其实是同一个问题,但系统每次都老老实实调一次大模型,token 白烧、钱白花、回复还慢半拍。这篇文章用 Spring AI 2.0 的语义缓存(Semantic Cache)解决它:把"问题 + 答案"存进 Redis 向量库,新问题先用向量相似度匹配历史问题,命中了直接返回缓存答案,没命中才真调大模型。代码完整可运行,读完就能复现。
一、这个问题到底是什么
先讲清楚语义缓存到底解决什么痛点。
你肯定用过传统缓存,比如 Redis 里存 key=用户ID,value=用户资料。它的规则很死:key 必须一模一样才能命中。"退款"和"申请退款"是两条不同的 key,缓存永远认不出来这俩是同一件事。
但用户不会照着标准 key 说话。同一个"怎么退款",用户会问成"如何退款""退款流程是啥""钱能退吗""我要退货退钱",甚至带几个错别字。传统缓存面对这些变体,全部 miss,全部重新调大模型。
语义缓存(Semantic Cache)就是解决"换种说法就 miss"的问题。 它不看字面是否一样,而是看"含义是否接近"。
用一个类比把关系说清楚:
- 传统缓存:像图书馆按书名找书,书名一个字不对就找不到。
- 语义缓存:像图书馆按"内容主题"找书,你说"想看怎么退钱的书",它能找到《退款操作指南》,哪怕书名不完全一样。
所以语义缓存的核心价值是:把"重复问题"从"必须重新花一遍大模型的算力和钱",变成"直接返回上次算好的答案"。 带来三个直接好处:
- 省钱:命中缓存的请求根本不调大模型,token 费用为 0。
- 提速:省掉大模型那几秒到十几秒的生成时间,命中后毫秒级返回。
- 更稳:少调大模型,就少一次被限流、超时、报错的风险。
一句话总结:语义缓存让你只给"真正的新问题"付钱,重复问题直接白嫖上次的答案。
二、底层原理到底怎么回事
要搞懂语义缓存,得先理解两件事:Embedding 和向量相似度。
Embedding(嵌入)是把一段文字变成一串数字。 大模型内部不认识"字",它只认识数字。所以有个专门的 embedding 模型,负责把"怎么退款"这种中文句子,转换成一个固定长度的数字数组(也叫向量)。比如 OpenAI 的 text-embedding-3-small 会把任意文本变成 1536 个数字。
这个转换有个神奇的性质:含义相近的句子,转出来的向量在空间里离得近;含义无关的,离得远。 "怎么退款"和"如何申请退款"的向量几乎重叠,"怎么退款"和"今天天气不错"的向量差得十万八千里。
那"离得近"怎么量化?用余弦相似度(Cosine Similarity),它算两个向量夹角的余弦值,结果在 -1 到 1 之间。对我们来说只看 0 到 1:
- 1 表示两个向量方向完全一致,几乎同义。
- 0.8 表示很接近,基本是一个意思。
- 0 表示完全不相关。
Spring AI 的 Redis 向量库默认就用余弦相似度。**相似度阈值(similarityThreshold)**就是那条"算不算同一个问题"的分界线:默认 0.8,意思是新问题只要和历史问题相似度达到 0.8,就当作同一个问题,直接返回历史答案。
接下来看语义缓存的完整流程,分两步:
第一步:写入(第一次问,缓存 miss 时)
- 用户问"怎么退款",缓存里没有。
- 调用大模型生成答案,拿到完整的
ChatResponse。 - 把问题"怎么退款"转成向量,存进 Redis 向量索引;同时把完整答案序列化成 JSON,作为这个文档的一个元数据字段(
response)存起来。
第二步:读取(第二次问,换种说法)
- 用户问"如何申请退款"。
- 把这句话转成向量。
- 在 Redis 里做向量最近邻搜索(VECTOR_RANGE 查询),找出相似度最高的历史文档。
- 拿回那个文档,看它的相似度是否 ≥ 0.8。
- 达标 → 直接反序列化出上次存的答案,返回给用户,不调大模型;不达标 → 走正常流程调大模型,然后把这次的结果也写进缓存。
这里有个容易被忽略但很重要的细节------上下文隔离(contextHash) 。同一个"你是谁",对"客服"这个 system prompt 和对"技术专家"这个 system prompt,答案应该不同。所以缓存会额外把 system prompt 的 SHA-256 哈希算进去:只有 system prompt 也一样的相似问题,才会命中缓存,避免不同角色之间串答案。
最后说底层存储。语义缓存用的是 Redis Stack (带 RediSearch 模块的 Redis),不是普通 Redis。因为向量检索靠 RediSearch 提供的 HNSW 索引和 FT.CREATE/FT.SEARCH 命令,普通 Redis 没有这些能力。文档以 JSON 格式存,答案 JSON 就躺在文档的 response 字段里。
三、实战:手把手写代码
下面这段用 Spring Boot 4.1.1 + Spring AI 2.0.1(都是当前最新 GA 版),Java 21。你可以照着直接跑。
3.1 依赖:pom.xml
三个关键依赖:spring-ai-starter-model-openai(提供聊天模型和 embedding 模型)、spring-ai-redis-semantic-cache(提供语义缓存实现)、spring-boot-starter-web(提供 REST 接口)。
xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>semantic-cache-demo</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>semantic-cache-demo</name>
<description>Spring AI Semantic Cache Demo</description>
<properties>
<java.version>21</java.version>
<spring-ai.version>2.0.1</spring-ai.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-openai</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-redis-semantic-cache</artifactId>
</dependency>
</dependencies>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>${spring-ai.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
3.2 配置:application.yml
配置 OpenAI 的 API Key、聊天模型和 embedding 模型,以及 Redis 的连接地址。
yaml
spring:
application:
name: semantic-cache-demo
ai:
openai:
api-key: ${OPENAI_API_KEY}
chat:
options:
model: gpt-4o-mini
embedding:
options:
model: text-embedding-3-small
app:
redis:
host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379}
3.3 核心配置类:SemanticCacheConfig
这个类是重点。它做了四件事:
redisClient:创建 Jedis 7 的RedisClient,连你的 Redis Stack。semanticCache:用DefaultSemanticCache.builder()把 RedisClient 和 EmbeddingModel 组装成语义缓存,并设相似度阈值为 0.8。semanticCacheAdvisor:把语义缓存包成 ChatClient 的 advisor(拦截器)。chatClient:把 advisor 挂到 ChatClient 上,之后每次对话都会先查缓存。
关键点:这里用 jedisClient(...) + embeddingModel(...) 的方式,让 DefaultSemanticCache 内部自己去建向量索引,并自动声明好 response、response_text、context_hash 这几个元数据字段(这点很重要,第四节会展开讲为什么必须这样)。
java
package com.example.semanticcache;
import org.springframework.ai.chat.cache.semantic.SemanticCache;
import org.springframework.ai.chat.cache.semantic.SemanticCacheAdvisor;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.embedding.EmbeddingModel;
import org.springframework.ai.vectorstore.redis.cache.semantic.DefaultSemanticCache;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import redis.clients.jedis.RedisClient;
@Configuration
public class SemanticCacheConfig {
@Bean
public RedisClient redisClient(
@Value("${app.redis.host}") String host,
@Value("${app.redis.port}") int port) {
return RedisClient.builder()
.hostAndPort(host, port)
.build();
}
@Bean
public SemanticCache semanticCache(RedisClient redisClient, EmbeddingModel embeddingModel) {
return DefaultSemanticCache.builder()
.jedisClient(redisClient)
.embeddingModel(embeddingModel)
.similarityThreshold(0.8)
.build();
}
@Bean
public SemanticCacheAdvisor semanticCacheAdvisor(SemanticCache semanticCache) {
return new SemanticCacheAdvisor(semanticCache);
}
@Bean
public ChatClient chatClient(ChatClient.Builder builder, SemanticCacheAdvisor advisor) {
return builder
.defaultAdvisors(advisor)
.build();
}
}
3.4 对外接口 + 启动类
SemanticCacheController 暴露一个 /ask 接口,接收 question 参数,把问题交给挂了缓存的 ChatClient。
java
package com.example.semanticcache;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class SemanticCacheController {
private final ChatClient chatClient;
public SemanticCacheController(ChatClient chatClient) {
this.chatClient = chatClient;
}
@GetMapping("/ask")
public String ask(@RequestParam String question) {
return chatClient.prompt()
.user(question)
.call()
.content();
}
}
启动类:
java
package com.example.semanticcache;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class SemanticCacheApplication {
public static void main(String[] args) {
SpringApplication.run(SemanticCacheApplication.class, args);
}
}
3.5 跑起来验证
先确保本地起了 Redis Stack(Docker 一行命令):
bash
docker run -d --name redis-stack -p 6379:6379 redis/redis-stack-server:latest
然后启动应用,连续问两个"同义不同字"的问题:
bash
curl "http://localhost:8080/ask?question=怎么退款"
# 第一次:缓存 miss,真的调大模型,返回答案
curl "http://localhost:8080/ask?question=如何申请退款"
# 第二次:语义相似度 >= 0.8,命中缓存,直接返回上次答案,不再调大模型
第二次请求时,你去 OpenAI 后台看调用记录,会发现这次根本没产生 token 消耗------这就是语义缓存省下的钱。
四、踩坑经验和最佳实践
坑一(最隐蔽):元数据字段没声明,缓存永远 miss。
这是最容易翻车的地方,而且表现是"缓存静默失效"------不报错,就是永远命中不了。原因在 Spring AI 的 RedisVectorStore 底层:做向量搜索时,FT.SEARCH 的 RETURN 只返回你在建索引时声明过的字段 。而语义缓存把答案存在元数据的 response 字段里,如果建索引时没声明这个字段,搜索结果的元数据里就取不到 response,get() 只能返回空,缓存形同虚设。
解决办法就是本文 3.3 里的写法:用 DefaultSemanticCache.builder().jedisClient(...).embeddingModel(...) 让它内部自动声明 response、response_text、context_hash 字段。千万别图省事,自己 new 一个 RedisVectorStore 丢进去,却不配 metadataFields。
坑二:必须用 Redis Stack,不是普通 Redis。
语义缓存依赖 RediSearch 的向量检索能力。如果你连的是普通 Redis,启动时执行 FT.CREATE 会直接报 unknown command 'FT.CREATE'。要么用 redis/redis-stack-server 镜像,要么用带 RediSearch 模块的云 Redis。
坑三:阈值 0.8 不是万能值,要按业务调。
阈值越高越保守(宁可多花点钱调模型,也要答案准),越低越激进(省更多钱但可能答非所问)。中文短句、口语化问题多的话,0.8 可能偏严,可以试 0.75 甚至 0.7,但要配合线上抽检,确认没出现"牛头不对马嘴"的缓存命中。
坑四:多角色不隔离会串答案。
如果你的 ChatClient 有多个 system prompt(比如客服角色和技术专家角色),一定要确保 contextHash 生效。语义缓存默认会算 system prompt 的哈希做隔离,但如果你在 advisor 链里动态改 system prompt,要注意顺序,别让隔离失效,导致客服答案串给技术专家。
坑五:带时效性的答案要设 TTL。
价格、库存、活动规则这类答案会过期。缓存层有带 TTL 的写入方法,可以给这类条目设过期时间,避免用户看到过期的退款政策或价格。
最佳实践小结:
- 写入和读取必须用同一个 embedding 模型,换了模型向量空间就不对齐,相似度全乱。
- 缓存命中率是你最该盯的指标:命中率高,说明省钱;命中率低,说明阈值太严或问题太发散。
- 对命中返回的答案,建议在关键业务(金额、时效)上做二次校验,别 100% 无脑信任缓存。
五、性能对比和技术选型
一张表看清三种方案的区别:
| 方案 | 命中条件 | 换说法能命中吗 | 成本 | 延迟 |
|---|---|---|---|---|
| 无缓存 | 每次都调模型 | - | 最高 | 最慢 |
| 传统 key 缓存 | 字面完全一致 | 不能 | 低 | 快 |
| 语义缓存 | 含义相似 ≥ 阈值 | 能 | 低 | 快(命中时) |
实测经验值参考:客服、FAQ 这类"问题重复度高"的场景,语义缓存命中率很容易做到 50% 以上,意味着一半以上的请求直接省掉了大模型的 token 费用和生成延迟。对于高频问答、企业知识库问答、AI 客服,这笔账非常划算。
但语义缓存也不是银弹,选型时认清它的边界:
- 适合:FAQ、客服、知识库问答、固定的提示词任务------答案是相对稳定的、问题会重复出现的。
- 不适合:每次答案都必须最新(实时行情、个性化推荐)、用户问题极度长尾几乎不重复、答案高度依赖上下文(多轮对话的后续轮次)。
一句话选型建议:答案越稳定、问题越重复,语义缓存越划算;答案越实时、问题越长尾,越没必要。
六、总结
语义缓存解决的是一个非常具体又非常费钱的问题:用户换着说法问同一个问题,每次都要重新付一遍大模型的账。 它用 embedding 把"含义"变成可比较的向量,用相似度阈值判断"算不算同一个问题",命中了就直接返回历史答案,一分钱 token 都不花。
落地要点回顾:
- 依赖用
spring-ai-redis-semantic-cache,版本对齐 Spring AI 2.0.1(当前最新 GA)。 - 用
DefaultSemanticCache.builder().jedisClient(...).embeddingModel(...)建缓存,让它自动声明response等元数据字段------这是避免"缓存永远 miss"的关键。 - 后端必须是 Redis Stack(带 RediSearch 模块)。
- 阈值默认 0.8,按业务调;多角色靠 contextHash 隔离;时效性答案设 TTL。
语义缓存的本质是用"一点点向量检索的算力",换掉"大模型重复生成的大笔费用"。对于重复问题占比高的场景,这是性价比极高的优化,而且接入成本低------本文的代码改改配置就能上。