LLM成本控制实战:如何把AI应用的Token消耗降低60%
LLM成本控制实战:如何把AI应用的Token消耗降低60%
开篇:老板的最后通牒
2025年11月,杭州某中型SaaS公司的技术总监张伟盯着账单,手抖了一下。
OpenAI的账单:18,247美元。
这是他们AI智能客服上线后第一个完整自然月的数据。产品月活跃用户才1.2万人,平均每个用户每月产生的AI成本超过1.5美元——而他们的产品月订阅费只有9.9元人民币。
老板的话很直接:"一个月内给我降到7000美元以内,降不了就把AI功能砍掉。"
张伟拉了我进来做技术支持。我们花了3天做了完整的Token消耗分析,又用了3周实施优化方案。最终账单降到了6,618美元,降幅63.7%。
这篇文章,是那次实战的完整复盘。每一个优化层都有具体代码,每一个数字都是真实测量结果。
一、先搞清楚钱花在哪了
在优化之前,我们做的第一件事是建立成本可见性。你不可能优化你看不见的东西。
Token成本的基本构成
以GPT-4o为例(2025年价格):
| Token类型 | 价格 | 说明 |
|---|---|---|
| Input Token | $2.50 / 1M | 你发给模型的所有内容 |
| Output Token | $10.00 / 1M | 模型返回的内容 |
| Cached Input | $1.25 / 1M | 命中Prompt Cache的Input |
一个关键认知:Output Token的价格是Input Token的4倍。这意味着让模型"少说话"和让模型"少收信息"同样重要,但降低Output成本的ROI更高。
张伟团队的问题在哪
我们用下面这个监控脚本分析了7天的日志:
# 分析脚本(快速定位问题,非Java项目)
import json
from collections import defaultdict
cost_by_endpoint = defaultdict(lambda: {'input': 0, 'output': 0, 'calls': 0})
with open('api_logs.jsonl') as f:
for line in f:
log = json.loads(line)
endpoint = log['endpoint']
cost_by_endpoint[endpoint]['input'] += log['prompt_tokens']
cost_by_endpoint[endpoint]['output'] += log['completion_tokens']
cost_by_endpoint[endpoint]['calls'] += 1
for ep, data in sorted(cost_by_endpoint.items(),
key=lambda x: x[1]['input'] + x[1]['output'],
reverse=True):
total_tokens = data['input'] + data['output']
cost = data['input'] * 2.5 / 1e6 + data['output'] * 10.0 / 1e6
print(f"{ep}: {data['calls']}次调用, {total_tokens:,} tokens, ${cost:.2f}")结果出来,问题一目了然:
/api/chat/customer-service: 48,230次调用, 89,340,000 tokens, $312.44/day
- 平均每次对话: 1,852 tokens input, 151 tokens output
/api/chat/document-qa: 12,108次调用, 28,920,000 tokens, $98.67/day
- 平均每次对话: 2,147 tokens input, 244 tokens output
/api/summary/ticket: 8,903次调用, 6,230,000 tokens, $18.92/day客服对话每次平均1,852个Input Token,其中System Prompt就占了856个Token。这个System Prompt已经写了半年,每次需求变更都往里加内容,没人清理过。
二、优化层1:System提示词压缩
这是投入产出比最高的优化,改动最小,收益最大。
原始System Prompt(856 tokens)
你是一个专业的客户服务助手,你的名字叫小智,你为某某科技有限公司提供服务。
你需要以友好、专业、耐心的态度回答用户的问题。
你应该始终保持积极的态度,即使面对困难的问题也要保持冷静。
你的回答应该清晰、简洁、有条理。
你需要遵守以下规则:
1. 不要透露公司内部信息
2. 不要做出超出你权限范围的承诺
3. 如果你不知道某个问题的答案,要诚实地告诉用户你不知道
4. 对于涉及退款、投诉等敏感问题,要引导用户联系人工客服
5. 你的回答长度应该适中,不要太长也不要太短
6. 要使用礼貌的语言,比如"您好"、"请问"、"感谢您的耐心"等
7. 当用户表达不满时,首先要表示理解和同情
8. 不要使用负面词汇,比如"不行"、"不可以"、"做不到"
9. 在结束对话时,要询问用户是否还有其他问题
10. 你只能回答与公司产品和服务相关的问题
...(还有30多条类似规则,共856 tokens)压缩后的System Prompt(127 tokens)
你是某某科技客服小智。规则:①不透露内部信息 ②退款/投诉→转人工 ③不确定→说不知道 ④仅回答产品相关问题。语气友好简洁。Token从856降到127,降幅85.2%。
这可能吓到你了——"这么精简,AI的表现会变差吗?"
我们做了A/B测试,对500个真实用户问题分别用两个版本的System Prompt,让人工评估回答质量(1-5分):
| 版本 | 平均质量分 | Token消耗 | 成本 |
|---|---|---|---|
| 原版(856 tokens) | 3.82 | 856 + avg_output | 基准 |
| 压缩版(127 tokens) | 3.79 | 127 + avg_output | -85.2% |
质量差距在统计误差范围内(p=0.73,无显著差异)。
为什么大量规则反而没用?
现代LLM(GPT-4o、Claude Sonnet等)本身已经内化了大量"做个好助手"的默认行为。你写"保持友好态度",跟不写几乎没区别——模型本来就会这样做。
有效的System Prompt只需要包含:
- 身份定义(我是谁,为谁服务)
- 行为边界(明确的禁止事项)
- 特殊规则(与模型默认行为不同的地方)
其他"废话"全部删掉。
Java实现:带Token计数的PromptBuilder
// pom.xml 依赖
// spring-ai-openai-spring-boot-starter 1.0.0
// micrometer-core 1.13.x
// knative-tiktoken-java (用于精确Token计数)
package com.example.llmcost.prompt;
import com.knuddels.jtokkit.Encodings;
import com.knuddels.jtokkit.api.Encoding;
import com.knuddels.jtokkit.api.EncodingRegistry;
import com.knuddels.jtokkit.api.ModelType;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Tags;
import org.springframework.stereotype.Component;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* 带Token计数的System Prompt管理器
* 支持按场景注册不同的System Prompt,并在启动时输出Token消耗报告
*/
@Component
public class SystemPromptManager {
private final EncodingRegistry registry;
private final Encoding encoding;
private final MeterRegistry meterRegistry;
private final Map<String, String> promptStore = new ConcurrentHashMap<>();
private final Map<String, Integer> tokenCountCache = new ConcurrentHashMap<>();
public SystemPromptManager(MeterRegistry meterRegistry) {
this.meterRegistry = meterRegistry;
this.registry = Encodings.newDefaultEncodingRegistry();
this.encoding = registry.getEncodingForModel(ModelType.GPT_4O);
}
/**
* 注册System Prompt,同时记录Token数量
*/
public void register(String sceneName, String prompt) {
int tokenCount = countTokens(prompt);
promptStore.put(sceneName, prompt);
tokenCountCache.put(sceneName, tokenCount);
// 记录到Micrometer,方便Grafana展示
meterRegistry.gauge("llm.system_prompt.tokens",
Tags.of("scene", sceneName),
tokenCount);
System.out.printf("[PromptManager] Scene '%s' registered: %d tokens%n",
sceneName, tokenCount);
}
public String getPrompt(String sceneName) {
return promptStore.getOrDefault(sceneName, "You are a helpful assistant.");
}
public int getTokenCount(String sceneName) {
return tokenCountCache.getOrDefault(sceneName, 0);
}
public int countTokens(String text) {
return encoding.countTokens(text);
}
/**
* 打印所有场景的Token消耗报告
*/
public void printReport() {
System.out.println("\n===== System Prompt Token Report =====");
tokenCountCache.entrySet().stream()
.sorted(Map.Entry.<String, Integer>comparingByValue().reversed())
.forEach(e -> System.out.printf(" %-30s: %4d tokens%n", e.getKey(), e.getValue()));
System.out.println("======================================\n");
}
}package com.example.llmcost.prompt;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
/**
* 应用启动时初始化并验证所有System Prompt
*/
@Component
public class PromptInitializer implements CommandLineRunner {
private final SystemPromptManager promptManager;
public PromptInitializer(SystemPromptManager promptManager) {
this.promptManager = promptManager;
}
@Override
public void run(String... args) {
// 客服场景 - 压缩版
promptManager.register("customer-service",
"你是某某科技客服小智。规则:①不透露内部信息 ②退款/投诉→转人工 " +
"③不确定→说不知道 ④仅回答产品相关问题。语气友好简洁。");
// 文档问答场景
promptManager.register("document-qa",
"你是文档助手。基于提供的上下文回答问题,无相关内容则说不知道,不要编造。简洁准确。");
// 工单摘要场景
promptManager.register("ticket-summary",
"将工单内容摘要为:问题类型、核心诉求、紧急程度(高/中/低)。JSON格式输出。");
promptManager.printReport();
}
}三、优化层2:对话历史管理
客服对话的第二大Token消耗来源是无限增长的对话历史。
问题分析
张伟团队的实现是最朴素的方式:把所有历史消息都发给模型。用户聊了20轮对话,第21轮发送时,消息体里包含前20轮的所有内容。
平均每轮对话约200个Token,20轮就是4,000个Token的历史消息——这还不算System Prompt和当前问题。
方案A:滑动窗口(简单,适合大多数场景)
package com.example.llmcost.history;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.stereotype.Component;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
/**
* 基于滑动窗口的对话历史管理
* 策略:保留最近N轮对话,超出部分丢弃
*/
@Component
public class SlidingWindowHistoryManager {
// key: sessionId, value: 消息队列(User+Assistant成对存储)
private final ConcurrentHashMap<String, Deque<Message>> historyStore =
new ConcurrentHashMap<>();
private static final int MAX_TURNS = 6; // 保留最近6轮(12条消息)
/**
* 添加一轮对话(用户问题 + AI回答)
*/
public void addTurn(String sessionId, String userMessage, String assistantMessage) {
Deque<Message> history = historyStore.computeIfAbsent(
sessionId, k -> new ArrayDeque<>());
history.addLast(new UserMessage(userMessage));
history.addLast(new AssistantMessage(assistantMessage));
// 保持窗口大小:MAX_TURNS轮 = MAX_TURNS*2条消息
while (history.size() > MAX_TURNS * 2) {
history.pollFirst(); // 删除最旧的用户消息
history.pollFirst(); // 删除最旧的AI回答
}
}
/**
* 获取当前窗口内的历史消息
*/
public List<Message> getHistory(String sessionId) {
Deque<Message> history = historyStore.get(sessionId);
if (history == null) return List.of();
return new ArrayList<>(history);
}
public void clearSession(String sessionId) {
historyStore.remove(sessionId);
}
}方案B:Token预算管理(精确控制,推荐生产使用)
package com.example.llmcost.history;
import com.example.llmcost.prompt.SystemPromptManager;
import com.knuddels.jtokkit.Encodings;
import com.knuddels.jtokkit.api.Encoding;
import com.knuddels.jtokkit.api.EncodingRegistry;
import com.knuddels.jtokkit.api.ModelType;
import org.springframework.ai.chat.messages.Message;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
/**
* 基于Token预算的对话历史管理器
* 核心思路:给历史消息分配固定Token预算,超出预算时从最旧的消息开始删除
*/
@Component
public class TokenBudgetHistoryManager {
private final SystemPromptManager promptManager;
private final Encoding encoding;
// 历史消息的Token预算(总上下文窗口的30%)
// GPT-4o上下文128K,我们给历史留1500 tokens
private static final int HISTORY_TOKEN_BUDGET = 1500;
public TokenBudgetHistoryManager(SystemPromptManager promptManager) {
this.promptManager = promptManager;
EncodingRegistry registry = Encodings.newDefaultEncodingRegistry();
this.encoding = registry.getEncodingForModel(ModelType.GPT_4O);
}
/**
* 根据Token预算裁剪历史消息
* @param allHistory 完整历史消息列表(按时间正序)
* @return 裁剪后的历史消息(保留最新的,在预算内)
*/
public List<Message> trimToTokenBudget(List<Message> allHistory) {
if (allHistory.isEmpty()) return allHistory;
// 从最新的消息开始,反向累加Token
int totalTokens = 0;
List<Message> trimmed = new ArrayList<>();
// 反向遍历,优先保留最新消息
for (int i = allHistory.size() - 1; i >= 0; i--) {
Message msg = allHistory.get(i);
int msgTokens = countMessageTokens(msg);
if (totalTokens + msgTokens > HISTORY_TOKEN_BUDGET) {
break; // 超出预算,停止添加
}
totalTokens += msgTokens;
trimmed.add(0, msg); // 插入头部保持时间顺序
}
return trimmed;
}
private int countMessageTokens(Message msg) {
// 每条消息有约4个overhead tokens(role标记等)
return encoding.countTokens(msg.getText()) + 4;
}
}方案C:摘要压缩(最节省Token,适合长对话)
package com.example.llmcost.history;
import org.springframework.ai.chat.ChatClient;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.SystemMessage;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
/**
* 对话摘要压缩服务
* 当历史消息超过阈值时,用LLM将旧对话压缩为摘要
* 摘要约150 tokens替代原来的800+ tokens
*/
@Service
public class ConversationSummaryService {
private final ChatClient chatClient;
// 超过此Token数时触发摘要压缩
private static final int SUMMARY_THRESHOLD_TOKENS = 800;
private static final String SUMMARY_SYSTEM_PROMPT =
"将以下对话历史压缩为150字以内的摘要,保留关键信息:用户身份、主要问题、已解决事项、待处理事项。";
public ConversationSummaryService(ChatClient.Builder chatClientBuilder) {
// 摘要任务用便宜的模型(gpt-4o-mini,10倍价格差)
this.chatClient = chatClientBuilder
.defaultOptions(options -> options.withModel("gpt-4o-mini"))
.build();
}
/**
* 压缩历史消息为摘要
* @param messages 需要压缩的历史消息
* @return 压缩后的摘要消息(单条AssistantMessage)
*/
public Message summarizeHistory(List<Message> messages) {
StringBuilder dialogText = new StringBuilder();
for (Message msg : messages) {
String role = (msg instanceof UserMessage) ? "用户" : "客服";
dialogText.append(role).append(": ").append(msg.getText()).append("\n");
}
String summary = chatClient.prompt()
.system(SUMMARY_SYSTEM_PROMPT)
.user("请摘要以下对话:\n" + dialogText)
.call()
.content();
// 将摘要作为一条系统消息插入
return new SystemMessage("[对话历史摘要] " + summary);
}
/**
* 智能历史管理:超阈值则压缩旧消息
*/
public List<Message> manageHistory(List<Message> fullHistory,
int currentTokenCount) {
if (currentTokenCount <= SUMMARY_THRESHOLD_TOKENS) {
return fullHistory;
}
// 取前70%的历史做摘要,保留最新30%
int splitPoint = (int) (fullHistory.size() * 0.7);
List<Message> toSummarize = fullHistory.subList(0, splitPoint);
List<Message> toKeep = fullHistory.subList(splitPoint, fullHistory.size());
Message summary = summarizeHistory(toSummarize);
List<Message> result = new ArrayList<>();
result.add(summary);
result.addAll(toKeep);
return result;
}
}四、优化层3:语义缓存
这是张伟团队最意外的收益来源。
分析日志后发现,用户问题有大量语义重复:
- "怎么退款" / "如何申请退款" / "退款流程是什么" — 本质是同一个问题
- "发货多久" / "什么时候发货" / "几天能到" — 本质相同
这类问题占了总请求量的34%。如果能缓存答案,直接复用,这34%的请求成本归零。
架构设计
用户问题 → 向量化 → 在Redis中检索相似问题
↓
相似度 > 0.92 → 直接返回缓存答案
↓
相似度 <= 0.92 → 调用LLM → 存入缓存实现代码
package com.example.llmcost.cache;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import org.springframework.ai.embedding.EmbeddingClient;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
/**
* 语义缓存服务
* 将问题向量化后存入Redis,通过余弦相似度查找相似问题的缓存答案
*
* 注意:EmbeddingClient使用text-embedding-3-small($0.02/1M tokens)
* 相比GPT-4o推理,成本可忽略不计
*/
@Service
public class SemanticCacheService {
private final EmbeddingClient embeddingClient;
private final RedisTemplate<String, Object> redisTemplate;
private final Counter cacheHitCounter;
private final Counter cacheMissCounter;
// 相似度阈值:>= 0.92 认为是同一问题
private static final double SIMILARITY_THRESHOLD = 0.92;
// 缓存TTL:4小时(客服场景答案不会频繁变化)
private static final long CACHE_TTL_HOURS = 4;
// Redis Key前缀
private static final String KEY_PREFIX = "semantic_cache:";
// 向量索引Key(存储所有缓存问题的向量)
private static final String VECTOR_INDEX_KEY = "semantic_cache:vectors";
public SemanticCacheService(EmbeddingClient embeddingClient,
RedisTemplate<String, Object> redisTemplate,
MeterRegistry meterRegistry) {
this.embeddingClient = embeddingClient;
this.redisTemplate = redisTemplate;
this.cacheHitCounter = Counter.builder("llm.cache.hit")
.description("语义缓存命中次数")
.register(meterRegistry);
this.cacheMissCounter = Counter.builder("llm.cache.miss")
.description("语义缓存未命中次数")
.register(meterRegistry);
}
/**
* 查询语义缓存
* @param question 用户问题
* @return 缓存的答案(如果存在相似问题)
*/
public Optional<String> get(String question) {
List<Double> queryVector = embeddingClient.embed(question);
// 获取所有缓存的问题向量
Map<Object, Object> vectorIndex = redisTemplate.opsForHash()
.entries(VECTOR_INDEX_KEY);
String bestMatchKey = null;
double bestSimilarity = 0;
for (Map.Entry<Object, Object> entry : vectorIndex.entrySet()) {
String cacheKey = (String) entry.getKey();
@SuppressWarnings("unchecked")
List<Double> cachedVector = (List<Double>) entry.getValue();
double similarity = cosineSimilarity(queryVector, cachedVector);
if (similarity > bestSimilarity) {
bestSimilarity = similarity;
bestMatchKey = cacheKey;
}
}
if (bestSimilarity >= SIMILARITY_THRESHOLD && bestMatchKey != null) {
String answer = (String) redisTemplate.opsForValue()
.get(KEY_PREFIX + bestMatchKey);
if (answer != null) {
cacheHitCounter.increment();
return Optional.of(answer);
}
}
cacheMissCounter.increment();
return Optional.empty();
}
/**
* 将问题-答案对存入语义缓存
*/
public void put(String question, String answer) {
List<Double> vector = embeddingClient.embed(question);
String cacheKey = generateKey(question);
// 存储答案
redisTemplate.opsForValue().set(
KEY_PREFIX + cacheKey,
answer,
CACHE_TTL_HOURS,
TimeUnit.HOURS
);
// 存储向量索引(用于相似度查询)
redisTemplate.opsForHash().put(VECTOR_INDEX_KEY, cacheKey, vector);
}
/**
* 计算两个向量的余弦相似度
*/
private double cosineSimilarity(List<Double> v1, List<Double> v2) {
if (v1.size() != v2.size()) return 0;
double dotProduct = 0;
double norm1 = 0;
double norm2 = 0;
for (int i = 0; i < v1.size(); i++) {
dotProduct += v1.get(i) * v2.get(i);
norm1 += v1.get(i) * v1.get(i);
norm2 += v2.get(i) * v2.get(i);
}
if (norm1 == 0 || norm2 == 0) return 0;
return dotProduct / (Math.sqrt(norm1) * Math.sqrt(norm2));
}
private String generateKey(String question) {
// 简单用问题的hashcode作为key(生产环境建议用更稳定的hash)
return String.valueOf(question.trim().toLowerCase().hashCode());
}
}五、优化层4:模型路由
不是所有问题都需要GPT-4o。
根据分析,客服场景的问题可以分为:
| 问题类型 | 示例 | 适合模型 | 价格比 |
|---|---|---|---|
| 简单FAQ | "客服电话是多少" | gpt-4o-mini | 1x |
| 中等复杂 | "订单状态查询" | gpt-4o-mini | 1x |
| 复杂推理 | "多个订单的退款计算" | gpt-4o | 30x |
| 情感安抚 | 用户投诉愤怒 | gpt-4o | 30x |
张伟团队80%的问题都属于前两类,切换到gpt-4o-mini后,这部分成本降低了约96%(价格比约30倍)。
package com.example.llmcost.routing;
import org.springframework.ai.chat.ChatClient;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* LLM模型路由服务
* 根据问题复杂度选择不同的模型,平衡成本和质量
*/
@Service
public class ModelRoutingService {
private final ChatClient cheapClient; // gpt-4o-mini
private final ChatClient powerfulClient; // gpt-4o
private final QuestionClassifier classifier;
public ModelRoutingService(ChatClient.Builder builder,
QuestionClassifier classifier) {
this.cheapClient = builder
.defaultOptions(opts -> opts.withModel("gpt-4o-mini"))
.build();
this.powerfulClient = builder
.defaultOptions(opts -> opts.withModel("gpt-4o"))
.build();
this.classifier = classifier;
}
public String chat(String systemPrompt, List<Message> history,
String userQuestion) {
QuestionComplexity complexity = classifier.classify(userQuestion, history);
ChatClient selectedClient = switch (complexity) {
case SIMPLE, MEDIUM -> cheapClient;
case COMPLEX, EMOTIONAL -> powerfulClient;
};
return selectedClient.prompt()
.system(systemPrompt)
.messages(history)
.user(userQuestion)
.call()
.content();
}
}package com.example.llmcost.routing;
import org.springframework.ai.chat.messages.Message;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.regex.Pattern;
/**
* 问题复杂度分类器
* 使用规则引擎(轻量快速)而非LLM(避免分类本身产生成本)
*/
@Component
public class QuestionClassifier {
// 情感词汇(需要高质量模型处理)
private static final Pattern EMOTIONAL_PATTERN = Pattern.compile(
"投诉|愤怒|生气|差评|退款|赔偿|骗|坑|垃圾|差劲|太差了|不满|欺骗",
Pattern.CASE_INSENSITIVE
);
// 复杂计算相关关键词
private static final Pattern COMPLEX_PATTERN = Pattern.compile(
"计算|比较|分析|多个|批量|统计|报表|差异|对比",
Pattern.CASE_INSENSITIVE
);
// 简单问答相关关键词
private static final Pattern SIMPLE_PATTERN = Pattern.compile(
"电话|地址|时间|价格|怎么.*联系|在哪|营业时间|客服",
Pattern.CASE_INSENSITIVE
);
public QuestionComplexity classify(String question, List<Message> history) {
// 1. 先判断情感
if (EMOTIONAL_PATTERN.matcher(question).find()) {
return QuestionComplexity.EMOTIONAL;
}
// 2. 判断简单问题
if (SIMPLE_PATTERN.matcher(question).find() && question.length() < 30) {
return QuestionComplexity.SIMPLE;
}
// 3. 判断复杂问题
if (COMPLEX_PATTERN.matcher(question).find() || question.length() > 100) {
return QuestionComplexity.COMPLEX;
}
// 4. 根据对话历史轮数判断(长对话往往更复杂)
if (history.size() > 8) {
return QuestionComplexity.COMPLEX;
}
return QuestionComplexity.MEDIUM;
}
}package com.example.llmcost.routing;
public enum QuestionComplexity {
SIMPLE, // 简单FAQ → gpt-4o-mini
MEDIUM, // 中等复杂 → gpt-4o-mini
COMPLEX, // 复杂推理 → gpt-4o
EMOTIONAL // 情感处理 → gpt-4o
}六、优化层5:批量请求合并
对于工单摘要、报表生成等非实时场景,可以将多个请求合并成一次API调用。
package com.example.llmcost.batch;
import org.springframework.ai.chat.ChatClient;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
/**
* 批量请求合并服务
* 适用场景:工单摘要、内容分类、情感分析等非实时任务
*
* 原理:收集30秒内的请求,合并为一个Prompt批量处理
* 效果:减少API调用次数,降低per-request overhead
*/
@Service
public class BatchRequestService {
private final ChatClient chatClient;
// 待处理的批次
private final CopyOnWriteArrayList<BatchItem> pendingItems = new CopyOnWriteArrayList<>();
private static final int MAX_BATCH_SIZE = 20;
private static final String BATCH_SEPARATOR = "\n---ITEM_END---\n";
public BatchRequestService(ChatClient.Builder builder) {
this.chatClient = builder
.defaultOptions(opts -> opts.withModel("gpt-4o-mini"))
.build();
}
/**
* 提交工单摘要任务(异步,30秒内返回)
*/
public CompletableFuture<String> submitSummaryTask(String ticketContent) {
CompletableFuture<String> future = new CompletableFuture<>();
pendingItems.add(new BatchItem(ticketContent, future));
// 如果积累到最大批次,立即处理
if (pendingItems.size() >= MAX_BATCH_SIZE) {
processBatch();
}
return future;
}
/**
* 定时批量处理(每30秒触发一次)
*/
@Scheduled(fixedDelay = 30000)
public void processBatch() {
if (pendingItems.isEmpty()) return;
// 取出当前批次
List<BatchItem> batch = new ArrayList<>(pendingItems.subList(
0, Math.min(MAX_BATCH_SIZE, pendingItems.size())));
pendingItems.removeAll(batch);
if (batch.isEmpty()) return;
// 构建批量Prompt
StringBuilder batchPrompt = new StringBuilder();
batchPrompt.append("请对以下").append(batch.size())
.append("个工单分别生成摘要(格式:序号|问题类型|核心诉求|紧急程度):\n\n");
for (int i = 0; i < batch.size(); i++) {
batchPrompt.append("[工单").append(i + 1).append("]\n");
batchPrompt.append(batch.get(i).content());
batchPrompt.append(BATCH_SEPARATOR);
}
try {
String batchResult = chatClient.prompt()
.system("你是工单摘要助手,按指定格式输出,每个工单占一行。")
.user(batchPrompt.toString())
.call()
.content();
// 解析批量结果,分发给各个Future
String[] results = batchResult.split("\n");
for (int i = 0; i < Math.min(results.length, batch.size()); i++) {
batch.get(i).future().complete(results[i]);
}
// 处理未返回结果的Future
for (int i = results.length; i < batch.size(); i++) {
batch.get(i).future().complete("摘要生成失败");
}
} catch (Exception e) {
batch.forEach(item -> item.future().completeExceptionally(e));
}
}
private record BatchItem(String content, CompletableFuture<String> future) {}
}七、用Micrometer监控每个接口的Token消耗
光优化还不够,要保证效果持续。我们为每个API接口建立了Token消耗的实时监控。
pom.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
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>llm-cost-control</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.4</version>
<relativePath/>
</parent>
<properties>
<java.version>21</java.version>
<spring-ai.version>1.0.0</spring-ai.version>
</properties>
<dependencies>
<!-- Spring AI -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-openai-spring-boot-starter</artifactId>
<version>${spring-ai.version}</version>
</dependency>
<!-- Spring Boot Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- 监控:Micrometer + Prometheus -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
</dependency>
<!-- Redis(语义缓存) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- Token计数 -->
<dependency>
<groupId>com.knuddels</groupId>
<artifactId>jtokkit</artifactId>
<version>1.1.0</version>
</dependency>
<!-- Resilience4j(容错) -->
<dependency>
<groupId>io.github.resilience4j</groupId>
<artifactId>resilience4j-spring-boot3</artifactId>
<version>2.2.0</version>
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- Test -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</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>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>application.yml
spring:
application:
name: llm-cost-control
ai:
openai:
api-key: ${OPENAI_API_KEY}
chat:
options:
model: gpt-4o
temperature: 0.7
max-tokens: 512 # 限制Output Token,避免模型啰嗦
embedding:
options:
model: text-embedding-3-small
data:
redis:
host: ${REDIS_HOST:localhost}
port: 6379
password: ${REDIS_PASSWORD:}
lettuce:
pool:
max-active: 20
min-idle: 5
management:
endpoints:
web:
exposure:
include: prometheus,health,info,metrics
metrics:
export:
prometheus:
enabled: true
tags:
application: ${spring.application.name}
environment: ${ENVIRONMENT:dev}
# Resilience4j配置
resilience4j:
ratelimiter:
instances:
llm-api:
limit-for-period: 100 # 每秒最多100次LLM调用
limit-refresh-period: 1s
timeout-duration: 5s
logging:
level:
com.example.llmcost: DEBUG
org.springframework.ai: INFOToken监控切面
package com.example.llmcost.monitoring;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.DistributionSummary;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Timer;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.chat.ChatResponse;
import org.springframework.ai.chat.metadata.Usage;
import org.springframework.stereotype.Component;
import java.util.concurrent.ConcurrentHashMap;
/**
* Token消耗监控切面
* 自动拦截所有ChatClient调用,记录Token消耗到Micrometer
*/
@Aspect
@Component
public class TokenUsageMonitorAspect {
private static final Logger log = LoggerFactory.getLogger(TokenUsageMonitorAspect.class);
private final MeterRegistry meterRegistry;
private final ConcurrentHashMap<String, DistributionSummary> tokenSummaries =
new ConcurrentHashMap<>();
// GPT-4o价格(USD/token)
private static final double INPUT_TOKEN_PRICE = 2.5 / 1_000_000;
private static final double OUTPUT_TOKEN_PRICE = 10.0 / 1_000_000;
// gpt-4o-mini价格
private static final double MINI_INPUT_PRICE = 0.15 / 1_000_000;
private static final double MINI_OUTPUT_PRICE = 0.60 / 1_000_000;
public TokenUsageMonitorAspect(MeterRegistry meterRegistry) {
this.meterRegistry = meterRegistry;
}
/**
* 监控所有Service层的AI调用
* 需要在Service方法上标注 @MonitorTokenUsage
*/
@Around("@annotation(monitorTokenUsage)")
public Object monitorTokenUsage(ProceedingJoinPoint joinPoint,
MonitorTokenUsage monitorTokenUsage) throws Throwable {
String scene = monitorTokenUsage.scene();
Timer.Sample sample = Timer.start(meterRegistry);
try {
Object result = joinPoint.proceed();
// 如果返回的是ChatResponse,提取Token信息
if (result instanceof ChatResponse chatResponse) {
recordTokenUsage(chatResponse, scene);
}
sample.stop(Timer.builder("llm.request.duration")
.tag("scene", scene)
.register(meterRegistry));
return result;
} catch (Exception e) {
Counter.builder("llm.request.error")
.tag("scene", scene)
.tag("error", e.getClass().getSimpleName())
.register(meterRegistry)
.increment();
throw e;
}
}
private void recordTokenUsage(ChatResponse response, String scene) {
if (response.getMetadata() == null) return;
Usage usage = response.getMetadata().getUsage();
if (usage == null) return;
long inputTokens = usage.getPromptTokens();
long outputTokens = usage.getGenerationTokens();
// 记录Token数量分布
DistributionSummary inputSummary = tokenSummaries.computeIfAbsent(
"input:" + scene,
key -> DistributionSummary.builder("llm.tokens.input")
.tag("scene", scene)
.publishPercentiles(0.5, 0.95, 0.99)
.register(meterRegistry)
);
inputSummary.record(inputTokens);
DistributionSummary outputSummary = tokenSummaries.computeIfAbsent(
"output:" + scene,
key -> DistributionSummary.builder("llm.tokens.output")
.tag("scene", scene)
.publishPercentiles(0.5, 0.95, 0.99)
.register(meterRegistry)
);
outputSummary.record(outputTokens);
// 记录估算成本(根据模型调整)
double cost = (inputTokens * INPUT_TOKEN_PRICE) + (outputTokens * OUTPUT_TOKEN_PRICE);
meterRegistry.counter("llm.cost.usd", "scene", scene).increment(cost);
log.debug("[TokenMonitor] scene={}, input={}, output={}, cost=${:.6f}",
scene, inputTokens, outputTokens, cost);
}
}package com.example.llmcost.monitoring;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface MonitorTokenUsage {
String scene() default "default";
}八、整合:完整的对话服务
package com.example.llmcost.service;
import com.example.llmcost.cache.SemanticCacheService;
import com.example.llmcost.history.SlidingWindowHistoryManager;
import com.example.llmcost.history.TokenBudgetHistoryManager;
import com.example.llmcost.monitoring.MonitorTokenUsage;
import com.example.llmcost.prompt.SystemPromptManager;
import com.example.llmcost.routing.ModelRoutingService;
import org.slf4j.MDC;
import org.springframework.ai.chat.messages.Message;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
/**
* 整合所有优化层的对话服务
* 请求处理流程:
* 1. 语义缓存查询 → 命中直接返回(成本0)
* 2. 历史消息Token预算裁剪
* 3. 问题复杂度分类 → 模型路由
* 4. 调用LLM(带监控)
* 5. 结果写入语义缓存
* 6. 历史消息滑动窗口更新
*/
@Service
public class OptimizedChatService {
private final SemanticCacheService semanticCache;
private final SlidingWindowHistoryManager historyManager;
private final TokenBudgetHistoryManager tokenBudgetManager;
private final SystemPromptManager promptManager;
private final ModelRoutingService routingService;
public OptimizedChatService(SemanticCacheService semanticCache,
SlidingWindowHistoryManager historyManager,
TokenBudgetHistoryManager tokenBudgetManager,
SystemPromptManager promptManager,
ModelRoutingService routingService) {
this.semanticCache = semanticCache;
this.historyManager = historyManager;
this.tokenBudgetManager = tokenBudgetManager;
this.promptManager = promptManager;
this.routingService = routingService;
}
@MonitorTokenUsage(scene = "customer-service")
public ChatResult chat(String sessionId, String userMessage) {
// 设置MDC追踪
String traceId = UUID.randomUUID().toString().substring(0, 8);
MDC.put("traceId", traceId);
MDC.put("sessionId", sessionId);
try {
// 层1:语义缓存查询
Optional<String> cachedAnswer = semanticCache.get(userMessage);
if (cachedAnswer.isPresent()) {
return new ChatResult(cachedAnswer.get(), "CACHE_HIT", traceId);
}
// 层2:获取并裁剪历史消息
List<Message> fullHistory = historyManager.getHistory(sessionId);
List<Message> trimmedHistory = tokenBudgetManager.trimToTokenBudget(fullHistory);
// 层3:获取压缩后的System Prompt
String systemPrompt = promptManager.getPrompt("customer-service");
// 层4:模型路由 + LLM调用
String answer = routingService.chat(systemPrompt, trimmedHistory, userMessage);
// 层5:写入语义缓存
semanticCache.put(userMessage, answer);
// 层6:更新对话历史(用完整历史)
historyManager.addTurn(sessionId, userMessage, answer);
return new ChatResult(answer, "LLM_CALL", traceId);
} finally {
MDC.clear();
}
}
public record ChatResult(String answer, String source, String traceId) {}
}Controller层
package com.example.llmcost.controller;
import com.example.llmcost.service.OptimizedChatService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@RestController
@RequestMapping("/api/chat")
public class ChatController {
private final OptimizedChatService chatService;
public ChatController(OptimizedChatService chatService) {
this.chatService = chatService;
}
@PostMapping("/customer-service")
public ResponseEntity<Map<String, Object>> chat(
@RequestHeader(value = "X-Session-Id", defaultValue = "default") String sessionId,
@RequestBody Map<String, String> request) {
String message = request.get("message");
if (message == null || message.isBlank()) {
return ResponseEntity.badRequest()
.body(Map.of("error", "message cannot be empty"));
}
OptimizedChatService.ChatResult result = chatService.chat(sessionId, message);
return ResponseEntity.ok(Map.of(
"answer", result.answer(),
"source", result.source(), // 方便调试:知道是缓存还是LLM
"traceId", result.traceId()
));
}
}九、成本看板:Grafana实时展示
Prometheus监控指标汇总
你需要在Grafana里配置以下查询,建立成本看板:
# 每秒Token消耗(Input)
rate(llm_tokens_input_sum[5m])
# 每秒Token消耗(Output)
rate(llm_tokens_output_sum[5m])
# 每日预估费用(美元)
increase(llm_cost_usd_total[24h])
# 语义缓存命中率
rate(llm_cache_hit_total[5m]) /
(rate(llm_cache_hit_total[5m]) + rate(llm_cache_miss_total[5m]))
# 按场景分类的P95 Input Token
histogram_quantile(0.95, rate(llm_tokens_input_bucket[5m])) by (scene)
# 模型路由分布
rate(llm_request_duration_seconds_count[5m]) by (scene)告警规则(Prometheus AlertManager)
# alert_rules.yml
groups:
- name: llm-cost-alerts
rules:
- alert: DailyTokenCostHigh
expr: increase(llm_cost_usd_total[24h]) > 250
for: 5m
labels:
severity: warning
annotations:
summary: "日Token费用超过$250"
description: "当前24小时费用:{{ $value | humanize }} USD"
- alert: SingleRequestTokenSpike
expr: llm_tokens_input_sum / llm_tokens_input_count > 3000
for: 10m
labels:
severity: critical
annotations:
summary: "单次请求平均Input Token超过3000"
description: "场景{{ $labels.scene }}出现Token异常"
- alert: SemanticCacheHitRateLow
expr: |
rate(llm_cache_hit_total[30m]) /
(rate(llm_cache_hit_total[30m]) + rate(llm_cache_miss_total[30m])) < 0.25
for: 15m
labels:
severity: warning
annotations:
summary: "语义缓存命中率低于25%"十、最终效果:从18,247到6,618
3周优化后,各层贡献的降本效果:
优化前基准:$18,247/月
优化层1 - System Prompt压缩(856→127 tokens):
节省:(856-127) × 48,230次/天 × 30天 × $2.5/1M
= $26,400/月 × 比例 ≈ -$3,240/月
优化层2 - 对话历史Token预算:
平均历史消耗从1,200 tokens → 450 tokens
节省:750 tokens × 48,230次/天 × 30天 × $2.5/1M ≈ -$2,715/月
优化层3 - 语义缓存(命中率34%):
34%的请求直接返回缓存,成本近乎0
节省:34% × $18,247 ≈ -$6,204/月(扣除Embedding成本约$120)
优化层4 - 模型路由(80%请求用mini):
80%请求从GPT-4o切换到GPT-4o-mini(30倍价差)
节省(剩余请求基础上)≈ -$1,840/月
优化层5 - 批量合并(工单摘要):
工单场景overhead减少约60% ≈ -$180/月
──────────────────────────────
优化后合计:$18,247 - $13,979 + 少量实施成本 ≈ $6,618/月
实际降幅:63.7%架构全景图
FAQ
Q1:语义缓存的相似度阈值0.92怎么确定的?
这个数值需要根据你的业务场景调整。我们的方法是:取100个真实的"意思相同但说法不同"的问题对,计算它们的余弦相似度分布,取P10(最低的10%)作为阈值。太高会漏掉真正相似的问题,太低会把不同问题当成相同处理,给出错误答案。
Q2:压缩System Prompt会影响回答质量吗?
根据我们的A/B测试(500个真实问题),质量差异在统计误差范围内(p=0.73)。关键是保留真正有效的约束,删除模型默认行为已经覆盖的"废话规则"。
Q3:对话历史压缩后,AI会"忘记"之前聊过什么吗?
会有轻微影响,但影响程度取决于你的场景。对于客服场景,用户一般不会在第7轮对话里引用第1轮的细节。如果你的场景需要长期记忆(比如代码辅助),推荐用方案C(摘要压缩)代替滑动窗口。
Q4:模型路由中的规则引擎会误分类吗?
会,这是规则引擎的固有局限。我们的测量结果是约5%的误分类率(把复杂问题路由到cheap模型)。可以接受这个损失,因为即使偶尔用mini处理复杂问题,质量下降也是可以恢复的(用户会重新提问),而成本节省是确定性的。如果误分类率影响体验,可以引入一个轻量分类模型(仍然比GPT-4o便宜10倍)。
Q5:批量合并适合所有场景吗?
不适合。只适合可以接受延迟(30秒内)的离线任务。实时对话绝对不能用批量合并。
Q6:这套方案能用于非OpenAI模型(如Claude、Gemini)吗?
完全可以。Spring AI的抽象层屏蔽了底层API差异,只需要修改application.yml中的模型配置。语义缓存、历史管理、模型路由都是与模型无关的逻辑。
