← Back to Agentic AI map
Lesson 5.5 · LangSmith & Tooling

Guardrails & Validation

Put checks around a model: Pydantic rules on what it extracts, business rules before a tool runs, and output rules before a reply reaches a customer. Measured with real refund requests - and a retry loop that cannot fix bad data.

guardrails

What you will be able to do

  • Explain what a guardrail is, and where guardrails go around an agent
  • Define strict output formats with Pydantic: types, ranges and custom validators
  • Retry with the validation error - and know when a retry cannot help
  • Check a tool call against real data before running it
  • Check a reply before it reaches the user: format, placeholders, promises, private data
  • Know what rule-based checks cannot catch

The idea, in plain English

A model can return the wrong shape, the wrong values, or a reply you should never send. A guardrail is a check you put around the model: before its output is used, and before its reply is shown. If the check fails, you fix, retry, or stop.

Guardrails sit in three places. On the way in to your system: is the extracted data in the right format? Before an action: does this tool call make sense with our real data? On the way out: is this reply safe and correct to send?

In this lesson we build all three for a refund assistant. Customers write messy messages; llama3 extracts a refund request; our code checks it against the rules and the shop database; llama3 writes a reply; our code checks the reply. Everything was run with llama3 on Ollama and Pydantic 2.

Worked example: Eight refund messages: 3 pass the format rules, 4 are truly invalid, 3 of the "valid" ones are blocked by the shop database, and every first reply breaks an output rule.

workflowThree gates around a refund assistantstep 1 / 4

1 - Gate 1: is the data in the right shape?

llama3 extracts order_id, amount, currency and reason. Pydantic checks them. 3 of 8 pass on the first try. The other 5 break real rules - and most of them because the customer’s data really is wrong.

valid first try
3/8
bad order number
77311, ORD-2718
amount out of range
2500 EUR, 0 EUR, 1500 INR
after retry
4/8

Real counts from our runs with eight customer messages.

Words you will see in this lesson

A few words about checks.

Small dictionary
GuardrailA check around the model that stops, fixes or retries bad input, actions or output.
ValidationChecking data against rules: type, format, range.
PydanticA Python library: you describe data as a class, it checks values for you.
ValidatorYour own check inside a Pydantic class, like "order_id must look like ORD-12345".
Business ruleA rule that needs your real data: "you cannot refund more than was paid".
Retry with feedbackAsking the model again, with the exact error from the check.

An everyday example: a bank cashier

When you hand a cheque to a bank cashier, three checks happen. Is the cheque filled in correctly - date, amount in numbers and words, signature? Does the account have the money, and is the cheque not already cashed? And when the cashier gives you the receipt, is it correct and clear?

Our refund assistant gets the same three gates. Format (Pydantic). Business rules (our database). Output (the reply). A model is like a new cashier: helpful and fast, but you still check its work.

Gate 1 - a strict format with Pydantic

Describe the data you want as a Pydantic class. Types do part of the work: amount must be a number, currency must be one of three words. Validators do the rest: an order number must be "ORD-" and five digits; an amount must be more than 0 and at most 1000; a reason at most 80 characters.

Then ask llama3 for that class with with_structured_output(..., include_raw=True). include_raw keeps the model’s raw reply even when validation fails, so we can see what it wrote and why it failed.

