短期記憶 — Context Window 管理#
CH04 做完多輪 REPL 後,agent 已經能持續對話 — 但 CH01 1.4 講過 Model 本身沒記憶,每輪 chat 都要把累積的整段 messages list 重新發出去。 對話越長 token 越多,會有兩個現實問題:超過 context window 上限 API 直接拒收和每輪對話都越貴越慢。
Context window 指的是 Model 單次 API 呼叫能讀進來的 input token 上限(Claude Sonnet 4.6 是 200k)。 對話越長,messages list 越大、越接近這個上限。
這章用 Rolling Summary 解決:保留最近 N 個 turn 逐字、舊 turn 壓縮成一條 summary 訊息 — 在「保留對話脈絡」跟「控制 token 用量」之間取得平衡。 後續 CH06 / CH07 處理另外兩個記憶問題:關掉程式對話消失、跨 session 記不住事實。
5.1 Rolling Summary#
要讓 messages list 不無限長,做法是 把最舊的對話壓縮成一個 summary 訊息、最新的對話保留逐字 — 這就是 Rolling Summary。
整個流程四步:
flowchart TD
A1["1a. 自動:messages list 的 tokens 超過預算"]
A2["1b. 手動:使用者打 /compact 指令"]
B["2.messages list 最新 N 個 turn 保留,<br/>其餘 turn 待 summarize"]
C["3.把待 summarize 的 turn<br/>交給 Model 壓縮成一段 summary 訊息"]
D["4.新 messages list =<br/>summary 訊息 + 最新 N turn"]
A1 -->|N=5 預設| B
A2 -->|N=0| B
B --> C
C --> D它有兩個入口:
| 入口 | 何時觸發 | 做什麼 |
|---|---|---|
| 自動壓縮 | count_tokens() > 預算 時自動觸發 | 保留最新 N 個 turn 逐字,其餘 turn 壓縮成 summary |
/compact | 使用者手動下指令 | 把整段歷史壓縮成一條 summary(=「保留 0 個 turn」) |
兩個入口跑的是同一條流程(圖中 step 2 → 3 → 4),只差圖裡標的 N — 自動是 5、手動是 0。 5.3–5.5 把整套機制拆開來看。
5.2 messages list 的 token 數#
5.1 step 1a 的觸發條件是 「messages list 的 tokens 超過預算」 — 所以每輪 chat 開始前,executor 都要先算一下「現在這份 messages list 送出去會是幾個 token」,才知道要不要先做 summary。
Anthropic SDK 提供兩種測量法:
方法 A: client.messages.count_tokens(messages, tools)
- 獨立 API,可在送出前算
- 算精確值;要錢(獨立計費 API)
方法 B: response.usage.input_tokens
- 每次正常 messages.create() 都會回
- 完全免費
- 反映「上一輪實際送出的 token 數」兩個各有用途:
- 方法 A — 每輪 chat 一進來就呼叫一次,把結果跟
max_input_tokens(預算)比 — 超過就觸發 5.1 step 1a 開始壓縮。 這就是 step 1a 那個判斷的實作。 - 方法 B — 副產品,每次
messages.create()完都從response.usage順便拿一個值。 用來印給 user 看「上一輪用了多少 token」當監控。
兩個方法在完整 chat() 流程裡呼叫的時機,5.4 那張 sequence diagram 會把它放回去看。
5.3 最小的裁切單位:Turn#
5.1 講過 Rolling Summary 要在 messages list 上「畫一條線」分成兩半 — 線之前的壓縮成 summary、線之後的逐字保留。 但這條線不能隨便畫,最小的裁切單位是一個完整的 Turn:
一個完整 turn = 從一個 string-content user message 到下一個 string-content user message 之前。
turn 1 turn 2
───────────────────────────────── ─────────
[0] user "讀 README"
[1] assistant text + tool_use
[2] user tool_result
[3] assistant "這個專案是 ..."
[4] user "再讀 src/"
[5] assistant text + tool_use
[6] user tool_result
[7] assistant "..."判定方式:messages[i]["role"] == "user" 且 content 是 string(不是 tool_result list)。
為什麼一定要切在 turn 邊界?#
如果切點落在 turn 中間,會切壞 tool_use / tool_result 配對:
| 原始 messages | 切點落在 [2],保留 [2:] 後 |
|---|---|
[0] user "讀一下 README" | — (被壓縮掉) |
[1] assistant tool_use(id=A) | — (被壓縮掉) |
[2] user tool_result(id=A) | [0] user tool_result(id=A) ← 配對的 tool_use 不見了! |
[3] assistant "這個專案是 ..." | [1] assistant "這個專案是 ..." |
新 messages 的第 [0] 個訊息是 tool_result(id=A),但對應的 tool_use(id=A) 已經跟著 [1] 一起被壓縮掉 — API 直接拒收。
API 規則:每個 tool_result 必須有對應的 tool_use。 切點只要落在 turn 邊界(也就是 string-content user message 上),這個配對天然不會斷。
5.4 自動壓縮 workflow#
5.1 / 5.2 / 5.3 把零件鋪好了,這節把它們組起來:
- 觸發 — 用方法 A(5.2)算當前 token 數,超過預算就觸發
- 切點 — 落在 turn 邊界上(5.3)
- 目標 — 把切點之前的 turns 壓縮成一個 summary 訊息、切點之後的逐字保留
從 user / AI Agent / Model 三個角色看 chat() 完整一輪:
%%{init: {'sequence': {'noteAlign': 'left'}}}%%
sequenceDiagram
actor User
box AI Agent
participant Executor
end
participant Model
User->>Executor: chat(user_msg) 進來
loop 直到 messages list 的 tokens 不超過預算
Note over Executor: 方法 A: count_tokens#40;#41;<br/>算當前 messages list 的 token 數
alt messages list 的 tokens > 預算
Note over Executor: messages list 最新 N 個 turn 保留,其餘 turn 待 summarize
Executor->>Model: 其餘 turn
Model-->>Executor: summary 訊息
Note over Executor: 新 messages = summary 訊息 + 最新 N 個 turn
else messages list 的 tokens 不超過預算
Note over Executor: 跳出迴圈
end
end
Note over Executor: 把 user_msg<br/>append 到 messages list
Executor->>Model: messages.create(messages, tools)
Model-->>Executor: response (含 usage.input_tokens)
Note over Executor: 方法 B: 從 response.usage<br/>順便拿這輪 token 用量
Executor-->>User: 印 token 用量監控幾個重要性質:
- 保留最近 5 個 turn 逐字 — 剛剛在做的事完全不損失,模型不會忘掉一分鐘前剛讀的檔案內容。
- 舊內容不丟、改成壓縮 — 一小時前的 file path、決策、結論都還在 summary 裡,只是壓縮成更短的文字。
- summary 累加式更新 — 第二次壓縮觸發時,已存在的舊 summary 也會被當成「待壓縮的內容」跟更舊一輪的 turns 一起送進去,LLM 吐回合併過的新 summary。 整個對話歷史永遠只有一條 summary 訊息漂在最前面。
- 切點永遠落在 turn 邊界 — 5.3 講過的,
tool_use/tool_result配對天然不會斷。
具體變化長這樣:
壓縮前(10 個 turn,超過預算):
[user "問題1"] [assistant tool_use] [user tool_result] [assistant "答1"]
[user "問題2"] [assistant "答2"]
...
[user "問題10"] [assistant "答10"]
壓縮後(保留最新 5 個,前 5 個被壓縮掉):
[user "[Earlier conversation summary]\n問題 1-5 ..."]
[assistant "Understood. Continuing from the summary."]
[user "問題6"] [assistant "答6"]
...
[user "問題10"] [assistant "答10"]Sample code#
minimal-agent 的實作骨架大致長這樣:
| |
完整實作含 has_summary 判斷、累加式 summary、turn 邊界判定等細節,見 minimal_agent.py @ ch05。
5.5 /compact 指令也是 Rolling Summary#
/compact 指令跟 5.4 的自動壓縮跑的是同一條 Rolling Summary 流程,差別只在 N:
| 自動壓縮 | /compact | |
|---|---|---|
| 觸發 | tokens 超過預算(自動) | 使用者手動下 /compact |
| 要壓縮成 summary 的部分 | 切點之前的舊 turns | 整段對話歷史 |
| 逐字保留的部分 | 最新 5 個 turn(N=5) | 不保留(N=0) |
| 結果 | summary 訊息 + 最新 5 個 turn | 只剩一個 summary 訊息 |
換句話說 /compact 就是「N=0」的 Rolling Summary — 全部壓縮成 summary,一個 turn 都不留。 同一條流程,兩個入口都能呼叫。
5.6 何時該自動壓縮、何時該 /compact#
預算 70% → 不動,繼續
預算超標 → 自動 rolling summary(最近 5 個 turn 留著)
資訊太散凌亂 → 手動 /compact,整段壓縮乾淨
換工作主題 → 手動 /reset 從頭來--max-input-tokens 跟 --keep-recent-turns 兩個 CLI flag 控制觸發點與保留量,不需要動 code。
階段檢查點#
到這裡你應該理解:
- 為什麼需要 context 管理 — 每輪都要重發整段 messages,對話越長 token 越多(撞 context window 上限或越來越貴)
- Rolling Summary 概念 — 把最舊的 turns 壓縮成一條 summary 訊息、最新 N 個 turn 逐字保留
- 怎麼知道目前的 token 數 — 方法 A
count_tokens()在送出前算精確值、方法 B 從response.usage免費拿上一輪用量 - 最小的裁切單位是 Turn — 切點必須落在 turn 邊界,否則會切斷
tool_use/tool_result配對讓 API 拒收 - 自動壓縮 vs
/compact— 跑同一條 Rolling Summary 流程,只差 N — 自動是 5、手動/compact是 0
下一章 CH06 中期記憶 處理「關掉程式對話消失」 — 把 messages list 持久化到磁碟,下次可以 --resume 接回來。