我构建了一个法律科技产品,其中 AI 助手会回答真实案件的问题:客户姓名、国民身份证号、IBAN 账号、电话号码等。该对话会发送至一个我无法控制的 LLM。直接发送原始数据是不行的。

显而易见的解决方案是一堆正则表达式,将所有看起来像 ID 的内容清空,但这会同时在两个方向上崩溃。它有假阳性(将非 ID 的内容也标记了)和假阴性(遗漏了格式异常的 ID),最糟糕的是它是单向的:一旦你清空了数据,你就无法得到连贯的答案,因为模型现在正在对 [REDACTED][REDACTED] 之间的对话进行推理。

我想要的是能正确检测 PII、将其替换为模型可以推理的稳定占位符,并在答案中将真实值替换回来的方案。这就是 Laranon。

如何安装

它以单个 Composer 包的形式发布:

composer require edulazaro/laranon

Enter fullscreen mode Exit fullscreen mode

这就是全部需求。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.

Enter fullscreen mode Exit fullscreen mode

这就是整个思路。以下所有内容都是使其值得信赖的机制,以及将其集成到真实应用中的方法。

它为何不仅仅是正则表达式

一些设计选择是演示版与可信赖客户数据的区别所在。

校验和,而不仅仅是模式

西班牙 DNI 并非仅仅是“八位数字加一个字母”,而是八位数字加上正确的 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»)。这正是人类读者所拥有的信息,不多不少。

替换永不重复

令牌映射保证两个不同的值永远不会共享一个占位符。如果它们共享了,你就会合并两个人,并破坏还原。

精确、可逆的还原

每个令牌都映射回字面上的原始文本,逐字节对应。它还支持流式传输:即使一个令牌被分割在两个 SSE 块中,甚至在多字节的 « 内部,也会被缓冲并正确还原。

会话:LLM 回合的正确形态

对于一个聊天回合,你需要一个在整个提示(用户消息、检索到的上下文、工具结果)中共享的内存映射,并且希望它在请求结束时消失。这就是会话:一个拥有其映射、持久化任何内容、并随请求消亡的一次性对象。

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.

Enter fullscreen mode Exit fullscreen mode

anonymize()restore() 接受一个字符串、一个列表,或一个指向消息列表的键路径,包括嵌套的点路径和 * 通配符:

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

Enter fullscreen mode Exit fullscreen mode

这里的 $messages 是一个普通的 PHP 数组,采用通常的 OpenAI 聊天格式(rolecontenttool_calls……)。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

Enter fullscreen mode Exit fullscreen mode

数据库保险库是唯一需要一张表的部分。发布其配置和迁移一次:

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

Enter fullscreen mode Exit fullscreen mode

然后在 config/laranon.php 中将作用域保险库指向 database,以便它能跨作业边界保留;cache 适用于短生命周期的工作,而 array 仅持续一个请求。而 forget() 是将可逆假名化转变为真正匿名化的开关:一旦映射消失,令牌就永远无法被还原。

集成到聊天循环中

这三个钩子就是整个模式。我在法律 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);

Enter fullscreen mode Exit fullscreen mode

钩子 2 是让它起作用的关键。模型对 «AP_1» 进行推理,但当它决定查找“客户的未决案件”时,它会交给你 «AP_1»,你将其还原为真实姓氏,然后查询命中数据库。模型永远不会看到真实值;数据库永远不会看到令牌。

一个值得一提的语言细节是 except('person')

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

Enter fullscreen mode Exit fullscreen mode

这会标记姓氏、DNI、IBAN、电话和电子邮件,但将名字保留为明文。在西班牙语中,名字带有语法性别,因此对其进行标记会使模型错误地猜测一致性(“estimad@ «PER_1»”)。保留 “María” 而隐藏 “López García” 足以让它写出自然的西班牙语,同时仍能保护识别部分。

策略及其他

上述令牌策略是用于 LLM 往返的可逆策略。还有另外两种:

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

Enter fullscreen mode Exit fullscreen mode

faker 将真实的 DNI 替换为一个有效的假 DNI,并将姓名替换为一个合理的姓名,这正是你在生成必须自然阅读的文档时所需要的。redact 是用于日志和任何出站数据的单向版本。顺便说一下,Laranon 还可以插入到你的日志堆栈中,以清理每一行日志,并插入到 HTTP 客户端中,以对出站请求体进行单向清理:

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

Enter fullscreen mode Exit fullscreen mode

还有一个 laranon:scan 命令,用于在生产环境中信任之前,审计语料库将检测到的内容。

总结

传出时匿名化,传回时还原,通过校验和排除假阳性,并且令牌映射永远不会离开你的服务器。与手动编写的正则表达式层相比,最吸引我的地方是那些不那么光鲜的部分:还原是精确的,相同的值总是映射到相同的令牌,并且会话和作用域模型可以直接插入到请求或排队作业中。将它集成到现有的聊天循环中,只花了一个下午的时间,而不是一个冲刺周期。

📌 你可以通过 Inis 法律聊天机器人查看 Laranon 的实际应用。

👉 包在 Packagist 上。
👉 源代码在 GitHub 上。