iT邦幫忙

2026 iThome 鐵人賽

DAY 4
1

Day 4|它醒來了,但不記得昨天

https://ithelp.ithome.com.tw/upload/images/20260820/20183634TyfUzWbOe9.png

有個功能我做過兩次,第二次才做對。

第一次,我讓同一個 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,這是你的設計決定,而預設值往往是錯的。

https://ithelp.ithome.com.tw/upload/images/20260820/20183634Tn15nGWFPy.png

我那個 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 的順序很重要

最後把歷史組進 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 這個數字拆開給你看。


上一篇
最小可行常駐:讓 agent 自己醒來
下一篇
把 CLI 當 runtime 要付多少過路費
系列文
Claude Code 下班之後:30 天把 CLI 工具養成會自己交差的 AI 員工7
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言