Java 后端接入大模型:Token 缓存优化实战,接口成本直降 40%
前言
大模型火了之后,很多后端开发者开始在项目里接入 AI 能力。但上线后发现一个问题:Token 费用太高了。
一个简单的客服机器人,每天 10 万次请求,每次消耗 500 Token,按 GPT-4o 的价格算,一个月下来费用惊人。
Token 缓存是目前最有效的降本手段之一。本文用 Java 实现一套完整的 Token 缓存方案,实测成本降低 40%+。
什么是 Token 缓存
大模型的每次请求,Prompt 部分都会被重新计算。但很多场景下,Prompt 的前半段是固定的(系统提示词、知识库前缀等),只有最后的用户问题在变。
Token 缓存的核心思路:把不变的部分缓存起来,下次请求时复用,避免重复计算。
| 概念 | 说明 | 类比 |
|---|---|---|
| Prompt | 发给模型的完整输入 | HTTP 请求体 |
| System Prompt | 固定的系统指令 | 请求头 |
| Token Cache | 缓存已计算的 Token 结果 | CDN 缓存 |
| Cache Hit | 命中缓存,无需重新计算 | 缓存命中 |
💡 不同厂商叫法不同:OpenAI 叫 Prompt Caching,Anthropic 叫 Cache Breakpoints,Ollama 本地部署天然支持上下文复用。
环境准备
-
JDK 17+
-
Spring Boot 3.x
-
Ollama(本地部署,零成本测试)
-
Maven
<!-- pom.xml 核心依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>okhttp</artifactId>
<version>4.12.0</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
基础接入:不带缓存
先写一个最基础的调用,理解没有缓存时的问题:
@Service
public class LlmService {
private final OkHttpClient client = new OkHttpClient();
private static final String OLLAMA_URL = "http://localhost:11434/api/generate";
/**
* 基础调用,无缓存
* 每次请求都会完整计算所有 Token
*/
public String chat(String systemPrompt, String userMessage) throws IOException {
Map<String, Object> body = new HashMap<>();
body.put("model", "qwen2.5:7b");
body.put("system", systemPrompt); // 每次重复发送
body.put("prompt", userMessage);
body.put("stream", false);
Request request = new Request.Builder()
.url(OLLAMA_URL)
.post(RequestBody.create(
new ObjectMapper().writeValueAsString(body),
MediaType.parse("application/json")))
.build();
try (Response response = client.newCall(request).execute()) {
Map result = new ObjectMapper().readValue(response.body().string(), Map.class);
return (String) result.get("response");
}
}
}
❌ 问题:system prompt 有 2000 Token,每次请求都重新计算。10 万次请求 = 多算了 2 亿 Token。
方案一:Ollama 上下文复用
Ollama 支持返回 context(上下文向量),下次请求时带上,模型会跳过已计算的部分:
@Service
public class CachedLlmService {
private final OkHttpClient client = new OkHttpClient();
// 缓存上下文:key=systemPrompt的hash, value=context向量
private final ConcurrentHashMap<String, List<Integer>> contextCache
= new ConcurrentHashMap<>();
/**
* 带上下文缓存的调用
* 首次请求:完整计算,缓存 context
* 后续请求:复用 context,只计算新增部分
*/
public CachedResponse chatWithCache(String systemPrompt, String userMessage) throws IOException {
String cacheKey = DigestUtils.md5Hex(systemPrompt);
Map<String, Object> body = new HashMap<>();
body.put("model", "qwen2.5:7b");
body.put("system", systemPrompt);
body.put("prompt", userMessage);
body.put("stream", false);
// 命中缓存 → 带上 context
List<Integer> cachedContext = contextCache.get(cacheKey);
if (cachedContext != null) {
body.put("context", cachedContext);
}
Request request = new Request.Builder()
.url("http://localhost:11434/api/generate")
.post(RequestBody.create(
new ObjectMapper().writeValueAsString(body),
MediaType.parse("application/json")))
.build();
try (Response response = client.newCall(request).execute()) {
Map result = new ObjectMapper().readValue(response.body().string(), Map.class);
// 更新缓存
List<Integer> newContext = (List<Integer>) result.get("context");
if (newContext != null) {
contextCache.put(cacheKey, newContext);
}
return new CachedResponse(
(String) result.get("response"),
cachedContext != null, // 是否命中缓存
((Number) result.get("eval_count")).intValue() // 实际计算的 Token 数
);
}
}
}
// 返回对象
record CachedResponse(String response, boolean cacheHit, int tokensUsed) {}
// 使用示例
@Service
public class CustomerServiceBot {
@Autowired
private CachedLlmService llmService;
private static final String SYSTEM_PROMPT = """
你是一个专业的客服助手。
产品:XX云服务器
规则:
1. 回答要简洁
2. 不确定的说"请联系人工客服"
3. 不要透露内部信息
"""; // 固定不变,适合缓存
public String answer(String userQuestion) throws IOException {
CachedResponse resp = llmService.chatWithCache(SYSTEM_PROMPT, userQuestion);
System.out.println("缓存命中: " + resp.cacheHit() + ", 消耗Token: " + resp.tokensUsed());
return resp.response();
}
}
方案二:语义缓存(进阶)
如果用户问的问题也经常重复(比如 FAQ),可以用语义缓存:相似问题直接返回缓存的答案,不调用模型。
@Service
public class SemanticCacheService {
// 已缓存的问答对
private final List<CachedQA> cache = new CopyOnWriteArrayList<>();
/**
* 语义相似度计算(简化版,生产环境建议用向量数据库)
* 基于关键词重叠度,适合 FAQ 场景
*/
public double similarity(String text1, String text2) {
Set<String> words1 = Set.of(text1.toLowerCase().split("[\\s,,。!?]+"));
Set<String> words2 = Set.of(text2.toLowerCase().split("[\\s,,。!?]+"));
long common = words1.stream().filter(words2::contains).count();
return (double) common / Math.min(words1.size(), words2.size());
}
/**
* 先查缓存,命中直接返回,未命中调用模型
*/
public String chatWithSemanticCache(String systemPrompt, String userMessage,
LlmService llmService) throws IOException {
// 1. 查语义缓存
for (CachedQA qa : cache) {
if (similarity(qa.question(), userMessage) > 0.75) {
return qa.answer() + "\n\n> 💡 [来自缓存,相似度: "
+ String.format("%.0f%%", similarity(qa.question(), userMessage) * 100) + "]";
}
}
// 2. 未命中,调用模型
String answer = llmService.chat(systemPrompt, userMessage);
// 3. 存入缓存
cache.add(new CachedQA(userMessage, answer));
return answer;
}
record CachedQA(String question, String answer) {}
}
两种方案对比
| 方案 | 适用场景 | 节省幅度 | 实现难度 |
|---|---|---|---|
| 上下文复用 | System Prompt 固定的场景 | 30-50% | ⭐⭐ |
| 语义缓存 | FAQ、客服、高频重复问题 | 50-80% | ⭐⭐⭐ |
| 两者叠加 | 生产环境推荐 | 40-70% | ⭐⭐⭐ |
性能实测
用 Ollama + qwen2.5:7b 本地测试,System Prompt 2000 Token:
| 指标 | 无缓存 | 上下文缓存 | 提升 |
|---|---|---|---|
| 首次响应 | 2.3s | 2.3s | - |
| 第2次响应 | 2.1s | 0.8s | 62% |
| 第10次响应 | 2.0s | 0.6s | 70% |
| Token 消耗/次 | 2500 | 500 | 80% |
💡 本地部署用 Ollama 完全免费。如果用 OpenAI/Anthropic 的 API,Token 缓存通常有 50% 的折扣。
生产环境注意事项
1. 缓存淘汰策略
// LRU 缓存,最多存 1000 个上下文
private final LinkedHashMap<String, List<Integer>> contextCache =
new LinkedHashMap<>(1000, 0.75f, true) {
@Override
protected boolean removeEldestEntry(Map.Entry eldest) {
return size() > 1000;
}
};
2. 缓存失效
-
System Prompt 变更 → 清空对应缓存
-
模型更新 → 清空所有缓存
-
建议给缓存加 TTL(如 24 小时)
3. 监控指标
// 缓存命中率监控
@Component
public class CacheMetrics {
private final AtomicLong hits = new AtomicLong(0);
private final AtomicLong misses = new AtomicLong(0);
public double hitRate() {
long total = hits.get() + misses.get();
return total == 0 ? 0 : (double) hits.get() / total;
}
}
常见问题
Q: 缓存会不会导致回答不准确?
A: 上下文缓存不会,它只是跳过重复计算,结果完全一致。语义缓存需要注意相似度阈值,建议设 0.75 以上。
Q: 多个用户共享缓存安全吗?
A: System Prompt 级别的缓存可以共享(所有人用同一套系统指令)。用户级别的上下文需要隔离,用用户 ID 作为 cache key。
Q: 支持流式输出吗?
A: 支持。Ollama 的
stream: true同样可以带 context,只是返回格式改为逐行 JSON。
总结
-
Token 缓存是大模型应用降本的第一步,投入小收益大
-
上下文复用适合 System Prompt 固定的场景,实现简单
-
语义缓存适合 FAQ 类场景,可以省掉大部分请求
-
两者叠加使用效果最佳,实测成本降低 40%+
-
本地部署用 Ollama 零成本,线上 API 通常有缓存折扣
参考
更多推荐
所有评论(0)