语义缓存大揭秘:用Spring AI+Redis拦截重复大模型请求,省下80%调用费用!

本文详解如何利用Spring AI与Redis实现语义缓存,通过向量相似度智能复用大模型响应,显著降低延迟与成本,适合高并发AI应用部署。

在当今疯狂拥抱大模型的时代,每个公司都在拼命往AI应用里塞Prompt,但你有没有想过——用户问“我能请几天病假?”和“病假最多能休多少天?”,其实根本就是同一个问题!可偏偏你的系统却傻乎乎地又调了一次OpenAI,又花了一次钱,又等了一次响应。这不仅浪费钱,还拖慢用户体验。今天,我们就来聊一个超级实用但被严重低估的技术:语义缓存(Semantic Caching)。它不是普通缓存,不是按字符串完全匹配那种,而是能理解“意思”的缓存!

想象一下,你的AI客服每天被成千上万条用户提问轰炸,其中30%的问题只是换了个说法而已。如果没有语义缓存,你等于在给OpenAI或Claude送钱。但如果你在调用大模型前,先问一句:“这问题之前有人问过类似的意思吗?”——只要答案是“有”,那就直接把之前的结果吐出去,整个过程毫秒级完成,费用趋近于零。这不是幻想,这是已经能落地的工程方案。

而今天我们要手把手教你的,就是如何用Spring AI + Redis,搭建一套企业级的语义缓存系统。这套系统不仅能自动把用户问题转成向量,还能在Redis里高效存储和检索语义相似的问题,实现“一次调用,多次复用”。整套架构轻量、灵活、可扩展,特别适合中大型AI产品团队在生产环境中部署。话不多说,我们直接上代码和重构。



项目搭建:三步配出AI语义缓存骨架
要玩转语义缓存,首先得有两样东西:一个能把文字变成向量的嵌入模型(Embedding Model),以及一个能存向量、能搜向量的向量数据库。Spring AI已经帮我们把这两块封装得非常友好,我们只需要正确引入依赖、配好参数就行。今天演示的是OpenAI的text-embedding-3-small模型,配合Redis作为向量存储后端,组合拳打出来又快又稳。

要实现语义缓存,核心依赖两个模块:嵌入模型(Embedding Model)与向量数据库(Vector Store)。Spring AI已为开发者高度封装,我们只需引入对应starter依赖即可快速启动。

首先,在你的Maven项目里加上Spring AI的OpenAI启动器依赖。注意版本号用1.0.3,这是截至目前最稳定的版本之一。这行依赖会自动帮你拉取EmbeddingModel的实现,后续你都不用自己写HTTP调用,Spring AI全给你封装好了。

首先,在Maven项目的pom.xml中添加OpenAI嵌入模型支持:


<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-starter-model-openai</artifactId>
    <version>1.0.3</version>
</dependency>


第二步,我们要引入Redis向量存储的支持。Redis从7.0开始原生支持向量搜索,Spring AI也提供了专门的starter模块。同样加一个依赖:spring-ai-starter-vector-store-redis。然后在配置文件里填上你的Redis连接地址,格式是redis://用户名:密码@主机:端口。如果你用的是AWS ElastiCache或阿里云Redis,记得开启ACL认证并分配权限。

接着,加上Redis向量存储的starter:


<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-starter-vector-store-redis</artifactId>
    <version>1.0.3</version>
</dependency>


这两个依赖会自动引入必要的Spring Boot自动配置类,包括EmbeddingModel、RedisVectorStore等。


接着,打开application.yaml,把你的OpenAI密钥通过环境变量注入进去,别硬编码!安全第一。然后指定用text-embedding-3-small模型,维度设成512。这个模型小而快,适合做语义缓存的嵌入生成,精度完全够用,而且便宜。如果你公司有自己的私有嵌入模型,比如用Sentence-BERT微调过的,Spring AI也支持替换,核心逻辑不变。


接下来是配置文件application.yaml:

yaml
spring:
  ai:
    openai:
      api-key: ${OPENAI_API_KEY}
      embedding:
        options:
          model: text-embedding-3-small
          dimensions: 512
  data:
    redis:
      url: ${REDIS_URL}

