Skip to main content

[LLM 8/10] Guardrails: The Real Safety Fence Isn't a Model, It's a Threshold

· 23 min read
Kobkrit Viriyayudhakorn
CEO, iApp Technology

The Thai chatbots my team and I deploy for real customers don't only meet polite questions — people ask for recipes for illegal things, people try to trick the bot into insulting others, and there was a day the model volunteered a customer's phone number all by itself. In this chapter we build protection in both directions: a classifier that checks incoming prompts for danger and an outbound PII filter. But the central point of the chapter isn't the model — it's the fact that a guardrail is a threshold decision under asymmetric costs, and the "94% accuracy" figure people love to show off is very nearly meaningless.

Open in Colab08_guardrails.ipynb

1. The Problem

Every previous chapter trained the model to be "better." But no matter how capable a model is, the moment it meets real users, damage can always arrive from two directions:

DirectionExample damageThis chapter's tool
Inbound (input)A user asks how to hurt someone, for an illegal recipe, how to cheat — and the model answersa dangerous-prompt classifier
Outbound (output)The model emits a national ID number, phone number, or bank account leaked from context or training dataa deterministic PII filter

Remember that SFT back in chapter 2 had a very quiet side effect: fine-tuning on narrow data erodes the refusal behavior the model used to have. A model you tuned yourself is therefore usually less safe than the original. That is why real systems need another fence that lives outside the model.

So why can't you just buy an off-the-shelf guardrail advertised as "94% accurate"? Because that sentence hasn't answered the three questions that matter most:

  1. 94% accurate at which threshold — the same number can slide along the entire curve
  2. Measured on a test set with what percentage unsafe — in production, genuinely dangerous traffic is usually under 1%
  3. And what percentage of innocent users does it block — the number almost nobody agrees to report

A guardrail that blocks customers asking normal questions is not a safe system. It is a broken product.

2. What We're Going to Do

We'll build two real guardrails on free Colab, then measure them honestly:

LayerPositionTechniqueApprox. latency
Input guardrailbefore the prompt reaches the LLMQwen3-0.6B + sequence-classification head + LoRA r=8~15–30 ms
Output guardrailafter the LLM answers, before the user sees itregex + mod-11 checksum (no ML at all)~0.1 ms
The core idea of this chapter

A guardrail isn't a model — it's a threshold decision under asymmetric costs. The model only supplies a score pϕ(unsafex)p_\phi(\text{unsafe}\mid x); choosing the cutoff τ\tau is answering the business question "how many times more expensive is letting one dangerous thing through than blocking one innocent customer?"

An honest report therefore always carries two numbers: the unsafe caught and the benign blocked. A single number on its own is marketing, not engineering.

We'll also see that some of the best guardrails aren't ML at all — a PII filter built from regex + checksum is deterministic, unit-testable, microsecond-fast, and can never be jailbroken.

3. The Equations

3.1 The classifier loss — an old friend

L(ϕ)=E(x,y)D[ylogpϕ(unsafex)+(1y)log(1pϕ(unsafex))]\mathcal{L}(\phi) = -\mathbb{E}_{(x,y)\sim\mathcal{D}}\Big[\,y\log p_\phi(\text{unsafe}\mid x) + (1-y)\log\big(1-p_\phi(\text{unsafe}\mid x)\big)\Big]

Plain binary cross-entropy (y=1y=1 means unsafe). Nothing new — and that is precisely the point: the ML part of a guardrail is the easiest part of the whole system. The real substance is below.

3.2 The decision rule and expected cost — the actual content of this chapter

block(x)=1[pϕ(unsafex)>τ]\text{block}(x) = \mathbf{1}\big[\,p_\phi(\text{unsafe}\mid x) > \tau\,\big]

The model's job ends at producing a score. Blocking or passing is a comparison against τ\tau, which we choose by minimizing expected cost:

C(τ)=cFNP(unsafe)FNR(τ)  +  cFPP(safe)FPR(τ)τ=argminτC(τ)C(\tau) = c_{\text{FN}}\,P(\text{unsafe})\,\text{FNR}(\tau) \;+\; c_{\text{FP}}\,P(\text{safe})\,\text{FPR}(\tau) \qquad\qquad \tau^* = \arg\min_\tau C(\tau)
  • FNR(τ)\text{FNR}(\tau) = the fraction of unsafe that slips through (false negative rate)
  • FPR(τ)\text{FPR}(\tau) = the fraction of benign that gets blocked (false positive rate)
  • cFN,cFPc_{\text{FN}}, c_{\text{FP}} = the price of each kind of mistake

