When you wire AI into a backend, you cannot feed an LLM's free-form prose into the next block of software logic. If the model returns a long explanatory preamble, the system breaks immediately at the parsing step.
This lesson fits two guarantees onto that link in the chain. Structured Outputs forces the model to return JSON matching a predefined JSON Schema — and we will see why it cannot fail, by implementing the blocking mechanism at the logits layer ourselves. Function Calling lets the model request that a function inside your system be run, through a four-step cycle you write by hand. The project at the end is a runnable email processor, with a test that proves it really reads the email instead of inventing the result.
json,
re, math and random from the Python standard library. No network,
no API key. Knowledge you need: Lesson 11 — the role-tagged
messages array, and more
importantly the softmax() function that turns logits into probabilities. Section 12.2
reuses exactly that function. Lesson 10 supplies the idea of a mask: assigning
-1e9 to a position before running Softmax makes its probability exactly 0 — Structured
Outputs is that same technique, applied to the vocabulary instead of to positions.
12.1 Forcing structured data out of an LLM: JSON Mode & Structured Outputs
In conventional programming we rely on static types and explicit object structures (classes, structs) to move information between services. By default an LLM generates free text and prefers to communicate in natural language. API vendors offer two options to close that gap:
-
JSON Mode: a system setting that requires the model to return a syntactically valid
JSON string (matching braces
{and}, and so on). It does not, however, guarantee that the fields (keys) inside will be the ones you wanted. - Structured Outputs: the stronger mechanism. The developer sends along a detailed JSON Schema, and the model is guaranteed to only pick tokens that keep the output matching that schema exactly. Section 12.2 dissects the machinery behind that guarantee.
EMAIL_SCHEMA is exactly such a schema, and the hand-written validate()
function checks data against it.
The crucial point that many people skip:
parsing and shape-checking are two different jobs. json.loads() only answers
the question "is this string JSON?". It has no opinion whatsoever on whether the resulting object contains
the fields you need. The project runs four typical model replies through both steps and prints the real
result:
=== JSON Mode guarantees syntax, not shape ===
complete and correct
json.loads : ok
validate : OK
valid JSON, missing a required field
json.loads : ok
validate : missing required field 'urgency'
valid JSON, urgency outside the enum
json.loads : ok
validate : field 'urgency' must be one of ['low', 'high'], got 'khan cap'
wrapped in a markdown fence, as models love to do
json.loads : CRASHED - Expecting value
validate : never reached
The three middle lines are the whole problem with JSON Mode. The second and third cases sail
through json.loads() — no exception, no warning — and only fail at the schema check.
If your code has no such check, what continues into the database is a record missing its
urgency field, and the error will surface somewhere else entirely, much later.
response_format). The model still politely leads in with a sentence and wraps the
JSON in a ```json markdown fence. That string is not JSON, so
json.loads() throws at the very first character — exactly the
CRASHED - Expecting value line above. A prompt is a request;
response_format is a constraint.
12.2 Why Structured Outputs cannot fail: blocking at the logits layer
"The model is guaranteed to return the right schema" sounds like a marketing promise. It is in fact a very specific engineering claim, and you already have the tools to understand it from the last two lessons.
Recall Lesson 11: at every generation step the model computes a raw score (a logit) for every token in the vocabulary, then runs Softmax to get probabilities. And recall Lesson 10: to eliminate a choice entirely, you set its logit to $-10^9$ before Softmax — the exponential turns that into an absolute 0, not a "very small" number.
Grammar-constrained decoding joins those two ideas: at each step a state machine determines which tokens still keep the output on a valid path, and every other token is assigned $-10^9$. The model is not "trying to comply" — it has nowhere else to go.
The project implements exactly this on a toy vocabulary of 11 tokens, with a grammar that only accepts
objects of the form {"urgency": "low"}. It generates 500 strings at high temperature in each
mode and counts how many both parse and match the schema:
=== Constrained decoding: masking logits before softmax ===
free sampling : 0/500 parsed and matched the schema
first failure looked like: "summary":"low"}"summary"
constrained : 500/500 parsed and matched the schema
Masking makes an invalid token unreachable, not just unlikely.
These two numbers are the entire claim of section 12.1, measured rather than asserted. Without the mask:
0 out of 500 — the first broken string looked like
"summary":"low"}"summary", precisely the kind of garbage json.loads() throws on.
With the mask: 500 out of 500, and that rate is not luck — it is 100% on every run,
because the wrong tokens were removed from the distribution. The only thing the model still decides is
"low" versus "high": exactly the semantic part we want it thinking about, not
the syntax.
finish_reason: "length" from Lesson 11 — a cut-off string is no longer valid JSON). A
validate() call on your side costs a few milliseconds and turns a silent failure into a
loud one. Keep it.
12.3 Reaching the outside world: function calling
At this point we can force the model to answer in the right shape. But it still only knows what was in its training data: a bare language model is like an isolated brain — it cannot reach the live internet, cannot read your internal database, cannot send an email.
Function calling is the mechanism that lets us describe local software functions (name, purpose, parameter list) to the LLM. When a user asks something that requires an action or live information, the model recognises it and, instead of answering in prose, returns a request to call a function — a tool call.
The model's output at that point is a JSON structure specifying:
- The name of the function to call (for example
send_alert_email). -
The argument values, extracted directly from the user's message (for example
{"recipient_email": "[email protected]"}).
For the LLM to "know" that send_alert_email exists, the developer has to describe it as a
JSON Schema and pass it in the request's tools parameter — not send the actual Python source:
{
"type": "function",
"function": {
"name": "send_alert_email",
"description": "Send an urgent alert email to the systems administrator.",
"parameters": {
"type": "object",
"properties": {
"recipient_email": { "type": "string" },
"subject": { "type": "string" },
"alert_content": { "type": "string" }
},
"required": ["recipient_email", "subject", "alert_content"]
}
}
}
Notice this is JSON Schema again — the same standard as section 12.1, now describing a function's
parameters instead of a return value. The LLM reads only the description and the
parameters structure to decide whether to call the function and what to put in each field —
it never sees, and does not need to know, what send_alert_email actually does inside. Which
is why description is not a decorative comment: it is all the information the model has for
choosing the right tool.
12.4 The complete tool call cycle
To complete a function-calling task, your system runs a closed four-step loop:
🔄 The four-step function calling cycle:
- Step 1 (User → LLM): the user's request goes up together with the list of tool descriptions the application provides.
-
Step 2 (LLM → App): the LLM decides a tool is needed and returns
a
tool_callsstructure (function name plus JSON arguments). Inference pauses there. -
Step 3 (App → local DB/API): your code parses
tool_calls, runs the real function in a safe environment, and collects the raw result (the observation). -
Step 4 (App → LLM): send that raw result back to the LLM tagged
with the
toolrole, so the model can read it and produce the final natural-language answer for the user.
Those four steps end with a messages array holding four roles, not the three
from Lesson 11. The new one is tool, and it must carry a tool_call_id matching
the id the model produced in step 2 — otherwise the server cannot tell which call this result
answers. This is the array shape the project prints at the end:
=== The message array after the tool loop ===
system | You classify support email precisely.
user | Cảnh báo khẩn cấp từ [email protected]: cơ sở dữ l
assistant | tool_calls=send_alert_email
tool | {"status": "delivered", "recipient": "admin_ops@comp
Look at the assistant turn: its content is empty. The model said nothing to the
user on that turn — it only issued a call request. This is a commonly mis-built part of a hand-rolled
loop: people skip that empty assistant turn and splice the function result straight in, leaving a history
with no trace of what the model asked for.
12.5 Lesson 12 project: an email classifier with a full tool call cycle
The project takes a raw customer email, extracts structured information (sender, summary, urgency)
according to EMAIL_SCHEMA, and if the urgency comes out high it triggers the
function-calling cycle to alert the administrator.
The file has four parts matching the four sections above: validate() and the example set from
12.1; generate() with the logit mask from 12.2; the TOOL_SPECS declaration from
12.3; and EmailProcessingPipeline running the four-step loop from 12.4.
MockStructuredLLM is not a real LLM; it extracts using regular expressions and keywords.
What it demonstrates faithfully is the shape of the data flow: the schema, the tool spec, the
four-step loop, the tool role. What it does not demonstrate is language
understanding — a real model would summarise far better and would need no keyword list. The only place
that changes when you move to a real API is the body of process_request.
"""Lesson 12 project: structured outputs and function calling, made checkable.
Run: python3 email_processor.py
No API key and no network. The model is mocked, but everything the lesson
claims is executed here: JSON Schema validation actually runs and actually
fails, constrained decoding actually masks logits, and the extracted fields
actually come from the input email rather than from a constant.
"""
import json
import math
import random
import re
# ---------------------------------------------------------------------------
# Part 1 - the schema, and what "valid JSON" does not buy you
# ---------------------------------------------------------------------------
EMAIL_SCHEMA = {
"type": "object",
"properties": {
"sender": {"type": "string"},
"urgency": {"type": "string", "enum": ["low", "high"]},
"summary": {"type": "string"},
},
"required": ["sender", "urgency", "summary"],
"additionalProperties": False,
}
TYPES = {"string": str, "number": (int, float), "boolean": bool, "object": dict}
def validate(obj, schema):
"""Check obj against a small subset of JSON Schema. Returns a list of errors.
An empty list means valid. This is deliberately hand-written: the point is
that the check is separate from parsing, not that it is production-grade.
"""
errors = []
if not isinstance(obj, dict):
return [f"expected an object, got {type(obj).__name__}"]
for field in schema.get("required", []):
if field not in obj:
errors.append(f"missing required field '{field}'")
for key, value in obj.items():
rule = schema["properties"].get(key)
if rule is None:
if not schema.get("additionalProperties", True):
errors.append(f"unexpected field '{key}'")
continue
expected = TYPES[rule["type"]]
if not isinstance(value, expected):
errors.append(f"field '{key}' should be {rule['type']}")
elif "enum" in rule and value not in rule["enum"]:
errors.append(f"field '{key}' must be one of {rule['enum']}, got '{value}'")
return errors
def demo_json_mode_gap(schema):
"""Three replies a model might return, put through parse + validate."""
replies = [
('complete and correct',
'{"sender": "[email protected]", "urgency": "high", "summary": "DB is down"}'),
('valid JSON, missing a required field',
'{"sender": "[email protected]", "summary": "DB is down"}'),
('valid JSON, urgency outside the enum',
'{"sender": "[email protected]", "urgency": "khan cap", "summary": "DB is down"}'),
('wrapped in a markdown fence, as models love to do',
'Here is your result:\n```json\n{"sender": "[email protected]"}\n```'),
]
print("=== JSON Mode guarantees syntax, not shape ===")
for label, raw in replies:
try:
parsed = json.loads(raw)
except json.JSONDecodeError as exc:
print(f" {label}")
print(f" json.loads : CRASHED - {exc.msg}")
print(f" validate : never reached\n")
continue
errors = validate(parsed, schema)
verdict = "OK" if not errors else "; ".join(errors)
print(f" {label}")
print(f" json.loads : ok")
print(f" validate : {verdict}\n")
# ---------------------------------------------------------------------------
# Part 2 - why Structured Outputs cannot fail: constrained decoding
# ---------------------------------------------------------------------------
# A toy vocabulary. A real model has ~100k of these; the mechanism is identical.
VOCAB = ['{', '}', ':', ',', '"urgency"', '"summary"', '"low"', '"high"',
'Chao', 'ban', 'nhe']
# The grammar as a state machine: at step i, only these tokens keep the output
# on a path that can still finish as a valid object matching the schema.
ALLOWED = [
['{'],
['"urgency"'],
[':'],
['"low"', '"high"'],
['}'],
]
def softmax(logits, temperature=1.0):
"""Same function as Lesson 11, repeated here so this file stands alone."""
scaled = [z / temperature for z in logits]
ceiling = max(scaled)
exps = [math.exp(s - ceiling) for s in scaled]
total = sum(exps)
return [e / total for e in exps]
def generate(rng, constrained, temperature=2.0):
"""Emit five tokens. With constrained=True, illegal tokens are masked off."""
out = []
for step in range(len(ALLOWED)):
# Pretend the model's raw preferences are mildly random each step.
logits = [rng.uniform(0.0, 4.0) for _ in VOCAB]
if constrained:
# This is the whole trick: drive illegal tokens to -infinity BEFORE
# softmax, so their probability is exactly zero, not merely small.
legal = set(ALLOWED[step])
logits = [z if t in legal else -1e9 for z, t in zip(logits, VOCAB)]
probs = softmax(logits, temperature)
out.append(rng.choices(VOCAB, weights=probs, k=1)[0])
return "".join(out)
def demo_constrained_decoding(trials=500):
"""Count how many generations parse, with and without the mask."""
print("=== Constrained decoding: masking logits before softmax ===")
for label, constrained in (("free sampling", False), ("constrained", True)):
rng = random.Random(42)
valid = 0
first_bad = None
for _ in range(trials):
text = generate(rng, constrained)
try:
parsed = json.loads(text)
except json.JSONDecodeError:
if first_bad is None:
first_bad = text
continue
if not validate(parsed, {"type": "object",
"properties": EMAIL_SCHEMA["properties"],
"required": ["urgency"]}):
valid += 1
elif first_bad is None:
first_bad = text
print(f" {label:<14}: {valid}/{trials} parsed and matched the schema")
if first_bad is not None:
print(f" first failure looked like: {first_bad}")
print(" Masking makes an invalid token unreachable, not just unlikely.\n")
# ---------------------------------------------------------------------------
# Part 3 - the local tools the model is allowed to ask for
# ---------------------------------------------------------------------------
TOOL_SPECS = [
{
"type": "function",
"function": {
"name": "send_alert_email",
"description": "Send an urgent alert email to the systems administrator.",
"parameters": {
"type": "object",
"properties": {
"recipient_email": {"type": "string"},
"subject": {"type": "string"},
"alert_content": {"type": "string"},
},
"required": ["recipient_email", "subject", "alert_content"],
},
},
}
]
SENT_MAILBOX = [] # so the test at the end can check the tool really ran
def send_alert_email(recipient_email, subject, alert_content):
"""The real local action. In production this would hit an SMTP server."""
print(f" [ACTION] sending alert email")
print(f" to : {recipient_email}")
print(f" subject : {subject}")
print(f" body : {alert_content[:60]}")
SENT_MAILBOX.append({"to": recipient_email, "subject": subject})
return json.dumps({"status": "delivered", "recipient": recipient_email})
# ---------------------------------------------------------------------------
# Part 4 - a mock model that extracts from the input instead of inventing it
# ---------------------------------------------------------------------------
URGENT_WORDS = ["khẩn cấp", "sập nguồn", "sự cố", "không thể kết nối", "treo"]
class MockStructuredLLM:
"""Stands in for a model called with response_format + tools.
Every field it returns is derived from the text it was given. That matters:
a mock that returns constants would still print a convincing transcript
while proving nothing about extraction.
"""
def process_request(self, messages, tools=None):
body = messages[-1]["content"]
# sender: the first address actually present in the email text
match = re.search(r"[\w.+-]+@[\w-]+\.[\w.]+", body)
sender = match.group(0) if match else "unknown@unknown"
# urgency: keyword evidence from the text, not a hardcoded branch
lowered = body.lower()
hits = [w for w in URGENT_WORDS if w in lowered]
urgency = "high" if hits else "low"
# summary: the first sentence of the email, trimmed
first = re.split(r"(?<=[.!?])\s", body.strip())[0]
summary = first if len(first) <= 90 else first[:87] + "..."
data = {"sender": sender, "urgency": urgency, "summary": summary}
errors = validate(data, EMAIL_SCHEMA)
if errors:
raise ValueError(f"the model broke its own schema: {errors}")
if urgency == "low" or not tools:
return {"type": "structured", "content": data, "evidence": hits}
return {
"type": "tool_call",
"content": data,
"evidence": hits,
"tool_call": {
"id": "call_0001",
"name": "send_alert_email",
"arguments": {
"recipient_email": "[email protected]",
"subject": f"URGENT: {summary[:40]}",
"alert_content": f"Reported by {sender}: {summary}",
},
},
}
class EmailProcessingPipeline:
"""Runs the four-step tool loop and keeps the message array it built."""
def __init__(self):
self.model = MockStructuredLLM()
self.available_tools = {"send_alert_email": send_alert_email}
def run(self, raw_email_body):
print(f" input: '{raw_email_body[:72]}...'")
# Step 1 - send the request, declaring which tools exist.
messages = [
{"role": "system", "content": "You classify support email precisely."},
{"role": "user", "content": raw_email_body},
]
# Step 2 - the model answers with structured data, and maybe a tool call.
response = self.model.process_request(messages, tools=TOOL_SPECS)
data = response["content"]
print(f" extracted sender : {data['sender']}")
print(f" extracted urgency : {data['urgency']}"
f" evidence: {response['evidence'] or 'none'}")
print(f" extracted summary : {data['summary'][:60]}")
if response["type"] != "tool_call":
print(" no tool needed; the record goes straight to the database\n")
return messages, data
call = response["tool_call"]
function = self.available_tools.get(call["name"])
if function is None:
raise KeyError(f"the model asked for an unknown tool: {call['name']}")
# Step 3 - our code runs the function. The model never touches it.
observation = function(**call["arguments"])
# Step 4 - hand the result back, tagged with the role "tool".
messages.append({"role": "assistant", "content": None,
"tool_calls": [call]})
messages.append({"role": "tool", "tool_call_id": call["id"],
"content": observation})
print(f" observation returned to the model: {observation}\n")
return messages, data
SAMPLE_EMAILS = [
"Xin chào, tôi là [email protected], tôi cảm ơn đội ngũ kỹ thuật rất "
"nhiều. Tôi muốn hỏi thêm thông tin về lịch khai giảng khóa sau.",
"Cảnh báo khẩn cấp từ [email protected]: cơ sở dữ liệu chính đang bị treo "
"và sập nguồn, không thể kết nối từ 3 giờ sáng!",
]
def main():
demo_json_mode_gap(EMAIL_SCHEMA)
demo_constrained_decoding()
print("=== The four-step tool loop, on two real emails ===")
transcripts = [EmailProcessingPipeline().run(body) for body in SAMPLE_EMAILS]
print("=== Did extraction really read the input? ===")
for body, (_, data) in zip(SAMPLE_EMAILS, transcripts):
found = data["sender"] in body
print(f" sender '{data['sender']}' appears in its own email: {found}")
assert found, "the extracted sender is not in the email it came from"
assert len(SENT_MAILBOX) == 1, "exactly one alert should have been sent"
print(" PASS - every extracted address came out of the text it belongs to,")
print(" and exactly one alert email was sent.\n")
print("=== The message array after the tool loop ===")
for message in transcripts[1][0]:
content = message.get("content")
if content:
shown = content[:52]
else:
shown = f"tool_calls={message['tool_calls'][0]['name']}"
print(f" {message['role']:<10} | {shown}")
if __name__ == "__main__":
main()
The most notable thing in the file is what MockStructuredLLM does not do: it
contains no pre-written return values. The sender address comes from a regular expression run against the
actual email body; the urgency comes from which keywords were found in the text (and the program prints
the evidence — precisely which words matched); the summary is the email's first sentence. Before
returning, it runs validate() on its own output and raises if it violated the schema.
Running the program, the loop section prints this:
=== The four-step tool loop, on two real emails ===
input: 'Xin chào, tôi là [email protected], tôi cảm ơn đội ngũ kỹ thuật rất ...'
extracted sender : [email protected]
extracted urgency : low evidence: none
extracted summary : Xin chào, tôi là [email protected], tôi cảm ơn đội ngũ k
no tool needed; the record goes straight to the database
input: 'Cảnh báo khẩn cấp từ [email protected]: cơ sở dữ liệu chính đang bị tr...'
extracted sender : [email protected]
extracted urgency : high evidence: ['khẩn cấp', 'sập nguồn', 'không thể kết nối', 'treo']
extracted summary : Cảnh báo khẩn cấp từ [email protected]: cơ sở dữ liệu chín
[ACTION] sending alert email
to : [email protected]
subject : URGENT: Cảnh báo khẩn cấp từ [email protected]
body : Reported by [email protected]: Cảnh báo khẩn cấp từ monito
observation returned to the model: {"status": "delivered", "recipient": "[email protected]"}
=== Did extraction really read the input? ===
sender '[email protected]' appears in its own email: True
sender '[email protected]' appears in its own email: True
PASS - every extracted address came out of the text it belongs to,
and exactly one alert email was sent.
appears in its own email: True lines look trivial, but they are what separates a
real demo from a performance. A program that returns the constant
"sender": "[email protected]" for every email would still print an
identical-looking record, still fire the tool call, still finish with "success" — without having read a
single email. The assertion assert data["sender"] in body makes that kind of demo fail
instantly. The question to ask of any AI pipeline is not "does it run", but "if it ignored the input
completely, would I notice?".
How to run this project on your machine
- Nothing to install:
python3 email_processor.py. Standard library only. -
Download
email_processor.pyat the end of the lesson, or retype the code above. Thanks torandom.Random(42), two runs produce identical output. -
Then try breaking it three ways:
-
In
generate(), change-1e9to-5. The mask becomes a suggestion rather than a constraint, and the valid rate drops off 500/500 — showing why the number has to be negative infinity rather than "a fairly large negative number". -
In
EMAIL_SCHEMA, add"critical"to theenumforurgency. The third case in section 12.1 stops reporting an error — the schema is what defines "correct", so loosening the schema loosens the check with it. -
Change
MockStructuredLLM.process_requestto return the constant"sender": "[email protected]"instead of the regex result. The printed record still looks as good as before, but theassertat the end fails — this is exactly the bug the first version of this lesson shipped with.
-
In
Lesson summary & what comes next
-
Achieved: telling JSON Mode and Structured Outputs apart, and writing a
validate()function that catches exactly whatjson.loads()lets through. - Achieved: understanding and implementing grammar-constrained decoding — a logit mask that makes a wrong token impossible rather than unlikely, measured at 0/500 against 500/500.
- Achieved: describing a local function to the model as a JSON Schema, and knowing the boundary exactly: the model proposes, your code decides to execute.
-
Achieved: running the full four-step tool call cycle, including the empty
assistantturn and thetoolrole with itstool_call_id.
Bridge to the next lesson: everything in this lesson sends data to a third party's server — which costs money, and with customer email is also a privacy question. Lesson 13 pulls the model down onto your own machine with Ollama, and measures what that costs you.
Download the practice code for this lesson
The Python file email_processor.py — schema validation, the constrained-decoding mask and
the full four-step tool call cycle (run python3 email_processor.py):
Comments