com:
  jdon:
    semantic:
      cache:
        similarity-threshold: 0.8
        content-field: question
        embedding-field: embedding
        metadata-field: answer

说明一下:OpenAI密钥和Redis地址通过环境变量注入,保障安全;嵌入模型选择text-embedding-3-small,维度512平衡速度与精度;自定义语义缓存字段命名清晰,便于维护。

到这里,基础依赖就齐了。但光有这些还不够,语义缓存需要几个自定义参数:比如相似度阈值设多少?缓存里问题字段叫什么?答案存在哪个元数据字段?这些我们统一用@ConfigurationProperties来管理,写成一个record类叫SemanticCacheProperties,代码干净又类型安全。

相似度阈值我们设成0.8。为什么是0.8?因为太低了会误命中,比如“我能请假吗”和“我能加薪吗”可能被误判为相似;太高了又太苛刻,用户换个词就miss。0.8是个经验平衡点,你上线后可以根据业务调整。三个字段名也明确:question存原始问题,embedding存向量(Redis自动处理),answer存大模型返回的答案。


最后,我们要手动创建两个核心Bean:一个是JedisPooled连接池,用来和Redis通讯;另一个是RedisVectorStore,这是Spring AI提供的向量存储抽象,内部已经集成了嵌入模型和Redis客户端。通过Builder模式传入字段名和元数据配置,几行代码就搞定。整个配置类加上@EnableConfigurationProperties注解,Spring就能自动绑定YAML里的值。

记住,这一步看似简单,但却是整个语义缓存的基石。如果Redis连不上,或者嵌入模型没配对,后面全白搭。建议本地先用Docker跑一个Redis 7+实例,验证连通性再继续。


然后我们定义一个配置类,用于绑定自定义属性并创建核心Bean:

java
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisProperties;
import org.springframework.ai.embedding.EmbeddingModel;
import org.springframework.ai.vectorstore.RedisVectorStore;
import redis.clients.jedis.JedisPooled;

@Configuration
@EnableConfigurationProperties(SemanticCacheProperties.class)
public class LLMConfiguration {

    @Bean
    public JedisPooled jedisPooled(RedisProperties redisProperties) {
        return new JedisPooled(redisProperties.getUrl());
    }

    @Bean
    public RedisVectorStore vectorStore(
        JedisPooled jedisPooled,
        EmbeddingModel embeddingModel,
        SemanticCacheProperties props
    ) {
        return RedisVectorStore.builder(jedisPooled, embeddingModel)
            .contentFieldName(props.contentField())
            .embeddingFieldName(props.embeddingField())
            .metadataFields(RedisVectorStore.MetadataField.text(props.metadataField()))
            .build();
    }
}

对应的属性记录类如下:

java
import org.springframework.boot.context.properties.ConfigurationProperties;

@ConfigurationProperties(prefix = "com.jdon.semantic.cache")
public record SemanticCacheProperties(
    Double similarityThreshold,
    String contentField,
    String embeddingField,
    String metadataField
) {}

这段配置代码结构清晰、职责单一,完全遵循Spring Boot最佳实践。注意:RedisVectorStore目前仅支持Jedis作为客户端,若你使用Lettuce需自行扩展。



核心逻辑重构:语义缓存服务类完整实现

核心逻辑:如何存?如何查?两招搞定

配置搭好之后,就进入最核心的业务逻辑:缓存服务的实现。我们新建一个叫SemanticCachingService的Service类,里面就两个方法——save和search。一个负责写,一个负责读,干净利落。

java
import org.springframework.ai.document.Document;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.Optional;

@Service
public class SemanticCachingService {

    private final VectorStore vectorStore;
    private final SemanticCacheProperties properties;

    public SemanticCachingService(VectorStore vectorStore, SemanticCacheProperties properties) {
        this.vectorStore = vectorStore;
        this.properties = properties;
    }

