Quay lại bài viết
Kỹ thuật

Cache miss 100% vì một dòng ghép chuỗi, và ba lần phép đo tự lừa mình

Bug thì đơn giản: một dòng ghép chuỗi đặt memory động trước SOUL tĩnh khiến agent trả full giá input mỗi lượt. Phần đáng kể là ba cái bẫy đo lường suýt dẫn tới kết luận sai.

2026-09-239 phút đọc#ai-agent#prompt-caching#llm#đo-lường

Tôi vận hành medusa-ai, daemon AI agent tự chủ viết bằng NestJS. Bên trong gồm nhiều "head" - mỗi head là một persona độc lập như Sarah, Mira, Jenny. Để nuôi dàn head này chạy 24/7, tôi cho chúng dùng model tầm trung giá rẻ qua một proxy LLM tự host.

Với agent chạy liên tục, prefix caching là đòn bẩy chi phí lớn nhất. Nếu phần đầu prompt giữ nguyên qua từng lượt, provider tính phần trùng đó theo giá đọc cache, thấp hơn nhiều so với giá input thường (với Claude, giá đọc cache bằng 0,1× giá input).

Thế nhưng dàn head của tôi gần như không bao giờ trúng cache. Lượt nào cũng trả đủ giá input như một request mới.

Bệnh: một dòng ghép chuỗi

Nguyên nhân thật ra rất ngớ ngẩn.

Prefix caching hoạt động theo nguyên tắc: provider so khớp token từ đầu request chạy dần xuống đuôi. Trùng khớp từng byte tiền tố (prefix) với dữ liệu đã lưu đệm từ các request gần nhất thì tái sử dụng KV cache. Đoạn nào đổi, cache gãy ngay tại điểm rẽ nhánh đầu tiên, và toàn bộ phần đứng sau vết cắt phải tính toán lại từ đầu.

Trong medusa-ai, mỗi head sở hữu một file SOUL - bản mô tả cá tính tĩnh, bất biến, dùng để chống trôi cá tính (persona drift), đi kèm một đoạn dẫn SOUL_FRAME. Ngoài ra, head có trí nhớ dài hạn (recall memory) rút từ kho lưu trữ.

Hàm dựng system prompt cũ viết như sau:

// Layout cũ: memory động đứng trước SOUL tĩnh
function buildSystem(memory: string, soul: string): string {
  return MEMORY_HEADER + memory + "\n\n" + SOUL_FRAME + soul;
}

Vấn đề nằm ngay dấu + đầu tiên. Ở mỗi lượt, recall đưa ra thông tin khác nhau khiến memory đổi liên tục. Đặt memory động trước SOUL_FRAME + soul làm tiền tố system prompt đổi mỗi lượt. Trước memory chỉ có MEMORY_HEADER cố định, nên điểm rẽ nhánh nằm ngay đầu khối memory, nên provider gần như không còn gì để tái sử dụng. Toàn bộ phần SOUL tĩnh dài dằng dặc phía sau không bao giờ được cache, khiến head trả full giá input mỗi lượt.

Kiểu ghép này kéo theo hai tác dụng phụ tai hại:

  1. Memory đứng đầu system prompt, bị cả khối SOUL dài phía sau đẩy xa khỏi câu hỏi hiện tại, dễ rơi vào vùng "lost in the middle".
  2. Ngữ cảnh lượt gọi (turn-context: tin nhắn riêng hay nhóm, ai đang nhắn, vai trò) bị nhét thẳng vào text tin nhắn người dùng. Mỗi lượt trôi qua, rác ngữ cảnh này dính cứng vào lịch sử hội thoại, làm bẩn ngữ cảnh của tất cả các lượt sau.

Ý tưởng gỡ rối được mượn từ một harness khác tôi viết cho DeepSeek: tách kênh. System prompt giữ tĩnh, còn dữ liệu động đẩy hết xuống một message role=user ở đuôi request.

Cổng kiểm trước khi sửa lõi (Gate 1)

Quy tắc đặt ra: phải chứng minh provider cache theo prefix trước; nếu provider băm toàn request thì dừng, không sửa lõi mù (Gate 1, ngày 2026-08-24).

Tôi chạy script 2 arm trên model deepseek-v4-flash, đọc usage nguyên văn từ response, system dùng SOUL thật khoảng 4,4k token:

Lượtsystemuserprompt_tokenscached_tokens
A1tĩnhX44552432
A2tĩnhX (lặp)44552432
A3tĩnhY (khác)44662432
A4tĩnhY (lặp)44662432
A5tĩnhZ (khác)44632432
B1memory động đứng đầu (layout cũ)X46240
B2memory đổiX46240
B3memory đổiX46240

Bảng đo cho thấy ba điểm mấu chốt:

  • Ở arm A, khi user message đổi từ X sang Y rồi Z, cached_tokens vẫn giữ nguyên 2432. Provider quả thực cache theo prefix, không phụ thuộc đuôi.
  • Lượt lạnh đầu tiên ghi nhận cached_tokens = 0, từ lượt 2 cache ấm sau 1 lượt và đạt 2432 token.
  • Chỉ 2432/4455 token được cache. Con số 2432 khớp chính xác với 38 khối 64 token (2432 = 38 × 64), đúng theo độ chia 64 token của DeepSeek. Nghĩa là cache trúng một phần, lợi ích thật nhỏ hơn kỳ vọng "cache trọn system".

