优化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发送,使用具有稳定前缀的提示词。重要的是你能测量每个请求的成本。有了数据,你才能知道哪些更改真正有效。

llmaispring-boot成本优化技术趋势