    /
     * 将问题与答案存入语义缓存
     * @param question 用户原始问题
     * @param answer 大模型返回的答案
     */
    public void save(String question, String answer) {
        if (question == null || question.trim().isEmpty()) {
            throw new IllegalArgumentException("问题不能为空");
        }
        if (answer == null) {
            answer = ""; // 允许空答案但需标准化
        }

        Document doc = Document.builder()
            .text(question.trim())
            .metadata(properties.metadataField(), answer)
            .build();

        vectorStore.add(List.of(doc));
    }

    /
     * 根据新问题语义搜索缓存答案
     * @param question 用户新问题
     * @return 若命中缓存则返回答案,否则返回空
     */
    public Optional search(String question) {
        if (question == null || question.trim().isEmpty()) {
            return Optional.empty();
        }

        SearchRequest request = SearchRequest.builder()
            .query(question.trim())
            .similarityThreshold(properties.similarityThreshold())
            .topK(1)
            .build();

        List results = vectorStore.similaritySearch(request);

        if (results.isEmpty()) {
            return Optional.empty();
        }

        Document bestMatch = results.getFirst();
        Object metadataValue = bestMatch.getMetadata().get(properties.metadataField());
        return Optional.ofNullable(metadataValue).map(String::valueOf);
    }
}


先看save方法。它接收两个参数:用户问的原始问题(question)和大模型返回的答案(answer)。内部我们构造一个Document对象,把question作为主文本,answer放进metadata里。Document是Spring AI的标准数据结构,专门用于向量存储。调用vectorStore.add()时,它会自动用EmbeddingModel把question转成512维向量,并连同原始文本和元数据一起存进Redis。整个过程对开发者透明,你不用关心向量怎么算、怎么存。

关键在于:我们只缓存“问题+答案”,不缓存整个对话上下文。这是为了控制缓存粒度。如果你要做对话级缓存,就得引入会话ID和上下文窗口管理,复杂度飙升。而我们这里聚焦在单轮问答场景——这也是大多数企业AI客服、智能问答系统的主力场景。

再看search方法。这是整个语义缓存的“大脑”。用户发来一个新问题,我们先构建SearchRequest:query就是用户问题,similarityThreshold设成配置里的0.8,topK设成1(因为我们只需要最相似的那个)。调用vectorStore.similaritySearch()后,Spring AI会先对新问题做嵌入,然后在Redis里执行KNN(K近邻)搜索,返回满足阈值的结果列表。

如果列表为空,说明没找到语义相近的历史问题,那就返回Optional.empty(),主流程继续调用大模型。如果找到了,就取第一个结果,从它的metadata里拿出answer字段,包装成Optional返回。整个过程耗时通常在10毫秒以内,远低于调用外部LLM的300~800毫秒。

这里有个细节:RedisVectorStore的底层其实是用Redis的FT.SEARCH命令做的向量相似度检索,配合HNSW索引加速。你如果用过Redis Stack,应该很熟悉这套机制。Spring AI帮你屏蔽了所有底层命令,你只需要关心业务逻辑。

值得注意的是,这个search不是精确匹配,而是语义匹配。比如“病假天数”和“生病能休几天假”会被视为高度相似,即使字面完全不同。这就是嵌入模型的力量——它把语言映射到高维语义空间,近的就“意思一样”,远的就“毫不相干”。

这个服务类做了几点优化:  
1. 增加空值校验,避免无效缓存;  
2. 明确方法职责,save只负责存储,search只负责检索;  
3. 使用Optional避免空指针,符合函数式编程风格;  
4. 问题统一trim处理,提升缓存一致性。

注意:这里我们没有做并发控制——因为Redis向量写入本身是原子的,且语义缓存通常允许重复写入(内容相同则向量相同,Redis会覆盖或忽略)。若你担心高并发写冲突,可加分布式锁,但一般场景没必要。



集成测试:用JUnit验证语义缓存效果
语义缓存到底靠不靠谱?

光说不练假把式,我们得写测试用例来验证这套机制是否真的work。测试逻辑非常简单:先存一条“我能请几天病假? → 不准请假!回去干活!”,然后用“病假最多能休多少天?”去查。如果系统返回了缓存答案,说明语义理解成功。

