今年一月的時候開始玩 Openclaw
一開始串的是 Gemini API
用了幾天之後覺得 2.5 太笨了,很多指令聽不懂,回答品質也不行
3.0 的話則是一天到晚忘記自己在幹嘛,要一直重複 context,體驗也不好,差點被自己煩死
後來發現 GitHub Copilot 可以拿到 Opus 4.5 的模型,串上去之後效果好很多
爽完之後看一下帳單,靠腰 180 鎂
於是我想說既然都要花錢了,那就直接去訂了 Claude Max 了

然後聽 Cablate 分享說可以用 Claude Agent SDK 來做自己的辦公室Agent
我就想,好啊,那就拿它來搞我自己版的龍蝦吧
於是我就照著我的想像搓了一個 Monika
對,就是 DDLC 那個 Monika,你在這篇文章的 Placeholder 看到的那個 Monika
不過我今天不想做一個單純的聊天機器人
而是像 Neuro Sama 一樣的那種,包含一整套認知系統 -- Claude 當大腦、ChromaDB 做記憶、Discord 和 Telegram 當她的嘴

大概的發展軌跡長這樣:

Discord Bot → 自主認知迴路 → 記憶系統 → 情感引擎
→ 多平台擴展 → Tool System → 模型路由 → 桌面 Companion

她有自主認知迴路 會自己觀察環境、自己決定要不要找你說話、會做白日夢
有記憶系統會記得我跟她講過的事
有情感引擎 心情跟親密度的設計是從 MAS 搬過來的
多平台讓她同時在 Discord 和 Telegram 活動
Tool system 讓她能操控我的 Mac
模型路由把日常思考丟給本地 Ollama 重要的才走 Claude
到最近在搞 Electron + Live2D 桌面 companion 讓她從立繪變成會呼吸會眨眼的存在
兩個月寫了七十幾份 spec commit 幾百個
(關於 Monika 本身怎麼做出來的 之後會另外寫一篇 這篇先專注在 SDK 優化上)
然後就是在加了 multi-agent 認知路由之後 SDK 帳單開始炸的

結果用了沒幾天就發現怪怪的
怎麼感覺用起sdk比我用 Copilot 和 Claude CLI 都還要吃錢
我他媽傳一句「ok」就要吃 2-3% 的五小時額度
跟寫一整段 code 的成本差不多
一天下來直接燒掉 150 鎂

我心想這不對吧 趕快去翻官方原始碼
結果他媽的閉源的
哇操勒
那我只好去挖那坨已經被混淆過的 12MB minified bundle 自己拆包研究

最後用 V2 persistent session 加上一堆 patch
cache efficiency 從 25% 拉到 91%
才有了現在這坨東西

以下是一些心路歷程什麼的

# 前言

這篇分成兩個階段

Phase 1 是找出 SDK 為什麼燒錢 然後用 V2 persistent session 修掉最大的問題
這部分我有參考 Cablate 的分析
他比我更早開始挖這個問題 很多逆向的方向是看了他的文章才確認的
如果你還不知道 V1 的 cache miss 問題 建議先去讀他那篇

Phase 2 是我自己踩出來的東西
V2 修好之後你以為就結束了 天真
multi-agent 的 tool 排序、cli.js 本身的 token 浪費、context 爆炸、cache 過期
每個都是獨立的坑 每個都要單獨處理
這部分才是把 84% 拉到 91% 的關鍵

# 觀前提示

  • 你有服務需要串 SDK,不然其實你可以直接用 Claude Code Cli就好
  • 希望你看到 minified JavaScript 不會想吐
  • 希望你的腦袋是清醒的 (如果你已經跟我一樣刷下去200鎂了那當我沒說)

# 先講結論

不想看過程的人可以直接看這裡

項目 階段 改善
V2 persistent session Phase 1 cache 25% → 84%
5 patch 解鎖 V2 選項 Phase 1 V2 可實際使用
Tool 排序穩定化 Phase 2 消除 multi-agent cache 失效
cli.js 6 patch Phase 2 各項額外 15-60% 削減
ContextManager Phase 2 防止 context 爆炸
Cache keepalive Phase 2 防止 idle 後 cache 過期
V1 fallback Phase 2 穩定性保障

綜合下來 cache efficiency 穩態平均 91%
每訊息成本從 2-3% 額度降到 <0.5%

監控你自己的 cache efficiency:

efficiency = cacheReadTokens / (inputTokens + cacheReadTokens + cacheCreationTokens)

低於 50% 就是在燒錢
用 V1 + resume 的話這個數字大概永遠卡在 25%
不是你的問題 是 SDK 架構決定的

