AIエージェントのログ設計は、今のチームが最も後回しにしがちな領域だと思っています。
理由はわかります。エージェントを「動かすこと」に夢中になっているうち、ログはデフォルトのprintやconsole.logのままになる。しかし本番に移行して問題が起きたとき、ログが整備されていないシステムのデバッグは地獄です。「エージェントが何を判断して何を実行したか」の記録がなければ、障害の原因を追うことすらできません。
正直に言うと、ログ設計はエージェント開発の中で最も「後でやろう」と先延ばしにされ、最も痛い目を見る部分です。この記事では、私がAIエージェント導入支援で学んできた構造化ログの設計思想と実装パターンを、コード付きで公開します。
なぜ従来のテキストログではエージェントに不十分なのか
従来のWebアプリのログは「イベントを時系列で記録する」設計です。エージェントには、それだけでは足りません。
エージェント特有の要素として、以下があります:
- マルチターン推論 — 一つのユーザーリクエストが何度もLLMを呼び出す。どのターンで判断が変わったかを追跡できないと、デバッグが不可能
- ツール呼び出しの連鎖 — 「検索→読み込み→要約→送信」のような一連の実行を、会話単位で集約して見る必要がある
- PII(個人識別情報)の混入リスク — ユーザーの氏名・メールアドレス・クレジットカード番号がプロンプトに入ることがあり、ログにそのまま記録するとGDPR/個人情報保護法違反になる
- コスト追跡 — トークン消費量をログに記録しないと、エージェントのコストが見えない
これらに対応するには、構造化ログ(JSON形式)が必須です。AIエージェントの観測性全般については、AIエージェント構築完全ガイドでも触れています。
JSONLines(JSONL)形式を選ぶ理由
構造化ログの形式として、JSONLinesを推奨します。
JSONLinesは「1行1JSONオブジェクト」という単純なフォーマットです:
{"timestamp":"2026-04-11T10:00:01Z","level":"INFO","session_id":"s-abc123","event":"agent_start","user_id":"u-***redacted***","tool":"research_agent"}
{"timestamp":"2026-04-11T10:00:02Z","level":"DEBUG","session_id":"s-abc123","event":"llm_call","model":"claude-sonnet-4-6","tokens_in":1200,"tokens_out":350}
{"timestamp":"2026-04-11T10:00:03Z","level":"INFO","session_id":"s-abc123","event":"tool_call","tool_name":"web_search","query":"AI agent logging best practices"}
{"timestamp":"2026-04-11T10:00:05Z","level":"INFO","session_id":"s-abc123","event":"agent_complete","duration_ms":4200,"total_tokens":2890,"cost_usd":0.0087}
純粋なJSON(配列)と比べてJSONLinesが優れている点:
- ファイルを途中まで読んでもパースできる(クラッシュ前のログも取れる)
grepやjqでシェルから手軽に検索できる- ログ収集エージェント(Fluentd、Vector)がJSONLをネイティブサポート
- BigQuery / Athena に直接インポート可能
ログレベルの設計
エージェントには5段階のログレベルを推奨します。標準的なDEBUG/INFO/WARN/ERRORに、エージェント固有の「TRACE」を追加します。
| レベル | 用途 | 本番での出力 |
|---|---|---|
| TRACE | LLMの入出力全文、ツール引数の詳細 | 無効(PII含む可能性高) |
| DEBUG | トークン数、レイテンシ、判断フロー | 開発/ステージングのみ |
| INFO | セッション開始/終了、ツール呼び出し、コスト | 常に出力 |
| WARN | リトライ、レート制限接近、予期しない入力 | 常に出力 |
| ERROR | 例外、API失敗、タイムアウト | 常に出力 + アラート |
TRACEレベルを本番で無効にすることが重要です。LLMへの入力プロンプトにはユーザーの個人情報が含まれることが多く、そのままログに残すとPII漏洩になります。
PII除去の実装パターン
PIIをログに記録しないための実装は、「書く前に除去する」が原則です。書いた後から削除するのは現実的ではありません。
# pii_redactor.py — PII除去ユーティリティ
# 注意: 本番環境で使用する前に、必ずテスト環境で動作確認してください。
import re
from typing import Any
# よく使われる正規表現パターン
PII_PATTERNS = [
# メールアドレス
(re.compile(r'b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+.[A-Z|a-z]{2,}b'), "***EMAIL***"),
# 日本の電話番号
(re.compile(r'b0d{1,4}-d{1,4}-d{4}b'), "***PHONE***"),
# クレジットカード番号(16桁)
(re.compile(r'b(?:d{4}[-s]?){3}d{4}b'), "***CARD***"),
# 日本のマイナンバー(12桁)
(re.compile(r'bd{4}s?d{4}s?d{4}b'), "***MYNUMBER***"),
]
def redact_pii(text: str) -> str:
"""テキストからPIIを除去して返す"""
for pattern, replacement in PII_PATTERNS:
text = pattern.sub(replacement, text)
return text
def redact_dict(data: dict[str, Any], pii_fields: set[str]) -> dict[str, Any]:
"""
辞書の特定フィールドをPII除去する。
pii_fields で指定したキーの値を自動的にマスク。
"""
result = {}
for key, value in data.items():
if key in pii_fields:
result[key] = "***redacted***"
elif isinstance(value, str):
result[key] = redact_pii(value)
elif isinstance(value, dict):
result[key] = redact_dict(value, pii_fields)
else:
result[key] = value
return result
# 使用例
user_message = "山田太郎です。メールはtaro@example.comです。"
safe_message = redact_pii(user_message)
# → "山田太郎です。メールは***EMAIL***です。"
ポイント: 氏名の除去は難しく(「山田太郎」は正規表現で確実に検出できない)、LLMベースのPII検出器を組み合わせるのが現実的です。2026年時点では Tonic Textual や AegisGate などのツールが使えます。
Pythonでの構造化ログ実装
# agent_logger.py — AIエージェント用構造化ロガー
# 注意: 本番環境で使用する前に、必ずテスト環境で動作確認してください。
import json
import logging
import time
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
class AgentLogger:
"""AIエージェント用の構造化ロガー(JSONLines出力)"""
PII_FIELDS = {"email", "phone", "credit_card", "user_name", "address"}
def __init__(self, log_dir: str = "logs", agent_name: str = "agent"):
self.agent_name = agent_name
log_path = Path(log_dir) / f"{agent_name}.jsonl"
log_path.parent.mkdir(parents=True, exist_ok=True)
# ローテーションログハンドラ(1日ごと、7世代保持)
from logging.handlers import TimedRotatingFileHandler
handler = TimedRotatingFileHandler(
log_path, when="midnight", backupCount=7, encoding="utf-8"
)
handler.setFormatter(logging.Formatter("%(message)s"))
self._logger = logging.getLogger(agent_name)
self._logger.addHandler(handler)
self._logger.setLevel(logging.DEBUG)
def _log(self, level: str, event: str, session_id: str, **kwargs: Any) -> None:
"""JSONLinesフォーマットでログを出力"""
record = {
"timestamp": datetime.now(timezone.utc).isoformat(),
"level": level,
"agent": self.agent_name,
"session_id": session_id,
"event": event,
**kwargs,
}
self._logger.info(json.dumps(record, ensure_ascii=False))
def session_start(self, session_id: str, user_id: str) -> None:
# user_idはハッシュ化 or "u-***" でマスク
self._log("INFO", "session_start", session_id,
user_id=f"u-{user_id[:8]}***")
def llm_call(self, session_id: str, model: str,
tokens_in: int, tokens_out: int, latency_ms: int) -> None:
self._log("DEBUG", "llm_call", session_id,
model=model, tokens_in=tokens_in,
tokens_out=tokens_out, latency_ms=latency_ms)
def tool_call(self, session_id: str, tool_name: str,
success: bool, duration_ms: int) -> None:
self._log("INFO", "tool_call", session_id,
tool_name=tool_name, success=success, duration_ms=duration_ms)
def error(self, session_id: str, error_type: str, message: str) -> None:
self._log("ERROR", "error", session_id,
error_type=error_type,
message=redact_pii(message)) # エラーメッセージもPII除去
def session_end(self, session_id: str, total_tokens: int,
cost_usd: float, duration_ms: int) -> None:
self._log("INFO", "session_end", session_id,
total_tokens=total_tokens,
cost_usd=round(cost_usd, 6),
duration_ms=duration_ms)
OpenTelemetryとの連携
ログだけでなく、トレーシングとメトリクスも組み合わせると、エージェントの観測性が格段に上がります。2026年の標準は OpenTelemetry (OTel) です。
# otel_setup.py — OpenTelemetryとの統合
# 注意: 本番環境で使用する前に、必ずテスト環境で動作確認してください。
# pip install opentelemetry-sdk opentelemetry-exporter-otlp
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
# トレーサーの初期化
provider = TracerProvider()
exporter = OTLPSpanExporter(endpoint="http://otel-collector:4317")
provider.add_span_processor(BatchSpanProcessor(exporter))
trace.set_tracer_provider(provider)
tracer = trace.get_tracer("ai-agent")
# エージェントの実行をスパンで囲む
def run_agent_with_tracing(session_id: str, user_input: str):
with tracer.start_as_current_span("agent_session") as span:
span.set_attribute("session.id", session_id)
# PII含む user_input はスパンに直接セットしない
span.set_attribute("input.length", len(user_input))
with tracer.start_as_current_span("llm_call") as llm_span:
# LLM呼び出し処理
llm_span.set_attribute("model", "claude-sonnet-4-6")
# ... 実際のLLM呼び出し
OTelのトレースデータをGrafana Tempoで可視化すると、エージェントの処理フローをウォーターフォールチャートで見られます。「どのツール呼び出しが遅いか」の特定が一目瞭然になります。OpenTelemetryを含むエージェントの観測性全般については、AIエージェント観測性ガイド(OpenTelemetry・ロギング・トレーシング)も参照してください。
私が考えるログ設計の3原則
正直に言うと、完璧なログ設計を最初から構築しようとすると失敗します。私が支援してきた現場で学んだ原則を共有します。
原則1: 最初は「書きすぎ」でいい、ただしPII除去だけは妥協しない
ログの量は後から絞れます。しかし個人情報を一度ログに書き込んでしまったら、GDPRや個人情報保護法に基づく削除要求への対応コストは甚大です。PIIフィールドの定義とredact関数だけは初日に実装しましょう。
原則2: 検索可能な形で設計する
「この session_id の全ログを時系列で追う」「エラーが起きたツール呼び出しを全部出す」という検索ができないログは価値が半減します。session_id / event / level の3フィールドを必ず持たせることで、jqやElasticsearchでの検索が現実的になります。
原則3: TRACEレベルを活用して本番では切る
LLMの入出力全文が見られるTRACEログは、デバッグに絶大な効果があります。ただし本番では必ず無効にする。コスト(ストレージ)とセキュリティリスクを考えれば当然の判断です。環境変数で制御できる設計にしておきましょう。
まだ明らかになっていない部分
正直なところ、AIエージェントのログ設計には現時点でベストプラクティスが固まっていない領域があります。マルチエージェント(複数エージェントが連携する)システムでの distributed tracing 設計はその一例です。W3C Trace Context の仕様はありますが、エージェントフレームワーク側のサポートはまだ成熟していません。この点は今後も変化を追っていく予定です。
参考・出典
- How to redact sensitive / PII data in your logs — OpenObserve Blog(参照日: 2026-04-11)
- How to Redact Sensitive Data from Logs in the OpenTelemetry Pipeline — OneUptime(参照日: 2026-04-11)
- Announcing the Tonic Textual MCP server: PII redaction meets AI agents — Security Boulevard(参照日: 2026-04-11)
- PII Redaction for Voice Agent Transcripts — Hamming AI(参照日: 2026-04-11)
AIエージェントのログ設計・観測性の整備でお困りのことがあれば、Uravationのお問い合わせフォームからご相談ください。
この記事はAIgent Lab編集部がお届けしました。