上下文與記憶
Agent 每跑一步,messages 就變長一點,遲早會又慢又貴,甚至超過模型能讀的上限。這堂課教你三個方法:讓工具結果變小、摘要後重新開始、把重要的事寫進筆記檔當作長期記憶。
上一堂課你的 Agent 已經能自己列檔案、讀檔案、回答問題。不過如果你讓它讀了好幾個長檔案,可能會注意到:越到後面,每一步都越慢,usage.input_tokens 也越來越大。
原因你其實已經知道了:在 LLM API 單元我們學過,模型不記憶,每次都要把整份歷史送出去。Agent 每一輪都會把工具結果加進歷史,所以它長大的速度比一般聊天快很多。
這堂課就來處理這個問題。
上下文視窗:模型的桌面
上下文視窗(context window)是模型一次能讀進去的內容上限,用 token 計算。可以把它想成一張桌子:系統提示、任務、每次的工具結果、模型的回答,全部都要攤在桌上,模型才看得到。
桌面有大小限制,而且堆得越多,每一步越貴。
現在的模型桌面已經很大,但仍然值得管理,因為:
- 費用:每一輪都要為整張桌面付輸入 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 這類工具讀取專案說明檔的基本原理。
為什麼在一開始讀,而不是隨時更新系統提示?
系統提示最好在一次執行中保持不變。中途修改它,等於改動了歷史的開頭,和上面「不要從中間挖洞」是同一個道理。新寫的筆記,下次啟動時自然就會讀進來。
這是我的 Agent 程式。請幫我加上:每次任務結束後印出這輪的 input_tokens;超過 TOKEN_LIMIT 時呼叫 compact() 壓縮歷史,並印出壓縮前後的訊息數量。另外加一個 /memory 指令,可以印出 memory.md 的內容。
- 在
workspace放一個很長的文字檔,替read_file加上截斷,問 Agent 這個檔案的最後一段寫什麼,觀察它怎麼回應「已截斷」的提示。 - 加上
save_note工具,告訴 Agent「我習慣把檔名用日期開頭,請記住」,確認memory.md有寫入。 - 結束程式後重新執行,問它「我的檔名習慣是什麼?」,確認它從筆記裡知道答案。
- 進階:上一堂的
run_agent每次都從新的messages開始。把它改成可以連續接收多個任務、共用同一份歷史(可以用上面的提示詞請 AI 幫忙),再把TOKEN_LIMIT設得很小,例如2000,確認壓縮後它仍然記得之前做過的事。
讀完了嗎?
標記完成後,單元與專案的進度會自動更新(只存在這台裝置的瀏覽器)。
留言與討論
卡關了、有心得,或發現內容需要更新?都歡迎留言。留言使用 GitHub 帳號登入。