やってみた 2026年10月2日

トークン削減プロキシcavemanを動かしたら、同じ形のログ行に紛れた外れ値が痕跡なく消えた

XECIN GoLLMプロキシログ

GitHubのトレンドで JuliusBrussee/caveman を見かけました。説明文は「why use many token when few token do trick」。原始人の口調でしゃべらせてトークンを減らす、という半分ネタのような触れ込みです。

ただ、READMEを読み進めると中身はネタではありませんでした。エージェントが読むログやテスト出力、JSON、差分を、プロバイダに送る前に手元のプロキシで縮める。縮める前の元データはローカルのSQLiteに残しておき、モデルが必要としたら取り戻せる。そういう作りです。

私は以前、ログ収集の経路に「似た行は間引く」設定を入れていて、障害の手がかりになる1行まで落としていたというケースもありました。間引いた側の画面は平穏そのものなので、なかなか気づけないんですよね。それ以来、ログを縮める仕組みを見ると、何を捨てる設計なのかを先に確かめたくなります。今回もそこを中心に触ってみました。

試した環境

項目内容
実行場所Windows の Docker Desktop 上の使い捨てコンテナ(node:22-bookworm、メモリ4GB・CPU2)
Node.jsv22.23.1
cavemannpm の @caveman-ai/cli 2.0.0(バイナリは bin-v2.0.0)。リポジトリのリリースは v3.0.0、main は b39c908628
LLM使っていません。Anthropic Messages 互換のモック上流を自作し、届いたリクエストを保存して調べました
利用統計DO_NOT_TRACK=1 で停止(caveman status の表示は telemetry off)

利用統計の件は先に触れておきます。READMEとSECURITY.mdによると、CLIは実行したコマンドや処理・削減したトークン数などを既定で送ります。プロンプトやコード、ファイルパスは送らないと書かれていますが、オプトアウト式です。私は最初から DO_NOT_TRACK=1 を付けて動かしました。

入れるところまでは20秒足らず

$ npm install -g @caveman-ai/cli
added 1 package in 1s                 # 実測 1.345 秒
$ caveman --version
{ "version": "2.0.0", "binary_release": "bin-v2.0.0" }
$ caveman setup --install             # 実測 16.0 秒(進捗表示と配置先の行は省略)
caveman-proxy  linux/amd64  45.8 MB  checksum verified
caveman-engine  linux/amd64  26.6 MB  checksum verified
caveman-mcp  linux/amd64  24.5 MB  checksum verified
cavemem  linux/amd64  24.6 MB  checksum verified
caveman-browse  linux/amd64  32.3 MB  checksum verified
caveman-shrink  linux/amd64  24.3 MB  checksum verified
next: caveman claude

npmのパッケージ自体は依存ゼロで、setup --install がGo製のバイナリを6本取ってきます。合計178,069,598バイト。チェックサムを確かめてから置いてくれるのは好印象でした。

小さなことですが、caveman compress --help は使い方を出さずに "tokens_before":0 の集計を返し、caveman retrieve --help は recovery handle not found でした。--help を素直に受け付けないサブコマンドがあるので、使い方は caveman help tools で一覧を見るのが早いです。

まずCLIで縮めてみる

題材はコンテナの中で自作しました。同じ形のレコード500件のうち1件だけがエラーを持つJSON(71,495B)と、進捗のINFO/DEBUGが3,000行続く途中にWARNと、ERROR+スタックトレースが挟まったログ(187,253B)です。

$ caveman compress < app.log
2026-10-02T12:00:00Z DEBUG export: chunk 0 buffered (0 rows)
2026-10-02T12:00:01Z INFO  export: progress 1/3000 (0.0%)
… 1198 lines elided (caveman) …
2026-10-02T12:20:00Z WARN  pool: connection pool at 92% (46/50)
… 499 lines elided (caveman) …
2026-10-02T12:28:20Z ERROR export: failed to write chunk 1700: ENOSPC: no space left on device, write
    at writeChunk (/srv/app/src/export/writer.js:88:13)
    at async exportBatch (/srv/app/src/export/batch.js:41:7)
    at async runJob (/srv/app/src/jobs/run.js:120:5)
… 1298 lines elided (caveman) …
2026-10-02T12:49:59Z INFO  export: progress 2999/3000 (100.0%)
2026-10-02T13:00:00Z INFO  export: finished with 1 error
… caveman: elided lines resemble shown lines and match the stated invariants — answer from this view; if you truly cannot, one broad caveman_retrieve …
# 標準エラーに出る集計(一部の項目を省略)
{"content_type":"log","tokens_before":83689,"tokens_after":292,"ratio":0.9965108915150139,"recovery_handle":"ccr_81736aca5112c304bc9aabcc56a5678a","method":"log","lossless_to_model":false}

