LLMBuild a Robot Brain · Week 7 · Lesson 10

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.

Time2–3 hours
You'll needPython 3, your Lesson 9 file. No internet, no API key.
New wordsLLM, JSON, parsing, validation, allow-list, fallback
Project pieceA safety layer for AI output

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 typedLLM repliedWhat'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 blockChatty 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 blueI 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)
  1. Is it a dict at all? None fails here: "not a JSON object".
  2. Exactly the right keys? Not "at least these keys". Exactly. Extra keys fail.
  3. Is the intent on the list? "throw" fails.
  4. Is the object on the list, or None? "purple block" fails.
  5. 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).


Source
–
Robot does
–

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", reason

Run 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:

  1. pip install anthropic
  2. An Anthropic API key, set as the environment variable ANTHROPIC_API_KEY.
  3. 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:

Stretch

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).

  1. 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".
  2. 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.
  3. 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.
  4. Record in a table: card, pass or fail, which rule, and whether you made a mistake.
  5. 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

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)