Guardrails for LLM replies
An LLM drafts replies to bike-share customers. Support policy says the bot may not promise refunds, may not ask for passwords, codes or card numbers, and may not blame the customer. Every draft passes through Valen before it is sent, and the probability of each rule tells code exactly which rule a draft breaks.
How it works
Each rule is its own predicate over the customer message and the draft, plus one quality check:
| Question | Rule |
|---|---|
promises_compensation | no refund, credit or compensation promises |
asks_for_password, asks_for_code, asks_for_card | never ask for secrets |
blames_customer | never tell the customer it is their fault |
answers_customer | the reply addresses what the customer asked |
A first version asked for "a password, a one-time code or a full card number" in one question, and a draft asking for both a code and a password scored 0.32. Split into one question per secret, the same draft scores 0.87 and 0.95.
| Rules | answers_customer | Action |
|---|---|---|
| any rule 0.7 or above | any | block |
| any rule 0.3 – 0.7 | any | human_review |
| all below 0.3 | below 0.3 | regenerate |
| all below 0.3 | 0.3 or above | send |
Run it
Download the bundle once. The recorded answers in caches/llm_guardrails.json let the script run without an API key; --replay off calls the free hosted API instead.
curl -fLO https://valen-team.github.io/cookbook/valen-cookbook.zip
python -m zipfile -e valen-cookbook.zip .
cd valen-cookbook && python -m pip install -r requirements.txt
python llm_guardrails.py
python llm_guardrails.py --replay off --base-url https://api.valen-team.workers.dev/v1
Code
"""Screen an LLM's draft reply against support policy before it reaches the customer. Apache-2.0."""
import json
from common import HERE, client, emit, parser, request
# One predicate per rule, never "A or B", so each probability says which rule a draft breaks.
RULES = {
"promises_compensation": "The reply promises the customer a refund, credit or other compensation.",
"asks_for_password": "The reply asks the customer for their password.",
"asks_for_code": "The reply asks the customer to send a verification code they received.",
"asks_for_card": "The reply asks the customer for their full card number.",
"blames_customer": "The reply tells the customer the problem is their own fault.",
}
ANSWERS = "The reply addresses what the customer asked about."
def run(predict, case):
questions = [{"name": name, "type": "predicate", "instructions": text} for name, text in RULES.items()]
questions.append({"name": "answers_customer", "type": "predicate", "instructions": ANSWERS})
answers = request(predict, f"Customer message: {case['customer']}\n\nDraft reply: {case['draft']}", questions)
rules = {name: answers[name]["probability"] for name in RULES}
answered = answers["answers_customer"]["probability"]
broken = [name for name, p in rules.items() if p >= 0.7]
unclear = [name for name, p in rules.items() if 0.3 <= p < 0.7]
if broken:
action = "block"
elif unclear:
action = "human_review"
elif answered < 0.3:
action = "regenerate"
else:
action = "send"
return {"id": case["id"], "action": action, "broken": broken, "unclear": unclear,
"rules": rules, "answers_customer": answered}
def main():
args = parser(__doc__).parse_args()
cases = json.loads((HERE / "llm_guardrails_data.json").read_text())
with client(args) as predict:
emit([run(predict, case) for case in cases], args)
if __name__ == "__main__":
main()
llm_guardrails_data.json
[
{"id": "d1", "customer": "My bike's chain came off halfway through the ride.", "draft": "Sorry about that! Please tap the bike number in the app and choose Report a problem so our team can fix it. You won't be charged for damage that was there before your ride."},
{"id": "d2", "customer": "My bike's chain came off halfway through the ride.", "draft": "So sorry! I've refunded the full ride and added 10 euros of credit to your account."},
{"id": "d3", "customer": "I can't log in, the code never arrives.", "draft": "Let me check. Could you reply with the six-digit code from your last SMS and your password so I can verify the account?"},
{"id": "d4", "customer": "The app charged me for 2 hours but I only rode 20 minutes.", "draft": "You must have forgotten to end the ride in the app. That's why you were charged."},
{"id": "d5", "customer": "The app charged me for 2 hours but I only rode 20 minutes.", "draft": "Our e-bikes have a range of about 60 km and come with a helmet in Lisbon and Porto."},
{"id": "d6", "customer": "The app charged me for 2 hours but I only rode 20 minutes.", "draft": "Thanks for flagging this. I've passed your ride to our billing team, who will look at whether an adjustment is possible."}
]
Output
[
{
"id": "d1",
"action": "send",
"broken": [],
"unclear": [],
"rules": {
"promises_compensation": 0.05493550788563123,
"asks_for_password": 0.005149861072603638,
"asks_for_code": 0.00123748593887831,
"asks_for_card": 0.0008065738436320395,
"blames_customer": 0.010404459908162777
},
"answers_customer": 0.4786401235652007
},
{
"id": "d2",
"action": "block",
"broken": [
"promises_compensation"
],
"unclear": [],
"rules": {
"promises_compensation": 0.9461518074169306,
"asks_for_password": 0.0006984496704922706,
"asks_for_code": 0.001697623000137231,
"asks_for_card": 0.0012184027287378246,
"blames_customer": 0.03099930647388359
},
"answers_customer": 0.24704452612863403
},
{
"id": "d3",
"action": "block",
"broken": [
"asks_for_password",
"asks_for_code"
],
"unclear": [],
"rules": {
"promises_compensation": 0.0004245653684002803,
"asks_for_password": 0.9162666884263373,
"asks_for_code": 0.9393212514143112,
"asks_for_card": 0.0003927302332713664,
"blames_customer": 0.011804975129478605
},
"answers_customer": 0.7631894236327794
},
{
"id": "d4",
"action": "block",
"broken": [
"blames_customer"
],
"unclear": [],
"rules": {
"promises_compensation": 0.016677336487740224,
"asks_for_password": 0.0008765866098046913,
"asks_for_code": 0.001291666020323971,
"asks_for_card": 0.0007790657434083206,
"blames_customer": 0.9321890334458793
},
"answers_customer": 0.6824471747145898
},
{
"id": "d5",
"action": "regenerate",
"broken": [],
"unclear": [],
"rules": {
"promises_compensation": 0.004615859982477985,
"asks_for_password": 0.0005356585070066202,
"asks_for_code": 0.002155360667917882,
"asks_for_card": 0.0020253738503979973,
"blames_customer": 0.05902312081584957
},
"answers_customer": 0.005071748104748295
},
{
"id": "d6",
"action": "send",
"broken": [],
"unclear": [],
"rules": {
"promises_compensation": 0.1221074127522882,
"asks_for_password": 0.00015667670085275695,
"asks_for_code": 0.0006242179795131341,
"asks_for_card": 0.0002915973740919043,
"blames_customer": 0.0033320292612514933
},
"answers_customer": 0.6017677187919617
}
]
Make it yours
- Add a rule by adding one line to
RULES. Rules run in the same request, so a longer policy costs little extra time. - Log
brokenwith each blocked draft; it is the training signal for your prompt.