Một AI agent chạy trong agentic loop (vòng lặp tác vụ của agent) mà không có caching (bộ nhớ đệm) giống như một nhân viên mới mỗi sáng đều đọc lại toàn bộ tài liệu dự án từ đầu, dù hôm qua họ đã đọc chính xác những dòng đó. Tốn thời gian, tốn tiền, và không ai chấp nhận điều đó ở một nhân viên con người — nhưng lại đang xảy ra âm thầm trong rất nhiều hệ thống agent production.
The Problem: Agent Đọc Lại Những Gì Nó Đã Biết
Trong một agentic loop điển hình — agent quan sát (observe), suy luận (reason), rồi hành động (act), lặp lại nhiều vòng cho tới khi hoàn thành tác vụ — mỗi vòng lặp mới không tự động "nhớ" những gì các vòng trước đã khám phá ra, trừ khi kiến trúc được thiết kế cẩn thận để giữ lại thông tin đó. Hệ quả thực tế: agent liên tục gọi lại đúng những tool call giống nhau.
Ví dụ cụ thể và rất phổ biến: một coding agent được giao nhiệm vụ "sửa lỗi ở module thanh toán". Trong vòng lặp đầu tiên, nó gọi read_file("payment/service.py") để hiểu code hiện tại. Ba vòng lặp sau, sau khi đã đi khám phá một vài file khác, nó lại cần xác nhận lại một chi tiết trong payment/service.py — và vì không có cơ chế cache, nó gọi lại đúng tool đó, gửi lại đúng nội dung file đó (có thể vài nghìn token) vào context, dù nội dung file chưa đổi một ký tự nào. Nhân với hàng chục vòng lặp trong một session dài, và nhân tiếp với hàng trăm session mỗi ngày ở một team dùng agent thường xuyên, chi phí token lãng phí này cộng dồn thành một con số rất đáng kể trên hóa đơn LLM API.
Vấn đề còn nghiêm trọng hơn ở các multi-agent system (hệ thống nhiều agent phối hợp), nơi agent A và agent B độc lập gọi cùng một tool với cùng tham số để lấy cùng một dữ liệu — ví dụ cả hai đều gọi API tra cứu thông tin khách hàng cho cùng một customer_id trong cùng một workflow, chỉ vì không có cơ chế chia sẻ kết quả giữa các agent.
Ba nguyên nhân gốc rễ dẫn tới tình trạng "đọc lại cái đã biết" này:
Thứ nhất, thiết kế agentic loop theo mô hình stateless per-call (không lưu trạng thái giữa các lần gọi) — nhiều framework agent mặc định coi mỗi tool call là một sự kiện độc lập, không tra cứu xem kết quả này đã từng được tính trước đó chưa.
Thứ hai, context window (cửa sổ ngữ cảnh) bị cắt/tóm tắt — khi agent chạy dài, các framework thường tóm tắt (summarize) hoặc cắt bớt phần đầu context để tiết kiệm token, và trong quá trình đó, thông tin "tôi đã đọc file X rồi, nội dung là Y" dễ bị mất, khiến agent "quên" và phải đọc lại.
Thứ ba, thiếu tầng trung gian (caching layer) giữa agent và môi trường thực thi (execution environment) — hầu hết agent gọi trực tiếp tool/API mà không đi qua một lớp kiểm tra "kết quả này đã có sẵn chưa" trước khi thực thi thật.
Hiểu rõ nguyên nhân giúp bạn nhìn caching và deduplication (loại bỏ trùng lặp) không phải như một tối ưu hóa hạ tầng khô khan, mà như một phần thiết yếu của việc thiết kế agentic loop hiệu quả — giống như bất kỳ hệ thống phần mềm production nào cũng cần caching ở đúng chỗ.
Mẹo: Trước khi tối ưu bất cứ thứ gì khác trong agent của bạn, hãy log lại toàn bộ tool call trong một vài session thực tế và tính tỷ lệ phần trăm tool call có cùng tên hàm + cùng tham số xuất hiện nhiều hơn một lần. Nếu số này vượt 15-20%, caching sẽ mang lại ROI (tỷ suất lợi nhuận đầu tư) rất nhanh và rất rõ ràng.
Hai Cấp Độ Caching Trong Hệ Thống Agentic
Caching trong một hệ thống agentic không phải là một khái niệm đơn nhất — có hai cấp độ hoàn toàn khác nhau về cơ chế, về nơi triển khai, và về loại token mà nó tiết kiệm được. Phân biệt rõ hai cấp độ này là bước đầu tiên để thiết kế chiến lược caching đúng.
Level 1: LLM Response Caching
Đây là caching ở tầng gọi model — khi cùng một prompt (hoặc cùng một phần đầu của prompt) được gửi lại nhiều lần, hệ thống tránh việc phải tính toán lại (hoặc tính phí lại) toàn bộ.
Cơ chế phổ biến nhất hiện nay là prompt caching (cơ chế đệm phần đầu của prompt) do các nhà cung cấp LLM lớn hỗ trợ trực tiếp — phần system prompt, tool definitions (định nghĩa các tool), và các đoạn context tĩnh (không đổi giữa các lượt gọi) được đánh dấu để model "nhớ" phần tính toán nội bộ (khái niệm gần với KV cache — bộ nhớ đệm giá trị key/value trong cơ chế attention của transformer) mà không phải xử lý lại từ đầu ở mỗi lượt gọi. Về phía người dùng API, điều này thể hiện qua chi phí: token trong phần được cache thường được tính giá rẻ hơn đáng kể (thường giảm 50-90% tùy nhà cung cấp) so với token mới.
def call_llm_with_cache(conversation_history, system_prompt, tool_definitions):
request = {
"system": [
{
"type": "text",
"text": system_prompt,
"cache_control": {"type": "ephemeral"}, # đánh dấu để provider cache phần này
}
],
"tools": tool_definitions, # tool definitions cũng nên được cache nếu provider hỗ trợ
"messages": conversation_history,
}
response = llm_client.messages.create(**request)
# response.usage sẽ cho biết bao nhiêu token đọc từ cache (cache_read_input_tokens)
# so với bao nhiêu token phải xử lý mới (input_tokens)
log_cache_metrics(response.usage)
return response
Điểm quan trọng cần nắm: prompt caching hoạt động tốt nhất khi phần đầu của prompt ổn định và phần thay đổi được đặt ở cuối. Nếu bạn chèn timestamp hiện tại hay một biến động ngay vào đầu system prompt, bạn vô tình phá vỡ toàn bộ cache — vì cơ chế caching dựa trên việc so khớp tiền tố (prefix matching) chính xác từng token.
Level 2: Tool Result Caching
Đây là caching ở tầng thực thi tool — khi agent gọi một tool (đọc file, gọi API, chạy truy vấn database) với cùng tham số, kết quả trả về được lưu lại và tái sử dụng thay vì thực thi lại toàn bộ.
Khác với Level 1 (vốn do nhà cung cấp LLM vận hành ngầm), tool result caching là thứ bạn tự thiết kế và triển khai trong kiến trúc agent của mình. Cơ chế cốt lõi là dùng content hashing (băm nội dung) — tạo ra một khóa (cache key) duy nhất từ tên tool + tham số đầu vào, tra cứu xem khóa này đã có kết quả lưu sẵn chưa trước khi thực thi thật.
import hashlib
import json
import time
class ToolResultCache:
def __init__(self, ttl_seconds=300):
self.store = {} # trong production nên dùng Redis hoặc key-value store bền vững
self.ttl_seconds = ttl_seconds
def _make_key(self, tool_name: str, arguments: dict) -> str:
# Sắp xếp key của dict để đảm bảo cùng tham số luôn tạo ra cùng hash,
# bất kể thứ tự truyền vào.
normalized = json.dumps(arguments, sort_keys=True)
raw = f"{tool_name}:{normalized}"
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
def get(self, tool_name: str, arguments: dict):
key = self._make_key(tool_name, arguments)
entry = self.store.get(key)
if entry is None:
return None, False # cache miss
result, cached_at = entry
if time.time() - cached_at > self.ttl_seconds:
del self.store[key]
return None, False # đã hết hạn (stale), coi như cache miss
return result, True # cache hit
def set(self, tool_name: str, arguments: dict, result):
key = self._make_key(tool_name, arguments)
self.store[key] = (result, time.time())
def execute_tool_with_cache(tool_name, arguments, tool_registry, cache: ToolResultCache):
cached_result, is_hit = cache.get(tool_name, arguments)
if is_hit:
log_metric("tool_cache_hit", tool_name=tool_name)
return cached_result
log_metric("tool_cache_miss", tool_name=tool_name)
result = tool_registry[tool_name](**arguments)
cache.set(tool_name, arguments, result)
return result
Với ví dụ số cụ thể: giả sử một agent trong một session trung bình gọi read_file 40 lần, nhưng chỉ có 12 file khác nhau thực sự — nghĩa là 28 lần gọi là trùng lặp hoàn toàn (đọc lại file đã đọc, chưa hề thay đổi). Nếu mỗi lần đọc file trung bình tốn 1.500 token đưa vào context, tool result caching loại bỏ được 28 × 1.500 = 42.000 token dư thừa trong một session — có thể chiếm 30-50% tổng token tiêu thụ của session đó, tùy vào độ dài các bước suy luận khác.
Mẹo: Đừng cố cache "mọi thứ" ngay từ đầu. Bắt đầu bằng cách chỉ cache những tool có tính chất đọc (read-only) và không phụ thuộc thời gian thực (ví dụ đọc file local, đọc config) — đây là nhóm an toàn nhất và mang lại lợi ích lớn nhất với rủi ro thấp nhất.
Phân Biệt Operation Nào Cacheable, Operation Nào Không
Không phải mọi tool call đều nên được cache — cache sai chỗ có thể khiến agent hành động dựa trên dữ liệu cũ (stale data) và đưa ra quyết định sai, đôi khi nguy hiểm hơn cả việc lãng phí token. Việc đầu tiên trước khi triển khai bất kỳ cơ chế caching nào là phân loại rõ operation nào an toàn để cache, operation nào tuyệt đối không.
Nhóm an toàn để cache (cacheable) — có ba đặc điểm chung:
- Idempotent và deterministic (không thay đổi trạng thái hệ thống, và cùng input luôn ra cùng output): đọc file, đọc schema database, tra cứu thông tin tĩnh (danh sách quốc gia, tỷ giá đã lock tại một thời điểm lịch sử), chạy phân tích code tĩnh (static analysis) trên một file chưa đổi.
- Chi phí tính lại cao so với lợi ích của dữ liệu mới nhất: chạy toàn bộ test suite của một dự án lớn (mất vài phút, tốn nhiều token log output) khi không có commit mới nào từ lần chạy trước.
- Có ranh giới hiệu lực rõ ràng (TTL hoặc invalidation trigger — điều kiện làm mất hiệu lực cache) mà bạn kiểm soát được: kết quả build, kết quả lint, kết quả phân tích dependency — tất cả đều "hết hạn" khi file nguồn thay đổi, và bạn có thể phát hiện điều đó bằng hash nội dung file.
Nhóm không nên cache (non-cacheable) — cũng có ba đặc điểm chung:
- Có side effect (tác dụng phụ) làm thay đổi trạng thái: gửi email, tạo order, ghi vào database, gọi payment gateway — cache những operation này là sai về bản chất, vì mục đích của chúng không phải là "trả về giá trị" mà là "thực thi một hành động", và hành động không thể "cache" theo nghĩa tái sử dụng.
- Dữ liệu thay đổi liên tục theo thời gian thực: giá cổ phiếu, trạng thái tồn kho, vị trí GPS, kết quả tìm kiếm web — cache những thứ này (trừ khi TTL cực ngắn, tính bằng giây) khiến agent đưa ra quyết định dựa trên thông tin đã lỗi thời ngay lúc đọc.
- Kết quả phụ thuộc vào context ngoài tham số truyền vào (ví dụ phụ thuộc user hiện tại đang đăng nhập, session token, quyền truy cập động) — cache theo tham số bề mặt sẽ vô tình trả kết quả của user A cho user B nếu cache key không bao gồm đủ thông tin phân biệt.
Có một nhóm trung gian đáng chú ý: các lệnh gọi API bên ngoài (ví dụ tra cứu thông tin công ty từ một API b2b) — về lý thuyết là "đọc", nhưng dữ liệu vẫn có thể thay đổi (dù chậm). Với nhóm này, giải pháp thực tế là cache có TTL vừa phải (vài phút đến vài giờ tùy tốc độ thay đổi thực tế của dữ liệu), không cache vô thời hạn và không bỏ qua cache hoàn toàn.
TOOL_CACHE_POLICY = {
"read_file": {"cacheable": True, "ttl": None}, # None = cache tới khi file đổi (theo hash)
"run_static_analysis": {"cacheable": True, "ttl": None}, # phụ thuộc nội dung file, không phụ thuộc thời gian
"get_exchange_rate": {"cacheable": True, "ttl": 300}, # 5 phút — đủ mới cho hầu hết use case
"search_web": {"cacheable": True, "ttl": 900}, # 15 phút — kết quả tìm kiếm ít đổi trong ngắn hạn
"get_current_time": {"cacheable": False, "ttl": 0}, # luôn phải mới
"send_email": {"cacheable": False, "ttl": 0}, # có side effect, không được cache
"create_order": {"cacheable": False, "ttl": 0}, # có side effect, không được cache
"get_inventory_level": {"cacheable": False, "ttl": 0}, # thay đổi liên tục, rủi ro cao nếu stale
}
Mẹo: Khi không chắc một tool có nên cache hay không, hãy tự hỏi "nếu agent hành động dựa trên kết quả này từ 10 phút trước, hậu quả tệ nhất là gì?". Nếu câu trả lời là "không sao, chỉ hơi lỗi thời" thì cache được. Nếu câu trả lời liên quan tới tiền, dữ liệu khách hàng, hoặc an toàn hệ thống, đừng cache — hoặc chỉ cache với TTL rất ngắn và luôn có cách invalidate chủ động.
Deduplication: Phát Hiện Và Ngăn Chặn Operation Trùng Lặp
Caching giải quyết vấn đề "đã tính rồi, đừng tính lại" khi tham số giống hệt nhau. Nhưng trong thực tế, agent thường gọi những operation về bản chất là giống nhau nhưng khác nhau ở bề mặt — khác thứ tự tham số, khác cách viết đường dẫn, hoặc khác cách diễn đạt của một câu hỏi ngữ nghĩa tương đương. Đây là lúc deduplication (loại bỏ trùng lặp) cần một tầng xử lý riêng, thông minh hơn so khớp hash đơn giản.
Path and Argument Normalization
Chuẩn hóa đường dẫn và tham số (path and argument normalization) là kỹ thuật deduplication đơn giản nhất nhưng lại xử lý được một lượng lớn trường hợp trùng lặp giả (false negative — trông khác nhau nhưng thực chất giống nhau) trong thực tế. Vấn đề điển hình: agent gọi read_file("./src/utils.py"), rồi sau đó gọi read_file("src/utils.py"), rồi lại gọi read_file("/home/user/project/src/utils.py") — cả ba đều trỏ tới đúng một file, nhưng nếu cache key được tạo trực tiếp từ chuỗi tham số thô, cả ba sẽ tạo ra ba cache entry khác nhau và cache hoàn toàn không phát huy hiệu quả.
import os
def normalize_arguments(tool_name: str, arguments: dict) -> dict:
"""
Chuẩn hóa tham số trước khi tạo cache key, để các cách gọi
tương đương về ngữ nghĩa tạo ra cùng một key.
"""
normalized = dict(arguments)
if tool_name in ("read_file", "write_file", "run_static_analysis"):
if "path" in normalized:
# Quy về đường dẫn tuyệt đối, resolve symlink, bỏ ./ và ../ dư thừa
normalized["path"] = os.path.realpath(normalized["path"])
if tool_name == "search_code":
if "query" in normalized:
# Chuẩn hóa khoảng trắng và chữ hoa/thường cho truy vấn tìm kiếm
normalized["query"] = " ".join(normalized["query"].lower().split())
if tool_name == "run_shell_command":
if "command" in normalized:
# Loại bỏ khoảng trắng dư thừa không ảnh hưởng tới ngữ nghĩa lệnh
normalized["command"] = normalized["command"].strip()
return normalized
def make_cache_key(tool_name: str, raw_arguments: dict) -> str:
normalized_args = normalize_arguments(tool_name, raw_arguments)
payload = json.dumps(normalized_args, sort_keys=True)
return hashlib.sha256(f"{tool_name}:{payload}".encode()).hexdigest()
Ngoài đường dẫn file, các trường hợp cần chuẩn hóa phổ biến khác gồm: URL (bỏ query param không ảnh hưởng kết quả như utm_source, chuẩn hóa http:// vs https:// nếu tương đương), số (chuẩn hóa "5" và 5 về cùng kiểu dữ liệu), và tên trường không phân biệt thứ tự (như đã xử lý bằng sort_keys=True ở trên).
Semantic Deduplication with Embeddings
Chuẩn hóa tham số xử lý tốt các trường hợp trùng lặp ở tầng cú pháp, nhưng không giúp được gì khi hai lời gọi trùng lặp về ngữ nghĩa mà khác hoàn toàn về câu chữ — ví dụ agent gọi search_docs("how to configure rate limiting") rồi vài vòng lặp sau gọi search_docs("setting up API rate limits"). Hai truy vấn này rất có thể trả về đúng cùng một tập tài liệu liên quan, nhưng không hash nào so khớp được vì chuỗi ký tự khác nhau hoàn toàn.
Semantic deduplication (loại bỏ trùng lặp theo ngữ nghĩa) giải quyết vấn đề này bằng embeddings (vector biểu diễn ngữ nghĩa) — chuyển mỗi truy vấn thành một vector số, rồi so sánh độ tương đồng (similarity, thường dùng cosine similarity) giữa vector mới với các vector đã lưu trong cache. Nếu độ tương đồng vượt một ngưỡng (similarity threshold) đã định, coi đó là trùng lặp và tái sử dụng kết quả cũ.
import numpy as np
class SemanticCache:
def __init__(self, embed_fn, similarity_threshold=0.92):
self.embed_fn = embed_fn # hàm gọi embedding model, ví dụ text-embedding-3-small
self.threshold = similarity_threshold
self.entries = [] # list of (embedding_vector, query_text, result)
def _cosine_similarity(self, vec_a, vec_b) -> float:
return float(
np.dot(vec_a, vec_b) / (np.linalg.norm(vec_a) * np.linalg.norm(vec_b))
)
def find_similar(self, query: str):
query_vector = self.embed_fn(query)
best_match = None
best_score = 0.0
for stored_vector, stored_query, stored_result in self.entries:
score = self._cosine_similarity(query_vector, stored_vector)
if score > best_score:
best_score = score
best_match = (stored_query, stored_result)
if best_score >= self.threshold:
return best_match, best_score # cache hit ngữ nghĩa
return None, best_score # không đủ tương đồng, coi là cache miss
def add(self, query: str, result):
vector = self.embed_fn(query)
self.entries.append((vector, query, result))
def search_docs_with_semantic_cache(query: str, semantic_cache: SemanticCache, search_fn):
match, score = semantic_cache.find_similar(query)
if match is not None:
matched_query, cached_result = match
log_metric("semantic_cache_hit", score=score, matched_query=matched_query)
return cached_result
result = search_fn(query)
semantic_cache.add(query, result)
return result
Ngưỡng tương đồng (threshold) là tham số quan trọng nhất cần điều chỉnh cẩn thận: đặt quá thấp (ví dụ 0.75) dẫn tới false positive (coi hai truy vấn khác nhau là trùng, trả sai kết quả) — đặt quá cao (ví dụ 0.98) khiến semantic cache gần như chỉ bắt được trùng lặp gần y hệt, mất hết lợi ích so với hash thông thường. Trong thực tế, ngưỡng 0.90-0.93 thường là điểm khởi đầu hợp lý cho truy vấn ngôn ngữ tự nhiên ngắn, cần được tinh chỉnh dựa trên dữ liệu thật của domain cụ thể — nên luôn có bộ test case gồm các cặp câu "nên coi là trùng" và "không nên coi là trùng" để đánh giá threshold trước khi đưa vào production.
Mẹo: Luôn lưu lại
matched_querygốc mỗi khi semantic cache trả về cache hit, và log riêng các trường hợp có score gần ngưỡng (ví dụ 0.88-0.94) để review định kỳ — đây là cách thực tế nhất để phát hiện threshold đang đặt sai và tinh chỉnh lại theo dữ liệu thật, thay vì đoán một số cố định rồi để yên vĩnh viễn.
Cross-Session Caching: Giữ Lại Kết Quả Giữa Các Lần Chạy Agent
Tất cả các kỹ thuật caching ở trên đều mang lại lợi ích trong một session — nhưng lợi ích lớn hơn nhiều nằm ở cross-session caching (caching xuyên suốt nhiều lần chạy agent khác nhau), vì trong thực tế một codebase, một tài liệu, hay một tập dữ liệu thường được nhiều agent, nhiều session, nhiều người dùng khác nhau truy cập lặp lại trong một khoảng thời gian dài mà nội dung không đổi.
Ví dụ điển hình: một coding agent phân tích cấu trúc dependency của một file lớn (analyze_dependencies("src/core/engine.py")) — việc này có thể tốn 3.000-5.000 token đầu vào (nội dung file) cộng với thời gian xử lý đáng kể. Nếu 5 kỹ sư khác nhau, trong 5 session khác nhau suốt một ngày, đều yêu cầu agent phân tích file này (và file chưa hề thay đổi), việc tính toán lại 5 lần là lãng phí hoàn toàn có thể tránh được bằng một cache lưu trữ bền vững (persistent), sống lâu hơn một session đơn lẻ.
Thiết kế cross-session cache khác caching trong-session ở ba điểm quan trọng: (1) cần lưu trữ bền vững (file system, Redis, hoặc database — không chỉ trong bộ nhớ của một process), (2) cần cơ chế invalidation dựa trên nội dung thực tế thay đổi (thường dùng hash nội dung file, không dùng timestamp — vì timestamp có thể đổi mà nội dung không đổi, ví dụ sau một lần git checkout), và (3) cần cân nhắc chia sẻ cache giữa nhiều agent/nhiều người dùng có đúng quyền truy cập dữ liệu đó hay không (tránh rò rỉ dữ liệu giữa các tenant/khách hàng khác nhau trong hệ thống multi-tenant).
import hashlib
import os
import json
import sqlite3
class PersistentFileAnalysisCache:
"""
Cache bền vững cho kết quả phân tích file, dùng file content hash làm khóa
thay vì đường dẫn hoặc timestamp — đảm bảo cache tự động invalidate đúng lúc
khi nội dung file thay đổi, bất kể tên file, vị trí, hay mtime.
"""
def __init__(self, db_path="agent_cache.sqlite"):
self.conn = sqlite3.connect(db_path)
self.conn.execute("""
CREATE TABLE IF NOT EXISTS file_analysis_cache (
content_hash TEXT PRIMARY KEY,
analysis_type TEXT NOT NULL,
result_json TEXT NOT NULL,
created_at REAL NOT NULL
)
""")
self.conn.commit()
def _hash_file_content(self, file_path: str) -> str:
with open(file_path, "rb") as f:
return hashlib.sha256(f.read()).hexdigest()
def get_analysis(self, file_path: str, analysis_type: str):
content_hash = self._hash_file_content(file_path)
cursor = self.conn.execute(
"SELECT result_json FROM file_analysis_cache "
"WHERE content_hash = ? AND analysis_type = ?",
(content_hash, analysis_type),
)
row = cursor.fetchone()
if row is None:
return None # cache miss — file mới, hoặc nội dung đã đổi, hoặc analysis_type khác
return json.loads(row[0])
def save_analysis(self, file_path: str, analysis_type: str, result: dict):
content_hash = self._hash_file_content(file_path)
self.conn.execute(
"INSERT OR REPLACE INTO file_analysis_cache "
"(content_hash, analysis_type, result_json, created_at) VALUES (?, ?, ?, ?)",
(content_hash, analysis_type, json.dumps(result), os.path.getmtime(file_path)),
)
self.conn.commit()
def analyze_dependencies_with_cache(file_path: str, cache: PersistentFileAnalysisCache):
cached = cache.get_analysis(file_path, "dependency_analysis")
if cached is not None:
log_metric("cross_session_cache_hit", file_path=file_path)
return cached
result = run_dependency_analysis(file_path) # tốn nhiều token + thời gian xử lý
cache.save_analysis(file_path, "dependency_analysis", result)
return result
Với ví dụ số minh họa lợi ích: giả sử một team có 20 kỹ sư, mỗi người chạy trung bình 8 agent session/ngày, và 30% các session đó cần phân tích dependency cho một trong số khoảng 200 file "nóng" (hot files — được sửa và truy vấn thường xuyên) của codebase. Nếu không có cross-session cache, số lần phân tích thực tế mỗi ngày có thể lên tới 48 lần (20 × 8 × 30%), trong khi số file thực sự thay đổi trong một ngày làm việc thường chỉ khoảng 10-15 file. Với cross-session cache theo content hash, số lần phân tích thực sự cần chạy giảm xuống gần bằng số lần file thực sự đổi — tức là giảm hơn 70% số lần gọi phân tích, tương ứng tiết kiệm hàng trăm nghìn token input mỗi ngày ở quy mô team.
Mẹo: Đặt TTL tối đa hợp lý cho cross-session cache dù dùng content hash (ví dụ 7-30 ngày) để tự động dọn các entry cũ không còn liên quan (file đã bị xóa khỏi codebase từ lâu) — tránh cache phình to vô hạn theo thời gian mà không ai chủ động xóa.
The Tool Result Summary Pattern
Có một vấn đề tinh vi mà caching thuần túy không giải quyết được: đôi khi kết quả tool trả về quá lớn để đưa nguyên vẹn vào context ở mọi lần agent cần tham chiếu tới nó, dù đó là cache hit hay không. Đọc một file 3.000 dòng để tìm một hàm cụ thể, rồi giữ nguyên toàn bộ 3.000 dòng đó trong context suốt các vòng lặp còn lại của session, làm phình to context window một cách không cần thiết — dù kỹ thuật thì đó không phải là "trùng lặp" theo nghĩa gọi lại tool.
The Tool Result Summary Pattern (mẫu thiết kế tóm tắt kết quả tool) giải quyết vấn đề này bằng cách tách biệt hai thứ: full result (kết quả đầy đủ, lưu trong cache/storage) và summary (bản tóm tắt ngắn, đưa vào context để agent tham chiếu). Agent chỉ nhìn thấy summary trong lịch sử hội thoại; nếu cần chi tiết đầy đủ trở lại, nó gọi một tool riêng (ví dụ expand_result(result_id)) để lấy lại full result từ cache bằng một ID tham chiếu, chứ không phải nhồi lại toàn bộ nội dung vào context ngay từ đầu.
import uuid
class ResultSummaryStore:
def __init__(self):
self.full_results = {} # result_id -> full content (có thể rất lớn)
def store_and_summarize(self, tool_name: str, full_result: str, summarize_fn) -> dict:
result_id = str(uuid.uuid4())
self.full_results[result_id] = full_result
summary = summarize_fn(tool_name, full_result)
return {
"result_id": result_id,
"summary": summary,
"full_length_chars": len(full_result),
"note": "Call expand_result(result_id) if you need the full content.",
}
def expand(self, result_id: str) -> str:
return self.full_results.get(result_id, "Result not found or expired.")
def summarize_file_read(tool_name: str, full_result: str) -> str:
lines = full_result.splitlines()
if len(lines) <= 40:
return full_result # nội dung đã ngắn, không cần tóm tắt
# Tóm tắt: giữ 15 dòng đầu (thường có import/header) + liệt kê tên hàm/class tìm được
# + 10 dòng cuối, thay phần giữa bằng placeholder có thông tin số dòng bị ẩn.
head = "\n".join(lines[:15])
tail = "\n".join(lines[-10:])
function_names = extract_function_and_class_names(full_result) # dùng regex/AST parser
return (
f"[File has {len(lines)} lines total. Showing summary.]\n"
f"--- head ---\n{head}\n"
f"--- detected functions/classes ---\n{', '.join(function_names)}\n"
f"--- tail ---\n{tail}"
)
def read_file_with_summary(path: str, store: ResultSummaryStore, cache: ToolResultCache):
cached, is_hit = cache.get("read_file", {"path": path})
if is_hit:
return cached # đã ở dạng summary từ lần trước, không cần tóm tắt lại
full_content = open(path, "r").read()
summarized_response = store.store_and_summarize("read_file", full_content, summarize_file_read)
cache.set("read_file", {"path": path}, summarized_response)
return summarized_response
Pattern này đặc biệt hữu ích khi kết hợp với caching: cache lưu cả full result và summary, nhưng context của agent chỉ nhận summary — tối ưu đồng thời hai chiều (giảm số lần gọi tool trùng lặp VÀ giảm kích thước mỗi lần tool result được đưa vào context). Với các tool trả về output lớn có tính lặp lại cấu trúc cao (log file, kết quả test suite, kết quả grep trên toàn repo), summary pattern thường giảm được 70-90% số token cần đưa vào context cho mỗi lần tham chiếu, trong khi agent vẫn có đường "drill down" (đi sâu vào chi tiết) khi thực sự cần.
Mẹo: Đầu tư thời gian viết một
summarize_fntốt riêng cho từng loại tool output khác nhau (file code, log, kết quả test, response JSON từ API) — một summary generic dùng chung cho mọi loại dữ liệu thường tệ hơn nhiều so với không tóm tắt gì cả, vì nó dễ bỏ sót đúng phần thông tin agent cần ở bước tiếp theo.
Đo Lường Hiệu Quả Của Cache
Triển khai caching mà không đo lường hiệu quả là một sai lầm phổ biến — bạn sẽ không biết liệu cache đang thực sự tiết kiệm token, hay đang âm thầm gây ra lỗi do trả dữ liệu cũ (stale), hoặc tệ hơn, đang chiếm nhiều tài nguyên hệ thống hơn lợi ích nó mang lại. Cần một bộ chỉ số (metrics) rõ ràng, theo dõi liên tục.
Bốn chỉ số cốt lõi cần theo dõi:
1. Cache hit rate (tỷ lệ cache hit) — số lần cache hit chia cho tổng số lần tra cứu cache. Đây là chỉ số cơ bản nhất, nhưng cần tách riêng theo từng loại tool/operation, vì hit rate trung bình toàn hệ thống có thể che giấu sự khác biệt lớn (ví dụ read_file có hit rate 65% nhưng search_web chỉ có 8%, và con số trung bình gộp lại không nói lên điều gì hữu ích để hành động).
2. Token saved (số token tiết kiệm được) — với mỗi cache hit, tính số token mà lẽ ra phải xử lý nếu không có cache (ước lượng dựa trên độ dài kết quả tương tự gần nhất, hoặc dựa trên độ dài trung bình lịch sử của loại tool đó). Đây là chỉ số quy đổi trực tiếp ra chi phí, dễ trình bày với stakeholder không có background kỹ thuật.
3. Cache staleness incidents (số lần cache trả dữ liệu cũ gây ảnh hưởng) — số lần agent hành động dựa trên cache hit nhưng dữ liệu thực tế đã thay đổi (phát hiện qua báo cáo lỗi, hoặc qua so sánh định kỳ giữa cached value và giá trị thực). Chỉ số này quan trọng không kém hit rate — một hệ thống có hit rate 90% nhưng staleness incident cao là một hệ thống caching tồi, dù nhìn qua tưởng đang tối ưu tốt.
4. Cache overhead (chi phí vận hành cache) — thời gian tính hash, thời gian tra cứu, dung lượng lưu trữ. Với hầu hết use case, overhead này nhỏ tới mức không đáng kể so với lợi ích, nhưng với semantic cache dùng embeddings, chi phí gọi embedding model cho mỗi lần tra cứu cần được tính vào — nếu chi phí gọi embedding model gần bằng chi phí thực thi tool gốc, semantic cache mất hết ý nghĩa.
class CacheMetricsTracker:
def __init__(self):
self.stats = {} # tool_name -> {"hits": 0, "misses": 0, "tokens_saved": 0}
def record(self, tool_name: str, is_hit: bool, estimated_tokens: int = 0):
entry = self.stats.setdefault(tool_name, {"hits": 0, "misses": 0, "tokens_saved": 0})
if is_hit:
entry["hits"] += 1
entry["tokens_saved"] += estimated_tokens
else:
entry["misses"] += 1
def report(self):
for tool_name, data in self.stats.items():
total = data["hits"] + data["misses"]
hit_rate = (data["hits"] / total * 100) if total > 0 else 0
print(
f"{tool_name}: hit_rate={hit_rate:.1f}% "
f"({data['hits']}/{total}), tokens_saved={data['tokens_saved']}"
)
Một ví dụ số tổng hợp thực tế: một team sau khi triển khai đủ ba tầng caching (LLM prompt caching, tool result caching, cross-session caching cho file analysis) trong một tháng đo được: hit rate trung bình cho read_file là 58%, cho run_static_analysis là 71% (do file ít đổi hơn tần suất agent cần tham chiếu), và tổng token input giảm 34% so với baseline tháng trước — trong khi phát hiện 2 trường hợp staleness incident (do quên invalidate cache khi file được sửa qua một script bên ngoài agent, không đi qua tool write_file được theo dõi) — dẫn tới việc bổ sung một file-watcher chủ động invalidate cache bất kể nguồn thay đổi từ đâu.
Mẹo: Thiết lập dashboard theo dõi hit rate và staleness incident theo thời gian thực (hoặc ít nhất theo ngày), và đặt alert khi staleness incident tăng bất thường — đây là dấu hiệu sớm nhất cho biết logic invalidation của bạn đang có lỗ hổng trước khi nó gây ra sự cố lớn hơn ở production.
Tổng Kết
- Agent không tự động "nhớ" kết quả giữa các vòng lặp hoặc giữa các session, dẫn tới việc gọi lại đúng tool với đúng tham số nhiều lần — đây là nguồn lãng phí token rất phổ biến nhưng dễ bị bỏ qua vì nó "âm thầm" xảy ra trong logic điều phối, không nằm trong prompt hay code nghiệp vụ.
- Có hai cấp độ caching độc lập: LLM response caching (prompt caching, do nhà cung cấp model vận hành, tối ưu phần prompt tĩnh lặp lại) và tool result caching (do bạn tự thiết kế, dùng content hashing để tránh thực thi lại tool với cùng tham số).
- Chỉ cache operation idempotent, deterministic, và có ranh giới invalidation rõ ràng — tuyệt đối không cache operation có side effect (gửi email, tạo order) hoặc dữ liệu thay đổi liên tục theo thời gian thực mà không có TTL phù hợp.
- Deduplication cần vượt ra khỏi so khớp hash đơn giản: normalization (chuẩn hóa đường dẫn, tham số) bắt được trùng lặp cú pháp, còn semantic deduplication bằng embeddings với similarity threshold bắt được trùng lặp về ngữ nghĩa mà câu chữ khác nhau.
- Cross-session caching, dùng content hash của file (không dùng path hay timestamp) làm khóa, giúp nhiều session/nhiều người dùng chia sẻ lại kết quả phân tích cho cùng nội dung không đổi — đây là tầng mang lại lợi ích lớn nhất ở quy mô team.
- Tool Result Summary Pattern tách full result (lưu trong cache) và summary (đưa vào context), giải quyết vấn đề context window bị phình to bởi các tool output lớn, kể cả khi đó không phải là một cuộc gọi trùng lặp.
- Luôn đo lường bằng bộ chỉ số cụ thể — cache hit rate theo từng tool, token saved, staleness incident, và cache overhead — vì caching không đo lường được không khác gì caching không tồn tại, và tệ hơn, có thể đang gây hại âm thầm qua dữ liệu cũ mà không ai biết.