「ユーザーが入力した質問文をそのまま Gemini API に投げていますが、個人情報が含まれていたらどうしましょう」— BtoB SaaS の開発を進めている方から、この相談を立て続けにいただきました。法務レビューや SOC2 の監査が近づいてくると、現場のエンジニアが急に PII(Personally Identifiable Information)対応を求められる、というのはよくある話です。
私自身、自社のチャットボットで使用ログを見返したときに、メールアドレスや電話番号がプレーンテキストで保存されていたのを見つけて青ざめた経験があります。Gemini API そのものはエンタープライズ対応が進んでおり、Vertex AI 経由なら学習に使われない契約も結べます。けれど「学習されない」と「ログに残らない」「漏れない」は別の話です。ここではGemini API を使った本番システムで、個人情報を検出してマスキングし、監査可能なログ設計まで含めて運用するための実装と判断軸を、現場で揉まれた形でまとめておきます。
なぜ Gemini API への入力で PII リスクが見落とされやすいのか
LLM 連携の機能を作るとき、最初に書くコードはたいてい「ユーザー入力を contents フィールドにそのまま渡す」というものです。プロトタイプの段階では何の問題もありません。問題は、本番化のフェーズで「とりあえず動いている」ものに後付けで安全策を入れる順序を間違えること、そしてそもそも PII リスクが発生する経路が複数ある、という事実です。
経路は大きく3つあります。1つ目は「ユーザー入力からモデル本体への送信」。2つ目は「ログ・トレース・APM への保存」。3つ目は「失敗時のスタックトレースやエラーレポートに含まれてしまう」というもの。多くの現場で、1つ目だけ対策して 2 と 3 が漏れています。Vertex AI のデータポリシーで安全だと安心していても、自社のロギング基盤や Sentry にプレーンテキストで残ってしまえば、それは「自社が持つ個人情報」として個情法の対象になります。
私の経験では、システム障害の切り分けで CloudWatch Logs を全文検索したときに、フリーテキスト入力に紛れ込んでいたマイナンバーが見つかった、というのが一番ヒヤリとした瞬間でした。検索可能な状態で個人情報が長期保管されていると、内部不正の温床にもなります。だからこそ「LLM に渡す前」と「ログに残す前」の二段構えで、検出とマスキングを実装しておく必要があります。
PII マスキングの3層設計 — 速度・精度・コストのバランスを取る
実装上、PII 検出のアプローチは大きく3つに分かれます。それぞれ得意領域が異なるので、ユースケースで使い分けるのが現実解です。
第一層は「正規表現ベースの軽量検出」。メールアドレス・電話番号・クレジットカード番号・郵便番号・マイナンバーのように、フォーマットが明確に決まっている PII に対しては、正規表現の方が速くて確実です。レイテンシも 1ms 以下で、Gemini API のリクエストパスに挟んでも体感に影響しません。
第二層は「Microsoft Presidio による NER ベース検出」。氏名・住所・組織名のように文脈依存の PII は、固有表現抽出が必要になります。Presidio は Spacy ベースで日本語にも対応しており、自社内に立てて使う場合のコスト面でも現実的です。レイテンシは 50〜200ms 程度なので、ユーザーが待てる範囲ではあるものの、ストリーミング応答との兼ね合いは設計が必要です。
第三層は「Google Cloud DLP API による高精度検出」。Vertex AI と同じプロジェクトに DLP を立てれば、データ越境の問題なくエンタープライズ要件を満たせます。infoTypes は150種類以上揃っており、医療情報や金融情報のような厳格な PII にも対応できます。コストは 1,000 文字あたりで課金されるため、トラフィックが多い場合は事前に正規表現でフィルタリングしてから DLP に流す、二段構えにするのがコスト最適解です。
私はこのうち、初期段階では正規表現 + Presidio の組み合わせから始めることをおすすめします。「DLP まで導入しないと審査に通らない」と言われたら、後から差し替えやすいインターフェース設計にしておけばよいわけです。具体的なコードを次から見ていきましょう。
実装1:正規表現ベースの軽量マスキングミドルウェア
最初に、Gemini API を呼ぶ直前に挟む薄いマスキング関数を実装します。ここでのポイントは、検出した PII を「マスキングして送る」だけでなく、「どの種類が何件検出されたかをメタデータとして残す」点です。後の監査ログで、どのリクエストが要注意だったかを追えるようにします。
# pii_redactor.py — Gemini API 呼び出し前後で使う軽量 PII マスカ
import re
from dataclasses import dataclass, field
from typing import Pattern
# 日本のフォーマットも含めた基本パターン
_PATTERNS : dict[ str , Pattern[ str ]] = {
"EMAIL" : re.compile( r " [\w \. - ] + @ [\w \. - ] + \. \w + " ),
"PHONE_JP" : re.compile( r "0 \d {1,4} - \d {1,4} - \d {3,4} " ),
"CREDIT_CARD" : re.compile( r " \b(?:\d[ - ] ? ) {13,16} \b " ),
"JP_POSTAL" : re.compile( r " \b\d {3} - \d {4} \b " ),
"JP_MYNUMBER" : re.compile( r " \b\d {4} \s ? \d {4} \s ? \d {4} \b " ),
"IP_V4" : re.compile( r " \b(?:\d {1,3} \. ) {3} \d {1,3} \b " ),
}
@dataclass
class RedactionResult :
redacted_text: str
detections: dict[ str , int ] = field( default_factory = dict )
def redact (text: str ) -> RedactionResult:
detections: dict[ str , int ] = {}
redacted = text
for label, pattern in _PATTERNS .items():
matches = pattern.findall(redacted)
if matches:
detections[label] = len (matches)
redacted = pattern.sub( f "[REDACTED: { label } ]" , redacted)
return RedactionResult( redacted_text = redacted, detections = detections)
if __name__ == "__main__" :
sample = "お問い合わせは support@example.com、または 03-1234-5678 までご連絡ください。"
result = redact(sample)
print (result.redacted_text)
print (result.detections)
# → お問い合わせは [REDACTED:EMAIL]、または [REDACTED:PHONE_JP] までご連絡ください。
# → {'EMAIL': 1, 'PHONE_JP': 1}
この RedactionResult を Gemini API 呼び出しの直前に挟みます。マスキングしたテキストをモデルに渡し、detections を後段の監査ログに記録するわけです。なぜ detections を別フィールドで残すかというと、本番で「どのテナントが PII を多く含む入力を送ってくるか」を可視化できるようにするためです。利用パターンの理解と、社内向けのアラートにも繋がります。
# gemini_with_redaction.py
import os
from google import genai
from pii_redactor import redact
client = genai.Client( api_key = os.environ[ "GEMINI_API_KEY" ])
def safe_generate (user_input: str , * , audit_logger) -> str :
redaction = redact(user_input)
audit_logger.info(
"gemini_request" ,
extra = {
"pii_detections" : redaction.detections,
"redacted_chars" : len (redaction.redacted_text),
},
)
response = client.models.generate_content(
model = "gemini-2.5-pro" ,
contents = redaction.redacted_text,
)
return response.text
ここまでで、Gemini に「素のテキスト」が渡る経路は塞がりました。期待動作としては、safe_generate("私の番号は 090-1234-5678 です") を呼ぶと、API には 私の番号は [REDACTED:PHONE_JP] です が送られ、ログには pii_detections={"PHONE_JP": 1} が記録されます。
実装2:Microsoft Presidio で人名・住所・組織名にも対応する
正規表現では拾えない、文脈依存の PII を扱うには Presidio を組み合わせます。Presidio は Microsoft がオープンソースで公開している PII 検出フレームワークで、内部で Spacy を使って固有表現抽出を行います。日本語の固有表現には ja_core_news_lg モデルを使うのが一般的です。
# presidio_redactor.py
from presidio_analyzer import AnalyzerEngine
from presidio_analyzer.nlp_engine import NlpEngineProvider
from presidio_anonymizer import AnonymizerEngine
_PROVIDER = NlpEngineProvider( nlp_configuration = {
"nlp_engine_name" : "spacy" ,
"models" : [{ "lang_code" : "ja" , "model_name" : "ja_core_news_lg" }],
})
_ANALYZER = AnalyzerEngine( nlp_engine = _PROVIDER .create_engine(), supported_languages = [ "ja" ])
_ANONYMIZER = AnonymizerEngine()
def redact_with_presidio (text: str ) -> str :
results = _ANALYZER .analyze(
text = text,
language = "ja" ,
entities = [ "PERSON" , "LOCATION" , "ORGANIZATION" , "PHONE_NUMBER" , "EMAIL_ADDRESS" ],
score_threshold = 0.4 ,
)
anonymized = _ANONYMIZER .anonymize( text = text, analyzer_results = results)
return anonymized.text
if __name__ == "__main__" :
text = "山田太郎さんは東京都渋谷区で株式会社サンプルに勤務しています。"
print (redact_with_presidio(text))
# → <PERSON>さんは<LOCATION>で<ORGANIZATION>に勤務しています。
score_threshold は false positive を抑えるためのつまみです。0.4 から始めて、運用しながら誤検知ログを見て調整するのが現実的です。私は最初 0.7 で始めて、人名の検出漏れが多すぎて 0.4 まで下げた、という調整を経験しました。「Presidio をデフォルト設定で動かして満足する」のは危険で、自社のドメイン語彙に合わせたチューニングが必須です。
ここで重要な判断軸が一つあります。「Presidio を Gemini リクエストパスに常時挟むか、それともサンプリングして監視のみに使うか」です。レイテンシが 100〜200ms 増えるのを許容できないチャットボットでは、リクエストパスは正規表現のみにし、Presidio は別経路でサンプリング監査だけする、という設計もあります。可用性とコンプライアンスのバランスは、扱うデータの機微度合いで判断するしかありません。
実装3:Google Cloud DLP API でエンタープライズ要件に応える
法務レビューで「DLP API を使っていなければ通せない」と言われたら、Cloud DLP に切り替えます。Vertex AI と同じ Google Cloud 上で完結するため、データ越境の懸念がなく、医療情報や金融情報といった厳格な PII にも infoTypes ベースで対応できます。
# cloud_dlp_redactor.py
from google.cloud import dlp_v2
_DLP = dlp_v2.DlpServiceClient()
_PROJECT = "your-gcp-project-id"
# 検出対象 — JAPAN_BANK_ACCOUNT, JAPAN_INDIVIDUAL_NUMBER 等の日本固有 infoType も使える
_INFO_TYPES = [
{ "name" : "EMAIL_ADDRESS" },
{ "name" : "PHONE_NUMBER" },
{ "name" : "CREDIT_CARD_NUMBER" },
{ "name" : "JAPAN_INDIVIDUAL_NUMBER" },
{ "name" : "PERSON_NAME" },
]
def redact_with_dlp (text: str ) -> str :
parent = f "projects/ { _PROJECT } /locations/global"
item = { "value" : text}
inspect_config = { "info_types" : _INFO_TYPES , "min_likelihood" : dlp_v2.Likelihood. POSSIBLE }
deidentify_config = {
"info_type_transformations" : {
"transformations" : [{
"primitive_transformation" : {
"replace_with_info_type_config" : {}
}
}]
}
}
response = _DLP .deidentify_content(
request = {
"parent" : parent,
"deidentify_config" : deidentify_config,
"inspect_config" : inspect_config,
"item" : item,
}
)
return response.item.value
if __name__ == "__main__" :
text = "山田太郎、メールは taro@example.com、マイナンバーは 1234 5678 9012 です。"
print (redact_with_dlp(text))
# → [PERSON_NAME]、メールは [EMAIL_ADDRESS]、マイナンバーは [JAPAN_INDIVIDUAL_NUMBER] です。
DLP API の課金は 1,000 文字あたりで発生するので、トラフィックが多い場合は前段に正規表現フィルタを挟みます。「正規表現で1件以上検出されたら DLP に回す」「全件 DLP に回さずサンプリングする」など、コスト最適化の選択肢は複数あります。私は実運用では、無料枠(月 1GB)の範囲で動かせる量にチューニングしてから本格採用を判断する、という順序で進めるのを好みます。
「どれを組み合わせるか」の判断軸
「結局どれを使えばいいですか」という質問をよくいただきますが、これは多くの場合「どれか一つ」を選ぶ問いではありません。私がチームの設計を手伝うとき、次の観点を順番に確認しています。
第一に、自社のトラフィックに実際どんな PII が混ざっているか。サンドボックスで直近 1 週間分のログを慎重に取り出し、カテゴリ別に件数を数えてみてください。検出の 95% がメールアドレスと電話番号なら、正規表現だけで十分です。人名や住所が多いなら Presidio が要ります。医療・金融なら、自前では現実的に書けない infoType(医療記録番号・各国固有 ID 等)が必要になるため、DLP がほぼ必須になります。
第二に、レイテンシの予算です。エンドツーエンドの P95 を 1.5 秒に収めたいリアルタイムチャットでは、同期的に 200ms を足す Presidio をリクエストパスには置けません。バッチパイプラインならレイテンシは問題にならず、論点はコストへ移ります。
第三に、どの規制が拘束力を持つか。BtoB SaaS なら正規表現 + Presidio で通せる監査もあります。一方で、医療・行政系の監査では DLP 相当のエンタープライズサービス以外を一切認めないこともあります。作り始める前に確認しておくべき点です。
第四に、false positive をユーザーがどこまで許容できるか。人名検出を強めると、製品名や地名を誤ってマスキングして UX を損ねます。弱めれば実在の人名を取りこぼします。唯一の正解はなく、ユーザーが自分で書いたテキストを送るのか(リスク大)、整備済みのソースから引くのか(リスク小)で変わります。
実運用では、私はほぼ必ず層を組み合わせる形に落ち着きます。速度のために正規表現をリクエストパスに、文脈依存の PII にはサンプリングワーカーで Presidio を、決済・本人確認・医療といった最も機微な経路にだけ DLP を充てる、という配分です。これを「一つの選択」と捉えてしまうと、DLP を全経路に入れて請求額が膨らむ過剰設計か、正規表現だけで人名を取りこぼす過少設計か、どちらかに振れてしまいます。
ログ・トレース・エラーレポートにも PII を漏らさない設計
ここまでで Gemini API 本体への流出は塞げましたが、ロギング・トレーシング側にも同じ仕組みを適用しないと、結局 PII は社内システムに残ります。よくあるアンチパターンは、Sentry や DataDog APM が「リクエストボディ全文」を自動収集する設定で、そこにマスキング前のテキストが含まれてしまうことです。
対策は3つ。1つは、APM のリクエストボディキャプチャを明示的にオフにすること。Sentry なら before_send フックで event["request"]["data"] を削除します。2つ目は、ログフォーマッタにマスキング処理を埋め込むこと。Python なら logging.Filter を継承し、メッセージを正規表現で再マスキングするのが堅実です。3つ目は、OpenTelemetry のスパン属性に raw 入力を入れない運用ルールを徹底することです。トレースの設計については、別記事のGemini API を OpenTelemetry でトレースする本番運用ガイド で詳しく扱っているので併せてご覧ください。
# logging_filter.py — ログ出力時にもう一度 PII を再マスキングする保険
import logging
from pii_redactor import redact
class PIIRedactingFilter ( logging . Filter ):
def filter (self, record: logging.LogRecord) -> bool :
if isinstance (record.msg, str ):
record.msg = redact(record.msg).redacted_text
if record.args:
try :
redacted_args = tuple (
redact(a).redacted_text if isinstance (a, str ) else a
for a in record.args
)
record.args = redacted_args
except Exception :
pass
return True
このフィルタを root logger に付けておくと、開発者が logger.info("user said: %s", user_input) のような書き方をしても、最終出力時にもう一度マスキングがかかります。「全員が常にマスキングを意識する」より「漏れても最後の砦で塞がる」設計の方が、人為的ミスに強いと私は考えています。
ロールベースの可逆/不可逆マスキングを使い分ける
ここから先は、運用設計の話です。ユーザー向けのプロダクトでは「マスキングは不可逆」が原則ですが、社内オペレーション・サポート対応のためにマスキングしたテキストを後から復号したいケースがあります。たとえば「クレジットカード番号は完全に伏せたいが、メールアドレスはサポート担当が確認できるようにしたい」というケースです。
このときは、infoType ごとに「マスキング戦略」を分けます。クレジットカード番号は不可逆ハッシュ(SHA-256 + ソルト)、メールアドレスは KMS で暗号化したうえでフォーマット保持型暗号化(FPE)、人名は削除、というように使い分けると、運用要件と漏洩リスクの両立がしやすくなります。Cloud DLP には crypto_deterministic_config という、復号可能な決定論的暗号化機能がありますが、その鍵管理が新たな攻撃面になることは忘れないでください。
私は実運用では「迷ったら不可逆」を優先しています。「あとで戻せるかも」という設計判断は、ほぼ必ず数年後に「結局戻す経路を作らずに、暗号化された無意味なデータが残った」結果を生みます。マルチテナント SaaS で、テナントごとに鍵を分けたい場合は、本サイトのマルチテナント SaaS で Gemini API を安全に提供する設計 も参考にしていただければと思います。
よくある間違いと落とし穴 — 私が現場で踏んだ4つ
1つ目は「正規表現で \d{16} だけでクレジットカード番号を拾えると思ってしまう」こと。実際には Luhn チェックを通さないと、商品コードや注文番号と区別できません。マスキング過剰で UX を壊すこともあります。Presidio や DLP は Luhn チェックを内蔵しているので、そちらに任せるのが安全です。
2つ目は「ストリーミング応答のチャンクごとにマスキングをかけ忘れる」こと。generate_content_stream を使っていると、応答テキストにユーザーの個人情報がオウム返しされる場合があり、その断片がログに保存されます。応答側のマスキングも、入力側と同じパイプラインを通す設計にしておきましょう。
3つ目は「LLM の判断にマスキング処理を委ねる」こと。「個人情報が含まれていたら除いて要約してください」というプロンプトで PII 対策とする実装を見たことがありますが、これはモデルの出力品質に依存するため監査に通りません。検出は決定論的な処理(正規表現 + DLP)で行うのが原則です。
4つ目は「Few-shot 例文に実データを含めてしまう」こと。プロンプトテンプレートに「お客様の例:山田太郎さん(東京都)...」のような実例を入れている実装は、本番投入前に必ず洗い直してください。テンプレート自体に PII が固定で埋め込まれていると、毎リクエストで同じ個人情報がモデルに送られ続けることになります。
監査ログを「使えるもの」にする3つの観点
監査ログは作っただけでは意味がなく、有事に検索・追跡できる状態を保って初めて価値があります。3つの観点で設計を見直してみてください。
第一に、ログには「誰が」「どのテナントとして」「どの infoType の PII が」「何件マスキングされたか」「マスキング前の文字数」が含まれている必要があります。本文は当然残しません。これがあれば、ある時点で個人情報を含む入力が急増した、といった異常検知ができます。
第二に、ログ自体の保管期間と削除運用です。個情法では「利用目的の達成に必要な範囲で」となっているので、目的を明確にしないと長期保管は説明できません。私は監査ログを 90 日で自動削除し、集計済みのメトリクスのみを 1 年保持する運用にしています。
第三に、可観測性プラットフォームとの統合です。Langfuse のような LLM 専用の可観測性ツールを使うと、PII 検出件数を可視化しやすくなります。実装の詳細はGemini API × Langfuse で構築する LLM 可観測性の本番運用ガイド に譲りますが、要点だけ言えば、Langfuse のメタデータフィールドに pii_detections を載せておくと、後からダッシュボードで眺められて運用が楽になります。
LLM に閉じない、Web アプリ全体のセキュリティ視点を持っておくと、PII 設計の判断にも厚みが出ます。
レイテンシのコストを実測する — 推測で語らない
「これだけマスキングを挟んだらチャットが重くなりませんか」という指摘をよくいただきます。誠実な答えは「測ってください」です。gemini-2.5-pro の呼び出しでモデル本体が 800〜1,500ms かかる前提で、私自身の本番ログから取った数値を挙げておきます。
正規表現のみのマスキング(上記の redact())は、数千文字の入力でも 0.2〜0.8ms。実質ゼロです。Presidio(ja_core_news_lg)は初回 70〜180ms、ウォーム後 40〜90ms。CPU バウンドなので、スレッドではなくワーカー数で並列化します。Cloud DLP API はリージョンとペイロード次第で 80〜250ms、ネットワークの往復が支配的です。ログフィルタの再マスキングは 1 レコードあたり約 0.1ms に収まります。
モデル自身のレイテンシと比べれば、正規表現とログフィルタは誤差の範囲です。Presidio と DLP は実コストになるため、レイテンシ予算が厳しいときはリクエストパスから外すべきだと私は考えています。正規表現を同期実行し、リクエストをサンプリングワーカーに積んで Presidio や DLP を非同期で走らせ、監査イベントだけ後から発行する。これでユーザー体感のレイテンシは変えずに、コンプライアンス報告に必要な文脈依存 PII も検出できます。
Presidio をリクエストパスに置く場合の実務的なコツが二つあります。Spacy モデルはプロセス起動時に一度だけロードすること。そして小さな入力では GPU のウォームアップが支配的になるため、大量バッチでない限り CPU 推論にすること。サーバーレス環境のコールドスタートでモデルダウンロードの起動コストを払わずに済むよう、Spacy モデルはコンテナイメージに焼き込んでおきます。
既存システムへ段階的に導入する
ゼロから作るなら簡単です。けれど本記事を読んでいる多くの方は、すでに数ヶ月分のマスキング前リクエストがログに溜まった、稼働中の Gemini 連携プロダクトを抱えているはずです。ユーザー体験を壊さずに移行するには、4 つのフェーズを踏みます。
第一フェーズは、挙動を変える前に計測することです。リダクターを「ドライラン」で投入します。検出だけ行ってログに残し、Gemini に送るテキストは変えません。これを最低 1 週間回すと、自社トラフィックの PII 実出現率がわかります。たいていは、チームの誰の想像よりも高い数字が出ます。その実データを根拠に、関係者とスケジュールを交渉できます。
第二フェーズは、マスキングをフラグ付きで有効化することです。Gemini 呼び出しをフィーチャーフラグで包み、まず社内ユーザー、次に 1% のカナリアへと広げ、エラー率と利用者側のフィードバックを見ます。私が最も多く見た劣化は、応答品質のドリフトでした。人名や住所がマスキングされると、モデルの応答が文脈の一貫性を失うことがあります。これは全展開ではなくカナリアの段階で捕まえます。
第三フェーズは、過去ログへの監査フィールドのバックフィルです。ここが一番つらい工程です。pii_detections メタデータを持たない数ヶ月分のログがあると、導入前後の比較ができません。直近ログを読んでリダクターを通し、メタデータを別系統の監査ストリームへ吐き出す使い捨てジョブを書きます。元テキストは二度と保存せず、カテゴリ件数だけ数えます。監査担当は「導入前」「導入後」のテレメトリを並べて見られることを高く評価します。
第四フェーズは、残すべきでなかったデータの削除です。新パイプラインが安定したら、マスキング前 PII を含む古いログへ削除パスを走らせます。この削除を一度きりの是正措置として、インシデントやコンプライアンスのログに記録します。「ギャップを特定し、是正し、記録した」と示せると、指摘事項がクローズ済み項目に変わります。
私はこのロールアウトを、個人開発で運用しているアプリのバックエンドを含む 3 つのプロダクトで通してきました。毎回ずれ込むのは第三フェーズです。バックフィルジョブは使い捨て作業に見えるからです。けれど、そこで作る監査ログの証跡こそが、半年後に移行全体の正当性を支えてくれます。
リダクターをテストする — 「動いてそう」では足りない
リダクターが最後の砦である以上、ほかのセキュリティ制御と同じだけのテスト厳格さに値します。CI に入れたいテストは 3 種類です。
一つ目は、既知のフィクスチャに対するユニットテストです。各検出器が反応すべき入力と、反応してはいけない否定例のリストを用意します。改行で分断された電話番号、プラスエイリアス付きメール、Luhn を通らないカード風文字列、URL に埋め込まれた人名といった敵対的ケースも含めます。目的は自然言語の 100% 網羅ではなく、誰かが正規表現をいじったときの退行を捕まえることです。
# test_pii_redactor.py
import pytest
from pii_redactor import redact
def test_email_basic ():
assert "[REDACTED:EMAIL]" in redact( "連絡は jane@example.com まで" ).redacted_text
def test_phone_jp_dashed ():
assert redact( "電話は 03-1234-5678" ).detections.get( "PHONE_JP" ) == 1
def test_credit_card_with_spaces ():
text = "カード 4111 1111 1111 1111 の下4桁"
assert "[REDACTED:CREDIT_CARD]" in redact(text).redacted_text
def test_should_not_mask_short_digits ():
# 6 桁の注文番号 — カードとしてマスキングしてはいけない
assert "ORDER-123456" in redact( "ORDER-123456" ).redacted_text
def test_multiple_emails_counted ():
text = "a@x.com と b@y.com に送付"
assert redact(text).detections[ "EMAIL" ] == 2
二つ目は、Hypothesis のようなツールによるプロパティベーステストです。任意の文脈にメールや電話番号風の文字列を生成し、常にマスキングされることを表明します。「一行に 8 個のメールアドレスがあったら」という、人間がまず書かないケースを、プロパティテストは拾ってくれます。
三つ目は、ステージングでの合成トラフィックによるシャドウテストです。匿名化した過去トラフィックをステージングのリダクターに流し、検出件数をベースラインと比較します。Presidio のモデル更新後に PERSON_NAME 検出が突然 10 倍になる、といったドリフトは、本番ではなくここで捕まえたいものです。
私は「PII カナリア」スイートも常備しています。明らかに合成だがフォーマットは本物らしい文字列(canary+pii@gemilab-test.local、0312-CANARY-9999 等)をステージングのトラフィックに種まきしておき、いずれかのログ検索でマスキングされずに現れたらアラートを発火させます。パイプラインが端から端まで正しく繋がっているかを検証する、最も安価な方法です。
法規制が設計をどう左右するか
エンジニアは法令の文言の固さに弾かれがちですが、設計上効いてくる要点はわずかです。
GDPR 第 25 条「データ保護バイデザイン・バイデフォルト」が、これら全ての根拠になります。EU 居住者のデータを扱うなら、LLM の手前にマスキングが無いこと自体が指摘事項です。後で起きるかもしれない漏洩ではなく、無いこと自体が問われます。日本の個情法や各国の法も、同じ姿勢へ寄ってきています。今は国内ユーザーだけでも、GDPR 同等で作っておくと拡大時に効いてきます。
至るところで出てくる「データ最小化」は、最も実装に落ちる原則です。モデルには必要な分だけ送る。プロンプトが「このサポートチケットを要約して」なら、顧客の氏名やメールは送る前に削ぎ落とします。要約に氏名は要りません。これは過敏なのではなく、目的限定の要請に直接応えるものです。
特に SOC2 Type II 監査で見られるのは「証跡」です。文書化されたポリシー、それを強制する自動制御、そして制御が動いていることを示す監査証跡。本記事のアーキテクチャはこの 3 つを満たします。リダクターが制御、pii_detections を含む構造化ログが証跡、それを説明する README がポリシーの成果物です。各要素がなぜ存在するのかを一段落リポジトリに書いておくこと。監査担当は、その段落を必ず読みます。
全体を振り返って — 今日から動かす最初の一歩
PII 対応は「全部入りの完璧な設計」を最初から作ろうとすると、いつまでも本番に出せません。今日できる最初の一歩として、まずは Gemini API を呼び出している関数の直前に、上で示した正規表現ベースの redact() を1行挟んでみてください。それだけで、メールアドレス・電話番号・クレジットカードといった機械的に拾える PII の流出は止められます。
そのうえで、ログ側のフィルタ追加、Presidio の本格導入、DLP API への移行、と段階的に拡張していけばよいわけです。コンプライアンス審査が現実に迫ってきたタイミングで「全部一斉に作ろう」とすると必ず破綻するので、今日のうちに薄い砦を一枚立てておくのを強くおすすめします。Gemini API の安全な本番運用について、もう一段踏み込みたい方はGemini API のセーフティ設定を多層化する本番設計 もあわせてご覧ください。