私は法務テック製品を開発しており、AIアシスタントが実際の事件に関する質問に回答しています:クライアント名、国民ID、IBAN、電話番号など。その会話は私が管理できないLLMに送信されます。生のデータを送信することは選択肢ではありません。

明らかな解決策は、IDのように見えるものをすべて空白にする正規表現の山ですが、一度に2つの方向で破綻します。偽陽性(IDでないものを編集する)と偽陰性(奇妙な形式のものを見逃す)があり、最悪なのは一方向であることです:一度データを空白にすると、モデルが今[REDACTED][REDACTED]について推論しているため、一貫した回答を得ることができません。

私はPIIを適切に検出し、モデルが推論できる安定したプレースホルダーと交換し、回答に実際の値を戻すものを望みました。それがLaranonです。

インストール方法

単一のComposerパッケージとして提供されます:

composer require edulazaro/laranon

全画面モードに入る 全画面モードを終了する

それだけです。Laranonはデフォルトでデータベースに何も保持しません:チャットターンではリクエストで終了するインメモリマップを使用します。テーブルに触れる唯一のケースは、キューに入れられたジョブ用のオプショナルなデータベースボールトで、これは後で詳しく説明します。

使用方法

送信時に匿名化し、戻り時に復元します。トークンマップはサーバーから離れません。

use EduLazaro\Laranon\Laranon;

$result = Laranon::anonymize(
    'Client John Smith, SSN 536-90-4399, wants the transfer to GB29 NWBK 6016 1331 9268 19.'
);

$result->text;
// "Client «PER_1» «AP_1», SSN «SSN_1», wants the transfer to «IBAN_1»."

$reply = $chat->send($result->text);

$result->restore($reply);
// The tokens become the real values again.

全画面モードに入る 全画面モードを終了する

それが全体のアイデアです。以下はすべて、それを信頼できるものにするための仕組みと、実際のアプリに組み込む方法です。

正規表現以上のものにするもの

いくつかの設計上の選択が、デモとクライアントデータを信頼できるものの違いを生みます。

パターンだけでなくチェックサム

スペインのDNIは「8桁と文字」ではなく、8桁と正しいmod-23制御文字です。Laranonはその文字を検証し、IBANはmod-97、クレジットカードはLuhnプラスIINで検証し、NIE、CIF、NSS、CCCについても同様です。誤った文字の12345678Aはフラグが立てられないため、素朴なスクラバーを役に立たなくする偽陽性のほとんどが除去されます。

単語ごとの名前トークン

名前は人ごとではなく単語ごとにトークン化され、アイデンティティは推測されません。「John Smith」は«PER_1» «AP_1»になり、名と姓はそれぞれ独自の安定したトークンを受け取ります。後の裸の「John」は、トークンが人ではなく単語に属するため、«PER_1»を再度受け取ります。「Mr. Baker」は「John Baker」の«AP_2»を共有します。敬称と助詞は平文のままです(「John de la Cruz」は«PER_1» de la «AP_3»と読まれます)。人間の読者が持つ情報とまったく同じもので、それ以上ではありません。

置換は決して繰り返されない

トークンマップは、2つの異なる値が同じプレースホルダーを共有しないことを保証します。もしそうであれば、2人をマージして復元を台無しにするでしょう。

正確で可逆的な復元

すべてのトークンは、バイトごとに元のテキストにマッピングされます。そしてストリーミング対応です:2つのSSEチャンクに分割されたトークン、«のマルチバイト内部でも、バッファリングされて正しく復元されます。

セッション:LLMターンに適した形状

チャットターンでは、プロンプト全体(ユーザーメッセージ、取得したコンテキスト、ツール結果)で共有される1つのインメモリマップが必要で、リクエスト終了時に消える必要があります。それがセッションです:マップを所有し、何も永続化せず、リクエストで終了する使い捨てオブジェクトです。

use EduLazaro\Laranon\Anonymizer;

$anon = Anonymizer::create();

$messages = $anon->anonymize($messages, 'content');   // tokenize the prompt
$reply    = $anon->restore($model->send($messages));  // real values back in the answer
// $anon goes out of scope here. The map is gone. Nothing was stored.

全画面モードに入る 全画面モードを終了する

anonymize()restore()は文字列、リスト、またはメッセージリストへのキーパスを受け取り、ネストされたドットパスと*ワイルドカードを含みます:

$anon->anonymize($messages, 'content');
$anon->anonymize($messages, 'tool_calls.*.function.arguments');

全画面モードに入る 全画面モードを終了する

$messagesは通常のOpenAIチャット形状(rolecontenttool_calls...)のプレーンなPHP配列です。Laranonはこの形状を定義したり要求したりしません:Laravelのdata_get()のように、指定されたドットパスを通じて任意のネストされた配列を歩き、そこに着いた文字列を匿名化します。ロール、ID、ツール名、その他すべてはそのままです。

