0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

abort した LLM 呼び出しは usage を返さない:コスト表から消えた課金を下限で推定する

0
Posted at

abort した LLM 呼び出しは usage を返さない:コスト表から消えた課金を下限で推定する

結論

LLM のストリームを途中で abort すると、最後の usage チャンクが届きません。リクエストは上流に届いて課金されているのに、usage を起点に集計しているコスト表にはその1行が存在しません

厄介なのは、これが「多少の誤差」ではなく構造的な欠落だという点です。usage が取れた呼び出しだけを集計している限り、表は常に実際より安く見えます。しかも表からは「安く見えている」こと自体が分かりません。

この記事では、対策を次の3点に絞って書きます。

  1. 欠落を検知する(usage に依存しないカウンタと突き合わせる)
  2. 実測と推定を別の欄に分けて出す(絶対に合算しない)
  3. 推定値を下限として扱う(外挿サンプルの偏りを踏まえる)

usage はストリームの最後の1チャンクにしか載らない。abort すると記録から消える

なぜ 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つの数字にする。 合算した瞬間に内訳が分からなくなります。合計を出すのは自由ですが、loggedestimated を必ず併記します。
  • 根拠が無い推定を 0 で埋める。 0 は「無かった」と読めます。実際は「あったが測れなかった」ので、別の表現が必要です。
  • abort を減らせば解決だと思う。 abort は設計であって不具合ではないので、無くせません。消えないようにするのではなく、消えた分を別枠で持つのが正解です。

まとめ

  • usage はストリームの最後に1回だけ届く。abort すると消える(Anthropic は input だけ message_start で先に取れる)
  • usage が取れないことを前提に、呼び出しの行は必ず書く。usage の有無はフラグで持つ
  • 欠落は「ゲートウェイのリクエスト数」と「usage 行数」の突き合わせで検知する
  • 推定は abort 集団自身のサンプルから外挿する。通常経路の平均は過大評価になる
  • その推定は下限。長く走った側・キャッシュヒットが高い側に偏るため、コストを系統的に低く見積もる
  • 実測と推定は絶対に混ぜない。合計だけを見せると、次に読む人が下限を中央値として扱う

コスト表が「静かに実際より安く見える」状態は、数字が動いているだけに気づきにくいです。まず自分の環境で、ゲートウェイのリクエスト数と usage 行数を突き合わせて、差が何 % あるかを見てみるのが最初の一歩だと思います。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?