月次クレジット制への移行が本日発効し、自動投稿パイプラインの headless 実行が「サブスクの定額内」から「使った分だけクレジットを削る」世界に変わりました。移行前に工程の振り分けは見直していたのですが、いざ初日を迎えて気づいたのは、そもそも自分が「どの工程がいくら使っているか」を数字で把握していなかった ということです。
個人開発で4サイトを回している自分にとって、見積もりは立てていました。けれど見積もりは見積もりで、実測ではありません。繰越のないクレジットを月内で配分するなら、推測ではなく「先週この工程は実際にいくら溶かしたか」という台帳が要ります。そこで初日のうちに、各 API 呼び出しのトークン消費を工程タグ単位で記録し、日次で円換算するだけの薄い計測レイヤーを差し込みました。今日はその中身を順番に書いていきます。
派手な可観測性基盤の話ではありません。狙いは「既存のコードにほぼ手を入れず、後から工程別にコストを問い詰められる状態」を最小コストで作ることです。
なぜ Console の請求画面だけでは足りないのか
Anthropic Console の使用状況画面は、アカウント全体・API キー単位での消費は見せてくれます。けれど私の運用では、ひとつの API キーで「記事生成」「品質ゲート判定」「ニュース収集」「翻訳同期」という性質の違う工程が混ざって走っています。Console を開いても、今週クレジットを最も食ったのが生成なのか、それともリトライを繰り返した品質ゲートなのかが分からない のです。
月次クレジットは繰越がありません。つまり「月末に足りなくなる工程」を事前に特定できなければ、優先度の低い工程が高い工程のクレジットを食い潰したまま気づけません。Console の粒度では、この「工程別の取り合い」が見えないのが本質的な問題でした。
必要なのは、API 呼び出しのたびに usage を工程名つきで記録し、後から stage=quality-gate の今月の累計はいくら と聞ける台帳です。これはアプリ側でしか作れません。
response.usage を取りこぼさない
計測の土台は、すべての応答に含まれる usage オブジェクトです。ここで最初の落とし穴がありました。usage は単純な入力・出力の2項目ではありません。プロンプトキャッシュを使っていると、実際には4種類のトークンが返ってきます。
// Anthropic SDK の応答に含まれる usage の実際の形
// (キャッシュを使うと cache_* が非ゼロになる)
type RawUsage = {
input_tokens : number ; // 非キャッシュの入力
output_tokens : number ; // 生成された出力
cache_creation_input_tokens ?: number ; // キャッシュへの書き込み(割増)
cache_read_input_tokens ?: number ; // キャッシュからの読込(大幅割引)
};
ここを input_tokens + output_tokens だけで集計すると、キャッシュ生成・キャッシュ読込のトークンがまるごと台帳から抜け落ちます。キャッシュ生成は通常の入力より割高、キャッシュ読込は大幅に安い、という非対称な単価がついているので、4種類を別々に持っておかないと円換算が合いません。私は最初これを2項目で集計していて、実測値が Console の請求とどうしても噛み合わず、半日溶かしました。
そこで、4種類を必ず別フィールドで保持する正規化関数を最初に置きます。
// 欠けたフィールドを 0 で埋め、4種類を必ず揃える
export function normalizeUsage ( raw : Partial < RawUsage > | undefined ) {
return {
input: raw?.input_tokens ?? 0 ,
output: raw?.output_tokens ?? 0 ,
cacheWrite: raw?.cache_creation_input_tokens ?? 0 ,
cacheRead: raw?.cache_read_input_tokens ?? 0 ,
};
}
単価表をコードの外に出す
円換算には単価が要りますが、単価は変わります。モデルが更新されれば変わりますし、為替でも変わります。単価をロジックに直書きすると、改定のたびに集計コードを触ることになる ので、最初から設定として分離しておきます。
ここで重要なのは、4種類のトークンそれぞれに別単価を持たせることです。キャッシュ読込を入力と同じ単価で計算すると、キャッシュを多用する工程ほどコストを過大評価してしまいます。
// 単価は「100万トークンあたりの円」で持つ(MTok単価)。
// 値はあなたの契約・最新の価格表・為替で必ず置き換えてください。
// モデルごとに別テーブルを持てるよう、キーをモデル名にしています。
type Rate = { input : number ; output : number ; cacheWrite : number ; cacheRead : number };
const RATE_TABLE : Record < string , Rate > = {
// 例: 値はプレースホルダー。実際の単価に差し替えること
"default" : { input: 450 , output: 2250 , cacheWrite: 560 , cacheRead: 45 },
};
function rateFor ( model : string ) : Rate {
// 完全一致がなければ default にフォールバック
return RATE_TABLE [model] ?? RATE_TABLE [ "default" ];
}
// 正規化済み usage を円に変換する
export function usageToYen ( model : string , u : ReturnType < typeof normalizeUsage>) {
const r = rateFor (model);
const perMTok = ( tokens : number , rate : number ) => (tokens / 1_000_000 ) * rate;
return (
perMTok (u.input, r.input) +
perMTok (u.output, r.output) +
perMTok (u.cacheWrite, r.cacheWrite) +
perMTok (u.cacheRead, r.cacheRead)
);
}
単価をモデル名でテーブル化しておくと、フォールバックで別モデルに切り替わった呼び出しも正しい単価で集計できます。複数モデルを順次フォールバックさせている場合、ここが一本化されていないと「安いつもりが高いモデルで走っていた」ことに後から気づけません。
計測ラッパー — 既存コードに一行で差し込む
土台ができたら、API 呼び出しを包む薄いラッパーを用意します。狙いは、呼び出し側のコードを書き換えず、工程名(stage)を渡すだけで台帳に1行積まれる ようにすることです。
import Anthropic from "@anthropic-ai/sdk" ;
const client = new Anthropic ();
// 台帳への1レコード
type CostRecord = {
ts : string ; // ISO 8601
stage : string ; // 工程タグ("generate" / "quality-gate" など)
model : string ;
yen : number ;
tokens : ReturnType < typeof normalizeUsage>;
};
// 記録先は差し替え可能にしておく(後述)
export interface CostSink {
write ( rec : CostRecord ) : Promise < void >;
}
// messages.create を包むだけのラッパー
export async function meteredCreate (
sink : CostSink ,
stage : string ,
params : Anthropic . MessageCreateParamsNonStreaming ,
) {
const res = await client.messages. create (params);
const usage = normalizeUsage (res.usage);
const yen = usageToYen (params.model, usage);
// 記録の失敗で本処理を止めないため、await しても sink 側で握り潰す設計にする
await sink. write ({
ts: new Date (). toISOString (),
stage,
model: params.model,
yen,
tokens: usage,
});
return res;
}
呼び出し側はこう変わります。
// before:
// const res = await client.messages.create({ model, max_tokens, messages });
// after: stage を足すだけ
const res = await meteredCreate (sink, "generate" , { model, max_tokens, messages });
工程タグは粗くて構いません。私は最初、関数ごとに細かくタグを切ろうとして挫折しました。「クレジットの取り合いが起きる単位」までで十分 です。私の場合は generate / quality-gate / news-fetch / translate の4つに落ち着きました。
ここで一点だけ運用上の注意があります。計測の sink.write が失敗したときに本来の生成処理まで巻き添えで落ちては本末転倒です。記録はベストエフォートに留め、書き込み例外は内側で握り潰してログに残すだけにします。台帳が1行欠けても困りませんが、記事生成が止まると困ります。
記録先 — まず1日1ファイルの追記から
CostSink を抽象にしておいたのは、記録先を後から差し替えられるようにするためです。とはいえ初日に凝った基盤は要りません。私は「1日1ファイルへの JSON Lines 追記」から始めました。これなら集計はあとから cat と小さなスクリプトで済みます。
import { appendFile } from "node:fs/promises" ;
// JSON Lines(1行1レコード)で日付別ファイルに追記する Sink
export class JsonlFileSink implements CostSink {
constructor ( private dir : string ) {}
async write ( rec : CostRecord ) : Promise < void > {
const day = rec.ts. slice ( 0 , 10 ); // YYYY-MM-DD
try {
await appendFile ( `${ this . dir }/cost-${ day }.jsonl` , JSON . stringify (rec) + " \n " );
} catch (e) {
// 計測失敗で本処理を止めない
console. warn ( "[cost-meter] write failed:" , (e as Error ).message);
}
}
}
JSON Lines にしておくと、行ごとに独立しているので追記中にプロセスが落ちても既存行は壊れません。一気に書く配列 JSON だと、途中で落ちたファイルがパースできなくなって台帳ごと失う、という事故が起きます。append-only の素朴さが、ここではむしろ堅牢さになります。
KV や Durable Objects に載せ替えたくなったら、CostSink を実装し直すだけで呼び出し側は無傷です。予算上限での強制停止まで踏み込むなら、Claude API の予算サーキットブレーカー設計 で書いた停止戦略とこの台帳を組み合わせると、「計測して、超えたら止める」が一本につながります。
日次レポート — 工程別の円を一目で
台帳が溜まったら、1日分を工程別に畳んで読める形にします。月次クレジットの配分判断に効くのは「工程別の合計円」と「呼び出し回数」、そして「キャッシュ読込の比率」です。キャッシュ読込比率が低い工程は、キャッシュの貼り方を見直す余地が残っているサインになります。
import { readFile } from "node:fs/promises" ;
type StageAgg = { yen : number ; calls : number ; cacheReadTokens : number ; totalInputTokens : number };
export async function dailyReport ( dir : string , day : string ) {
const lines = ( await readFile ( `${ dir }/cost-${ day }.jsonl` , "utf8" ))
. split ( " \n " ). filter (Boolean);
const byStage = new Map < string , StageAgg >();
for ( const line of lines) {
const r : CostRecord = JSON . parse (line);
const a = byStage. get (r.stage) ?? { yen: 0 , calls: 0 , cacheReadTokens: 0 , totalInputTokens: 0 };
a.yen += r.yen;
a.calls += 1 ;
a.cacheReadTokens += r.tokens.cacheRead;
a.totalInputTokens += r.tokens.input + r.tokens.cacheRead + r.tokens.cacheWrite;
byStage. set (r.stage, a);
}
// 円の多い順に並べる
const rows = [ ... byStage. entries ()]
. map (([ stage , a ]) => ({
stage,
yen: Math. round (a.yen * 100 ) / 100 ,
calls: a.calls,
// 入力に占めるキャッシュ読込の割合(高いほどキャッシュが効いている)
cacheReadRatio: a.totalInputTokens
? Math. round ((a.cacheReadTokens / a.totalInputTokens) * 100 )
: 0 ,
}))
. sort (( x , y ) => y.yen - x.yen);
return { day, total: rows. reduce (( s , r ) => s + r.yen, 0 ), rows };
}
実際に初日のレポートを出してみると、見積もりとずれた工程がはっきりしました。私の場合、生成より品質ゲートの再試行 が想定以上にクレジットを食っていました。ゲートで弾かれた記事を1回作り直すだけで、その記事にかかるクレジットはおおよそ2倍になります。工程全体でならしても、再試行の多いゲートは他工程よりクレジット効率が約30%悪い数字が出ていました。原因は、生成と判定の両方が二重に走っていたことです。Console の総額を眺めていたら、おそらくこの偏りには気づけませんでした。工程タグに分けて初めて「リトライの多い工程ほどクレジット効率が悪い」という当たり前の事実が、自分の数字として見えたのです。
計測の負荷をかけすぎない
最後に、計測そのものがコストや遅延にならないよう気をつけている点を挙げておきます。トークンの集計と円換算は単なる四則演算なので、API 呼び出しに対して無視できる負荷です。重くなるのは記録先への I/O のほうなので、高頻度で走る工程ではレコードをメモリにバッファして数十件ごとに flush する、あるいは1呼び出しごとのファイル append で十分かを最初に見極めます。私の運用はせいぜい1日数十〜百数十呼び出しなので、本番運用でも素朴な append で困っていません。まずはこの最小構成から始めることを推奨します。凝った基盤の導入は、計測値を見て I/O がボトルネックだと分かってからで遅くありません。早すぎる最適化は、ここでは避けたい落とし穴です。
もうひとつ、台帳にはプロンプト本文を絶対に書かない ことです。記録するのはトークン数・モデル名・工程タグ・円だけに留めます。本文を残すと台帳が肥大化するうえ、機微な内容が平文で散らばる温床になります。コストを知るのに本文は要りません。
次にやること
導入は次の3手順だけで始められます。
いちばん回数の多い API 呼び出し1か所を meteredCreate でくるみ、stage を1つ付ける
記録先は今日のうちは JsonlFileSink(JSON Lines の append)で十分なので、それを差し込む
1日走らせて dailyReport を出し、その工程が実際に何円使ったかという最初の1数字を確認する
この1数字が手に入ると、Console の総額しか見ていなかった頃には戻れなくなるはずです。
工程の振り分けそのものをこれから見直す段階なら、月次クレジット移行に向けた工程配分の見直し と合わせて読むと、「計測して振り分ける」の両輪がそろうと思います。私自身も初日に入れたばかりの仕組みで、これから数日の実測を見ながら調整していくところです。お読みいただきありがとうございました。