チャット履歴を平文(実際の値)で保持するため、匿名化されたものは永続化しません。各ターンは新しいセッションを構築し、プロンプト全体を最初から再匿名化します。トークンは同一に出力されます(読み取り順序で決定論的)、したがってマルチターン会話はターン間で状態を運ぶことなく一貫性を保ちます。

キューに入れられたジョブはセッションではなくスコープを必要とする

セッションはメモリに存在し、リクエストで終了します。これは同期チャットターンにはぴったりです。キューに入れられたジョブは異なります:後で別のプロセスで実行され、セッションを作成したリクエストはすでに終了しています。共有するインメモリマップはありません。

そのためには、永続ボールトに裏打ちされたスコープを使用します。マップは(アプリキーで)暗号化されて保存され、選択したキーの下に保存されるため、ジョブはそれを再開して復元できます:

// In the request
$safe = Laranon::scope("job-{$id}")->anonymize($text);
ProcessWithLlm::dispatch($safe->text, $id);

// Later, inside the queued job (a different process)
$reply = Laranon::scope("job-{$id}")->restore($model->send($payload));
Laranon::scope("job-{$id}")->forget(); // drop the map once you are done

全画面モードに入る 全画面モードを終了する

データベースボールトはテーブルが必要な唯一の部分です。設定とマイグレーションを一度公開します:

php artisan vendor:publish --tag=laranon-config
php artisan vendor:publish --tag=laranon-migrations

全画面モードに入る 全画面モードを終了する

次にconfig/laranon.phpでスコープボールトをdatabaseに向け、ジョブ境界を越えて存続するようにします;cacheは短命の作業には適しており、arrayは1リクエストしか持続しません。そしてforget()は可逆的な仮名化を実在の匿名化に変えるスイッチです:マップがなくなると、トークンは決して元に戻せません。

チャットループへの組み込み

3つのフックが全体のパターンです。私は法的AIアシスタントで実行していますが、そのどれもアプリ固有ではありません。

$anon = Anonymizer::create();

// 1. Anonymize the prompt before it leaves. Cover the message content AND the
//    arguments of any tool calls in the history, or PII leaks back on replay.
$payload = $anon->anonymize($messages, ['content', 'tool_calls.*.function.arguments']);

$response = $client->chat($payload);

// 2. The model asked to call tools. Restore the arguments so the tools query
//    your database with the REAL values. The model only ever saw tokens.
//    tool_calls is a list, so the keyed path applies to each call, same as hook 1.
$toolCalls = $anon->restore($response->toolCalls, 'function.arguments');
$result    = runTools($toolCalls);

// 3. Restore the model's answer before you show it or store it.
$reply = $anon->restore($response->content);

全画面モードに入る 全画面モードを終了する

フック2がクリックさせるものです。モデルは«AP_1»について推論しますが、「クライアントの未解決のケース」を調べることを決定すると、«AP_1»を渡し、それを実際の姓に復元し、クエリはデータベースにヒットします。モデルは実際の値を見たことはなく、データベースはトークンを見たことはありません。

呼び出す価値のある言語の詳細はexcept('person')です:

$anon = app('laranon')->except('person')->newSession();

全画面モードに入る 全画面モードを終了する

これにより姓、DNI、IBAN、電話、メールはトークン化されますが、は平文のままになります。スペイン語では名は文法的性別を運ぶため、それをトークン化するとモデルが一致を間違って推測します("estimad@ «PER_1»")。「María」を保持しつつ「López García」を隠すことで、自然なスペイン語を書くのに十分な情報を与えつつ、識別部分を保護します。

戦略、その他

上記のトークン戦略はLLMラウンドトリップ用の可逆的なものです。さらに2つあります:

Laranon::strategy('faker')->anonymize($text);  // valid surrogates, same format, reversible
Laranon::strategy('redact')->anonymize($text); // [DNI], one-way, nothing vaulted

全画面モードに入る 全画面モードを終了する

fakerは実際のDNIを有効な偽のDNIと交換し、名前を妥当な名前に交換します。これは自然に読む必要があるドキュメントを生成する場合に必要なものです。redactはログや送信されるもの用の片方向バージョンです。その点で、Laranonはロギングスタックに組み込んですべてのログ行をスクラブし、HTTPクライアントに組み込んで送信リクエストボディを片方向でスクラブします:

Http::scrubPii()->post($url, $payload);

全画面モードに入る 全画面モードを終了する

また、本番で信頼する前にコーパスが検出するものを監査するためのlaranon:scanコマンドがあります。

まとめ

送信時に匿名化し、戻り時に復元し、チェックサムが偽陽性を排除し、トークンマップがサーバーから離れない。手作りの正規表現レイヤーに対して私を説得したのは、地味な部分でした:復元は正確で、同じ値は常に同じトークンにマッピングされ、セッションとスコープモデルはリクエストやキューに入れられたジョブに直接組み込めます。既存のチャットループに組み込むのに半日かかりませんでした。

📌 Laranonの動作はInis 法務チャットボットでご覧いただけます。

👉 Packagist上のパッケージ。
👉 GitHub上のソース。