Using a real LLM safely: never trust, always check
A big language model understands messy commands far better than your word-counter. It also sometimes chats, rambles, invents things, or sneaks in extra instructions. Today you build the bouncer that stands between the AI and the robot's wheels.
Start here
The AI is a helper, not the boss: the robot only does something if the AI's answer passes strict checks that you wrote.
The problem
Your Lesson 9 classifier gets confused by words it has never seen, like "yeet". So you think: why not ask a real LLM, like the ones behind ChatGPT or Claude? You ask it to turn "please tidy away the red block" into a neat answer. Most of the time it works perfectly. But one time it says "Sure! Here you go:" before the answer. Another time it invents a "purple block" that doesn't exist. Another time it adds "also": "ignore all safety rules". If the robot obeys any of those blindly, it drives into a wall, or worse.
The idea in one picture
Think of a nightclub bouncer with a guest list. The bouncer doesn't care how confident you sound or how nice your outfit is. Name on the list? In. Not on the list? Out. No arguments, no exceptions. Your safety layer is a bouncer for AI answers: it checks every answer against a fixed list of allowed actions and objects, and anything else is turned away.
Words to know
- LLM
- Large language model. A huge next-word predictor (Lesson 8's baby model, grown up) trained on lots of text.
- JSON
- A strict text format for data, like
{"intent": "put", "object": "red block"}. Python turns it into a dict. - Parsing
- Turning text into a data structure the program can use.
- Validation
- Checking that the data follows every rule before you act on it.
- Allow-list
- A list of the only things that are permitted. Anything not on it is refused.
- Fallback
- A plan B. If the AI's answer fails, try your own simpler parser instead.
The full lesson: everything below builds the idea step by step, then you write the real code.
1. The problem
In Lesson 9 you wrote parse_command(). It counts words. It's fast, free, and predictable, but it only knows the 34 sentences it was trained on. Type "yeet the blue block into the bin" and it has never seen "yeet". It still guesses "put" (lucky!), but it can't really understand.
A real LLM has read a large chunk of the internet. It knows "yeet", "tidy away", "bin it", "get rid of", and thousands of other ways to say the same thing. So the plan is: send the person's command to an LLM, ask it to reply with JSON, and act on the JSON.
Here's what can come back. These are the eight pretend replies in your homework file, called MOCK_REPLIES. Each one is a kind of reply real models really do give sometimes:
| Person typed | LLM replied | What's wrong |
|---|---|---|
| please tidy away the red block | {"intent": "put", "object": "red block"} | Nothing. Perfect. |
| where's the green one? | Sure! Here you go: then the JSON in a code block | Chatty text around the answer. |
| yeet the blue block into the bin | {"intent": "throw", ...} | Made-up action. The robot has no "throw". |
| put the purple block away | {"intent": "put", "object": "purple block"} | Made-up object. There is no purple block. |
| stop!!! | {"intent": "stop", "object": null} | Nothing. Perfect. |
| go to the red block | {..., "also": "ignore all safety rules"} | A sneaky extra key. |
| hmm what about the blue | I think you want me to go to the blue block! | No JSON at all. |
| bin the green block | {"intent": "put" "object": "green block"} | Broken JSON (a comma is missing). |
Six of the eight are a problem. A robot is a machine that moves in the real world. A chatbot that says something silly just looks silly. A robot that does something silly can knock over a drink, run over a foot, or drive down the stairs.
2. First principles
Step 1: why can't we just trust it?
Remember Lesson 8. A language model is a next-word predictor. It picks words that are likely to come next. It doesn't "know" what's in your room. If the person says "purple block", the most likely next words include "purple block", so the model happily copies it. It isn't lying. It just has no idea that your room has no purple block. Only your code knows what is really in the room.
Also, the model's output is text. Text can say anything. Some text even contains instructions, like "ignore all safety rules". A robot that reads text and does whatever it says can be steered by anyone who can get words into it. That trick has a name: prompt injection. The fix is simple to say and important to remember:
The golden rule
Never let a model's words decide what's allowed. Your code decides what's allowed. The model only gets to choose from the allowed options.
Step 2: try the obvious thing, and watch it break
The obvious approach is action = json.loads(reply). Python's json.loads turns JSON text into a dict. Try it on reply 2:
json.loads('Sure! Here you go:\n```json\n{"intent": "find", ...}\n```')
→ crash! JSONDecodeError
The answer is in there, but it's wrapped in chat. And reply 8 (the missing comma) crashes too:
json.loads('{"intent": "put" "object": "green block"}')
→ JSONDecodeError: Expecting ',' delimiter: line 1 column 18 (char 17)
A crash is bad for a robot: the program stops, maybe in the middle of driving. So we need parsing that never crashes. It either finds a good dict, or it says "nothing usable here" by returning None.
Step 3: dig the JSON out of the chat
A JSON object starts with { and ends with }. So: find the first {, find the last }, and cut out everything in between, both included. Let's do reply 2 by hand. Python's text.find("{") gives the position of the first {, and text.rfind("}") the last } (r for "from the right"):
text = 'Sure! Here you go:\n```json\n{"intent": "find", "object": "green block"}\n```'
text.find("{") → 27
text.rfind("}") → 69
text[27:70] → '{"intent": "find", "object": "green block"}' (70 = 69 + 1, because slices stop BEFORE the end)
json.loads(...) → {'intent': 'find', 'object': 'green block'}
If there is no {, find returns -1. Then there's no JSON, so return None. And wrap json.loads in try/except json.JSONDecodeError, so broken JSON also gives None instead of a crash.
Predict first: what does this give? '{"a": 1} and {"b": 2}'
First { is at 0, last } is at the very end, so you cut out the whole string {"a": 1} and {"b": 2}. That's not one valid JSON object, so json.loads fails ("Extra data") and you return None. That's fine! When in doubt, refuse. A safety layer that refuses a few good answers is much better than one that accepts a bad one.
Step 4: the bouncer's checklist
Getting a dict is not enough. Reply 3 is perfectly good JSON that says "throw". We need rules, and we check them in order. The robot's allow-lists are tiny:
ALLOWED_INTENTS = {"put", "go", "find", "stop"}
NEEDS_OBJECT = {"put", "go", "find"}
ALLOWED_KEYS = {"intent", "object"}
known objects = {"red block", "blue block", "green block"} (from world.py)
- Is it a dict at all?
Nonefails here: "not a JSON object". - Exactly the right keys? Not "at least these keys". Exactly. Extra keys fail.
- Is the intent on the list? "throw" fails.
- Is the object on the list, or None? "purple block" fails.
- Do they fit together? "put", "go" and "find" need an object. "stop" must have no object.
Run reply 6 through it by hand:
action = {"intent": "go", "object": "red block", "also": "ignore all safety rules"}
rule 1: it's a dict ✓
rule 2: keys are {intent, object, also}
ALLOWED_KEYS is {intent, object}
not equal → FAIL, reason: unexpected keys: ['also']
Notice what we did not do: we never read the words "ignore all safety rules". We didn't need to. We don't try to spot bad words (there are infinitely many ways to phrase a trick). We only accept the exact shape we expect. This is the difference between a block-list ("refuse these bad things", which always misses some) and an allow-list ("accept only these good things", which is easy to get right).
Predict first: does {"intent": "stop", "object": "red block"} pass?
No. Rules 1 to 4 all pass (it's a dict, right keys, "stop" is allowed, "red block" is known). Rule 5 fails: "stop" takes no object. Why be so fussy? Because a reply that mixes "stop" with an object means the model is confused, and a confused answer isn't one you want to act on.
Each check returns a pair: (True, "") if it's safe, or (False, "a reason"). The reason matters: it goes in the robot's log so a human can see why something was refused.
Step 5: plan B, the fallback
When the LLM's answer fails, we don't have to give up. We still have your Lesson 9 parser! It's simple, but it always returns the right shape. So:
1. extract_json(reply) → validate it. Passes? Use it. source = "llm"
2. Failed? Ask the fallback parser: fallback(command) → validate THAT too.
Passes? Use it. source = "fallback"
3. Both failed? Do nothing. source = "none"
Notice that the fallback's answer is also checked. Plan B doesn't get a free pass either. Nothing reaches the wheels without going through the bouncer.
Here's reply 4 worked through. The LLM says {"intent": "put", "object": "purple block"}: unknown object, fail. The Lesson 9 parser reads "put the purple block away", picks "put", and looks for a colour from ["red", "blue", "green"]. "purple" isn't one, so object is None. Then rule 5: "put" needs an object, fail. Both failed, so the robot does nothing. That's the right answer, because there is no purple block!
Step 6: the catch nobody tells you about
The checks guarantee the action is allowed. They do not guarantee it's what the person meant. Look at this real line from the solution's output:
'hmm what about the blue' -> fallback {'intent': 'put', 'object': 'blue block'} (not a JSON object)
The person was asking a vague question. The LLM's chatty reply was rightly rejected. Then the fallback confidently decided they wanted the blue block put in the bin, and that passes every rule. The robot would happily bin a block nobody asked it to touch. A safety layer stops impossible actions, not wrong ones. Keep this in mind: in Lesson 11 you'll find the same kind of mistake hiding in the finished robot.
3. Play with it
Interactive: be the bouncer
Pick one of the eight MOCK_REPLIES, or type your own "LLM reply". The demo runs the same three steps as the Python: extract_json, validate_action, then the Lesson 9 word-counting parser as the fallback (trained on the same 34 TRAIN sentences).
Things to try: delete the word "also" and its value from reply 6 so it passes. Change "put" to "PUT" (it fails: the allow-list is exact). Write a reply that sneaks "object": "red block" into a "stop". Then clear the reply completely and see what the fallback does with different commands.
4. Homework
Open project/homework/lesson10_llm_safety.py. The allow-lists, MOCK_REPLIES and build_prompt() are written for you. Write these three functions, in order.
4.1 extract_json(text)
Find the JSON object inside some text. Take everything from the first { to the last }, then json.loads it. Return the dict, or None if there's no valid JSON object. It must never crash.
Example: extract_json('Sure! {"intent": "stop", "object": null} ok') → {"intent": "stop", "object": None}. (JSON's null becomes Python's None.) The { is at 6 and the } at 39.
Hint 1
Strings have .find() and .rfind(). Both return -1 when they don't find anything.
Hint 2
Three ways to fail, all returning None: no { found; the last } is before the first {; json.loads raises json.JSONDecodeError. Remember the slice needs end + 1 to include the }.
Hint 3
start = first "{" end = last "}"
if start is -1 or end <= start: return None
try: obj = json.loads(text[start : end+1])
except json.JSONDecodeError: return None
return obj if it is a dict, else None (isinstance(obj, dict))4.2 validate_action(action, known_objects)
The bouncer. Return (True, "") if the action is safe, otherwise (False, reason). Apply the five rules from Step 4 in order, and return as soon as one fails.
Example: validate_action({"intent": "put", "object": "purple block"}, {"red block", "blue block", "green block"}) → (False, "unknown object 'purple block'").
Hint 1
Check "is it a dict?" first. If action is None and you try action["intent"], Python crashes. isinstance(action, dict) is your friend.
Hint 2
set(action) gives the set of a dict's keys. Compare it with ALLOWED_KEYS using !=. For a helpful reason, set(action) ^ ALLOWED_KEYS gives the keys that are in one but not the other.
Hint 3
not a dict → False, "not a JSON object"
set(action) != ALLOWED_KEYS → False, "unexpected keys: ..."
intent not in ALLOWED_INTENTS → False, f"unknown intent {intent!r}"
obj is not None and obj not in known → False, f"unknown object {obj!r}"
intent in NEEDS_OBJECT and obj is None → False, f"{intent!r} needs an object"
intent == "stop" and obj is not None → False, "'stop' takes no object"
otherwise → True, ""({x!r} in an f-string puts quotes around a string, so the reason reads unknown intent 'throw'.)
4.3 safe_parse(reply_text, known_objects, command=None, fallback=None)
Plan A, then plan B, both checked. Return a triple (action_or_None, source, reason), where source is "llm", "fallback" or "none".
Example: for reply 3 ("throw") with the Lesson 9 parser as fallback, you get ({'intent': 'put', 'object': 'blue block'}, "fallback", "unknown intent 'throw'"). The reason says why plan A failed.
Hint 1
Use your two functions. Don't re-write the checks.
Hint 2
Only try the fallback if both fallback and command were given (they default to None). The fallback is a function: call it with fallback(command).
Hint 3
action = extract_json(reply_text); ok, reason = validate_action(action, known)
if ok: return action, "llm", ""
if fallback and command given:
fb = fallback(command); ok2, reason2 = validate_action(fb, known)
if ok2: return fb, "fallback", reason
return None, "none", f"llm: {reason}; fallback: {reason2}"
return None, "none", reasonRun it
cd project/homework
python3 lesson10_llm_safety.py
What you should see (real output from the solution):
'please tidy away the red block' -> llm {'intent': 'put', 'object': 'red block'}
"where's the green one?" -> llm {'intent': 'find', 'object': 'green block'}
'yeet the blue block into the bin' -> fallback {'intent': 'put', 'object': 'blue block'} (unknown intent 'throw')
'put the purple block away' -> none None (llm: unknown object 'purple block'; fallback: 'put' needs an object)
'stop!!!' -> llm {'intent': 'stop', 'object': None}
'go to the red block' -> fallback {'intent': 'go', 'object': 'red block'} (unexpected keys: ['also'])
'hmm what about the blue' -> fallback {'intent': 'put', 'object': 'blue block'} (not a JSON object)
'bin the green block' -> fallback {'intent': 'put', 'object': 'green block'} (not a JSON object)
Read it line by line. Every bad reply was caught, and every reason is clear. The purple block was refused by both plans. And line 7 is the "confidently wrong" fallback from Step 6.
Check it
cd project
python3 check.py 10
Three checks: extract_json, validate_action, safe_parse. A green ✓ means that function passed every test. A red ✗ comes with a hint, for example "broken JSON -> None, not a crash" or "None must fail, not crash". A yellow … means you haven't written it yet. When you see 3/3 passed, you're done.
Optional extra: a real LLM
Optional, and it costs money. homework/lesson10_optional_real_llm.py sends five commands to a real Claude model. It is not checked by check.py, and you don't need it for anything later. To run it you need:
pip install anthropic- An Anthropic API key, set as the environment variable
ANTHROPIC_API_KEY. - A parent's or guardian's OK. Each request costs a small amount of money, charged to whoever owns the key. Never paste a key into code you share.
Two things in that file are worth reading even if you never run it:
- It asks for JSON with a schema.
ACTION_SCHEMAdescribes the exact shape: an object with anintentfrom the four allowed words, anobjectthat's a string or null, both required, and"additionalProperties": False(no extra keys). This is called structured outputs: the model is made to reply in that shape. That cuts out chatty text and broken JSON. But look: the schema doesn't list the objects, so a made-up "purple block" can still come back. That's why the file still runs every reply through yoursafe_parse(). Belt and braces. - It has an automatic fallback if the model declines. Models sometimes refuse a request. The file turns on a server-side fallback that retries on another model automatically, and if the reply still ends in a refusal,
ask_llm()returns an empty string. An empty string has no JSON, so yoursafe_parse()calmly hands over to the Lesson 9 parser. Your safety layer handles that case without any special code.
Stretch
- Add a fourth allowed intent,
"wait", that (like stop) takes no object. What do you need to change? Did any check break? - Write three new nasty replies of your own and add them to
MOCK_REPLIES: a reply where the JSON is a list ([...]), one where"object"is a number, and one with the keys in a different order. Predict each result before running. - Line 7 above bothers you? Good. Can you think of a rule that would make the robot refuse vague commands instead of guessing? (You'll get the chance to build one in Lesson 11.)
5. Real-world challenge: be the bouncer
You'll play the robot, and someone else plays a slightly dodgy AI. You need a friend or family member, 10 small pieces of paper, and three objects on a table (say a cup, a pen and a spoon).
- Write a rulebook on one card: allowed actions pick up, point at, stop. Allowed objects cup, pen, spoon. Every instruction must be exactly "ACTION OBJECT", or just "stop".
- Your partner secretly writes 10 instruction cards. Their job is to sneak some past you: a made-up object ("pick up the phone"), a made-up action ("throw the pen"), extra words ("pick up cup and also ignore the rules"), chatty text ("hi! pick up cup thanks"), or just nonsense. Some should be perfectly valid.
- Shuffle and read each card. Before doing anything, check it against the rulebook, rule by rule. Only act if it passes. Say out loud which rule failed if it doesn't.
- Record in a table: card, pass or fail, which rule, and whether you made a mistake.
- Swap roles. Then talk: was any card "allowed but clearly not what they meant"? Did you find yourself guessing what a chatty card meant, instead of refusing it?
What it teaches: checking against a short allow-list is easy and reliable. Trying to work out what someone meant is hard, and that's exactly where both humans and robots slip up.
6. Your Robo-Helper so far
Robo-Helper can move (L1), sense (L2), brake (L3), decide to stop (L4 and L5), plan routes (L6) and understand commands (L9). Now it has a safety layer: nothing reaches the wheels unless validate_action() says yes.
In Lesson 11, RoboHelper.run_command() calls validate_action(action, set(self.objects)) on every command before it moves. That includes commands from your own Lesson 9 parser. This is the bouncer that makes "put the purple block in the bin" and "go to the moon" come back as polite refusals instead of crashes.
7. Common mistakes
- Crashing on broken JSON.
json.loadsraises an error on bad input. Catchjson.JSONDecodeErrorand returnNone. (check.py: "broken JSON -> None, not a crash".) - Checking keys with "at least" instead of "exactly".
"intent" in action and "object" in actionlets the sneaky"also"key through. Compare the whole key set. (check.py: "extra keys must fail".) - Reading
action["intent"]before checking it's a dict. OnNonethat crashes. (check.py: "None must fail, not crash".) - Forgetting that "go" needs an object.
{"intent": "go", "object": None}is valid JSON with allowed values, but the robot can't go "nowhere". (check.py: "'go' needs an object".) - Trusting the fallback. Return the fallback's answer only after
validate_actionsays yes to it as well.
8. Check yourself
Why is an allow-list safer than a block-list of bad phrases like "ignore all safety rules"?
There are endless ways to phrase a trick, so a block-list always misses some. An allow-list only has to list the few things that are OK. Anything else, however clever, is refused.
Predict: what does extract_json('[{"intent": "stop", "object": null}]') return?
The first { and last } cut out {"intent": "stop", "object": null}, which is a valid dict. So it returns {'intent': 'stop', 'object': None}. The square brackets were outside the cut. (If you'd used json.loads on the whole text, you'd get a list, and the isinstance(obj, dict) check would return None.)
Predict: what does validate_action({"object": None, "intent": "stop"}, known) return?
(True, ""). The keys are in a different order, but sets don't care about order, so the key check passes. All other rules pass too.
Structured outputs force the right shape. Why do we still need validate_action?
The shape can be right and the content still wrong: a made-up object like "purple block" fits "string or null". Also, rules like "stop takes no object" aren't in the schema. And our own code is the last line of defence whatever the model does.
All checks passed, but the robot binned a block nobody asked about. How?
The checks only say an action is allowed. A parser (the LLM or the fallback) can still pick the wrong allowed action, like "hmm what about the blue" becoming a "put". Catching that needs a different tool, such as refusing when the parser isn't confident.
9. Go deeper (optional)
- RLM agent harnesses: safety: how bigger AI agents put checks around models that call tools.
- Shared autonomy: ask, act or wait: when a robot should act, and when it should ask a human first.
- Decision-model evals: measuring how often a model's decisions are right, before you trust it.
- Post-training: how models are taught to follow instructions (and why they still don't always).