Agent kamu jalan mulus di tugas 10 turn. Masuk turn 40 dia baca ulang file yang sudah dibaca di turn 12, lupa satu constraint yang kamu taruh di system prompt, dan menjawab pertanyaan yang sudah dijawab dua puluh menit lalu. Tidak ada yang crash. Context window-nya juga belum penuh. Cuma padat, dan modelnya habiskan attention buat output tool yang sudah basi daripada buat instruksi kamu.
Ini terukur, bukan mitos. Chroma menguji 18 model di 8 panjang input dan 11 posisi needle, hasilnya akurasi turun begitu input bertambah, turunnya tidak merata, dan turun lebih cepat kalau pertanyaan dan jawabannya tidak berbagi kosakata. Kesimpulan mereka: model tidak memakai context secara seragam.
Anthropic menyebut efek yang sama sebagai attention budget. Tiap token yang masuk mengurangi anggaran itu, dan transformer membangun relasi pairwise n² antar token, jadi fokus makin tipis saat window terisi. Solusinya bukan window yang lebih besar. Solusinya memutuskan apa yang dibawa agent di dalam context dan apa yang ditaruh di file.
Empat langkah cukup untuk itu. Walkthrough ini membangun keempatnya dalam satu file Python yang bisa dijalankan, lengkap dengan hitungan token biar kelihatan hasil tiap langkahnya.
Yang perlu kamu siapkan
- Python 3.11 atau lebih baru
pip install anthropicANTHROPIC_API_KEYdi environment- satu folder file markdown buat dicari, folder docs apa pun bisa
- budget buat beberapa kali panggilan API per run
Contoh di bawah pakai claude-sonnet-4-5. Ganti sesuai model yang kamu bayar.
Step 1: ukur dulu sebelum ubah apa pun
Mulai dari tools dan corpus yang bisa dicari.
# agent.py
import json
from pathlib import Path
from anthropic import Anthropic
MODEL = "claude-sonnet-4-5"
client = Anthropic()
CORPUS = Path("corpus")
def search_docs(query: str) -> str:
hits = []
for path in sorted(CORPUS.glob("*.md")):
text = path.read_text()
if query.lower() in text.lower():
hits.append(f"{path.name} :: {text[:200]}")
return "\n".join(hits[:10]) or "no matches"
def read_doc(name: str) -> str:
path = (CORPUS / name).resolve()
if CORPUS.resolve() not in path.parents:
return "refused: outside corpus"
return path.read_text()[:8000]
HANDLERS = {"search_docs": search_docs, "read_doc": read_doc}
TOOLS = [
{
"name": "search_docs",
"description": "Search corpus files. Returns file name plus a 200 character snippet.",
"input_schema": {
"type": "object",
"properties": {"query": {"type": "string"}},
"required": ["query"],
},
},
{
"name": "read_doc",
"description": "Read one corpus file by name, truncated to 8000 characters.",
"input_schema": {
"type": "object",
"properties": {"name": {"type": "string"}},
"required": ["name"],
},
},
]
Lanjut ke loop-nya, sekalian pasang pencatatan token.
SYSTEM = """You answer questions about the local corpus.
Cite the file each claim came from. Keep answers short."""
def run_tool(block):
return {
"type": "tool_result",
"tool_use_id": block.id,
"content": HANDLERS[block.name](**block.input),
}
def budget(messages) -> int:
return client.messages.count_tokens(
model=MODEL, messages=messages, tools=TOOLS
).input_tokens
def chat(messages, **kwargs):
response = client.messages.create(
model=MODEL, max_tokens=4096, system=SYSTEM,
messages=messages, tools=TOOLS, **kwargs
)
usage = response.usage
print(f" in={usage.input_tokens:,} out={usage.output_tokens:,}")
return response
def main(question: str) -> None:
messages = [{"role": "user", "content": question}]
for turn in range(40):
response = chat(messages)
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
print(response.content[-1].text)
return
messages.append({
"role": "user",
"content": [run_tool(b) for b in response.content if b.type == "tool_use"],
})
Jalankan satu pertanyaan dari corpus bersih dan perhatikan kolom in=. Angkanya naik tiap turn karena semua tool result tetap ikut di payload, termasuk yang sudah selesai dipakai lima turn lalu. Angka itu baseline kamu, dan semua langkah di bawah ada buat menekuknya.
Step 2: tulis yang tidak boleh hilang ke sebuah file
Structured note-taking artinya agent menulis temuan ke storage di luar window lalu membacanya lagi saat butuh. Catatan bertahan melewati compaction, restart, dan proses yang mati, dan tidak ada teknik lain di artikel ini yang bisa begitu.
Dua tools, ditopang satu file.
NOTES = Path("notes/STATE.md")
NOTES.parent.mkdir(exist_ok=True)
def write_notes(content: str) -> str:
with NOTES.open("a") as handle:
handle.write(f"\n{content}\n")
return f"appended {len(content)} chars to {NOTES}"
def read_notes() -> str:
if not NOTES.exists():
return "no notes yet"
return NOTES.read_text()[-4000:]
HANDLERS.update({"write_notes": write_notes, "read_notes": read_notes})
TOOLS.extend([
{
"name": "write_notes",
"description": (
"Append a durable finding to notes/STATE.md. Use it for decisions, "
"file paths, dead ends and open questions. Survives compaction."
),
"input_schema": {
"type": "object",
"properties": {"content": {"type": "string"}},
"required": ["content"],
},
},
{
"name": "read_notes",
"description": "Read notes/STATE.md before starting work.",
"input_schema": {"type": "object", "properties": {}},
},
])
Tools-nya baru berguna kalau system prompt bilang kapan harus dipakai.
SYSTEM = """You answer questions about the local corpus.
Cite the file each claim came from. Keep answers short.
Before your first search, call read_notes.
After any finding that changes your plan, call write_notes with the file path
and what it means for the task."""
Kenapa ini bertahan: waktu compaction menghapus transkrip, agent cukup memuat ulang file dua kilobyte daripada baca ulang dua belas dokumen. Memory tool milik Anthropic jalan dengan ide yang sama, yaitu file store di sisi client yang dibaca dan ditulis model, dan saat diaktifkan dia menyuntik instruksi sistem supaya model memeriksa store itu sebelum mulai dan mencatat progres sambil jalan.
Bikin file catatannya membosankan dan spesifik. Path, keputusan, teks error, pertanyaan yang belum terjawab. File catatan yang isinya paragraf ringkasan cuma jadi masalah context lain dengan baju berbeda.
Step 3: berhenti bayar untuk tool result yang sudah dibaca
Compaction paling ringan itu membuang tool result. Setelah agent selesai memproses isi sebuah file, teks mentahnya masih menempel di setiap request berikutnya tanpa alasan.
Yang ini dikerjakan di sisi server, satu blok config, dan butuh beta header.
CONTEXT_EDITS = {
"edits": [
{
"type": "clear_tool_uses_20250919",
"trigger": {"type": "input_tokens", "value": 30_000},
"keep": {"type": "tool_uses", "value": 3},
"clear_at_least": {"type": "input_tokens", "value": 5_000},
"exclude_tools": ["read_notes"],
}
]
}
response = client.beta.messages.create(
model=MODEL,
max_tokens=4096,
system=SYSTEM,
messages=messages,
tools=TOOLS,
betas=["context-management-2025-06-27"],
context_management=CONTEXT_EDITS,
)
Empat knob, masing-masing punya tugas. trigger menentukan jumlah token saat pembersihan mulai. keep menyisakan pasangan tool terbaru supaya agent tetap pegang working state-nya. clear_at_least menolak membersihkan kalau token yang dibebaskan terlalu sedikit, dan ini penting karena pembersihan membatalkan cached prompt prefix kamu. exclude_tools menandai hasil yang tidak boleh dibuang; isi memory di situ kalau kamu pakai memory tool bawaan.
Pasang clear_at_least cukup tinggi supaya tiap pembersihan menutup biaya cache rewrite yang dia picu. Pembersihan yang membebaskan 800 token tapi mereset cache 40.000 token itu rugi bersih.
Step 4: compact transkripnya kalau transkripnya yang besar
Tool result clearing tidak menolong kalau pesannya sendiri yang besar: agent support dengan 200 percakapan pendek, atau sesi di mana orangnya terus memperjelas tujuan. Di situ kamu meringkas lalu mulai ulang.
COMPACT_AT = 60_000
KEEP_TAIL = 6
COMPACT_PROMPT = """You compress an agent transcript so the agent can continue
working without losing anything task-critical.
Preserve verbatim where possible:
- the original request and every constraint the user stated
- file paths, commands, function names, error text
- decisions made, and the reason given at the time
- what was tried and failed, so it is not retried
- open questions and the current next step
Drop raw tool output that is already summarized, restatements and narration.
Write in the language the user used. Output only the summary."""
def flatten(message) -> str:
if isinstance(message["content"], str):
return message["content"]
parts = []
for block in message["content"]:
if block.type == "text":
parts.append(block.text)
elif block.type == "tool_use":
parts.append(f"[tool_use {block.name} {json.dumps(block.input)}]")
else:
parts.append(f"[{block.type}]")
return "\n".join(parts)
def compact(messages):
old, tail = messages[:-KEEP_TAIL], messages[-KEEP_TAIL:]
transcript = "\n\n".join(f"{m['role']}: {flatten(m)}" for m in old)
summary = client.messages.create(
model=MODEL,
max_tokens=2000,
system=COMPACT_PROMPT,
messages=[{"role": "user", "content": transcript}],
).content[0].text
return [{
"role": "user",
"content": f"<summary_of_earlier_work>\n{summary}\n</summary_of_earlier_work>",
}] + tail
Pasang gerbangnya di dalam loop, dan cek tiap beberapa turn, bukan tiap turn, karena count_tokens memakan satu round trip.
if turn % 5 == 0 and budget(messages) > COMPACT_AT:
before = budget(messages)
messages = compact(messages)
print(f" compacted {before:,} -> {budget(messages):,} tokens")
Tiga detail menentukan apakah compaction menolong atau diam-diam merusak run.
Sisakan ekornya apa adanya. Enam pesan terakhir memegang working state langsung, termasuk tool result yang masih dipakai agent buat berpikir. Meringkasnya lebih mahal daripada yang dihemat.
Tuning recall dulu, presisi belakangan. Saran Anthropic: tulis prompt compaction berdasarkan trace asli dari run yang gagal, pertama pastikan dia menangkap semua yang relevan, baru potong yang ternyata tidak perlu. Prompt yang dituning dari contoh buatan kamu sendiri tidak akan cocok dengan cara agent kamu sebenarnya salah.
Beri tag pada ringkasannya. <summary_of_earlier_work> memberi tahu model bahwa ini hasil kompresi, bukan kalimat user, dan bikin log-nya gampang di-grep saat ada run yang aneh.
Kalau mengurus itu tidak sepadan, Claude Developer Platform menyediakan compaction berbasis ringkasan sebagai strategi beta di sisi server, sebelah tool result clearing. Trade-off-nya sama, kodenya lebih sedikit.
Step 5: isolasi pekerjaannya ke subagent, jangan diringkas
Kompresi memang lossy secara desain. Isolasi tidak. Subagent punya context window bersih, boleh bakar puluhan ribu token buat mencari, dan hanya mengembalikan ringkasan pendek. Anthropic menyebut ringkasan itu sekitar 1.000 sampai 2.000 token, dan itu intinya: parent bayar kesimpulannya, bukan proses eksplorasinya.
Tambah satu tool task yang dipanggil parent seperti tool lain.
WORKER_SYSTEM = """You are a research worker with read-only tools.
Work until you can answer, then stop and reply in this exact shape:
FINDINGS:
- one line per finding, each with the file or command it came from
UNCERTAIN:
- anything you could not confirm
At most 400 words. Never paste file contents. Never ask a question back."""
def run_subagent(spec: str) -> str:
messages = [{"role": "user", "content": spec}]
try:
for _ in range(12):
response = client.messages.create(
model=MODEL, max_tokens=2000, system=WORKER_SYSTEM,
messages=messages, tools=TOOLS,
)
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
return "".join(b.text for b in response.content if b.type == "text")
messages.append({
"role": "user",
"content": [run_tool(b) for b in response.content if b.type == "tool_use"],
})
except Exception as exc:
return f"worker failed: {exc}"
return "worker hit its turn limit without an answer"
Daftarkan, lalu tulis deskripsi yang memberi tahu parent cara membrief-nya.
HANDLERS["task"] = run_subagent
TOOLS.append({
"name": "task",
"description": (
"Run a subagent in a clean context window and get back a short summary. "
"Use it for breadth-first search across many files."
),
"input_schema": {
"type": "object",
"properties": {
"spec": {
"type": "string",
"description": "Self-contained brief. The worker sees none of this conversation.",
}
},
"required": ["spec"],
},
})
Dua aturan yang mencegah ini jadi berantakan.
Spec-nya wajib self-contained. Kalau worker butuh constraint dari user, tulis ulang di spec. Subagent yang harus bertanya balik itu bug, karena tidak ada jalur balik buat dia.
Kegagalan harus mengembalikan string, bukan raise. Di worker paralel, satu exception bisa membatalkan sibling-nya, jadi mengembalikan worker failed: ... bikin parent bisa memutuskan apakah celah itu penting.
Buat menjalankan beberapa sekaligus, batasi lebarnya dan pakai thread, karena SDK sync tidak punya event loop buat ditempeli asyncio.gather.
from concurrent.futures import ThreadPoolExecutor
SPECS = [
"how is authentication configured",
"which endpoints write to the database",
"what is rate limited and why",
]
with ThreadPoolExecutor(max_workers=4) as pool:
summaries = list(pool.map(run_subagent, SPECS))
Lalu tambah satu baris di system prompt parent: Delegate breadth-first search with task. Answer directly when one or two reads will do. Tanpa aturan soal kapan mendelegasikan, model cenderung tidak pernah menyentuhnya atau malah memanggilnya untuk semua pertanyaan.
Di dalam Claude Code, ide yang sama berbentuk file markdown di .claude/agents/.
---
name: corpus-researcher
description: Searches the local corpus for one question and returns a short evidence-backed summary. Use when an answer needs more than two file reads.
tools: Read, Grep, Glob
model: haiku
---
You search the corpus and answer one question at a time.
Report findings as bullets with file paths. Never return file contents verbatim.
Claude Code memuat file itu dalam beberapa detik, menjalankannya di context window sendiri dengan hanya tools tersebut, lalu mengembalikan hasilnya ke percakapan utama. Tidak mencantumkan Agent di daftar tools mencegahnya membentuk subagent lagi, dan itu biasanya yang kamu mau kalau bayar per token.
Kapan masing-masing justru pilihan yang salah
| Situasi | Pakai |
|---|---|
| Bolak-balik panjang, alur percakapan penting | compaction |
| Kerja iteratif dengan milestone jelas | file catatan |
| Breadth-first search di banyak sumber | subagent |
| Subtask saling bergantung pada output satu sama lain | tidak keduanya, rangkai berurutan di satu context |
| Satu dua panggilan tool saja | tidak perlu apa-apa, panggil model langsung |
Subagent itu opsi termahal. Anthropic melaporkan agent memakai sekitar 4× token percakapan chat, dan sistem riset multi-agent mereka sekitar 15×. Itu tukar yang wajar kalau pencarian paralel memang jadi bottleneck dan tugasnya sepadan dengan biayanya, dan pemborosan kalau satu panggilan saja sudah selesai.
Dua mode kegagalan yang perlu diawasi. Synthesis loss, di mana parent memadatkan laporan worker lalu menjatuhkan satu detail penting, makanya worker sebaiknya mengembalikan bukti (path, command) bukan cuma kesimpulan. Dan non-independence, di mana subtask kedua diam-diam butuh output subtask pertama. Jalankan bersamaan dan hasilnya percaya diri tapi salah. Kasus begitu butuh satu context dan langkah berurutan.
Bikin dia membuktikan sendiri
Catat input_tokens per request ke CSV, jalankan 20 pertanyaan yang sama dengan mekanisme mati lalu hidup, dan bandingkan akurasi plus total token. Threshold compaction dan brief subagent itu hal yang kelihatan oke di demo tapi ngaco di turn ke-200 sesi sungguhan, dan satu kali run tidak akan menunjukkan itu. Simpan juga file catatan di git. Dari situ kamu punya jejak yang enak dibaca soal apa yang diyakini agent di tiap langkah, lebih berguna daripada baris log waktu kamu mau debug kenapa dia ambil jalur yang salah.
Referensi
- Effective context engineering for AI agents (Anthropic Engineering)
- Context Rot: How Increasing Input Tokens Impacts LLM Performance (Chroma)
- Managing context on the Claude Developer Platform (context editing + memory tool)
- Context editing docs (Claude Developer Platform)
- Create custom subagents (Claude Code docs)