Một người dùng đã báo cáo rằng plugin dịch thuật của chúng tôi trả về cùng một kết quả cache cho mọi yêu cầu, bất kể đầu vào là gì. Chúng tôi đã điều tra và phát hiện ra một điều tồi tệ hơn: 95% tất cả các lượt semantic cache hit trên nền tảng của chúng tôi là dương tính giả (false positives). 199 yêu cầu dịch thuật khác nhau, 198 nội dung yêu cầu duy nhất, nhưng chỉ có một phản hồi được lưu trong cache được phục vụ cho tất cả chúng.
Nếu bạn quan tâm đến trạng thái agent lâu dài và xử lý yêu cầu trong môi trường production, bài viết này sẽ rất phù hợp khi đọc cùng với Tại sao AI Agent của bạn liên tục mất trí nhớ, hướng dẫn chatbot một mã khóa, và hướng dẫn rate limiting cho AI API.
Báo cáo lỗi
Báo cáo rất đơn giản: "Tôi đã tắt semantic cache, nhưng mọi bản dịch đều trả về cùng một kết quả."
Ba request ID, ba phân đoạn dịch thuật khác nhau, nhưng các phản hồi cache lại giống hệt nhau. Nội dung yêu cầu (request body) dao động từ 1.564 đến 8.676 bytes. ID phản hồi được lưu trong cache là giống nhau cho tất cả chúng: chatcmpl-DG6J03nhdvcF7Ek0C8rJkjh7lN9pF.
Nghi vấn đầu tiên: cài đặt cache của người dùng không được áp dụng. Hóa ra đó là một lỗi đồng bộ hóa nguồn dữ liệu riêng biệt (bảng điều khiển admin ghi vào một bảng, nhưng API gateway lại đọc từ bảng khác). Tuy nhiên, việc khắc phục lỗi đó chỉ giải quyết được một nửa vấn đề. Ngay cả khi cache được bật và hoạt động chính xác, semantic cache vẫn khớp với các yêu cầu lẽ ra không bao giờ được khớp.
Dữ liệu thực tế từ Production
Chúng tôi đã trích xuất dữ liệu cache hit trong 24 giờ từ ClickHouse. Các con số thật tệ.
| Model | Tổng số yêu cầu | Cache Hits | Yêu cầu duy nhất | Phản hồi duy nhất | Tỷ lệ Hit |
|---|---|---|---|---|---|
| gpt-4.1-nano | 200 | 199 | 198 | 1 | 99.5% |
| glm-4.6-thinking | 100 | 38 | 13 | 1 | 38% |
| gpt-5-nano | 31 | 29 | 28 | 2 | 93.5% |
| gpt-oss-120b | 18 | 17 | 17 | 1 | 94.4% |
| qwen3-vl-flash | 17 | 16 | 16 | 1 | 94.1% |
198 yêu cầu dịch thuật duy nhất, tất cả đều trả về cùng một phản hồi cache duy nhất. Đó không phải là cache. Đó là một hàm bị hỏng luôn trả về một hằng số.
Mọi model bị ảnh hưởng đều có hai đặc điểm chung: tất cả các yêu cầu đều đến từ một người dùng duy nhất và tất cả đều sử dụng một system prompt template cố định với nội dung người dùng thay đổi.
Tại sao Embeddings thất bại với đầu vào có cấu trúc
Plugin dịch thuật gửi các yêu cầu như thế này:
System: "Act as a translation API. Output a single raw JSON object only.
Input: {"targetLanguage":"<lang>","title":"...","segments":[...]}"
User: {"targetLanguage":"zh","title":"Product Page",
"description":"Translate product descriptions",
"tone":"formal",
"segments":[{"text":"actual varying content here"}]}
System prompt giống hệt nhau trong tất cả các yêu cầu. User message là một đối tượng JSON trong đó targetLanguage, title, description, và tone là cố định. Chỉ có segments[].text là thay đổi.
Khi semantic cache của chúng tôi trích xuất văn bản để tạo embedding, nó sẽ nối system prompt và user message lại với nhau. Template cố định chiếm khoảng 80% văn bản. Mô hình embedding (all-mpnet-base-v2, 768 dimensions) nén nội dung này thành một vector mà cấu trúc template chiếm ưu thế. Nội dung dịch thuật thực tế hầu như không làm thay đổi vector đó.
Kết quả: độ tương đồng cosine (cosine similarity) giữa "dịch 'Hello world'" và "dịch 'Báo cáo tài chính quý cho thấy doanh thu tăng 15%'" vượt quá 0.95. Ngưỡng của chúng tôi là 0.95. Mọi yêu cầu dịch thuật đều khớp với mục nhập cache đầu tiên.
Tìm hiểu kỹ hơn qua các bản log, chúng tôi tìm thấy ba cách mà vấn đề này xảy ra:
Plugin dịch thuật là trường hợp tệ nhất. Các khóa và giá trị JSON cố định làm lu mờ các phân đoạn dịch thuật thực tế. Cả gpt-4.1-nano và gpt-5-nano đều gặp phải lỗi này.
Một trợ lý tóm tắt ngữ cảnh cũng gặp vấn đề tương tự nhưng ở một khía cạnh khác. System prompt của nó quá dài khiến nội dung người dùng (dao động từ 5KB đến 47KB) hầu như không được ghi nhận trong embedding. Đó là lý do tại sao glm-4.6-thinking cuối cùng lại trả về cùng một bản tóm tắt cho mọi cuộc hội thoại.
Mô hình thứ ba tinh vi hơn. Đối với gpt-oss-120b và qwen3-vl-flash, 500 ký tự đầu tiên của mọi yêu cầu đều giống hệt nhau từng byte. Nội dung thay đổi nằm ở phía sau, nhưng embedding đã bị chi phối bởi phần tiền tố chung.
Nghiên cứu nói gì
Đây không phải là một vấn đề mới. Các bài báo nghiên cứu gần đây đã định lượng được nó.
Dự án vCache của UC Berkeley đã phát hiện ra rằng các lượt cache hit đúng và sai có "phân phối độ tương đồng chồng chéo cao". Ngưỡng tối ưu thay đổi từ 0.71 đến 1.0 trên các mục nhập cache khác nhau. Không có một con số duy nhất nào hoạt động hiệu quả. Giải pháp của họ: học một ngưỡng riêng cho mỗi mục nhập cache, giúp giảm tỷ lệ lỗi xuống 6 lần trong khi tăng gấp đôi tỷ lệ hit. (vCache, 2025)
Mọi thứ còn tệ hơn khi bạn trộn lẫn các loại truy vấn. Một nghiên cứu về caching nhận diện danh mục (category-aware caching) cho thấy ngưỡng 0.80 tạo ra 15% kết quả khớp sai trên các truy vấn mã nguồn (sort_ascending so với sort_descending), trong khi cùng ngưỡng đó lại bỏ lỡ các cách diễn đạt khác nhau (paraphrases) hợp lệ trong các truy vấn hội thoại. Một ngưỡng, hai kiểu thất bại. (Category-Aware Semantic Caching, 2025)
Các ngân hàng cũng gặp phải vấn đề này. Một nghiên cứu điển hình của InfoQ đã ghi lại một hệ thống RAG nơi câu hỏi "Tôi có thể bỏ qua khoản thanh toán khoản vay tháng này không" khớp với "Điều gì xảy ra nếu tôi lỡ một khoản thanh toán khoản vay" với độ tương đồng 88.7%. Ý định khác nhau, nhưng cùng một câu trả lời được lưu trong cache. Họ bắt đầu với tỷ lệ dương tính giả 99% và cần bốn vòng tối ưu hóa để giảm xuống còn 3.8%. (InfoQ Banking Case Study, 2025)
Vấn đề sâu xa hơn: embeddings đo lường xem hai prompt có tương đồng về mặt ngữ nghĩa hay không, chứ không phải liệu cùng một phản hồi có thể trả lời cho cả hai hay không. Khoảng cách đó chính là nơi các lượt cache hit sai tồn tại. (Efficient Prompt Caching via Embedding Similarity, 2024)
Mọi bài báo chúng tôi tìm thấy đều đồng ý ở một điểm: chỉ riêng độ tương đồng embedding là không đủ. Bạn cần một lớp xác minh.
Giải pháp hai lớp
Chúng tôi đã xây dựng hai lớp phòng thủ. Lớp đầu tiên loại bỏ nhiễu template trước khi tạo embedding. Lớp thứ hai xác minh các lượt hit sau khi khớp.
Lớp 2: Trích xuất nội dung cho Embeddings
Trước khi tạo embedding, giờ đây chúng tôi phát hiện đầu vào có cấu trúc (JSON) và chỉ trích xuất nội dung có ý nghĩa và có tính thay đổi.
Logic xử lý:
- Kiểm tra xem nội dung tin nhắn có bắt đầu bằng
{hoặc[hay không - Nếu nó có thể parse thành JSON, thu thập đệ quy tất cả các giá trị lá (leaf values) là chuỗi
- Lọc bỏ các giá trị ngắn (20 ký tự trở xuống) vì chúng thường là các trường cấu hình như
"zh","formal", hoặc"Product Page" - Nếu văn bản trích xuất quá ngắn hoặc trống, quay lại sử dụng văn bản gốc
function extractContentForEmbedding(text: string): string {
const extracted = tryExtractJsonContent(text);
return extracted && extracted.length > 20 ? extracted : text;
}
Điều này áp dụng cho cả system prompt và user message. Đối với plugin dịch thuật, embedding giờ đây đại diện cho "Hello world" thay vì một khối JSON 2KB. Đối với trợ lý tóm tắt, nó sẽ kéo cuộc hội thoại thực tế ra khỏi vỏ bọc template.
Ngưỡng 20 ký tự được chọn dựa trên thực nghiệm:
"zh"(2 ký tự): bị lọc. Giá trị cấu hình."formal"(6 ký tự): bị lọc. Giá trị cấu hình."Product Page"(12 ký tự): bị lọc. Trường template."Translate product descriptions"(31 ký tự): được giữ lại. Nội dung có ý nghĩa."The quarterly financial report..."(40+ ký tự): được giữ lại. Nội dung dịch thuật thực tế.
Lớp 3: Xác minh Fingerprint
Sau khi có một lượt semantic cache hit, chúng tôi so sánh mã hash của văn bản đã trích xuất của yêu cầu hiện tại với mã hash được lưu trữ trong mục nhập cache. Nếu chúng không khớp, lượt hit đó sẽ bị từ chối.
// Khi ghi vào cache
entry.metadata.textHash = fnv1aHash(extractedText);
// Khi đọc từ cache, sau khi tìm thấy một kết quả khớp tương đồng
if (entry.metadata.textHash !== undefined) {
if (entry.metadata.textHash !== fnv1aHash(currentExtractedText)) {
// Dương tính giả: tương đồng về ngữ nghĩa nhưng nội dung khác nhau
metrics.recordFingerprintRejection();
return null;
}
}
Mã hash sử dụng văn bản đã trích xuất (sau Lớp 2), không phải đầu vào thô. Hai yêu cầu với các vỏ bọc template khác nhau nhưng nội dung thực tế giống hệt nhau vẫn sẽ khớp. Nội dung khác nhau, mã hash khác nhau, sẽ bị từ chối.
Các mục nhập cache cũ không có textHash sẽ bỏ qua bước xác minh (để tương thích ngược). Chúng sẽ tự động hết hạn thông qua TTL.
Chúng tôi sử dụng FNV-1a (32-bit) cho mã hash. Nhanh, có tính xác định và tỷ lệ xung đột ~1 trên 4 tỷ là đủ tốt để kiểm tra một lượt cache hit duy nhất.
Tại sao không chỉ đơn giản là nâng ngưỡng?
Ngưỡng của chúng tôi đã là 0.95. Việc nâng nó lên không giúp ích gì. Vấn đề là các đầu vào có cấu trúc tương tự nhau tạo ra điểm tương đồng trên 0.95 bất kể nội dung thực tế nói gì.
Dữ liệu của vCache đã chứng minh điều này: phân phối độ tương đồng của các lượt hit đúng và sai chồng chéo lên nhau đến mức không có một điểm cắt duy nhất nào có thể tách biệt chúng. Nếu đẩy ngưỡng lên 0.99, bạn sẽ giết chết các lượt cache hit hợp lệ cho các cách diễn đạt khác nhau mà không loại bỏ được các dương tính giả từ các yêu cầu nặng về template.
Hãy sửa đầu vào, xác minh đầu ra. Đừng loay hoay với ngưỡng.
Kết quả
Sau khi triển khai cả hai lớp:
| Chỉ số | Trước đây | Sau này |
|---|---|---|
| gpt-4.1-nano dương tính giả | 198/199 | 0 |
| Tỷ lệ dương tính giả trong tổng số cache hits | ~95% | <5% |
| Tỷ lệ cache hit hợp lệ | Không đổi | Không đổi |
| Độ trễ tăng thêm mỗi yêu cầu | 0 | <1ms (JSON parse + FNV hash) |
Chỉ riêng Lớp 2 đã có thể khắc phục được plugin dịch thuật. Lớp 3 là lưới an toàn cho các trường hợp trích xuất JSON không tách biệt hoàn toàn được nội dung, hoặc cho các đầu vào có cấu trúc nhưng không phải là JSON.
Bài học rút ra
Nếu bạn đang vận hành một semantic cache trong môi trường production:
Theo dõi sự đa dạng của phản hồi. Nếu một model có tỷ lệ cache hit 100% và chỉ có 1 phản hồi duy nhất, bạn đang gặp vấn đề về dương tính giả. Truy vấn:
SELECT model, uniqExact(substring(response_body, 1, 200)) as unique_responses, count() as total FROM request_logs WHERE cache_hit = true GROUP BY model.Đầu vào có cấu trúc làm hỏng embedding thô. Bất kỳ yêu cầu nào có template cố định (JSON API, system prompt wrapper, các tác vụ điền biểu mẫu) đều sẽ tạo ra điểm tương đồng cao một cách giả tạo. Hãy tiền xử lý trước khi tạo embedding.
Lớp xác minh là bắt buộc. Mọi semantic cache trong môi trường production được đề cập trong các tài liệu nghiên cứu đều có một lớp này. Câu hỏi là bạn sử dụng kiểm tra mã hash nhẹ nhàng, một cross-encoder reranker, hay một cuộc gọi xác minh LLM đầy đủ. Hãy chọn dựa trên ngân sách độ trễ của bạn.
Ngưỡng toàn cục là một sự đánh đổi, không phải là một giải pháp. Các loại truy vấn khác nhau cần các ngưỡng khác nhau. Nếu bạn không thể thực hiện ngưỡng theo từng danh mục hoặc từng mục nhập, ít nhất hãy thêm tiền xử lý đầu vào để chuẩn hóa chất lượng embedding giữa các danh mục.
Semantic caching có thể cắt giảm 30-70% chi phí LLM API. Nhưng nếu không có tiền xử lý đầu vào và xác minh lượt hit, bạn đang cung cấp những câu trả lời cũ kỹ và gọi đó là một thắng lợi về hiệu suất.
LemonData cung cấp quyền truy cập hợp nhất vào hơn 300 mô hình AI với các tính năng tích hợp sẵn như caching, routing và tối ưu hóa chi phí. Dùng thử miễn phí với 1$ tín dụng.
