// BLOG — 2026-08-29

✦ Cắt 90% chi phí LLM bằng prompt caching: từ một bài trên dev.to tới setup thực tế

Bài mổ xẻ cơ chế prompt caching và cách tách prefix ổn định để giảm mạnh chi phí LLM trong hội thoại dài. Tập trung vào nguyên lý, điều kiện áp dụng và cái giá phải trả khi cache không ăn.

# Cắt 90% chi phí LLM bằng prompt caching: gói "đắt" nhất hoá ra được tính lại từng lần

Bài này nằm trong chuỗi mổ xẻ kỹ thuật tại zeodavu.com/blog. Mình ghi lại một phát hiện mình tình cờ thấy khi thấy "sao cùng một hội thoại, càng chạy lâu càng đắt?" — và hoá ra một phần lớn cái giá mình trả mỗi lần là do cái không hề thay đổi. Không có recipe để sao chép; chỉ có cơ chế, con số, và cái giá phải trả.


Vấn đề mở đầu nghe rất quen: một hội thoại dài với một model ngôn ngữ càng chạy lâu càng tốn. Bạn đưa vào một lượng ngữ cảnh khổng lồ (hướng dẫn, lịch sử, quy tắc) rồi mỗi câu hỏi hỏi thêm một chút. Về mặt cảm giác thì "câu hỏi chỉ dài thêm một đoạn", nhưng hoá đơn lại leo theo cái khối ngữ cảnh kếch xù phía trước.

Mình từng trả cái giá ấy mỗi ngày. Và điểm mấu chốt là: phần lớn cái phần được gửi đi mỗi lần là... không đổi. Cái không đổi mà bị tính lại hết giá — đó là chỗ lãng phí.


Vì sao hội thoại dài lại đắt: phần "cố định" bị tính lại từng lần

Với hầu hết API model ngôn ngữ, cách tính tiền dựa trên số token. Mỗi request bạn gửi đi bao gồm toàn bộ chuỗi ngữ cảnh: hệ thống (system), các lượt trước, và câu hỏi mới. Model không "nhớ" các lượt trước — bạn phải gửi lại tất cả, mỗi lần.

# pseudo-code minh hoạ — KHÔNG phải nguồn triển khai
# Bản "ngây thơ": mỗi request gửi nguyên cái lịch sử dài, tính giá full
def ask(history, new_question):
    payload = system_prompt + history + new_question   # gửi hết, từ đầu
    return model(payload)

Vậy nên một hội thoại 50 lượt, mỗi lượt gửi lại 49 lượt trước đó — thậm chí cả phần đã trả lời xong. Bạn trả tiền cho cái đã có trong ngữ cảnh mỗi lần bạn mở miệng hỏi một câu mới.

Model không có trí nhớ. Mỗi lần bạn hỏi, bạn đang trả tiền để gửi lại toàn bộ cái bạn đã nói từ đầu — kể cả phần nó đã trả lời xong từ lâu.


Ý tưởng cốt lõi: đừng tính lại cái không đổi

Prompt caching đi đúng vào chỗ đó. Nếu phần trước của một prompt (system, hướng dẫn, lịch sử gần như bất biến) là ổn định, nhà cung cấp có thể giữ lại nó ở một chỗ và chỉ tính giá phần thay đổi ở cuối.

# pseudo-code minh hoạ — KHÔNG phải nguồn triển khai
# Với prompt caching: phần đầu ổn định chỉ tính 1 lần theo giá "hit"
def ask(history, new_question):
    prefix = build_stable_prefix(history)       # gần như bất biến
    if prefix in cache:
        cached_cost = cache_price(prefix)        # đắt hơn input thường CHÚT xíu
    else:
        cached_cost = full_input_price(prefix)   # lần đầu mới tính đầy đủ
    # chỉ phần question (suffix) tính giá full
    return cached_cost + full_price(new_question)

Điểm đáng chú ý: phần cache không miễn phí, nhưng thường rẻ hơn nhiều (ví dụ vài lần rẻ hơn so với input thường). Và — quan trọng nhất — nó chỉ tính một lần, sau đó mọi request tái dùng cái prefix đó đều trả giá "hit" thay vì giá đầy đủ.

Kết quả: một hội thoại 50 lượt, thay vì trả giá input đầy đủ của 50 × (phần dài), bạn trả khoảng một lần giá cache cho phần prefix ổn định, cộng giá thật của từng câu hỏi mới ngắn ngủi ở cuối.


Bảng chi phí trước & sau

Để mình minh hoạ bằng con số tương đối (không phải giá tuyệt đối, chỉ tỷ lệ) — đại lượng ta thật sự kiểm soát được là số token bị tính ở mức nào:

| Kịch bản | Không cache (gửi lại prefix mỗi lần) | Có cache prefix | Tiết kiệm |

|---|---|---|---|

| 10 lượt, prefix ngắn | 10 × giá input đầy đủ | ~1 × giá cache + 10 × giá input ngắn | ~50% |

| 50 lượt, prefix dài | 50 × giá input đầy đủ | ~1 × giá cache + 50 × giá input ngắn | ~80–90% |

| 100 lượt, prefix rất dài | 100 × giá input đầy đủ | ~1 × giá cache + 100 × giá input ngắn | ~>90% |