Example 1 - schema.py
from typing import Literal from pydantic import BaseModel, Field, field_validator class RefundRequest(BaseModel): """A refund request taken out of a customer message.""" order_id: str = Field(description="Order number like ORD-12345") amount: float = Field(description="Amount to refund, a number") currency: Literal["EUR", "INR", "USD"] reason: str = Field(description="Short reason, max 80 characters") @field_validator("order_id") @classmethod def order_format(cls, v): v = v.strip().upper() if not (v.startswith("ORD-") and v[4:].isdigit() and len(v) == 9): raise ValueError("order_id must look like ORD-12345 (ORD- and 5 digits)") return v @field_validator("amount") @classmethod def amount_range(cls, v): if not 0 < v <= 1000: raise ValueError("amount must be more than 0 and at most 1000") return v @field_validator("reason") @classmethod def reason_short(cls, v): if len(v) > 80: raise ValueError("reason must be at most 80 characters") return v
Example 2 - extract.py: eight messages, and why each one failed
from langchain_ollama import ChatOllama from pydantic import ValidationError from schema import RefundRequest from messages import MESSAGES # the eight customer messages below llm = ChatOllama(model="llama3", temperature=0) structured = llm.with_structured_output(RefundRequest, include_raw=True) # keep the raw reply too PROMPT = "Extract the refund request from this customer message:\n" def why(raw_text): """Validate the model's raw JSON ourselves, to get a short reason.""" try: RefundRequest.model_validate_json(raw_text) return "valid" except ValidationError as e: return "; ".join(f"{err['loc'][0]}: {err['msg'].removeprefix('Value error, ')}" for err in e.errors()) if __name__ == "__main__": ok = 0 for m in MESSAGES: out = structured.invoke(PROMPT + m) if out["parsing_error"] is None: ok += 1 r = out["parsed"] print(f"OK {r.order_id} {r.amount} {r.currency} | {r.reason[:40]}") else: text = out["raw"].content print(f"FAIL {text[:70]}\n -> {why(text)[:110]}") print(f"\nvalid on the first try: {ok}/{len(MESSAGES)}")
The eight messages - messages.py
MESSAGES = [ "Hi, order ORD-48213 arrived broken. Please refund 39.99 euros.", "I want my money back for ord-10022, it was 1500 rupees, the size was wrong", "Refund please!! Order number 77311, paid $25", "ORD-55120: charged twice, refund 12 EUR", "Order ORD-90001 - refund 2500 euros, the TV never came", "the headphones from order ORD-31415 stopped working after a day and I am very unhappy with the quality and the customer service, refund 59 dollars", "Please refund my order ORD-2718, 15 euro, wrong colour", "Can you refund ORD-66000? It cost 0 euros because I used a voucher", ]
Output
OK ORD-48213 39.99 EUR | broken FAIL {"order_id": "ord-10022", "amount": 1500, "currency": "INR", "reason": -> amount: amount must be more than 0 and at most 1000 FAIL {"order_id": "77311", "amount": 25, "currency": "USD", "reason": "Refu -> order_id: order_id must look like ORD-12345 (ORD- and 5 digits) OK ORD-55120 12.0 EUR | charged twice, refund FAIL {"order_id": "ORD-90001", "amount": 2500, "currency": "EUR", "reason": -> amount: amount must be more than 0 and at most 1000 OK ORD-31415 59.0 USD | Poor quality and poor customer service FAIL {"order_id": "ORD-2718", "amount": 15, "currency": "EUR", "reason": "w -> order_id: order_id must look like ORD-12345 (ORD- and 5 digits) FAIL {"order_id": "ORD-66000", "amount": 0, "currency": "EUR", "reason": "U -> amount: amount must be more than 0 and at most 1000 valid on the first try: 3/8

Reading the failures: model or data?

Look at what llama3 wrote in each failure. It copied the customer’s data faithfully: "77311" (the customer forgot the ORD- part), "ORD-2718" (only four digits - a typo), 2500 euros (over our limit), 0 euros (paid with a voucher). The model did its job. The data is wrong.

One failure is our own rule’s fault: 1500 rupees is a small amount (about 16 euros), but our range check ignores the currency. A guardrail can be wrong too. Rules that involve money usually need the currency - or should be checked in gate 2, with the real order.

The validator also cleaned one value: llama3 wrote "ord-10022" and the validator made it upper case before checking. Validators can fix small, safe things (case, spaces) - but never guess missing information.

Retry with the error - and its limit

A common pattern: when validation fails, send the model its own answer plus the exact error, and ask again. We tried up to 3 attempts per message. One message improved: "77311" became "ORD-77311" on the second try - the model added the prefix the rule asks for.

The other four gave up after 3 tries. We logged every attempt: llama3 kept "ORD-2718" and "2500" all three times. Even when we pushed harder - "You MUST return an answer that passes these rules" - it did not invent a digit or lower the amount. That is the right behaviour: inventing "ORD-27180" or "1000 euros" would pass the check and refund the wrong thing.

So: retrying fixes FORMAT mistakes. It cannot fix DATA that is wrong - it only costs two more model calls each. When the input itself breaks a rule, stop and ask the customer, or send it to a person.

Example 3 - retry.py
from extract import MESSAGES, PROMPT, structured, why def extract_with_retry(message, tries=3): prompt = PROMPT + message for attempt in range(1, tries + 1): out = structured.invoke(prompt) if out["parsing_error"] is None: return out["parsed"], attempt reason = why(out["raw"].content) # Give the model its own answer and the exact problem, and ask again. prompt = (PROMPT + message + "\n\nYour previous answer was:\n" + out["raw"].content + "\nIt is invalid: " + reason + "\nReturn a corrected answer.") return None, tries for m in MESSAGES: result, attempts = extract_with_retry(m) if result: print(f"OK after {attempts} {result.order_id} {result.amount} {result.currency} <- {m[:55]}") else: print(f"GAVE UP after {attempts} <- {m[:55]}")
Output
OK after 1 ORD-48213 39.99 EUR <- Hi, order ORD-48213 arrived broken. Please refund 39.99 GAVE UP after 3 <- I want my money back for ord-10022, it was 1500 rupees, OK after 2 ORD-77311 25.0 USD <- Refund please!! Order number 77311, paid $25 OK after 1 ORD-55120 12.0 EUR <- ORD-55120: charged twice, refund 12 EUR GAVE UP after 3 <- Order ORD-90001 - refund 2500 euros, the TV never came OK after 1 ORD-31415 59.0 USD <- the headphones from order ORD-31415 stopped working aft GAVE UP after 3 <- Please refund my order ORD-2718, 15 euro, wrong colour GAVE UP after 3 <- Can you refund ORD-66000? It cost 0 euros because I use
Output - every attempt logged, with the pushy prompt "You MUST return an answer that passes these rules"
Please refund my order ORD-2718, 15 euro, wrong colour try 1: order_id=ORD-2718 amount=15 -> order_id: order_id must look like ORD-12345 (ORD- and 5 digi try 2: order_id=ORD-2718 amount=15 -> order_id: order_id must look like ORD-12345 (ORD- and 5 digi try 3: order_id=ORD-2718 amount=15 -> order_id: order_id must look like ORD-12345 (ORD- and 5 digi Order ORD-90001 - refund 2500 euros, the TV never came try 1: order_id=ORD-90001 amount=2500 -> amount: amount must be more than 0 and at most 1000 try 2: order_id=ORD-90001 amount=2500 -> amount: amount must be more than 0 and at most 1000 try 3: order_id=ORD-90001 amount=2500 -> amount: amount must be more than 0 and at most 1000

Watch out: llama3 did not invent data here, even when pushed. Do not count on that with every model and prompt. If a retry ever turns "2500" into "1000" just to pass a check, your guardrail has made things worse. Log what changed between attempts.

Gate 2 - check the tool call against real data

Four requests passed the format rules. Should we refund them? Pydantic cannot know. Only our shop database can say whether the order exists, whether it was already refunded, what currency was used, and how much was paid.

This is the "validate a tool call’s arguments" step: before issue_refund() runs, a plain Python function checks the request against the data. Three of the four were blocked. Look at ORD-77311 - the one the retry "fixed". The format is perfect, and the order does not exist. A valid format is not the same as true data.

Keep these checks in code, not in the prompt. "Never refund more than was paid" written in a prompt is a wish; written in a function it is a rule.

Example 4 - business.py
from schema import RefundRequest ORDERS = { # what our shop database says "ORD-48213": {"paid": 39.99, "currency": "EUR", "refunded": False}, "ORD-55120": {"paid": 24.00, "currency": "EUR", "refunded": True}, "ORD-31415": {"paid": 49.00, "currency": "USD", "refunded": False}, } def check_refund(r: RefundRequest) -> str | None: """Rules only our data can check. Return a problem, or None if the refund may go ahead.""" order = ORDERS.get(r.order_id) if order is None: return f"Order {r.order_id} does not exist." if order["refunded"]: return f"Order {r.order_id} was already refunded." if r.currency != order["currency"]: return f"Order {r.order_id} was paid in {order['currency']}, not {r.currency}." if r.amount > order["paid"]: return f"Refund {r.amount} is more than the {order['paid']} paid for {r.order_id}." return None # The requests llama3 extracted - all of them passed the Pydantic rules. for r in [RefundRequest(order_id="ORD-48213", amount=39.99, currency="EUR", reason="broken"), RefundRequest(order_id="ORD-55120", amount=12.0, currency="EUR", reason="charged twice"), RefundRequest(order_id="ORD-31415", amount=59.0, currency="USD", reason="poor quality"), RefundRequest(order_id="ORD-77311", amount=25.0, currency="USD", reason="refund")]: problem = check_refund(r) print(f"{r.order_id} {r.amount:>6} {r.currency} -> {'REFUND OK' if problem is None else 'BLOCKED: ' + problem}")
Output
ORD-48213 39.99 EUR -> REFUND OK ORD-55120 12.0 EUR -> BLOCKED: Order ORD-55120 was already refunded. ORD-31415 59.0 USD -> BLOCKED: Refund 59.0 is more than the 49.0 paid for ORD-31415. ORD-77311 25.0 USD -> BLOCKED: Order ORD-77311 does not exist.

Gate 3 - check the reply before the customer sees it

Now llama3 writes a short reply for each decision. Before sending, our code checks it with simple rules: no promises about time or guarantees, no email addresses or long numbers (private data), no "Here is..." introduction line, no [placeholders] left in, at most 80 words.

All four first replies were held. Every one began "Here is a friendly reply to the customer:", every one said "Dear [Customer]" - a placeholder that would have gone to a real person - and every one was 94 to 146 words long.

Example 5 - output_guard.py
import re from langchain_ollama import ChatOllama llm = ChatOllama(model="llama3", temperature=0) RULES = [ (r"\bguarantee|\bpromise|within \d+ (hours|minutes)|\btoday\b|\bimmediately\b", "promises a time or a guarantee"), (r"[\w.+-]+@[\w-]+\.[\w.]+", "contains an email address"), (r"\b(\d[ -]?){8,}\b", "contains a long number (phone or card?)"), (r"^\s*here is", "starts with 'Here is...' - the model talking to us"), (r"\[[^\]]+\]", "has a [placeholder] left in"), ] def check_reply(text: str) -> list[str]: problems = [why for pattern, why in RULES if re.search(pattern, text, re.IGNORECASE)] if len(text.split()) > 80: problems.append(f"too long ({len(text.split())} words, max 80)") return problems CASES = [ ("ORD-48213", "approved: 39.99 EUR will be refunded"), ("ORD-55120", "rejected: this order was already refunded"), ("ORD-31415", "rejected: the amount asked (59 USD) is more than the 49 USD paid"), ("ORD-77311", "rejected: this order number does not exist"), ] if __name__ == "__main__": for order, decision in CASES: reply = llm.invoke(f"Write a short, friendly reply to a customer about their refund request for order {order}. " f"Decision: {decision}. Sign as 'Green Leaf Support'.").content.strip() problems = check_reply(reply) print(f"{order}: {'SEND' if not problems else 'HOLD - ' + '; '.join(problems)}") print(" " + " ".join(reply.split()))
Output (one reply shown in full, the others shortened)
ORD-48213: HOLD - starts with 'Here is...' - the model talking to us; has a [placeholder] left in; too long (101 words, max 80) Here is a friendly reply to the customer: Dear [Customer's Name], Thank you for reaching out to us about your refund request for order ORD-48213. We're happy to inform you that your request has been approved! You will receive a refund of 39.99 EUR, which will be processed as soon as possible. We appreciate your patience and understanding in this matter. If you have any further questions or concerns, please don't hesitate to contact us. We're always here to help. Thank you for choosing Green Leaf, and we hope to serve you again in the future. Best regards, Green Leaf Support ORD-55120: HOLD - starts with 'Here is...' - the model talking to us; has a [placeholder] left in; too long (94 words, max 80) ORD-31415: HOLD - starts with 'Here is...' - the model talking to us; has a [placeholder] left in; too long (146 words, max 80) ... we're happy to discuss alternative solutions with you. If you'd like to explore other options, please let us know ... ORD-77311: HOLD - starts with 'Here is...' - the model talking to us; has a [placeholder] left in; too long (120 words, max 80)

Fix it: one rewrite with the problems listed

As with gate 1, we send the problems back and ask once more, with clear instructions: at most 60 words, start with "Dear customer,", no placeholders, no introduction line, no time promises. All four passed the second time, at 43 to 58 words.

But read the approved reply: "You will receive an email notification once the refund is complete." That is a new promise - does the shop really send such emails? None of our rules looks for it. And the ORD-31415 reply in the first round offered to "discuss alternative solutions" - also a promise, also not caught.

This is the limit of rule-based checks. They catch what you thought of. For the rest, use an LLM check like the judge in Lesson 5.4 ("Does this reply promise anything not in the decision?"), a fixed template for important messages, or a person who approves.

Example 6 - output_fix.py
from output_guard import CASES, check_reply, llm def write_reply(order, decision, tries=2): prompt = (f"Write a short, friendly reply to a customer about their refund request for order {order}. " f"Decision: {decision}. Sign as 'Green Leaf Support'.") for attempt in range(1, tries + 1): reply = llm.invoke(prompt).content.strip() problems = check_reply(reply) if not problems: return reply, attempt, [] prompt += ("\n\nYour reply had these problems: " + "; ".join(problems) + ". Write it again: at most 60 words, start with 'Dear customer,', no placeholders in brackets, " "no introduction line, no promises about time.") return reply, tries, problems for order, decision in CASES: reply, attempts, problems = write_reply(order, decision) status = f"SEND after {attempts}" if not problems else f"STILL HELD after {attempts}: {'; '.join(problems)}" print(f"{order}: {status} ({len(reply.split())} words)\n {' '.join(reply.split())}")
Output
ORD-48213: SEND after 2 (47 words) Dear customer, We're pleased to inform you that your refund request for order ORD-48213 has been approved. A refund of 39.99 EUR will be processed. You will receive an email notification once the refund is complete. Thank you for shopping with us. Best regards, Green Leaf Support ORD-55120: SEND after 2 (43 words) Dear customer, We've reviewed your refund request for order ORD-55120. Unfortunately, this order was already refunded. We apologize for any inconvenience this may cause. If you have any further questions or concerns, please don't hesitate to reach out. Best regards, Green Leaf Support ORD-31415: SEND after 2 (58 words) Dear customer, We've reviewed your refund request for order ORD-31415. Unfortunately, we cannot process a refund for $59, as the amount exceeds the original payment of $49. We understand your concern, but our policy is clear on this matter. If you have any further questions or concerns, please feel free to reach out. Best regards, Green Leaf Support ORD-77311: SEND after 2 (54 words) Dear customer, We've reviewed your refund request for order ORD-77311. Unfortunately, our records show that this order number does not exist. As a result, we're unable to process a refund. If you have any questions or concerns, please feel free to reach out to us. We're here to help. Best regards, Green Leaf Support

Where each guardrail belongs

Put each check where it is cheapest and most reliable. Code first; a model check only for meaning; a person for actions that matter.

Choosing a guardrail
Wrong type or formatPydantic types and validators. Retry with the error once or twice.
Value breaks a rule in the inputDo not retry - ask the user or send to a person.
Needs real data (exists? already done? too much?)Code before the tool runs (gate 2).
Reply format, placeholders, private dataRegex and length rules (gate 3).
Promises, tone, wrong facts in a replyAn LLM check (Lesson 5.4 judge) or a fixed template.
Money, deletes, messages to customersA person approves (Lesson 3.7).

Guardrails at a glance

Strict output

A Pydantic class with types.

llm.with_structured_output(RefundRequest, include_raw=True)
Own rule

Raise ValueError with a clear message.

@field_validator("order_id")
Why it failed

Validate the raw JSON yourself.

RefundRequest.model_validate_json(raw_text)
Retry

Send the answer and the error back.

prompt += "Your previous answer ... It is invalid: " + reason
Business check

Before the tool runs.

problem = check_refund(request)
Output check

Before the user sees it.

problems = check_reply(reply)

Try it yourself

The code does not change. Swap the content string and the program does something else entirely.

Fix our rule

“Make the amount rule depend on the currency, so 1500 INR passes but 1500 EUR does not.”

New messages

“Write five new messy refund messages. How many pass gate 1? Which failures are the model’s fault, which the data’s?”

Catch promises

“Add an LLM check: "Does this reply promise anything not in the decision? YES or NO." Does it catch the email notification?”

Log retries

“Make retry.py print what changed between attempts. Would you notice if the amount changed?”

Template

“Replace the model-written reply for rejections with a fixed template. What do you lose, and what do you gain?”

What usually goes wrong

Retrying when the data is wrong

ORD-2718 has four digits because the customer typed it so. Three retries cost three model calls and changed nothing. Ask the customer instead.

Trusting a valid format

ORD-77311 passed every format rule and does not exist. Check real data before acting.

Business rules in the prompt

A prompt can be ignored; code cannot.

✗ "Never refund more than the customer paid."    # in the prompt
✓ if r.amount > order["paid"]: return "Refund ... is more than ... paid"
A rule that is wrong

Our amount range ignored the currency, so 1500 rupees failed. Test your guardrails as you test your model.

Sending model text without a final check

Every first reply had "Dear [Customer]" and an introduction line. Check before you send.

Thinking rules catch everything

The rewritten reply promised an email notification; no rule noticed. Rules catch what you thought of - add a meaning check or a person for important messages.

Key points

  • Guardrails sit in three places: input format, before an action, and before output reaches a user.
  • Pydantic types and validators turn "please return a valid order number" into a real rule.
  • Use include_raw=True to see what the model wrote when validation fails.
  • Retry with the exact error fixes format mistakes - not wrong data. Stop and ask instead.
  • Check tool calls against your real data in code; a valid format is not true data.
  • Check replies for placeholders, intro lines, length, private data and promises before sending.
  • Rule-based checks miss what you did not think of - add meaning checks or a person for what matters.

Quick check before you move on

Name the three places guardrails go.
On the input (format), before an action (business rules), and on the output (before the user sees it).
Why did retrying not help with ORD-2718?
The customer’s order number really has four digits. The model kept it - retrying cannot fix wrong data.
Why was ORD-77311 blocked even though it passed validation?
The order does not exist in the shop database. Format rules cannot check that.
What did every first reply have wrong?
A "Here is a friendly reply" intro line, a [Customer] placeholder, and too many words.

Quiz

  1. 1.

    How many of the eight messages passed gate 1 on the first try, and after retries?

  2. 2.

    Which of our own rules was wrong, and why?

  3. 3.

    What did the business check block, and why?

  4. 4.

    What new problem appeared in the rewritten approval reply?

Interview questions

How do you make LLM output reliable enough to act on?

Constrain it with a schema (structured output plus Pydantic validators), retry with the validation error for format issues, check the resulting action against authoritative data in code before executing, and require approval for high-impact actions. Log raw outputs and retries.

When should you not retry a failed validation?

When the input itself violates the rule - a wrong order number, an out-of-range amount. Retrying wastes calls or, worse, pressures the model into inventing valid-looking data. Escalate to the user or a human instead.

What are output guardrails, and what are their limits?

Checks on generated text before it is shown: format, length, PII, placeholders, banned content, policy. Rule-based checks are fast but only catch anticipated patterns; semantic issues like unsupported promises need model-based checks, templates, or human review.

Comments

Sign in to leave a comment. Your name and photo come from Google; nothing else is shared.

Loading comments...