计算 Token 并估算调用 LLM 的成本(Java)
1. 概述
每次调用 GPT-4o 等 LLM 时,你按 Token 数量 付费,而不是按字符数或请求数。如果不加以监控,月底的账单可能会让你大吃一惊。特别是当 prompt 包含长对话历史或 RAG 文档时。
在本文中,你将在 JVM 内使用 JTokkit 计算 Token,用 BigDecimal 计算成本,并阻止超预算的请求。最后,你将把估算与 Spring AI 返回的实际 Token 数进行对比。
2. Token 是什么以及为什么要计算
Token 是模型的 tokenizer 分割文本的单位。对于英文,平均一个 Token 约为 4 个字符。带声调的越南语或中文通常需要 更多 Token 来表达同样的意思,因为这些字符在 tokenizer 的训练数据中出现频率较低。
一个请求的成本分为两部分,定价不同:
- Input tokens(Prompt Tokens):系统提示、聊天历史、问题、附加文档。
- Output tokens(Completion Tokens):模型生成的部分。这部分的单价通常比输入高出许多倍。
需要在两个时间点进行计数:
- 调用前:用于估算、裁剪上下文或拒绝过于昂贵的请求。
- 调用后:记录实际成本,基于供应商返回的数据。
3. 使用 JTokkit 在本地计算 Token
JTokkit 是一个纯 Java 库,实现了与 OpenAI 的 tiktoken 相同的 BPE 编码表。该库不需要网络调用。
<dependency>
<groupId>com.knuddels</groupId>
<artifactId>jtokkit</artifactId>
<version>1.1.0</version>
</dependency>
计算字符串的 Token 数:
import com.knuddels.jtokkit.Encodings;
import com.knuddels.jtokkit.api.Encoding;
import com.knuddels.jtokkit.api.EncodingRegistry;
import com.knuddels.jtokkit.api.EncodingType;
public class TokenCountDemo {
public static void main(String[] args) {
EncodingRegistry registry = Encodings.newDefaultEncodingRegistry();
Encoding encoding = registry.getEncoding(EncodingType.O200K_BASE);
String english = "Hello, world!";
String chinese = "你好,世界!你的订单已确认。";
System.out.println(english + " -> " + encoding.countTokens(english));
System.out.println(chinese + " -> " + encoding.countTokens(chinese));
}
}
逐部分解释:
Encodings.newDefaultEncodingRegistry()加载编码表。这个加载过程 耗时且占用内存,所以应该只创建一次然后重复使用。O200K_BASE是 GPT-4o 系列的编码。较早的模型如 GPT-4 和 GPT-3.5 使用CL100K_BASE。countTokens仅返回数量。它比encode更快,因为不需要保留 Token 列表。
英文句子的结果如下。中文句子的数字取决于编码,但会明显高于词数。
Hello, world! -> 4
你好,世界!你的订单已确认。-> (约 10-15)
如果想根据模型名称选择编码,可以使用 registry.getEncodingForModel(ModelType.GPT_4O)。
注意: JTokkit 只与 OpenAI 的 tokenizer 准确。Claude、Gemini 或 Llama 使用不同的 tokenizer。对于这些模型,你应该仅使用 JTokkit 进行粗略估算,并应添加约 10-20% 的安全边际。
3.1. 计算对话的 Token 数
Chat API 不发送单纯的文本字符串。它发送包含 role 的消息列表,每条消息都带有一些格式 Token。根据 OpenAI 对聊天模型的描述,每条消息额外消耗约 3 个 Token。回复也会多消耗 3 个 Token。
import com.knuddels.jtokkit.api.Encoding;
import java.util.List;
public record ChatMessage(String role, String content) {}
public class ChatTokenCounter {
private static final int TOKENS_PER_MESSAGE = 3;
private static final int REPLY_PRIMING = 3;
private final Encoding encoding;
public ChatTokenCounter(Encoding encoding) {
this.encoding = encoding;
}
public int count(List<ChatMessage> messages) {
int total = REPLY_PRIMING;
for (ChatMessage message : messages) {
total += TOKENS_PER_MESSAGE
+ encoding.countTokens(message.role())
+ encoding.countTokens(message.content());
}
return total;
}
}
这是一个近似数字。如果请求包含 tool/function 定义或图像,它们也会计入输入。JTokkit 无法直接计算这部分。因此,你需要始终与实际数据进行对比(见第 6 节)。
4. 配置价格表和计算成本
模型价格经常变化,所以 不要在代码中硬编码价格。应该将价格放在配置文件中。下面的数字仅作示例,以 USD 计算,每百万 Token 的价格。
pricing:
models:
gpt-4o:
input-per-million: 2.50
output-per-million: 10.00
gpt-4o-mini:
input-per-million: 0.15
output-per-million: 0.60
将配置绑定到 record:
import java.math.BigDecimal;
import java.util.Map;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "pricing")
public record PricingProperties(Map<String, ModelPrice> models) {
public record ModelPrice(BigDecimal inputPerMillion, BigDecimal outputPerMillion) {}
public ModelPrice priceOf(String model) {
ModelPrice price = models.get(model);
if (price == null) {
throw new IllegalArgumentException("未配置模型价格: " + model);
}
return price;
}
}
记得通过在 @SpringBootApplication 类上使用 @EnableConfigurationProperties(PricingProperties.class) 或 @ConfigurationPropertiesScan 来启用绑定。如果模型名称包含句号(例如 gpt-4.1),在 YAML 中应写为 "[gpt-4.1]"。否则,Spring 会将句号理解为嵌套级别。
现在是计算成本的服务:
import java.math.BigDecimal;
import java.math.RoundingMode;
import org.springframework.stereotype.Service;
@Service
public class CostEstimator {
private static final BigDecimal ONE_MILLION = BigDecimal.valueOf(1_000_000);
private final PricingProperties pricing;
public CostEstimator(PricingProperties pricing) {
this.pricing = pricing;
}
public BigDecimal estimate(String model, long inputTokens, long outputTokens) {
PricingProperties.ModelPrice price = pricing.priceOf(model);
BigDecimal inputCost = price.inputPerMillion()
.multiply(BigDecimal.valueOf(inputTokens));
BigDecimal outputCost = price.outputPerMillion()
.multiply(BigDecimal.valueOf(outputTokens));
return inputCost.add(outputCost)
.divide(ONE_MILLION, 8, RoundingMode.HALF_UP);
}
}
几个需要注意的点:
- 使用
BigDecimal而不是double。一个请求成本几千分之几美元,用double累加数百万个请求会产生舍入误差。 - 先乘后除以保持精度。
- 四舍五入至 8 位小数。这足够小,不会丢失廉价请求。
例如,对于 gpt-4o,1,200 个输入 Token 和 300 个输出 Token:
input : 1200 × 2.50 / 1_000_000 = 0.003
output: 300 × 10.00 / 1_000_000 = 0.003
total : 0.00600000 USD
你可以看到输出只占输入 Token 数的 1/4,但成本相当。这就是为什么你应该限制响应长度。
5. 在调用前阻止超预算的请求
在调用前,你还不知道模型会生成多长的回复。但你知道 上限:max_tokens 参数。最坏情况下的成本等于已计算的输入加上 max_tokens 的输出。
import java.math.BigDecimal;
import java.util.List;
import org.springframework.stereotype.Component;
@Component
public class BudgetGuard {
private final ChatTokenCounter tokenCounter;
private final CostEstimator costEstimator;
public BudgetGuard(ChatTokenCounter tokenCounter, CostEstimator costEstimator) {
this.tokenCounter = tokenCounter;
this.costEstimator = costEstimator;
}
public BigDecimal checkWorstCase(String model, List<ChatMessage> messages,
int maxOutputTokens, BigDecimal limitUsd) {
int inputTokens = tokenCounter.count(messages);
BigDecimal worstCase = costEstimator.estimate(model, inputTokens, maxOutputTokens);
if (worstCase.compareTo(limitUsd) > 0) {
throw new IllegalStateException(
"请求估算成本 %s USD,超过预算 %s USD"
.formatted(worstCase.toPlainString(), limitUsd.toPlainString()));
}
return worstCase;
}
}
ChatTokenCounter 需要声明为 bean,并配合共享的 Encoding:
import com.knuddels.jtokkit.Encodings;
import com.knuddels.jtokkit.api.EncodingType;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class TokenizerConfig {
@Bean
public ChatTokenCounter chatTokenCounter() {
var encoding = Encodings.newDefaultEncodingRegistry()
.getEncoding(EncodingType.O200K_BASE);
return new ChatTokenCounter(encoding);
}
}
你可以用多种方式使用 BudgetGuard。你可以拒绝请求。你也可以裁剪最旧的聊天历史,直到成本在预算内。或者你可以切换到 gpt-4o-mini 这样的更便宜的模型。
6. 从 Spring AI 获取实际 Token 数
估算只是为了做决定。要 真正计费,你应该使用供应商返回的数据。添加 Spring AI:
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>1.0.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-openai</artifactId>
</dependency>
</dependencies>
spring.ai.openai.api-key=${OPENAI_API_KEY}
spring.ai.openai.chat.options.model=gpt-4o
spring.ai.openai.chat.options.max-tokens=500
调用模型并读取 Usage:
import java.math.BigDecimal;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.metadata.Usage;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.stereotype.Service;
@Service
public class SupportAssistant {
private static final Logger log = LoggerFactory.getLogger(SupportAssistant.class);
private final ChatClient chatClient;
private final CostEstimator costEstimator;
public SupportAssistant(ChatClient.Builder builder, CostEstimator costEstimator) {
this.chatClient = builder.build();
this.costEstimator = costEstimator;
}
public String answer(String question) {
ChatResponse response = chatClient.prompt()
.user(question)
.call()
.chatResponse();
Usage usage = response.getMetadata().getUsage();
String model = response.getMetadata().getModel();
BigDecimal cost = costEstimator.estimate(
"gpt-4o", usage.getPromptTokens(), usage.getCompletionTokens());
log.info("model={} prompt={} completion={} cost={} USD",
model, usage.getPromptTokens(), usage.getCompletionTokens(),
cost.toPlainString());
return response.getResult().getOutput().getText();
}
}
日志输出会看起来像这样:
model=gpt-4o-2024-08-06 prompt=412 completion=187 cost=0.00290000 USD
注意 getModel() 返回包含版本的名称,例如 gpt-4o-2024-08-06。因此示例使用固定的 key "gpt-4o" 来查询价格。在实际应用中,你应该在查询价格表前标准化模型名称。
将实际的 prompt 数与第 3.1 节中计数的进行比较时,差几个 Token 是正常的。如果差异较大,通常是因为默认的系统提示、tool 定义或顾问(例如内存、RAG)添加了你没有计算的额外内容。
7. 测试 CostEstimator
计费逻辑是不能出错的地方。你应该编写独立测试,不需要 Spring context:
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import com.knuddels.jtokkit.Encodings;
import com.knuddels.jtokkit.api.EncodingType;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
class CostEstimatorTest {
private final PricingProperties pricing = new PricingProperties(Map.of(
"gpt-4o", new PricingProperties.ModelPrice(
new BigDecimal("2.50"), new BigDecimal("10.00"))));
private final CostEstimator estimator = new CostEstimator(pricing);
@Test
void givenInputAndOutputTokens_whenEstimate_thenSumsBothParts() {
BigDecimal cost = estimator.estimate("gpt-4o", 1_200, 300);
assertThat(cost).isEqualByComparingTo("0.006");
}
@Test
void givenUnknownModel_whenEstimate_thenThrows() {
assertThatThrownBy(() -> estimator.estimate("unknown-model", 10, 10))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("unknown-model");
}
@Test
void givenChineseText_whenCount_thenUsesMoreTokensThanWords() {
var encoding = Encodings.newDefaultEncodingRegistry()
.getEncoding(EncodingType.O200K_BASE);
var counter = new ChatTokenCounter(encoding);
String content = "你的订单已确认";
int tokens = counter.count(List.of(new ChatMessage("user", content)));
assertThat(tokens).isGreaterThan(content.split(" ").length);
}
}
第一个测试使用 isEqualByComparingTo 而不是 isEqualTo。原因是 0.00600000 和 0.006 有不同的 scale,所以 BigDecimal 的 equals 会返回 false。
8. 常见错误
- 在每个请求中创建
EncodingRegistry。编码表加载成本很高。你应该创建一次然后重复使用,因为Encoding是线程安全的。 - 使用错误的编码。如果用
CL100K_BASE计数 GPT-4o,你会得到偏离的数字。根据模型选择正确的编码。 - 仅计算输入。输出的成本通常是输入的 4 倍或更多。忽略这部分是最大的偏差。
- 遗漏“隐藏”Token。推理模型(o 系列)生成推理 Token。这些 Token 不会显示在响应中,但 仍按输出计费。反之,缓存的输入 Token 通常会打折。如果需要详细信息,请阅读
getNativeUsage()。 - 对金钱使用
double。舍入误差累积会导致成本报告与发票不符。 - 完全相信估算。估算用于阻止和警告。记录实际成本必须基于真实的
Usage。
注意: 你不需要为每个请求都提前计算 Token。如果 prompt 很短、固定并且已有
max_tokens,成本已有上限。此时,仅在调用后记录Usage就足够了。当上下文随用户变化时(如聊天历史、RAG 或文件上传),提前计算才有价值。
9. 总结
在本文中,你使用 JTokkit 在本地计算了 Token,包括每条聊天消息的开销。你使用 BigDecimal 基于配置中的价格表精确计算了成本。你还使用 max_tokens 在调用模型前阻止了最坏情况下的成本。
核心原则是:使用估算来做决定,使用实际数据来计费。通过在请求前使用 BudgetGuard 并在响应后记录 Usage,你将拥有足够的数据来按模型、按功能或按客户跟踪成本。