はじめに
社内システムとLLMをつなごうとすると、モデルへの問い合わせそのものより先に考えたいことがあります。
その文字列を、そのまま外へ渡してよいのか。
たとえば問い合わせ履歴を要約したいとします。本文には相談内容だけでなく、氏名、メールアドレス、電話番号、住所、顧客番号などが混ざっているかもしれません。
私はAIを使った業務の自動化では、「人が決め、AIが下ごしらえする」くらいが扱いやすいと考えています。その前提に立つと、LLMへ何を渡すかもアプリケーション側で制御したくなります。
今回は、LLMのAPIを呼ぶ前段に置ける小さなマスキング処理をPythonで作ります。
持ち帰れるものは、次のような関数です。
result = mask_text(source_text)
llm_client.generate(
prompt=result.text
)
メールアドレス、電話番号、郵便番号、IPv4アドレス、そしてシステム固有の識別子を置換します。
ただし、この記事で一番書きたいのはコードそのものより、「正規表現でマスキングしたから安全」とは言えない理由です。
結論
LLMへ送る直前ではなく、LLMに渡してよいデータを作る境界としてマスキング処理を置きます。
機械的に判定できる情報はコードで除去し、曖昧な個人情報は別の検出方法や送信対象そのものの制限で補います。
マスキングは安全対策の一部であって、完全な匿名化ではありません。
🔧 環境
この記事のサンプルは、以下を前提にしています。
- OS: macOS / Linux
- Python: 3.12
- 外部ライブラリ: なし
- 使用モジュール:
re,dataclasses - LLMサービス: 特定のサービスには依存しない
あえて外部ライブラリを使わず、Python標準ライブラリだけで作ります。
理由は、まず「どこで何を消すのか」という設計を小さく確認したいからです。日本語の人名や住所まで高精度に検出する段階になれば、正規表現だけでは足りません。その話は後半で触れます。
全体の処理は次のようにします。
ポイントは、
社内データ → LLM
と直結させず、
社内データ → 選別 → マスキング → 検査 → LLM
という境界を作ることです。
🛠️ 実装
まず、何をマスキングするか決める
今回のサンプルでは、以下を対象にします。
| 種類 | 入力例 | 置換後 |
|---|---|---|
| メールアドレス | user@example.com |
[EMAIL] |
| 電話番号 | 090-1234-5678 |
[PHONE] |
| 郵便番号 | 123-4567 |
[POSTAL_CODE] |
| IPv4 | 192.0.2.10 |
[IP_ADDRESS] |
| 社内顧客ID | CUSTOMER-123456 |
[CUSTOMER_ID] |
最後の顧客IDが重要です。
個人情報対策というと、メールアドレスや電話番号のような一般的な形式だけを探しがちです。しかし実際の業務データでは、システム固有のIDも人を特定する手掛かりになります。
しかも、こうした値は汎用ライブラリより自分たちのシステムのほうが構造をよく知っています。
そこで、
- 一般的な形式
- 自社システム固有の形式
を分けて考えます。
ルールをデータとして定義する
最初から巨大なre.sub()を作るのではなく、ルールをオブジェクトとして持たせます。
masking.pyを作ります。
from __future__ import annotations
import re
from dataclasses import dataclass
from typing import Pattern
@dataclass(frozen=True)
class MaskRule:
name: str
pattern: Pattern[str]
replacement: str
RULES: tuple[MaskRule, ...] = (
MaskRule(
name="email",
pattern=re.compile(
r"(?<![\w.+-])"
r"[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+"
r"@[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?"
r"(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)+"
r"(?![\w.-])"
),
replacement="[EMAIL]",
),
MaskRule(
name="phone",
pattern=re.compile(
r"(?<!\d)"
r"(?:0\d{1,4}[-ー-]\d{1,4}[-ー-]\d{3,4}"
r"|0[789]0[-ー-]?\d{4}[-ー-]?\d{4})"
r"(?!\d)"
),
replacement="[PHONE]",
),
MaskRule(
name="postal_code",
pattern=re.compile(
r"(?<!\d)\d{3}[-ー-]\d{4}(?!\d)"
),
replacement="[POSTAL_CODE]",
),
MaskRule(
name="ipv4",
pattern=re.compile(
r"(?<!\d)"
r"(?:"
r"(?:25[0-5]|2[0-4]\d|1?\d?\d)\."
r"){3}"
r"(?:25[0-5]|2[0-4]\d|1?\d?\d)"
r"(?!\d)"
),
replacement="[IP_ADDRESS]",
),
MaskRule(
name="customer_id",
pattern=re.compile(
r"\bCUSTOMER-\d{6}\b"
),
replacement="[CUSTOMER_ID]",
),
)
MaskRuleに分けた理由は、正規表現をきれいに見せるためだけではありません。
運用を始めると、
「電話番号は何件消えたか」
「顧客IDのルールが想定以上に反応していないか」
「新しいID形式を追加したい」
といった要求が出てきます。
そのとき、ルールに名前が付いていると扱いやすくなります。
なお、ここに書いた正規表現はRFCや各種番号体系を完全に検証するものではありません。LLM送信前の前処理として、対象システムで想定する形式を検出するための例です。
置換件数も返す
単純な文字列だけでなく、何を何件置換したかも返します。
@dataclass(frozen=True)
class MaskResult:
text: str
counts: dict[str, int]
def mask_text(text: str) -> MaskResult:
masked = text
counts: dict[str, int] = {}
for rule in RULES:
masked, count = rule.pattern.subn(
rule.replacement,
masked,
)
if count > 0:
counts[rule.name] = count
return MaskResult(
text=masked,
counts=counts,
)
Pythonのre.subn()は、置換後の文字列と置換回数を返します。
これを使うと、
source = """
お問い合わせありがとうございます。
連絡先は user@example.com です。
電話番号は 090-1234-5678 です。
顧客番号は CUSTOMER-123456 です。
"""
result = mask_text(source)
print(result.text)
print(result.counts)
出力は次のようになります。
お問い合わせありがとうございます。
連絡先は [EMAIL] です。
電話番号は [PHONE] です。
顧客番号は [CUSTOMER_ID] です。
{
"email": 1,
"phone": 1,
"customer_id": 1,
}
ここで大事なのは、ログに元の個人情報を残さないことです。
logger.info("masked: %s", result.text)
のようにマスキング済み本文まで保存する必要があるかも、一度考えたほうがよいです。
私なら、監視目的ならまず件数だけを残す設計を考えます。
logger.info(
"masking completed counts=%s",
result.counts,
)
マスキング処理を追加したのに、その直前で生データをデバッグログへ書いていたら意味がありません。
LLM呼び出しから分離する
次に意識したいのが、LLMクライアントの中へマスキング処理を書かないことです。
たとえば、こういう形です。
def prepare_prompt(source_text: str) -> str:
result = mask_text(source_text)
return result.text
def summarize(source_text: str, llm_client) -> str:
safe_text = prepare_prompt(source_text)
prompt = f"""
以下の文章を要約してください。
{safe_text}
""".strip()
return llm_client.generate(prompt=prompt)
これならLLMサービスを変更しても、マスキング処理を再利用できます。
さらに重要なのは、テスト時にLLM APIへ接続しなくても前処理だけ確認できることです。
AI関連の処理はモデル部分に目が行きやすいのですが、業務システムでは、
何を取得するか
↓
何を除外するか
↓
何をモデルへ送るか
↓
何を戻すか
という境界のほうが重要になることがあります。
送信前の検査をもう一段入れる
マスキングしたら、そのまま送るのではなく、検出対象が残っていないか検査します。
def find_sensitive_patterns(text: str) -> list[str]:
detected: list[str] = []
for rule in RULES:
if rule.pattern.search(text):
detected.append(rule.name)
return detected
def prepare_prompt(source_text: str) -> str:
result = mask_text(source_text)
remaining = find_sensitive_patterns(result.text)
if remaining:
raise ValueError(
f"sensitive patterns remain: {remaining}"
)
return result.text
今回の実装では同じルールで置換と再検査をしているため、通常は再検査で引っ掛かりません。
それでも関数を分けておくと、後から、
MASK_RULES = (...)
VALIDATION_RULES = (...)
のように検査側だけ厳しくできます。
たとえば、「検出したら置換するルール」と「見つかったら送信そのものを止めるルール」を分けられます。
BLOCK_PATTERNS = (
re.compile(r"\bSECRET_INTERNAL_FORMAT\b"),
)
def validate_before_send(text: str) -> None:
for pattern in BLOCK_PATTERNS:
if pattern.search(text):
raise ValueError(
"送信禁止パターンを検出しました"
)
何でも自動的に消して通すより、分からないものは止めるほうが適切なデータもあります。
🧪 テストを書く
個人情報の前処理は、コードを書いた時点よりもルールを変更したときのほうが怖いです。
正規表現を少し直した結果、それまで消えていた形式が通過することがあります。
外部ライブラリを使わない前提なので、unittestでテストします。
test_masking.pyを作ります。
import unittest
from masking import mask_text
class MaskTextTest(unittest.TestCase):
def test_email(self) -> None:
result = mask_text(
"連絡先は user@example.com です"
)
self.assertEqual(
result.text,
"連絡先は [EMAIL] です",
)
self.assertEqual(
result.counts,
{"email": 1},
)
def test_mobile_phone_with_hyphen(self) -> None:
result = mask_text(
"電話は 090-1234-5678 です"
)
self.assertEqual(
result.text,
"電話は [PHONE] です",
)
def test_mobile_phone_without_hyphen(self) -> None:
result = mask_text(
"電話は 09012345678 です"
)
self.assertEqual(
result.text,
"電話は [PHONE] です",
)
def test_postal_code(self) -> None:
result = mask_text(
"郵便番号は 123-4567 です"
)
self.assertEqual(
result.text,
"郵便番号は [POSTAL_CODE] です",
)
def test_ipv4(self) -> None:
result = mask_text(
"接続元は 192.0.2.10 です"
)
self.assertEqual(
result.text,
"接続元は [IP_ADDRESS] です",
)
def test_customer_id(self) -> None:
result = mask_text(
"顧客ID: CUSTOMER-123456"
)
self.assertEqual(
result.text,
"顧客ID: [CUSTOMER_ID]",
)
def test_multiple_values(self) -> None:
result = mask_text(
"user@example.com / 090-1234-5678"
)
self.assertEqual(
result.text,
"[EMAIL] / [PHONE]",
)
def test_normal_text_is_not_changed(self) -> None:
source = "商品の返品方法について知りたいです"
result = mask_text(source)
self.assertEqual(result.text, source)
self.assertEqual(result.counts, {})
if __name__ == "__main__":
unittest.main()
実行します。
python -m unittest test_masking.py
正常系だけでなく、消してはいけない文字列を消さないテストも重要です。
たとえば業務文書には、注文番号、日付、金額、型番など大量の数字が登場します。
電話番号らしい数字をすべて[PHONE]にしてしまうと、LLMへ渡したかった情報まで失われます。
そのため実運用では、実データそのものではなく、個人情報を含まない形で作った代表的なテストケースを増やしていきます。
def test_order_number_is_preserved(self) -> None:
source = "注文番号は ORDER-482910 です"
result = mask_text(source)
self.assertEqual(result.text, source)
「検出できるか」だけでなく「誤検出しないか」も同じくらい確認します。
⚠️ ハマりどころ
正規表現では人名を十分に判断できない
ここが最大の限界です。
たとえば、
担当は山田太郎です。
という文章を考えます。
山田太郎が人名なのは人間には分かりやすいですが、文字列だけを見れば、
山田
太郎
が必ず人名だとは限りません。
さらに、
東京都千代田区...
のような住所もあります。
郵便番号なら形式で検出しやすい一方、住所本文は自由度が高くなります。
つまり、
正規表現 = 個人情報検出
ではありません。
正規表現が得意なのは、構造が比較的決まっている値です。
メールアドレス
電話番号
郵便番号
IPアドレス
社員番号
顧客番号
契約番号
一方で、
氏名
住所
所属
文章中に書かれた個人を特定できる情報
は別の方法を考える必要があります。
「マスキング」と「匿名化」を同じものとして扱わない
たとえば次の文章があったとします。
営業部の部長で、先月大阪支店から異動してきた人物から問い合わせがありました。
氏名やメールアドレスがなくても、組織や状況によっては対象者を推測できる可能性があります。
これを正規表現だけで、
個人情報なし
と判定するのは危険です。
今回のmask_text()が保証しているのは、定義したパターンに一致した文字列を置換したことだけです。
保証していないことを関数名や設計上も意識しておきます。
その意味でも、
anonymize(text)
ではなく、
mask_text(text)
という名前にしています。
置換するとLLMに必要な文脈まで消える
マスキングは多ければ多いほどよいわけでもありません。
たとえば、
CUSTOMER-123456 と CUSTOMER-654321 の契約を統合したい
を、
[CUSTOMER_ID] と [CUSTOMER_ID] の契約を統合したい
にすると、「別々の顧客IDである」という情報まで消えます。
必要なら、値ごとに仮名化する方法があります。
CUSTOMER-123456
→ [CUSTOMER_ID_1]
CUSTOMER-654321
→ [CUSTOMER_ID_2]
同じ値がもう一度出たら、同じトークンへ置換します。
def pseudonymize_values(
text: str,
pattern: re.Pattern[str],
prefix: str,
) -> str:
mapping: dict[str, str] = {}
def replace(match: re.Match[str]) -> str:
value = match.group(0)
if value not in mapping:
number = len(mapping) + 1
mapping[value] = f"[{prefix}_{number}]"
return mapping[value]
return pattern.sub(replace, text)
こうすると、
CUSTOMER-123456 が問い合わせ。
CUSTOMER-654321 も問い合わせ。
CUSTOMER-123456 には返信済み。
を、
[CUSTOMER_ID_1] が問い合わせ。
[CUSTOMER_ID_2] も問い合わせ。
[CUSTOMER_ID_1] には返信済み。
のようにできます。
LLMには元のIDを渡さず、「同一人物か別人物か」という文章構造を残せます。
ただし、対応表を永続保存すれば、その対応表自体をどう守るかという問題が生まれます。用途に応じて、単純なマスキングとの使い分けが必要です。
マスキング前に不要な列を落とす
個人的には、ここがかなり重要だと考えています。
たとえばデータベースから、
record = {
"customer_name": "...",
"email": "...",
"phone": "...",
"inquiry": "...",
}
を全部取得してから、
text = mask_text(str(record))
とするより、要約に必要なのが問い合わせ本文だけなら、
text = record["inquiry"]
だけを処理対象にしたほうが単純です。
SQLでも同じです。
SELECT *
FROM inquiries;
で取得して後から削るより、
SELECT inquiry_text
FROM inquiries;
で必要なものだけ取得できるなら、そのほうが扱う情報を減らせます。
私は業務の自動化を考えるとき、「何を自動化するか」の前に業務を整理することを大切にしています。
LLM連携でも同じで、「どうやって全部マスキングするか」を考える前に、
そもそも、そのデータをLLMへ渡す必要があるか
を確認したほうが設計しやすくなります。
ログ、例外、トレースも送信経路になる
見落としやすいのがログです。
次のようなコードは避けます。
try:
result = llm_client.generate(prompt=prompt)
except Exception:
logger.exception(
"LLM request failed prompt=%s",
original_text,
)
raise
LLMにはマスキング済みデータしか送っていなくても、ログには元データが残っています。
同じことはAPM、エラー監視、HTTP通信のデバッグログにも当てはまります。
設計時には、
のように、LLM以外の経路も確認します。
「LLMへ生データを送っていない」だけでは、システム全体として十分とは限りません。
正規表現だけで足りなくなったら
今回のコードは、最初の境界を作るには便利ですが、日本語の人名や住所などまで扱うなら限界があります。
次の段階では、固有表現抽出などを利用したPII検出を組み合わせる方法があります。
たとえばMicrosoftが公開しているPresidioには、テキストからPIIを検出するAnalyzerと、検出結果を匿名化するAnonymizerがあります。
ただし、検出器を追加すれば問題が全部解決するわけではありません。
誤検出と見逃しがありますし、日本語を含む対象言語、認識させたいエンティティ、使用するNLPエンジンについても確認が必要です。
そのため私は、設計を次の層に分けて考えます。
1. 取得するデータを減らす
2. 形式が決まった値をルールでマスキングする
3. 必要なら固有表現検出を追加する
4. 送信前検査を行う
5. LLMへ渡す
6. ログなど別経路も確認する
高度な検出器を最初に入れるより、「不要なデータを取得しない」という単純な処理のほうが確実な場面もあります。
また、PII検出器を使う場合も、
masked = pii_analyzer.mask(text)
llm_client.generate(masked)
だけで終わらせず、どの情報を検出対象にしているのか、見逃した場合にどうするのかを決めておく必要があります。
✅ まとめ
LLMを社内システムにつなぐとき、APIクライアントを書くところから始めると、入力データの扱いが後回しになりがちです。
今回はその順序を逆にして、
必要な情報だけ取得
↓
ルールベースでマスキング
↓
送信前に検査
↓
LLMへ送信
という流れにしました。
Pythonのreだけでも、メールアドレス、電話番号、システム固有IDなど、形式が決まった値には対応できます。また、re.subn()で置換件数を取っておけば、元データを記録せず処理状況を確認する材料にもできます。
一方、氏名や自由形式の住所、文章全体から個人を推測できる情報まで正規表現だけで取り除くことはできません。マスキング済みだから匿名化済み、と考えないことが大切です。
最初から「どんな文章でも安全にする万能フィルター」を作ろうとすると難しくなります。
それよりも、
この処理に必要な列は何か
この値をLLMが知る必要はあるか
形式で確実に消せるものは何か
検出できなかった場合は送信を止めるべきか
を一つずつ決めるほうが、業務システムには合わせやすいと考えています。
AIの出力を鵜呑みにしないのと同じように、個人情報検出器の判定も鵜呑みにはしません。
LLMを社内システムの「代わり」にするのではなく、必要な範囲をこちらで決めたうえで「味方」として使う。そのための前処理として、まず小さなマスキング層を置くところから始めるのが扱いやすいと思います。