Module C · Topic 06 — Structured Outputs (Getting JSON Out of an LLM)¶
CLO-3 · Maps to: Session 28 · Notebook 17 · Lab 28 · Level: Beginner→Intermediate
1. Beginner explanation¶
LLMs reply in free text — great for chatting, useless for code. When your
program needs the answer ("the best day was Friday, mean tip 3.10"), free
text means parsing prose: fragile and error-prone. The fix is to force the
model to reply in a structure: JSON. Ollama has a format="json" mode
that makes the model emit valid JSON (a dict you can load with
json.loads). Now the model's answer is a data object your code can use —
and that is the bridge to tool calling and agents (topics 07–08).
2. Conceptual explanation (the WHY)¶
Under the hood, format="json" constrains the model's token sampling to
tokens that keep the output parseable as JSON — it biases the decoder
towards JSON-legal continuations. It is not a guarantee the JSON
semantically matches what you asked for, only that it parses. The JSON
could parse perfectly and still contain the wrong day, a hallucinated
number, or missing keys. So the pipeline is always:
- Force JSON (parseable by construction, mostly).
- Load it with
json.loads— if that fails, you have a real error to handle. - Validate the schema — check the keys you expect exist and values have the right types.
- Validate the values — is the day actually one of the days in the data? Is the number plausible? This is where hallucination gets caught.
Structured output turns "trust the model's prose" into "treat the model's answer as untrusted data" — exactly the right mental model.
3. Simple diagram¶
prompt: "Return JSON: {\"best_day\": ..., \"mean_tip\": ...}"
│
▼
LLM with format="json" ──► text that parses as JSON
│
▼
json.loads(...)
│
▼
{"best_day": "Sat", "mean_tip": 3.03} ← parses fine
│
▼
VALIDATE: "Sat" in df["day"]? ✓ ← semantics check
3.03 == groupby mean? ✗ ← CATCHES HALLUCINATION
4. Python examples¶
import importlib, json
def module_available(name):
try:
importlib.import_module(name)
return True
except ImportError:
return False
if module_available("ollama"):
import ollama
try:
# Step 1: force JSON output
resp = ollama.chat(
model="llama3.2",
messages=[
{"role": "system",
"content": "You answer data questions with JSON only."},
{"role": "user",
"content": "DataFrame tips, columns: total_bill, tip, day. "
"Which day has the highest total tips and what is "
"the mean tip that day? Return JSON with keys "
"best_day and mean_tip."},
],
format="json", # <-- force valid JSON
)
text = resp["message"]["content"]
print("RAW:", text)
# Step 2: parse
answer = json.loads(text)
print("PARSED:", answer)
# Step 3: validate schema + values
assert "best_day" in answer and "mean_tip" in answer, "missing keys"
assert isinstance(answer["mean_tip"], (int, float)), "wrong type"
# Step 4: verify against real data
import pandas as pd
import seaborn as sns
tips = sns.load_dataset("tips")
truth = (tips.groupby("day")["tip"].sum().idxmax())
print("MODEL best_day:", answer["best_day"],
"| DATA best_day:", truth)
except Exception as e:
print("Ollama not running:", e)
else:
print("ollama package not installed: pip install ollama")
Safe parsing — never crash on a bad reply:
import json
def parse_json_safely(text):
"""Try to parse; return None if the model broke the format."""
try:
return json.loads(text)
except json.JSONDecodeError:
return None
# Use it:
parsed = parse_json_safely('{"best_day": "Sat", "mean_tip": 3.03}')
print(parsed)
# A model can also wrap JSON in prose or code fences — strip them first:
text = '```json\n{"best_day": "Sat"}\n```'
clean = text.strip().removeprefix("```json").removesuffix("```").strip()
print(json.loads(clean))
5. Practical exercise (25 min)¶
Dataset: sns.load_dataset("titanic").
- Ask for JSON with keys
survival_rateandmost_dangerous_class(useformat="json"). - Parse it with
json.loadsand print the dict. - Add the schema check (
assertthe keys exist) — then break the model intentionally with a vague prompt and watch the validation catch it. - Verify both values against real pandas output
(
titanic["survived"].mean(),groupby("class")["survived"].mean()). - Write a
parse_json_safelyhelper and use it in your notebook's audit log.
6. Common errors¶
| Error | Fix |
|---|---|
json.JSONDecodeError |
model wrapped in prose/code fences — strip first; or retry once |
| Parses but wrong keys | validate schema with asserts; re-prompt with explicit key names |
| Parses but wrong values | semantics check against real data — this is the hallucination catch |
| Numbers as strings ("3.03") | cast types after parsing, then validate with isinstance |
Forgot format="json" |
free-text answers are not guaranteed parseable |
7. Limitations¶
- Format ≠ correctness — valid JSON can still be factually wrong; the value checks are on you.
- Small models break format — tiny models sometimes emit invalid JSON
even with
format="json"; build the retry/skip path in. - Schema drift — models may invent extra keys or rename yours; validate what you need, ignore the rest, don't trust the rest.
- No nested guarantees —
format="json"helps top-level structure; deeply nested/complex schemas are less reliable. - Slower, more tokens — JSON output costs tokens and can be slower than a one-word answer.
8. Responsible AI considerations¶
- Untrusted-data mindset — a parsed JSON answer is input to your code, not a fact; validate before it influences anything.
- Never auto-execute — if the structured output contains code or a tool name, treat it as untrusted; the agent loop (topic 08) must whitelist tools, never let the model pick arbitrary functions.
- Log both raw and parsed — the raw text can hold injection content; keep it in the audit log.
- Failure handling is a safety feature — refusing a malformed reply loudly beats silently using a plausible wrong value.
9. Assessment questions¶
Q1. What exactly does Ollama's format="json" guarantee? (CLO-3 ·
Understand · Easy)
Answer: That the output is parseable JSON (approximately) — it does not
guarantee the content is correct or matches your requested keys.
Q2. The model returns {"best_day": "Saturday", "mean_tip": "3.03"}.
It parses fine. List the two validation problems. (CLO-3 · Analyze ·
Medium)
Answer: (1) type: mean_tip is a string, not a number; (2) value/schema:
"Saturday" is not a value in the data's day column (data has Sat) and the
number must be checked against the real aggregation.
Q3. Write the three lines that parse a JSON reply and handle a malformed one gracefully. CLO-3 · Apply · Medium Answer:
try:
answer = json.loads(text)
except json.JSONDecodeError:
answer = None # handle: log, retry once, or skip
Q4. Why must structured output be validated against the real data and not just parsed? CLO-3 · Evaluate · Medium Answer: parsing only proves the text is JSON; the values are still generated guesses — validating against the data is the only check that catches hallucinated numbers before they enter your analysis.