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?

request_id/model/usage は残して prompt/body は残さない LLM ログにした

0
Posted at

はじめに: ログが便利すぎて怖くなった

LLM API を使った機能を運用していると、あとから見たい情報はだいたい決まっています。

どの request_id だったのか、どの model に流れたのか、usage はどれくらいだったのか、遅かったのか、失敗したのか。このあたりが残っていないと、障害調査も請求前の違和感チェックもかなりつらいです。

一方で、調査しやすくしたいからといって promptmessagesrequest body をそのままログに出すのは、私はかなり怖いと思っています。ユーザー入力、社内データ、ツール呼び出しの引数、API key が混ざる可能性があるからです。

今回は、LLM API のログを allowlist 方式にして、残す情報と残さない情報を分けました。やったこと自体は小さいのですが、後から効く設計だと思ったのでメモします。

3行まとめ

  • 残す項目は request_id, client_request_id, model, usage, latency_ms, status くらいから始める
  • 残さない項目は prompt, messages, request body, response body, Authorization, 生のユーザー入力
  • デバッグしたい時だけ例外的に本文を残すのではなく、最初から allowlist でログスキーマを決める

前提

この記事は、アプリ側のログ設計の話です。OpenAI 互換 API、Responses API、Chat Completions API、API ルーターのどれを使っていても、だいたい同じ考え方で整理できると思います。

私の前提はこんな感じです。

  • SaaS の backend から LLM API を呼ぶ
  • 利用者単位、機能単位、モデル単位であとから調査したい
  • prompt や body はログ基盤に流したくない
  • 請求や急な usage 増加には早めに気づきたい
  • Flatkey AI のような API gateway 側の usage/billing visibility も見るが、アプリ側の原因追跡は自分のログで持つ

OpenAI の API reference では、トラブルシュート用に x-request-id を見ること、production で request ID をログに残すことが案内されています。また、必要なら X-Client-Request-Id を自分で付与できます。ここはかなり大事で、プロバイダー側の ID と自分の trace ID を分けて持てます。

まず allowlist を決める

最初にやったのは、ログに出してよいフィールドを先に決めることです。

「この object から危なそうなものを消す」ではなく、「このキーだけログに入れる」に寄せました。request body は endpoint や SDK の更新で形が変わるので、denylist だと抜け漏れが出そうだと思ったからです。

私はまず、このくらいにしました。

項目 残す理由 注意
client_request_id アプリ側で発行した trace ID と突き合わせる UUID など、本文を含まない値にする
request_id API provider のサポート調査に渡せる SDK や endpoint で取得方法が違う
model どのモデルに流れたかを確認する alias がある場合は実解決後の値も見たい
usage token 増加や請求前の違和感を見る endpoint ごとにキー名が違う
latency_ms timeout や遅延調査に使う アプリ側の計測として残す
status success, retry, error を分ける HTTP status とアプリ status は分ける
error_type 429, timeout, upstream error の切り分け error body 丸ごとは残さない

逆に、最初からログに入れないものも決めました。

項目 残さない理由
prompt / messages ユーザー入力や社内文書が混ざりやすい
request body model 以外の本文、tool args、file ID などが混ざる
response body 生成結果に個人情報や秘密情報が再出力される場合がある
Authorization header API key そのもの
生の user_id / email ログ横断で個人を追える形にしない
tool call arguments 外部システムの ID や業務データが入りやすい

この表を作るだけでも、レビューの会話がだいぶ楽になりました。「このログ出して大丈夫ですか」ではなく、「allowlist に追加する理由がありますか」と聞けるからです。

小さく実装してみる

Python で書くと、だいたいこんな形です。例なので、実際の logger や trace context は自分の環境に合わせてください。

import logging
import os
import time
from uuid import uuid4

from openai import OpenAI

logger = logging.getLogger("app.llm")

client_args = {"api_key": os.environ["OPENAI_API_KEY"]}
if os.environ.get("OPENAI_BASE_URL"):
    client_args["base_url"] = os.environ["OPENAI_BASE_URL"]

client = OpenAI(**client_args)

CHAT_USAGE_KEYS = (
    "prompt_tokens",
    "completion_tokens",
    "total_tokens",
)


def usage_to_dict(usage):
    if usage is None:
        return {}

    if hasattr(usage, "model_dump"):
        data = usage.model_dump()
    elif isinstance(usage, dict):
        data = usage
    else:
        return {}

    return {
        key: data[key]
        for key in CHAT_USAGE_KEYS
        if key in data and data[key] is not None
    }