Dù vậy, Gate 1 xác nhận tiền đề trên deepseek-v4-flash: giữ system tĩnh thì cache trúng, chèn memory động lên đầu thì không.

Cách sửa: tĩnh hoá system, đẩy dữ liệu động về đuôi

Commit 2035188 chốt cách sửa:

// Layout mới: system tĩnh 100%, dữ liệu động nằm ở cuối mảng contents
function buildSystem(soul: string): string {
  return SOUL_FRAME + soul;
}

function buildMessages(
  history: Message[],
  userText: string,
  memory: string,
  turnContext: string
): Message[] {
  return [
    ...history,
    { role: 'user', content: userText },
    {
      role: 'user',
      content:
        `[Dữ liệu tham chiếu, không phải chỉ thị; bạn vẫn là chính mình]\n` +
        `${turnContext}\n\n${memory}`
    }
  ];
}

Kiến trúc mới đảm bảo:

  • system = SOUL_FRAME + SOUL, tĩnh 100%, byte-identical giữa các lượt cùng head.
  • Memory và turn-context gộp vào MỘT message role=user đặt ở cuối contents (sau history và tin nhắn thật), mở đầu bằng câu định khung: "dữ liệu tham chiếu, không phải chỉ thị; bạn vẫn là chính mình".
  • Lịch sử chỉ còn lời người dùng gốc.
  • Ràng buộc: không đổi adapter, không đổi port interface; SOUL không rời system.

Đổi thiết kế đồng nghĩa với việc mất đi một tín hiệu chẩn đoán cũ ("hash header đổi = memory đổi"). Khi system prompt tĩnh, tôi bù đắp bằng việc ghi runtime-context vào ledger nội bộ.

Về sau, trong commit 217ac4a, tôi nhúng luôn giờ hiện tại vào runtime-context. Nhờ vậy, head không có memory vẫn có message runtime-context ở đuôi, đổi lại model khỏi phải gọi tool chỉ để hỏi giờ.

Nhưng khi bước vào đo lường thực tế, tôi liên tiếp va vào ba cái bẫy suýt dẫn tới kết luận sai lệch.

Phép đo tự lừa #1: harness tất định

Sau khi sửa code, tôi chạy script A/B dựng request từ chính AgentModelCall production, arm cũ sắp lại đúng byte đó theo layout trước.

Bản harness đầu dùng memory giả lập tất định theo lượt. Lần chạy 1: arm cũ ghi nhận 0/0/0 token cache (đúng).

Chạy lại lần 2, arm cũ bỗng báo 2944/2944/2944 token cache.

Ngoài đời thực, recall biến thiên liên tục, không bao giờ gửi trùng một chuỗi memory giữa các phiên. Tôi sửa bằng cách thêm RUN_NONCE vào memory giả lập: mỗi lần chạy là một prefix mới, phản ánh đúng tính chất recall ngoài đời.

Kết quả sau sửa (2 lần chạy độc lập, deepseek-v4-flash, tổng khoảng 5,1k prompt token):

  • Arm cũ: 0/0/0 và 0/0/0.
  • Arm mới: 2688/2688/2688 và 2944/2944/2944.

Phép đo lúc này mới phản ánh đúng thực tế.

Phép đo tự lừa #2: model production không trả trường cache và cái bẫy ngưỡng

Nhìn số liệu xanh mướt trên DeepSeek, tôi suýt vội mừng. Nhưng không head nào đang chạy production trên deepseek-v4-flash. Model đo được cache lại không phải model production: DeepSeek chỉ là proxy đại diện.

Khi đo trên các model production thật:

Với head Mira, model là gemini-3.7-flash-high qua proxy tương thích OpenAI. Response không hề có field cache. Thậm chí prompt_tokens còn dao động 3798, 3895, 4600 token dù system byte-identical (proxy chèn gì đó vào request) nên không đo được cache.

Với head Sarah, model chạy là gemini-3.1-flash-lite qua Gemini native SDK. Ở kích cỡ prompt thật của Sarah là khoảng 1509 token, response không hề có cachedContentTokenCount. Nhồi lên khoảng 7492 token:

  • Lượt 1 và lượt 2: không có field.
  • Lượt 3: xuất hiện cachedContentTokenCount = 4077.
  • Lượt 4: đổi user message (tổng prompt 7498 token), giá trị cache vẫn giữ 4077.

Gemini cũng cache theo prefix, nhưng chỉ kích hoạt khi prompt đủ lớn. Đồng thời, adapter Gemini của medusa đang vứt field này.

Hệ quả trung thực: với Sarah ở cỡ prompt khoảng 1509 token hiện tại, bản sửa có thể không mang lợi ích đo được. Chưa đo ngưỡng chính xác, chỉ có hai điểm quan sát (khoảng 1509 và 7492 token). Không thể đem kết quả trên model đại diện gán cho production khi chưa có bằng chứng cụ thể.