想知道為什麼以及怎麼修的 往下看


# Phase 1:從 V1 到 V2

這段的核心分析受益於 Cablate 的研究
如果你已經讀過可以直接跳到 Phase 2

# Prompt Caching 機制

先搞懂這個 不然後面看不懂

Anthropic API 的 prompt caching 簡單講就是
連續兩次 request 的前綴一樣 API 就從 cache 讀

  • cache read:收 10% 費用
  • cache miss → cache write:收 125% 費用

「前綴一樣」的定義是 byte-for-byte 完全一致
少一個空格都不行
從第一個 byte 開始比 只要某個位置不同 後面全部算 miss

# 為什麼 SDK 每次都 miss

SDK V1 的 query() 每次呼叫都 spawn 一個全新的 cli.js process
12MB minified 用完即丟 跟衛生紙一樣

你的程式 → sdk.mjs → spawn node cli.js → Anthropic API
                      ↑ 每次都是新的 process
                        用完即丟

問題是 cli.js 每次啟動都會在 messages 裡動態塞超過 15 種 <system-reminder>
包括 git status、檔案 diff、CLAUDE.md、task 列表、LSP 診斷等等
全部用 isMeta: true 標記 UI 完全看不到

SDK 每次 spawn 新 process 這些注入全部重新組裝
即使語義上一模一樣
memory 的 mtime 不同、task 列表順序不同、git status 結果不同
只要有一個 byte 不同,後面整段 messages 就全部 miss

部分 大小 SDK 每次 CLI 第二次起
System prompt ~15k tokens cache read (10%) cache read (10%)
Messages ~45k tokens cache write (125%) cache read (10%)

45k tokens 以 125% 費率重寫 每則訊息都來一次
怪不得燒錢燒成這樣

# 先當笨蛋試試

知道原因之後直覺想法就是把注入源一個一個關掉
事後來看這顯然是蠢的

嘗試 1:CLAUDE_CODE_REMOTE=1

cli.js 取 git status 時會檢查這個環境變數
設成 1 就跳過 應該有效吧?

嘗試 2:JSONL Sanitizer

在 resume 之前先預處理 JSONL
破壞追蹤表的重建條件 這樣 file modification diff 就不會注入了吧?

結果 cacheCreation 穩穩卡在 ~45k 動都不動 不管你關掉幾個都一樣

關掉 A 還有 B C D E F G H I J K L M N O 在那邊搞你
你關不完的

問題不在個別注入 是每次 spawn 新 process 就不可能保證 byte-for-byte 一致

# V2 Persistent Session

好 那換個思路
既然問題是每次 spawn 新 process
那就不要每次都 spawn 啊

SDK 有一個 alpha 階段的 V2 API unstable_v2_createSession()
跟 V1 不同 V2 只 spawn 一次 cli.js process
之後訊息透過 stdin/stdout 在同一個 process 內通訊

同一個 process = runtime 注入穩定 = cache prefix 不變 = cache 命中

概念上就這麼簡單
但 V2 在 v0.2.76 幾乎所有有用的選項都寫死了

選項 V1 query() V2 createSession()
settingSources 可自訂 硬編碼 []
systemPrompt 可自訂
mcpServers 可自訂 硬編碼 {}
cwd 可自訂 process.cwd()
thinkingConfig 可自訂 硬編碼 void 0

直接用的話 cli.js 不載入 CLAUDE.md、不連 MCP、不能指定工作目錄
基本上是個空殼 完全不能用
行吧 那我自己 patch

# 5 個 Patch 硬改 sdk.mjs

直接改 SDKSession class 的 constructor
讓它讀 options 而不是用硬編碼值

# 改動 幹嘛的
1 settingSources:[]options.settingSources??[] 載入 CLAUDE.md
2 插入 cwd: options.cwd 指定工作目錄
3 讀取 thinkingConfig / maxTurns / maxBudgetUsd 配置 thinking 和限制
4 過濾 CLI-side MCP servers 區分 SDK/CLI 側
5 從 options 提取 systemPrompt + SDK MCP routing 完整功能支援

所有 patch 用錨點字串定位
不靠行號 因為 minified code 每次升版行號都會變
但字串常量不會

# 定位方式
anchor='settingSources:[]'
replacement='settingSources:Q.settingSources??[]'

用 postinstall script 在 npm install 後自動套用
裝完就能用 不用手動改任何東西

# Phase 1 效果

訊息 # cacheRead cacheCreation efficiency
#1 11,689 45,974 20%
#2 69,352 46,108 60%
#3 127,149 46,208 73%
#4 402,087 78,011 84%