Điểm mù mà nhiều người bỏ qua nằm ở chỗ: tỷ lệ tiết kiệm phụ thuộc vào tỷ lệ giữa phần ổn định và phần thay đổi. Hội thoại/tác vụ càng "dài mà lặp" càng được lợi. Tác vụ hỏi một câu mỗi lần, prefix ngắn thì lợi ít.

Prompt caching không phải "rẻ hơn". Nó nói rằng: đừng tính lại cái đã cũ. Cái gì không đổi thì chỉ nên trả công một lần.


Nhưng đó không phải thứ miễn phí — cái giá phải trả

Cache không tự nhiên sinh ra. Để hưởng lợi, bạn phải chấp nhận vài điều kiện.

Điều kiện 1 — prefix phải ổn định tuyệt đối. Chỉ một token nhỏ đổi ở đầu prompt (một timestamp, một số ngẫu nhiên, một id phiên, một thứ tự tool random) là vô hiệu hoá toàn bộ cache. Cache hoạt động theo chuỗi byte khớp tuyệt đối từ đầu. Vậy nên toàn bộ hệ thống phải bảo đảm phần "cố định" sinh ra giống hệt nhau mỗi lần.

# pseudo-code minh hoạ — KHÔNG phải nguồn triển khai
# LỖI thường gặp: chèn giá trị động vào đầu prompt -> giết cache
def build_stable_prefix(history):
    prefix = system_prompt
    prefix += "generated_at=" + str(time.now())   # <-- SAI: đổi mỗi lần
    return prefix                                 # cache chết ngay
python

Điều kiện 2 — token đắt nhất vẫn phải trả. Các token ra (output) — phần model viết ra — không cache được và vẫn tính đầy đủ. Nếu tác vụ của bạn chủ yếu là output dài (sinh cả một bài viết dài) mà input ngắn, caching không cứu được gì nhiều. Nó chỉ tối ưu khi input chiếm đa số khối lượng.

Điều kiện 3 — TTL (thời gian sống). Cache không sống mãi; nhà cung cấp thường giữ prefix một khoảng thời gian (ví dụ vài phút tới vài giờ) sau lần dùng cuối. Nếu hội thoại của bạn cách nhau lâu (người dùng rời đi 1 tiếng), cache có thể đã hết hạn và bạn lại trả giá đầy đủ lần tiếp.

Điều kiện 4 — phụ thuộc nhà cung cấp. Bật "cache" ở provider A có thể khác hệ số, TTL, hay cách tính ở provider B. Cái bạn đọc được khi chuyển sang provider khác không tự động giữ nguyên.


Cái bẫy hay gặp nhất: "một token động" giết cả cơ hội

Mình từng thấy một setup muốn gắn cache nhưng hoàn toàn không ăn. Không phải vì cache sai, mà vì mỗi request lại có một cái gì đó "hơi khác" ở vị trí không ngờ — một thứ tự tool, một số ngẫu nhiên, một metadata thời gian. Một token thôi là toàn bộ cái prefix coi như mới, và bạn trả giá full mỗi lần mà không hề hay biết.

Bài học ở đây không phải "cache không hiệu quả". Mà là: muốn cache ăn, bạn phải chủ động dọn phần động ra khỏi phần ổn định. Tách system_prompt + lịch sử ra làm prefix, đẩy mọi thứ "đổi mỗi lần" xuống suffix.

Cache không phải một nút bật. Nó là một cam kết về sự ổn định. Bạn càng giữ cho phần đầu prompt giống hệt nhau, càng được trả công.


Kết luận

Điểm đáng lấy đi từ bài này không phải "có một nút giảm giá". Mà là: nhìn vào cái gì thật sự đổi giữa các lần gọi model. Nếu phần lớn khối lượng bạn gửi đi là không đổi (hướng dẫn dài, lịch sử, quy tắc), thì bạn đang trả giá đầy đủ cho một thứ hoá ra chỉ nên trả gần như một lần.

Ba điều mình rút ra:

  • Xác định phần nào của prompt là bất biến — đó chính là thứ đáng được cache, và cũng là nơi lãng phí lớn nhất.
  • Dọn mọi giá trị động ra khỏi prefix. Một token động ở đầu giết toàn bộ lợi ích. Tách prefix (ổn định) khỏi suffix (thay đổi) là bước quan trọng nhất.
  • Chỉ kỳ vọng vào đúng loại tác vụ. Cache thắng khi input chiếm đa số và lặp lại; input ngắn + output dài thì nó chẳng cứu được gì.

Bài này cố ý không nêu tên provider, hệ số cache, TTL, hay token budget cụ thể. Mỗi code block là minh hoạ cơ chế, không phải source triển khai. Điều đáng giữ là cách nhìn ra "cái đổi với cái không đổi", không phải một con số giá.

Toàn bộ con số và trải nghiệm mình ghi lại ở zeodavu.com/blog.


Một phần của chuỗi mổ xẻ kỹ thuật tại zeodavu.com/blog. Nội dung là bài học mở, không phải blueprint để sao chép — thứ đáng giữ là cấu trúc và trải nghiệm, không phải danh sách công cụ.

Nếu thấy hữu ích, theo dõi [blog](/blog).