AI工程师的成本控制手册:把每月LLM账单降低50%
AI工程师的成本控制手册:把每月LLM账单降低50%
一、那张让CTO倒吸一口冷气的账单
2025年3月,某互联网公司的AI团队交了一份月度成本报告。
CTO翻开第一页,看到了一个数字:LLM API调用费用:47.3万元。
这是他们上线内部知识库问答系统的第一个完整月份。
系统功能不复杂:员工输入问题,AI检索知识库,返回答案。每天大概有2000名员工使用,平均每人发起10次查询。
CTO做了个简单的算术:2000人 × 10次 × 31天 = 620,000次调用,47.3万 ÷ 62万次 ≈ 每次调用0.76元。
"每次问一个问题就花7毛6,这个系统是金子做的吗?"
AI团队负责人老张(巧了,也叫老张)听到这话,当场出了一身冷汗。他们用的是GPT-4o,每次调用平均输入1500 tokens、输出400 tokens,成本没算过。
接下来的两周,老张带着团队做了一次全面的成本优化,最终把月账单从47.3万压缩到了21.8万,下降54%。
这篇文章,就是老张那次优化的完整复盘。
二、LLM成本的构成分析
在优化之前,先搞清楚钱花在哪里了。
通常,输出token比输入token贵3-5倍(以GPT-4o为例:输入$2.5/M,输出$10/M)。但大多数RAG应用,输入token数量远大于输出,因为每次调用都要塞入检索到的文档片段。
典型分布:
| 成本项 | 占比 | 优化空间 |
|---|---|---|
| System Prompt | 10-20% | 中等(可压缩) |
| RAG检索内容 | 30-50% | 大(精排、截断) |
| 对话历史 | 10-20% | 大(摘要压缩) |
| 用户输入 | 5-10% | 小 |
| 输出内容 | 15-30% | 中等(限制长度) |
三、五大降本策略
策略一:语义缓存——相似问题不重复调用
最立竿见影的优化:把之前问过的相似问题的答案缓存起来。
/**
* 语义缓存服务
* 对语义相似的问题直接返回缓存答案,避免重复调用LLM
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class SemanticCacheService {
private final EmbeddingModel embeddingModel;
private final VectorStore cacheVectorStore;
private final ChatClient chatClient;
// 相似度阈值:超过0.92认为是"相同"问题
private static final double SIMILARITY_THRESHOLD = 0.92;
/**
* 带语义缓存的问答
*/
public String askWithCache(String question) {
// 1. 查询缓存
List<Document> cacheHits = cacheVectorStore.similaritySearch(
SearchRequest.query(question)
.withTopK(1)
.withSimilarityThreshold(SIMILARITY_THRESHOLD));
if (!cacheHits.isEmpty()) {
String cachedAnswer = cacheHits.get(0).getMetadata()
.get("answer").toString();
log.info("Cache hit! Question: '{}', Similarity: {}",
question.substring(0, Math.min(50, question.length())),
cacheHits.get(0).getScore());
return cachedAnswer;
}
// 2. 缓存未命中,调用LLM
log.info("Cache miss, calling LLM for question: '{}'",
question.substring(0, Math.min(50, question.length())));
String answer = chatClient.prompt()
.user(question)
.call()
.content();
// 3. 将结果存入缓存
Document cacheEntry = new Document(
question,
Map.of(
"question", question,
"answer", answer,
"cached_at", LocalDateTime.now().toString()
)
);
cacheVectorStore.add(List.of(cacheEntry));
return answer;
}
/**
* 获取缓存命中统计
*/
public CacheStats getStats() {
// 从监控系统获取
return CacheStats.builder()
.hitCount(metricsService.getCacheHits())
.missCount(metricsService.getCacheMisses())
.hitRate(metricsService.getCacheHitRate())
.estimatedMonthlySavings(calculateSavings())
.build();
}
}效果:在企业知识库场景下,相似问题的重复率通常在30-40%。语义缓存命中率30%,可以直接降低30%的调用次数。
策略二:模型分级路由——不同任务用不同模型
不是所有任务都需要GPT-4o。简单分类、意图识别,用GPT-4o-mini就够了,成本差10-20倍。
/**
* 智能模型路由器
* 根据任务复杂度和类型,路由到合适的模型
*/
@Service
@RequiredArgsConstructor
public class ModelRouter {
private final ChatClient gpt4oClient; // GPT-4o: 复杂推理
private final ChatClient gpt4oMiniClient; // GPT-4o-mini: 简单任务,便宜10倍
private final ChatClient embeddingClient; // 仅用于嵌入
/**
* 根据任务类型路由到合适的模型
*/
public String route(String task, String content) {
TaskComplexity complexity = assessComplexity(task, content);
return switch (complexity) {
case SIMPLE -> {
log.debug("Routing to mini model: task={}", task);
yield gpt4oMiniClient.prompt().user(content).call().content();
}
case COMPLEX -> {
log.debug("Routing to full model: task={}", task);
yield gpt4oClient.prompt().user(content).call().content();
}
};
}
/**
* 评估任务复杂度
*/
private TaskComplexity assessComplexity(String task, String content) {
// 明确的简单任务:分类、意图识别、情感分析
if (task.matches("classify|intent|sentiment|extract_keyword")) {
return TaskComplexity.SIMPLE;
}
// 内容较短且是简单问答
if (content.length() < 500 && !task.contains("reason")) {
return TaskComplexity.SIMPLE;
}
// 需要多步推理、代码生成、复杂分析
return TaskComplexity.COMPLEX;
}
public enum TaskComplexity { SIMPLE, COMPLEX }
}效果:将30-50%的简单任务路由到mini模型,整体成本可降低30-40%。
策略三:Prompt压缩——删掉废话
很多System Prompt里充满了重复的、冗余的指令。压缩Prompt可以直接减少输入token数量。
/**
* Prompt压缩优化器
*/
@Service
@RequiredArgsConstructor
public class PromptOptimizer {
/**
* 压缩System Prompt:删除冗余,保留核心
* 通常可压缩30-50%
*/
public String compressSystemPrompt(String originalPrompt) {
// 1. 删除重复的格式要求
String compressed = originalPrompt
// 删除常见的废话
.replaceAll("(?i)please (make sure|ensure|note that|remember)\\s*", "")
.replaceAll("(?i)it is important (to|that)\\s*", "")
.replaceAll("(?i)you should always\\s*", "")
// 压缩空行
.replaceAll("\n{3,}", "\n\n")
.trim();
// 2. 用LLM进一步压缩(一次性操作,结果缓存)
return compressed;
}
/**
* 截断RAG检索内容
* 只保留最相关的部分,而不是全文
*/
public String truncateRAGContext(List<Document> docs, int maxTokens) {
StringBuilder context = new StringBuilder();
int estimatedTokens = 0;
// 按相关性排序,优先取高分文档
List<Document> sorted = docs.stream()
.sorted(Comparator.comparingDouble(Document::getScore).reversed())
.collect(toList());
for (Document doc : sorted) {
String content = doc.getContent();
// 粗略估算token数(中文1.5token/字,英文0.3token/字符)
int docTokens = estimateTokens(content);
if (estimatedTokens + docTokens > maxTokens) {
// 如果超出预算,截断当前文档
int remainingTokens = maxTokens - estimatedTokens;
if (remainingTokens > 100) { // 至少保留100个token
content = truncateToTokens(content, remainingTokens);
context.append(content);
}
break;
}
context.append(content).append("\n\n");
estimatedTokens += docTokens;
}
return context.toString();
}
private int estimateTokens(String text) {
long chineseChars = text.chars()
.filter(c -> c >= 0x4E00 && c <= 0x9FFF)
.count();
int otherChars = text.length() - (int) chineseChars;
return (int) (chineseChars * 1.5 + otherChars * 0.3);
}
private String truncateToTokens(String text, int maxTokens) {
// 简化实现:按字符比例截断
int maxChars = (int) (maxTokens / 1.5 * 2);
if (text.length() <= maxChars) return text;
return text.substring(0, maxChars) + "...";
}
}策略四:输出长度控制
@Configuration
public class TokenBudgetConfig {
/**
* 不同场景配置不同的输出限制
*/
@Bean
public Map<String, ChatClient> scenarioChatClients(ChatModel chatModel) {
Map<String, ChatClient> clients = new HashMap<>();
// 简短回答场景(FAQ、意图识别)
clients.put("brief", ChatClient.builder(chatModel)
.defaultOptions(OpenAiChatOptions.builder()
.maxTokens(256) // 最多256 tokens
.build())
.build());
// 标准回答场景(知识库问答)
clients.put("standard", ChatClient.builder(chatModel)
.defaultOptions(OpenAiChatOptions.builder()
.maxTokens(1024)
.build())
.build());
// 详细报告场景(代码生成、长文写作)
clients.put("detailed", ChatClient.builder(chatModel)
.defaultOptions(OpenAiChatOptions.builder()
.maxTokens(4096)
.build())
.build());
return clients;
}
}策略五:批处理与请求合并
/**
* 请求批处理器
* 将短时间内的多个相似请求合并为一次LLM调用
*/
@Service
@RequiredArgsConstructor
public class BatchRequestProcessor {
private final ChatClient chatClient;
// 等待合并的请求队列
private final Queue<PendingRequest> pendingQueue = new ConcurrentLinkedQueue<>();
private static final int BATCH_SIZE = 10;
private static final Duration BATCH_WAIT = Duration.ofMillis(200);
/**
* 批量分类处理
* 把10个单独的分类请求合并成1次API调用
*/
public CompletableFuture<String> classifyAsync(String text) {
CompletableFuture<String> future = new CompletableFuture<>();
pendingQueue.offer(new PendingRequest(text, future));
// 队列满了就立即处理
if (pendingQueue.size() >= BATCH_SIZE) {
processBatch();
}
return future;
}
@Scheduled(fixedDelay = 200) // 每200ms处理一次
private void processBatch() {
if (pendingQueue.isEmpty()) return;
List<PendingRequest> batch = new ArrayList<>();
PendingRequest req;
while ((req = pendingQueue.poll()) != null && batch.size() < BATCH_SIZE) {
batch.add(req);
}
if (batch.isEmpty()) return;
// 构建批量分类请求
StringBuilder batchPrompt = new StringBuilder(
"请对以下每条文本进行情感分类(正面/负面/中性)," +
"按序号返回结果,格式:1:正面\n");
for (int i = 0; i < batch.size(); i++) {
batchPrompt.append(i + 1).append(". ")
.append(batch.get(i).text()).append("\n");
}
// 一次调用处理所有请求
String batchResult = chatClient.prompt()
.user(batchPrompt.toString())
.call()
.content();
// 解析批量结果并分发给各自的Future
String[] results = batchResult.split("\n");
for (int i = 0; i < batch.size() && i < results.length; i++) {
String result = results[i].replaceAll("^\\d+:", "").trim();
batch.get(i).future().complete(result);
}
}
public record PendingRequest(String text, CompletableFuture<String> future) {}
}四、成本监控仪表盘
所有优化都需要可视化才能评估效果:
@RestController
@RequestMapping("/api/metrics/cost")
@RequiredArgsConstructor
public class CostMetricsController {
private final CostTrackingService costService;
@GetMapping("/daily-report")
public DailyCostReport getDailyReport(@RequestParam LocalDate date) {
return costService.generateDailyReport(date);
}
public record DailyCostReport(
LocalDate date,
long totalApiCalls,
long cacheHits,
double cacheHitRate,
long totalInputTokens,
long totalOutputTokens,
double estimatedCostUSD,
Map<String, Long> callsByModel, // 各模型调用次数
Map<String, Double> costByModel, // 各模型成本
double savingsFromCache, // 缓存节省的成本
double savingsFromRouting // 模型路由节省的成本
) {}
}五、成本优化整体架构
六、总结:成本优化的优先级
按投入产出比排序,建议按此顺序实施:
| 优先级 | 策略 | 实施难度 | 预期降本 |
|---|---|---|---|
| 1 | 模型分级路由 | 低 | 20-40% |
| 2 | 语义缓存 | 中 | 15-35% |
| 3 | RAG内容截断 | 低 | 10-20% |
| 4 | 输出长度控制 | 低 | 5-15% |
| 5 | 批处理合并 | 高 | 5-10% |
按顺序做完前三项,就能实现50%以上的成本下降。
记住:成本优化不是一次性的工作,而是需要持续监控和调优。建立成本仪表盘,每周看一眼账单趋势,是每个AI工程师的日常。