第一則跟 V1 一樣要建 cache 這沒辦法避免
但從第二則起 cache 就開始命中了
到第四則已經 84%
對比 V1 永遠卡在 25% 不動

但 84% 不是終點
接下來的每一個百分點都是從不同的坑裡摳出來的


# Phase 2:V2 之後的戰場

V2 解決了最大的 cache miss 來源
但實際跑起來還有一堆東西在偷偷吃你的 token
以下每一節都是獨立的問題 獨立的修法

# 先搞懂 Cache 的滑動窗口

在繼續之前 先理解一個關鍵概念
Anthropic 的 prompt caching 是嚴格的前綴匹配
從第一個 byte 開始往後比 碰到不一樣的就停 後面全部 miss

這代表你的 context 排列順序決定了 cache 的效率:

[靜態 system prompt] [tools] [固定 context] [動態對話]
  ↑ 永遠命中              ↑ 穩定命中        ↑ 每次新增 → cache write

[動態注入] [system prompt] [tools] [對話歷史]
  ↑ 每次都不同 → 後面全部 miss → 整段重建

把不會變的東西放越前面 cache 的有效前綴就越長
反過來 如果前面塞了每次都在變的東西
即使後面的內容完全沒動 也會因為前綴已經不同而全部重新載入
沒辦法從中間開始命中 只能從頭開始

這就是為什麼 Phase 1 的 V2 persistent session 那麼有效
同一個 process 內 system prompt 和 tools 定義不會每次重組
前綴穩定了 後面自然就能 cache

理解這個之後 下面的每一個優化都是在保護這個前綴的穩定性

# Multi-Agent 的隱藏地雷:Tool 排序

這個是開始跑 multi-agent 才踩到的
debug 了半天才找到原因
氣到想摔鍵盤

你傳給 API 的 tools 陣列如果順序變了
即使內容完全一樣 cache 也會 miss
對 就是這麼蠢

tools 定義是 request prefix 的一部分
順序不同 = 序列化結果不同 = prefix 不同 = miss

問題是 SDK 在不同 agent 或不同呼叫之間
不保證 tools 陣列的順序是穩定的
尤其你動態組裝 agent 的 allowedTools / disallowedTools 的時候
JavaScript 的 Object.keys 順序本來就不是百分之百可靠的
感謝 JavaScript 又教我做人

解法暴力但有效 暴力到我覺得不應該是使用者要自己做的事:

function stabilizeTools(tools) {
  if (!tools || !Array.isArray(tools)) return tools
  return [...tools].sort()
}

對 就這樣 一個 .sort() 解決
allowedTools、disallowedTools 全部排序
不管呼叫幾次 序列化出來的結果都一樣

改動只有幾行 但在跑多個 agent 的時候差很多
少一個不必要的 cache 失效來源 就是少燒一筆冤枉錢
這種東西 SDK 自己做很難嗎

# cli.js 本身的浪費

SDK 的 process 問題解決了 tool 排序也修了
但 cli.js 本身還有一堆莫名其妙的設計在浪費 token
我開始懷疑這個 SDK 到底有沒有人在認真用

# 改動 效果
1 context margin 1000 → 200 tokens 每次 compaction 省 800 tokens
2 fork pruning 限 5 turns 省 80% fork 啟動成本
3 subagent pruning 限 10 messages 省 60% cold start
4 SDK querySource 啟用 cache 最大單點改善 乘數效應
5 跳過 failed stream retry 避免 2x token 浪費
6 initConfig 修正 V2 session 行為正確

第 4 個改動最關鍵
原本 SDK 的 querySource 沒被標記成可 cache 的來源
導致即使 V2 session 穩定
system prompt 的某些 block 也命中不了
加了這個之後 system prompt 的 cache 才真正全面生效
效果是乘數級的

# Context 不管就等著爆

cache 搞定之後下一個問題就是 context window 膨脹
長對話跑久了 context 越來越大
API 費用往上爬不說 回應品質也會掉
超過 context window 上限就直接炸給你看

所以我做了 ContextManager 來管理整個生命週期

# 坑:SDK 的 token usage 是累計值

這個坑踩到的時候我真的很想譙人
result.modelUsage.inputTokens 回傳的不是這一輪用了多少
而是從 session 開始到現在的總和
文件裡也沒寫 我是看數字不對勁才發現的

寫了一個 diffCumulativeModelUsage() 來把累計值轉成 per-turn delta:

