写字台就那么大。
你是个抄录员,接了一桩差事:核一笔陈年旧账。手边这张台子,是你唯一能摊开纸的地方——要查的票据、批注、往来信件,一张张摊上去,边算边记。活越做越深,纸越摞越高,很快就顶到了桌沿。
再摊不下了怎么办?你不会去换张更大的桌子(世上没有),也不能把纸胡乱扔掉——那些早年的往来里,藏着这桩差事的来龙去脉。你的办法是:把最早那一大叠、早就看过也用过的旧纸,逐页读一遍,誊成薄薄一页"节略"——原委、动过哪几个卷宗、得出过什么结论、还差哪一步,全都摘在这一页上。誊完,旧纸原件收进身后的档案柜,台面上只留这页节略,加上手边最近正在用的那四五张。台子一下子又空出大半,接着往下做。
只是有一条规矩要格外当心。台上的纸是成对的:一句"请调三月的流水",底下必跟着"三月流水如下……"。你收旧纸时,若把那句"请调"誊进了节略、原件归了档,却把底下那张答话单独留在台面最上头——它就成了一张没头没脑的纸,谁也不知道它在答什么。所以你收的时候,宁可多留一张、也绝不让台面最上那张,是一张没有问头的答话。
日子久了你才明白:让这张小桌撑得起没完没了的大差事的,从来不是把台子越换越大,而是这套**上下文压缩(compaction)**的手艺——桌满时把旧账折成一页摘要、只留新鲜的尾巴、且绝不把一对问答拦腰截断。
先猜揭晓前,先押一个猜测 · 你觉得这讲的是?
谜底 · The Concept
Stage 4 — compaction: fold old turns into a summary when the window fills
Build From Scratch · 从零构建
中文速览 · Quick read
The agent loop has one growing liability: messages. Every turn appends the
assistant's reply and then a batch of tool_results, and nothing ever shrinks
it. A model's context window is finite; a long task will eventually push the
transcript past it, and the very next API call is rejected — the loop dies not
because the work is hard but because it remembered too much.
Stage 4 fixes this with the smallest honest version of Claude Code's
compaction: before each API call, estimate how full the window is, and if it's
near the limit, fold the oldest turns into a single summary message and keep
only a recent tail — while never severing a tool_result from the tool_use
that produced it.
Everything lives in tutorial/04-context/compact.ts, wired into the loop from
tutorial/04-context/agent.ts.
First, we need to know how full the desk is. We don't have exact token counts, so we use a cheap character heuristic:
export const KEEP_RECENT = 4;
export function estimateTokens(messages: Anthropic.MessageParam[]): number {
// ~4 chars per token. Cheap and wrong-but-useful; the real harness reads
// usage.input_tokens off every API response instead.
return Math.ceil(JSON.stringify(messages).length / 4);
}
maybeCompact is a no-op until two conditions both allow a cut — the estimate
must exceed the limit, and there must be more than the tail worth trimming:
if (estimateTokens(messages) <= limit || messages.length <= KEEP_RECENT + 1) return messages;
When it does fire, the interesting part is choosing where to cut. The naive
answer is messages.length - KEEP_RECENT. But the transcript is full of pairs:
an assistant tool_use block is answered on the next message by a user
message whose content starts with a tool_result. If our cut lands so that the
kept tail begins with a tool_result whose matching tool_use got
summarized away, the API rejects the whole request — an orphaned tool_result
is illegal. So we walk the boundary forward past any such message:
let cut = messages.length - KEEP_RECENT;
const startsWithToolResult = (m: Anthropic.MessageParam) =>
Array.isArray(m.content) && m.content[0]?.type === 'tool_result';
while (cut < messages.length && startsWithToolResult(messages[cut]!)) cut++;
const old = messages.slice(0, cut);
const recent = messages.slice(cut);
if (old.length === 0) return messages;
The old slice goes to a summarizer call — a plain model request under a system
prompt that tells it to preserve the task, file paths, findings, decisions, and
what's unfinished — and the whole conversation collapses to [summary, ...recent]:
const res = await client.messages.create({
model, max_tokens: 1024, system: SUMMARIZER,
messages: [{ role: 'user', content: JSON.stringify(old) }],
});
const summary = res.content.filter((b) => b.type === 'text').map((b) => (b as Anthropic.TextBlock).text).join('\n');
return [{ role: 'user', content: `[COMPACTED HISTORY — summary of ${old.length} earlier messages]\n${summary}` }, ...recent];
The loop calls it at the top of every iteration, before the API request, so
the window is trimmed on the way in rather than after it overflows
(tutorial/04-context/agent.ts):
while (true) {
messages = await maybeCompact(client, MODEL, messages, CONTEXT_LIMIT);
console.log(` 📏 ~${estimateTokens(messages)} tokens of ${CONTEXT_LIMIT}`);
const res = await client.messages.create({ model: MODEL, max_tokens: 2048, messages, tools });
...
Set a deliberately small CONTEXT_LIMIT so a short session trips the fold:
ANTHROPIC_API_KEY=sk-… CONTEXT_LIMIT=1500 \
bun run tutorial/04-context/agent.ts "read a couple of files, then tell me what this project does"
A trimmed transcript:
The 🗜 line is the desk being cleared: five old messages became one summary,
and the token estimate drops back under the limit for the next turn.
Our ~40 lines are the same shape as Claude Code's compaction, minus the caching tricks. Piece by piece:
- The fullness check. Our
estimateTokensischars / 4; the real harness never guesses — it readsusage.input_tokensoff every response and compares against a threshold of the effective window minus a 13K buffer reserved for the summary's own output (getAutoCompactThreshold, claude-code/src/services/compact/autoCompact.ts:62-76). - Firing on the way in. We call
maybeCompactat the top of the loop; the real query loop runs micro-compaction and then autocompaction before each API request, so a cheap trim can make the expensive summarize a no-op (claude-code/src/query.ts:414-468). The autocompact entry itself gates on a circuit breaker andshouldAutoCompact, and prefers a pre-extracted session summary before paying for a fresh summarization (claude-code/src/services/compact/autoCompact.ts:241-351). - Fold old → one summary. Our single
client.messages.createunderSUMMARIZERis the miniature ofcompactConversation: PreCompact hooks → streamed summary → re-injected file attachments (claude-code/src/services/compact/compact.ts:387-763), returning aCompactionResultof a boundary marker plus summary messages plus the tail to keep (claude-code/src/services/compact/compact.ts:299-310). - Keep a recent tail. Our
KEEP_RECENT = 4is a constant; the realcalculateMessagesToKeepIndexexpands backward from the last summarized message until it meets a token minimum and a text-block-message minimum, or hits a max cap (claude-code/src/services/compact/sessionMemoryCompact.ts:324-397). - Never orphan a tool_result. Our forward walk past a leading
tool_resultprotects the same invariant as the real counterpart,adjustIndexToPreserveAPIInvariants, but from the opposite direction: the real function walks backward from the cut and pulls the assistant message owning an orphanedtool_result(and its paired thinking blocks) into the kept tail, growing the kept region earlier, while our toy walks forward and evicts the orphan out of the tail into the summary, shrinking the kept region. Same invariant, inverse repair (claude-code/src/services/compact/sessionMemoryCompact.ts:232-314).
The full mechanism — the layered micro/auto strategy, the cost gradient, the
cache-preserving cache_edits path — is unpacked in the deep-dives at
/stories/context/1/ and
/stories/context/2/.
Stage 3 (tutorial/03-permissions/agent.ts) is identical except it has no
maybeCompact. Its loop only ever grows messages: messages.push({ role: 'assistant', … }) and then messages.push({ role: 'user', content: results })
on every single turn, and nothing trims it. For a two-step task that's fine. But
point it at a real repository and let it read a dozen files, and the transcript
climbs monotonically toward — and then past — the model's context window. The
next client.messages.create comes back a hard 400 prompt_too_long, and the
agent dies mid-task, not because it couldn't do the work but because it kept
every scrap of paper it ever touched. Stage 4 is the one habit that lets a small
desk carry an arbitrarily long job.
classDiagram
class agentLoop {
<<agent ts>>
+CONTEXT_LIMIT
+messages
+maybeCompactFirst()
}
class estimateTokens {
<<chars over 4 heuristic>>
+stringifyMessages()
+approxTokens
}
class maybeCompact {
<<compact ts>>
+KEEP_RECENT is 4
+guardUnderLimitNoop()
+walkBoundaryForward()
+summarizeOld()
+returnSummaryPlusRecent()
}
class boundaryWalk {
<<tail avoids tool_result>>
+cutIsLenMinusKeep
+walkWhileToolResult()
}
class summarizer {
<<client messages create>>
+systemSUMMARIZER
+foldOldIntoOne()
}
agentLoop ..> estimateTokens : measures
agentLoop ..> maybeCompact : trimBeforeCall
maybeCompact ..> estimateTokens : overLimit
maybeCompact ..> boundaryWalk : chooseCut
maybeCompact ..> summarizer : foldOld 读法:循环每轮开头先调 maybeCompact(agent.ts),
用 estimateTokens(字符数 ÷ 4)估算窗口是否将满;超过 CONTEXT_LIMIT 才动手。
maybeCompact 保留 KEEP_RECENT = 4 条尾巴,把裁切边界向前走过任何以
tool_result 开头的消息(boundaryWalk),再让 summarizer
把旧消息折成一条摘要,返回 [summary, ...recent]——对照真实的
autoCompact.ts:62-76、sessionMemoryCompact.ts:232-397、
compact.ts:387-763。
写字台就那么大。每按一次「再干一轮」,循环就往 messages 里追加一轮消息,
token 计随之上涨。一旦估算越过 CONTEXT_LIMIT,maybeCompact 出手:
把最早那叠旧消息折成一页摘要,只留 KEEP_RECENT = 4 条尾巴——但裁切边界会向前走,
绝不让尾巴以 tool_result 开头(否则 API 直接 400)。点那张金色摘要卡,看它留下了什么、丢掉了什么。
桌面见底。按「再干一轮」开始铺纸。
这正是 Stage 4 的压缩:循环每轮开头先调 maybeCompact,
estimateTokens(字符数 ÷ 4)一旦逼近上限就把旧消息折成一条摘要、
保留 KEEP_RECENT 条尾巴,并把裁切点向前走过任何以 tool_result 开头的消息——
因为孤立的 tool_result(配对的 tool_use 已被摘要吞掉)会被 API 拒收。
真实的 Claude Code 用精确 token 数、可缓存的 cache_edits 与分层 micro/auto 策略把同一件事做得更省。
The goal
The agent loop has one growing liability: messages. Every turn appends the
assistant's reply and then a batch of tool_results, and nothing ever shrinks
it. A model's context window is finite; a long task will eventually push the
transcript past it, and the very next API call is rejected — the loop dies not
because the work is hard but because it remembered too much.
Stage 4 fixes this with the smallest honest version of Claude Code's
compaction: before each API call, estimate how full the window is, and if it's
near the limit, fold the oldest turns into a single summary message and keep
only a recent tail — while never severing a tool_result from the tool_use
that produced it.
Build it
Everything lives in tutorial/04-context/compact.ts, wired into the loop from
tutorial/04-context/agent.ts.
First, we need to know how full the desk is. We don't have exact token counts, so we use a cheap character heuristic:
export const KEEP_RECENT = 4;
export function estimateTokens(messages: Anthropic.MessageParam[]): number {
// ~4 chars per token. Cheap and wrong-but-useful; the real harness reads
// usage.input_tokens off every API response instead.
return Math.ceil(JSON.stringify(messages).length / 4);
}
maybeCompact is a no-op until two conditions both allow a cut — the estimate
must exceed the limit, and there must be more than the tail worth trimming:
if (estimateTokens(messages) <= limit || messages.length <= KEEP_RECENT + 1) return messages;
When it does fire, the interesting part is choosing where to cut. The naive
answer is messages.length - KEEP_RECENT. But the transcript is full of pairs:
an assistant tool_use block is answered on the next message by a user
message whose content starts with a tool_result. If our cut lands so that the
kept tail begins with a tool_result whose matching tool_use got
summarized away, the API rejects the whole request — an orphaned tool_result
is illegal. So we walk the boundary forward past any such message:
let cut = messages.length - KEEP_RECENT;
const startsWithToolResult = (m: Anthropic.MessageParam) =>
Array.isArray(m.content) && m.content[0]?.type === 'tool_result';
while (cut < messages.length && startsWithToolResult(messages[cut]!)) cut++;
const old = messages.slice(0, cut);
const recent = messages.slice(cut);
if (old.length === 0) return messages;
The old slice goes to a summarizer call — a plain model request under a system
prompt that tells it to preserve the task, file paths, findings, decisions, and
what's unfinished — and the whole conversation collapses to [summary, ...recent]:
const res = await client.messages.create({
model, max_tokens: 1024, system: SUMMARIZER,
messages: [{ role: 'user', content: JSON.stringify(old) }],
});
const summary = res.content.filter((b) => b.type === 'text').map((b) => (b as Anthropic.TextBlock).text).join('\n');
return [{ role: 'user', content: `[COMPACTED HISTORY — summary of ${old.length} earlier messages]\n${summary}` }, ...recent];
The loop calls it at the top of every iteration, before the API request, so
the window is trimmed on the way in rather than after it overflows
(tutorial/04-context/agent.ts):
while (true) {
messages = await maybeCompact(client, MODEL, messages, CONTEXT_LIMIT);
console.log(` 📏 ~${estimateTokens(messages)} tokens of ${CONTEXT_LIMIT}`);
const res = await client.messages.create({ model: MODEL, max_tokens: 2048, messages, tools });
...
Run it
Set a deliberately small CONTEXT_LIMIT so a short session trips the fold:
ANTHROPIC_API_KEY=sk-… CONTEXT_LIMIT=1500 \
bun run tutorial/04-context/agent.ts "read a couple of files, then tell me what this project does"
A trimmed transcript:
The 🗜 line is the desk being cleared: five old messages became one summary,
and the token estimate drops back under the limit for the next turn.
对照 the real one
Our ~40 lines are the same shape as Claude Code's compaction, minus the caching tricks. Piece by piece:
- The fullness check. Our
estimateTokensischars / 4; the real harness never guesses — it readsusage.input_tokensoff every response and compares against a threshold of the effective window minus a 13K buffer reserved for the summary's own output (getAutoCompactThreshold, claude-code/src/services/compact/autoCompact.ts:62-76). - Firing on the way in. We call
maybeCompactat the top of the loop; the real query loop runs micro-compaction and then autocompaction before each API request, so a cheap trim can make the expensive summarize a no-op (claude-code/src/query.ts:414-468). The autocompact entry itself gates on a circuit breaker andshouldAutoCompact, and prefers a pre-extracted session summary before paying for a fresh summarization (claude-code/src/services/compact/autoCompact.ts:241-351). - Fold old → one summary. Our single
client.messages.createunderSUMMARIZERis the miniature ofcompactConversation: PreCompact hooks → streamed summary → re-injected file attachments (claude-code/src/services/compact/compact.ts:387-763), returning aCompactionResultof a boundary marker plus summary messages plus the tail to keep (claude-code/src/services/compact/compact.ts:299-310). - Keep a recent tail. Our
KEEP_RECENT = 4is a constant; the realcalculateMessagesToKeepIndexexpands backward from the last summarized message until it meets a token minimum and a text-block-message minimum, or hits a max cap (claude-code/src/services/compact/sessionMemoryCompact.ts:324-397). - Never orphan a tool_result. Our forward walk past a leading
tool_resultprotects the same invariant as the real counterpart,adjustIndexToPreserveAPIInvariants, but from the opposite direction: the real function walks backward from the cut and pulls the assistant message owning an orphanedtool_result(and its paired thinking blocks) into the kept tail, growing the kept region earlier, while our toy walks forward and evicts the orphan out of the tail into the summary, shrinking the kept region. Same invariant, inverse repair (claude-code/src/services/compact/sessionMemoryCompact.ts:232-314).
The full mechanism — the layered micro/auto strategy, the cost gradient, the
cache-preserving cache_edits path — is unpacked in the deep-dives at
/stories/context/1/ and
/stories/context/2/.
What breaks without it
Stage 3 (tutorial/03-permissions/agent.ts) is identical except it has no
maybeCompact. Its loop only ever grows messages: messages.push({ role: 'assistant', … }) and then messages.push({ role: 'user', content: results })
on every single turn, and nothing trims it. For a two-step task that's fine. But
point it at a real repository and let it read a dozen files, and the transcript
climbs monotonically toward — and then past — the model's context window. The
next client.messages.create comes back a hard 400 prompt_too_long, and the
agent dies mid-task, not because it couldn't do the work but because it kept
every scrap of paper it ever touched. Stage 4 is the one habit that lets a small
desk carry an arbitrarily long job.
来源 · Source citations
- [1]
claude-code/src/query.ts:414-468 - [2]
claude-code/src/services/compact/autoCompact.ts:62-76 - [3]
claude-code/src/services/compact/autoCompact.ts:241-351 - [4]
claude-code/src/services/compact/sessionMemoryCompact.ts:324-397 - [5]
claude-code/src/services/compact/sessionMemoryCompact.ts:232-314 - [6]
claude-code/src/services/compact/compact.ts:387-763 - [7]
claude-code/src/services/compact/compact.ts:299-310