优化LLM调用成本:提示词缓存、小模型和批处理
1. 概述
当某个LLM功能从演示版本进入生产环境时,成本通常是首先需要解决的问题。账单按token数量增加,而token数量取决于用户数、提示词长度和agent调用次数。
本文介绍三种常见的成本降低技术:提示词缓存、路由到小模型和批处理。每种技术都有Java 21/Spring Boot 3.x的示例。读完后,你将了解如何组织提示词以利用缓存、如何根据任务类型选择模型,以及何时应该使用批处理。本文还指出何时不应该应用各种技术。
2. LLM成本来自何处
大多数提供商按token数量计费,分为三个类别:
- 输入token:系统提示词、对话历史、RAG文档、工具定义。
- 缓存输入token:从缓存中重新读取的输入部分,通常比普通输入便宜。
- 输出token:模型生成的内容,通常比输入更昂贵。
在许多应用中,输入占大部分成本。原因是每个请求都会发送相同的长系统提示词、相同的工具集和相同的文档片段。我们可以这样对成本进行建模:
public record TokenUsage(long inputTokens, long cachedInputTokens, long outputTokens) {}
public record ModelPricing(double inputPerMillion, double cachedInputPerMillion, double outputPerMillion) {
public double cost(TokenUsage u) {
long uncached = u.inputTokens() - u.cachedInputTokens();
return (uncached * inputPerMillion
+ u.cachedInputTokens() * cachedInputPerMillion
+ u.outputTokens() * outputPerMillion) / 1_000_000.0;
}
public static void main(String[] args) {
// 示例价格以假定单位表示,不是任何提供商的实际价格
var pricing = new ModelPricing(10, 1, 30);
System.out.printf("无缓存: %.3f%n", pricing.cost(new TokenUsage(20_000, 0, 500)));
System.out.printf("有缓存: %.3f%n", pricing.cost(new TokenUsage(20_000, 18_000, 500)));
}
}
TokenUsage包含提供商在每个响应中返回的token数量。cost()方法分离普通输入和缓存中获取的输入,然后乘以相应的单价。使用上面的假定价格,结果是:
无缓存: 0.215
有缓存: 0.053
这里的数字仅用于说明。需要理解的是这种结构:当大部分提示词在请求之间重复时,缓存直接影响最大的支出。你应该将提供商的实际价格放入配置中,并在日志中计算每个请求的成本。
3. 提示词缓存
3.1. 概念
提示词缓存是提供商端的一种机制。提供商首次处理提示词时,会保存为该提示词的开头部分(前缀)计算的状态。后续具有完全相同前缀(每个字符都相同)的请求将重新读取此状态,因此成本更低,通常也更快。
你需要记住三个特点:
- 缓存按前缀匹配。只要提示词开头有一个字符不同,其后面的整个部分都无法使用缓存。
- 缓存具有较短的生命周期,通常需要前缀足够长。具体阈值取决于提供商。
- 有些提供商会自动缓存。有些提供商要求你在请求中显式标记缓存切点。
3.2. 组织提示词:静态部分在前,动态部分在后
最重要的原则是将稳定内容放在开头,将变化内容放在末尾:
import java.time.LocalDate;
import java.util.List;
public record ChatMessage(String role, String content) {}
public final class SupportPromptBuilder {
private final String instructions;
private final String productCatalog;
public SupportPromptBuilder(String instructions, String productCatalog) {
this.instructions = instructions;
this.productCatalog = productCatalog;
}
public List<ChatMessage> build(String customerQuestion, LocalDate today) {
return List.of(
new ChatMessage("system", instructions), // 静态
new ChatMessage("system", "产品目录:\n" + productCatalog), // 静态
new ChatMessage("user", "今天:" + today + "\n问题:" + customerQuestion)); // 动态
}
}
说明和产品目录在客户之间不变,所以它们位于列表的开头。当前日期和问题因每个请求而变化,所以它们位于最后的消息中。
一个常见错误是在系统提示词的第一行写入"你是一个助手。现在是" + LocalDateTime.now()。时间戳在每个请求时变化,所以缓存永远不会命中。有些类似的错误更难检测:
- 从
HashMap序列化工具定义,导致键的顺序不稳定。 - 将用户名或请求ID插入系统提示词中。
- 在调用之间以随机顺序排序RAG文档。
你可以编写测试来确保在代码变化时前缀保持稳定:
import static org.assertj.core.api.Assertions.assertThat;
import java.time.LocalDate;
import org.junit.jupiter.api.Test;
class SupportPromptBuilderTest {
private final SupportPromptBuilder builder =
new SupportPromptBuilder("你是商店的支持员工。", "T恤、牛仔裤、鞋");
@Test
void prefixIsIdenticalAcrossDifferentRequests() {
var first = builder.build("还有M号吗?", LocalDate.of(2024, 1, 1));
var second = builder.build("运费多少?", LocalDate.of(2024, 1, 2));
assertThat(first.subList(0, 2)).isEqualTo(second.subList(0, 2));
assertThat(first.get(2)).isNotEqualTo(second.get(2));
}
}
测试比较两个不同请求的前两条消息。如果以后有人将动态数据插入静态部分,测试会立即报错。
3.3. 显式标记缓存点
对于要求标记的提供商,请求通常有一个字段明确指示“缓存到此处”。例如,JSON正文可能看起来如下。字段名称和放置位置取决于每个提供商的API:
{
"model": "your-model",
"system": [
{ "type": "text", "text": "你是支持员工..." },
{ "type": "text", "text": "产品目录:...", "cache_control": { "type": "ephemeral" } }
],
"messages": [ { "role": "user", "content": "还有M号吗?" } ]
}
标记点放在最后一个静态块的末尾。该点之前的所有内容都是缓存的候选。
3.4. 测量缓存命中率
响应通常返回从缓存读取的token数。你应该记录这个数字,因为没有数据你就不知道缓存是否有效:
public static double cacheHitRatio(TokenUsage u) {
return u.inputTokens() == 0 ? 0 : (double) u.cachedInputTokens() / u.inputTokens();
}
应该将此比率推送到Micrometer或以指标形式记录。当比率在部署后突然降至0时,几乎可以肯定有人改变了前缀。
4. 在应用程序端缓存答案
提示词缓存只能降低输入部分的成本。如果相同的问题被重复提出,你可以完全跳过LLM调用。添加以下依赖项:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
spring.cache.cache-names=faq-answers
spring.cache.caffeine.spec=maximumSize=10000,expireAfterWrite=6h
public interface LlmClient {
String complete(String model, String prompt);
}
@Service
public class FaqAnswerService {
private final LlmClient llmClient;
public FaqAnswerService(LlmClient llmClient) {
this.llmClient = llmClient;
}
@Cacheable(cacheNames = "faq-answers", key = "#root.target.normalize(#question)")
public String answer(String question) {
return llmClient.complete("small-model", "简要回答常见问题:" + question);
}
public String normalize(String question) {
return question.strip().toLowerCase(Locale.ROOT).replaceAll("\\s+", " ");
}
}
你需要将@EnableCaching添加到@Configuration类中。缓存键是已标准化的问题:删除多余的空格并转换为小写。因此,“运费?“和” 运费?“使用相同的结果。
注意:
@Cacheable通过代理工作。如果同一类中的另一个方法直接调用answer(),缓存将被绕过。不要缓存包含个人数据的答案,例如特定客户的订单状态,用不包含用户标识符的键。
5. 路由到小模型
许多任务不需要最强大的模型:票证分类、从电子邮件中提取字段、简短摘要、格式检查。小模型通常成本更低,速度明显更快。这就是为什么“模型路由”受到许多团队的关注。
实际做法是级联:先调用小模型,检查输出,只有在输出无效时才升级到大模型。
import java.util.Set;
public class TicketClassifier {
private static final Set<String> LABELS = Set.of("BILLING", "SHIPPING", "TECHNICAL", "OTHER");
private final LlmClient llm;
private final String smallModel;
private final String largeModel;
public TicketClassifier(LlmClient llm, String smallModel, String largeModel) {
this.llm = llm;
this.smallModel = smallModel;
this.largeModel = largeModel;
}
public String classify(String ticket) {
String prompt = "将票证分类到标签" + LABELS + "。仅返回标签。\n票证:" + ticket;
String label = llm.complete(smallModel, prompt).strip();
if (LABELS.contains(label)) {
return label;
}
return llm.complete(largeModel, prompt).strip();
}
}
小模型先处理。如果它返回允许集合中的正确标签,我们就直接使用该结果。否则,例如模型回答冗长,我们用大模型再调用一次。验证步骤必须是确定性的:解析JSON、匹配枚举、验证模式。不要问小模型它是否“有信心”。
用一个fake LlmClient进行测试:
import static org.assertj.core.api.Assertions.assertThat;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.Test;
class TicketClassifierTest {
@Test
void escalatesToLargeModelWhenSmallModelReturnsInvalidLabel() {
List<String> calledModels = new ArrayList<>();
LlmClient fake = (model, prompt) -> {
calledModels.add(model);
return model.equals("small-model") ? "也许是付款?" : "BILLING";
};
var classifier = new TicketClassifier(fake, "small-model", "large-model");
assertThat(classifier.classify("我被扣了两次钱")).isEqualTo("BILLING");
assertThat(calledModels).containsExactly("small-model", "large-model");
}
@Test
void staysOnSmallModelWhenOutputIsValid() {
List<String> calledModels = new ArrayList<>();
LlmClient fake = (model, prompt) -> {
calledModels.add(model);
return "SHIPPING";
};
new TicketClassifier(fake, "small-model", "large-model").classify("商品还没到");
assertThat(calledModels).containsExactly("small-model");
}
}
你应该将模型名称放入配置中,而不是硬编码。这样你可以在不重新构建的情况下更改模型:
llm:
models:
small: small-model
large: large-model
你也应该跟踪升级率。如果大多数请求仍然需要升级到大模型,级联会让你花费更多钱,因为每个请求被调用两次。
6. 批处理
6.1. 将多个项目合并到一个请求中
当需要分析许多短项目,例如50条产品评论时,你可以将它们合并到一个提示词中。说明部分只需发送一次,而不是50次:
public record Review(String id, String text) {}
public static String buildSentimentPrompt(List<Review> reviews) {
var sb = new StringBuilder("""
对于每条评论,返回一行JSON:{"id": "...", "sentiment": "POSITIVE|NEGATIVE|NEUTRAL"}
""");
reviews.forEach(r -> sb.append(r.id()).append(": ").append(r.text()).append('\n'));
return sb.toString();
}
你应该保持每个组的大小适中。组太大会让模型容易漏掉项目。此外,一个解析错误会破坏整个组。始终检查所有输入id是否都在输出中,并单独重试缺失的项目。
6.2. 异步Batch API
许多提供商提供Batch API。你发送包含许多请求的文件,提供商在较长的时间段内处理,以换取比同步调用更低的价格。常见格式是JSONL,每行一个请求,有custom_id用于匹配结果。具体结构取决于提供商:
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
public static String toJsonl(List<Review> reviews, String model, ObjectMapper mapper) {
return reviews.stream()
.map(r -> Map.of(
"custom_id", "review-" + r.id(),
"body", Map.of(
"model", model,
"messages", List.of(Map.of("role", "user",
"content", "分类情感:" + r.text())))))
.map(line -> {
try {
return mapper.writeValueAsString(line);
} catch (Exception e) {
throw new IllegalStateException(e);
}
})
.collect(Collectors.joining("\n"));
}
每条评论成为独立的JSON行。custom_id帮助你知道哪个结果属于哪条评论,因为返回顺序不被保证。输出看起来像这样:
{"custom_id":"review-1","body":{"model":"small-model","messages":[{"role":"user","content":"分类情感:送货快"}]}}
{"custom_id":"review-2","body":{"model":"small-model","messages":[{"role":"user","content":"分类情感:衣服撕裂"}]}}
JSON中键的顺序可能不同,因为Map.of不保持顺序。这不会影响Batch API。该过程通常包括四个步骤:一个@Scheduled作业在晚上创建文件、上传和创建批处理,将批处理ID保存到数据库,然后另一个作业定期检查状态并下载结果。
7. 何时不应该使用
- 提示词缓存当提示词较短、前缀因用户而异或流量稀疏至缓存在下一个请求前过期时,几乎没有好处。如果提供商在写入缓存时收费,为从未被重新读取的内容标记缓存会增加成本。
- 响应缓存当答案取决于持续变化的数据(如库存或价格)、需要个性化或用户期望多样化答案时,不合适。
- 小模型不适合需要多步推理、编写或审查复杂代码,或当你没有确定性方法验证输出时的重大后果决策(法律、医疗、财务)。不要仅基于感觉更换模型。应在评估数据集上提前运行。
- 批处理不能用于所有需要即时响应的流程,如聊天或自动完成。当项目需要长的单独上下文,或一个项目的失败不允许影响其他项目时,合并多个项目也不合适。
**注意:**优化前要测量。为每个请求记录
TokenUsage、使用的模型和调用功能。通常只有少数几个端点占大部分成本,那是应该开始的地方。
8. 结论
LLM成本主要来自于重复的token和使用超过所需强度的模型。本文中的每项技术都针对一个浪费源:
- 提示词缓存:将静态部分放在前面,保持前缀稳定并测量缓存命中率。
- 响应缓存:跳过重复问题的LLM调用。
- 级联小模型→大模型:以确定性方式验证输出,仅在需要时升级。
- 批处理:合并项目以共享说明部分,对不需要立即结果的工作使用Batch API。
这些技术可以结合使用。例如,一个夜间分类作业可以使用小模型,通过Batch API发送,使用具有稳定前缀的提示词。重要的是你能测量每个请求的成本。有了数据,你才能知道哪些更改真正有效。