LLMに問い合わせる前に、入力文に含まれる個人情報(PII: Personally Identifiable Information)を検出し、仮の識別子へ置き換える方法を試しました。
結論としては、LLMへ送る直前にPIIをマスキングし、LLMから返ってきた結果を必要に応じて元の値へ戻すゲートウェイを設けるのが扱いやすいです。
例えば、次のような入力をそのまま外部LLMへ送るのは避けたいケースがあります。
山田太郎さんの連絡先は taro.yamada@example.com、
電話番号は 090-1234-5678 です。
これをLLMへ送る前に、次のように変換します。
<PII_NAME_001>さんの連絡先は <PII_EMAIL_001>、
電話番号は <PII_PHONE_001> です。
この記事では、まずは依存ライブラリを増やさず、Pythonの正規表現と一時的な対応表で実装します。
1. マスキングの基本設計
PIIの検出からLLMへの送信、必要に応じた復元までの流れは次の通りです。
今回の処理は、次の4段階に分けます。
- 入力テキストからPIIを検出する
- PIIをプレースホルダーへ置き換える
- マスキング済みテキストだけをLLMへ送る
- 必要な場合だけレスポンス内のプレースホルダーを復元する
重要なのは、元の値とプレースホルダーの対応表をLLMへ渡さないことです。対応表はアプリケーション側のメモリや、アクセス制御された一時ストレージで保持します。
また、マスキングは「暗号化」ではありません。LLMに送らないことで漏えいリスクを下げる仕組みです。アプリケーション側に残る対応表の保護も必要になります。
2. PythonでPIIを検出して置き換える
まず、検証用のプロジェクトを作成します。
python -m venv .venv
.venv\Scripts\activate
python -m pip install requests
次のコードでは、メールアドレス、電話番号、マイナンバー形式の数字列を対象にします。
# pii_gateway.py
import hashlib
import hmac
import os
import re
from typing import Dict, Tuple
PII_PATTERN = re.compile(
r"(?P<email>[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,})"
r"|(?P<my_number>\d{4}[- ]?\d{4}[- ]?\d{4})"
r"|(?P<phone>0\d{1,4}[- ]?\d{1,4}[- ]?\d{4})"
)
TOKEN_PATTERN = re.compile(
r"<PII_(?:EMAIL|MY_NUMBER|PHONE)_[0-9a-f]{12}>"
)
def create_token(kind: str, value: str, secret: bytes) -> str:
digest = hmac.new(
secret,
f"{kind}:{value}".encode("utf-8"),
hashlib.sha256,
).hexdigest()[:12]
return f"<PII_{kind.upper()}_{digest}>"
def mask_pii(text: str) -> Tuple[str, Dict[str, str]]:
# リクエストごとに生成し、処理終了後に破棄する想定
secret = os.urandom(32)
vault: Dict[str, str] = {}
def replace(match: re.Match[str]) -> str:
kind = match.lastgroup or "unknown"
value = match.group(0)
token = create_token(kind, value, secret)
vault[token] = value
return token
masked_text = PII_PATTERN.sub(replace, text)
return masked_text, vault
def restore_pii(text: str, vault: Dict[str, str]) -> str:
def replace(match: re.Match[str]) -> str:
# 対応表にあるトークンだけを復元する
return vault.get(match.group(0), match.group(0))
return TOKEN_PATTERN.sub(replace, text)
if __name__ == "__main__":
source = (
"山田太郎さんの連絡先は "
"taro.yamada@example.com、電話番号は 090-1234-5678 です。"
)
masked, vault = mask_pii(source)
print("masked:", masked)
print("vault:", vault)
# LLMから返った文章を想定
llm_result = f"確認しました。{masked}"
restored = restore_pii(llm_result, vault)
print("restored:", restored)
実行します。
python pii_gateway.py
出力例は次のようになります。
masked: 山田太郎さんの連絡先は <PII_EMAIL_...>、電話番号は <PII_PHONE_...> です。
restored: 確認しました。山田太郎さんの連絡先は taro.yamada@example.com、電話番号は 090-1234-5678 です。
トークンにはHMACのダイジェストを使っています。単純に連番だけにするより、推測されにくく、同じリクエスト内で同じ値を扱いやすい設計です。
ただし、今回のコードでは氏名を自動検出していません。日本語の氏名は一般的な単語や会社名と区別しにくく、正規表現だけで高精度に判定するのは難しいためです。
3. マスキング済みの内容だけをLLMへ送る
次に、LLM APIを呼び出す部分を追加します。以下はOpenAI互換のローカルエンドポイントを想定した例です。
import requests
def ask_llm(masked_text: str) -> str:
payload = {
"model": "your-model",
"messages": [
{
"role": "system",
"content": (
"回答中の <PII_...> 形式のトークンは変更しないでください。"
),
},
{
"role": "user",
"content": masked_text,
},
],
"temperature": 0,
}
response = requests.post(
"http://localhost:8000/v1/chat/completions",
json=payload,
timeout=30,
)
response.raise_for_status()
data = response.json()
return data["choices"][0]["message"]["content"]
呼び出し側は次のようになります。
source = "taro.yamada@example.comへのメール文面を作成してください。"
masked, vault = mask_pii(source)
try:
result = ask_llm(masked)
safe_result = restore_pii(result, vault)
print(safe_result)
finally:
# 実運用ではログにも出さず、処理終了時に破棄する
vault.clear()
LLMへ渡るのは、次のような文字列だけです。
<PII_EMAIL_abcdef123456>へのメール文面を作成してください。
ここで気を付けたいのが、LLMに「トークンを変更しない」と明示することです。モデルがトークンを言い換えたり、記号を削除したりすると、復元できなくなります。
復元できなかったトークンは、そのまま残す設計にしました。対応表に存在しないトークンを勝手に復元する処理は、別の値を誤って置換する危険があるためです。
4. 実運用で追加すべき対策
正規表現だけのPII検出には限界があります。特に次のような情報は、別の仕組みと組み合わせる必要があります。
- 氏名
- 住所
- 所属組織
- 顧客番号
- 病院名や診療情報
- 自由記述内の固有名詞
入力項目が決まっている場合は、検出に頼るより、最初からフィールド単位で送信を制御する方が安全です。例えば氏名欄、住所欄、備考欄を分け、LLMへ送る項目を明示的に選択します。
ログにも注意が必要です。次のようなログは避けます。
# 避ける
logger.info("source=%s masked=%s vault=%s", source, masked, vault)
最低限、イベントと処理件数だけを記録します。
logger.info(
"pii_masking_completed",
extra={"detected_types": list(vault.keys())}
)
実際にはキー名に元の値を含めないよう、種別だけを別途記録する方が安全です。
また、次の項目も確認しました。
- 対応表に有効期限を設定する
- リクエスト単位で対応表を分離する
- エラー時に元の入力を再送しない
- 外部LLMへの送信先を許可リストで制限する
- 通信をTLSで保護する
- PIIを含むプロンプトやレスポンスを監視ログへ保存しない
- マスキング漏れを検出したら送信を中止する
マスキング処理でエラーが起きた場合は、便利さより安全性を優先して「送信しない」方針にします。これをフェイルクローズと呼びます。
5. 検証時に確認したポイント
手元では、次の入力を使って動作を確認しました。
連絡先:
email: taro.yamada@example.com
phone: 090-1234-5678
個人番号: 1234-5678-9012
確認したのは、次の3点です。
- LLMへ送る直前の文字列に元の値が残っていないこと
- 同じレスポンス内の複数トークンを正しく復元できること
- 対応表にないトークンが勝手に置換されないこと
特に1点目は、ユニットテストだけでなく、実際のHTTPリクエスト直前に検査するのがおすすめです。
masked, vault = mask_pii(source)
for original in vault.values():
assert original not in masked
result = ask_llm(masked)
for token in TOKEN_PATTERN.findall(result):
assert token in vault
ただし、正規表現による検出を追加したからといって、PIIを完全に取り除けるわけではありません。高い安全性が必要なシステムでは、検出器、ルールベースの入力制御、人手レビュー、送信先の契約や設定を組み合わせて評価する必要があります。
まとめ
LLMへPIIを送信したくない場合は、アプリケーションとLLMの間にマスキング層を置くと、導入しやすく制御もしやすくなります。
今回の実装では、次の流れを作りました。
- 入力からPIIを検出する
- リクエスト内だけ有効なトークンへ置き換える
- マスキング済みの文章をLLMへ送る
- 必要な場合だけアプリケーション側で復元する
- 対応表とログを厳格に管理する
まずは対象データを限定し、送信直前の文字列をテストで検査するところから始めるのが現実的です。検出漏れを前提に、送らない項目を明示する設計も併用すると、より安全なLLM連携にできます。