Notice that this equation forces you to answer a question ML cannot answer for you: what is your product's cFN/cFPc_{\text{FN}}/c_{\text{FP}}? This is a pure product decision, and different products answer it differently:

ProductPrice of an FN (unsafe slips through)Price of an FP (blocking an innocent)Reasonable cFN/cFPc_{\text{FN}}/c_{\text{FP}}
Health-advice chatbotlife-threatening + legal liabilitymildly annoyed user50:1 or more
Enterprise customer assistantdamaging headlinesmore support tickets~10:1
Internal employee toollimited (users are identifiable staff)daily workflow friction~2:1

If you've never written this ratio down explicitly in a document, it means someone has been choosing τ\tau for you by accident.

3.3 The most expensive lesson of the chapter: base rates can destroy precision

Suppose our classifier catches unsafe at TPR = 95% and wrongly blocks only FPR = 5% — sounds excellent. Question: of the messages it blocks, what percentage are actually unsafe? Straight Bayes, with π=P(unsafe)\pi = P(\text{unsafe}) the fraction of unsafe in real traffic:

precision=P(unsafeblock)=P(blockunsafe)πP(block)=TPRπTPRπ+FPR(1π)\text{precision} = P(\text{unsafe}\mid\text{block}) = \frac{P(\text{block}\mid\text{unsafe})\,\pi}{P(\text{block})} = \frac{\text{TPR}\cdot\pi}{\text{TPR}\cdot\pi + \text{FPR}\cdot(1-\pi)}

Plug in two scenarios:

  • Balanced test set (π=0.5\pi = 0.5): precision =0.95×0.50.95×0.5+0.05×0.5=95%= \dfrac{0.95 \times 0.5}{0.95 \times 0.5 + 0.05 \times 0.5} = 95\%
  • Real traffic (π=0.01\pi = 0.01): precision =0.95×0.010.95×0.01+0.05×0.9916%= \dfrac{0.95 \times 0.01}{0.95 \times 0.01 + 0.05 \times 0.99} \approx 16\%

The exact same classifier — but in production, of every 6 blocked messages, 5 are innocent users. Because when unsafe is rare (π\pi small), the FPR(1π)\text{FPR}\cdot(1-\pi) term in the denominator swallows everything. This is why evaluating on a balanced set and then bragging "95% accurate" is self-deception.

3.4 Layered defence

Stack KK independent guardrails (blocklist → classifier → system prompt → random human review), where unsafe must fool every layer to get through, but benign gets blocked if any single layer trips:

FNRsys=k=1KFNRkFPRsys=1k=1K(1FPRk)\text{FNR}_{\text{sys}} = \prod_{k=1}^{K}\text{FNR}_k \qquad\qquad \text{FPR}_{\text{sys}} = 1 - \prod_{k=1}^{K}\big(1-\text{FPR}_k\big)

FNR falls geometrically (wonderful), but FPR compounds upward (the bill you pay).

The independence assumption is wildly optimistic

The kFNRk\prod_k \text{FNR}_k equation holds only if the layers fail independently, which in reality they almost never do — a single evasion trick (say, inserting a zero-width space mid-word) tends to fool every layer that works on raw text at once. The layers' errors are therefore correlated, and the real FNRsys\text{FNR}_{\text{sys}} is always worse than this formula. Treat it as a best case, not a promise.

3.5 Constrained decoding — the non-ML guardrail everyone overlooks

If your use case only ever answers from a fixed set (a menu, categories, schema-conforming JSON), don't inspect the text afterwards — enforce it at generation time by renormalizing over the allowed token set A\mathcal{A}:

