
有個功能我做過兩次,第二次才做對。
第一次,我讓同一個 agent 服務多個聊天視窗,用一組固定規則算出 session id:把 agent 名字跟通道名字丟進 SHA-256,取前 32 碼。同一個 agent、同一個通道,永遠拿到同一個 id,--resume 一接就續上,看起來非常優雅。
上線之後開始有人回報怪事:在 A 群組問「剛剛那個檔案在哪」,agent 回答的是 B 群組十分鐘前討論的檔案。
兩個群組共用了同一個 session。優雅的 id 少了一個維度。
這三個東西經常被混用,混用就會做出上面那種 bug。
| 名詞 | 是什麼 | 生命週期 |
|---|---|---|
| session | CLI 那端的對話狀態,--resume 接的就是它 |
CLI 決定 |
| conversation | 使用者感知的「一個聊天串」 | 使用者決定 |
| run | 一次 spawn,從啟動到結束 | 幾秒到幾十分鐘 |
一個 conversation 裡有很多 run。一個 session 對應幾個 conversation,這是你的設計決定,而預設值往往是錯的。

我那個 bug 就是圖中那道閃電:兩個 conversation 被塞進同一個 session,於是 A 看得到 B 的東西。
我那個 bug 的本質是:session key 裡有 agent 維度、有通道維度,就是沒有 conversation 維度。所以兩個 conversation 撞在同一把鑰匙上。
--resume 到底幫你記住什麼Claude Code 的 --resume <session-id> 會把之前的對話接回來。它幫你省下把歷史塞進 prompt 的功夫,也讓 CLI 那端可以做自己的快取。
代價有三個,都要知道:
一、狀態不在你手上。 它存在 CLI 的資料目錄裡。你沒辦法查詢、沒辦法壓縮、沒辦法在裡面塞一句「順帶一提,使用者昨天改了需求」。你只能接上或不接。
二、跨機器不通。 換一台機器跑,session 就沒了。如果你的 agent 會在不同機器上被喚醒,這條路直接斷掉。
三、它會累積。 一個聊了三個月的 session,每次 resume 都要載入全部歷史。你會在某天發現一次呼叫要價 8 萬 token,而使用者只問了「今天天氣如何」。
我最後的選擇是:自己管歷史,不用 --resume。每次都是全新 spawn,需要什麼上下文由我自己組進 prompt。這樣做的理由很簡單,我要能控制「帶什麼進去」,因為那決定了成本和品質。
如果你的場景是單一使用者、單一對話、跑在同一台機器上,--resume 是最省事的做法,不用跟我一樣繞遠路。
先設計 session id。這是整件事的地基,寫錯了後面全部歪掉。
def compose_session_id(channel: str, agent_id: str, conversation: str) -> str:
"""三個維度缺一不可:哪個通道、哪個 agent、哪一串對話。"""
return f"{channel}#agent:{agent_id}#conv:{conversation}"
看起來很土,但這是對的。我原本那個 SHA-256 版本漂亮多了,也錯多了。id 是給機器讀的,可讀性帶來的除錯效率遠比美觀重要。
再來是儲存。用 SQLite,不要用 JSON 檔,因為你會需要「取最近 N 輪」這種查詢,而且會有並發寫入。
import sqlite3, json, time
SCHEMA = """
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
role TEXT NOT NULL, -- user / assistant
content TEXT NOT NULL,
tokens INTEGER DEFAULT 0,
created_at REAL NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_session_time
ON messages(session_id, created_at DESC);
"""
class SessionStore:
def __init__(self, path="sessions.db"):
self.db = sqlite3.connect(path, check_same_thread=False)
self.db.execute("PRAGMA journal_mode=WAL") # 並發讀寫
self.db.executescript(SCHEMA)
def append(self, session_id: str, role: str, content: str, tokens: int = 0):
self.db.execute(
"INSERT INTO messages(session_id, role, content, tokens, created_at)"
" VALUES (?,?,?,?,?)",
(session_id, role, content, tokens, time.time()),
)
self.db.commit()
def recent(self, session_id: str, limit: int = 20) -> list[dict]:
rows = self.db.execute(
"SELECT role, content FROM messages WHERE session_id=?"
" ORDER BY created_at DESC LIMIT ?",
(session_id, limit),
).fetchall()
return [{"role": r, "content": c} for r, c in reversed(rows)]
PRAGMA journal_mode=WAL 那一行不要省。沒有它,一邊寫一邊讀會鎖住彼此,你的 agent 會在高峰時段隨機卡住。
有了儲存,下一個問題是每次帶幾輪。這個問題沒有標準答案,但有一個很有效的粗暴手法:每一輪只留頭尾。
實際觀察對話內容會發現,長回合裡真正需要被後面幾輪參考的,通常是開頭那句「使用者要什麼」和結尾那句「結論是什麼」。中間的推理過程、貼上來的整份檔案、工具的原始輸出,對下一輪幾乎沒有幫助。
def trim_turn(text: str, head: int = 800, tail: int = 200) -> str:
"""超過長度的回合只留頭尾,中間用省略記號接起來。"""
if len(text) <= head + tail:
return text
# 注意中文:用字元切,不要用 byte 切,不然會切出半個字
return text[:head] + "\n…(中略)…\n" + text[-tail:]
這裡有個坑我踩過,值得單獨講:中文絕對不能用 byte 索引切字串。
s = "這是一段中文"
s.encode()[:7].decode() # UnicodeDecodeError,切在字元中間
Python 用字元索引很安全,Rust 那邊 &s[..n] 會直接 panic。我在自己的專案裡把這件事寫成硬性規範,所有截斷一律走一個會退回到字元邊界的共用函式。這種 bug 的特徵是「只有中文使用者會遇到」,而如果你自己都用中文,你會在上線第一天就發現,算是不幸中的大幸。
最後把歷史組進 prompt。順序有講究,而且會直接影響成本:
def build_prompt(store, session_id, task, static_system):
history = store.recent(session_id, limit=20)
turns = "\n".join(
f"{'使用者' if m['role']=='user' else '你'}:{trim_turn(m['content'])}"
for m in history
)
return (
f"{static_system}\n" # 1. 完全不變的部分擺最前面
f"### 之前的對話\n{turns}\n" # 2. 半穩定:只在對話推進時變
f"### 現在的任務\n{task}" # 3. 每次都變的擺最後
)
規則是:越不會變的東西擺越前面。
原因是 prompt cache 只能命中前綴。如果你把每次都變的任務描述放在最前面,後面的東西再穩定也全部要重算。這個順序調整在我的專案上線後,快取命中率從 40% 出頭跳到 90% 以上。Day 29 會完整講快取斷點怎麼配置。
自己管歷史,等於自己扛四件事:儲存的維護、壓縮策略、正確性、以及跨版本的相容。
最痛的是壓縮。20 輪對話會膨脹到什麼程度取決於使用者,有人一次貼三千行的 log 進來。硬性截斷會丟掉關鍵資訊,做摘要要多付一次 LLM 呼叫。我現在的做法是分級處理,先做零成本的頭尾裁切,超過預算才叫模型摘要,這條路徑 Day 29 會展開。
另外要老實說:這一整套只解決了「同一串對話記得上一句」。它完全沒有解決「三週前那個決定是什麼」。逐字歷史撐不到三週,撐到三週的東西必須經過壓縮、排序和遺忘,那是記憶,跟歷史是兩回事。第三幕會從第 13 天開始講這件事。
明天算一筆帳:agent 每次醒來,在還沒開始工作之前,你已經付了多少錢。
我會把 51,673 這個數字拆開給你看。