本番のチャット機能から、ある1件の応答が空で返ったという報告が届いたのは、3週間前の呼び出しについてでした。私は AI Studio のダッシュボードを開き、その時間帯の実行ログを遡ろうとしました。けれど、目的の呼び出しにはたどり着けませんでした。
見えていたのは直近の実行だけ。3週間前の1件が、記録の正本としてそこに残っている保証は、どこにもなかったのです。
このとき痛感しました。便利なダッシュボードほど、自前のログを省く言い訳になりやすい。そして省いた瞬間に、後から効く問いへ答えられなくなる。この記事は、その反省から組み直した観測の二層設計についての記録です。
AI Studio の開発者ログが「入口」になった
2026年7月6日から、Interactions API の対応する呼び出しの実行ログが、Google AI Studio のダッシュボードで確認できるようになりました。API 経由のリクエストが、コンソール上でそのまま追える。エージェントの各実行ステップや、ツール呼び出しの連なりを目で見て切り分けられる。デバッグの入口としては、率直にありがたい変更です。
私自身、Interactions API へ寄せた自動処理のデバッグで、まずここを開くようになりました。手元のログを grep するより速く、失敗した呼び出しの周辺が視覚的に見える。挙動の当たりをつける「速報レンズ」としては、これで十分に機能します。
ただ、便利さの手触りと、記録としての信頼性は別物です。ここを混同したのが、冒頭の失敗でした。
ダッシュボードを「正本」にしてはいけない理由
観測の道具には、性質のまったく異なる二種類があります。ひとつは、いま何が起きているかを速く掴むための「速報レンズ」。もうひとつは、後からいつでも同じ答えを返せる「正本(system of record)」です。
AI Studio の開発者ログは、前者として設計された道具だと捉えるのが安全です。マネージド側のダッシュボードは、保持期間・表示範囲・サンプリングの方針が提供側の都合で将来変わりえます。今日たまたま3週間前まで見えたとしても、それは仕様として保証された正本ではありません。私が辿れなかったのは、まさにこの前提を取り違えていたからです。
直感に反しますが、ここが要点でした。組み込みのログが充実するほど、自前のログは要らなくなる——ように感じてしまう。実際は逆です。便利な速報レンズが手に入ったときこそ、正本を自分の管理下に置いておかないと、監査・コスト按分・再現調査といった「時間が経ってから効く問い」に答えられなくなります。速報レンズは正本の代わりにはなりません。正本の上に重ねて使うものです。
二層設計 — 速報レンズと正本ログを分ける
そこで、観測を明確に二層へ分けました。
第一層は AI Studio の開発者ログ。障害が起きた直後の triage、つまり「どのステップで何が起きたか」を最短で掴むために使います。ここには何も足さず、提供されるまま使います。
第二層は、自前の追記専用(append-only)ログ。これを記録の正本とします。鍵は interaction_id。この1つの識別子があれば、ダッシュボードで見た実行と、自分のログに残した記録を、後からいつでも紐づけられます。
観点 第一層: AI Studio 開発者ログ 第二層: 自前 append-only ログ
役割 速報レンズ(triage) 正本(system of record)
保持 提供側依存・保証しない前提 自分で決める(例: 90日ホット + 400日コールド)
結合鍵 interaction_id interaction_id
主な用途 その場の挙動確認 監査・コスト按分・再現調査
PII 送信前に最小化すべき対象 マスキングして格納
この分離が効くのは、両者に別々の期待をかけられるからです。ダッシュボードには「速く見えること」だけを求め、保持や網羅性は求めない。正本には「いつでも同じ答えを返すこと」を求め、視覚的な速さは求めない。期待を混ぜないこと。この一点を、私はまず強く推奨します。
自前ログの最小実装
正本側の実装は、モデル呼び出しを薄く包むラッパーとして持つのが扱いやすいです。呼び出しの前後で、interaction_id・入力の指紋・使用トークン・所要時間・結果の要約を、1件ずつ構造化して追記します。
以下は Python の例です。モデル呼び出しの行は Interactions API のパターンを示すもので、実際のエンドポイント名やパラメータは最新の changelog に合わせて確認してください。記録と突合のロジックは、それ自体で完結する application 層のコードです。
import hashlib
import json
import time
import uuid
from dataclasses import dataclass, asdict
from typing import Optional
@dataclass
class InteractionRecord :
interaction_id: str
prev_interaction_id: Optional[ str ]
prompt_fingerprint: str # 入力そのものは残さず、指紋だけを正本に置く
model: str
input_tokens: int
output_tokens: int
latency_ms: int
status: str # "ok" | "empty" | "error"
created_at: float
def fingerprint (text: str ) -> str :
# PII を正本に残さないための最小化。突合には十分。
return hashlib.sha256(text.encode( "utf-8" )).hexdigest()[: 16 ]
class ObservedClient :
"""Interactions API 呼び出しを薄く包み、正本ログへ1件ずつ追記する。"""
def __init__ (self, client, sink):
self .client = client # Gemini の Interactions クライアント
self .sink = sink # append_only(record: dict) を持つ格納先
def run (self, prompt: str , * , model: str ,
prev_interaction_id: Optional[ str ] = None ) -> dict :
started = time.perf_counter()
# クライアント側でも id を用意し、応答が空でも正本に痕跡を残す
local_id = prev_interaction_id or str (uuid.uuid4())
status = "error"
usage = { "input_tokens" : 0 , "output_tokens" : 0 }
server_id = local_id
try :
resp = self .client.interactions.create(
model = model,
input = prompt,
previous_interaction_id = prev_interaction_id,
)
server_id = getattr (resp, "id" , local_id)
usage = {
"input_tokens" : resp.usage.input_tokens,
"output_tokens" : resp.usage.output_tokens,
}
status = "ok" if resp.output_text else "empty"
return resp
finally :
# try/finally なので、例外時も必ず1件は残る
rec = InteractionRecord(
interaction_id = server_id,
prev_interaction_id = prev_interaction_id,
prompt_fingerprint = fingerprint(prompt),
model = model,
input_tokens = usage[ "input_tokens" ],
output_tokens = usage[ "output_tokens" ],
latency_ms = int ((time.perf_counter() - started) * 1000 ),
status = status,
created_at = time.time(),
)
self .sink.append_only(json.dumps(asdict(rec)))
要点は try/finally です。応答が空で返っても、例外で落ちても、正本には必ず1件の痕跡が残ります。冒頭の「3週間前の空応答」を、私が今なら status="empty" の1行として拾えるのは、この構造にしたからです。入力そのものは指紋だけを残し、原文は正本に置きません。突合には指紋で足りますし、PII を長期保管する理由もありません。
突合(リコンサイル)で欠落を捕まえる
二層に分けると、次の問いが生まれます。「自前の正本は、本当に取りこぼしていないか」。ここで速報レンズが逆向きに役立ちます。ダッシュボードで見えている実行と、自前ログの interaction_id を突き合わせ、片側にしかない id を洗い出すのです。
def reconcile (dashboard_ids: set[ str ], sink_ids: set[ str ]) -> dict :
"""速報レンズ側と正本側の interaction_id を突合し、ズレを返す。"""
missing_in_sink = dashboard_ids - sink_ids # 記録漏れ。最優先で調査
missing_in_dashboard = sink_ids - dashboard_ids # 提供側の保持切れ等
return {
"missing_in_sink" : sorted (missing_in_sink),
"missing_in_dashboard" : sorted (missing_in_dashboard),
"sink_coverage" : (
len (sink_ids & dashboard_ids) / len (dashboard_ids)
if dashboard_ids else 1.0
),
}
missing_in_sink が出たら、自前ログのラッパーを通っていない呼び出し経路がある、という強いシグナルです。ここを塞ぐことが、正本の網羅性を保つ作業になります。逆に missing_in_dashboard は、多くの場合ダッシュボード側の保持が切れただけで、正本が正しく機能している証拠にもなります。この非対称性を理解しておくと、アラートの向き先を間違えずに済みます。
私はこの突合を日次のバッチで回し、カバレッジが 99% を下回ったときだけ通知する形にしました。毎回のログを人が見るのではなく、ズレが出たときだけ手を動かす。個人開発では、この「静かなときは静かでいてくれる」設計が、続けられるかどうかを分けます。
本番運用で効いた判断
設計を続けるうちに、いくつかの具体的な線引きが固まりました。状況別にまとめます。
決めごと 採った方針 理由
正本の保持 90日ホット + 400日コールド(指紋のみ) 直近はすぐ引ける。長期は監査に足る最小限だけ残す
入力の扱い 原文は残さず16文字の指紋のみ 突合には十分・PII を抱え込まない
突合の頻度 日次バッチ・カバレッジ 99% 低下時のみ通知 平時は無音。異常時だけ人を呼ぶ
ダッシュボード triage 専用・自動化から参照しない 保証されない表示に運用を依存させない
失敗の記録 empty と error を別ステータスに分離 空応答と例外は原因も対処も別物だから
とりわけ効いたのは、empty と error を分けたことでした。当初はどちらも「失敗」で一括りにしていましたが、空応答はモデル側の抑制やフィルタが疑わしく、例外はこちらの実装やネットワークのエラーが疑わしい。原因の当たりが違うものを、同じ入れ物に入れて対処してはいけない。冒頭の1件も、empty と分かっていれば調査の初動が変わっていたはずです。本番運用で拾いたい注意点は、まさにこうした「見分けの粒度」に宿ります。
どこから始めるか
すでに Interactions API を本番で使っているなら、最初の一歩は小さくて構いません。次の順で始めると、無理なく正本が育ちます。
モデル呼び出しを1箇所だけ、上記のラッパーで包み、interaction_id と status を追記する
日次の突合バッチを回し、カバレッジを計測して基準線を掴む
カバレッジが 99% を下回ったときだけ通知する経路を1本だけ用意する
これだけで、次に「3週間前の1件」を問われたとき、あなたの手元には辿れる1行が残ります。速報レンズは、これからも便利になっていくはずです。だからこそ、正本を自分の側に置いておく。この一手間が、静かな安心につながると考えています。
私自身、まだ突合の閾値を調整しながら運用している途中です。同じように個人でサービスを支えている方の、設計を見直すきっかけになれば嬉しく思います。お読みいただき、ありがとうございました。