Azure OpenAI Service の利用料金を部門別・プロジェクト別に追うためのテレメトリ基盤を作った話

こんにちは、サイオステクノロジーの佐藤 陽です。
今回は、Azure OpenAI Serviceを複数部門や複数プロジェクトで利用する際に、「誰がどれだけ使ったのか」を見える化するための仕組みを作ってみた。という話を書いていこうと思います。

生成 AI の業務利用が広がるにつれて、Azure OpenAI Serviceを複数部門や複数プロジェクトで共用するケースは珍しくないかと思います。
一方で、運用を始めるとすぐに出てくるのが、誰がどれだけ使ったのかをどう見える化するか、という課題です。

  • 全体の利用額だけではなく、部門別にも見たい
  • 案件やプロジェクト単位でも利用料金を把握したい

といった方には、是非最後までご覧ください!
(記事の最後にはサンプルコードを記載しています)

はじめに

LLM のコストの考え方は、従来の Web アプリケーションとは少し異なります。
従量課金であることに加えて、利用されるトークン数を事前に見積もりづらいため、想定外の利用料金が発生することがあります。
こうした状況に備えて、LLM のコストを意識しておくことは非常に大切です。

ただし、全体の利用額だけが分かっても、そのままでは使いづらいことがあります。
Azure Portal のコスト管理ツールでも全体傾向は確認できますが、基本的にはリソース単位での集計になるため、1 つの Azure OpenAI リソースを複数部門や複数プロジェクトで共用している場合には、その内訳までは見えません。

そこで今回は、Azure OpenAI Serviceの利用料を部門別・プロジェクト別に可視化する仕組みを作りました。
本記事では、その設計意図、実装のポイント、実際に得られた知見をまとめます。

背景と課題

単一のAzure OpenAI Serviceを複数のアプリケーションや組織単位で共用すると、インフラとしてはシンプルですが、利用責任の所在が見えにくくなります。

たとえば、ある月に利用額が増えたとしても、次のような内容にはたどり着くことが難しいです。

  • どの部門が多く使っていたのか
  • その部門の中でどのプロジェクトが増えていたのか
  • どのモデル利用がコストを押し上げていたのか

そこで、このあたりを見える化するため、まず次の 2 点で整理することにしました。

  • 部門(department_id)
  • プロジェクト(project_id)

例えば人事総務部(department_id)といった部門や、社内DX推進プロジェクト(project_id)といったような分類となります。

この 2 つを Azure OpenAI Service の利用料と紐付けられれば、部門別にも、プロジェクト別にも、比較的シンプルにコスト可視化ができます。
なお今回はこのような分け方を行いましたが、もちろんユーザー単位などでも分割することも可能で、そのあたりは定義・設計次第で自由に設定可能です。

何を作ったのか

今回作ったのは、Azure OpenAI Service を背後に持つチャットバックエンド兼テレメトリ BFF です。

アーキテクチャ

以下の流れで実装します。

  1. クライアントがチャットリクエストを送る
  2. リクエストには department_id と project_id を含める
  3. Azure Functions が Azure OpenAI Service に接続する
  4. Azure OpenAI Service のストリーミング最終チャンクから usage(利用料) を取り出す
  5. Functions が usage_telemetry という custom event を Application Insights に送る
  6. Log Analytics で KQL 集計する
  7. Workbook でグラフ表示する

usage をどう回収したか

Azure OpenAI のチャット補完をストリーミングで使う場合、usage(利用料) は最終チャンクに含まれます。
そのため、単にレスポンスを受け取るだけではなく、最後までストリームを読み切る必要があります。

公式ドキュメントにおいて、 chatCompletionStreamOptionsのinclude_usageの値をTrueにすると、`data: [DONE]` の直前に usage を持つ追加チャンクが返されると説明されています。
今回の実装でもこのオプションを有効にし、最終チャンクから usage を回収しました。

usage有効化

async def stream_chat_with_usage(
    create_stream: StreamFactory,
    **request: Any,
) -> AsyncIterator[Chunk]:
    stream_options = dict(request.get("stream_options") or {})
    stream_options["include_usage"] = True
    request["stream_options"] = stream_options
    request["stream"] = True


    stream_or_awaitable = create_stream(**request)
    if inspect.isawaitable(stream_or_awaitable):
        stream = await stream_or_awaitable
    else:
        stream = stream_or_awaitable


    async for chunk in stream:
        yield chunk
レスポンスの usage には主に次のような項目があります。
  • prompt_tokens: プロンプト内のトークンの数。
  • completion_tokens: 生成された入力候補内のトークンの数。
  • total_tokens: 要求内で使われたトークンの合計数 (プロンプトとcompletionの和)。
  • completion_tokens_details: 出力トークンの詳細。モデルによっては reasoning_tokens のような詳細項目が返ります。
    • reasoning_tokens 推論のためにモデルによって生成されたトークン。
  • prompt_tokens_details: 入力トークンの詳細。プロンプトキャッシュが有効なモデルでは、この下に cached_tokens が含まれます。
    • cached_tokens: キャッシュされたプロンプト トークンの数。
レスポンスのイメージは次のようになります。
"usage": {
  "prompt_tokens": 1566,
  "completion_tokens": 1518,
  "total_tokens": 3084,
  "completion_tokens_details": {
    "reasoning_tokens": 576
  },
  "prompt_tokens_details": {
    "cached_tokens": 1408
  }
}
特に今回のコスト可視化で重要だったのは、次の 3 つです。
  • prompt_tokens
  • completion_tokens
  • cached_tokens
