圖中的「一天 800 多 MB」是觸發某日流量告警的數值;
235條成功落盤的 HTTP flow、脫敏側錄檔裡的29.2 MB上傳 body、其中15.7 MB(53.9%)的共同前綴,以及7.8 MB的event_logging,則來自後來挑選的一場單次審查側錄。兩者不是同一組數據,也不能用後者直接回推前者。
昨天把一場審查攤開成時間、token 和錢,看得出每個角色各花掉多少。那些數字都是程式自己回報出來的:它說它用了多少 token,我們就只能相信它。
今天要問的是另一種數字。不是它說它用了多少,而是 mitmproxy 解密後,脫敏側錄檔實際留下多少 HTTP body。這能呈現量級與重送結構,但不是網卡上的原始 bytes。
有一天,一則流量告警上出現了我的機器:網域是 anthropic,一天 800 多 MB。
那天我在做的事情就是打字,頂多就是幾張螢幕截圖。
800 MB 是多少?先粗略當成 8 億個 byte;一個中文字用 UTF-8 存通常是 3 個 byte,換算下來大約是 2.7 億個中文字。紅樓夢全本七十幾萬字,等於那天我傳了三百多遍紅樓夢出去。上班八小時是 28,800 秒,平均每秒要打超過九千個字。用一分鐘一百字的手速,不吃不喝不睡要打五年 🤣。
這是換算,不是量測。真的送出去的東西不只中文,還有英文、程式碼和一堆結構;JSON 如果把中文轉義成 \uXXXX,一個字就變 6 個 byte,字數要砍一半。但不管怎麼算,跟「我打了幾千字」差的是好幾個量級。這個量不是人打得出來的。
這種時候,能回答的通常是「我大概都在做什麼」:寫程式、讀 Code Review 審查報告、跑審查。這是操作描述,不是流量行為。我想知道的是後者。
我對這件事本來有一套想像:伺服器那邊會暫時留著前面的對話,客戶端下一句只要帶一個 conversation_key 之類的東西過去,前面的內容不用再送一次。
問了 LLM,答案是相反的:Anthropic Messages API 是無狀態(stateless)的,client 每次呼叫都要帶上該次推論需要的對話脈絡。第一句、模型的回覆、第二句,到了第三輪又會跟著新的 request 一起送出。
老實說這件事我知道。我自己手刻過 Code Review 的 Script 版和網頁版,當時就是這樣設計的,也做了一個 Sliding Window,只把最後 N 則訊息放進去,就是怕它一直長大。
所以問題不在於我不懂原理,在於我從來沒把原理換算成 MB。
我要看到那段被重送的內容真的躺在線路上。
第一個想到的是 Wireshark。抓得到封包,但看不到內容。那是 HTTPS,攤開來還是一片亂碼。
要看內容,就得有一個東西坐在中間:把加密解開、看一眼、再加密送出去。這是 L7 Proxy 在做的事。我第一個念頭是自己寫一個 Relay,下一秒想到這不就是之前用過的 Burp Suite 嗎,再下一秒想到,坐在中間、把流量解開,這不就是中間人(man in the middle, MITM)。
下一個想到的就是 mitmproxy。
沒用過,但有聽過。選它的理由很直接:它是 Python 寫的,addon 也是 Python。Python 是我的主力,這代表我隨時可以基於它繼續往下開發,而不是只能用別人做好的介面。Burp 的介面更完整,但我要的是能長出東西的地基。
流量要進得了 Proxy,客戶端得先信任 mitmproxy 自己簽的憑證。
我在腦中先跑了一遍這件事:那我還得把這張憑證註冊到作業系統的信任庫裡吧?那是動到整台機器的東西,我不太想這樣做。
先別自己硬幹,我把這題丟給 Claude Code 去查。查出來的答案是:不用。
在我當時使用的 Claude Code 版本與啟動方式裡,可以透過 NODE_EXTRA_CA_CERTS 加入 mitmproxy 的 CA。這個環境變數只交給本場 Claude Code 的行程樹,因此不必先修改整台主機的系統信任庫。
同時它也收 HTTPS_PROXY,會照著我指的地方走。
兩件事湊起來就是:它讓我進來看。 這一點我當下只是覺得方便,後面才想清楚它的份量。
容器裡的接法是這樣:
HTTPS_PROXY=http://127.0.0.1:8880 \
NODE_EXTRA_CA_CERTS=~/.mitmproxy/mitmproxy-ca-cert.pem \
claude
錄之前要先決定一件事:畫面上看到什麼、磁碟上留下什麼,這是兩個問題。
我要邊錄邊看,所以用的是有網頁介面的 mitmweb,而不是純寫檔的版本。網頁上顯示的,是 proxy 記憶體裡那條還活著的連線。那條連線等一下要原封不動送回給 Claude。要是我在上面把憑證抹掉,Claude 收到的回應就會變成一堆 <redacted>。那不是觀察,那是干擾。
所以脫敏處理的是副本:活的那條一個 byte 都不碰,另外複製一份、洗乾淨、寫進檔案。兩條路從這裡分開,各自給界線:
落地那一份要講清楚的是:拿掉的是憑證,不是內容。 authorization 這類標頭整個換成 <redacted>,但整份 system prompt、送進去的程式碼都還在檔案裡,那正是錄它的目的,而那些東西本身可能就是機敏材料。所以錄下來的目錄要當機敏目錄看待。若要照這套做,至少把 ~/ncr/mitm/ 設為 chmod 700,並安排定期清理;目前程式不會替你決定保存期限。
還有兩條規矩是寫死的:脫敏過程只要出錯,那一條紀錄直接丟掉,絕不退回去寫一份沒掃過的;脫敏程式如果根本不在,那就整場不錄。錯的時候往少的那邊倒,不往多的那邊倒。
文章寫完後,我讓錄製跑得更久,才補出第三個問題:client 什麼時候收到回應。
我原本只開了 store_streamed_bodies=true,以為 SSE 既然能完整落檔,就代表它也在即時轉送。兩件事其實完全不同。mitmproxy 的預設行為是先讀完整個 request/response body,再交給另一端;stream_large_bodies 才是開啟串流的門檻。store_streamed_bodies 的語義則是「串流已經發生時,仍把 body 留在記憶體裡供 UI 和 addon 檢查」,本身不會讓串流發生,而且會增加記憶體用量。
於是畫面上很快就出現 /v1/messages 的 200,Claude Code 卻收不到任何 SSE event,要等模型整步生成完才一次拿到。短的 request 看起來都正常,只是原本的逐字輸出變成最後一起出現;直到某一步生成時間超過這個版本的等待上限(這次從 header 到斷線穩定實測約 198 秒),才變成 API Error: No response from API。client 一斷線,這一輪也不會突然從 proxy 的 buffer 裡復活;重送同一個工作,仍可能再次撞上相同條件。
修正要把兩個選項一起開:
--set stream_large_bodies=1 \
--set store_streamed_bodies=true
1 是 1 byte 的門檻,嚴格說是超過 1 byte 的 body 都串流;對實際的 SSE response,效果就是從第一批資料開始往 client 送。第二個選項仍然保留,讓串流結束後 addon 拿得到完整 body,複製、脫敏再落地。
修完後我在真容器裡做了對照:經過 proxy 的 plain/gzip SSE 都逐秒抵達;同一個容器拿掉 stream_large_bodies 的對照組,八筆事件在最後一秒一起出現。串流模式下,完整 body 仍能脫敏落檔,假 secret 也確實被拿掉。
錄得到、存得下來、送得及時,是三件不同的事。
錄完之後我在自己的機器上想打開來翻,結果是這個:
ValueError: mitmproxy 9.0.1 cannot read files with flow format version 21,
please update mitmproxy.
容器裡的 mitmproxy 是 12,我的機器上預設的 Python 虛擬環境還留著 9(訊息裡那個 21 是檔案格式的版本,跟 mitmproxy 自己的版本號是兩套編號)。.mitm 檔案裡帶著格式的版本號,舊版讀到新格式不會盡力讀到哪算哪,是直接拒絕。
這件事值得記一下,因為它改變了「錄起來」這個動作的意義。今天錄的東西,如果哪天工具自己升上去、或是換一台機器用舊版打開,就有可能讀不回來。所以容器裡的版本我釘死了,分析用的腳本也宣告同一個下限。這跟我前面對掃描工具釘版本是同一個理由,只是後果更直接:那邊釘版本是為了報告可重現,這邊不釘就是檔案打不開。
工具放在 repo 的 mitm/,三個檔案:
mitm/
├── redact.py 落盤前的脫敏規則
├── capture_addon.py mitmproxy 的 addon,把每條連線的脫敏副本寫進 .mitm
└── wire_report.py 從 .mitm 統計脫敏後留下的 HTTP body,輸出單頁報表
容器啟動時會問四題,錄製佔了其中兩題:
網路能力:
1 = 限制(白名單) 2 = 完全開放
錄製本場流量?(mitmproxy)
y = 錄,落在 ~/ncr/mitm/<session-id>/(脫敏後)
n = 不錄(預設)
錄製範圍:(只有上一題答 y 才會問)
1 = 全部流量(預設) — 憑證裝進容器的系統信任庫,proxy 進關鍵路徑
(範圍是這一場 CLI 及其子行程;另外 docker exec 進來的不在裡面)
2 = 只錄模型 API — 只落地 api.anthropic.com;其餘流量照樣經過 proxy,只是不寫進紀錄
送 telemetry trace 到 Jaeger?
預設是不錄。錄流量這種事應該要有人明確答應,不該是預設行為。
答 y 之後畫面會印兩行:
● 錄製中 → ~/ncr/mitm/<session-id>/
● 即時畫面 → http://localhost:40000/?token=cQ33EFCbhBrPTXg1...
第二行是可以邊錄邊看的網址。那個 port 是啟動時從 40000 開始找一個沒被占用的,同時開兩個容器不會撞;token 每一場重新產生。網址印的是 host 的視角,容器裡的路徑和 port 對坐在電腦前的人沒有意義。
一場一個資料夾,收工時裡面是這些:
~/ncr/mitm/ce4d051f-e389-4bcc-bdca-b19d6ba4685a/
├── flows.mitm 錄到的流量
├── firewall.txt 從 firewall 初始化起的累計封包計數
│ (包含啟動自我驗證,只有限制模式才有)
└── meta.json 錄了哪個檔、起訖時間、網路模式、有沒有送 telemetry、
這一場錄的範圍、session id
firewall.txt 只在限制模式產生,因為完全開放模式根本沒有規則可以數。它是收工時讀取的累計快照,不是從側錄開始後才歸零計算的差值:firewall 會在錄製選單出現前套用,初始化腳本也會先測一次「該擋的確實被擋、該通的確實可達」,所以裡面包含啟動自我驗證與側錄前的流量。就算在限制模式,取不到計數的時候它也會把檔案刪掉、印一行警告,不會留一個空檔在那裡。這跟報表那邊是同一條規矩:沒有量到就說沒有量到,不要留一個 0 讓人誤會。
meta.json 裡有一個欄位值得單獨講,是 capture_hosts,也就是這一場錄的範圍。報表上那份端點清單看起來像「這一場連過的全部」,實際上是「過濾器讓我看到的那些」。範圍不記下來,讀報表的人分不出這兩件事,而這兩件事差很多。
資料夾的名字就是這一場的 session id,而 CLI 的對話紀錄也叫這個名字:
~/.claude/projects/<專案>/ce4d051f-e389-4bcc-bdca-b19d6ba4685a.jsonl
所以流量、防火牆、對話紀錄三份資料,靠同一個 id 對得起來。
這個 id 是我們指定的,不是事後去認的。 我第一版是用猜的:收工時去撈「錄製開始之後才被改到的那顆對話紀錄」。多數時候會對,但同時開兩個容器、或本機剛好也在跑,就會對到別人身上。
後來發現 CLI 收 --session-id,那就不必猜了。開場自己產一個 uuid,餵給它,同時拿來當資料夾名:
NCR_SESSION_ID=$(cat /proc/sys/kernel/random/uuid)
claude --session-id "$NCR_SESSION_ID" ...
只在該注入的時候注入:跑的不是 CLI、或呼叫端已經帶了 --session-id、--resume、--continue,就不碰。硬塞一個新 id 給要接續的對話,只會直接衝突。
這件事跟這篇要講的是同一回事。能對得起來,是因為在開場就決定了它,不是事後去推。
錄完之後出報表:
uv run mitm/wire_report.py ~/ncr/mitm/<session-id>/flows.mitm --open
uv run mitm/wire_report.py <capture> --json > wire.json # 要拿去接別的東西
它算的是四件事,都是只有攔在中間才看得到的:脫敏側錄檔裡留下多少 HTTP body bytes(位元組)、其中多少與同一條對話的前一次共同、除了模型 API 還連了誰、以及快取邊界落在哪裡。錢和每個角色花多久它不算。那些昨天那套已經有了,而且來源更準,重複做只會讓人不知道該信哪一份。要用到牌價的時候,它直接去 import 昨天那支腳本,不自己抄一份。
一場程式碼審查,約從 17:17 跑到 19:50,歷時約 2 小時 33 分。
這份脫敏側錄統計出 235 條成功落盤的 HTTP flow、上傳 body 29.2 MB、下載 body 771 KB。
第一個反差在這裡:上行是下行的 39 倍。直覺會以為跟 AI 互動是「它講很多」,實際上是我一直在把同一段話重複講給它聽。
報表在脫敏後的 request body 中,比對出 15.7 MB(53.9%)曾出現在同一條對話前一次請求的共同前綴。一半以上的上行側錄,都與前一次的開頭相同。這不是失敗重試,而是每次呼叫都要帶上當下所需對話脈絡所形成的累積;所以愈到後面,新內容的佔比愈低。

