Tối ưu chi phí gọi LLM: prompt caching, model nhỏ và batch
1. Tổng quan
Khi một tính năng dùng LLM đi từ bản demo lên production, chi phí thường trở thành vấn đề đầu tiên cần giải quyết. Hóa đơn tăng theo số token, và token tăng theo số người dùng, độ dài prompt và số vòng gọi của agent.
Bài này trình bày ba kỹ thuật phổ biến để giảm chi phí: prompt caching, định tuyến sang model nhỏ và xử lý batch. Mỗi kỹ thuật có ví dụ Java 21 / Spring Boot 3.x. Đọc xong, bạn sẽ biết cách tổ chức prompt để tận dụng cache, cách chọn model theo loại tác vụ, và nên dùng batch ở đâu. Bài cũng chỉ ra khi nào không nên áp dụng từng kỹ thuật.
2. Chi phí LLM đến từ đâu
Hầu hết các provider tính tiền theo token và chia làm ba nhóm:
- Input token: system prompt, lịch sử hội thoại, tài liệu RAG, định nghĩa tool.
- Cached input token: phần input đọc lại từ cache, thường rẻ hơn input thường.
- Output token: nội dung model sinh ra, thường đắt hơn input.
Trong nhiều ứng dụng, input chiếm phần lớn chi phí. Lý do là mỗi request đều gửi lại cùng một system prompt dài, cùng một bộ tool, cùng một đoạn tài liệu. Ta mô hình hóa chi phí như sau:
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) {
// Giá minh hoạ theo đơn vị giả định, KHÔNG phải giá thật của provider nào
var pricing = new ModelPricing(10, 1, 30);
System.out.printf("Không cache: %.3f%n", pricing.cost(new TokenUsage(20_000, 0, 500)));
System.out.printf("Có cache: %.3f%n", pricing.cost(new TokenUsage(20_000, 18_000, 500)));
}
}
TokenUsage chứa số token mà provider trả về trong mỗi response. cost() tách phần input thường và phần input lấy từ cache, rồi nhân với đơn giá tương ứng. Với bộ giá giả định ở trên, kết quả là:
Không cache: 0.215
Có cache: 0.053
Các con số ở đây chỉ để minh họa. Điều cần rút ra là cấu trúc: khi phần lớn prompt lặp lại giữa các request, cache tác động trực tiếp lên khoản chi lớn nhất. Bạn nên đưa bộ giá thật của provider vào cấu hình và tính chi phí cho từng request trong log.
3. Prompt caching
3.1. Khái niệm
Prompt caching là cơ chế phía provider. Lần đầu xử lý một prompt, provider lưu lại trạng thái đã tính cho phần đầu (prefix) của prompt đó. Những request sau có prefix giống hệt từng ký tự sẽ đọc lại trạng thái này, nên rẻ hơn và thường cũng nhanh hơn.
Ba đặc điểm bạn cần nhớ:
- Cache khớp theo prefix. Chỉ cần một ký tự khác ở đầu prompt là toàn bộ phần phía sau không dùng được cache.
- Cache có thời gian sống ngắn và thường yêu cầu prefix đủ dài. Ngưỡng cụ thể tùy provider.
- Có provider tự động cache. Có provider bắt bạn đánh dấu điểm cắt cache trong request.
3.2. Sắp xếp prompt: phần tĩnh trước, phần động sau
Nguyên tắc quan trọng nhất là đặt nội dung ổn định lên đầu và nội dung thay đổi xuống cuối:
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), // tĩnh
new ChatMessage("system", "Danh mục sản phẩm:\n" + productCatalog), // tĩnh
new ChatMessage("user", "Hôm nay: " + today + "\nCâu hỏi: " + customerQuestion)); // động
}
}
Hướng dẫn và danh mục sản phẩm không đổi giữa các khách hàng, nên chúng nằm đầu danh sách. Ngày hiện tại và câu hỏi thay đổi theo từng request, nên chúng nằm ở message cuối.
Lỗi hay gặp là viết "Bạn là trợ lý. Bây giờ là " + LocalDateTime.now() ngay dòng đầu của system prompt. Timestamp thay đổi ở mỗi request, nên cache không bao giờ trúng. Một số lỗi tương tự khó phát hiện hơn:
- Serialize định nghĩa tool từ một
HashMap, khiến thứ tự key không ổn định. - Chèn tên người dùng hoặc request ID vào system prompt.
- Sắp xếp tài liệu RAG theo thứ tự ngẫu nhiên giữa các lần gọi.
Bạn có thể viết test để giữ prefix ổn định khi code thay đổi:
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("Bạn là nhân viên hỗ trợ của cửa hàng.", "Áo thun, Quần jean, Giày");
@Test
void prefixIsIdenticalAcrossDifferentRequests() {
var first = builder.build("Còn size M không?", LocalDate.of(2024, 1, 1));
var second = builder.build("Phí ship bao nhiêu?", LocalDate.of(2024, 1, 2));
assertThat(first.subList(0, 2)).isEqualTo(second.subList(0, 2));
assertThat(first.get(2)).isNotEqualTo(second.get(2));
}
}
Test so sánh hai message đầu của hai request khác nhau. Nếu sau này có ai đó chèn dữ liệu động vào phần tĩnh, test sẽ báo lỗi ngay.
3.3. Đánh dấu điểm cache tường minh
Với provider yêu cầu đánh dấu, request thường có một trường chỉ rõ “cache đến đây”. Ví dụ, body JSON có thể trông như sau. Tên trường và vị trí đặt tùy vào API của từng provider:
{
"model": "your-model",
"system": [
{ "type": "text", "text": "Bạn là nhân viên hỗ trợ..." },
{ "type": "text", "text": "Danh mục sản phẩm: ...", "cache_control": { "type": "ephemeral" } }
],
"messages": [ { "role": "user", "content": "Còn size M không?" } ]
}
Điểm đánh dấu đặt ở cuối khối tĩnh cuối cùng. Mọi nội dung trước điểm đó là ứng viên được cache.
3.4. Đo tỉ lệ cache hit
Response thường trả về số token đọc từ cache. Bạn nên ghi lại con số này, vì không có số liệu thì bạn không biết cache có hoạt động hay không:
public static double cacheHitRatio(TokenUsage u) {
return u.inputTokens() == 0 ? 0 : (double) u.cachedInputTokens() / u.inputTokens();
}
Hãy đẩy tỉ lệ này lên Micrometer hoặc log dưới dạng metric. Khi tỉ lệ đột ngột về 0 sau một lần deploy, gần như chắc chắn ai đó đã làm thay đổi prefix.
4. Cache câu trả lời ở phía ứng dụng
Prompt caching chỉ giảm giá phần input. Nếu cùng một câu hỏi được hỏi lặp lại, bạn có thể bỏ qua hẳn lời gọi LLM. Thêm các dependency sau:
<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", "Trả lời ngắn gọn câu hỏi FAQ: " + question);
}
public String normalize(String question) {
return question.strip().toLowerCase(Locale.ROOT).replaceAll("\\s+", " ");
}
}
Bạn cần thêm @EnableCaching vào một lớp @Configuration. Key của cache là câu hỏi đã được chuẩn hóa: bỏ khoảng trắng thừa và đưa về chữ thường. Nhờ vậy, “Phí ship?” và “ phí SHIP? “ dùng chung một kết quả.
Lưu ý:
@Cacheablehoạt động qua proxy. Nếu một method khác trong cùng class gọianswer()trực tiếp, cache sẽ bị bỏ qua. Đừng cache câu trả lời có dữ liệu cá nhân hóa, chẳng hạn trạng thái đơn hàng của một khách cụ thể, bằng key không chứa định danh người dùng.
5. Định tuyến sang model nhỏ
Nhiều tác vụ không cần model mạnh nhất: phân loại ticket, trích xuất trường từ email, tóm tắt ngắn, kiểm tra định dạng. Model nhỏ thường rẻ hơn và nhanh hơn đáng kể. Đây là lý do “model routing” đang được nhiều đội quan tâm.
Cách làm thực tế là cascade: gọi model nhỏ trước, kiểm tra đầu ra, và chỉ chuyển lên model lớn khi đầu ra không hợp lệ.
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 = "Phân loại ticket vào một nhãn " + LABELS + ". Chỉ trả về nhãn.\nTicket: " + ticket;
String label = llm.complete(smallModel, prompt).strip();
if (LABELS.contains(label)) {
return label;
}
return llm.complete(largeModel, prompt).strip();
}
}
Model nhỏ xử lý trước. Nếu nó trả về đúng một nhãn trong tập cho phép, ta dùng luôn kết quả đó. Nếu không, ví dụ model trả lời lan man, ta gọi lại bằng model lớn. Bước kiểm tra phải mang tính xác định: parse JSON, so khớp enum, validate schema. Đừng hỏi model nhỏ xem nó có “tự tin” không.
Test bằng một 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") ? "Có lẽ là thanh toán?" : "BILLING";
};
var classifier = new TicketClassifier(fake, "small-model", "large-model");
assertThat(classifier.classify("Tôi bị trừ tiền hai lần")).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("Hàng chưa tới");
assertThat(calledModels).containsExactly("small-model");
}
}
Bạn nên đưa tên model vào cấu hình thay vì hard-code. Như vậy bạn có thể đổi model mà không cần build lại:
llm:
models:
small: small-model
large: large-model
Bạn cũng nên theo dõi tỉ lệ escalate. Nếu phần lớn request vẫn phải lên model lớn, cascade đang làm bạn tốn thêm tiền vì mỗi request bị gọi hai lần.
6. Batch
6.1. Gộp nhiều item vào một request
Khi cần phân tích nhiều mục ngắn, ví dụ 50 review sản phẩm, bạn có thể gộp chúng vào một prompt. Phần hướng dẫn chỉ gửi một lần thay vì 50 lần:
public record Review(String id, String text) {}
public static String buildSentimentPrompt(List<Review> reviews) {
var sb = new StringBuilder("""
Với mỗi review, trả về một dòng JSON: {"id": "...", "sentiment": "POSITIVE|NEGATIVE|NEUTRAL"}
""");
reviews.forEach(r -> sb.append(r.id()).append(": ").append(r.text()).append('\n'));
return sb.toString();
}
Bạn nên giữ mỗi nhóm ở kích thước vừa phải. Nhóm quá lớn làm model dễ bỏ sót item. Ngoài ra, một lỗi parse sẽ làm hỏng cả nhóm. Hãy luôn kiểm tra rằng mọi id đầu vào đều có trong đầu ra, và retry riêng các item bị thiếu.
6.2. Batch API bất đồng bộ
Nhiều provider cung cấp Batch API. Bạn gửi một file chứa nhiều request, và provider xử lý trong một khoảng thời gian dài hơn, đổi lại giá thường thấp hơn gọi đồng bộ. Định dạng phổ biến là JSONL, mỗi dòng một request và có custom_id để ghép kết quả. Cấu trúc cụ thể tùy provider:
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", "Phân loại cảm xúc: " + r.text())))))
.map(line -> {
try {
return mapper.writeValueAsString(line);
} catch (Exception e) {
throw new IllegalStateException(e);
}
})
.collect(Collectors.joining("\n"));
}
Mỗi review thành một dòng JSON độc lập. custom_id giúp bạn biết kết quả nào thuộc về review nào, vì thứ tự trả về không được đảm bảo. Đầu ra trông như sau:
{"custom_id":"review-1","body":{"model":"small-model","messages":[{"role":"user","content":"Phân loại cảm xúc: Giao hàng nhanh"}]}}
{"custom_id":"review-2","body":{"model":"small-model","messages":[{"role":"user","content":"Phân loại cảm xúc: Áo bị rách"}]}}
Thứ tự key trong JSON có thể khác vì Map.of không giữ thứ tự. Điều này không ảnh hưởng đến Batch API. Quy trình thường gồm bốn bước: một job @Scheduled ban đêm tạo file, upload và tạo batch, lưu batch ID vào database, rồi một job khác định kỳ kiểm tra trạng thái và tải kết quả về.
7. Khi nào không nên dùng
- Prompt caching gần như không có lợi khi prompt ngắn, khi prefix thay đổi theo từng người dùng, hoặc khi lưu lượng thưa đến mức cache hết hạn trước request tiếp theo. Với provider tính thêm phí khi ghi cache, việc đánh dấu cache cho nội dung không bao giờ được đọc lại sẽ làm tăng chi phí.
- Response cache không phù hợp khi câu trả lời phụ thuộc dữ liệu thay đổi liên tục như tồn kho hay giá, khi cần cá nhân hóa, hoặc khi người dùng mong đợi câu trả lời đa dạng.
- Model nhỏ không phù hợp với tác vụ cần suy luận nhiều bước, viết hoặc review code phức tạp, hay các quyết định có hậu quả lớn (pháp lý, y tế, tài chính) khi bạn không có cách kiểm tra đầu ra một cách xác định. Đừng đổi model chỉ dựa vào cảm giác. Hãy chạy trên một bộ dữ liệu đánh giá trước.
- Batch không dùng được cho mọi luồng cần phản hồi tức thì như chat hay autocomplete. Gộp nhiều item cũng không phù hợp khi các item cần ngữ cảnh riêng dài, hoặc khi một item lỗi không được phép ảnh hưởng đến item khác.
Lưu ý: Hãy đo trước khi tối ưu. Ghi lại
TokenUsage, model đã dùng và tính năng gọi tới cho từng request. Thường chỉ một vài endpoint chiếm phần lớn chi phí, và đó là nơi nên bắt đầu.
8. Kết luận
Chi phí LLM chủ yếu đến từ token lặp lại và từ việc dùng model mạnh hơn mức cần thiết. Mỗi kỹ thuật trong bài nhắm vào một nguồn lãng phí:
- Prompt caching: đặt phần tĩnh lên đầu, giữ prefix ổn định và đo tỉ lệ cache hit.
- Response cache: bỏ qua lời gọi LLM cho câu hỏi lặp lại.
- Cascade model nhỏ → lớn: kiểm tra đầu ra một cách xác định và chỉ escalate khi cần.
- Batch: gộp item để chia sẻ phần hướng dẫn, và dùng Batch API cho công việc không cần kết quả ngay.
Các kỹ thuật này kết hợp được với nhau. Ví dụ, một job phân loại ban đêm có thể dùng model nhỏ, gửi qua Batch API, với prompt có prefix ổn định. Điều quan trọng là bạn đo được chi phí trên từng request. Có số liệu, bạn mới biết thay đổi nào thực sự hiệu quả.