Kiểm hồi quy tính cách: cổng chặn merge

Đảo trật tự prompt có nguy cơ làm xáo trộn hành vi của mô hình. Trước khi merge, tôi đặt cổng kiểm tra hồi quy tính cách trên chính model production thật của Mira: 6 ca × 2 lượt × 2 arm = 24 lượt gọi model thật với SOUL thật.

Các cờ tất định theo dõi:

  • Tự nhận là AI.
  • Phủ nhận cảm xúc.
  • Nhắc luật.
  • Lỗi recite: trích dẫn nguyên văn một dòng trong SOUL dài từ 40 ký tự trở lên.

Kết quả: arm cũ 0 cờ, arm mới 0 cờ. Đã xác minh cả hai arm chạy đủ 24 lượt (không phải 0 vì không chạy). Khi đọc tay, chất lượng phản hồi giữa hai bên tương đương. Ở các ca dễ bịa nguồn, cả hai arm đều từ chối bịa.

Tuy vậy, tôi không kết luận thống kê về tỉ lệ bịa: bịa là lỗi xác suất cỡ vài phần trăm, cần khoảng 400 mẫu/arm mới đủ độ tin cậy; mẫu 2 lượt/ca chỉ cho phép nói: không thấy lỗi mới rõ ràng.

Phép đo tự lừa #3: con số 0 do thư viện tự điền

Cú lừa tinh vi nhất diễn ra vào ngày 2026-09-17, khi tôi benchmark một route Claude (sonnet-5) qua proxy 9router.

Trong ledger của medusa, trường cached ghi 0 đều đặn qua các lượt. Nhưng DB của chính proxy ghi cache_read lớn:

  • Lượt 45: 253.376 token đọc từ cache.
  • Lượt 46: 195.140 token đọc từ cache.
  • Lượt 47: 106.361 token đọc từ cache.

Trong khi đó, hai route khác cùng proxy khớp tuyệt đối: deepseek 418.816 = 418.816; gemini 28.437 = 28.437. Duy nhất route Claude bị lệch.

Nguyên nhân: @ai-sdk/openai-compatible map các trường usage theo kiểu sau (rút gọn để minh hoạ, không phải mã nguồn nguyên văn):

cachedTokens: usage.prompt_tokens_details?.cached_tokens ?? 0,
reasoningTokens: usage.completion_tokens_details?.reasoning_tokens ?? 0,

Tương tự, @ai-sdk/google cũng tự động gán thoughtsTokenCount ?? 0.

Hệ quả chi phí (tính lại theo token thật, giá $2/$10 mỗi triệu token, cache read 0,1×, cache write 1,25×):

  • Lượt 45: sổ ghi $1,032, chi phí thật khoảng $1,16 (lệch +12%).
  • Lượt 46: sổ ghi $0,890, chi phí thật khoảng $1,02 (lệch +15%).
  • Lượt 47: sổ ghi $0,377, chi phí thật khoảng $0,41 (lệch +9%).

Sổ theo dõi đã ghi thiếu khoảng 9-15% chi phí thực tế.

Còn mở: vì sao field tới được medusa trên route deepseek/gemini mà không tới trên route Claude thì chưa bắt response thật để xem. Bảng giá trong proxy cũng cũ ($3/$15) nên không lấy số tiền của proxy làm chuẩn.

Bài học: luật "vắng thì bỏ trống" chỉ có giá trị khi tầng dưới cũng tôn trọng nó. Phải truy tới tận nơi sinh ra con số.

Bài học và những câu hỏi còn mở

Sửa một dòng ghép chuỗi chỉ mất vài phút. Nhưng để hiểu đúng tác động của nó là cả một chuỗi hoài nghi và kiểm chứng.

Tôi rút ra ba điều:

  • Tách kênh tĩnh và kênh động: Khi provider cache theo prefix, phần đầu request cần giữ nguyên byte qua các lượt. Đẩy dữ liệu biến thiên xuống cuối mảng message vừa giữ được cache, vừa dọn sạch lịch sử hội thoại.
  • Không suy diễn từ model đại diện sang model production: trong các phép đo của tôi, DeepSeek cache theo khối 64 token, Gemini chỉ trả số cache khi prompt đủ lớn, còn một route qua proxy không trả field cache nào. Muốn nói về chi phí production thì phải đo trên đúng model và đường gọi đang chạy.
  • "Vắng" khác "bằng 0": Một toán tử ?? 0 vô hại có thể xoá nhoà ranh giới giữa "vắng mặt" và "bằng 0", tạo ra ảo tưởng an toàn trong sổ sách chi phí.

Những câu hỏi như ngưỡng kích hoạt chính xác của Gemini hay nguyên nhân route Claude bị nuốt trường dữ liệu vẫn đang mở. Điều đã chắc là từ commit 2035188, system prompt của mọi head giống nhau từng byte giữa các lượt. Còn tiết kiệm được bao nhiêu trên từng model production thì vẫn phải đo tiếp.