還有一個數字我原本沒打算量。報表把相鄰請求間超過兩分鐘的空窗加總起來:全場約 2 小時 33 分裡,有 1 小時 57 分沒有任何請求;扣除這些長空窗後,時間軸上剩下約 35 分鐘有請求活動。那段最長的空白,正好是我通勤的路上:容器活著、程式活著,但網路斷了。
至於「連續兩次請求開頭一模一樣」這件事,量法是逐 byte 比對共同前綴。要注意比對的對象是同一條對話的前一次,不是時間上相鄰的那一次。審查過程會派出子代理人,它們的請求會交錯進來,拿時間相鄰的兩次去比會得到一個沒有意義的小數字。
壓縮的問題也順便確認了:這一場沒有任何 request 帶 content-encoding,因此側錄裡的 request body 沒有再經過內容壓縮。不過落盤前的脫敏會替換敏感值,也會重新序列化 JSON;這些數字適合觀察量級與累積方式,不是線上原始 body 的逐 byte 副本。它同樣沒有包含 HTTP 標頭、HTTP/2 framing、TLS 與 TCP/IP 的額外成本,不能直接拿來跟網卡數字一比一對帳。若要精確對帳,必須在脫敏前另外保存長度等純量指標,而不是保存原文。
一開始我只錄模型 API 那一個網域。這是錯的,而且錯在論證:一份只錄了自己允許的那一條的紀錄,拿來回答「有沒有東西走漏」是循環論證。
所以我把 HTTP(S) proxy 的 capture host 改成不設限。啟動時先問要不要錄,答應了才問範圍;選擇「全部流量」時,capture_hosts 會留空,代表不再按 request host 過濾。下文為了好讀仍簡稱「全錄」,但這個「全」只指這一場 CLI 及其子行程裡,所有實際經過 proxy 的 HTTP(S) flow;它不是整顆容器的封包側錄。DNS、主動排除的 OTLP/gRPC,以及沒有走 proxy 的程式仍不在裡面,後面會逐一拆開。
改成全錄後,側錄檔裡馬上多出兩個先前沒有被保存的對象:一個是位於另一個網域的 MCP proxy;另一個是我透過 Bash 工具執行的 curl。它們其實早已經過 mitmproxy,只是當時 capture_hosts 只保留 api.anthropic.com,其他網域會在寫入側錄檔前被過濾掉。這也讓我分清楚兩件事:流量有經過 proxy,不代表最後一定會出現在側錄報告裡。
這裡也補回 Day 20 留下的一塊證據。當時容器直接連 Google Docs 失敗,Google Drive Connector 卻讀回了同一份私人文件;我的判斷來自 Claude 當場的回答,以及 Connector 在 Anthropic 雲端執行的官方架構。這次攤開 /v1/messages 的 request,我在對話內容裡直接看到 mcp__claude_ai_Google_Drive__read_file_content 的 tool call,以及它收到的同一個 Google Drive fileId。
這張圖能證明的是:那次對話裡確實發生了 Google Drive Connector 的工具呼叫。至於 Anthropic 後端如何連向 Google,仍由官方架構文件支撐,不能只靠這張圖推完剩下那一段。
要連「我沒預料到的客戶端」都錄得到,就得讓憑證進作業系統的信任庫。這是第一個代價:那張憑證從此是整台機器信的,不再只有我指定的那一個行程。我接受它,因為這台機器是用完即丟的容器,憑證每一場現產、不持久化。但這就是三種信任裡最外面那一層(明天會把三種攤開講),往上一層買到的是完整,付出的是範圍。
第二個代價:proxy 從此在工作的關鍵路徑上。先前審查用的內部流量繞開它,proxy 掛了不影響工作;現在它掛了,這一場就跟著掛。
第三個代價比較難察覺:就算全錄,還是有東西錄不到。
DNS 封包不在這份側錄裡。這套設定只保存經過 mitmproxy 的 HTTP(S) flow;網域解析走的是另一條 DNS 通道,因此目前這支 addon 不會記錄。若要觀察 DNS、TCP 或 TLS 封包,得改用 tcpdump/Wireshark,在正確的 container network namespace 擷取。代價是 HTTPS 內容仍是密文;mitmproxy 能看解密後的 HTTP 內容,封包工具則能看完整的網路層行為。
gRPC 也不在裡面,但這個是我主動排除的,不是做不到。送給觀測後端的 OTLP 走的是明文 HTTP/2,不是 TLS,而 proxy 那條隧道本來是給 TLS 用的;就算接得過去,protobuf 是二進位,我的脫敏規則是文字和 JSON 的,只能整塊抹掉,錄了也讀不出東西。更根本的理由是:把觀測的管道穿過被觀測的東西,本身就是壞主意。proxy 一抖,你連「剛才發生了什麼」都失去了。
有意思的是,這兩件事在另一個儀器上看得見。同一場的防火牆計數:
Chain INPUT udp spt:53 68 個封包
流量表上一個 DNS 查詢都沒有,防火牆數到 68 個回應封包。兩個儀器各自回答一半,而且都不知道自己少了哪一半。
錄不到的清單上還有第三項:另外開一個行程進去,也錄不到。
proxy 是用環境變數交出去的。entrypoint 在起 CLI 之前 export HTTPS_PROXY,而環境變數只往下傳:那支 bash 之後生出來的子行程拿得到,其他人沒份。docker exec 開進去的行程不在這條鏈上。它是 Docker 從外面另外塞進容器的,環境是照 image 的設定重新給的一份,entrypoint 在執行期 export 的東西,一個都不在裡面。
我是繞了一圈才發現的。當時想量「開了錄製之後,被擋的請求長什麼樣」,docker exec 進去跑 curl,量到的結果跟沒開錄製一模一樣。以為是 mitm 沒起來,去看行程,它在跑;去看 flows 檔,它在長。最後是把每支行程的 /proc/<pid>/environ 攤開比對才看懂:
PID 1 bash ← entrypoint
├ 58 mitmweb
└ 945 CLI ← HTTPS_PROXY 在這一支上
docker exec 的 curl ← 不在這棵樹上,環境裡沒有 proxy
這裡還埋著一個小陷阱:/proc/1/environ 裡也沒有 proxy 變數,會讓你以為連 entrypoint 自己都沒設。其實有。environ 是行程 exec 當下的快照,export 發生在 bash 起來之後,照不進去;但它傳給子行程的是活的那份,所以 CLI 拿得到。儀器沒有壞,是我拿著快照當現況看。
(順帶一提 PID 1 是 bash 不是 CLI:開了錄製之後要有人在 CLI 結束後做收尾,也要有人接 docker stop 的 SIGTERM。寬限期一過就是 SIGKILL,那時 meta.json 與防火牆計數都還沒寫。所以那支 bash 留著當 PID 1,CLI 變成它的子行程。)
跟 DNS 那件事一樣,這件事最難的地方是它沒有任何跡象:畫面上錄製中,檔案在長,只是少了這一塊。要涵蓋它得改用 iptables 透明轉址:環境變數這條路是程式自願走的,透明轉址不問意願。
端點清單有一個它答不了的問題:沒連成功的呢?
流量紀錄不是防火牆紀錄。沒有經過 proxy、而且被防火牆攔下的連線,不會在 HTTP flow 裡留下痕跡;至於走 proxy 的被擋嘗試,proxy 仍可能回傳一張 502,讓這次失敗留在流量檔裡。拿一份只涵蓋 proxy 所見範圍的資料去說「我沒有連別的地方」,仍然是循環論證。
要回答那個問題得換一個儀器:防火牆自己的封包計數。每一條規則都記著它處理過多少封包,包含最後那條「其餘一律拒絕」。
Chain OUTPUT (policy DROP)
pkts target
3489 ACCEPT out=lo ← proxy 走 loopback
1259 ACCEPT tcp dpt:22 → GitLab
1682 ACCEPT → docker 網段
68 ACCEPT match-set allowed-domains ← 白名單裡的網域
60 REJECT ← 自 firewall 初始化起,累計 60 個封包命中拒絕規則
最後那行是這份資料唯一的重點。60 個封包、3,600 bytes,代表從 firewall 初始化到收工,累計有 60 個封包命中最後的 REJECT 規則;其中包含啟動時的自我驗證,不能全部歸給這場審查或某一次 curl。這些封包也可能來自一次或多次連線嘗試,只靠這份 counter 無法換算成確切的連線次數。它們要去哪裡、是誰發的,這裡同樣看不出來,但「期間確實有封包試著往外走」這件事,只有這一層數得到。
所以收工的時候把這份累計計數一起寫下來,報表上獨立一塊,跟端點清單並排。一份講「我跟誰講了話」,一份講「從 firewall 初始化以來,有多少封包撞上拒絕規則」。若要把封包精確歸因到某次測試,就得在測試前後各讀一次 counter,以差值判斷。
報表在沒有這份計數的時候,會明說「沒有量到」,而不是顯示 0。沒有量到跟量到零是兩件完全不同的事,混在一起就是給人一個假的安心。
還有一件我到很後面才知道的事:同一個被擋的請求,在有沒有開錄製之下長得完全不一樣。
| 沒開錄製 | 開了錄製 | |
|---|---|---|
curl http://ifconfig.me(白名單外) |
HTTP 000,exit 7Failed to connect to ifconfig.me port 80 after 7 ms: Couldn't connect to server |
HTTP 502,exit 0[Errno 113] Connect call failed ('34.160.111.145', 80) |
curl https://api.anthropic.com/v1/messages(白名單內) |
405,exit 0 |
405,exit 0 |
第二列的 405 不是壞掉,而是拿 GET 去打一個只接受 POST 的端點。這個測試不是在等 200,而是在確認開啟錄製後,原本放行的 Anthropic API 是否仍然可達。沒開錄製與開啟錄製時都得到 405,表示加入 mitmproxy 沒有把這次請求的路徑弄壞;但這裡只比較了連線結果與 HTTP 狀態碼,不能據此宣稱 proxy 完全不會改變其他行為。
擋的位置沒有變,兩種情況都是同一條 iptables -A OUTPUT -j REJECT。變的是誰去撞牆:沒有 proxy 時是 curl 自己撞;有 proxy 時是 mitmproxy 撞,然後回你一張 502。
這順手修正了本節開頭那句話的邊界:開著錄製的時候,走 proxy 的被擋嘗試其實會以一張 502 留在流量檔裡,流量紀錄不是完全看不到失敗。看不到的是不走 proxy 的那些:DNS、上一節那種另外開進來的行程。要數得齊,還是只有防火牆計數。
這次 502 裡的 errno 113 與防火牆的 REJECT 機制相符;不過收工時那份累計 counter 不能單獨證明這一次 curl 增加了幾個封包。要精確歸因,仍然得比對測試前後的 counter 差值。
真正的坑在第二欄的退出碼:開了錄製之後,curl 對一個被擋的目標會回傳 exit 0。 HTTP 交易確實完整走完了,只是內容是一張 502。任何寫成 curl ... || echo "被擋了" 的檢查,在開錄製之後就安靜地失效。修法有三個:看狀態碼;給 curl 加 -f 讓 502 變成非零退出碼;或者加 --noproxy '*' 直接對牆說話。
端點清單攤開來是這樣:

