abort した LLM 呼び出しは usage を返さない:コスト表から消えた課金を下限で推定する
結論
LLM のストリームを途中で abort すると、最後の usage チャンクが届きません。リクエストは上流に届いて課金されているのに、usage を起点に集計しているコスト表にはその1行が存在しません。
厄介なのは、これが「多少の誤差」ではなく構造的な欠落だという点です。usage が取れた呼び出しだけを集計している限り、表は常に実際より安く見えます。しかも表からは「安く見えている」こと自体が分かりません。
この記事では、対策を次の3点に絞って書きます。
- 欠落を検知する(usage に依存しないカウンタと突き合わせる)
- 実測と推定を別の欄に分けて出す(絶対に合算しない)
- 推定値を下限として扱う(外挿サンプルの偏りを踏まえる)
なぜ usage が消えるのか
OpenAI の Chat Completions は stream_options: { include_usage: true } を付けると、[DONE] の直前に usage だけを持つチャンクを送ってきます。公式リファレンスには次のように書かれています。
NOTE: If the stream is interrupted or cancelled, you may not receive the final usage chunk which contains the total token usage for the request.
つまり usage はストリームの最後に1回だけ届く設計です。途中で抜ければ、それ以前に受け取った内容から usage を復元する手段はありません。
const stream = await client.chat.completions.create({
model,
messages,
stream: true,
stream_options: { include_usage: true },
});
for await (const chunk of stream) {
const text = chunk.choices[0]?.delta?.content ?? '';
process.stdout.write(text);
if (shouldStop(text)) break; // ← usage チャンクを受け取る前に抜けている
}
break した瞬間に、その呼び出しの usage は失われます。
プロバイダによって「どこまで取れるか」が違う
ここは実装前に確認しておく価値があります。
| usage の届き方 | abort したときに残るもの | |
|---|---|---|
| OpenAI Chat Completions | 最後の1チャンク(include_usage) |
入出力ともに何も残らない |
| Anthropic Messages |
message_start に input、message_delta に累積 output |
input トークンは取れる |
Anthropic は message_start の時点で input トークンを返し、以降の message_delta に載る usage は累積値です(チャンクごとの増分ではないので、足し上げてはいけません)。つまり abort しても「input は満額、output は打ち切った時点まで」が分かります。逆に OpenAI は最後の1回に全部を載せるので、途中で切ると入出力ともにゼロになります。
同じ「ストリームを abort する」という操作でも、プロバイダごとに残る情報が違う。ここを揃えて考えてしまうのが最初の落とし穴です。
「静かに消える」が最悪な理由
abort される呼び出しは、例外系ではありません。正常系の設計として普通に発生します。
- ユーザーが入力を始めたので、生成中の応答を打ち切る(barge-in)
- 先読み(投機実行)した結果が使われなかったので捨てる
- レイテンシを詰めるために2本並走させ、負けた側を abort する
- クライアントが切断した
どれも「上流には届いて、上流は課金した」呼び出しです。しかし usage を起点にした集計からは消えます。
問題は欠落が表に出ないことです。usage が取れなかった呼び出しを単純にスキップする実装だと、コスト表はこうなります。
- 呼び出し回数: 実際より少ない
- トークン数: 実際より少ない
- 「1回あたりの平均」: これは正しいまま
つまり平均単価は正しいのに、合計だけが小さいという、ぱっと見では気づけない壊れ方をします。ダッシュボードを見て「先月より安くなった」と読んでしまうのが一番危険です。
欠落を検知する
まず、消えていることを測れるようにします。方法は1つで、usage とは独立したカウンタと突き合わせるだけです。
- プロキシ / ゲートウェイ側のリクエスト数(usage に依存しない)
- アプリ側が書き出した usage 行数
この2つを同じ窓で数えて、差が「abort された呼び出し」です。abort の発生源ごとにカウンタを分けておくと、どこで消えているかまで分かります。
type Outcome = 'ok' | 'aborted' | 'error';
// abort した呼び出しも必ず1行残す。usage は無くてよい。
// 「usage が無い行」と「呼び出しが無かったこと」を区別するのが目的。
type CallRecord = {
requestId: string;
model: string;
outcome: Outcome;
usageReported: boolean; // 実際に usage チャンクを受け取れたか
inputTokens?: number;
cachedInputTokens?: number;
outputTokens?: number;
};
ポイントは、usage が無くても呼び出しの行は必ず書くことです。usage が取れたときだけ行を書く実装だと、欠落が「行が無い」という形になり、後から数えられません。
推定する:サンプルは「その集団」から取る
次に、abort された呼び出しのトークン数を推定します。ここでどの平均を使うかが精度を決めます。
素直な発想は「同じタスクの正式な呼び出しの平均を使う」ですが、これは過大評価になります。abort される呼び出しは、正式なものより早い時点で走っているからです。プロンプトが短い(会話がまだ進んでいない)ため、正式な呼び出しの平均トークン数は大きすぎます。
精度が高いのは、abort された呼び出し集団の中からサンプルを取る方法です。具体的には「abort される直前に usage チャンクを受け取れた」レアケースを使います。usage は最後に1回だけなので、これは「abort が usage チャンクの後ろにずれ込んだ」場合にだけ発生します。頻度は低いですが、外挿先と同じ集団から抽出されているので、分布が合います。
export type EstimateBasis =
| 'sampled-usage' // abort 集団自身のサンプルから外挿(第一候補)
| 'usage-log-average' // 同じモデルの通常呼び出しの平均で代用(フォールバック)
| 'none'; // どちらも無い。推測しない
type Usage = { inputTokens: number; cachedInputTokens: number; outputTokens: number };
export function estimateDiscardedUsage(input: {
/** abort 集団のうち usage を拾えた件数と、その合計 */
sample: { calls: number; total: Usage };
/** 同じモデルの通常呼び出しの平均(フォールバック用) */
fallbackPerCall?: Usage;
}): { perCall: Usage; basis: EstimateBasis } | { perCall: null; basis: 'none' } {
const { sample, fallbackPerCall } = input;
if (sample.calls > 0) {
return {
perCall: {
inputTokens: sample.total.inputTokens / sample.calls,
cachedInputTokens: sample.total.cachedInputTokens / sample.calls,
outputTokens: sample.total.outputTokens / sample.calls,
},
basis: 'sampled-usage',
};
}
if (fallbackPerCall) {
return { perCall: fallbackPerCall, basis: 'usage-log-average' };
}
// サンプルもフォールバックも無いなら、数字を作らない
return { perCall: null, basis: 'none' };
}
none を返す経路を残すのが重要です。根拠が無いときに 0 でも平均でも埋めず、欠けていることを欠けているまま返す。埋めてしまうと、後から見て「推定済み」と区別が付かなくなります。
なお usage-log-average に落ちたときは、その値が過大評価であることを併せて返します。どの経路で出した数字なのかが分からないと、後から数字を正しく読めません。
推定値は「下限」であって「中央値」ではない
ここが一番伝えたいところです。サンプル外挿は系統的にコストを低く見積もります。
理由は2つあります。
(1) サンプルが「長く走った」側に偏る。 abort が usage チャンクの後ろにずれ込んだケースだけがサンプルになります。これは「生成が長引いた」呼び出しほど起きやすい。つまりサンプルは、abort 集団の中でもより多く生成した側に偏ります。
(2) キャッシュヒット率が高い側に偏る。 長く走った呼び出しは、プロンプトキャッシュが温まっている可能性が高い。そしてキャッシュ読みの単価は未ヒットの 1/10 程度です(GPT-5.6 以降の OpenAI は uncached input の 0.1 倍、Anthropic も標準で 0.1 倍)。キャッシュヒット率が高いサンプルから単価を計算すると、実際の請求より安い単価が出ます。
この2つは同じ方向に効くので、誤差が打ち消し合いません。
したがって、この推定値は「だいたいこのくらい」ではなく「これ以上」として読む必要があります。ダッシュボードに下限だと書いておきます。書かないと、次に読む人が中央値として扱います。
やってはいけないこと
- 推定値を実測テーブルに書き戻す。 実測テーブルの価値は「全部が実測であること」です。1行でも混ざると、その表から実測値だけを取り出す手段が無くなります。推定は別の欄に持ちます。
-
実測と推定を合算して1つの数字にする。 合算した瞬間に内訳が分からなくなります。合計を出すのは自由ですが、
loggedとestimatedを必ず併記します。 - 根拠が無い推定を 0 で埋める。 0 は「無かった」と読めます。実際は「あったが測れなかった」ので、別の表現が必要です。
- abort を減らせば解決だと思う。 abort は設計であって不具合ではないので、無くせません。消えないようにするのではなく、消えた分を別枠で持つのが正解です。
まとめ
- usage はストリームの最後に1回だけ届く。abort すると消える(Anthropic は input だけ
message_startで先に取れる) - usage が取れないことを前提に、呼び出しの行は必ず書く。usage の有無はフラグで持つ
- 欠落は「ゲートウェイのリクエスト数」と「usage 行数」の突き合わせで検知する
- 推定は abort 集団自身のサンプルから外挿する。通常経路の平均は過大評価になる
- その推定は下限。長く走った側・キャッシュヒットが高い側に偏るため、コストを系統的に低く見積もる
- 実測と推定は絶対に混ぜない。合計だけを見せると、次に読む人が下限を中央値として扱う
コスト表が「静かに実際より安く見える」状態は、数字が動いているだけに気づきにくいです。まず自分の環境で、ゲートウェイのリクエスト数と usage 行数を突き合わせて、差が何 % あるかを見てみるのが最初の一歩だと思います。