187,253Bが840B、13行になりました。WARNもERRORもスタックトレースも残っていて、ログを読む側としては「要るところは残っている」形です。READMEに載っているログの削減の目安は85〜95%ですが、今回の作り物のログは単調だったこともあり、トークン数で99.65%減りました。

JSONのほうも71,495Bが1,264Bに縮み、エラーを持つ job-0317 はエラーの中身ごと残りました。省いた範囲には __caveman_invariants__ という要約が付き、「job-0003..job-0316 の314件はすべてある」「region は tokyo×105 osaka×105 fukuoka×104」のように件数が書かれます。

復元も試しました。caveman retrieve ccr_fc467e20... で戻したデータは71,495Bで、sha256も(先頭16桁で比べて)元のファイルと一致しました。ついでに気づいたのですが、復元ハンドルの ccr_ に続く32桁は、元データのsha256の先頭32桁そのものでした(3ファイルで確認)。同じ内容なら同じハンドルになるので、内容から引ける形で保存しているのだと思います。

消えるのはどんな異常か

ここからがエッジケース好きの本題です。「エラーの形をしていない異常」を1か所だけ仕込んだ変種を作り、1変種ずつ別プロセスで縮めて、仕込んだ箇所が縮めた後も見えるかを確かめました。

仕込んだ異常縮めた後見えるか
JSON: job-0250 の latency_ms を 98765 に(status は ok のまま)1,741Bその行がそのまま表示される
JSON: job-0250 の status を degraded に1,237B行は省かれる。要約に「status: ok×313 degraded×1」とだけ出て、どのジョブかは分からない
JSON: job-0250 にだけ retries: 5 を追加1,235B行は省かれる。要約に「retries: 5×1 absent×313」とだけ出る
ログ: INFO で「chunk 1500 skipped: checksum mismatch (rows=0)」957B残る
ログ: レベル無しで「chunk 1500 failed: checksum mismatch (rows=0)」950B残る
ログ: 進捗の行の末尾に「rows=0」を足しただけ944B残る
ログ: 他の DEBUG 行と同じ形のまま「(99999 rows)」に840B痕跡なく消える(何も仕込まないログと同じ840B)

ログは思っていたより賢くて、文言が少しでも違う行はINFOでもレベル無しでも拾いました。ERRORという単語だけに頼った単純なフィルタではないようです。

ただ、最後の1行だけは事情が違います。形は他のDEBUG行と同じで、数字だけが桁外れ。これは跡形もなく消え、出力は何も仕込まないログと同じ840Bでした。しかも末尾には「省いた行は見えている行に似ていて要約どおりなので、この表示から答えよ」という注記が付きます(どうしても無理なら一度だけ広く caveman_retrieve せよ、と続きます)。正直なところ、ここが一番ひやりとしました。JSONでは数値の外れ値が行ごと残ったのに、ログでは同じ種類の外れ値が残らない。データの形式によって、拾える異常の種類が違うわけです。

プロキシを挟む: 最初のアプローチは素通しだった

本命はプロキシです。LLMのキーは使いたくないので、Anthropic Messages互換のモック上流をNodeの標準ライブラリだけで書き、届いたリクエストを丸ごとファイルに保存するようにしました。送る側は「エージェントがログをcatして、その結果をtool_resultで返す」場面をまねた1リクエストです。

最初のアプローチは、設定ファイルの compat に独自の上流としてモックを登録し、/compat/mock/v1/messages に送る形でした。READMEの例どおり CAVEMAN_MODE=compress caveman start で立ち上げたのに、上流に届いたのは送ったのと同じ190,786Bでした。

途中でもう一つ、別の詰まり方もしました。記録モード(既定)のプロキシを止めきれていない状態で CAVEMAN_MODE=compress caveman start を実行すると、「Caveman proxy already running」と表示されて終わり、モードは記録のままでした。エラーにはならないので、圧縮しているつもりで素通しになっていることに、届いたバイト数を比べるまで気づけません。

改善後のアプローチは、providers でAnthropicの接続先そのものをモックに向ける形です。こちらは効きました。

# 使った設定の抜粋(CAVEMAN_CONFIG で指定し、CAVEMAN_MODE=compress caveman start で起動)
mode: record                          # 起動時の CAVEMAN_MODE が優先される
listen: 127.0.0.1:8787
providers:
  anthropic:
    base_url: http://127.0.0.1:4000   # 自作のモック。ドキュメントに従い CAVE_SSRF_ALLOWLIST=127.0.0.1 も指定
