本番のチャット機能から、ある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つの識別子があれば、ダッシュボードで見た実行と、自分のログに残した記録を、後からいつでも紐づけられます。
この分離が効くのは、両者に別々の期待をかけられるからです。ダッシュボードには「速く見えること」だけを求め、保持や網羅性は求めません。正本には「いつでも同じ答えを返すこと」を求め、視覚的な速さは求めません。速さは借りものでよく、記録は自分の手元に。 この線引きだけは、締め切りが近い週でも崩さないようにしております。
自前ログの最小実装
正本側の実装は、モデル呼び出しを薄く包むラッパーとして持つのが扱いやすいです。呼び出しの前後で、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 # 入力そのものは残さず、指紋だけを正本に置く
feature: 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 , feature: 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),
feature = feature,
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 を長期保管する理由もありません。
格納先(sink)に何を選ぶか — 100,000件で測った実寸
ラッパーが呼んでいる sink.append_only() の中身を、最初は JSON Lines へのファイル追記にしていました。実装が最も短く、追記専用という性質もそのまま満たせます。
ただ、記録が積み上がったときの手触りを知らないまま本番に置くのは落ち着きませんでした。そこで手元で 100,000 件を流し、容量と書き込みの実寸を測りました。上記のレコード定義そのままの構造で、status は実運用に近い比率(ok 97%・empty 2%・error 1%)にしています。
数字を見て、私はホット層を SQLite に切り替えました。索引を2本張っても、JSON Lines の無圧縮より小さく収まります。1日 3,000 件の呼び出しなら、90日で約 50 万件・90 MB 前後という見当が立ちます。個人開発で抱える容量として、これは現実的な範囲でした。
書き込みの遅延も測っておきました。WAL モードの SQLite に1件ずつ INSERT して commit() するかたちで、p50 が 0.31 ms、p95 が 1.29 ms、p99 が 1.49 ms。モデル呼び出しの往復が数百 ms から数秒の世界ですから、正本への追記が応答時間に乗る心配はまずありません。ここが重いと分かっていたら非同期のキューを挟むところでしたが、その必要はありませんでした。
import sqlite3
from dataclasses import asdict
DDL = """
CREATE TABLE IF NOT EXISTS interactions(
interaction_id TEXT PRIMARY KEY,
prev_interaction_id TEXT,
prompt_fingerprint TEXT,
feature TEXT, -- どの機能からの呼び出しか
model TEXT,
input_tokens INTEGER,
output_tokens INTEGER,
latency_ms INTEGER,
status TEXT,
created_at REAL
);
CREATE INDEX IF NOT EXISTS idx_created ON interactions(created_at);
CREATE INDEX IF NOT EXISTS idx_status ON interactions(status);
"""
class SqliteSink :
"""ホット層の正本。INSERT OR IGNORE で再送・リトライを冪等に受ける。"""
COLS = ( "interaction_id" , "prev_interaction_id" , "prompt_fingerprint" ,
"feature" , "model" , "input_tokens" , "output_tokens" ,
"latency_ms" , "status" , "created_at" )
def __init__ (self, path: str ):
self .con = sqlite3.connect(path, check_same_thread = False )
self .con.execute( "PRAGMA journal_mode=WAL" )
self .con.executescript( DDL )
def append_only (self, record) -> None :
row = asdict(record) if not isinstance (record, dict ) else record
placeholders = "," .join( f ": { c } " for c in self . COLS )
self .con.execute(
f "INSERT OR IGNORE INTO interactions VALUES( { placeholders } )" ,
{c: row.get(c) for c in self . COLS },
)
self .con.commit()
INSERT OR IGNORE にしているのは、リトライで同じ interaction_id が二度届いても正本が二重にならないようにするためです。追記専用と冪等は相性がよく、この一語で突合の前提が安定します。
レコードに feature を持たせているのは、この列があるだけで後述のコスト按分がそのまま SQL で書けるからです。呼び出し時に文字列を1つ渡すだけの負担で、後から取り返しのつかない情報が残ります。安い保険だと考えています。
コールド層へ落とす — 射影して gzip で寝かせる
先の表でいちばん小さかった「コールド射影」は、正本を丸ごと圧縮したものではありません。90日を過ぎた行から、後で必ず要る列だけを抜き出して並べ直したものです。id・指紋・機能・トークン・状態・時刻——この6つがあれば、監査もコスト按分も再現調査も成立します。逆に prev_interaction_id やモデル名の文字列は、古い行で参照する機会がほとんどありませんでした。
import gzip
import json
import sqlite3
import time
COLD_COLS = ( "interaction_id" , "prompt_fingerprint" , "feature" ,
"input_tokens" , "output_tokens" , "status" , "created_at" )
HOT_DAYS = 90
def demote_to_cold (db_path: str , cold_path: str , now: float | None = None ) -> int :
"""90日を過ぎた行を射影して gzip へ追記し、ホット層から落とす。"""
now = now or time.time()
cutoff = now - HOT_DAYS * 86400
con = sqlite3.connect(db_path)
rows = con.execute(
f "SELECT { ',' .join( COLD_COLS ) } FROM interactions WHERE created_at < ?" ,
(cutoff,),
).fetchall()
if not rows:
con.close()
return 0
# 追記モードで開く。既存のコールドファイルは読み直さない
with gzip.open(cold_path, "at" , encoding = "utf-8" ) as f:
for r in rows:
f.write(json.dumps( dict ( zip ( COLD_COLS , r)), ensure_ascii = False ) + " \n " )
con.execute( "DELETE FROM interactions WHERE created_at < ?" , (cutoff,))
con.commit()
con.execute( "VACUUM" ) # DELETE だけでは実ファイルは縮まない
con.close()
return len (rows)
最後に VACUUM を呼んでいるのには理由があります。SQLite は DELETE しただけでは空いた領域を内部で抱えたままで、ファイルサイズを返してくれません。これを入れ忘れていた時期があり、落としているつもりのホット層が想定より膨らんでいたことに、あとから気づきました。落とす処理は、落ちたことがファイルサイズに現れて初めて完了します。
gzip を追記モード("at")で開いているのは、月次でまとめて書き直すより事故が少ないからです。gzip のストリームは連結しても正しく展開できますので、追記を重ねても読み出し側は何も変えずに済みます。
突合(リコンサイル)で欠落を捕まえる
二層に分けると、次の問いが生まれます。「自前の正本は、本当に取りこぼしていないか」。ここで速報レンズが逆向きに役立ちます。ダッシュボードで見えている実行と、自前ログの 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% を下回ったときだけ通知する形にしました。毎回のログを人が見るのではなく、ズレが出たときだけ手を動かします。個人開発では、この「平時は黙っていてくれる」設計が、続けられるかどうかを分けます。
抜け道を先に塞ぐ — ラッパーを通らない呼び出しを探す
いま書いた突合には、前提として弱いところがあります。ダッシュボード側の interaction_id を機械的に取り出す口が用意されていない場合、dashboard_ids は人が画面から拾うしかありません。私は調査したい時間帯だけを手で拾って集合にしており、全件を毎日突き合わせてはおりません。
そこで、突合を待たずに missing_in_sink の原因そのものを潰す手を足しました。ラッパーを経由しない生のクライアント呼び出しを、実行時に見つける仕掛けです。
import contextvars
import functools
import logging
log = logging.getLogger( "interactions.guard" )
_inside_wrapper = contextvars.ContextVar( "inside_wrapper" , default = False )
def install_bypass_guard (client, * , strict: bool = False ) -> None :
"""生クライアントの直接呼び出しを検出する。ObservedClient 経由なら通す。"""
raw = client.interactions.create
@functools.wraps (raw)
def guarded ( * args, ** kwargs):
if not _inside_wrapper.get():
msg = "ObservedClient を経由しない Interactions 呼び出しです"
if strict:
raise RuntimeError (msg)
log.warning( " %s (model= %s )" , msg, kwargs.get( "model" ))
return raw( * args, ** kwargs)
client.interactions.create = guarded
ObservedClient.run() の側では、try に入った直後に token = _inside_wrapper.set(True) を置き、finally で _inside_wrapper.reset(token) を呼びます。フラグをモジュール変数ではなく ContextVar にしているのは、非同期で複数の呼び出しが並んだときにフラグが混ざらないようにするためです。ここを単なるグローバル変数で書いていた最初の版では、並行実行の途中で警告が出たり出なかったりして、原因を掴むのに半日かかりました。
ステージングでは strict=True にして落とし、本番では警告に留めるのが私の落としどころです。抜け道はたいてい、後から足した管理用スクリプトや、確認のために書いた小さな検証コードから生まれます。落として気づけるのはステージングまで、というのが正直なところでした。
この仕掛けを入れてから、日次の突合で出るズレは「ダッシュボード側の保持切れ」だけになりました。欠落は、見つけるよりも生まれない形にしておくほうが、後の自分を助けてくれるのだと思います。
正本があるから答えられる問い — 機能別のコスト按分
二層に分けた効果が最も分かりやすく出たのは、請求額の内訳を問われたときでした。
月末に API の請求を見て、「どの機能が使ったのか」を知りたくなります。ダッシュボードでは実行を1件ずつ眺めることはできても、機能ごとに束ねて合計する視点は得られません。正本が手元にあると、これが1本のクエリで済みます。
SELECT feature,
model,
COUNT ( * ) AS calls,
SUM (input_tokens) AS in_tok,
SUM (output_tokens) AS out_tok,
ROUND ( AVG (latency_ms)) AS avg_ms,
ROUND ( 100 . 0 * SUM ( status <> 'ok' ) / COUNT ( * ), 2 ) AS fail_pct
FROM interactions
WHERE created_at >= :period_start
GROUP BY feature, model
ORDER BY in_tok + out_tok DESC ;
100,000 行に対してこの集計を走らせて 85.8 ms でした。月次のレポートを作るのに待たされる速度ではありません。単価は変動しますので、ここで出したトークン数に最新の料金表を掛けて按分する、という手順にしています。単価をコードに埋め込まないのは、料金改定のたびに過去の集計が壊れないようにするためです。
この内訳が出て、私は自分の見立てが外れていたことを知りました。呼び出し回数が最も多い機能と、トークンを最も食っている機能が別だったのです。回数の多い機能は短い分類プロンプトの繰り返しで、単価に効いていたのは頻度の低い長文要約のほうでした。回数だけを見て最適化の順番を決めていたら、効かない場所を削っていたと思います。
fail_pct を同じクエリに入れているのは、コストと失敗率を並べて見たいからです。トークンを多く使っている機能の失敗率が高ければ、そこは「高い上に不安定」な場所ということになります。優先順位を決めるとき、この2列が並んでいるだけで迷いが減りました。
本番運用で効いた判断
設計を続けるうちに、いくつかの具体的な線引きが固まりました。状況別にまとめます。
とりわけ効いたのは、empty と error を分けたことでした。当初はどちらも「失敗」で一括りにしていましたが、空応答はモデル側の抑制やフィルタが疑わしく、例外はこちらの実装やネットワークのエラーが疑わしいところでした。原因の当たりが違うものを、同じ入れ物に入れて対処してはいけません。冒頭の1件も、empty と分かっていれば調査の初動が変わっていたはずです。本番運用で拾いたい注意点は、まさにこうした「見分けの粒度」に宿ります。
どこから始めるか
すでに Interactions API を本番で使っているなら、最初の一歩は小さくて構いません。次の順で始めると、無理なく正本が育ちます。
モデル呼び出しを1箇所だけ、上記のラッパーで包み、interaction_id と status を追記します
生クライアントへのガードを警告モードで入れ、迂回している経路がないかを1週間だけ眺めます
日次の突合バッチを回し、カバレッジを計測して基準線を掴みます
カバレッジが 99% を下回ったときだけ通知する経路を1本だけ用意します
これだけで、次に「3週間前の1件」を問われたとき、あなたの手元には辿れる1行が残ります。速報レンズは、これからも便利になっていくはずです。だからこそ、正本を自分の側に置いておく。この一手間が、後から効いてくるのだと考えています。
私自身、まだ突合の閾値を調整しながら運用している途中です。同じように個人でサービスを支えている方の、設計を見直すきっかけになれば嬉しく思います。お読みいただき、ありがとうございました。