この記事は約 6 分で読めます。
筆者プロフィール: ソフトウェアエンジニア。「知った気にならない。いつまでも学び続ける」を信条に、業務と個人開発の両輪で技術を磨いています。AI 駆動開発で複数の個人開発アプリを構築・運用中。
👉 ポートフォリオ: 筆者ホームページ
LLM を従量課金にすると、「内部で 2 回 API を呼んだら、ユーザに 2 件請求すべきか?」という設計問題が必ず出てきます。本記事では、運用中の SaaS 「たすきば Knowledge Relay」 で採用した 「1 業務操作 = 1 ApiCallLog」 に集約するパターンを整理します。
サービスの機能紹介・画面イメージ・コンセプトは公式プロダクトページをご覧ください。
👉 たすきば Knowledge Relay — 公式プロダクトページ
なぜ課金単位を業務単位に揃えるのか
ユーザが見る課金画面に「3,210 件」と並んでいたら、不安になります。「何でそんなに使ったんだろう?」と。
実際、内部処理では複数の API 呼び出しが裏で走ります。
- 1 件のプロジェクト作成 → Anthropic タグ抽出 + Voyage embedding = 内部 API 2 回
- 1 件の CSV インポート (100 件) → Voyage embedding 100 回
これをそのままユーザに見せると:
- 「プロジェクト 1 つ作るだけで 2 回課金されるの?」 → 直感に反する
- 「100 件 import したら 100 件分? 1 回じゃないの?」 → 不安
たすきばは、課金画面の表示単位を ユーザの業務操作と一致 させる方針を取りました。
1. 「1 業務操作 = 1 ApiCallLog」の設計
ルール:
ユーザが 1 回 "操作" を行ったとき、ApiCallLog は 1 件しか増えない。
| ユーザ操作 | 内部 API 呼び出し | ApiCallLog |
|---|---|---|
| プロジェクト作成 (1 件) | Anthropic + Voyage (計 2 回) | 1 件 |
| プロジェクト更新 (テキスト変更時) | Anthropic + Voyage (計 2 回) | 1 件 |
| プロジェクト更新 (テキスト不変) | 呼ばない | 0 件 |
| ナレッジ作成 (public) | Voyage (1 回) | 1 件 |
| ナレッジ CSV インポート (100 件) | Voyage (100 回) | 1 件 |
| 提案画面を開く | 呼ばない (DB のみ) | 0 件 |
ユーザの体感と請求件数を 完全一致 させています。
2. withMeteredLLM ヘルパ
この設計を実装するのが、src/lib/metered-llm.ts の withMeteredLLM ヘルパ。
// src/lib/metered-llm.ts (簡略版)
export async function withMeteredLLM<T>(
featureUnit: keyof typeof BILLABLE_FEATURE_UNITS,
context: { tenantId: string; userId: string },
callback: () => Promise<T>,
): Promise<T> {
// ハードキャップチェック
const counter = await getTenantUsageCounter(context.tenantId);
if (counter >= getTenantHardCap(context.tenantId)) {
throw new HardCapExceededError();
}
// コールバック実行 (内部で複数 API を呼んでも OK)
const result = await callback();
// ApiCallLog を 1 件記録
await prisma.apiCallLog.create({
data: {
tenantId: context.tenantId,
userId: context.userId,
featureUnit,
costJpy: BILLABLE_FEATURE_UNITS[featureUnit].costJpy,
createdAt: new Date(),
},
});
// Tenant counter を +1
await incrementTenantUsageCounter(context.tenantId);
return result;
}
ポイント:
-
callbackの中で何回 API を呼んでも、apiCallLog.createは 外側で 1 回だけ -
tenantUsageCounterも +1 だけ -
costJpyは featureUnit 単位の固定単価
3. プロジェクト作成での使用例
// src/services/project.service.ts
export async function createProject(
input: { name: string; purpose: string; background: string; scope: string },
context: { tenantId: string; userId: string },
) {
return await withMeteredLLM('project-upsert', context, async () => {
// ここで内部 API を 2 回呼ぶ
const tagPromise = anthropicExtractTags(input.purpose, input.background, input.scope);
const embedPromise = voyageEmbed(`${input.purpose}\n${input.background}\n${input.scope}`);
const [tags, embedding] = await Promise.all([tagPromise, embedPromise]);
return prisma.project.create({
data: {
...input,
tenantId: context.tenantId,
businessDomainTags: tags.businessDomain,
techStackTags: tags.techStack,
processTags: tags.process,
contentEmbedding: embedding,
},
});
});
}
withMeteredLLM の外側で apiCallLog.create が 1 回呼ばれる → ユーザに見える課金は 1 件。
4. Bulk import での使用例
CSV インポートも、callback 内でループする ことで bulk 操作を 1 ApiCallLog に集約します。
// src/services/knowledge-sync-import.service.ts
export async function importKnowledgesFromCsv(
rows: KnowledgeCsvRow[],
context: { tenantId: string; userId: string },
) {
return await withMeteredLLM('knowledge-embedding', context, async () => {
const results = [];
for (const row of rows) {
if (row.visibility !== 'public') {
results.push({ ...row, contentEmbedding: null });
continue;
}
const embedding = await voyageEmbed(`${row.title}\n${row.content}`);
results.push({ ...row, contentEmbedding: embedding });
}
await prisma.knowledge.createMany({ data: results });
return results.length;
});
}
100 件 import しても、ApiCallLog は 1 件だけ。UI 側では「インポート 100 件 (うち課金対象 80 件)」のように、内訳も表示します。
5. 「空テキスト早期 return」最適化
purpose / background / scope のすべてが空文字 (or 空白のみ) のときは、withMeteredLLM を 呼ばずに即座に return します。
if (isAllEmpty(input.purpose, input.background, input.scope)) {
return { tags: null, embedding: null };
}
Anthropic も Voyage も呼ばれず、ApiCallLog も増えない。
「ユーザの体感に合った課金」の一環です。空テキストで保存しても外部 API を呼ぶ意味はないし、コストも発生させない。
6. 「両方失敗」と「片方成功」の扱い
withMeteredLLM の callback 内で内部 API を 2 つ呼ぶとき、片方が失敗するケースが起きます。
たすきばのルール:
| シナリオ | ApiCallLog | 課金 |
|---|---|---|
| 両方失敗 | 記録なし | なし (throw) |
| 片方成功 | 1 件記録 | あり |
| 両方成功 | 1 件記録 | あり |
理由:
- 「両方失敗」はユーザ体感としても明確な失敗 → 課金しないのが筋
- 「片方成功」はユーザにとって部分的に価値が出ている → 課金する
たとえば「Anthropic は失敗したが Voyage は成功」の場合、タグなし + embedding ありで保存されます。提案エンジンは Voyage 由来の意味類似度で動くため、価値の大半は出ている からです。
7. 設計判断の核 — 課金単位と API 呼び出し回数を分離
withMeteredLLM の設計判断を一言で表すと:
「ユーザに見える課金単位」と「内部の API 呼び出し回数」を分離する。
これにより:
- ユーザは直感的に請求を理解できる
- 内部実装は最適化の自由度を持てる (API 呼び出し回数は変更可能)
- 課金体系を変更するときも、ApiCallLog の単位はそのままで済む
これが、たすきばの課金 invariant (次回 F-2 で詳説) の 土台 になっています。
おわりに
| ルール | 効果 |
|---|---|
withMeteredLLM で 1 業務操作を包む |
内部 API 回数と課金件数を分離 |
| callback 内で bulk 処理 OK | 100 件 import = 1 件課金 |
| 空テキスト早期 return | 無意味な API 呼び出しを排除 |
| 両方失敗 → 課金なし | ユーザ体感と課金一致 |
たすきば全体で、この withMeteredLLM パターンが LLM 課金の 正規ルート になっています。
LLM を従量課金にする SaaS を作る方は、ぜひ「内部 API 回数」と「ユーザに見える単位」の分離を検討してみてください。
本記事の設計は、運用中の SaaS 「たすきば Knowledge Relay」 の実装ベースです。
👉 たすきば Knowledge Relay — 公式プロダクトページ