PII マスキングを Claude API の手前に入れる、という方針自体に異論を持つ人は少ないと思います。つまずくのはその先です。正規表現と NER で検出器を組んだあと、「マスクしたものをどう元に戻すか」「その対応表をどこに置くか」「本当に漏れていないとどう証明するか」という運用の問いが残り、ここで設計が一気に難しくなります。
検出ロジックは数日あれば形になります。けれども本番で痛い目を見るのは、たいてい検出ではなく 復元用の台帳(ledger) の扱いと、漏洩率を測り続ける仕組み の欠如です。個人開発で業務向けアシスタントを Claude API に組み込み、私自身が運用してきた中で、レビューや障害の大半はこの2点に集中していました。この記事は検出器の作り方を一通り示しつつ、重心を「台帳運用」と「継続測定」に置いて、本番投入で効く判断を実装コードとともにまとめます。
マスクは「往復」で考える — 片道設計が破綻する理由
PII マスキングを「送信前に消す処理」とだけ捉えると、要約や業務アシスタントのユースケースで必ず破綻します。Claude が <PERSON_001>さんへ連絡しました と返してきても、それを利用者に見せる前に 田中太郎 へ戻さなければ意味が通りません。つまりマスキングは送信時の片道処理ではなく、マスク(往路)と復元(復路)が対になった往復処理 です。
往復で考えると、設計の主役は検出器ではなく、トークンと原文の対応を持つ台帳になります。台帳は次の3つを同時に満たす必要があります。
第一に、会話やリクエストをまたいで一貫していること。同じ 田中太郎 には常に同じ <PERSON_001> を割り当てないと、Claude が同一人物だと認識できず出力品質が落ちます。第二に、暗号化されていること。台帳は「どのトークンが誰なのか」を記した地図そのものなので、生の PII より漏れたときの被害が大きい。第三に、多インスタンスで共有できること。本番は単一プロセスでは動きません。
検出は不可逆マスク(<PERSON> で潰す)なら台帳すら不要です。台帳が要るのは復元する場合だけ。だからこそ「可逆をデフォルトにするか」という判断が、運用コストを左右する最初の分岐点になります。私は復元が必要かどうか曖昧なうちは可逆を選びます。後から不可逆へ落とすのは一行ですが、不可逆から可逆へ戻すのは原文が失われていて不可能だからです。
検出は2層で十分 — 正規表現の Luhn と Haiku の NER
台帳の話に入る前に、検出器を最小構成で固めておきます。私の運用では正規表現(Layer 1: 識別子)と軽量モデル NER(Layer 2: 準識別子)の2層で実用十分でした。文脈推論が必要な Layer 3 は機械処理を諦め、UI 側の入力ガイダンスに寄せています。
正規表現で重要なのは、クレジットカードらしき数字列を Luhn チェックで絞ることです。「13桁以上の数字」だけで拾うと ISBN・JAN・配送伝票番号まで巻き込み、要約精度が目に見えて落ちます。
// pii-detect.ts — Layer 1(識別子)の機械検出。トークンは <CATEGORY_NNN> 形式で統一する
// 設計意図: 誤検出はモデル精度を、見逃しは漏洩を悪化させる。クレカは Luhn で誤検出を抑える
export type Span = { start : number ; end : number ; category : string ; text : string };
const REGEXES : Record < string , RegExp > = {
EMAIL: / \b [A-Za-z0-9._%+-] + @ [A-Za-z0-9.-] + \. [A-Za-z] {2,}\b / g ,
PHONE_JP: /(?: \+ ? 81 [-\s] ?| 0) \d {1,4} [-\s] ? \d {1,4} [-\s] ? \d {3,4} / g ,
CREDIT_CARD: / \b (?: \d[ -] *? ) {13,19}\b / g ,
};
export function detectLayer1 ( input : string ) : Span [] {
const spans : Span [] = [];
for ( const [ category , regex ] of Object. entries ( REGEXES )) {
for ( const m of input. matchAll (regex)) {
const text = m[ 0 ];
if (category === "CREDIT_CARD" && ! isLuhnValid (text. replace ( / [ -] / g , "" ))) continue ;
spans. push ({ start: m.index ! , end: m.index ! + text. length , category, text });
}
}
return spans;
}
function isLuhnValid ( num : string ) : boolean {
if ( ! / ^ \d {13,19}$ / . test (num)) return false ;
let sum = 0 , alt = false ;
for ( let i = num. length - 1 ; i >= 0 ; i -- ) {
let n = parseInt (num[i], 10 );
if (alt) { n *= 2 ; if (n > 9 ) n -= 9 ; }
sum += n; alt = ! alt;
}
return sum % 10 === 0 ;
}
名前と住所は正規表現では捕まりません。ここで Claude Haiku を NER として使います。NER への入力もまた PII を含むので、汎用のクラウド NER に出すよりは、自分が管理する Claude 呼び出しに閉じる方が監査上は説明しやすいです。ポイントは、抽出結果を信用しすぎず、必ず固定バージョンのモデルにピン留めしておくことです(後述の落とし穴4)。
// pii-ner.ts — Layer 2(準識別子: 名前・住所)の抽出。モデルは日付付き ID にピン留めする
import Anthropic from "@anthropic-ai/sdk" ;
const client = new Anthropic ({ apiKey: process.env. ANTHROPIC_API_KEY ! });
const NER_MODEL = "claude-haiku-4-5-20251001" ; // 挙動を固定するため日付付きで固定
const NER_SYSTEM = `あなたは日本語の固有表現抽出器です。入力から PERSON と ADDRESS_JP のスパンのみを抽出し、
必ず次のJSONで返してください。推測や創作は禁止。分類できないものは出力しないでください。
{"entities":[{"text":"string","category":"PERSON"|"ADDRESS_JP"}]}` ;
export async function detectLayer2 ( input : string ) : Promise <{ text : string ; category : string }[]> {
const res = await client.messages. create ({
model: NER_MODEL , max_tokens: 1024 , system: NER_SYSTEM ,
messages: [{ role: "user" , content: input }],
});
const text = res.content. filter (( b ) => b.type === "text" ). map (( b : any ) => b.text). join ( "" );
const match = text. match ( / \{ [\s\S] * \} / );
if ( ! match) return []; // 抽出できなければ空。後段の再スキャンで漏れを検知する
try {
const parsed = JSON . parse (match[ 0 ]) as { entities ?: { text : string ; category : string }[] };
return (parsed.entities ?? []). filter (( e ) => e.text && [ "PERSON" , "ADDRESS_JP" ]. includes (e.category));
} catch {
return []; // パース失敗時もスキップ。漏れは測定で捕まえる前提に倒す
}
}
台帳を一級市民に — 暗号化された共有ストアとして実装する
ここからが本題です。検出したスパンをトークンに置き換え、対応を台帳に積みます。台帳をメモリの Map に置く実装は、サンプルとしては正しくても本番では即破綻します。マルチインスタンス構成では、ターン1を捌いたプロセスとターン3を捌くプロセスが別なら、台帳が共有されず復元に失敗するからです。
台帳は 暗号化した JSON を共有ストア(Redis / Cloudflare KV など)に会話単位で置く のが定石です。暗号化は必須です。先述の通り、台帳は PII の所在地マップそのものなので、平文で Redis のスナップショットやスローログに残ると、生 PII を漏らすより重大な事故になります。
// pii-ledger.ts — 復元用台帳を AES-256-GCM で暗号化して共有ストアへ保存する
// 鍵は KMS から注入する想定。プロセス内に平文鍵を長く保持しない
import { createCipheriv, createDecipheriv, randomBytes, createHash } from "node:crypto" ;
const KEY = Buffer. from (process.env. LEDGER_KEY_BASE64 ! , "base64" ); // 32 bytes
export function sealLedger ( ledger : Record < string , string >) : string {
const iv = randomBytes ( 12 );
const cipher = createCipheriv ( "aes-256-gcm" , KEY , iv);
const body = Buffer. concat ([cipher. update ( JSON . stringify (ledger), "utf8" ), cipher. final ()]);
const tag = cipher. getAuthTag ();
// iv(12) + tag(16) + body を base64 で1本化
return Buffer. concat ([iv, tag, body]). toString ( "base64" );
}
export function openLedger ( sealed : string ) : Record < string , string > {
const buf = Buffer. from (sealed, "base64" );
const iv = buf. subarray ( 0 , 12 ), tag = buf. subarray ( 12 , 28 ), body = buf. subarray ( 28 );
const decipher = createDecipheriv ( "aes-256-gcm" , KEY , iv);
decipher. setAuthTag (tag);
const out = Buffer. concat ([decipher. update (body), decipher. final ()]);
return JSON . parse (out. toString ( "utf8" ));
}
// 会話単位のキー。会話IDは推測されにくいよう、生IDではなくハッシュを使う
export const ledgerKey = ( conversationId : string ) =>
`pii:ledger:${ createHash ( "sha256" ). update ( conversationId ). digest ( "hex" ). slice ( 0 , 32 ) }` ;
GCM を選ぶのは、暗号化と同時に改竄検知(認証タグ)が付くからです。CBC で組むと台帳が静かに壊れたときに検知できず、復元結果が崩れる事故につながります。鍵は環境変数に直書きせず、KMS から起動時に注入し、ローテーション時は旧鍵での復号も一定期間許す二重鍵運用にしておくと安全です。
ラッパーの最終形 — 往復と多ターン一貫性をまとめる
検出2層と暗号化台帳がそろえば、呼び出し側が PII を一切意識しなくて済むラッパーに統合できます。設計の核は「同一文字列は会話全体で同一トークンに寄せる」ことと、「応答の復元は台帳の全エントリで一括置換する」ことです。
// pii-pipeline.ts — Claude API ラッパー最終形。会話単位で台帳を継承し、応答を復元する
import Anthropic from "@anthropic-ai/sdk" ;
import { detectLayer1 } from "./pii-detect" ;
import { detectLayer2 } from "./pii-ner" ;
import { sealLedger, openLedger, ledgerKey } from "./pii-ledger" ;
import type { Redis } from "ioredis" ;
const client = new Anthropic ({ apiKey: process.env. ANTHROPIC_API_KEY ! });
// 原文 → トークンの逆引きを作りつつ、台帳(トークン → 原文)を更新する
function applyMasks ( text : string , ledger : Record < string , string >) : string {
const reverse = new Map (Object. entries (ledger). map (([ t , o ]) => [o, t]));
const counters : Record < string , number > = {};
for ( const t of Object. keys (ledger)) {
const cat = t. slice ( 1 , t. lastIndexOf ( "_" ));
counters[cat] = Math. max (counters[cat] ?? 0 , parseInt (t. slice (t. lastIndexOf ( "_" ) + 1 ), 10 ) || 0 );
}
const register = ( raw : string , category : string ) : string => {
const existing = reverse. get (raw);
if (existing) return existing;
counters[category] = (counters[category] ?? 0 ) + 1 ;
const token = `<${ category }_${ String ( counters [ category ]). padStart ( 3 , "0" ) }>` ;
ledger[token] = raw; reverse. set (raw, token);
return token;
};
let masked = text;
// Layer1 と Layer2 の検出結果を、長い文字列から順に全置換(オフセットずれ回避)
const items = [
... detectLayer1 (text). map (( s ) => ({ text: s.text, category: s.category })),
];
// 同期関数内では Layer2 は呼べないため、呼び出し側で結合する(下の callClaude を参照)
for ( const it of items. sort (( a , b ) => b.text. length - a.text. length )) {
masked = masked. split (it.text). join ( register (it.text, it.category));
}
return masked;
}
export async function callClaude (
redis : Redis , conversationId : string ,
messages : { role : "user" | "assistant" ; content : string }[],
) : Promise < string > {
const key = ledgerKey (conversationId);
const sealed = await redis. get (key);
const ledger : Record < string , string > = sealed ? openLedger (sealed) : {};
const masked = [];
for ( const m of messages) {
// Layer2(NER) を先に流し、原文へ反映してから Layer1 を含めて一括マスク
const ents = await detectLayer2 (m.content);
let pre = m.content;
const reverse = new Map (Object. entries (ledger). map (([ t , o ]) => [o, t]));
const counters : Record < string , number > = {};
for ( const e of ents. sort (( a , b ) => b.text. length - a.text. length )) {
const ex = reverse. get (e.text);
const token = ex ?? `<${ e . category }_${ String (( counters [ e . category ] = ( counters [ e . category ] ?? 0 ) + 1 )). padStart ( 3 , "0" ) }>` ;
if ( ! ex) { ledger[token] = e.text; reverse. set (e.text, token); }
pre = pre. split (e.text). join (token);
}
masked. push ({ role: m.role, content: applyMasks (pre, ledger) });
}
// 台帳を暗号化保存(TTLは会話の想定最大時間。例: 24h)
await redis. set (key, sealLedger (ledger), "EX" , 60 * 60 * 24 );
const res = await client.messages. create ({
model: "claude-sonnet-4-6" , max_tokens: 2048 ,
system: "応答中の <PERSON_001> のような <カテゴリ_番号> 形式の文字列は翻訳・変形せず、必ずそのまま出力に含めてください。" ,
messages: masked,
});
const raw = res.content. filter (( b ) => b.type === "text" ). map (( b : any ) => b.text). join ( "" );
// 復元: 台帳の全トークンを原文へ戻す
let restored = raw;
for ( const [ token , original ] of Object. entries (ledger)) restored = restored. split (token). join (original);
return restored;
}
このラッパーで意図する往復は次のようになります。台帳が会話キーに紐づくため、ターンをまたいでも 田中太郎 は常に同じトークンに収束します。
入力: "田中さんに次回訪問日を確認してください。連絡先は tanaka@example.com です"
送信: "<PERSON_001>に次回訪問日を確認してください。連絡先は <EMAIL_001> です"
応答: "<PERSON_001>さんへ <EMAIL_001> 経由で確認しました"
出力: "田中さんへ tanaka@example.com 経由で確認しました"
漏洩率を毎日測る — ゴールデンデータセットとシャドウ再スキャン
本番運用で最も重要なのは、検出ロジックの精緻さではなく「いま漏れていないことを毎日言えるか」です。私は2つの測定を併用しています。
ひとつは ゴールデンデータセット による CI 測定です。PII を意図的に埋め込んだ合成テキストを100〜500件用意し、検出率(再現率)・偽陽性率を毎日測ります。数値が前日より悪化したらマージをブロックします。閾値は「漏れゼロを目指す再現率」と「精度を落とさない偽陽性率」のトレードオフで決めますが、私は再現率を優先側に置いています。
# golden_eval.py — マスク後本文に PII の痕跡が残っていないかを毎日CIで測る
# 合成データなので生PIIを扱わず、検出漏れ(false negative)を再現率として可視化する
import json, re, sys
from statistics import mean
EMAIL = re.compile( r " \b[ A-Za-z0-9._%+- ] + @ [ A-Za-z0-9.- ] + \. [ A-Za-z ] {2,} \b " )
PHONE = re.compile( r " (?: \+ ? 81 [ - \s] ?| 0 )\d {1,4} [ - \s] ? \d {1,4} [ - \s] ? \d {3,4} " )
def leaked (masked: str ) -> bool :
# マスク後に識別子パターンが残っていれば漏れ
return bool ( EMAIL .search(masked) or PHONE .search(masked))
def run (path: str , threshold: float = 0.99 ) -> int :
cases = [json.loads(l) for l in open (path, encoding = "utf-8" )]
recalls = []
for c in cases:
masked = c[ "masked" ] # マスク器の出力(事前生成)
expected_tokens = c[ "expected" ] # 埋め込んだPII件数
found = masked.count( "<" ) - leaked_count(masked)
recalls.append( min (found, expected_tokens) / max (expected_tokens, 1 ))
r = mean(recalls) if recalls else 0.0
leaks = sum ( 1 for c in cases if leaked(c[ "masked" ]))
print ( f "recall= { r :.4f } leaks= { leaks } / { len (cases) } " )
return 0 if (r >= threshold and leaks == 0 ) else 1
def leaked_count (masked: str ) -> int :
return len ( EMAIL .findall(masked)) + len ( PHONE .findall(masked))
if __name__ == "__main__" :
sys.exit(run(sys.argv[ 1 ]))
もうひとつは本番の シャドウ再スキャン です。全リクエストの 0.1% を構造化ログに残し、別のジョブが「マスク後の本文だけ」を再スキャンして識別子パターンの残存を探します。ここで重要なのは、再スキャンが生 PII を一切見ないことです。マスク後本文だけを対象にすれば、漏れの兆候を捕まえつつ二次漏洩を起こしません。再スキャンで残存が見つかったら、その入力パターンをゴールデンデータセットに追加し、検出器を更新する——この往復で漏洩率を毎月着実に下げていきます。
実運用で踏んだ落とし穴
設計が正しくても、運用で初めて顕在化するバグがあります。私が実際に踏んだものを挙げます。
ひとつめは トークンの英訳 です。システムプロンプトで明示しないと、Claude は <PERSON_001> を時折 Person 001 と英訳して返します。すると台帳の逆引きが外れ、Person 001 が利用者に見えてしまいます。前掲のラッパーで system に「形式を変えずそのまま出力に含めてください」と書いているのはこのためです。
ふたつめは ストリーミング中のトークン分断 です。text_delta が <PERSON_ と 001> を別チャンクで届けることがあり、チャンク単位で復元すると失敗します。復元はチャンクをバッファに溜め、<...> が閉じた時点で一括処理するか、ストリーム終了後にまとめて行います。中途半端な部分文字列を逐次置換しないことが肝心です。
みっつめは NER モデルの更新ドリフト です。NER に使う Claude のバージョンを上げると、抽出基準が微妙に変わり、見逃し率や誤検出率が動きます。だから claude-haiku-4-5-20251001 のように日付付き ID でピン留めし、更新時は必ずゴールデンデータセットで回帰してから切り替えます。検出器の挙動が静かに変わることが、PII マスキングでは最も怖い種類の変化です。
加えて、2026年6月以降の Claude API の課金変更で headless 実行やエージェント委譲が別枠のクレジットへ移った点も、NER に Haiku を多用する設計ではコストに効いてきます。NER 呼び出しの頻度とキャッシュ(マスク後本文のハッシュをキーにする)は、運用開始時に実測しておくことを推奨します。
着手順序
完璧さを追わず、効果の大きい順に積むのが現実的です。私自身もこの順番で運用に乗せました。
detectLayer1 を Claude 呼び出しの直前に挟む。メール・電話・PAN の機械マスクが入るだけで、社内レビューで受ける指摘の半分は消えます。
台帳の暗号化保存(sealLedger/openLedger)を入れて復元を安全にする。多インスタンスでの復元失敗と台帳平文化という二大事故をここで塞ぎます。
ゴールデンデータセットの CI を回し始め、シャドウ再スキャンを 0.1% サンプリングで追加する。漏洩率を数値で毎日見るリズムができます。
この順で積めば、検出の完璧さを追わずとも、漏洩確率を毎月下げ続ける運用に到達できます。
完璧なマスキングは存在しません。重要なのは、台帳を暗号化して往復を閉じ、漏れていないことを毎日数値で言える状態を保つことです。そこまで作って初めて、PII を含む業務データを Claude に安心して託せるようになります。