Ở bài 1 tôi có nhắc đến memory như là “sổ ghi chép”. Bài này đi sâu hơn vào cái sổ đó, vì nó thực ra gồm nhiều quyển riêng biệt, và nhầm quyển là một trong những lý do thường xuyên nhất khiến agent đốt tiền hoặc mất context.
Analogy tôi thấy dễ nhớ nhất: agent giống một nhân viên mới đang làm việc trong một văn phòng không có bộ nhớ dài hạn. Mỗi buổi sáng, họ chỉ nhớ những gì đọc trong tập hồ sơ để trên bàn. Nếu tập hồ sơ quá dày, họ phải bỏ bớt trang cũ để đọc trang mới. Nếu muốn nhớ thứ gì lâu hơn, phải ghi vào sổ tay riêng. Và nếu cần tìm một thông tin từ tuần trước, phải đi mở ngăn kéo lưu trữ.
Ba quyển sổ đó trong thực tế là: conversation history (tập hồ sơ trên bàn), scratchpad (sổ tay), và vector DB (ngăn kéo lưu trữ).
Phần 1: Conversation history và context window
Context window là gì
Mỗi lần gọi LLM, bạn gửi một danh sách messages. Toàn bộ danh sách đó phải nằm trong “context window”: giới hạn token tối đa mà model có thể xử lý trong một lần gọi.
Các mốc cần nhớ năm 2026:
| Model | Context window |
|---|---|
| Claude Sonnet 4.6 / Claude Opus 4.7 | 200K tokens |
| Gemini 1.5 Pro / 2.0 | 1M tokens |
| GPT-4o | 128K tokens |
200K tokens nghe lớn, nhưng trong thực tế nó hết nhanh hơn bạn nghĩ. Một vòng lặp agent đơn giản, mỗi tool call thêm khoảng 500-2000 token vào history. Task phức tạp 50 vòng lặp: 50 × 1500 token trung bình = 75K token chỉ cho history. Cộng thêm system prompt (5-10K), tool schemas (2-5K), và kết quả của các tool lớn như đọc file hay HTML của trang web (dễ vượt 20K mỗi lần). Tổng cộng 200K không còn xa nữa.
Pattern message roles
Anthropic API dùng role user và assistant xen kẽ nhau. Tool results được nhét vào role user (không phải role riêng):
messages = [
# Vòng 1: user gửi task
{"role": "user", "content": "Tìm tất cả file Python trong /repo có import requests"},
# LLM quyết định gọi tool
{"role": "assistant", "content": [
{"type": "text", "text": "Tôi sẽ list directory trước."},
{"type": "tool_use", "id": "tool_1", "name": "list_dir",
"input": {"path": "/repo"}}
]},
# Tool result được nhét vào role user
{"role": "user", "content": [
{"type": "tool_result", "tool_use_id": "tool_1",
"content": "main.py\nutils.py\napi_client.py\nREADME.md"}
]},
# LLM tiếp tục gọi tool khác
{"role": "assistant", "content": [
{"type": "tool_use", "id": "tool_2", "name": "read_file",
"input": {"path": "/repo/api_client.py"}}
]},
# Tool result vòng 2
{"role": "user", "content": [
{"type": "tool_result", "tool_use_id": "tool_2",
"content": "import requests\nimport json\n\ndef get_user(user_id):\n..."}
]},
# LLM trả kết quả cuối
{"role": "assistant", "content": "File api_client.py import requests. Tổng cộng 1 file."}
]
History này tăng trưởng tuyến tính theo số vòng lặp. Vấn đề xuất hiện khi history tiến gần giới hạn 200K.
Phần 2: Hai chiến lược khi history quá dài
Sliding window truncation
Đơn giản nhất: bỏ bớt message cũ, giữ lại N message gần nhất. Giống như bạn chỉ đọc 20 trang cuối của tập hồ sơ.
import anthropic
from typing import Any
client = anthropic.Anthropic()
def trim_history(messages: list[dict], max_tokens: int = 150_000) -> list[dict]:
"""Giữ message gần nhất, ước tính token đơn giản bằng chia 4."""
# Ước tính token: ~4 chars/token (thô nhưng đủ dùng cho truncation logic)
def estimate_tokens(msg: dict) -> int:
content = msg.get("content", "")
if isinstance(content, str):
return len(content) // 4
if isinstance(content, list):
return sum(len(str(block)) // 4 for block in content)
return 0
total = sum(estimate_tokens(m) for m in messages)
if total <= max_tokens:
return messages
# Bỏ từ đầu nhưng luôn giữ message đầu tiên (task gốc)
first = messages[:1]
rest = messages[1:]
while rest and sum(estimate_tokens(m) for m in first + rest) > max_tokens:
rest = rest[2:] # Bỏ theo cặp user+assistant để tránh mất cân đối roles
return first + rest
def agent_loop(user_input: str, max_iterations: int = 10) -> str:
messages: list[dict[str, Any]] = [{"role": "user", "content": user_input}]
for i in range(max_iterations):
# Trim trước mỗi lần gọi
trimmed = trim_history(messages)
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=4096,
messages=trimmed,
)
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason == "end_turn":
return response.content[0].text
return "Max iterations reached"
Sliding window nhanh và đơn giản. Nhược điểm: khi bỏ message cũ, LLM mất context về những gì đã làm. Nếu task cần nhớ quyết định từ vòng 2 trong khi đang ở vòng 30, LLM sẽ không biết.
Hierarchical summarization
Thay vì bỏ luôn, tóm tắt phần bị bỏ lại thành một đoạn ngắn rồi chèn vào đầu history. LLM vẫn biết đã làm gì, nhưng ở dạng nén.
def summarize_old_messages(messages: list[dict]) -> str:
"""Dùng LLM tóm tắt phần history cũ thành 1 đoạn."""
summary_prompt = (
"Tóm tắt ngắn gọn những hành động và kết quả đã thực hiện trong đoạn hội thoại sau. "
"Chỉ giữ thông tin quan trọng cho việc tiếp tục task. Tối đa 200 từ.\n\n"
)
content_to_summarize = "\n".join(
f"{m['role']}: {m['content']}" if isinstance(m["content"], str)
else f"{m['role']}: [tool interaction]"
for m in messages
)
resp = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=512,
messages=[{"role": "user", "content": summary_prompt + content_to_summarize}]
)
return resp.content[0].text
def trim_with_summary(
messages: list[dict],
keep_recent: int = 10,
max_tokens: int = 150_000
) -> list[dict]:
"""Giữ N message gần nhất, tóm tắt phần còn lại."""
if len(messages) <= keep_recent:
return messages
old = messages[:-keep_recent]
recent = messages[-keep_recent:]
summary_text = summarize_old_messages(old)
summary_message = {
"role": "user",
"content": f"[Tóm tắt context trước đó]: {summary_text}"
}
return [summary_message] + recent
Summarization tốt hơn sliding window về chất lượng context, nhưng đắt hơn (mỗi lần summarize tốn thêm một lần gọi LLM). Pattern thực tế: chỉ trigger summarization khi token vượt ngưỡng, không phải mỗi vòng lặp.
Phần 3: Scratchpad, LLM tự ghi note
Sliding window và summarization đều cố bảo toàn history thụ động. Scratchpad là cách tiếp cận khác: cho LLM chủ động ghi note về những thứ nó cần nhớ.
Analogy: thay vì cố nhớ mọi chi tiết của cuộc họp, bạn ghi vào sticky note: “cần check lại với team X”, “DB schema thay đổi ở bảng users”. Khi cần, bạn đọc sticky note thay vì cố nhớ lại toàn bộ cuộc họp.
TOOLS_WITH_SCRATCHPAD = [
{
"name": "update_scratchpad",
"description": (
"Ghi note quan trọng vào scratchpad để dùng ở các bước sau. "
"Dùng khi tìm được thông tin quan trọng, quyết định cần nhớ, "
"hoặc kết quả trung gian cần tham chiếu lại."
),
"input_schema": {
"type": "object",
"properties": {
"note": {
"type": "string",
"description": "Nội dung cần ghi nhớ, ngắn gọn và cụ thể"
}
},
"required": ["note"]
}
},
{
"name": "read_scratchpad",
"description": "Đọc tất cả note đã ghi trong scratchpad",
"input_schema": {"type": "object", "properties": {}}
},
]
scratchpad_notes: list[str] = []
def execute_scratchpad_tool(name: str, args: dict) -> str:
if name == "update_scratchpad":
scratchpad_notes.append(args["note"])
return f"Đã ghi: {args['note']}"
if name == "read_scratchpad":
if not scratchpad_notes:
return "Scratchpad trống"
return "\n".join(f"- {note}" for note in scratchpad_notes)
return "Tool không tồn tại"
Ưu điểm của scratchpad: nội dung rất nhỏ gọn (chỉ những gì LLM tự quyết định là quan trọng), và LLM có thể đọc lại bất kỳ lúc nào mà không cần toàn bộ history.
Nhược điểm: LLM có thể quên ghi, hoặc ghi quá nhiều và scratchpad trở nên nhiễu. Giải pháp: nhắc rõ trong system prompt khi nào nên dùng update_scratchpad.
Phần 4: Long-term memory và vector DB
History và scratchpad là memory trong một session. Khi session kết thúc, mọi thứ biến mất. Long-term memory giải quyết vấn đề này: lưu thông tin ra ngoài, có thể là database, file, hoặc vector store.
Vector store là lựa chọn phổ biến nhất vì nó hỗ trợ semantic search: tìm thông tin theo nghĩa, không phải theo từ khóa chính xác.
# Simplified pattern: lưu và tìm lại memories
# Bài 14 sẽ đi sâu vào RAG cho agents toàn diện hơn
from anthropic import Anthropic
import json
client = Anthropic()
# Long-term store đơn giản dùng file (production dùng vector DB như pgvector, Qdrant)
MEMORY_FILE = "agent_memory.json"
def save_to_long_term(key: str, value: str) -> None:
try:
with open(MEMORY_FILE) as f:
memory = json.load(f)
except FileNotFoundError:
memory = {}
memory[key] = value
with open(MEMORY_FILE, "w") as f:
json.dump(memory, f, ensure_ascii=False, indent=2)
def load_relevant_memories(query: str) -> str:
"""Production: dùng embedding + vector search. Demo: trả toàn bộ."""
try:
with open(MEMORY_FILE) as f:
memory = json.load(f)
return "\n".join(f"{k}: {v}" for k, v in memory.items())
except FileNotFoundError:
return "Chưa có memory nào."
Pattern thực tế với vector DB: trước mỗi task, retrieve top-K memories liên quan nhất, nhét vào system prompt. Sau khi task xong, lưu những gì quan trọng vào store.
Bài 14, RAG cho agents: retrieval trong vòng lặp, sẽ đi sâu toàn diện hơn: embedding, chunking, reranking, và cách tích hợp retrieval vào control loop mà không làm chậm latency.
Phần 5: Pitfall token cost tăng phi tuyến
Đây là câu chuyện thật.
Một agent tôi deploy để phân tích log server. Task đơn giản: đọc log file, tìm pattern lỗi, summarize. Chạy ổn tuần đầu. Tuần thứ 3, cost tự nhiên tăng gấp 3. Cùng số lần chạy, cùng loại task.
Lý do: agent được thiết kế để “học” từ các lần chạy trước bằng cách nhét toàn bộ history của 10 lần chạy gần nhất vào context mỗi lần gọi LLM mới. History tích lũy từ tuần 1 đến tuần 3 làm prefix của mỗi request tăng từ 5K lên 50K token.
Vấn đề không phải ở 50K token đó. Vấn đề là prefix lớn không được cache sẽ tính full price. Anthropic tính tiền theo input token mỗi lần gọi. Nếu 50K token prefix được gửi lại 20 lần trong một lần agent chạy (20 vòng lặp), đó là 1 triệu token input chỉ từ prefix. Với giá Claude Sonnet 4.6, input base price là $3/MTok: $3 chỉ từ prefix repeat, chưa kể output.
Cách fix: prompt caching.
import anthropic
client = anthropic.Anthropic()
# System prompt + long-term context được cache
# cache_control chỉ hỗ trợ type "ephemeral" (TTL 5 phút, gia hạn nếu dùng tiếp)
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=4096,
system=[
{
"type": "text",
"text": "You are a log analysis agent...",
},
{
"type": "text",
"text": long_term_context, # 50K token context từ các lần chạy trước
"cache_control": {"type": "ephemeral"} # Cache phần này
}
],
messages=current_messages, # Chỉ history của lần chạy hiện tại
)
# Kiểm tra cache hit
usage = response.usage
print(f"Input: {usage.input_tokens} tokens")
print(f"Cache write: {getattr(usage, 'cache_creation_input_tokens', 0)} tokens")
print(f"Cache read: {getattr(usage, 'cache_read_input_tokens', 0)} tokens")
# Cache read tính giá 0.1x, cache write tính giá 1.25x, chỉ xảy ra lần đầu
Khi prefix 50K token được cache: lần đầu ghi cache tốn $0.1875 (1.25x), các lần tiếp theo chỉ $0.015 (0.1x). Thay vì $3 mỗi lần gọi, còn $0.015. Với 20 vòng lặp: tiết kiệm 95% cost trên phần prefix.
Bài 22, Cost và latency: token budget, streaming, prompt caching, sẽ đi qua toàn bộ chiến lược giảm cost cho agent production.
Bài học từ incident này: không có prefix lớn nào là miễn phí. Trước khi nhét thêm bất kỳ thứ gì vào system prompt hay đầu history, hỏi: “cái này có thể cache được không?”
Phần 6: Khi nào dùng cái nào
| Loại memory | Dùng khi | Giới hạn | Chi phí |
|---|---|---|---|
| Conversation history | Luôn dùng, đây là default | Context window (200K) | Tính theo token, tăng tuyến tính |
| Sliding window | History dài, task không cần nhớ xa | Mất context cũ | Rẻ, không tốn LLM call thêm |
| Summarization | Cần nhớ context cũ nhưng ở dạng nén | Tốn 1 LLM call mỗi lần summarize | Trung bình |
| Scratchpad | Task phức tạp nhiều bước, cần ghi note trung gian | LLM có thể quên ghi | Rẻ (chỉ là tool call) |
| Long-term / vector DB | Cần nhớ giữa các session, knowledge base lớn | Retrieval lag, embedding cost | Cao nhất (indexing + query) |
| Prompt caching | Prefix lớn lặp lại (system prompt, long context) | TTL 5 phút, cần dùng lại trong window đó | Giảm cost 90% trên cached token |
Cheatsheet
Memory types cho agent:
1. Conversation history = danh sách messages, tự tăng mỗi vòng
2. Sliding window = trim message cũ, giữ N message gần nhất
3. Summarization = tóm tắt phần bị trim, giữ essence
4. Scratchpad = tool để LLM tự ghi note quan trọng
5. Long-term / vector DB = lưu cross-session, retrieve bằng embedding search
6. Prompt caching = cache prefix lớn, giảm 90% cost trên phần đó
Khi nào truncate:
- Ước tính token > 150K: bắt đầu trim
- Luôn giữ message đầu tiên (task gốc)
- Trim theo cặp user+assistant để giữ format hợp lệ
Pitfall phổ biến:
- Prefix lớn không cache: cost tăng quadratic theo history length
- Scratchpad không có hướng dẫn rõ: LLM ghi quá nhiều hoặc không ghi
- Summarize quá thường xuyên: tốn LLM call không cần thiết
Context window 2026:
- Claude Sonnet 4.6: 200K tokens (~150K chars)
- Gemini 1.5 Pro: 1M tokens (~750K chars)
- GPT-4o: 128K tokens (~100K chars)
Lời kết
Memory là phần ít được chú ý nhất khi mới build agent, nhưng là nguồn gốc của nhiều sự cố production nhất: agent mất context giữa chừng, cost tăng không rõ lý do, session dài bắt đầu trả lời sai vì history quá dài.
Ba nguyên tắc cần nhớ: trim sớm (đừng đợi đến khi vượt limit), cache prefix lớn (không có prefix nào miễn phí), và scratchpad là tool rẻ nhất để giữ thông tin quan trọng.
Bài tiếp theo, Build agent từ đầu: 100 dòng Python với Anthropic SDK, lắp ráp tất cả bốn thành phần (LLM, tools, memory, control loop) đã học trong Part 1 thành một agent hoàn chỉnh chạy được. Đó là bài code nhiều nhất trong series.