🎯 什麼情境該想到我

當你「要把角色設定、世界觀、聊天紀錄全擠進一個有限的 context window,還得決定誰先被犧牲」的時候。

⚙️ 怎麼用

問題定義:一個固定大小的箱子

每個模型有固定 context window(例如 8K、128K tokens)。系統提示、角色描述、World Info、聊天歷史全部要塞進去,還要替模型的回應留位置。所以第一步永遠是:

可用空間 = max_context − max_tokens(回應長度)
例:8192 − 300 = 7892

可用空間再分給三類內容:

  • 固定 prompts:main、角色描述、個性、場景、使用者人設、WI、jailbreak 等——定義角色,不能少;
  • 聊天歷史:填滿剩下的空間;
  • 控制訊息:[Start a new Chat]、群組引導(Group Nudge)、Tool Calling 等。

★ 設計原則:固定的先放、可伸縮的後放、必須出現在特定位置的先預留。

Chat Completion 路徑:Reserve → Add → Fill → Free

為什麼不能從前往後依序塞?

控制訊息必須出現在固定位置——[Start a new Chat] 要在聊天歷史之前、群組引導在聊天歷史之後、控制提示在最末。但它們的 token 數必須在聊天歷史填充之前就被算進去;否則聊天歷史會把空間吃光,控制訊息就放不進去了。解法是先「佔位」(只扣額度、不放內容),等聊天填完再把佔位釋放、把真內容插到正確位置。

四個階段

  1. Reserve(預留):先扣掉之後一定要放的東西——每則 assistant 回應的格式標記(3)、控制提示(impersonate / quiet / continue)、[Start a new Chat](分隔對話範例與正式聊天)、群組引導(如 [Write the next reply only as Alice.])、工具定義與呼叫空間。
  2. Add(加入固定內容):依序加入 main、worldInfoBefore、personaDescription、charDescription、charPersonality、scenario、worldInfoAfter、jailbreak。任何一項超過剩餘預算 → 拋出 TokenBudgetExceededError。
  3. Fill(填聊天):把所有聊天訊息從最新往舊逐則放入,放不下就停。這是 LIFO:最新的最先保留。
  4. Free + Insert(釋放並歸位):釋放 newChat 的預留、把它插到聊天歷史開頭;釋放 groupNudge、插到聊天歷史結尾;釋放控制提示、加到最後。

算一次帳(worked example)

設定:max_context = 8192、max_tokens = 300,可用 7892。

階段項目扣除剩餘
初始7892
Reserve助理回應標記37889
controlPrompts1307759
newChat97750
groupNudge157735
toolTokens2007535
Addmain1507385
worldInfoBefore8006585
charDescription2006385
charPersonality1006285
scenario806205
personaDescription506155
worldInfoAfter8005355
jailbreak1005225
FillMessage 10(最新)1505075 ✓
Message 92004875 ✓
Message 81804695 ✓
…(7、6)
Message 5190394 ✓
Message 4500✗ 超出 → 停止
Free+Insertfree(newChat) / insert+9 / −9394
free(groupNudge) / insert+15 / −15394
free(control) / add+130 / −130394

自己驗算幾個關鍵數字:

  • Reserve 共 3+130+9+15+200 = 357,7892 − 357 = 7535 ✓
  • Add 共 150+800+200+100+80+50+800+100 = 2280,7535 − 2280 = 5225 ✓
  • Fill 用掉 5225 − 394 = 4831,放進了 Message 10 到 5 共 6 則(10、9、8、5 已知為 150+200+180+190 = 720,推得 7、6 兩則合計 4111)
  • 最終:7892 − 394 = 7498 已使用;7498 + 300(回應)+ 394(緩衝)= 8192 ✓

★ 從帳上可以看出:Free+Insert 階段的淨值永遠為零——因為預留時已經扣過,它只是「把佔位換成真內容並移到正確位置」。預留的價值在於 Fill 階段不會把那 154 tokens(9+15+130)當成可用空間拿去塞聊天。
★ 另一個觀察:兩段 WI 共 1600 tokens,佔固定內容 2280 的七成。WI 若不另設上限,會直接吃掉聊天記憶的空間——可推測這正是下面 WI 獨立預算存在的理由。

Text Completion 路徑:反覆修剪

Text Completion 用更簡單的策略——先全放,超過再砍:

loop:
  total = storyString + examples + chatHistory + cache
  if total <= max_context: 完成
  elif 還有對話範例: 移除一條對話範例
  elif 還有聊天訊息: 移除最舊的一條聊天訊息
  else: 無法再刪,強制送出
刪除順序理由
1. 對話範例只是 few-shot 示範,拿掉後模型仍能從角色定義理解角色
2. 最舊的聊天訊息近期脈絡比遠期脈絡重要
永不刪除Story String(角色定義)是基礎

兩條路徑的差別:Chat Completion 是「先算好額度再逐項放」(加法),Text Completion 是「全放再逐項減」(減法),但保護優先序一致:角色定義 > 近期聊天 > 範例/遠期聊天。

World Info 的獨立預算

WI 有自己的額度,與主預算分開計算:

WI 預算 = max_context × world_info_budget%
若 world_info_budget_cap > 0:WI 預算 = min(上式, cap)
例:8192 × 25% = 2048 tokens

激活時逐條累計:A 300(累計 300 ✓)→ B 500(800 ✓)→ C 800(1600 ✓)→ D 600(2200 ✗ 超過 2048,不激活)→ E 400 但 ignoreBudget=true(不受限 ✓)。

設定預設說明
world_info_budget25佔 max_context 的百分比
world_info_budget_cap0絕對上限,0 表示只看百分比
entry.ignoreBudgetfalse單條可無視預算

Tokenizer:計數本身就是變數

同一段文字在不同分詞器下 token 數可能差很多,所以預算一定要用目標模型的 tokenizer 算:

Tokenizer對應備註
OPENAIGPT-3.5/4tiktoken 系列
CLAUDEClaudeWebTokenizer
LLAMA3Llama 3WebTokenizer
MISTRALMistralSentencePiece
BEST_MATCH自動依目前 API 與模型選擇
NONE後備粗估每 3.35 字元 ≈ 1 token

Token 計數是高頻操作(每次組 prompt 要算數十次),因此用 per-chat 快取:key = tokenizer 類型 + 字串 hash + 模型 hash + padding,存在瀏覽器本地(localforage)。

⚠️ 注意 / 什麼時候不適用

  • 固定內容太大會直接失敗:Add 階段超額是拋錯,不是自動裁切。角色卡+WI 過肥時,聊天歷史會被壓到只剩幾則甚至放不下。
  • LIFO 是硬切:Message 4 放不下就整則丟掉,不會截半;後面更舊但更短的訊息也不會被撿回來(Fill 遇到第一個放不下的就停止)。被砍掉的遠期記憶要靠摘要或向量記憶補回。
  • NONE 的 3.35 字元估法對中文很不準,中文一個字常常就是一個以上 token;上線產品應接真實 tokenizer。
  • 剩餘緩衝不等於浪費:例子裡最後剩 394,是因為下一則訊息 500 太大;緩衝大小取決於訊息粒度。
  • 原文件的階段圖把 personaDescription 排在 main 之後,數值範例卻把它排在 scenario 之後;總和不受影響,但實際順序以程式為準。

🧪 我實際套用的紀錄

  • (尚無)

🔗 相關