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?

「1 業務操作 = 1 ApiCallLog」に集約する withMeteredLLM パターン — Bulk な LLM 課金を直感に揃える

0
Last updated at Posted at 2026-06-08

この記事は約 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.tswithMeteredLLM ヘルパ。

// 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 だけ
  • costJpyfeatureUnit 単位の固定単価

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.create1 回呼ばれる → ユーザに見える課金は 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 — 公式プロダクトページ

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?