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.

✅ What you need before starting
Libraries: nothing to install — the project only uses 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.
💡 What is a JSON Schema?
JSON Schema is a declarative standard for describing the shape of a JSON document and the rules for validating it. It states explicitly which fields an object has, the data type of each one (string, number, boolean), which fields are required, which values are permitted (enum), and any special string formats (an email address, for example). In the project at the end of this lesson, 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:

Terminal
=== 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.

⚠️ Pitfall: demanding JSON in the prompt without enabling the API setting
The fourth case above is the crudest failure, and it happens when you only write "return the result as JSON..." in the prompt without configuring the system parameter (such as OpenAI's 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 grammar mask, written as a formula
Let $z_i$ be the logit of token $i$, and $L_t$ the set of legal tokens at step $t$. We replace the logit with: $$z'_i = \begin{cases} z_i & \text{if token } i \in L_t \\ -10^9 & \text{otherwise} \end{cases}$$ and only then run Softmax as usual: $P(w_i) = \exp(z'_i) / \sum_j \exp(z'_j)$. For a blocked token, $\exp(-10^9)$ underflows to 0 in floating-point arithmetic, so $P(w_i) = 0$ exactly. That is the difference between unlikely and impossible: a clever prompt only lowers the probability of a bad token, while the mask removes it from the space of choices.

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:

Terminal
=== 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.

💡 Still validate, even with Structured Outputs enabled
The mask runs on the vendor's server, not on yours. You cannot see it, you do not control its version, and you can still hit a network failure that truncates the reply mid-string (remember 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]"}).
⚡ An important boundary: the LLM does not run your code!
Many developers assume that enabling function calling lets the LLM connect to the server and execute that Python or JS function itself. It does not: the LLM cannot execute your code. It only reads the context and emits a JSON string specifying the call. Receiving that string, running the real function on your server, collecting the result and sending it back is entirely the developer's job. That boundary is also a security boundary: the model only proposes, your code decides whether to run anything.

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:

tools (request body)
{
  "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:

  1. Step 1 (User → LLM): the user's request goes up together with the list of tool descriptions the application provides.
  2. Step 2 (LLM → App): the LLM decides a tool is needed and returns a tool_calls structure (function name plus JSON arguments). Inference pauses there.
  3. 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).
  4. Step 4 (App → LLM): send that raw result back to the LLM tagged with the tool role, 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:

Terminal
=== 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.

⚠️ The model here is mocked — and that has consequences
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.
email_processor.py
"""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:

Terminal
=== 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.
🔬 Why that final check matters so much
The two 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

  1. Nothing to install: python3 email_processor.py. Standard library only.
  2. Download email_processor.py at the end of the lesson, or retype the code above. Thanks to random.Random(42), two runs produce identical output.
  3. Then try breaking it three ways:
    • In generate(), change -1e9 to -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 the enum for urgency. 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_request to return the constant "sender": "[email protected]" instead of the regex result. The printed record still looks as good as before, but the assert at the end fails — this is exactly the bug the first version of this lesson shipped with.

Lesson summary & what comes next

🔑 What you achieved:
  • Achieved: telling JSON Mode and Structured Outputs apart, and writing a validate() function that catches exactly what json.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 assistant turn and the tool role with its tool_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):

Download email_processor.py

📖 Further reading

Related lessons in this series

Lesson 11: Prompt programming & mastering the LLM API Lesson 13: Running an LLM offline with Ollama Back to the Practical AI Engineer roadmap

Comments