再用一个完全无关的问题“我能涨工资吗?”去查,这时候应该返回空,说明系统不会乱匹配。这两个测试用例覆盖了“语义相似命中”和“语义无关miss”两种核心场景。

我们写一个完整的集成测试,验证语义缓存是否按预期工作。测试需启动Spring上下文并连接真实Redis(可用Testcontainers)。


import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.utility.DockerImageName;

import static org.assertj.core.api.Assertions.assertThat;

@SpringBootTest
@Testcontainers
public class SemanticCachingServiceIntegrationTest {

    @Container
    static GenericContainer<?> redis = new GenericContainer<>(DockerImageName.parse("redis/redis-stack:latest"))
        .withExposedPorts(6379);

    @DynamicPropertySource
    static void configureRedis(DynamicPropertyRegistry registry) {
        registry.add(
"spring.data.redis.url"
            () ->
"redis://localhost:" + redis.getMappedPort(6379));
    }

    @Autowired
    private SemanticCachingService semanticCachingService;

    @Test
    void shouldReturnCachedAnswerForRephrasedQuestion() {
       
// 准备原始问答
        String originalQuestion =
"How many sick leaves can I take?";
        String answer =
"No leaves allowed! Get back to work!!";
        semanticCachingService.save(originalQuestion, answer);

       
// 用语义相似问题查询
        String rephrased =
"How many days sick leave can I take?";
        Optional<String> result = semanticCachingService.search(rephrased);

        assertThat(result).isPresent().hasValue(answer);
    }

    @Test
    void shouldReturnEmptyForUnrelatedQuestion() {
        String unrelated =
"Can I get a raise?";
        Optional<String> result = semanticCachingService.search(unrelated);
        assertThat(result).isEmpty();
    }

    @Test
    void shouldHandleEmptyOrNullInputGracefully() {
        semanticCachingService.save(
"What is your name?", "I am AI.");
        assertThat(semanticCachingService.search(null)).isEmpty();
        assertThat(semanticCachingService.search(
"")).isEmpty();
        assertThat(semanticCachingService.search(
"   ")).isEmpty();
    }
}


这个测试覆盖了三大场景:语义命中、语义不命中、边界输入。确保系统在各种情况下行为可预测。注意:测试依赖Redis Stack镜像(包含向量搜索模块),普通Redis镜像不支持。


实际跑下来你会发现,rephrased question的相似度得分大概在0.85~0.92之间,稳稳超过0.8的阈值;而unrelated question的得分通常低于0.4,完全不会触发缓存。这说明text-embedding-3-small在这个任务上表现相当稳健。

你可能会问:如果用户问题特别长,比如带上下文的多轮对话,还能缓存吗?答案是:可以,但效果会打折扣。嵌入模型对长文本的语义捕捉不如短文本精准。所以最佳实践是:只缓存单句、意图明确的问题。复杂对话建议结合对话状态跟踪(DST)+摘要生成,再做缓存,但那是另一个话题了。

另外,缓存命中率怎么监控?建议你在search和save方法里加埋点,记录总请求数、命中数、平均相似度分值。上线后你可以画出命中率曲线,动态调整阈值。比如初期设0.8,发现误命中多,就调到0.85;发现命中率太低,就降到0.75。这需要结合业务容忍度来权衡。



生产级增强建议:从Demo走向高可用

上述代码虽能跑通,但要上生产,还需以下增强:

1. 缓存过期机制  
   RedisVectorStore暂未原生支持TTL,我们可扩展Document添加过期时间,并用Redis的EXPIRE命令处理。一种方式是自定义Redis脚本或监听add事件:

   

java
   // 伪代码:在add后设置过期
   String id = doc.getId(); // 假设Spring AI返回ID
   jedisPooled.expire("vs:" + id, 86400); // 24小时过期
   

2. 缓存命中监控  
   在search方法中埋点,上报命中率、平均相似度:

   

