iT邦幫忙

2026 iThome 鐵人賽

DAY 22
0
AI Engineering

AI 的駕馭之道:一個 AI Code Reviewer 的養成、評測與邊界實錄系列 第 22

Day 22|一天 800 MB:用 mitmproxy 攤開 Claude Code 的流量

  • 分享至 

  • xImage
  •  

Day 22:用 mitmproxy 攤開單場審查的實際流量

圖中的「一天 800 多 MB」是觸發某日流量告警的數值;235 條成功落盤的 HTTP flow、脫敏側錄檔裡的 29.2 MB 上傳 body、其中 15.7 MB(53.9%) 的共同前綴,以及 7.8 MBevent_logging,則來自後來挑選的一場單次審查側錄。兩者不是同一組數據,也不能用後者直接回推前者。

簡短回顧

昨天把一場審查攤開成時間、token 和錢,看得出每個角色各花掉多少。那些數字都是程式自己回報出來的:它說它用了多少 token,我們就只能相信它。

今天要問的是另一種數字。不是它說它用了多少,而是 mitmproxy 解密後,脫敏側錄檔實際留下多少 HTTP body。這能呈現量級與重送結構,但不是網卡上的原始 bytes。

一天 800 多 MB

有一天,一則流量告警上出現了我的機器:網域是 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 都不碰,另外複製一份、洗乾淨、寫進檔案。兩條路從這裡分開,各自給界線:

  • 畫面顯示原始內容。host 端只發布到 localhost,並要帶每場重新產生的隨機 token;這個 token 在該場期間可以重複使用,不是一次性的。專案不會另外把 UI 裡的原始 flow 主動寫進磁碟,落地的只有脫敏副本。
  • 檔案是脫敏版,因為它會留下來,會被複製、被翻閱、被拿去做分析。

落地那一份要講清楚的是:拿掉的是憑證,不是內容。 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/messages200,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,仍由官方架構文件支撐,不能只靠這張圖推完剩下那一段。

在側錄的訊息內容中看見 Google Drive Connector 的 read_file_content 工具呼叫

要連「我沒預料到的客戶端」都錄得到,就得讓憑證進作業系統的信任庫。這是第一個代價:那張憑證從此是整台機器信的,不再只有我指定的那一個行程。我接受它,因為這台機器是用完即丟的容器,憑證每一場現產、不持久化。但這就是三種信任裡最外面那一層(明天會把三種攤開講),往上一層買到的是完整,付出的是範圍。

第二個代價: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。

不過整件事真正讓我停下來的不是這個答案。是為了拿到這個答案,我做了什麼。我在自己跟原廠之間插了一台機器,把加密解開、看完、再送出去,而它從頭到尾配合我。

它其實可以不讓我看。明天講這件事。

後記:如果只想看 API 裡聊了什麼

這篇發出前,我讀到〈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

mitmproxy 以 reverse mode 接收 Claude Code 的 Messages API 流量

請注意畫面左下角:這次顯示的是 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,看看裡面的 systemtoolsmessages,這條路確實更直接。

兩種做法量到的東西並不相同:

面向 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。


上一篇
Day 21|用 OpenTelemetry 幫 AI 審查跑一次 Profiler:量出最冤枉的 13 分鐘
下一篇
Day 23|看得見也有邊界:AI 的 HTTPS 流量憑什麼讓我看
系列文
AI 的駕馭之道:一個 AI Code Reviewer 的養成、評測與邊界實錄30
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言