Skip to main content
valen

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:

QuestionRule
promises_compensationno refund, credit or compensation promises
asks_for_password, asks_for_code, asks_for_cardnever ask for secrets
blames_customernever tell the customer it is their fault
answers_customerthe 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.

Rulesanswers_customerAction
any rule 0.7 or aboveanyblock
any rule 0.3 – 0.7anyhuman_review
all below 0.3below 0.3regenerate
all below 0.30.3 or abovesend

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.

Bash
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​

llm_guardrails.py
"""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
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​

Recorded 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 broken with each blocked draft; it is the training signal for your prompt.