# 起動時の表示(compress モード)
# → subscription and OAuth logins stay byte-identical pass-through here: local compression
#   needs the caveman MCP retrieve tool to recover elided detail — run `caveman mcp install <agent>` to turn it on
# 上流(モック)に届いたバイト数(送ったのはどちらも 190,786B)
#   compat 経由  /compat/mock/v1/messages : 190,786B
#   providers 経由 /v1/messages            :   2,035B

2,035Bに縮んだリクエストを開くと、tool_resultの中身がCLIで見たのと同じ13行の要約に置き換わり、末尾に <<ccr:ccr_81736aca...>> という目印が付いていました。そしてtoolsには、送った覚えのない caveman_retrieve というツールの定義が足されていました。モデルが元のデータを必要としたら、このツールで取り戻させる仕組みです。

compat経由で縮まなかった理由までは追えていません。私の設定の書き方が悪かった可能性もあるので、ここは「この設定では縮まなかった」という事実だけを書いておきます。

モデルが「取り戻したい」と言ったとき

次に、モックをモデル役にして、目印を見つけたら caveman_retrieve を呼び返すようにしました。題材は先ほど「99999 rows」を仕込んだログです。プロキシはこのツール呼び出しを自分で処理して上流に2回目のリクエストを送り、クライアント側には最後の「ok」だけが返りました。エージェントからはこの往復が見えません。

問い合わせの文(query)だけを変えて、1ケースずつ試した結果がこちらです。

query2回目に上流へ届いたサイズ取り戻した中身99999 rows の行
chunk with an unusually large number of rows4,419B1,952B含まれない
rows4,379B1,952B含まれない
指定なし192,593B187,255B(全量)含まれる
999992,487B133B含まれる
# query「chunk with an unusually large number of rows」で取り戻した中身(先頭)
2026-10-02T12:00:00Z DEBUG export: chunk 0 buffered (0 rows)

… [caveman: non-adjacent] …

2026-10-02T12:00:05Z DEBUG export: chunk 5 buffered (65 rows)

… [caveman: non-adjacent] …

2026-10-02T12:00:10Z DEBUG export: chunk 10 buffered (130 rows)
# 以下 chunk 15, 20, 25 … と続く。chunk 1500 の行は含まれない
# 意味での絞り込みは効かず、語の一致で拾っている印象。ここは改善の余地がありそうです

「行数が異常に多いチャンク」という意味の問いでは、chunkやrowsという語を含む行が並ぶだけで、肝心の行は戻りませんでした。値を知っていれば133Bで一発ですが、値を知らないから探しているわけで、それでは順序が逆です。

全量を戻せば確実です。ただ、2回の往復で上流に送った合計は2,056B+192,593B=194,649Bで、クライアントが最初に送った190,809Bをそのまま届けるより大きくなりました。ここは好みが分かれるところですが、「縮めて、必要なら全部取り戻す」を毎回やる使い方だと、縮めないほうが安いケースもあったりします。

ストリーミングとOAuthは素通し

最後に、送り方だけを変えて確かめました。同じログをストリーミング指定(stream: true)で送ると190,800B、Claude Pro/MaxのようなOAuthのトークン形式(sk-ant-oat01- で始まる偽のトークンと anthropic-beta: oauth-2025-04-20 のヘッダ)で送ると190,786Bのまま上流に届きました。縮んだのは、通常のAPIキーで送った非ストリーミングの2,035Bだけです。

これは隠れた挙動ではなく、caveman status が「MCP recovery missing — streaming turns and Claude Pro/Max sessions pass through uncompressed (non-streaming API-key traffic still compresses)」とはっきり表示しています。MCPの復元ツールが無い状態では、ストリーミングとサブスクリプションには手を出さないという、安全側の設計です。caveman claude のようにエージェントを包む使い方では復元ツールも一緒に配るとREADMEにはありますが、今回はエージェント本体を入れていないので、そちらは確かめていません。

試してみての所感

縮み方は本物でした。187KBのログが840Bになり、ERRORとスタックトレースはちゃんと残る。元のデータはバイト数もsha256も一致して戻る。検証用のコンテナは作ってから捨てるまで9分34秒で、気軽に試せる部類です。

一方で、「省いた部分は見えている部分に似ている」という前提が崩れる異常、つまり同じ形の行に紛れた外れ値は、ログでは痕跡なく消えました。そのうえでモデルには「この表示から答えよ」と注記が付く。間引きの設定で1行を落とした経験がある身としては、ここは運用で補う前提で使いたいところです。

自分で使うなら、まずテスト出力やビルドログのように「異常はERRORとして出る」ものから試すつもりです。数値の外れ値を探す用途、たとえばメトリクスに近いログをエージェントに読ませる場面では、全量を読ませるほうを選びます。ストリーミングで動くエージェントに挟む構成は、復元ツールの配り方を自分の環境で確かめるまで様子見です。