Đếm token và ước tính chi phí khi gọi LLM trong Java
1. Tổng quan
Mỗi lần gọi một LLM như GPT-4o, bạn trả tiền theo token, không theo số ký tự hay số request. Nếu không đo, hóa đơn cuối tháng có thể khiến bạn bất ngờ. Đặc biệt là khi prompt chứa lịch sử hội thoại dài hoặc tài liệu RAG.
Trong bài này, bạn sẽ đếm token ngay trong JVM bằng JTokkit, tính chi phí bằng BigDecimal và chặn request vượt ngân sách. Cuối cùng, bạn sẽ đối chiếu ước tính với số token thực tế mà Spring AI trả về.
2. Token là gì và vì sao phải đếm
Token là đơn vị mà tokenizer của model cắt văn bản ra. Với tiếng Anh, một token trung bình khoảng 4 ký tự. Tiếng Việt có dấu thường tốn nhiều token hơn cho cùng một ý nghĩa, vì các ký tự như “ệ”, “ữ” ít xuất hiện trong dữ liệu huấn luyện tokenizer.
Chi phí một request gồm hai phần, tính giá khác nhau:
- Input tokens (prompt tokens): system prompt, lịch sử chat, câu hỏi, tài liệu đính kèm.
- Output tokens (completion tokens): phần model sinh ra. Đơn giá phần này thường cao hơn input nhiều lần.
Có hai thời điểm cần đếm:
- Trước khi gọi: để ước tính, cắt bớt context, hoặc từ chối request quá đắt.
- Sau khi gọi: để ghi nhận chi phí thật, dựa trên số liệu provider trả về.
3. Đếm token cục bộ với JTokkit
JTokkit là thư viện Java thuần, cài đặt các bảng mã BPE giống tiktoken của OpenAI. Thư viện không cần gọi mạng.
<dependency>
<groupId>com.knuddels</groupId>
<artifactId>jtokkit</artifactId>
<version>1.1.0</version>
</dependency>
Đếm token cho một chuỗi:
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 vietnamese = "Xin chào, thế giới! Đơn hàng của bạn đã được xác nhận.";
System.out.println(english + " -> " + encoding.countTokens(english));
System.out.println(vietnamese + " -> " + encoding.countTokens(vietnamese));
}
}
Giải thích từng phần:
Encodings.newDefaultEncodingRegistry()nạp sẵn các bảng mã. Việc nạp này tốn thời gian và bộ nhớ, nên chỉ nên tạo một lần rồi tái sử dụng.O200K_BASElà encoding của họ GPT-4o. Các model cũ hơn như GPT-4 và GPT-3.5 dùngCL100K_BASE.countTokenschỉ trả về số lượng. Nó nhanh hơnencodevì không cần giữ danh sách token.
Kết quả với câu tiếng Anh như sau. Con số của câu tiếng Việt phụ thuộc encoding, nhưng sẽ cao hơn đáng kể so với số từ.
Hello, world! -> 4
Xin chào, thế giới! Đơn hàng của bạn đã được xác nhận. -> (khoảng 15–20)
Nếu muốn chọn encoding theo tên model, bạn dùng registry.getEncodingForModel(ModelType.GPT_4O).
Lưu ý: JTokkit chỉ chính xác với tokenizer của OpenAI. Claude, Gemini hay Llama dùng tokenizer khác. Với các model đó, bạn chỉ dùng JTokkit để ước tính thô, và nên cộng thêm biên an toàn khoảng 10–20%.
3.1. Đếm token cho một cuộc hội thoại
Chat API không gửi một chuỗi trơn. Nó gửi danh sách message có role, và mỗi message có vài token định dạng đi kèm. Theo cách OpenAI mô tả cho các model chat, mỗi message tốn thêm khoảng 3 token. Câu trả lời cũng được “mồi” thêm 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;
}
}
Đây là con số xấp xỉ. Nếu request có tool/function definitions hoặc hình ảnh, chúng cũng bị tính vào input. Phần đó JTokkit không đếm được trực tiếp. Vì vậy, bạn luôn cần đối chiếu với số liệu thật (mục 6).
4. Cấu hình bảng giá và tính chi phí
Giá model thay đổi thường xuyên, nên đừng hard-code giá trong code. Bạn hãy đưa giá ra file cấu hình. Các con số dưới đây chỉ là ví dụ, tính theo USD cho 1 triệu 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
Bind cấu hình vào 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("Chưa cấu hình giá cho model: " + model);
}
return price;
}
}
Bạn nhớ bật binding bằng @EnableConfigurationProperties(PricingProperties.class) hoặc @ConfigurationPropertiesScan trên lớp @SpringBootApplication. Nếu tên model chứa dấu chấm (ví dụ gpt-4.1), hãy viết key trong YAML là "[gpt-4.1]". Nếu không, Spring sẽ hiểu dấu chấm là cấp lồng nhau.
Bây giờ là service tính chi phí:
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);
}
}
Một vài điểm đáng chú ý:
- Dùng
BigDecimalthay vìdouble. Một request tốn vài phần nghìn USD, và cộng dồn hàng triệu request bằngdoublesẽ sinh sai số. - Nhân trước, chia sau, để giữ độ chính xác.
- Làm tròn tới 8 chữ số thập phân. Như vậy đủ nhỏ để không mất các request rẻ.
Ví dụ với gpt-4o, 1.200 input token và 300 output token:
input : 1200 × 2.50 / 1_000_000 = 0.003
output: 300 × 10.00 / 1_000_000 = 0.003
total : 0.00600000 USD
Bạn thấy output chỉ bằng 1/4 số token input nhưng tốn ngang tiền. Đó là lý do bạn nên giới hạn độ dài câu trả lời.
5. Chặn request vượt ngân sách trước khi gọi
Trước khi gọi, bạn chưa biết model sẽ trả lời dài bao nhiêu. Nhưng bạn biết giới hạn trên: tham số max_tokens. Chi phí xấu nhất bằng input đã đếm cộng với max_tokens output.
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(
"Request ước tính %s USD, vượt ngân sách %s USD"
.formatted(worstCase.toPlainString(), limitUsd.toPlainString()));
}
return worstCase;
}
}
ChatTokenCounter cần được khai báo thành bean, kèm một Encoding dùng chung:
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);
}
}
Bạn có thể dùng BudgetGuard theo nhiều cách. Bạn có thể từ chối request. Bạn cũng có thể cắt bớt lịch sử chat cũ nhất cho tới khi đủ ngân sách. Hoặc bạn chuyển sang model rẻ hơn như gpt-4o-mini.
6. Lấy số token thực tế từ Spring AI
Ước tính chỉ để ra quyết định. Còn để tính tiền thật, bạn hãy dùng số liệu provider trả về. Thêm 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
Gọi model và đọc 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();
}
}
Đoạn log in ra sẽ có dạng:
model=gpt-4o-2024-08-06 prompt=412 completion=187 cost=0.00290000 USD
Bạn chú ý getModel() trả về tên có kèm phiên bản, ví dụ gpt-4o-2024-08-06. Vì vậy ví dụ dùng key cố định "gpt-4o" để tra giá. Trong thực tế, bạn nên chuẩn hóa tên model trước khi tra bảng giá.
Khi so số prompt thật với số bạn đếm ở mục 3.1, chênh lệch vài token là bình thường. Nếu chênh nhiều, thường là do system prompt mặc định, tool definitions hoặc advisor (ví dụ memory, RAG) đã chèn thêm nội dung mà bạn không đếm.
7. Kiểm thử CostEstimator
Logic tính tiền là chỗ không được phép sai. Bạn nên viết test độc lập, không cần 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 givenVietnameseText_whenCount_thenUsesMoreTokensThanWords() {
var encoding = Encodings.newDefaultEncodingRegistry()
.getEncoding(EncodingType.O200K_BASE);
var counter = new ChatTokenCounter(encoding);
String content = "Đơn hàng của bạn đã được xác nhận";
int tokens = counter.count(List.of(new ChatMessage("user", content)));
assertThat(tokens).isGreaterThan(content.split(" ").length);
}
}
Test đầu dùng isEqualByComparingTo thay vì isEqualTo. Lý do là 0.00600000 và 0.006 có scale khác nhau, nên equals của BigDecimal sẽ trả về false.
8. Lỗi hay gặp
- Tạo
EncodingRegistrytrong mỗi request. Việc nạp bảng mã khá nặng. Bạn hãy tạo một lần và dùng lại, vìEncodinglà thread-safe. - Dùng sai encoding. Nếu đếm GPT-4o bằng
CL100K_BASE, bạn sẽ ra con số lệch. Hãy chọn encoding theo model. - Chỉ tính input. Output thường đắt gấp 4 lần input hoặc hơn. Bỏ qua phần này là sai lệch lớn nhất.
- Quên token “ẩn”. Reasoning model (họ o-series) sinh reasoning tokens. Các token này không hiện trong câu trả lời nhưng vẫn tính tiền như output. Ngược lại, cached input tokens thường được giảm giá. Hãy đọc
getNativeUsage()nếu cần chi tiết. - Dùng
doublecho tiền. Sai số cộng dồn sẽ làm báo cáo chi phí lệch so với hóa đơn. - Tin tuyệt đối vào ước tính. Ước tính dùng để chặn và cảnh báo. Ghi nhận chi phí thì phải dựa trên
Usagethật.
Lưu ý: Bạn không cần đếm token trước cho mọi request. Nếu prompt ngắn, cố định và đã có
max_tokens, chi phí đã bị chặn trên. Lúc đó, chỉ cần logUsagesau khi gọi là đủ. Đếm trước đáng giá khi context thay đổi theo người dùng, như lịch sử chat, RAG hay file upload.
9. Kết luận
Trong bài này, bạn đã đếm token cục bộ bằng JTokkit, kể cả overhead của từng message chat. Bạn đã tính chi phí chính xác bằng BigDecimal với bảng giá đặt trong cấu hình. Bạn cũng đã dùng max_tokens để chặn chi phí xấu nhất trước khi gọi model.
Nguyên tắc cốt lõi là: ước tính để ra quyết định, số liệu thật để tính tiền. Khi kết hợp BudgetGuard trước request với việc log Usage sau response, bạn sẽ có đủ dữ liệu để theo dõi chi phí theo model, theo tính năng hoặc theo từng khách hàng.