130 次 21.3 MB ↑ /v1/messages ← 真正的對話
91 次 7.8 MB ↑ /api/event_logging/v2/batch ← 遙測回報
4 次 0 ↑ /mcp-registry/v0/servers (下載 201 KB)
2 次 56.0 KB ↑ /v1/messages/count_tokens
其餘端點:oauth/profile、claude_cli/bootstrap、
mcp_servers、account/settings⋯⋯
event_logging 一場送出去 7.8 MB,是全場第二大的上傳來源,佔總上傳超過四分之一。
這條在昨天那張以模型呼叫為單位的帳單與時間軸上看不到。它不是模型呼叫,不產生模型 token 費用,所以以「模型呼叫」為單位的觀測不會記錄它。要看到它,只能站在流量這一層。
看到重送量之後會有一個直覺的疑問:不是有 prompt cache 嗎?
有,而且命中率很好看。但快取省的是它的算力,不是我的頻寬。
伺服器要判斷這次的內容跟上次是不是同一段開頭,前提是我得先把完整的內容送上去給它比對。快取讓它不用重算,帳單也因此便宜很多,但客戶端送出的 HTTP body 並不會因此變小。
順著查了一下快取的規則,有三件事值得記住:
它是由左至右的前綴比對,很像 composite index。要命中某個 cache breakpoint,從開頭到該斷點的內容必須完全一致;其中任何一個字改動,該斷點的完整前綴就不再命中。不過系統會在有限的 lookback window 內往前尋找先前已寫入、仍然相符的 prefix;找得到時,前半段仍可能重用,不是必然整段歸零。
它是 best effort。並行送出去的請求會全部落空,因為快取要等第一個回應開始串流之後才存在;內容太短不到門檻的不會被快取,而且不會報錯,只是靜靜地沒有作用;五分鐘的存活時間是從請求開始算的,回應串了四分鐘,下一次就只剩一分鐘。
它不會跨組織共用。Claude API、Claude Platform on AWS 與 Microsoft Foundry 會再按 workspace 隔離;Bedrock 與 Google Cloud 則採 organization 層級。即使提示詞完全相同,跨過這些界線也不會命中同一份快取。
Responses API 預設會保存 Response,下一輪只要帶 previous_response_id 就能接續。啟用 ZDR 後,store 會被強制設為 false;API 仍然能用,只是回到 stateless 的使用方式,由 client 自己保存並重送需要的對話內容。同一支 API,差別是誰負責記住前文。
錄的時候也看到 Claude Code 的 /usage 指令會回報目前用量與剩餘額度;這個端點沒有公開文件,我先不把它當成穩定 API 🤫。
這場側錄沒有直接重建當初那一天的 800 多 MB,但它確實證實了反覆傳送的對話脈絡會累加成可觀的上傳量,也讓我看見一個能產生這類大量上傳的機制:Anthropic Messages API 是無狀態(stateless)的,每次請求都會帶上當下所需的對話脈絡;prompt cache 可以降低運算成本,卻不會因此減少客戶端送出的 HTTP payload。
不過整件事真正讓我停下來的不是這個答案。是為了拿到這個答案,我做了什麼。我在自己跟原廠之間插了一台機器,把加密解開、看完、再送出去,而它從頭到尾配合我。
它其實可以不讓我看。明天講這件事。
這篇發出前,我讀到〈Day 02 - 側錄 Claude Code!〉。它問的是一個比我今天確切、也因此有更漂亮解法的問題:如果只是想知道 Claude Code 送給 Anthropic Messages API 的 request 長什麼樣,不一定要把整場 HTTPS 流量都導進 proxy,也不必先處理 CA 信任。Claude Code 支援 ANTHROPIC_BASE_URL,可以直接把 API 指向本機的 reverse proxy。
mitmproxy 本身就有 reverse proxy mode。第一個終端機把它跑起來:
mitmweb \
--mode reverse:https://api.anthropic.com \
--listen-host 127.0.0.1 \
--listen-port 9527 \
--set stream_large_bodies=1 \
--set store_streamed_bodies=true
第二個終端機把 Claude Code 指過去:
ANTHROPIC_BASE_URL=http://127.0.0.1:9527 claude
請注意畫面左下角:這次顯示的是 reverse:https://api.anthropic.com。這是它和前面那套一般 proxy 最直接的畫面差異;mitmproxy 現在不是等 client 告訴它每一條連線要去哪裡,而是把收到的 request 固定轉送到這個上游。
這時 Claude Code 到本機 mitmproxy 走的是明文 HTTP,mitmproxy 再用 HTTPS 連到 api.anthropic.com;因此不需要另外餵 NODE_EXTRA_CA_CERTS。嚴格來說,它不是解開 Claude Code 原本那條 HTTPS,而是讓那條連線一開始就改成「Claude Code → 本機 reverse proxy → Anthropic」。如果目的只是打開 /v1/messages,看看裡面的 system、tools 和 messages,這條路確實更直接。
兩種做法量到的東西並不相同:
| 面向 | Proxy | BaseURL 指向 | 差異說明 |
|---|---|---|---|
| 導流方式 | 設定 HTTPS_PROXY |
設定 ANTHROPIC_BASE_URL |
Proxy 保留原本的 API 目的地,讓支援 HTTP proxy 的連線經過觀測點;BaseURL 則直接改掉 API 入口。 |
| Claude Code 到觀測點 | 仍是 HTTPS,需要讓該行程信任 mitmproxy CA | 可以走 localhost 明文 HTTP | 前者真的在中間解開 TLS;後者沒有攔原本的 TLS,而是由 reverse proxy 另開一條 TLS 連線到上游。 |
| 觀測範圍 | 這棵行程樹中願意遵守 proxy 設定的 HTTP(S) 流量 | 使用該 BaseURL 的 API client | 前者能看見模型呼叫以外的遙測與其他端點;後者適合看 Messages API request,但不能拿來當整場端點清單。 |
| 設定成本 | 要處理 proxy、CA 信任、側錄與脫敏 | 一個 reverse mode 加一個環境變數 | 只拆 request 結構時,BaseURL 做法比較短;要盤點整體流量時,Proxy 多出的設定才有意義。 |
| 適合回答的問題 | 「這場工作實際送了多少、連了哪裡?」 | 「Claude Code 送給模型的那包 JSON 長什麼樣?」 | 不是哪一個比較高明,而是先決定自己要量什麼,再選最小的儀器。 |
如果我今天只想拆開一包模型 request,我會選 BaseURL 這條路。這篇之所以繞比較遠,是因為起點不是 request schema,而是流量告警那一天 800 多 MB 的整體流量;只看 /v1/messages,不會看到這一場另外送出去的 event_logging,也無法知道還有哪些 client 根本沒有使用同一個 BaseURL。