The problem with blocking
The PII guardrail proxy I built last week works by classifying prompts and blocking the sensitive ones. That’s fine for a chat interface where a human can rephrase. It doesn’t work for automated pipelines.
If a Jira ticket contains someone’s name and an internal hostname, you don’t want the agent to fail β you want it to process the ticket without exposing that data. Blocking is the wrong primitive for pipelines. Anonymization is the right one.
The pattern
Input text
β anonymizer: extract PII, replace with semantic fakes
β "Nathan Chen from DataSoft LLC needs ProjectX fixed on dev.internal.net"
+ mapping: {"Nathan Chen" β "John Smith", "DataSoft LLC" β "ACME", ...}
β cloud LLM: processes coherent text, never sees real values
β "Nathan Chen should check the ProjectX docs with the DataSoft LLC team"
β string substitution with reverse mapping
β "John Smith should check the OAuth docs with the ACME team"
Two things that make this work:
Deanonymization needs no LLM. Once you have the mapping, restoring is pure string substitution. The model call only happens on the way in.
Semantic fakes beat placeholder tokens. An earlier version of this used [PERSON_1], [ORG_1] tokens. The problem: cloud models see bracketed text and subtly change behaviour β shorter responses, hedging, dropped context. When the cloud model sees Nathan Chen from DataSoft LLC, it treats it as real text and responds naturally. Quality is noticeably better.
Prior art β what already exists
This is a well-established pattern. Worth knowing what’s out there:
LLM Guard (Protect AI) β the most complete open-source implementation. Anonymize + Deanonymize scanner pair with a Vault for the mapping. Production-grade, actively maintained. Start here if you’re building this for anything serious.
Microsoft PII Shield β session-based proxy. Returns a session ID with the anonymized text, uses it to deanonymize the response.
anonLLM β uses GLiNER (a proper NER model) + Faker for realistic replacements. Better accuracy than a general chat model.
REDACT β IEEE paper describing a system using Ollama for PII redaction in documents.
HuggingFace Anonymizer SLM series β purpose-built models (0.6B/1.7B/4B) fine-tuned specifically for anonymization. 9.20/10 quality score for 1.7B, close to GPT-4.1’s 9.77.
That last one is what this implementation actually uses.
The model: Anonymizer-1.7B
eternisai/Anonymizer-1.7B is a Qwen3-1.7B fine-tune trained on ~30k anonymization samples using GRPO with GPT-4.1 as judge. It outputs structured tool calls instead of free text:
{
"name": "replace_entities",
"arguments": {
"replacements": [
{"original": "John Smith", "replacement": "Nathan Chen"},
{"original": "ACME Corp", "replacement": "DataSoft LLC"},
{"original": "auth.acme.internal", "replacement": "dev.internal.net"}
]
}
}
No prompt engineering needed. The model knows exactly what it’s doing and outputs a structured contract. Compare that to the first version of this service, which sent a long JSON-format prompt to Phi-3.5-mini and hoped the output parsed correctly.
The model runs via Ollama (which handles the Qwen3 chat template and tool calling natively), pointed at the GGUF version from HuggingFace: hf.co/gabriellarson/Anonymizer-1.7B-GGUF.
The implementation
llm-anonymizer is a FastAPI service with two endpoints.
POST /anonymize β calls Ollama with the tool definition, parses the response:
TOOLS = [{
"type": "function",
"function": {
"name": "replace_entities",
"description": "Replace PII entities with anonymized versions",
"parameters": {
"type": "object",
"properties": {
"replacements": {
"type": "array",
"items": {
"type": "object",
"properties": {
"original": {"type": "string"},
"replacement": {"type": "string"},
},
"required": ["original", "replacement"],
},
}
},
"required": ["replacements"],
},
},
}]
resp = await client.post(f"{OLLAMA_BASE}/api/chat", json={
"model": MODEL,
"messages": [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": text + "\n/no_think"}, # skip Qwen3 thinking mode
],
"tools": TOOLS,
"stream": False,
})
tool_calls = resp.json()["message"]["tool_calls"]
replacements = tool_calls[0]["function"]["arguments"]["replacements"]
# Build reverse mapping: replacement β original (for deanonymization)
anonymized = text
mapping = {}
for pair in replacements:
anonymized = anonymized.replace(pair["original"], pair["replacement"])
mapping[pair["replacement"]] = pair["original"]
The /no_think suffix tells the model to skip its chain-of-thought β faster response, same accuracy for this task.
POST /deanonymize β no model call, just substitution:
for replacement, original in sorted(mapping.items(), key=lambda x: len(x[0]), reverse=True):
text = text.replace(replacement, original)
Sorted by length descending so longer tokens don’t get partially overwritten by shorter ones.
The Kubernetes stack
Ollama runs as a separate deployment in the same namespace as everything else (web-ai-engine). Intra-namespace traffic is always allowed β no new network policies.
llm-anonymizer (FastAPI) β Ollama (port 11434) β Anonymizer-1.7B GGUF
One-time model pull after first deploy:
kubectl exec -n web-ai-engine deploy/ollama -- \
ollama pull hf.co/gabriellarson/Anonymizer-1.7B-GGUF
Ollama caches it on a 10Gi PVC, so pod restarts don’t re-download.
The n8n pipeline
Five-node chain triggered by webhook:
Webhook β /anonymize β NVIDIA NIM β /deanonymize β Respond
The NVIDIA NIM call includes a system prompt instructing it to treat the text as normal input. No mention of tokens, no special handling β because the text looks like real text.
Wire any upstream source to the webhook: Jira event, Slack slash command, a scheduled job that processes internal docs. The pipeline is source-agnostic.
The caveats
1.7B isn’t GPT-4.1. The model scores 9.20/10 on the benchmark β which means roughly 1 in 10 cases has a missed or incorrect entity. Test with real examples from your domain before depending on it.
Deanonymization breaks on heavy rephrasing. If the cloud model restructures a sentence enough that the fake value no longer appears verbatim, the substitution silently misses it. The prompt helps but doesn’t eliminate the risk.
Ollama adds a deployment. It’s ~500MB image + the model weights (~1GB Q4). On a constrained single-node cluster that’s real overhead. llama-server already covers general chat; Ollama is purely for this model’s tool-calling support.
Source
github.com/janos-gyorgy/llm-anonymizer β MIT licensed, Kubernetes manifests and n8n workflow included.
