所有入門單元免費開放 · 課程內容持續更新中

上下文與記憶

Agent 每跑一步,messages 就變長一點,遲早會又慢又貴,甚至超過模型能讀的上限。這堂課教你三個方法:讓工具結果變小、摘要後重新開始、把重要的事寫進筆記檔當作長期記憶。

第 11.4 堂約 14 分鐘

上一堂課你的 Agent 已經能自己列檔案、讀檔案、回答問題。不過如果你讓它讀了好幾個長檔案,可能會注意到:越到後面,每一步都越慢,usage.input_tokens 也越來越大。

原因你其實已經知道了:在 LLM API 單元我們學過,模型不記憶,每次都要把整份歷史送出去。Agent 每一輪都會把工具結果加進歷史,所以它長大的速度比一般聊天快很多。

這堂課就來處理這個問題。

上下文視窗:模型的桌面

上下文視窗(context window)是模型一次能讀進去的內容上限,用 token 計算。可以把它想成一張桌子:系統提示、任務、每次的工具結果、模型的回答,全部都要攤在桌上,模型才看得到。

上下文視窗像一張桌面,system、任務、工具結果與回答依序堆上去直到快滿;三種處理方法分別是從源頭變小、摘要後重新開始、寫進筆記檔

桌面有大小限制,而且堆得越多,每一步越貴。

現在的模型桌面已經很大,但仍然值得管理,因為:

  • 費用:每一輪都要為整張桌面付輸入 token 的錢。
  • 速度:要讀的內容越多,回應越慢。
  • 品質:桌上堆滿不相關的東西,模型也比較容易分心或漏看重點。

方法一:從源頭讓工具結果變小

最簡單、也最有效的做法:別讓太大的東西上桌。例如 read_file 讀到一個很長的檔案,先截斷再交給模型,並清楚告訴它被截斷了:

MAX_CHARS = 5000


def read_file(relative: str) -> str:
    text = safe_path(relative).read_text(encoding="utf-8")
    if len(text) > MAX_CHARS:
        return (
            text[:MAX_CHARS]
            + f"\n\n(檔案共 {len(text)} 字,只顯示前 {MAX_CHARS} 字)"
        )
    return text

最後那句註明很重要。少了它,模型會以為檔案就到那裡為止。

同樣的道理也適用於其他工具:搜尋只回傳前 20 筆、列檔案只列檔名不列內容。設計工具時就先想好「模型真正需要多少資訊」。

方法二:摘要後重新開始

如果 Agent 要連續處理很多任務、共用同一份 messages,歷史還是會越來越長。這時可以請模型把目前為止的重點寫成摘要,然後用這份摘要取代整份歷史,重新開始。

TOKEN_LIMIT = 50_000  # 自己決定的門檻


def compact(messages: list) -> list:
    """請模型摘要目前的對話,回傳只含摘要的新歷史。"""
    response = client.messages.create(
        model=MODEL,
        max_tokens=16000,
        system=SYSTEM,
        tools=TOOLS,                  # 歷史裡有工具呼叫,所以要一起帶上
        tool_choice={"type": "none"}, # 這次只要文字摘要,不准用工具
        messages=messages + [{
            "role": "user",
            "content": "請把目前為止的對話整理成摘要:已完成的事、重要的發現、還沒完成的事。",
        }],
    )
    summary = "".join(b.text for b in response.content if b.type == "text")
    return [{"role": "user", "content": f"(以下是先前工作的摘要)\n{summary}"}]

在每個任務完成後檢查一次,超過門檻就壓縮:

if response.usage.input_tokens > TOKEN_LIMIT:
    messages = compact(messages)

有些供應商也提供在伺服器端自動壓縮對話的功能。功能和用法會更新,需要時到官方文件搜尋「compaction」或「context management」。

方法三:把重要的事寫進筆記檔

前兩個方法處理的是同一次執行裡的對話。但程式一結束,messages 就消失了。下次執行時,Agent 又是一張白紙。

如果想讓它記得「使用者喜歡用日期當檔名開頭」這類長期偏好,可以給它一個寫筆記的工具,把重點存進檔案:

MEMORY_FILE = Path("memory.md")

save_note_tool = {
    "name": "save_note",
    "description": (
        "把之後還會用到的重要資訊記到長期筆記裡,例如使用者的偏好或慣例。"
        "只記真正重要、之後會再用到的事,一次記一點。"
    ),
    "input_schema": {
        "type": "object",
        "properties": {"note": {"type": "string", "description": "要記下的一句話"}},
        "required": ["note"],
        "additionalProperties": False,
    },
}


def save_note(note: str) -> str:
    with MEMORY_FILE.open("a", encoding="utf-8") as f:
        f.write(f"- {note}\n")
    return "已記下"

記得在 run_tool 裡加上 save_note 的分支。然後在程式一開始把筆記讀進系統提示:

memory = MEMORY_FILE.read_text(encoding="utf-8") if MEMORY_FILE.exists() else "(目前沒有筆記)"

SYSTEM = f"""你是一個檔案助理,只能透過工具查看 workspace 資料夾。用繁體中文回答。

以下是你之前記下的長期筆記,請參考:
{memory}"""

這樣每次啟動,Agent 都會帶著上次學到的事開始工作。這也是 Claude Code 這類工具讀取專案說明檔的基本原理。

為什麼在一開始讀,而不是隨時更新系統提示?

系統提示最好在一次執行中保持不變。中途修改它,等於改動了歷史的開頭,和上面「不要從中間挖洞」是同一個道理。新寫的筆記,下次啟動時自然就會讀進來。

可以這樣問 AI

這是我的 Agent 程式。請幫我加上:每次任務結束後印出這輪的 input_tokens;超過 TOKEN_LIMIT 時呼叫 compact() 壓縮歷史,並印出壓縮前後的訊息數量。另外加一個 /memory 指令,可以印出 memory.md 的內容。

行動練習
  1. 在 workspace 放一個很長的文字檔,替 read_file 加上截斷,問 Agent 這個檔案的最後一段寫什麼,觀察它怎麼回應「已截斷」的提示。
  2. 加上 save_note 工具,告訴 Agent「我習慣把檔名用日期開頭,請記住」,確認 memory.md 有寫入。
  3. 結束程式後重新執行,問它「我的檔名習慣是什麼?」,確認它從筆記裡知道答案。
  4. 進階:上一堂的 run_agent 每次都從新的 messages 開始。把它改成可以連續接收多個任務、共用同一份歷史(可以用上面的提示詞請 AI 幫忙),再把 TOKEN_LIMIT 設得很小,例如 2000,確認壓縮後它仍然記得之前做過的事。

讀完了嗎?

標記完成後,單元與專案的進度會自動更新(只存在這台裝置的瀏覽器)。

卡住了?讓 AI 當你的家教複製這段提問範本,貼到 Claude 或其他 AI 工具,再補上你卡住的地方。

留言與討論

卡關了、有心得,或發現內容需要更新?都歡迎留言。留言使用 GitHub 帳號登入。

留言區即將開放。現在可以先到 GitHub 討論區 發問,或看看 交流頁 的其他管道。