java
   MeterRegistry meterRegistry; // Micrometer
   Counter cacheHitCounter = Counter.builder("semantic.cache.hit").register(meterRegistry);
   

3. 多租户隔离  
   在metadata中加入tenantId,并在搜索时过滤(需Redis支持属性过滤,当前Spring AI暂不支持,可考虑改用Milvus)。

4. 嵌入模型降级  
   若OpenAI不可用,应有本地嵌入模型(如SentenceTransformer)兜底,避免缓存系统整体瘫痪。

5. 缓存预热脚本  
   启动时加载高频问题:

   

java
   @EventListener(ApplicationReadyEvent.class)
   public void preloadFaq() {
       faqRepository.findAll().forEach(faq -> 
           cachingService.save(faq.getQuestion(), faq.getAnswer()));
   }


虽然Demo看起来简单,但真要上生产,有几个坑你必须避开。

第一,缓存污染。如果大模型某次返回了错误答案,而你把它缓存了,那后面所有相似问题都会复用这个错误答案。解决办法有两个:一是对高风险领域(比如医疗、法律)关闭语义缓存;二是在缓存前加一层人工审核或置信度过滤。

第二,缓存膨胀。Redis的内存不是无限的。如果你不做TTL(过期时间)或LRU淘汰策略,缓存会无限增长。Spring AI的RedisVectorStore目前不直接支持自动过期,你需要额外写个定时任务,定期清理低频访问的缓存条目,或者用Redis的EXPIRE命令在add时设置过期时间。

第三,多租户隔离。如果你的系统服务多个客户,必须确保A公司的缓存不会被B公司的问题命中。解决方案是在Document的metadata里加tenantId字段,检索时加过滤条件。不过Spring AI当前版本的RedisVectorStore还不支持带过滤的相似度搜索,你可能需要自己扩展或换用Milvus、Pinecone等更高级的向量数据库。

第四,嵌入模型一致性。千万别在开发、测试、生产环境用不同版本的嵌入模型!否则向量空间不一致,缓存直接失效。建议把模型版本写死在配置里,并做CI/CD校验。

最后,语义缓存不是万能药。它最适合高频、低变异性的问题场景,比如HR政策查询、产品FAQ、技术支持。如果是创意写作、代码生成这类高开放性任务,缓存基本没用,因为每次输出都该不一样。所以要用在刀刃上。

   



未来演进:语义缓存还能怎么玩?

语义缓存只是起点。如果你把这套机制嵌入到RAG(检索增强生成)系统里,效果会更惊人。比如用户问“公司差旅报销标准?”,系统先在语义缓存里查,没命中就去知识库向量库检索相关文档,生成答案后再存进缓存。下次再问类似问题,直接命中,全程不碰大模型。

更进一步,你可以做“缓存预热”。比如每天凌晨把Top 100高频问题批量生成答案并缓存,白天高峰时段直接命中,系统扛压能力翻倍。或者结合用户画像,对VIP用户的问题优先缓存,提升他们的体验。

还有一个前沿方向是:用缓存命中日志反哺微调数据。哪些问题经常被重问但没缓存?说明知识库缺失,可以自动触发内容补充流程。这已经不是单纯的技术优化,而是形成了AI系统的自进化闭环。

总之,语义缓存是AI工程化里性价比极高的优化手段。它不炫技,但能实打实省钱、提速、提体验。尤其在大模型调用成本居高不下的今天,每省一次API调用,都是净利润。



结语:让AI更聪明,而不是更贵

语义缓存不是魔法,但它是一种极其务实的工程优化手段。通过不到200行核心代码,我们构建了一个能理解用户意图、自动复用历史答案的智能层。它不改变你的AI模型,却能显著降低你的账单。

在大模型调用成本日益成为企业负担的今天,每一个被拦截的冗余请求,都是利润的直接提升。Spring AI让这一切变得前所未有的简单——你不需要精通向量数据库原理,也不需要手写嵌入调用,只需专注业务逻辑。

别再让大模型做重复劳动了。用语义缓存,把算力留给真正需要创造的地方。你的账单会感谢你,你的用户也会感谢你。