Spring AI 语义缓存:重复提问不重复花钱

你的客服系统里,用户每天都在问"怎么退款""退款流程是什么""钱能退吗"------其实是同一个问题,但系统每次都老老实实调一次大模型,token 白烧、钱白花、回复还慢半拍。这篇文章用 Spring AI 2.0 的语义缓存(Semantic Cache)解决它:把"问题 + 答案"存进 Redis 向量库,新问题先用向量相似度匹配历史问题,命中了直接返回缓存答案,没命中才真调大模型。代码完整可运行,读完就能复现。

一、这个问题到底是什么

先讲清楚语义缓存到底解决什么痛点。

你肯定用过传统缓存,比如 Redis 里存 key=用户ID,value=用户资料。它的规则很死:key 必须一模一样才能命中。"退款"和"申请退款"是两条不同的 key,缓存永远认不出来这俩是同一件事。

但用户不会照着标准 key 说话。同一个"怎么退款",用户会问成"如何退款""退款流程是啥""钱能退吗""我要退货退钱",甚至带几个错别字。传统缓存面对这些变体,全部 miss,全部重新调大模型。

语义缓存(Semantic Cache)就是解决"换种说法就 miss"的问题。 它不看字面是否一样,而是看"含义是否接近"。

用一个类比把关系说清楚:

  • 传统缓存:像图书馆按书名找书,书名一个字不对就找不到。
  • 语义缓存:像图书馆按"内容主题"找书,你说"想看怎么退钱的书",它能找到《退款操作指南》,哪怕书名不完全一样。

所以语义缓存的核心价值是:把"重复问题"从"必须重新花一遍大模型的算力和钱",变成"直接返回上次算好的答案"。 带来三个直接好处:

  1. 省钱:命中缓存的请求根本不调大模型,token 费用为 0。
  2. 提速:省掉大模型那几秒到十几秒的生成时间,命中后毫秒级返回。
  3. 更稳:少调大模型,就少一次被限流、超时、报错的风险。

一句话总结:语义缓存让你只给"真正的新问题"付钱,重复问题直接白嫖上次的答案。

二、底层原理到底怎么回事

要搞懂语义缓存,得先理解两件事: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 时)

  1. 用户问"怎么退款",缓存里没有。
  2. 调用大模型生成答案,拿到完整的 ChatResponse。
  3. 把问题"怎么退款"转成向量,存进 Redis 向量索引;同时把完整答案序列化成 JSON,作为这个文档的一个元数据字段(response)存起来。

第二步:读取(第二次问,换种说法)

  1. 用户问"如何申请退款"。
  2. 把这句话转成向量。
  3. 在 Redis 里做向量最近邻搜索(VECTOR_RANGE 查询),找出相似度最高的历史文档。
  4. 拿回那个文档,看它的相似度是否 ≥ 0.8。
  5. 达标 → 直接反序列化出上次存的答案,返回给用户,不调大模型;不达标 → 走正常流程调大模型,然后把这次的结果也写进缓存。

这里有个容易被忽略但很重要的细节------上下文隔离(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

这个类是重点。它做了四件事:

  1. redisClient:创建 Jedis 7 的 RedisClient,连你的 Redis Stack。
  2. semanticCache:用 DefaultSemanticCache.builder() 把 RedisClient 和 EmbeddingModel 组装成语义缓存,并设相似度阈值为 0.8。
  3. semanticCacheAdvisor:把语义缓存包成 ChatClient 的 advisor(拦截器)。
  4. 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 都不花。

落地要点回顾:

  1. 依赖用 spring-ai-redis-semantic-cache,版本对齐 Spring AI 2.0.1(当前最新 GA)。
  2. 用 DefaultSemanticCache.builder().jedisClient(...).embeddingModel(...) 建缓存,让它自动声明 response 等元数据字段------这是避免"缓存永远 miss"的关键。
  3. 后端必须是 Redis Stack(带 RediSearch 模块)。
  4. 阈值默认 0.8,按业务调;多角色靠 contextHash 隔离;时效性答案设 TTL。

语义缓存的本质是用"一点点向量检索的算力",换掉"大模型重复生成的大笔费用"。对于重复问题占比高的场景,这是性价比极高的优化,而且接入成本低------本文的代码改改配置就能上。

相关推荐
物联网老王1 小时前
第05篇-Agent 连不上工具?MCP 就是那根“USB-C”
开发语言·人工智能·ai·aigc·ai编程
镭封1 小时前
手机文字转语音怎么操作?新手从输入文案到导出音频
人工智能·智能手机·音视频·语音识别·媒体
samforce1 小时前
我拆了 399 个 AI 生成的网页:75% 拿 emoji 当图标,89% 用了大圆角,56% 是同一套四件套
人工智能
我喜欢就喜欢1 小时前
百度 AI 搜索实战应用场景与落地指南
人工智能·百度
海绵宝宝转agent1 小时前
leetcodehot100个人题解
java·笔记·学习·算法
sunburn-1 小时前
数据库增删改查:从 SQL 基础到实践要点
java·数据库·mysql
像污秽一样1 小时前
动手学kaggle-Intro to Machine Learning-1
人工智能·机器学习
东坡肘子1 小时前
当应用不再属于特定操作系统 -- 肘子的 Swift 周报 #159
人工智能·swiftui·swift
郭小李1 小时前
spring @RequestMapping注解
java·后端·spring