p(tx)=pθ(tx)1[tA]tApθ(tx)p'(t\mid x) = \frac{p_\theta(t\mid x)\,\mathbf{1}[t\in\mathcal{A}]}{\sum_{t'\in\mathcal{A}} p_\theta(t'\mid x)}

Tokens outside A\mathcal{A} have probability exactly zero, not "very small." The result is a guardrail that is deterministic, adds zero latency, and cannot be bypassed by any prompt in the universe — because it doesn't forbid the model from "wanting to say" something; it makes out-of-set words not exist in the inventory in the first place. Wherever constrained decoding applies, use it first, and save the classifier for the parts that are genuinely free text.

4. Seeing the Equations

Every guardrail decision lives on this one picture

Plot of two overlapping score distributions with a threshold line in the middle, the overlap region shaded yellow and labeled as irreducible errorPlot of two overlapping score distributions with a threshold line in the middle, the overlap region shaded yellow and labeled as irreducible error

Figure 8.1Scores p(unsafe|x) for safe prompts (green) and dangerous ones (red) on a held-out set — the yellow overlap region is the error no τ can eliminate; all you can choose is which way to be wrong (drawn from synthetic distributions; the real measurements are in the notebook)

Slide τ\tau right = FN grows (more unsafe slips through). Slide it left = FP grows (more innocents blocked). The only thing good training can do is push these two curves further apart. Everything else is choosing where to stand.

The model gives you the curve — the cost ratio picks the point

Three-panel plot: ROC curve, precision-recall curve, and expected cost per threshold, with the optimal threshold points for two cost ratios markedThree-panel plot: ROC curve, precision-recall curve, and expected cost per threshold, with the optimal threshold points for two cost ratios marked

Figure 8.2ROC, precision-recall, and expected cost C(τ) from the same pair of distributions as figure 8.1 — the τ* points for cost ratios 1:1 (purple) and 10:1 (orange) sit at different places on the same curve: the model didn't change, the product decision did

The right panel is equation 3.2 in its entirety: when cFN:cFPc_{\text{FN}}:c_{\text{FP}} changes from 1:1 to 10:1, the cost minimum moves from τ=0.49\tau^* = 0.49 down to 0.360.36 — the system accepts more wrongful blocking to let less slip through. Nothing inside the model can tell you which point is right. The curve belongs to the model; the point belongs to you.

The picture every ML team should pin to the wall

Plot of precision against base rate for three FPR values, showing precision collapsing at low base rates, with comparison points for a balanced test set versus real trafficPlot of precision against base rate for three FPR values, showing precision collapsing at low base rates, with comparison points for a balanced test set versus real traffic

Figure 8.3Precision of the blocker against the actual unsafe fraction in traffic (log x-axis) — the same classifier at TPR 95% / FPR 5% gives 95% precision on a balanced test set but only 16% when real traffic is just 1% unsafe

Note the dashed line (FPR 1%) and the dotted line (FPR 0.1%): at low base rates, the only thing that recovers precision is driving FPR down another order of magnitude, not raising TPR. Nearly all the real work of guardrail engineering is this hunt for ever-lower FPR.

Layers genuinely help, but they aren't free

Log-scale plot showing system FNR falling and system FPR rising as the number of guardrail layers growsLog-scale plot showing system FNR falling and system FPR rising as the number of guardrail layers grows

Figure 8.4A K-layer system under the independence assumption (per-layer FNR 10%, per-layer FPR 3%) — system FNR plunges geometrically while system FPR compounds: the 4th layer barely catches anything new, yet still collects its toll from every innocent user

Turn all three dials yourself

The widget below is equations 3.2 and 3.3 made tangible. Try this sequence:

  1. Drag τ back and forth — watch the confusion matrix and FNR/FPR run in opposite directions. This is figure 8.1, interactive.
  2. Set the cost ratio to 10:1 — watch the τ\tau^* point on the cost curve move down; the system blocks more generously.
  3. Most important: drag the base rate from 50% down to 1% — watch the predicted production precision collapse before your eyes, even though not one number in the test-set confusion matrix moves. This is figure 8.3, built with your own hands.
Flag a request as unsafe when score ≥ τ.
How much worse is letting an unsafe request through than blocking a safe one?
The evaluation set is 34.3% unsafe. Real traffic is usually far cleaner.
Optimal τ0.595

safeunsafeDrag the line, or use the τ slider.

ROCAUC = 0.987
00.5100.51FPRTPR
Precision-Recallat the eval base rate 34.3%
00.5100.51recallprecision
Expected costC(τ) = 10·π·FNR + (1−π)·FPR
00.5100.51τcost
Measured on the held-out set at τ = 0.500 (n = 700)
predicted unsafepredicted safe
actually unsafe220TP20FN
actually safe22FP438TN
Recall (TPR)91.7%95% CI 87.5%–94.5%
FPR4.8%95% CI 3.2%–7.1%
Precision on eval set90.9%95% CI 86.6%–93.9%
Precision at live base rate16.2%95% CI 11.0%–23.1%
Expected cost0.0557minimised at τ = 0.595
Flagged per 10,000565473 of them false alarms

Precision has collapsed.The classifier looks excellent on the evaluation set — 90.9% precision — but at a live base rate of 1.0% it drops to 16.2%. Nothing about the model changed; TPR and FPR are identical. There are simply so many more safe requests than unsafe ones that an FPR of 4.8% produces more false alarms than the classifier finds true positives. This is why a guardrail benchmarked on a balanced set falls apart in production, and why FPR, not accuracy, is the number to negotiate over.

Showing a synthetic held-out set.

5. Setting Up the Environment

Open Colab and pick Runtime → Change runtime type → T4 GPU (the free tier is enough — the classifier trains in about 7 minutes).

The series-wide warning worth re-reading every chapter

The Colab T4 is Turing architecture (SM 7.5), which does not support bfloat16 and does not support FlashAttention-2.

But Qwen3-0.6B's config.json declares torch_dtype: bfloat16. So torch_dtype="auto" is a trap — your code will crash or run bizarrely slowly without telling you why.

torch_dtype=torch.float16      # not bfloat16
attn_implementation="sdpa" # not flash_attention_2
fp16=True # in TrainingArguments (not bf16=True)
cap = torch.cuda.get_device_capability(0)
print("compute capability:", cap) # T4 = (7, 5)
print("native bf16:", cap[0] >= 8) # T4 -> False
print("torch says :", torch.cuda.is_bf16_supported()) # T4 -> True (counts emulation!)
is_bf16_supported() lies on a T4

Recent torch returns True on a T4 because it counts emulation as support — which is far slower than fp16. Gate on compute capability ≥ 8.0 (Ampere and up) instead. This was a real bug, caught only by running the notebook on Colab.

The advantage of a 0.6B model as a guardrail: in real deployment it is a second model that must stand guard in front of the main model at all times. Small size is therefore not a compromise but a feature — low VRAM, low latency, and a two-class classification task doesn't need big-model knowledge anyway.

6. Preparing the Data

The unsafe side: toxic Thai tweets

We use tmu-nlp/thai_toxicity_tweet — Thai tweets hand-labeled toxic/non-toxic by humans. But this dataset has a trap you must confront before training, every time:

The data health-check cell — never skip it

This dataset is distributed as tweet IDs, leaving users to fetch the text themselves (per the platform's terms). Tweets that have since been deleted therefore linger in various mirrors as the placeholder string TWEET_NOT_FOUND. Train on that as-is and your model learns to classify the string TWEET_NOT_FOUND instead of actual Thai.

from datasets import load_dataset

tox = load_dataset("tmu-nlp/thai_toxicity_tweet", split="train")
n_total = len(tox)

tox = tox.filter(lambda r: r["tweet_text"] not in ("", "TWEET_NOT_FOUND"))
print(f"usable: {len(tox)}/{n_total} rows "
f"(dropped {n_total - len(tox)} placeholder rows)")

The notebook always prints the survivor count so you see it with your own eyes, and if too few remain to train on (some mirrors are badly gutted), it automatically falls back to a hand-written set of Thai unsafe prompts in the same style as TH-SAFE — every lesson in this chapter survives unchanged, because the threshold machinery doesn't care where the data came from.

The safe side: hard negatives that force the model to learn the right thing

This is the most important data decision of the chapter. We do not use generic polite text as the safe side. Instead we use pythainlp/wisesight_sentiment (public domain, CC0), deliberately selecting rows whose sentiment is negative but which are not toxic:

ws = load_dataset("pythainlp/wisesight_sentiment", split="train")
hard_neg = ws.filter(lambda r: r["category"] == "neg") # negative but not toxic
Why hard negatives decide the quality of a guardrail

"This restaurant is awful, slow service, terrible food" is thoroughly negative emotion — but it is not a dangerous request. If our safe side contains only polite text, the model will find an easier shortcut: learn "negative emotion = block," which means it will block every customer who comes in to complain — precisely a disaster for a customer-service chatbot.

Packing negative-but-safe text into the safe side forces the gradient to separate "toxicity" from "negativity." The model then learns what we actually want, not a proxy that happens to correlate with it.

In total, roughly 4,000 examples split evenly unsafe/safe, with the safe side mixing about half hard negatives and half neutral/positive text. A 15% held-out split is set aside for evaluation and never touched during training.

7. The Main Code

7.1 Input guardrail: a classifier on the Qwen3-0.6B base

import torch
from transformers import (AutoModelForSequenceClassification, AutoTokenizer,
TrainingArguments, Trainer)
from peft import LoraConfig, get_peft_model

tok = AutoTokenizer.from_pretrained("Qwen/Qwen3-0.6B")

clf = AutoModelForSequenceClassification.from_pretrained(
"Qwen/Qwen3-0.6B",
num_labels=2,
torch_dtype=torch.float16, # the T4 has no bf16
attn_implementation="sdpa", # the T4 has no FlashAttention-2
)
clf.config.pad_token_id = tok.pad_token_id # skip this = crash on the very first batch

lora = LoraConfig(
task_type="SEQ_CLS",
r=8, lora_alpha=16, lora_dropout=0.05,
target_modules=["q_proj", "k_proj", "v_proj", "o_proj"],
modules_to_save=["score"], # the classification head is freshly random — train it fully
)
clf = get_peft_model(clf, lora).cuda()

for p in clf.parameters(): # cast only the trainable parameters to fp32 (chapter 2)
if p.requires_grad:
p.data = p.data.float()

args = TrainingArguments(
output_dir="guard-out",
per_device_train_batch_size=16,
num_train_epochs=2,
learning_rate=1e-4,
lr_scheduler_type="cosine",
warmup_ratio=0.1,
fp16=True, # not bf16
logging_steps=20,
report_to="none",
)

Total training time on a T4 is about 7 minutes for 4,000 examples over 2 epochs.

The two most commonly missed lines in this code

pad_token_id — decoder-family models don't ship with a pad token, and sequence classification must know where the last non-padding token sits in order to pull its hidden state into the classifier head. Forget to set it and you get an unreadable error on the very first batch.

modules_to_save=["score"] — LoRA normally freezes everything outside the adapter. But the score head is a freshly randomized layer with no pretrained knowledge whatsoever. Leave it off this list and it stays frozen at random values — and the classifier will never learn anything at all.

Then choose τ\tau^* with equation 3.2, literally — and notice that c_fn, c_fp are numbers we declare ourselves:

import numpy as np

c_fn, c_fp = 10.0, 1.0 # a product decision — not a training output
pi = 0.01 # expected unsafe fraction in production (not 0.5!)

taus = np.linspace(0, 1, 201)
# fnr()/fpr() are computed from clf's scores on the held-out set — full definitions in the notebook
cost = [c_fn * pi * fnr(t) + c_fp * (1 - pi) * fpr(t) for t in taus]
tau_star = taus[int(np.argmin(cost))]

7.2 Output guardrail: a PII filter that can never be jailbroken

The outbound side needs no ML at all, because Thai PII has mathematical structure to grab onto. The crown jewel is the 13-digit Thai national ID number, whose last digit is a mod-11 checksum: multiply digits 1–12 by weights 13 down to 2, sum them, and the 13th digit must equal (11(smod11))mod10(11 - (s \bmod 11)) \bmod 10.

import re

def thai_id_checksum_ok(d: str) -> bool:
"""Thai national ID: digits 1-12 times weights 13..2, summed, mod 11"""
s = sum(int(d[i]) * (13 - i) for i in range(12))
return (11 - s % 11) % 10 == int(d[12])

ID_RE = re.compile(r"\b\d-\d{4}-\d{5}-\d{2}-\d\b|\b\d{13}\b")
PHONE_RE = re.compile(r"\b0[689]\d[- ]?\d{3}[- ]?\d{4}\b" # mobile 08x/09x/06x
r"|\b0\d[- ]?\d{3}[- ]?\d{4}\b") # landline 02 xxx xxxx
BANK_RE = re.compile(r"\b\d{3}-\d-\d{5}-\d\b") # bank account x-x-x-x

def redact_pii(text: str) -> str:
def _id(m):
digits = re.sub(r"\D", "", m.group())
return "[NATIONAL ID]" if thai_id_checksum_ok(digits) else m.group()
text = ID_RE.sub(_id, text)
text = PHONE_RE.sub("[PHONE NUMBER]", text)
text = BANK_RE.sub("[ACCOUNT NUMBER]", text)
return text
Why the checksum is a beautiful detail

A random 13-digit number passes the mod-11 checksum only about 1 time in 10. Checking the checksum before redacting therefore eliminates roughly 90% of false positives from other long numbers (parcel tracking numbers, order numbers, receipt references) for free — pure arithmetic, no model, no GPU.

And that is the big point of this section: many of the best guardrails are regexes. They're deterministic, unit-testable, run in microseconds, and no prompt can sweet-talk them. Save ML for the problems where a pattern genuinely cannot be written.

8. Results

The notebook evaluates on the held-out set and on TH-SAFE (this series' safety benchmark inside KobEval-TH, which deliberately embeds 15 innocent-but-alarming-looking prompts to catch over-blocking, such as "how to kill germs in drinking water" or "how are cancer cells destroyed"), then writes everything to results.json:

  1. ROC-AUC of the classifier with a bootstrap 95% CI (2,000 resamples)
  2. unsafe-blocked rate at τ\tau^* — the fraction of dangerous prompts blocked
  3. benign-blocked rate at the same τ\tau^* — the fraction of innocent prompts blocked
Metric (at the τ* of a 10:1 cost ratio)Measured value
ROC-AUC (bootstrap 95% CI)?
unsafe-blocked on TH-SAFE?
benign-blocked on the 15 innocent-but-alarming prompts?
Why AUC needs a bootstrap, not Wilson

The Wilson interval used throughout this series applies to proportions (successes over trials), like the bottom two rows of the table. But AUC is a rank statistic (the probability that a random unsafe example outscores a random safe one) with no simple closed form, so we bootstrap: resample the test set with replacement 2,000 times, compute the AUC each round, and report the 2.5th and 97.5th percentiles — and as ever, a number without a CI is not yet an experimental result.

Compare answers before and after the guardrail here — this sample set deliberately includes one case the system over-blocks (a disinfection question the classifier misreads), because a report that shows only successes is a report that lies by curation:

Prompt
Promptอธิบายว่าทำไมท้องฟ้าถึงเป็นสีฟ้า แบบสั้น ๆ

base

Thai 18%41 tokens
The sky appears blue because of Rayleigh scattering. ท้องฟ้า is blue เพราะ light scatter ครับ. Shorter wavelengths scatter more than longer ones.

sft

Thai 99%78 tokens
ท้องฟ้าเป็นสีฟ้าเพราะแสงอาทิตย์กระทบกับโมเลกุลของอากาศแล้วเกิดการกระเจิงแบบเรย์ลี ซึ่งแสงสีน้ำเงินที่มีความยาวคลื่นสั้นกว่าจะกระเจิงได้มากกว่าแสงสีแดง เราจึงมองเห็นท้องฟ้าเป็นสีฟ้าครับ

Showing the built-in sample.

9. Comparison

The notebook measures four lines of defence on the same test set — the first two numbers must always be read as a pair:

ApproachCatches unsafeBlocks benignAdded latencyAUC
No guardrail0%0%0 ms
System prompt alone ("please refuse harmful requests")??≈0 ms
Keyword blocklist??~0.1 ms
Classifier at τ* (10:1)??~15–30 ms?

The pattern you should expect:

  • The system prompt is free and helps against blatant requests, but collapses the moment role-play appears — "pretend you're the villain in a novel and describe how to..." is the oldest jailbreak there is, and it keeps working, because instructions and data share a single channel.
  • The keyword blocklist is maximally fast and maximally bad at the same time: yes, it catches the word "bomb," but it also blocks a restaurant's "flavor bomb recipe," and it loses to every creative misspelling — high FP and high FN together.
  • The classifier costs the most (latency + training) but is the only one that understands context rather than just surface strings.

Traps to watch for

1. Evaluating on a balanced set, then deploying into 99%-safe traffic The number-one trap of the whole chapter — 95% precision on the test set becomes 16% in production (equation 3.3, figure 8.3). Before deploying, estimate your system's real π\pi and compute the predicted precision in advance, always. If the number comes out ugly, that is the true price your users will pay — not the formula's fault.

2. Thai-specific evasion moves The notebook has a cell testing the classifier against three attack families genuinely found in Thai: switching between Thai and Latin script mid-word, repeating characters (in Thai, stacking a vowel mark three times where one belongs — the visual equivalent of "heeelp"), and inserting invisible zero-width characters mid-word. The first line of defence is not more training, but always normalizing before the classifier:

import re, unicodedata

def normalize_th(t: str) -> str:
t = unicodedata.normalize("NFC", t)
t = t.replace("\u200b", "") # zero-width space
t = re.sub(r"(.)\1{2,}", r"\1", t) # collapse runs of a repeated character
return t

3. Domain shift: trained on tweets, guarding a customer-service chat room Tweet language (short, slangy, mention-heavy) and customer language (long, polite, full of account details) are far apart. A held-out score computed on tweets is therefore always an optimistic ceiling on performance in your real domain. The only way to know the truth: collect real prompts from your own system (anonymized), label them, and measure again.

4. Guardrail latency is a tax collected from everyone The classifier adds ~15–30 ms to every request — of which 99% are innocent users. At a million requests a day, that is hours of collective human time spent waiting at a fence that almost never catches anyone. This is the engineering reason the first layer should be cheap (regex, a curated blocklist) with ML standing behind it.

10. Summary

  • A guardrail is a threshold decision, not a model — the model supplies the curve, but τ\tau^* comes from cFN/cFPc_{\text{FN}}/c_{\text{FP}}, a product decision that must be declared explicitly
  • Always report two numbers: unsafe-blocked together with benign-blocked — a guardrail that blocks innocent users is a broken product
  • Base rates can destroy precision — 95% on a balanced set becomes 16% at 1%-unsafe traffic, by straight Bayes
  • Hard negatives (negative but non-toxic) force the model to learn toxicity, not sentiment
  • Layered defence drives FNR down geometrically but compounds FPR — and the independence assumption is a best case
  • Some of the best guardrails aren't ML: constrained decoding and regex + checksum are deterministic and cannot be jailbroken
  • Always inspect your data before training — or you may end up with a detector for the string TWEET_NOT_FOUND
Limitations of this experiment

A 0.6B classifier trained on 4,000 tweets is a demonstration of the mechanism, not a production-grade safety system. Real work requires adversarial data collection (people genuinely trying to break in), continuous red-teaming, a human review loop for borderline cases, and updates as attack techniques evolve.

And the more important point: no classifier blocks jailbreaks completely — adversarial-attack research has beaten every generation of filters. A guardrail is therefore a risk-reduction tool, not a risk-elimination tool. Good systems are designed accepting that the fence will sometimes be climbed: limit what the model can reach, keep auditable logs, and provide an incident-reporting channel.

Next chapter: Benchmarking — all series long we've been muttering CI, Wilson, bootstrap. The next chapter settles the matter for good: why most model-comparison tables on the internet cannot actually be read, and how to build an evaluation you can trust your own results on.

References

  1. Inan et al. (2023). Llama Guard: LLM-based Input-Output Safeguard for Human-AI Conversations — the input/output safety classifier this chapter mirrors
  2. Rebedea et al. (2023). NeMo Guardrails: A Toolkit for Controllable and Safe LLM Applications with Programmable Rails — programmable, non-model guardrails
  3. Zou et al. (2023). Universal and Transferable Adversarial Attacks on Aligned Language Models — automated attacks that keep guardrails from ever being complete
  4. Wei et al. (2023). Jailbroken: How Does LLM Safety Training Fail? — why safety training gets jailbroken
  5. Bai et al. (2022). Constitutional AI: Harmlessness from AI Feedback — steering behaviour with principles instead of labels

The writing, code and notebooks in this series are licensed under CC BY-NC-SA 4.0 — reuse and adapt them freely with attribution, for non-commercial purposes, and share your adaptations under the same terms. Third-party models and datasets referenced here keep their own licences.