請求書を見て初めて「合っていない」と気づく
従量課金のエージェントを運用していて一番こわいのは、エラーで止まることではありません。何も止まらないまま、自前のダッシュボードが示す利用量と、Stripe が月末に確定させた請求額が、少しずつ食い違っていくことです。
止まれば気づけます。けれど計測のずれは静かに進みます。あるユーザーには本来より多く請求し、別のユーザーには取りこぼす。気づくのは決まって、問い合わせが来てからです。
ここでは Antigravity の AgentKit 2.0 で組んだエージェントの実行量を Stripe Meter Events で課金するとき、内部の利用台帳とメーター集計の一致をどう保つかを、実装の単位でお話しします。計測の冪等化、遅延イベントの吸収、月またぎの扱い、そして日次の突合ジョブまで、ずれが生まれる経路を一つずつ塞いでいきます。
私自身、個人開発でいくつかのエージェント機能を従量課金に載せてきましたが、課金そのものより「課金が正しいと証明できる状態」を保つほうが、ずっと手間がかかると感じています。透明性は機能ではなく、運用で守り続けるものです。
ずれは三つの層で生まれる
内部台帳と Stripe の集計が食い違うとき、原因はだいたい次の三層のどこかにあります。最初に経路を地図にしておくと、突合ジョブが何を見ればよいかが決まります。
層 ずれの典型例 方向
計測の発生 リトライ・並列実行で同じステップを二重に記録 過大請求
送信の経路 送信失敗のまま破棄/バッファ溢れで欠落 過小請求
集計の境界 35日ウィンドウ超過・月またぎで請求書に載らない 過小請求
過大請求は信頼を一度で失わせ、過小請求は静かに利益を削ります。どちらも放置できません。突合ジョブは、この三層それぞれで「内部が知っている数」と「Stripe が受理した数」を突き合わせる役割を持ちます。
まず計測の単位を一つに固定する
ずれを論じる前に、計測の単位がぶれていると突合が成立しません。エージェントの使用量はステップ数・出力トークン・実時間のいずれでも計れますが、原価との相関と説明のしやすさは両立しません。
私が運用で落ち着いたのは、ステップ数を基本単位にしつつ、画像生成や大規模検索のような重い処理だけをトークン換算で別メーターに分ける形です。ユーザーには「ステップ」という一つの言葉で説明でき、原価のばらつきは重い処理を別建てにすることで吸収します。
重要なのは、この換算ルールを内部台帳と Stripe メーターの両方で同一にしておくことです。台帳側で「重い処理は3ステップ換算」としたなら、メーター送信値も同じ換算後の値を送ります。換算をどちらか一方だけに入れると、突合が永久に合いません。
冪等キーは「実行ID×ステップ番号」で決め打つ
過大請求の入口は、ほぼリトライです。ネットワークが揺れればメーター送信は再送され、同じステップが二回数えられます。Stripe Meter Events は identifier(冪等キー)が同一なら重複を排除してくれるので、ここを推測不能かつ決定的に作ります。
import crypto from 'node:crypto' ;
function meterIdentifier ( executionId , stepNumber ) {
// executionId は UUIDv4 以上のエントロピーを持たせる。
// タイムスタンプ+ユーザーIDのような推測可能な値は並列実行で衝突しうる。
return crypto
. createHash ( 'sha256' )
. update ( `${ executionId }:${ stepNumber }` )
. digest ( 'hex' )
. slice ( 0 , 40 );
}
同じ実行・同じステップは必ず同じキーになるので、何度再送しても Stripe 側で一度しか数えられません。逆に言えば、executionId の生成が弱いと、別の実行が同じキーを生んで「片方が消える」過小請求が起きます。実行IDの採番だけは手を抜かないでください。
そして内部台帳には、この identifier をそのまま主キーとして保存します。台帳側も同じキーで一意制約をかければ、台帳とメーターが同じ単位で重複排除されることになり、後の突合が素直になります。
CREATE TABLE usage_ledger (
identifier TEXT PRIMARY KEY , -- meterIdentifier と同一
customer_id TEXT NOT NULL ,
event_name TEXT NOT NULL ,
value INTEGER NOT NULL ,
occurred_at INTEGER NOT NULL , -- イベント発生時刻(UNIX秒)
sent_at INTEGER , -- Stripe受理時刻。NULL = 未送信
billing_month TEXT NOT NULL -- occurred_at から導出した 'YYYY-MM'
);
sent_at が NULL のままの行が、まさに「過小請求の予備軍」です。突合ジョブはこの列を手がかりにします。
遅延を前提にバッファを挟む
エージェントの実行が終わった瞬間にメーターを送れれば理想ですが、長時間実行やオフライン実行では完了イベントを即座に Stripe へ届けられません。送信を実行パスから切り離し、台帳への書き込みを正とし、送信は非同期ワーカーに委ねます。
// 実行側:台帳への書き込みが成功した時点で「計測は確定」とみなす
async function recordUsage ( db , { executionId , stepNumber , customerId , value , occurredAt }) {
const identifier = meterIdentifier (executionId, stepNumber);
const month = new Date (occurredAt * 1000 ). toISOString (). slice ( 0 , 7 );
await db. prepare (
`INSERT INTO usage_ledger (identifier, customer_id, event_name, value, occurred_at, billing_month)
VALUES (?, ?, 'agent_steps', ?, ?, ?)
ON CONFLICT(identifier) DO NOTHING`
). bind (identifier, customerId, value, occurredAt, month). run ();
}
// ワーカー側:未送信行を拾って Stripe に送り、受理できたら sent_at を打つ
async function flushLedger ( db , stripe ) {
const { results } = await db. prepare (
`SELECT * FROM usage_ledger WHERE sent_at IS NULL ORDER BY occurred_at LIMIT 200`
). all ();
for ( const row of results) {
try {
await stripe.billing.meterEvents. create ({
event_name: row.event_name,
payload: { stripe_customer_id: row.customer_id, value: String (row.value) },
identifier: row.identifier,
timestamp: row.occurred_at,
});
await db. prepare ( `UPDATE usage_ledger SET sent_at = ? WHERE identifier = ?` )
. bind (Math. floor (Date. now () / 1000 ), row.identifier). run ();
} catch (e) {
// 送信失敗は sent_at を打たずに残す。冪等キーがあるので次回再送して安全。
console. error ( 'meter flush failed' , row.identifier, e.message);
}
}
}
この形なら、実行側は Stripe の可用性に依存しません。Stripe が落ちていても計測は台帳に残り、復旧後にワーカーが順に送ります。occurred_at でソートして送るのは、後述の月またぎ判定を素直にするためです。
35日ウィンドウと月またぎ
Stripe Meter Events は、既定で過去35日以内に発生したイベントしか受け付けません。バッファの滞留がこのウィンドウを超えると、送信は成功したように見えて集計に載らない、という最悪の過小請求が起きます。
対策は二つです。一つは、未送信行の occurred_at が今より30日以上前になったらアラートを上げ、ウィンドウを使い切る前に必ず捌くこと。もう一つは、月またぎの計上を occurred_at ベースに統一し、送信時刻ではなく発生時刻で月を決めることです。台帳の billing_month を発生時刻から導出しているのはこのためです。
月末23:59に始まり月初00:03に終わる実行のように、境界をまたぐケースでも、ステップごとに発生時刻で月が決まるので判断がぶれません。送信が遅れても、Stripe 側は渡した timestamp で正しい月に集計してくれます。ここを送信時刻に寄せると、確定済みの先月の請求書に後から差分が生じて、会計が嫌がる事態になります。
日次の突合ジョブ — しきい値を超えたずれだけ拾う
ここが運用の本体です。毎日、内部台帳の月内合計と、Stripe メーターの集計サマリを取り寄せて突き合わせます。完全一致を求めると、送信途上の数件で毎日アラートが鳴って疲弊するので、許容しきい値を設けて「意味のあるずれ」だけを拾います。
async function reconcile ( db , stripe , { meterId , month , customerId }) {
// 1) 内部台帳:当月・送信済みの合計
const ledger = await db. prepare (
`SELECT COALESCE(SUM(value), 0) AS total
FROM usage_ledger
WHERE customer_id = ? AND billing_month = ? AND sent_at IS NOT NULL`
). bind (customerId, month). first ();
// 2) Stripe メーター集計サマリ(当月の合計)
const start = Math. floor ( new Date ( `${ month }-01T00:00:00Z` ). getTime () / 1000 );
const end = Math. floor (Date. now () / 1000 );
const summaries = await stripe.billing.meters.eventSummaries. list (meterId, {
customer: customerId,
start_time: start,
end_time: end,
value_grouping_window: 'day' ,
});
const stripeTotal = summaries.data. reduce (( s , x ) => s + x.aggregated_value, 0 );
// 3) ドリフトを評価。絶対差と相対差の両方でしきい値を切る。
const drift = ledger.total - stripeTotal;
const ratio = stripeTotal === 0 ? (ledger.total === 0 ? 0 : 1 ) : drift / stripeTotal;
const significant = Math. abs (drift) > 50 || Math. abs (ratio) > 0.02 ;
return { customerId, month, ledgerTotal: ledger.total, stripeTotal, drift, ratio, significant };
}
絶対差(50ステップ超)と相対差(2%超)の両方を見るのは、利用量の大小で「気にすべきずれ」の大きさが変わるからです。月に10万ステップ使う顧客の50ステップ差は誤差ですが、月に300ステップの顧客の50ステップ差は16%の過大・過小です。片方のしきい値だけでは、必ずどちらかの規模で見逃します。
significant が立った顧客だけを日次レポートに出し、未送信行(sent_at IS NULL)の滞留と突き合わせれば、ドリフトが「送信遅延による一時的なもの」か「本当に消えた計測」かを切り分けられます。前者は翌日には解消し、後者は手当てが要ります。
監査証跡は請求の前提
従量課金は、ユーザーから「今月は高すぎる」と言われたときに内訳を即座に示せるかで信頼が決まります。内部台帳に identifier・発生時刻・ステップ値・使ったツール名・実行IDを残しておけば、それがそのまま証跡になります。
問い合わせ対応では、当月の台帳から日別・エージェント別の内訳を CSV で出せる小さな機能を一つ用意しておくと、説明が一往復で終わります。私の経験では、内訳を見せられた時点でクレームのほとんどは収まります。人は金額そのものより、説明できない金額に不信を持つのだと思います。
私はこの突合ジョブが弾いたドリフトの履歴も、必ず残すようにしています。あとから「先月のあの差は何だったか」を追えることが、計測基盤への信頼を内部にも積み上げてくれます。
次の一歩
いま運用しているエージェントがあるなら、まず台帳に sent_at 列を足し、未送信のまま24時間以上残っている行を数えるクエリを一本書いてみてください。その件数がゼロでなければ、すでに過小請求の芽があります。
突合ジョブの本実装は、しきい値を緩め(たとえば相対差5%)から始めて構いません。毎日のレポートを眺めながら、自分のサービスの「正常なゆらぎ」がどこにあるかを掴んでから締めていくほうが、無用なアラート疲れを避けられます。計測の正しさは一度作って終わりではなく、毎日少しずつ確かめ続けるものだと考えています。