Prompt caching ghi mà không đọc: vì sao bill tăng 25%
Cache_read_input_tokens bằng 0 nghĩa là bạn trả 1.25 lần giá input cho không gì cả. Cách đọc usage object, ba bug phá prefix và bài test chặn lỗi.
Mục lục
- Chỉ một chỗ nói thật: usage object
- Vì sao "ghi mà không đọc" đắt hơn không cache
- Cache là prefix match, không phải key-value
- Đặt breakpoint ở byte ổn định cuối cùng
- Hai cơ chế miss dù payload giống hệt từng byte
- Một integration test giữ cache không âm thầm vỡ
- Khi cache đã đúng mà vẫn cần một cái trần
- Làm ngay hôm nay
Bạn bật prompt caching cho một agent loop để cắt chi phí input, vì trong một tool loop thì input token là gần như toàn bộ hoá đơn. Một thời gian sau bill tăng chứ không giảm, trong khi mọi request đều trả 200, latency không đổi và log không báo gì.
Bài này đi qua đúng ba việc: đọc ở đâu để biết cache có thực sự được đọc hay không, ba kiểu bug tạo ra cùng một lỗi, và bài test giữ cho cache không âm thầm vỡ sáu tháng sau.
Chỉ một chỗ nói thật: usage object
Một bài phân tích trên Dev.to ghi lại đúng ca này: agent loop gửi lại system prompt 12K token mỗi lượt, bật caching, và hoá đơn tăng "roughly a quarter" — khoảng một phần tư. Nơi duy nhất ghi lại sự thật là usage object của response:
cache_creation_input_tokens: 12184
cache_read_input_tokens: 0
input_tokens: 291
Mười hai nghìn token được ghi vào cache, không một token nào được đọc ra. Nếu bạn chỉ nhìn status code và latency, lỗi này không tồn tại.
Ba field này chia nhau toàn bộ prompt, nên đừng đọc riêng lẻ:
total prompt tokens = input_tokens + cache_creation_input_tokens + cache_read_input_tokens
Một agent chạy cả tiếng mà báo input_tokens: 4200 không có nghĩa là nó rẻ, cũng không có nghĩa là nó hỏng. Phải cộng cả ba.
Vì sao "ghi mà không đọc" đắt hơn không cache
Giá là thứ biến chuyện này từ "chưa tối ưu" thành "tệ hơn không làm gì". Theo bài viết, cache write tốn 1.25× giá input gốc (2× nếu dùng TTL 1 giờ), còn cache read chỉ khoảng 0.1×.
Phép tính rất ngắn. Hai request dùng chung prefix: 1.25 + 0.1 = 1.35, so với 2.0 nếu không cache — có lãi. Một request ghi cache rồi không ai đọc: trả 1.25× để đổi lấy không gì cả. Bốn mươi request như vậy thành một dòng trong hoá đơn.
"A cache that only ever writes is not a cache, it is a 25% surcharge."
Cache là prefix match, không phải key-value
Đây là mô hình tư duy sửa được lỗi. Cache không phải một store được key bằng "system prompt của tôi". Nó là một prefix match trên request đã render: API render tools, rồi system, rồi messages, hash chuỗi byte tới từng breakpoint và tìm entry sẵn có. Điểm khác biệt đầu tiên thắng — mọi thứ nằm sau nó đều lạnh.
Ba đoạn code dưới đây vì thế là cùng một bug, chỉ khác lớp áo (ví dụ minh hoạ theo nguồn):
# 1. Header động
system = f"You are a support agent. Current time: {datetime.now()}.\n\n{PLAYBOOK}"
# PLAYBOOK là 11K token ổn định, nằm SAU một chuỗi đổi mỗi request.
# 2. Serializer không xác định
system = "Schema:\n" + json.dumps(schema) # thiếu sort_keys=True
# Cùng một dict, thứ tự key khác, chuỗi byte khác.
# 3. Tool list theo user
tools = build_tools_for(user) # tools render ở vị trí 0
# Không bao giờ cache chéo được giữa các user.
Ca của tác giả là số 1, nhưng đội lốt: timestamp do một helper viết từ nhiều tháng trước cho mục đích logging chèn vào, cách chỗ gọi ba call frame.
Đặt breakpoint ở byte ổn định cuối cùng
Quy tắc: breakpoint nằm ở cuối phần dùng chung, không phải cuối toàn bộ prompt. Một cache_control đặt ở mức top-level sẽ gắn breakpoint vào block cacheable cuối cùng và trượt dần khi hội thoại dài ra. Hợp cho chat thread; sai cho pattern "preamble cố định lớn + câu hỏi riêng mỗi request", vì breakpoint rơi xuống sau phần riêng đó.
messages = [{"role": "user", "content": [
{"type": "text", "text": RETRIEVED_DOCS, # 9K token dùng chung
"cache_control": {"type": "ephemeral"}}, # breakpoint ĐẶT Ở ĐÂY
{"type": "text", "text": question}, # phần riêng, không đánh dấu, nằm sau
]}]
Bốn quy tắc đặt breakpoint còn lại, theo bài viết:
- Đóng băng system prompt. Ngày giờ, mode, tên user, feature flag không thuộc về đầu prefix. Trên Opus 5 và Opus 4.8 có thể append
{"role": "system", "content": "..."}vào trongmessages[]— nó nằm sau phần history đã cache nên không phá prefix. Model khác thì đẩy vào một user turn. - Serialize tools một cách xác định và không thêm, bớt hay đổi thứ tự tool giữa chừng hội thoại. Tools render ở vị trí 0, nên một lần reorder là rebuild toàn bộ.
- Cache gắn với model. Đổi sang model rẻ hơn cho một tác vụ phụ giữa loop là mất sạch prefix. Cho subagent một thread riêng thay vì đổi model trong thread đang chạy.
- Prefix tối thiểu phụ thuộc model và không tăng đều: 512 token trên các model mới nhất, 1024 trên Opus 4.8 và Sonnet 5, 4096 trên Opus 4.6 và Haiku 4.5. Dưới ngưỡng thì không có lỗi nào cả, chỉ là
cache_creation_input_tokens: 0. Tối đa 4 breakpoint mỗi request.
Hai cơ chế miss dù payload giống hệt từng byte
Đây là phần khiến bạn nghi ngờ chính cái diff của mình.
Lookback 20 vị trí. Mỗi breakpoint chỉ lùi tối đa 20 vị trí để tìm entry cũ. Một chuỗi tool_use liên tiếp tính là một vị trí, chuỗi tool_result liên tiếp cũng vậy — nên gọi tool song song nhiều thì không sao. Vấn đề là loop tuần tự dài: nếu một lượt nối thêm hơn 20 vị trí nội dung khác, breakpoint của request kế không tìm thấy entry trước đó và bạn ghi lại toàn bộ hội thoại với một payload diff ra sạch sẽ. Cách chữa là chèn breakpoint trung gian mỗi khoảng 15 vị trí.
Fan-out song song. Một entry chỉ đọc được sau khi response đầu tiên bắt đầu stream. Bắn 10 request cùng prefix cùng lúc thì cả 10 trả giá đầy đủ. Gửi một cái, đợi token đầu tiên stream về, rồi bắn chín cái còn lại. Thiết kế multi-agent dính đúng phép tính này: N worker ghép prompt hơi khác nhau trên cùng context sẽ ghi N entry và không đọc của nhau entry nào.
Về TTL: entry mặc định 5 phút được refresh miễn phí mỗi lần read, tính từ lúc request bắt đầu — nên một lượt generate mất bốn phút chỉ chừa lại khoảng một phút cho request kế. TTL 1 giờ gấp đôi chi phí write, nên chỉ đáng trong khoảng trống 5 đến 60 phút và cần ít nhất ba request mới hoà vốn.
Một integration test giữ cache không âm thầm vỡ
Chế độ hỏng đắt nhất trong production không phải bản implement đầu tiên làm sai. Nó là bản đang chạy đúng rồi hỏng sáu tháng sau, khi ai đó thêm một feature flag vào system prompt: không có lỗi nào, chỉ có hoá đơn trôi lên.
r1 = client.messages.create(**payload)
r2 = client.messages.create(**payload) # giống hệt từng byte
assert r2.usage.cache_read_input_tokens > 0, "cache prefix broke"
Khi cần khoanh vùng chỗ vỡ: log vài request body liên tiếp rồi diff từng cặp kề nhau. Trong hội thoại đang dài ra thì phần đuôi khác nhau là đúng, thứ bạn kiểm tra là phần chồng lấn — prompt của request trước phải xuất hiện nguyên vẹn làm prefix của request sau. Nhớ bỏ các marker cache_control trước khi diff, vì marker luôn dịch chuyển và không phải thủ phạm. Điểm khác biệt đầu tiên bên trong phần chồng lấn chính là chỗ làm hỏng cache.
Khi cache đã đúng mà vẫn cần một cái trần
Caching chỉ hạ đơn giá. Nó không trả lời được câu "session vừa rồi tốn bao nhiêu" và không chặn được một loop chạy quá tay. Một hướng khác đang được thử: gremlord, router chạy local bọc quanh binary claude không sửa đổi — giữ nguyên TUI và tool — nhưng model phía sau có thể là Anthropic, OpenAI, xAI, Ollama hoặc bất kỳ endpoint OpenAI-compatible nào. Tác giả cho biết mỗi token được tính giá ngay khi stream, và hạn mức cứng chặn request kế tiếp tại ngưỡng thay vì cảnh báo sau khi đã tiêu. Công cụ miễn phí, giấy phép MIT. Cùng tác giả cũng công bố luật quy đổi chi phí model chạy local, đáng tham khảo khi bạn tự vận hành hạ tầng: model 4-12B Q4 trên laptop tính 0,10 USD/giờ, máy có GPU 24GB tính 0,35 USD/giờ.
Làm ngay hôm nay
- Mở log request gần nhất của agent loop và đọc
cache_read_input_tokens. Bằng 0 qua nhiều request giống nhau nghĩa là có thứ gì đó phía trên đang ghi lại prefix của bạn. - Grep system prompt tìm
datetime,uuid, tên user vàjson.dumpskhông cósort_keys=True. - Dời
cache_controlvề byte ổn định cuối cùng, đẩy toàn bộ phần biến động xuống sau nó. - Thêm bài test hai request giống hệt nhau ở trên vào CI, trước khi có người thêm feature flag tiếp theo.
Không spam, hủy đăng ký bất kỳ lúc nào.