def provider_request_id(response_or_error):
    return (
        getattr(response_or_error, "_request_id", None)
        or getattr(response_or_error, "request_id", None)
    )


def call_llm(user_id_hash: str, user_text: str) -> str:
    started = time.perf_counter()
    client_request_id = str(uuid4())

    try:
        res = client.chat.completions.create(
            model=os.environ.get("LLM_MODEL", "gpt-4o-mini"),
            messages=[
                {"role": "system", "content": "Return one short answer."},
                {"role": "user", "content": user_text},
            ],
            extra_headers={"X-Client-Request-Id": client_request_id},
        )
        latency_ms = int((time.perf_counter() - started) * 1000)

        logger.info(
            "llm_call_succeeded",
            extra={
                "llm": {
                    "client_request_id": client_request_id,
                    "request_id": provider_request_id(res),
                    "model": res.model,
                    "usage": usage_to_dict(res.usage),
                    "latency_ms": latency_ms,
                    "status": "success",
                    "user_id_hash": user_id_hash,
                }
            },
        )

        return res.choices[0].message.content or ""

    except Exception as exc:
        latency_ms = int((time.perf_counter() - started) * 1000)

        logger.warning(
            "llm_call_failed",
            extra={
                "llm": {
                    "client_request_id": client_request_id,
                    "request_id": provider_request_id(exc),
                    "model": os.environ.get("LLM_MODEL", "gpt-4o-mini"),
                    "latency_ms": latency_ms,
                    "status": "error",
                    "status_code": getattr(exc, "status_code", None),
                    "error_type": exc.__class__.__name__,
                    "user_id_hash": user_id_hash,
                }
            },
        )
        raise

ここで大事なのは、user_text を関数には渡しているけれど、ログには入れていないことです。messagesrequest body も logger に渡していません。

成功時のログは、このくらいの粒度で十分な場面が多いです。

{
  "event": "llm_call_succeeded",
  "llm": {
    "client_request_id": "8a0d6c5e-1b4c-4a1a-a8ec-000000000000",
    "request_id": "req_redacted",
    "model": "gpt-4o-mini",
    "usage": {
      "prompt_tokens": 19,
      "completion_tokens": 10,
      "total_tokens": 29
    },
    "latency_ms": 843,
    "status": "success",
    "user_id_hash": "u_7e2_redacted"
  }
}

障害時も同じで、error body を丸ごと残したい気持ちはあるのですが、まずは status_code, error_type, request_id, client_request_id で調査を始めるようにしました。本文が必要なときは、ログではなく一時的な再現環境やサポート用の手順で扱うほうが安全だと思います。

usage の形は endpoint ごとに分ける

ここで少しハマりました。

usage と一言で言っても、Chat Completions では prompt_tokens, completion_tokens, total_tokens の形がよく出ます。一方で Responses API では input_tokens, output_tokens, total_tokens の形になります。さらに stream の途中では、最後の completed event まで usage がないこともあります。

なので、私は usage を無理にひとつの parser で吸収しないようにしました。

endpoint 例として見る usage
Chat Completions prompt_tokens, completion_tokens, total_tokens
Responses input_tokens, output_tokens, total_tokens
Streaming final event または集計済み response の usage
Provider gateway provider 固有の集計値が付く場合は別 namespace に分ける

ここを雑にすると、ログの数字は残っているのに集計がずれる、という微妙な事故が起きます。私も最初は prompt_tokens 前提で集計しそうになりました。Responses API 側では名前が違うので、ここは endpoint ごとの extractor にしたほうが良いと思います。

残さないためのテストも書く

allowlist にしたつもりでも、後から誰かが extra={"body": payload} を足すかもしれません。自分もやりそうです。

なので、ログ出力のテストでは「必要なキーがある」だけでなく、「危ないキーがない」も見ます。

FORBIDDEN_LOG_KEYS = {
    "prompt",
    "messages",
    "body",
    "request_body",
    "response_body",
    "authorization",
    "api_key",
}


def assert_no_forbidden_keys(log_record: dict):
    lowered = {key.lower() for key in log_record.keys()}
    leaked = lowered & FORBIDDEN_LOG_KEYS
    assert not leaked, f"forbidden log keys: {sorted(leaked)}"

