Quando um sistema de saúde ou financeiro chama um modelo de linguagem externo, cada prompt pode carregar nome, CPF, diagnóstico ou valores. Se isso sai da sua infraestrutura sem tratamento, o problema deixa de ser técnico e vira risco jurídico e de confiança.
A ideia de uma camada de privacidade é simples: antes da chamada, identificar as entidades sensíveis, trocar cada uma por um marcador e guardar o mapa de volta dentro do seu perímetro. O modelo raciocina sobre os marcadores, e a resposta é reidratada só do seu lado.
entrada: Maria Souza, CPF 123.456.789-09, internada em 12/03
modelo: [PESSOA_1], CPF [CPF_1], internada em [DATA_1]Até aqui, todo mundo concorda. O problema é que a versão ingênua disso protege bem menos do que parece, e os buracos estão em quatro lugares.
Token estável é pseudonimização, não anonimização
A tentação é fazer o mesmo CPF virar sempre o mesmo marcador, em todas as chamadas, para o modelo “lembrar” da pessoa. Isso cria linkabilidade: quem vê as requisições consegue juntar tudo o que passou sobre o mesmo marcador e montar um perfil sem nunca ver o CPF. E o mapa de volta vira o ativo mais sensível do sistema, porque é a única coisa que separa um monte de marcadores de um monte de pacientes.
Para a LGPD, dado pseudonimizado continua sendo dado pessoal. Só deixa de ser quando a anonimização não pode ser revertida, e um mapa de volta existe justamente para reverter. Então o marcador não te tira do escopo da lei; ele só reduz o que vaza se a chamada for interceptada.
Também não adianta trocar o mapa por um hash. O CPF tem uns 10⁹ valores possíveis, e calcular o SHA-256 de todos cabe numa tarde num notebook. Hash sem chave de identificador com espaço pequeno é só um CPF escrito de outro jeito.
O mínimo que eu considero razoável:
- Escopo por conversa ou por tenant, nunca global. Dentro de uma conversa, marcadores sequenciais (
CPF_1,CPF_2) bastam. - Quando precisar de consistência entre chamadas, como num documento processado em pedaços, use HMAC com chave por escopo, e rotacione a chave.
- O mapa tratado como cofre: criptografado, com acesso restrito, retenção curta e trilha de acesso própria.
token = "CPF_" + base32(HMAC(chave[tenant][conversa], cpf))[:8]Mandar PII para detectar PII
Um conselho comum é começar pelo determinístico, com regex e validadores para documentos, e deixar um LLM para os casos que a regra não alcança. O conselho está certo, com uma condição que costuma ficar de fora: esse modelo precisa rodar dentro do perímetro. Se o detector é uma API externa, você manda o texto cru para fora justamente para descobrir o que não podia mandar para fora.
Na prática, a detecção fica toda do seu lado: regex e validadores (dígito verificador de CPF, CNS, telefone, e-mail, datas), listas de entidades conhecidas, um NER como o do Presidio com modelo em português e, se precisar, um modelo pequeno rodando local. O modelo externo só vê texto que já passou por tudo isso.
texto
-> regex + validadores (CPF, CNS, telefone, e-mail, datas)
-> NER / modelo local, dentro do perímetro
-> generalização de quasi-identificadores
-> marcadores com escopo + mapa no cofre
-> LLM externo
-> validação dos marcadores na resposta
-> reidrataçãoEm saúde, o vazamento mora nos quasi-identificadores
Nome e CPF são a parte fácil. O que reidentifica um paciente de verdade é a combinação de atributos que, sozinhos, parecem inofensivos. Latanya Sweeney mostrou, em 2000, que CEP, data de nascimento e sexo bastavam para identificar unicamente cerca de 87% da população dos Estados Unidos. Num prontuário em texto livre a situação é pior, porque esses atributos vêm misturados com o resto.
Nenhuma regex pega “paciente de 34 anos com HIV atendido em Sorocaba”. Não tem nome, não tem documento, e numa base de uma operadora regional pode ser uma pessoa só. É aqui que mora o problema de verdade.
Substituir não resolve, porque o modelo precisa do conteúdo clínico: num resumo de caso, o diagnóstico é o assunto. A ferramenta certa é generalizar, decidindo por tarefa o que o modelo realmente precisa saber:
| Atributo | Original | Generalizado |
|---|---|---|
| Idade | 34 anos | 30 a 39 anos |
| Data | internado em 12/03 | [DATA_1], ou “D+3” relativo à entrada |
| Cidade | Sorocaba | interior de SP |
| Diagnóstico | HIV | mantido, se a tarefa precisa dele |
E dá para medir. Se, depois de generalizar, só um paciente da base combina com “30 a 39 anos, interior de SP, HIV, internado em março”, a generalização não foi suficiente. É a intuição do k-anonimato aplicada ao que sobra no texto: cada combinação que chega ao modelo deveria corresponder a várias pessoas, não a uma.
A reidratação também falha
A volta é onde ninguém olha. O modelo pode mudar o formato do marcador ([CPF_1] vira CPF 1, [cpf_1] ou CPF_01), inventar um que não existe ([PESSOA_3] quando só havia duas pessoas) ou trocar os marcadores de lugar. Um find-and-replace ingênuo deixa marcador quebrado na tela ou, pior, coloca o nome do paciente errado na resposta.
- Marcadores com formato rígido e difícil de parafrasear, e um reconhecedor que encontra as variações que o modelo costuma produzir.
- Todo marcador da resposta precisa existir no mapa daquela conversa. Marcador desconhecido é recusa ou nova geração, nunca palpite.
- Marcador de outra conversa ou de outro tenant nunca resolve, mesmo que exista no cofre.
- Depois de reidratar, nenhum marcador pode sobrar no texto.
for token in find_tokens(resposta):
if token not in mapa_da_conversa:
raise TokenDesconhecido(token)Meça o que escapa
Mascaramento que ninguém testa é só uma sensação de segurança. Mantenha um conjunto de casos sintéticos que se parecem com os reais, incluindo quasi-identificadores em texto livre, meça o recall do detector por tipo de entidade e conte o que chega ao modelo externo. A métrica que importa é o que vazou, não o que foi pego.
Isso conversa com O prompt não é fronteira de segurança: uma camada cuida do que o modelo vê, a outra cuida do que ele consegue fazer.