Context Engineering cho agent: cái gì vào context window, và khi nào
Context window một triệu token không làm việc quản lý context thành tuỳ chọn; nó chỉ làm hoá đơn to hơn. Bài này coi context window như một ngân sách, làm phép tính khiến mỗi lần chạy agent đắt đỏ, và đi qua năm kỹ thuật thực sự có tác dụng: retrieval đúng lúc, vệ sinh tool result, compaction, memory file và sub-agent.

English version: Context Engineering for Agents: What Goes in the Window, and When. Phần 3 của series. Phần trước: Agent Harness. Phần sau: Tool, permission và guardrail.
Lần chạy dạy tôi bài này tốn khoảng sáu mươi đô. Một coding agent được giao sửa một integration test flaky. Nó đọc file test, rồi module đang test, rồi ba dependency, rồi đọc lại file test vì đến bước mười hai lần đọc đầu tiên đã trôi khỏi sự chú ý của nó dù về kỹ thuật vẫn nằm trong window. Đến bước hai mươi nó đã đọc cùng bốn file đó mỗi file ba lần, context chứa 400.000 token, mỗi lần gọi mất bốn mươi giây, và patch cuối cùng sai. Không có gì trong lần chạy đó là lỗi của model. Tất cả là lỗi context.
Context engineering là kỷ luật quyết định cái gì nằm trong cửa sổ của model ở mỗi bước của vòng lặp. Thuật ngữ này nổi lên giữa 2025 khi Andrej Karpathy gọi nó là cái tên đúng hơn cho thứ mọi người vẫn gọi là prompt engineering, và Anthropic viết bài tham chiếu về nó vào tháng 9 năm đó. Tôi thích cái tên này vì nó nói đúng việc phải làm. Bạn không viết prompt. Bạn đang chăm sóc một bộ nhớ làm việc nhỏ, đắt tiền, cho một người thợ quên sạch mọi thứ giữa hai lần gọi.
Window là ngân sách, và vòng lặp làm nó dồn tích
Mọi thứ model thấy trong một lần gọi đều nằm trong window: system prompt, định nghĩa tool, mọi lượt trước đó, mọi tool result, bất cứ gì bạn retrieve. Bạn trả tiền cho tất cả ở mỗi lần gọi, và trong agent loop mỗi lần gọi bao gồm mọi thứ của các lần gọi trước. Chi phí không tuyến tính theo số bước. Nó xấp xỉ bậc hai.
Giả sử phần cố định (system prompt cộng định nghĩa tool) là 8.000 token và mỗi bước thêm 12.000 token tool result và suy luận. Bước n gửi 8.000 + 12.000 × n. Qua ba mươi bước là 30 × 8.000 cộng 12.000 × (1 + 2 + … + 30), tức 240.000 cộng 5.580.000: khoảng 5,8 triệu input token cho một task. Prompt caching cắt được phần lớn giá tiền, nhưng không cắt được latency, và không cắt được vấn đề chất lượng: model dùng thông tin trong một window chật chội kém đi rõ rệt từ rất lâu trước khi chạm giới hạn cứng. Ngành cũng có tên cho hiện tượng đó: context rot.
Vậy mục tiêu không phải "vừa window". Mục tiêu là giữ window của mỗi bước nhỏ hết mức có thể mà vẫn chứa đúng thứ bước đó cần. Năm kỹ thuật làm phần lớn việc.
1. Retrieve đúng lúc; đừng nạp trước
Phản xạ RAG là chạy search ngay từ đầu rồi nhét hai mươi chunk top vào prompt. Với câu hỏi một lần thì ổn. Với agent thì thường sai: bạn đang đoán model sẽ cần gì trước khi nó bắt đầu, và trả tiền cho những dự đoán đó ở mọi bước sau dù đúng hay sai.
Cách làm kiểu agent là đưa cho model một bản đồ và tool, rồi để nó tự kéo cái nó cần khi cần. Bản đồ nhỏ: một directory listing, một mục lục, các con trỏ trong CLAUDE.md ở phần 2. Tool thì hẹp: search(query, limit), read(path, start_line, end_line), sql(query) có giới hạn số dòng. Model quyết định mở gì, và chỉ những gì nó mở mới vào context.
Trong pipeline tài liệu của chúng tôi, điều này lật ngược thiết kế retrieval. Thay vì embed mọi hợp đồng rồi retrieve chunk, agent có tool list_sections(doc) trả về các heading kèm số trang, và tool read_section(doc, heading). Nó mở hai ba section cho mỗi câu hỏi thay vì nhận hai mươi chunk, và độ chính xác tăng vì section là đơn vị mạch lạc chứ không phải lát cắt 512 token. Index embedding vẫn tồn tại; nó đứng sau tool search cho những trường hợp model không biết tìm ở đâu.
Trường hợp phổ biến là lai: một bản đồ nhỏ nạp sẵn cộng tool cho mọi thứ còn lại. Thứ cần tránh là nạp trước thuần tuý, retrieval một lần, mù, trước khi vào vòng lặp.
2. Tool result là thứ bạn kiểm soát
Phần 1 nói chuỗi tool result là cần gạt đầu tiên của chất lượng agent. Về mặt context nó cũng là khoản lớn nhất: trong hầu hết trace tôi từng xem, tool result chiếm 70 đến 90 phần trăm token. Ba thói quen giữ nó trong tầm kiểm soát.
Chặn trần mọi kết quả, và nói rõ đã cắt gì. Một read trả về 300 dòng kèm "… còn 2.140 dòng; gọi lại với start_line=300" ăn đứt cái trả về 2.440 dòng. Model có thể xin thêm; nó không thể "bỏ đọc" thứ bạn ép nó nhận.
Trả về tham chiếu cho artifact lớn. Nếu tool sinh ra thứ gì to (build log, mẫu dataset, một trang đã render), ghi ra đĩa và trả về đường dẫn kèm vài dòng đầu. Model mở phần nó cần bằng read. Việc này cũng làm trace hữu ích: artifact nằm trên đĩa chứ không nhúng vào một megabyte JSON.
Thay tool result cũ bằng stub khi đã dùng xong. Sau khi model đã hành động dựa trên một file vừa đọc, toàn văn file hiếm khi còn quan trọng. Đổi nó thành một dòng stub ("read src/extract.py, 8.124 ký tự, xem trace bước 12"). Nếu model cần, nó đọc lại, tốn một lần gọi thay vì đánh thuế mọi lần gọi. Đây là mẹo hiệu quả nhất trong danh sách và gần như không ai làm, vì framework coi message history là bất biến.
Đây là một context manager làm hai việc cuối một cách máy móc. Nó ước lượng kích thước window, stub các tool result cũ hơn vài lượt gần nhất, và compact khi ước lượng vượt ngưỡng.
# context.py — giữ window của agent nhỏ mà không mất thứ quan trọng
import json
import os
from dataclasses import dataclass, field
import anthropic
MODEL = os.environ.get("ANTHROPIC_MODEL", "claude-sonnet-5-5")
client = anthropic.Anthropic()
def estimate_tokens(obj) -> int:
"""Ước lượng rẻ: ~4 ký tự/token với tiếng Anh, ~2.5 với tiếng Việt.
Dùng client.messages.count_tokens() trước các quyết định đắt tiền."""
text = obj if isinstance(obj, str) else json.dumps(obj, ensure_ascii=False, default=str)
return len(text) // 3
COMPACT_PROMPT = """Summarise the conversation so far for an engineer who will continue the task.
Keep: the task, decisions made and why, files/resources touched with their paths, facts discovered,
open questions, and the exact next step. Drop: raw tool output, dead ends already resolved.
Use short headed sections. Under 600 words."""
@dataclass
class Context:
system: str
tools: list
messages: list = field(default_factory=list)
keep_recent_turns: int = 6 # tool result trong N lượt gần nhất giữ nguyên văn
compact_at: int = 120_000 # ước lượng token kích hoạt compaction
result_cap: int = 8_000 # ký tự tối đa mỗi tool result
_stubbed: set = field(default_factory=set) # các tool_use_id đã bị thay bằng stub
def add_user(self, content):
self.messages.append({"role": "user", "content": content})
def add_assistant(self, content):
self.messages.append({"role": "assistant", "content": content})
def add_tool_results(self, results: list[dict]):
for r in results:
text = r["content"]
if len(text) > self.result_cap:
head = text[: self.result_cap]
r["content"] = f"{head}\n... [{len(text) - self.result_cap} chars omitted; ask for a narrower range]"
self.messages.append({"role": "user", "content": results})
self._stub_old_results()
if estimate_tokens(self.messages) > self.compact_at:
self.compact()
def _stub_old_results(self):
"""Thay tool result cũ hơn keep_recent_turns bằng stub một dòng."""
user_turns = [i for i, m in enumerate(self.messages) if m["role"] == "user"]
for i in user_turns[: -self.keep_recent_turns]:
content = self.messages[i]["content"]
if not isinstance(content, list):
continue
for block in content:
if block.get("type") == "tool_result" and block["tool_use_id"] not in self._stubbed:
size = len(block["content"])
block["content"] = f"[tool result, {size} chars, elided; call the tool again if needed]"
self._stubbed.add(block["tool_use_id"])
def compact(self):
"""Tóm tắt mọi thứ trừ hai lượt cuối thành một ghi chú."""
head, tail = self.messages[:-2], self.messages[-2:]
summary = client.messages.create(
model=MODEL, max_tokens=2000,
system="You compress agent transcripts without losing decisions or state.",
messages=[*head, {"role": "user", "content": COMPACT_PROMPT}],
)
note = "".join(b.text for b in summary.content if b.type == "text")
self.messages = [
{"role": "user", "content": f"Context so far (compacted):\n\n{note}"},
{"role": "assistant", "content": "Understood. Continuing from that state."},
*tail,
]
def call(self):
return client.messages.create(
model=MODEL, max_tokens=4096, system=self.system, tools=self.tools, messages=self.messages
)
Để ý compaction giữ lại gì: quyết định, đường dẫn, sự kiện, bước tiếp theo. Danh sách đó là khác biệt giữa một compaction hoạt động và một compaction âm thầm xoá mất lý do agent chọn cách B thay vì cách A, để rồi nó thử lại A.
Lưu ý về caching: prompt caching khớp theo prefix chính xác. Stub kết quả cũ và compact đều đổi prefix và làm mất cache ở lần gọi đó. Làm chúng theo từng bước lớn, hiếm, thay vì mỗi lượt một ít, và giữ system prompt cùng định nghĩa tool ở đầu để phần đó luôn hit.
3. Compact có chủ đích, không phải khi cạn
Harness nào rồi cũng compact; câu hỏi là nó xảy ra theo điều kiện của bạn hay không. Compact tự động ở 95 phần trăm window, mặc định của hầu hết tool, nghĩa là bản tóm tắt được viết đúng lúc model có ít chỗ nhất để viết cho tốt, và nó tóm tắt cả lần chạy kể cả những phần không còn quan trọng.
Pattern tốt hơn là compact ở một ranh giới: một feature xong, một sub-task đóng, một kế hoạch được chốt. Ở ranh giới, agent biết cái gì đã quan trọng. Pattern features.json và file progress ở phần 2 là compaction theo thiết kế: trạng thái quan trọng được ghi ra đĩa liên tục, nên transcript có thể vứt đi ở bất kỳ cuối session nào và session sau dựng lại context từ file. Nếu chỉ lấy một ý từ mục này, lấy ý đó: để trạng thái bền trong file mà agent tự duy trì, và để transcript là thứ dùng xong bỏ.
4. Memory file: agent tự viết context cho mình
Memory file là file agent được bảo đọc lúc bắt đầu và cập nhật trong lúc làm. Bản đơn giản nhất là một NOTES.md với ba mục: tôi đã học được gì về codebase này, tôi đã thử gì mà không được, còn gì phải làm. CLAUDE.md của Claude Code đóng vai này xuyên session cho cả người lẫn agent; log tiến độ đóng vai này trong một task.
Quy tắc làm memory file hoạt động: hướng dẫn phải nói khi nào ghi, không chỉ nói được phép ghi. "Sau mỗi tool call lộ ra điều gì bất ngờ, thêm một dòng vào NOTES.md" cho ra file hữu ích. "Bạn có thể ghi chú" cho ra con số không. Model giỏi làm theo trigger và dở tự nguyện.
Quy tắc thứ hai, học bằng giá đắt: memory file cũng là context. Một NOTES.md 2.000 dòng chỉ là cách khác để nổ ngân sách. Cho agent một quy tắc cắt tỉa ("giữ dưới 200 dòng; thay chi tiết bằng con trỏ tới file") hoặc có job hằng đêm tóm tắt nó.
5. Sub-agent là cách cô lập context, không phải để chạy song song
Hệ đa agent được bán như là làm việc nhóm. Lý do chúng thật sự giúp thì hẹp hơn và hữu ích hơn: sub-agent có window riêng. Khi agent chính cần tìm một pattern trong năm mươi file, nó có thể tự đọc rồi mang 150.000 token nội dung đó suốt phần còn lại của lần chạy, hoặc đưa câu hỏi cho một sub-agent đọc chúng trong window riêng rồi trả về câu trả lời 400 token. Context của agent chính tăng 400 token, không phải 150.000.
Đó là toàn bộ mẹo, và là lý do đơn vị uỷ quyền đúng là "một câu hỏi có câu trả lời ngắn": tìm chỗ X được cấu hình, liệt kê mọi nơi gọi Y, kiểm tra tài liệu có nhắc Z không. Cũng là lý do sub-agent không nên dùng chung transcript với cha; điểm mấu chốt là chúng không dùng chung.
# research_tool.py — một sub-agent được lộ ra cho agent chính dưới dạng một tool duy nhất
from context import Context, client, MODEL
SEARCH_TOOLS = [
{"name": "grep", "description": "Search files for a regex. Returns up to 50 matches with path:line.",
"input_schema": {"type": "object", "properties": {"pattern": {"type": "string"}}, "required": ["pattern"]}},
{"name": "read", "description": "Read lines [start, end) of a file.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "start": {"type": "integer"},
"end": {"type": "integer"}}, "required": ["path"]}},
]
def research(question: str, execute, max_steps: int = 12) -> str:
"""Trả lời một câu hỏi hẹp về codebase trong window cô lập. Trả về câu trả lời ngắn."""
ctx = Context(
system="You answer one question about a codebase using grep and read. "
"Be exhaustive in your search and terse in your answer: under 300 words, cite path:line.",
tools=SEARCH_TOOLS, keep_recent_turns=4, compact_at=60_000,
)
ctx.add_user(question)
for _ in range(max_steps):
response = ctx.call()
ctx.add_assistant(response.content)
calls = [b for b in response.content if b.type == "tool_use"]
if not calls:
return "".join(b.text for b in response.content if b.type == "text")
ctx.add_tool_results([
{"type": "tool_result", "tool_use_id": c.id, "content": execute(c.name, c.input)} for c in calls
])
return "Research budget exhausted; partial findings: " + "".join(
b.text for b in response.content if b.type == "text"
)
# Trong danh sách tool của agent chính:
RESEARCH_TOOL = {
"name": "research",
"description": "Delegate a narrow codebase question to a research agent. Use for anything that needs "
"reading more than two files. Returns a short answer with citations, not raw files.",
"input_schema": {"type": "object", "properties": {"question": {"type": "string"}}, "required": ["question"]},
}
Phần description của tool đó làm việc thật: nó nói cho agent chính biết khi nào nên uỷ quyền (hơn hai file) và sẽ nhận về gì (câu trả lời ngắn, không phải file). Thiếu nó, agent chính sẽ tự đọc file, vì đọc thì luôn sẵn còn uỷ quyền là một phán đoán nó sẽ không tự đưa ra.
System prompt, ngắn gọn
Sau tất cả, system prompt là phần nhỏ nhất của context engineering, nhưng vẫn quan trọng. Bài của Anthropic mô tả đích đến là đúng độ cao: đủ cụ thể để dẫn hành vi, không cứng tới mức thành một danh sách quy tắc giòn mà model chỉ khớp mẫu. Kiểu hỏng nằm ở cả hai đầu. Prompt mười dòng để model đoán quy ước của bạn. Prompt nghìn dòng dạy nó bỏ qua phần lớn, và bạn không biết nó bỏ phần nào.
Thứ đã hiệu quả: các mục ngắn có tiêu đề (vai trò, môi trường, cách làm việc, khi nào dừng), một hai ví dụ cụ thể về câu trả lời cuối tốt, và con trỏ tới file cho mọi thứ khác. Phần ổn định đặt trước để cache. Task đặt cuối. Nếu thấy mình thêm một quy tắc vào prompt sau mỗi sự cố, quy tắc đó có lẽ thuộc về một hook hoặc một thông điệp lỗi của tool, nơi nó được cưỡng chế thay vì gợi ý.
Một ghi chú về tiếng Việt
Hầu hết agent của tôi đọc tài liệu tiếng Việt, và phép tính ngân sách tệ hơn các con số tiếng Anh ở trên. Tiếng Việt tokenise ở mức khoảng 1,5 đến 2 lần số token mỗi từ so với tiếng Anh trên các tokenizer hiện tại, nên một hợp đồng 30 trang vốn là 25.000 token nếu bằng tiếng Anh thường thành 40.000 đến 50.000 bằng tiếng Việt. Mọi thứ trong bài này áp dụng với ngưỡng hạ xuống, và các tool đọc theo section ở kỹ thuật 1 quan trọng hơn chứ không kém. Hãy đo bằng count_tokens trên tài liệu của chính bạn trước khi đặt bất kỳ giới hạn nào; ước lượng theo ký tự trong code trên chỉ là chỗ để tạm.
Đo cái gì
Bạn không engineer được thứ mình không đo, và trace ở phần 1 cho bạn mọi thứ cần: token mỗi bước, token mỗi task, tỷ lệ cache hit, tỷ trọng token là tool result, và số lần cùng một file được đọc trong một lần chạy. Con số cuối là thứ làm chúng tôi ngượng. Giờ nó là một metric trên dashboard, và thường bằng một.
Tài liệu tham khảo
- Anthropic, Effective context engineering for AI agents (09/2025)
- Anthropic, Effective harnesses for long-running agents (11/2025)
- Anthropic, Prompt caching
- Chroma, Context Rot: How increasing input tokens impacts LLM performance (2025)