この 3 つが取れれば、少なくとも推定コストの算出に必要な基本情報は揃います。
特に  cached_tokens は「入力トークンのうち、プロンプトキャッシュから再利用できたトークン数」を表します。
公式ドキュメントにおける prompt caching の説明では、キャッシュヒット時はこの値が prompt_tokens_details.cached_tokens に現れ、キャッシュミス時は 0 になるとあります。

最終チャンクからのトークン数取得

def extract_usage_from_final_chunk(final_chunk: Chunk) -> UsageTokens:
    usage = final_chunk.get("usage")
    if not isinstance(usage, dict):
        raise ValidationError("usage payload missing in final chunk")


    prompt_tokens = usage.get("prompt_tokens")
    completion_tokens = usage.get("completion_tokens")
    prompt_tokens_details = usage.get("prompt_tokens_details") or {}
    cached_tokens = prompt_tokens_details.get("cached_tokens", 0)


    result = UsageTokens(
        prompt_tokens=prompt_tokens,
        completion_tokens=completion_tokens,
        cached_tokens=cached_tokens,
    )
    result.validate()
    return result
なお、prompt caching が効く条件についても公式ドキュメントに記載があり、少なくとも次の条件を満たす必要があります。
  • リクエスト全体が 1,024 トークン以上であること
  • 先頭 1,024 トークンが同一であること
そのため cached_tokens  は常に返るとは限らず、モデル種別や API バージョン、入力内容によっては 0 あるいは詳細項目自体が無いことがあります。

KQL でコストをどう計算したか

アプリケーション側では usage までを送るだけにして、金額換算は KQL で行うようにしました。

アプリ側で金額を計算してしまうと、モデル単価の変更や料金体系の更新があるたびにコード修正が必要になります。
一方で KQL に価格表を持っておけば、可視化ロジックの変更だけで追従できます。

KQL では、customEvents の usage_telemetry を対象にして、次のような流れで集計します。

  1. customDimensions から department_id、project_id、model_name、deployment_name を取り出す
  2. customMeasurements から prompt_tokens、completion_tokens、cached_tokens を取り出す
  3. モデルごとの価格表と join する
  4. 入力トークン、キャッシュ入力トークン、出力トークンを分けて推定コストを計算する
  5. 部門別に summarize する

この責務分離によって、アプリケーションコードはシンプルなまま、分析だけを柔軟に変えられるようになりました。

let pricing = datatable(model_name:string, input_per_1m:real, cached_input_per_1m:real, output_per_1m:real)
[
    'gpt-5-mini', 39.10, 3.91, 312.80
];
customEvents
| where name == 'usage_telemetry'
| extend department_id = tostring(customDimensions.department_id)
| extend model_name = tostring(customDimensions.model_name)
| extend prompt_tokens = tolong(customMeasurements.prompt_tokens)
| extend cached_tokens = tolong(customMeasurements.cached_tokens)
| extend completion_tokens = tolong(customMeasurements.completion_tokens)
| join kind=leftouter pricing on model_name
| extend normal_prompt_tokens = prompt_tokens - cached_tokens
| extend input_cost = normal_prompt_tokens / 1000000.0 * input_per_1m
| extend cached_input_cost = cached_tokens / 1000000.0 * cached_input_per_1m
| extend output_cost = completion_tokens / 1000000.0 * output_per_1m
| extend total_cost_jpy = round(input_cost + cached_input_cost + output_cost, 2)
| summarize total_cost_jpy = round(sum(total_cost_jpy), 2) by department_id
| order by total_cost_jpy desc

Workbook ではどう見せたか

可視化としては、部門ごとの推定コストを棒グラフで並べてみました。
このようにすることでビジネスサイドのユーザーにとっても一目で各部署の利用状況などが把握可能です。

余談:Azure Monitor の標準メトリックだけでは難しい理由

Azure OpenAI Service には、利用状況を把握するための標準メトリックも用意されています。
その中には、プロンプトキャッシュの効き具合を把握するうえで参考になる「Prompt Token Cache Match Rate」のようなメトリックもあります。

ただし、このメトリックは現時点ではLog Analytics にデータを転送して、KQL で継続的に分析することができません。(恐らく)
メトリックとしてポータル上で確認できたとしても、他のログや業務コンテキストと組み合わせて分析しづらいとなると、やや使いにくい場面があります。

今回 customEvents ベースの仕組みにしたのは、まさにこの点も理由の 1 つです。
Application 側で usage を回収し、department_id や project_id と一緒に送っておけば、必要な切り口で後から KQL 集計しやすくなります。

サンプルコード

今回作成したアプリケーションのコードは以下に置いてあります。
ただし、こちらは生成AIによって出力されたものであり、細部までの動作確認や、セキュリティ面での確認を行っておりません。
あくまで参考レベルとしてご確認いただけると幸いです。

https://github.com/satodayo/azure-openai-usage-telemetry

おわりに

Azure OpenAI Service の利用コストは、総額だけを追っていても運用に活かしにくい現実があります。
部門やプロジェクトのような業務単位と結び付けて初めて、予算管理や利用改善の議論に使える情報になります。

今回の PoC では、Azure Functions をチャットバックエンド兼テレメトリ BFF として使い、Azure OpenAI Service の usage を回収し、Application Insights の customEvents に送って、KQL と Workbook で可視化する流れを構築しました。
結果として、比較的シンプルな構成でも、部門別コスト可視化の最初の一歩として十分実用的であることを確認できました。

是非導入を検討してみてください!
ではまた!

ご覧いただきありがとうございます! この投稿はお役に立ちましたか?

役に立った 役に立たなかった

1人がこの投稿は役に立ったと言っています。
エンジニア募集中!

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です