Stop Leaking PII to LLM APIs: Build a Reversible Redaction Layer for Your AI Agents
Tuần này trên Hacker News có hai bài lên top gần như cùng lúc: paper A Privacy Analysis of Web and Mobile Conversational AI Agents (~400 điểm) và Dots: Always-on agents (~400 điểm). Đặt cạnh nhau thì thấy rõ vấn đề. Agent ngày càng chạy liên tục: nó đọc email, ticket, log, database, và gần như mọi thứ nó đọc đều được đưa vào prompt rồi gửi lên API của bên thứ ba. Mình từng review vài codebase của các team ở Việt Nam và gặp đi gặp lại một pattern: messages=[{"role": "user", "content": ticket.body}]. Trong ticket.body có số CCCD, số điện thoại, email của khách hàng. Không ai cố ý làm lộ, nhưng dữ liệu vẫn đã ra khỏi hệ thống. Bài này chia sẻ cách mình dựng một redaction layer có thể đảo ngược bằng Python, làm xong trong một buổi chiều.
Vì sao không thể chỉ "dặn team cẩn thận"
Có ba lý do chính:
- Bạn không kiểm soát được phía provider. Log request, thời gian lưu trữ (retention), cơ chế abuse monitoring là chính sách của họ. Họ có thể đổi chính sách, còn bạn chỉ biết qua email thông báo.
-
- Pháp lý ở Việt Nam đã chặt hơn. Có Nghị định 13/2023/NĐ-CP, và Luật Bảo vệ dữ liệu cá nhân có hiệu lực từ 1/1/2026. Chuyển dữ liệu cá nhân ra nước ngoài mà không kiểm soát là rủi ro có thật, không còn là chuyện lý thuyết.
-
- Agent tự ghép prompt. Với chatbot đơn giản, bạn còn nhìn thấy prompt. Với agent có tool calling, output của tool (kết quả query DB, nội dung file) được tự động nối vào context. Không ai đọc qua trước khi nó được gửi đi.
Vì vậy phải chặn ở tầng hạ tầng, không dựa vào việc từng người tự nhớ. Kiến trúc mình dùng như sau:
flowchart LR
A[Ticket / DB / Email] --> B[Redaction Layer]
B -->|text đã thay token| C[LLM API]
B <-->|mapping| V[(Vault per session)]
C -->|response có token| D[Restore]
V --> D
D --> E[User / Tool thực thi]
```
Ý tưởng chính: thay dữ liệu nhạy cảm bằng token như `<VN_PHONE_1>`, lưu mapping trong một vault cục bộ, rồi khôi phục giá trị thật khi response trả về. LLM vẫn hiểu ngữ cảnh ("đây là một số điện thoại") nhưng không bao giờ nhìn thấy giá trị thật.
## Setup Presidio + recognizer cho dữ liệu Việt Nam
Mình dùng [Microsoft Presidio] (bản 2.2.x), một thư viện open-source để phát hiện PII. Mặc định nó đã nhận diện được email, thẻ tín dụng, IP, IBAN... nhưng không biết CCCD hay số điện thoại Việt Nam, nên mình phải tự thêm recognizer.
```bash
python3.12 -m venv .venv && source .venv/bin/activate
pip install "presidio-analyzer>=2.2" "presidio-anonymizer>=2.2"
# Presidio cần spaCy model cho NER, bản lg khoảng 500MB
python -m spacy download en_core_web_lg
```
```python
from presidio_analyzer import AnalyzerEngine, PatternRecognizer, Pattern
# CCCD: 12 chữ số, bắt đầu bằng mã tỉnh 0xx
cccd = PatternRecognizer(
supported_entity="VN_CCCD",
patterns=[Pattern("cccd", r"\b0\d{11}\b", 0.5)],
context=["cccd", "căn cước", "cmnd", "cmt"],
)
# SĐT di động: 0 hoặc +84, đầu số 3/5/7/8/9, sau đó 8 chữ số
vn_phone = PatternRecognizer(
supported_entity="VN_PHONE",
patterns=[Pattern("vn_phone", r"(?:\+84|\b0)[35789]\d{8}\b", 0.7)],
context=["sđt", "điện thoại", "phone", "zalo", "liên hệ"],
)
analyzer = AnalyzerEngine() # load spaCy model, chỉ khởi tạo MỘT lần
analyzer.registry.add_recognizer(cccd)
analyzer.registry.add_recognizer(vn_phone)
```
Tham số `context` quan trọng hơn bạn nghĩ. Presidio sẽ tăng score khi thấy từ khóa nằm gần match, nhờ vậy một dãy 12 số đứng sau chữ "CCCD" được đánh giá tin cậy hơn một order ID ngẫu nhiên.
## Pseudonymize có thể đảo ngược
Presidio có sẵn operator `replace` và `mask`, nhưng thay tất cả bằng `[REDACTED]` thì response của LLM trở nên vô dụng: nó không biết số nào là số nào. Mình tự viết một vault nhỏ để mỗi giá trị luôn map về cùng một token ổn định:
```python
from dataclasses import dataclass, field
ENTITIES = ["EMAIL_ADDRESS", "VN_PHONE", "VN_CCCD", "CREDIT_CARD", "IP_ADDRESS"]
@dataclass
class Vault:
forward: dict = field(default_factory=dict) # "0912345678" -> "<VN_PHONE_1>"
backward: dict = field(default_factory=dict) # "<VN_PHONE_1>" -> "0912345678"
counters: dict = field(default_factory=dict)
def token_for(self, entity: str, value: str) -> str:
if value not in self.forward:
n = self.counters.get(entity, 0) + 1
self.counters[entity] = n
token = f"<{entity}_{n}>"
self.forward[value] = token
self.backward[token] = value
return self.forward[value]
def redact(text: str, vault: Vault) -> str:
results = analyzer.analyze(text=text, entities=ENTITIES, language="en",
score_threshold=0.5)
# Thay từ cuối lên đầu để offset không bị lệch, bỏ qua span chồng lấn
results.sort(key=lambda r: r.start, reverse=True)
last_start = len(text) + 1
for r in results:
if r.end > last_start:
continue
value = text[r.start:r.end]
text = text[:r.start] + vault.token_for(r.entity_type, value) + text[r.end:]
last_start = r.start
return text
def restore(text: str, vault: Vault) -> str:
for token, value in vault.backward.items():
text = text.replace(token, value)
return text
vault = Vault()
ticket = "SĐT 0912345678, email a.nguyen@gmail.com, CCCD 001099012345 báo lỗi thanh toán."
print(redact(ticket, vault))
# SĐT <VN_PHONE_1>, email <EMAIL_ADDRESS_1>, CCCD <VN_CCCD_1> báo lỗi thanh toán.
```
Token có dấu `>` ở cuối, nên `<VN_PHONE_1>` không bị nhầm với `<VN_PHONE_10>` khi chạy `replace`.
## Đưa vào agent: chặn ở biên của tool
Với agent có tool calling, chỗ rò rỉ lớn nhất không phải input của user mà là **output của tool**. Quy tắc của mình: mọi thứ đi vào context đều phải qua `redact`, mọi argument LLM truyền cho tool đều phải qua `restore`.
```mermaid
sequenceDiagram
participant L as LLM API
participant A as Agent Loop
participant V as Vault
participant T as Tool (DB/CRM)
L->>A: tool_call lookup_customer(phone="<VN_PHONE_1>")
A->>V: restore args
A->>T: lookup_customer(phone="0912345678")
T-->>A: {name, cccd, address...}
A->>V: redact output
A->>L: tool_result đã thay token
```
```python
def run_tool(name: str, args: dict, vault: Vault) -> str:
real_args = {k: restore(v, vault) if isinstance(v, str) else v
for k, v in args.items()}
output = TOOLS[name](**real_args)
return redact(str(output), vault)
```
Trong agent loop, bạn chỉ cần thay chỗ gọi tool trực tiếp bằng `run_tool(...)`. Mỗi conversation dùng một `Vault` riêng, lưu trong memory hoặc Redis kèm TTL. Tuyệt đối không dùng chung vault giữa các user.
## Những bẫy mình đã gặp
- **Tên người Việt.** Model `en_core_web_lg` bắt tên tiếng Việt rất kém. Mình kết hợp thêm danh sách khoảng 100 họ phổ biến (Nguyễn, Trần, Lê, Phạm...) với regex cho cụm 2–4 từ viết hoa đứng sau họ. Chưa hoàn hảo nhưng bắt được phần lớn trường hợp. Nếu cần tốt hơn thì thử NER tiếng Việt của `underthesea`.
- **False positive.** Order ID 12 số bắt đầu bằng 0 bị nhận nhầm thành CCCD. Cách xử lý: hạ base score xuống 0.4 để chỉ những match có context mới vượt `score_threshold`, hoặc đổi format ID nội bộ.
- **LLM viết lại token.** Thỉnh thoảng model trả về `VN_PHONE_1` mà thiếu dấu ngoặc nhọn. Thêm vào system prompt câu *"Giữ nguyên các token dạng <ENTITY_N>, không sửa đổi"* là giảm được gần hết.
- **Performance.** Khởi tạo `AnalyzerEngine` mất vài giây vì phải load model. Sau khi đã khởi tạo, mỗi lần analyze một đoạn text vài KB chỉ tốn vài ms đến vài chục ms, không đáng kể so với latency của LLM.
- **Test như test bảo mật.** Mình giữ một file fixture chứa khoảng 50 câu có PII thật (đã làm giả) và chạy `pytest` trong CI. Test assert rằng output của `redact` không còn chứa bất kỳ giá trị gốc nào. Có ai sửa regex làm hỏng thì CI đỏ ngay.
## Kết luận
Model ngày càng rẻ và agent ngày càng "luôn bật", nên lượng dữ liệu đi ra ngoài sẽ tăng theo cấp số nhân. Redaction layer là loại hạ tầng rẻ mà hiệu quả cao, nên có từ sớm thay vì đợi đến lúc có sự cố. Checklist để bắt đầu ngay tuần này:
1. **Grep codebase** tìm mọi chỗ gọi LLM API (`grep -rn "chat.completions\|messages.create" src/`) và liệt kê xem dữ liệu nào đang được đưa vào prompt.
2. **Dựng Presidio** kèm recognizer cho CCCD và SĐT Việt Nam như trên, khởi tạo một lần ở startup.
3. **Dùng token có thể đảo ngược** thay vì `[REDACTED]` để response vẫn dùng được.
4. **Chặn ở biên tool**: `redact` output, `restore` argument, mỗi session một vault riêng.
5. **Đưa PII fixture vào CI** để regex không âm thầm bị hỏng.
Layer này không thay thế được việc đọc kỹ data policy của provider, nhưng nó biến câu hỏi "lỡ provider log lại thì sao?" từ rủi ro pháp lý thành chuyện gần như không còn đáng lo.
All Rights Reserved