本当は nested dict も再帰的に見る必要があります。ここでは短く書いていますが、実運用では JSON log の flatten 後にチェックするか、logger wrapper のテストで再帰的に見るほうがよさそうです。

このテストは地味ですが、レビューで「この PR は prompt をログに出していないはず」を機械的に確認できるので助かりました。

保存期間と閲覧権限も一緒に決める

もうひとつ決めておいたほうがよかったのが、保存期間と閲覧権限です。

allowlist にしたログでも、長く置きすぎると別のリスクになります。request_iduser_id_hash だけなら安全、と言い切るのも少し雑だと思います。複数のログを横断すると、利用者の行動や機能利用の傾向が見えてくるからです。

私はまず、LLM 呼び出しログを「短期の障害調査用」と「日次の usage 集計用」に分けました。短期ログには request_idlatency_ms を残し、日次集計には feature, model, total_tokens, error_count のような集計値だけを残します。

閲覧権限も同じです。debug 目的の raw JSON log を誰でも見られる状態にせず、サポート、SRE、開発者で見える範囲を分けます。ここをやらないと、せっかく prompt を残さない設計にしても、運用上の説明が弱くなると思いました。

Flatkey AI 側では usage と billing を確認する

Flatkey AI のような API gateway を挟む場合、gateway 側の dashboard では keys, usage, billing, routing の確認ができます。これは便利です。モデルごとの usage や routing の状況を後から見られると、請求やルートの確認がしやすくなります。

ただし、私は gateway 側だけに寄せすぎないほうがよいと思っています。

アプリ側で見たいのは、「どの機能のどの処理が増えたのか」「retry が増えたのか」「ユーザー操作のどこで遅くなったのか」です。gateway 側で usage/cost/routing を見て、アプリ側で client_request_id, feature, latency_ms, status を見る。この役割分担にすると、ログに prompt を残さなくても調査しやすくなりました。

ここも宣伝っぽくしたくないので正直に書くと、dashboard の exact な列名まではこの記事では前提にしていません。自分のアカウントで見える項目は変わる可能性があるので、記事内では keys, usage, billing, routing の確認先として扱っています。

ハマったポイント

request_id が取れない時がある

timeout や network error の場合、provider 側の request_id が手元にないことがあります。そのため、私は client_request_id を自分で発行して必ずログに残すようにしました。

OpenAI API では X-Client-Request-Id を自分で送ることもできます。送った値は ASCII で、長すぎず、request ごとに unique にする必要があります。ここにユーザー名やメールアドレスを入れるのは避けたほうがよいと思います。

error body を見たくなる

失敗時は error body を丸ごと見たくなります。特に 400 系は body に原因が入っていることがあります。

ただ、ここで丸ごとログに出すと、危ない情報も一緒に入ります。私はまず status_code, error_type, request_id, client_request_id, model, latency_ms だけで調査し、それでも足りない時は再現用の最小 input を作るようにしています。

debug log は安全ではない

本番では info だけ安全にして、debug なら body を出してよい、という分け方も考えました。でも実際には debug log が一時的に本番で有効になることがありますし、log collector 側では同じ保存先に入ることもあります。

なので、私は log level ではなく log schema で守るほうが安心だと思いました。debug でも allowlist は変えない、というルールです。

まとめ

LLM API のログは、残さなすぎると運用できません。特に request_id, model, usage, latency_ms がないと、障害調査も usage 増加の説明もかなりつらくなります。

一方で、残しすぎると別の問題になります。prompt, messages, request body, response body, Authorization は、便利そうに見えてもログ基盤に流すには重すぎます。

私の今の落とし所は、次のような感じです。

  • ログ項目は allowlist で決める
  • provider の request_id と自分の client_request_id を分ける
  • usage は endpoint ごとに extractor を分ける
  • 失敗時も body を丸ごと残さない
  • gateway dashboard は usage/billing/routing の確認、アプリログは feature/trace の確認に使う

これだけでも、調査できる情報と残さない情報の境界がかなりはっきりしました。

参考

おわりに

ログは「あとで困らないように全部残す」に寄りがちですが、LLM API ではその考え方がそのままリスクになる場面があると思います。

私はまだこの設計が最終形だとは思っていません。特に tool call や RAG の document ID まわりは、もう少し細かく allowlist を分ける必要がありそうです。

同じようなログ設計をしている方がいたら、どこまで残しているか知りたいです。間違いあったらコメントください。よろしくお願いします。

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?