Bài 3: Cost Optimization & Caching¶
Tổng quan¶
Module I, Bài 8 đã giới thiệu ba đòn bẩy: prompt caching, semantic caching, model routing (rule-based / embedding / classifier). Bài này mở rộng sang cấp hệ thống:
- Hiểu token economics: tiền đi đâu, vì sao context bloat là kẻ giết ngân sách.
- Đo trước khi tối ưu: cost per request / user / feature.
- Cache 2 tầng hoàn chỉnh với invalidation đúng theo prompt version.
- Model cascading (khác routing) và Batch API.
- Cost governance: budget, alert, hard limit.
1. Token Economics¶
Tiền đi đâu trong một request¶
graph LR
subgraph "Input tokens (rẻ hơn)"
SYS[System prompt] --- FS[Few-shot] --- CTX[RAG context] --- HIST[Lịch sử hội thoại] --- Q[Câu hỏi]
end
subgraph "Output tokens (đắt 3-5x)"
ANS[Câu trả lời] --- REASON[Reasoning / chain-of-thought]
end
| Yếu tố | Đặc điểm chi phí |
|---|---|
| Input tokens | Rẻ hơn; với prompt caching còn rẻ nữa (–50→90% cho phần cache hit) |
| Output tokens | Đắt nhất - thường 3–5× giá input. Reasoning model "nghĩ" nhiều = tốn nhiều output ẩn |
| Context bloat | RAG lấy 10 chunk khi 3 chunk là đủ; lịch sử 20 lượt khi 4 lượt là đủ; tool schema JSON lặp lại mỗi request |
| Retry / self-reflection | Mỗi vòng lặp agent là một request đầy đủ - nhân chi phí |
Công thức nhẩm¶
cost_request ≈ (input_tokens × giá_in) + (output_tokens × giá_out)
Ví dụ RAG (giá minh hoạ, USD / 1M tokens: in $3, out $15):
system 400 + few-shot 300 + context 3000 + câu hỏi 100 = 3800 input
câu trả lời 350 output
≈ 3800/1e6 × 3 + 350/1e6 × 15 = $0.0114 + $0.00525 ≈ $0.0167 / request
→ context (3000 token) chiếm ~70% chi phí input. Cắt còn 1500 → tiết kiệm ~35% cả request.
Trước khi tối ưu: đo phân bố token
Log input_tokens, output_tokens, và breakdown (bao nhiêu cho context, cho history, cho system). Thường phát hiện: 1 tính năng ít dùng nhưng context khổng lồ đang ngốn 40% hoá đơn.
2. Đo trước khi tối ưu¶
Gắn nhãn mọi request¶
# cost/tracker.py
import tiktoken
PRICING = { # USD / 1M tokens (minh hoạ - luôn tra bảng giá hiện hành)
"claude-sonnet-5": {"in": 3.0, "out": 15.0},
"gpt-4o-mini": {"in": 0.15, "out": 0.60},
}
def record_cost(*, feature: str, user_id: str, model: str,
usage, cache_hit: bool = False):
p = PRICING[model]
cost = usage.prompt_tokens / 1e6 * p["in"] + usage.completion_tokens / 1e6 * p["out"]
metrics.emit("llm_cost_usd", cost, tags={
"feature": feature, "model": model,
"cache": "hit" if cache_hit else "miss",
})
metrics.emit("llm_tokens_in", usage.prompt_tokens, tags={"feature": feature})
metrics.emit("llm_tokens_out", usage.completion_tokens, tags={"feature": feature})
# per-user: dùng cho hard limit (mục 7), không cần gửi hết lên dashboard
redis.incrbyfloat(f"cost:user:{user_id}:{today()}", cost)
Ba lát cắt cần nhìn¶
| Lát cắt | Trả lời câu hỏi | Hành động |
|---|---|---|
| Per feature | Tính năng nào ngốn ngân sách? | Tối ưu context/model cho đúng tính năng đó |
| Per user | Ai đang lạm dụng / cost bất thường? | Rate limit, hard cap |
| Per request (phân bố) | p50 vs p99 chênh bao nhiêu? | p99 cao = có request context khổng lồ / loop agent dài |
Nguyên tắc: tìm 20% tính năng chiếm 80% chi phí rồi mới chọn kỹ thuật tối ưu. Đừng tối ưu đều tay.
3. Caching tầng 1: Exact / Prompt Cache¶
Prompt caching (nhắc lại + mở rộng)¶
Cơ chế đã học ở M1, Bài 8: provider cache phần prefix lặp lại. Điểm mở rộng cho production:
- Sắp xếp prompt: tĩnh trước, động sau. System prompt + few-shot + tài liệu ít đổi đặt đầu; context RAG và câu hỏi đặt cuối → prefix cache dùng lại được nhiều nhất.
- TTL ngắn (Anthropic mặc định ~5 phút): chỉ có lợi khi traffic đủ dày để request kế tiếp rơi vào cửa sổ TTL.
- Invalidation khi prompt đổi version: xem dưới.
Exact-match response cache (tầng ứng dụng)¶
Khác prompt caching (của provider), đây là cache toàn bộ câu trả lời cho input giống hệt:
# cache/exact.py
def cache_key(prompt_name: str, prompt_version: int, model: str,
rendered_prompt: str, params: dict) -> str:
raw = json.dumps({
"pn": prompt_name, "pv": prompt_version, "m": model,
"p": rendered_prompt, "params": params,
}, sort_keys=True, ensure_ascii=False)
return "resp:" + hashlib.sha256(raw.encode()).hexdigest()
def get_or_call(prompt_name, prompt_version, model, rendered_prompt, params, call_fn):
key = cache_key(prompt_name, prompt_version, model, rendered_prompt, params)
if (hit := redis.get(key)) is not None:
record_cost(feature=..., model=model, usage=ZERO_USAGE, cache_hit=True)
return json.loads(hit)
out = call_fn()
redis.setex(key, ttl=86400, value=json.dumps(out))
return out
Invalidation: prompt_version phải nằm trong cache key
Nếu bạn promote rag_answer v2 → v3 (Bài 1) mà cache key không chứa version, người dùng vẫn nhận câu trả lời sinh bởi v2 trong suốt TTL. Đưa prompt_version (và model) vào key → version mới tự động là "cache namespace" mới, bản cũ hết hạn tự nhiên.
4. Caching tầng 2: Semantic Cache¶
Trả lời cũ cho câu hỏi gần giống (không cần giống hệt). Cơ chế đã học ở M1, Bài 8. Bổ sung cho production:
Kiến trúc¶
graph LR
Q[Câu hỏi mới] --> EMB[Embed]
EMB --> SEARCH[Vector search trong cache store]
SEARCH --> SIM{Similarity ≥ ngưỡng?}
SIM -->|Có| HIT[Trả lời cache - $0, ~10ms]
SIM -->|Không| LLM[Gọi LLM]
LLM --> STORE[Lưu embedding + câu trả lời vào cache]
False hit và cách phòng¶
| Rủi ro | Ví dụ | Phòng |
|---|---|---|
| Ngưỡng quá thấp | "Điều kiện thành lập công ty TNHH" ↔ "Điều kiện giải thể công ty TNHH" similarity 0.86 → trả nhầm | Đặt ngưỡng cao (0.92–0.95 với embedding tốt); tune trên tập câu hỏi thật |
| Câu phủ định / thời gian | "... năm 2020" vs "... năm 2024" gần nhau về embedding | Chuẩn hoá / tách entity quan trọng; không cache câu có tham số ngày/số cụ thể |
| Ngữ cảnh hội thoại | "Còn trường hợp thứ hai thì sao?" phụ thuộc lượt trước | Chỉ semantic-cache câu hỏi standalone (sau bước rewrite), không cache follow-up |
| Cache stale | Tài liệu nguồn cập nhật, câu trả lời cache đã lỗi thời | TTL theo tốc độ đổi của tài liệu; xoá namespace khi RAG index rebuild |
Đo hiệu quả¶
Semantic cache chỉ đáng khi hit_rate đủ cao (FAQ, support bot: 20–40%; câu hỏi đa dạng: có thể < 5% → không đáng phức tạp).
5. Model Routing & Cascading¶
Routing vs Cascading¶
graph TB
subgraph "Routing - quyết định TRƯỚC"
Q1[Query] --> CLF[Phân loại: dễ / khó]
CLF -->|dễ| S1[Model rẻ]
CLF -->|khó| B1[Model mạnh]
end
subgraph "Cascading - thử rồi ESCALATE"
Q2[Query] --> S2[Model rẻ trả lời]
S2 --> CHK{Đạt ngưỡng tin cậy /<br/>qua guardrail?}
CHK -->|Có| DONE2[Dùng luôn]
CHK -->|Không| B2[Escalate: Model mạnh trả lại]
end
| Routing | Cascading | |
|---|---|---|
| Quyết định | Trước khi gọi model, dựa trên query | Sau câu trả lời của model rẻ, dựa trên output |
| Chi phí thêm | 1 lần phân loại (~ms) | Với case khó: trả tiền cả hai model |
| Rủi ro | Phân loại sai → model rẻ nhận câu khó | Model rẻ "tự tin sai" → không escalate |
| Khi nào tốt | Phân biệt dễ/khó rõ ràng từ query | Khó đoán độ khó trước; có tín hiệu chất lượng trên output |
Cascade implementation¶
# routing/cascade.py
def answer_cascade(question: str, context: str) -> dict:
cheap = call("gpt-4o-mini", question, context)
if _confident(cheap) and _passes_guardrails(cheap):
metrics.emit("cascade_escalate", 0)
return cheap
metrics.emit("cascade_escalate", 1)
strong = call("claude-sonnet-5", question, context)
return strong
def _confident(resp) -> bool:
# tín hiệu: model không nói "không chắc / không tìm thấy",
# logprobs cao nếu có, hoặc self-rating >= 4
return "không tìm thấy" not in resp["text"].lower()
Đo tỉ lệ escalate. Nếu 70% case phải escalate → cascade đang lỗ (trả 2 lần tiền cho phần lớn traffic); chuyển sang routing hoặc dùng thẳng model mạnh.
6. Kỹ thuật khác¶
Prompt compression & context trimming¶
| Kỹ thuật | Cách làm | Lưu ý |
|---|---|---|
| Trim RAG context | Rerank rồi chỉ giữ top-k thật sự liên quan (k=3 thay 10); cắt chunk còn phần cốt lõi | Đo lại faithfulness/recall - cắt quá tay làm rớt chất lượng |
| Nén lịch sử hội thoại | Tóm tắt các lượt cũ thành 1 đoạn ngắn, giữ nguyên 2–3 lượt gần nhất | Xem M2, Bài 3 - context compression |
| Bỏ few-shot khi model đủ mạnh | Model mới thường không cần 5 ví dụ; thử 0–2 shot + eval | So điểm eval trước/sau |
| Prompt compression tự động (LLMLingua…) | Model nhỏ nén prompt, giữ token "quan trọng" | Thêm 1 bước xử lý; hợp lý khi prompt rất dài và ổn định |
Batch API¶
Với workload không cần realtime (eval hàng loạt, tiền xử lý tài liệu, sinh embedding/nhãn):
- Gửi tập request dạng file, provider xử lý bất đồng bộ trong cửa sổ (thường ≤ 24h).
- Giá thường giảm ~50% so với API đồng bộ.
- Dùng cho: chạy full golden set nightly (Bài 2), backfill phân loại, sinh dataset synthetic.
# batch/submit.py
batch_input = [
{"custom_id": c["id"], "method": "POST", "url": "/v1/chat/completions",
"body": {"model": "gpt-4o-mini", "messages": build_messages(c)}}
for c in cases
]
# ghi JSONL, upload, tạo batch job, poll trạng thái, tải kết quả
7. Cost Governance¶
Budget & Alert¶
# cost/budget.py
MONTHLY_BUDGET_USD = 800
ALERT_THRESHOLDS = [0.5, 0.8, 1.0] # 50% / 80% / 100%
def check_budget():
spent = sum_month_to_date() # từ metrics store
projected = spent / days_elapsed() * days_in_month()
for t in ALERT_THRESHOLDS:
if spent >= MONTHLY_BUDGET_USD * t and not alerted(t):
alert(f"Chi phí LLM đạt {t:.0%} ngân sách tháng "
f"(${spent:.0f}/${MONTHLY_BUDGET_USD}). Dự phóng cuối tháng: ${projected:.0f}.")
mark_alerted(t)
Hard limit per user¶
def guard_user_budget(user_id: str, daily_cap_usd: float = 2.0):
spent = float(redis.get(f"cost:user:{user_id}:{today()}") or 0)
if spent >= daily_cap_usd:
raise BudgetExceeded(f"Vượt hạn mức ngày (${daily_cap_usd}). Thử lại vào ngày mai.")
Bảng theo dõi¶
| Cần theo dõi | Tần suất | Ngưỡng cảnh báo |
|---|---|---|
| Tổng chi phí ngày / tháng | Realtime + báo cáo ngày | > 120% dự phóng |
| Cost per feature | Ngày | Feature tăng > 50% tuần-trên-tuần |
| Cost per request (p50, p99) | Ngày | p99 > 5× p50 |
| Cache hit rate (tầng 1 + 2) | Ngày | Tụt đột ngột (cache bị vô hiệu do đổi key?) |
| Tỉ lệ escalate của cascade | Ngày | > 50% |
Cost spike là một loại incident
Xử lý cost spike (phát hiện → khoanh vùng feature/user → mitigate) được trình bày như một playbook ở Bài 7.
8. Hands-on: Cache 2 tầng cho Vietnamese chatbot¶
Dùng chatbot RAG từ Capstone Module I.
Bước 1 - Đo baseline¶
- Chuẩn bị tập replay: 200 câu hỏi (trộn ~30% trùng/gần giống để mô phỏng FAQ).
- Gắn
record_cost()vào đường gọi LLM. Chạy replay không cache. - Ghi: tổng cost, cost/request p50/p99, token in/out trung bình, breakdown context.
Bước 2 - Tầng 1: exact response cache¶
cache_keygồmprompt_name,prompt_version,model,rendered_prompt,params.- Redis
setexTTL 24h. Chạy lại replay → đo hit rate, cost mới. - Bump prompt version → xác nhận cache tự "reset" (không trả câu cũ).
Bước 3 - Tầng 2: semantic cache¶
- Embed câu hỏi (sau bước rewrite → standalone), vector store nhỏ (FAISS/Redis).
- Ngưỡng khởi đầu 0.93; chạy replay, soát 10 hit ngẫu nhiên xem có false hit.
- Tune ngưỡng; loại câu có tham số ngày/số khỏi semantic cache.
Bước 4 - Báo cáo¶
| Cấu hình | Tổng cost | Cost/req | Hit rate | Ghi chú |
|---|---|---|---|---|
| Không cache | ... | ... | – | baseline |
| + Tầng 1 | ... | ... | ...% | |
| + Tầng 1 & 2 | ... | ... | ...% | false hit: .../10 |
Bước 5 (mở rộng) - Cascade¶
- Thêm
answer_cascade():gpt-4o-minitrước, escalate sangclaude-sonnet-5khi output nói "không tìm thấy" hoặc self-rating < 4. - Chạy replay (cache miss only), đo tỉ lệ escalate + cost so với dùng thẳng model mạnh.
Tiêu chí hoàn thành¶
- Có số cost baseline với breakdown token
- Cache key chứa
prompt_version→ bump version không trả câu cũ - Semantic cache có kiểm false hit thủ công + ngưỡng đã tune
- Bảng so sánh cost trước/sau trên cùng tập replay
- (Mở rộng) tỉ lệ escalate của cascade < 50% hoặc kết luận rõ cascade không đáng
Tóm tắt¶
graph TB
REQ[Request] --> T1{Tầng 1<br/>exact cache?}
T1 -->|hit| DONE[Trả lời - $0]
T1 -->|miss| T2{Tầng 2<br/>semantic cache?}
T2 -->|hit| DONE
T2 -->|miss| ROUTE[Routing / Cascade]
ROUTE --> CALL[Gọi LLM<br/>prompt tĩnh trước, động sau -> prompt cache]
CALL --> REC[record_cost per feature/user] --> STORE[Ghi vào 2 tầng cache]
REC --> BUD{Budget / hard limit}
| Kỹ thuật | Khi nào dùng |
|---|---|
| Đo token/cost per feature | Luôn - trước mọi tối ưu |
| Prompt caching (provider) | Prefix dài lặp lại; sắp xếp tĩnh→động |
| Exact response cache | Input lặp lại y hệt; key phải chứa prompt_version + model |
| Semantic cache | FAQ/support, hit rate > ~20%; ngưỡng cao + loại câu có tham số |
| Routing | Phân biệt dễ/khó rõ từ query |
| Cascading | Khó đoán độ khó trước; theo dõi tỉ lệ escalate |
| Batch API | Workload offline (eval, backfill) - giảm ~50% giá |
| Budget + hard limit | Alert theo % ngân sách; cap ngày per user; cost spike = incident |