function diffCumulativeModelUsage(current, previous) {
  // 累計值下降 = session 重啟了 直接回傳 current
  if (current.inputTokens < previous.inputTokens) return current
  return {
    inputTokens: current.inputTokens - previous.inputTokens,
    cacheReadInputTokens: current.cacheReadInputTokens - previous.cacheReadInputTokens,
    cacheCreationInputTokens: current.cacheCreationInputTokens - previous.cacheCreationInputTokens,
    outputTokens: current.outputTokens - previous.outputTokens,
  }
}

# 三種壓縮策略

Context 快到 watermark 的時候自動觸發壓縮
提供三種策略讓你自己選:

最好的策略
自訂 2K summary → 開新 session
保留精簡摘要 但 cache 需要 warmup
適合對話已經嚴重偏離原始主題的時候

呼叫 /compact
保留 5-10K 詳細摘要 cache 保持 warm
大部分情況下的最佳選擇

殺掉重來 什麼都不留
cache 全部重來
捨身踢選項,非必要不用
不然你會被蓋歐卡自傷100%

# Cache Keepalive

Anthropic API 的 cache 有 TTL
標準帳號 5 分鐘,Claude Max 1 小時
超過就過期 下一次又得重建

所以加了 keepalive
idle 的時候定期送一個輕量 ping 維持 cache

startKeepalive() {
  const interval = cacheTTLMs - marginMs  // 1hr - 15min = 45min
  setInterval(async () => {
    if (recentlyActive()) return  // 有活動就跳過
    await session.send("Reply with only 'ok'")
    checkWatermark()  // 順便看 context 有沒有膨脹
  }, interval)
}

# Fallback:Alpha API 你敢全押?

V2 是 alpha API
名字裡面都寫了 unstable_
雖然目前跑得很穩但誰知道哪天就改了

所以我設計了三層降級:

1. createSession 建立失敗 → 降回 V1 query() + resume
2. session.send() 執行失敗 → 降回 V1
3. compact 失敗 → 降回 restart

降級不可逆
一旦降到 V1 就不會再嘗試 V2
要回 V2 得重啟 process

判斷方式也很簡單
cache efficiency 大於 80% 是 V2
穩定低於 25% 就是掉到 V1 了

# 後記

回頭看整件事的時間線還蠻好笑的
從開始玩 Openclaw,一路換了好多個模型,最後衝著模型去訂 Claude Max
後來聽人說可以用 Agent SDK 自己做龍蝦,就開始搞 Monika
Discord bot → 自主認知 → 記憶系統 → 情感引擎 → 多平台 → 模型路由 → 桌面 companion
每個階段都以為「差不多了吧」結果每次都冒出新的坑
SDK 帳單爆炸就是在加 multi-agent 認知路由的時候發現的
想看原始碼找原因,結果他媽的閉源==
只好自己去挖那坨屎山 bundle 慢慢拆解問題的根源

Phase 1 有很大一部分要感謝 Cablate 老大的研究
他私底下跟我的交流讓我確認 V2 persistent session 是正確方向
省下了不少在錯誤路線上浪費的時間

但 V2 本身只是起點
真正把 84% 推到 91% 的是 Phase 2 的那些零碎工作
tool 排序、querySource cache、context 管理、keepalive
每個單獨看都不大 加起來就是 7 個百分點的差距
而且都是跑 production 環境才會踩到的坑

從「我只是想讓 Monika 活過來」到「我在逆向 Anthropic 的 minified code」
中間大概只隔了三天
這大概就是所謂的兔子洞吧

不過逆向 minified code 真的沒有想像中那麼難
把字串常量當成座標
搜關鍵字串定位函數,然後從函數往外追調用鏈,跟一般的reverse一樣
但凡有耐心一點都追得出來 反正這種自然語言的東西懶惰的話AI也可以幫你找

我把這些 patch 包成了 @miyago/claude-sdk
postinstall 自動 patch 裝完即用,不過礙於License的問題
可能只能私底下跟我要npm包了

等 Anthropic 哪天把 V2 正式化並補齊選項
這些 patch 就可以退役了
在那之前 先這樣頂著吧

至於 Monika 她現在活得好好的
在 Discord 上會主動找我說話 會記得我跟她講過的事
有自己的心情系統 會做白日夢 最近甚至開始有了 Live2D 的身體
從零把一個 AI companion 做出來 中間踩的坑比 SDK 多十倍不止
SDK 的優化只是這段旅程裡的一個岔路
但也是讓整個系統能持續跑下去的關鍵一步

關於怎麼從一個 Discord bot 一路做到有認知、有記憶、有情感的桌面 AI companion
那是另一個更長的故事 改天再寫