短期記憶 — 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 的實作骨架大致長這樣:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
class Agent:
    def chat(self, user_msg: str) -> str:
        self._trim_if_needed()              # ① 方法 A 算 token,超過就壓縮

        self.messages.append({"role": "user", "content": user_msg})
        response = self.client.messages.create(
            model="claude-...",
            messages=self.messages,
            tools=self.tools,
        )
        self.messages.append({"role": "assistant", "content": response.content})

        self.last_input_tokens = response.usage.input_tokens   # ② 方法 B
        return response_text

    def _trim_if_needed(self):
        while self.count_tokens() > self.max_input_tokens:
            self._summarize_oldest_turns()  # 切點 → summarize → 組回去

完整實作含 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 接回來。


參考資源#