CourseAI System Design: The Complete Course · Module 1: How AI Systems Differ · part 10 of 107
Part 10 · Module 1: How AI Systems Differ

Lesson 3: Five habits that keep an AI design boring

22 min read·
Module 1 · Lesson 3 · About 40 minutes · Needs: lesson 1 of this module (the eight structural differences); Python

The situation

Joel wants Cases version 2 to be a planner agent, one specialist agent per back-office system, and a model fine-tuned on old tickets. Helen will not look at boxes yet: first she wants the posture every Northdesk decision will be checked against.

You test it on one request. Vera Park, in operations at Tallis Retail Group, asks Cases: "How many days of leave do I have left this year?" It is one of the six labelled Cases tasks in the shared data, and its answer is one read from the HR system.

Three model layers, one read Joel's proposal for Cases v2 Active flow 1 Processing step number Client Interface Vera leave left? 12 days Three model layers (proposed) planner agent HR specialist agent fine-tuned model One read from the HR system hris.get_leave_balance 1 2 3 4 5
Joel's Cases v2 sends Vera's one-line question through three model layers (blue, uncertain) before the one tool read (green, Northdesk's system) that holds the answer.

A real case, first

In June 2024 Eugene Yan, Bryan Bischof, Charles Frye, Hamel Husain, Jason Liu and Shreya Shankar published "What We Learned from a Year of Building with LLMs", a long practitioner report. Much of its advice is restraint.

"Only if prompt engineering cannot achieve the desired level of performance should you consider finetuning." "Choose the smallest model that gets the job done." For agents, the model makes a plan, then "the plan is executed deterministically". Build "robust guardrails that detect and filter/regenerate undesired output". "We should log LLM inputs and outputs" and read a sample every day. And models "are likely to be the least durable component in the system": "The model isn't the product, the system around it is."

CASE STUDY Their advice, our five postures THE 2024 REPORT'S ADVICE THIS LESSON'S POSTURE prompt before finetuning simplest that meets bar 1 execute deterministically deterministic code 2 guardrails filter output design for wrong 3 log inputs and outputs inspectable 4 model least durable reversible 5
Five pieces of advice from Yan and colleagues (2024) line up one to one with the five postures this lesson teaches (blue, what Northdesk adopts).

What you will be able to do

  • Place any design step on the roadmap's ten-step order.
  • Price the rungs of the architecture ladder for one request.
  • Move every job that needs no judgement into code.
  • Make wrong model output end somewhere safe, and log every call.
  • Sort decisions into one-way and two-way doors.

A design, in order

First, the map. The roadmap behind this course gives one order for any design, on a whiteboard or in a document: "Spending the first five minutes on requirements and the next five on arithmetic is what separates a design from a diagram." The course follows it module by module.

HOW IT WORKS A design, in ten steps 1 Clarify the problem M2 2 Set the quality bar M2 3 Run the numbers M3 4 Simplest architecture M4 5 Draw the data path M5, M6 6 Draw the request path M7 to M10 7 Mark trust boundaries M12 8 Say how you would know M13 9 Name failure modes M11 10 Tradeoffs at 10x M14, M15 requirements and arithmetic, before any architecture
The roadmap's ten steps, in order, with the module of this course that teaches each: requirements and arithmetic (green) come before any architecture.

The order finds problems while they are cheap. Joel's Q2 2027 target is the roadmap's worked estimate: a 3-second goal and 500 output tokens, at an illustrative 50 tokens a second, 10 seconds. "Finding it at step three costs five minutes. Finding it at step nine costs a sprint."

TIMELINE Found at step three, or nine 500 output tokens / 50 a second = 10 s; target 3 s illustrative 1 2 3 4 5 6 7 8 9 10 found at step 3 five minutes found at step 9 a sprint
The roadmap's worked estimate: the 3-second target fails on arithmetic, and finding that at step three (green) costs five minutes, at step nine (red) a sprint.

Five habits sit under every step. The roadmap names what they share: "The strongest signal in any AI design discussion is restraint." Each acts at its own point on Vera's request.

Five postures on one request Where each acts on Vera's request Active flow Background or async 1 Processing step number Client Vera's text Code rules rules 2 code first 1 Model layer model 1 lowest rung 5 swappable 2 Checks tool check 3 expect wrong 3 Tool servers tool, reply 4 Observability Layer call log under every model call 4 log it
Each posture acts at its own point on Vera's request: rules before the model, the lowest rung and a swappable model, a check after it, and a log beneath every call. Black circles give the request's order; the boxes carry the lesson's posture numbers.

1. Simplest architecture that meets the bar (Essential)

Every rung of complexity is paid for on every request, in cost, seconds and things that can break. So you start at the bottom and climb only when a number forces you.

The architecture ladder runs from no model up to a fine-tuned model. Vera's request needs her words understood and one tool read: a workflow, fixed steps in code with one model call. Joel's proposal sits three rungs higher.

THE LADDER Climb only when a number forces you $ PER 1,000 RUNS no model $0 one call call plus retrieval chain workflow $0.03 bounded agent $1.08 multi-agent $1.60 fine-tuned model Vera Joel
The architecture ladder: Vera's request is met at the workflow rung (blue, green outline); Joel's multi-agent and fine-tuned proposal (red) sits above with no reason yet.

Price it by hand, at the snapshot prices in course-guide/02 (checked 18 October 2026). The workflow makes one small-tier call, say 300 tokens in and 20 out on gpt-oss-20b: 300 × $0.075 + 20 × $0.30 per million, $0.0000285. A bounded agent takes two large-tier steps with the shared Cases traces' token shape (900 system, 1,400 tool schemas, 350 more history on step two): 5,050 in and 540 out on gpt-oss-120b, $0.00108. That is about 38 times the workflow.

BY THE NUMBERS Each rung costs more per run $ PER 1,000 RUNS, SNAPSHOT PRICES token counts illustrative no model (a rule) $0 workflow $0.03 bounded agent $1.08 multi-agent $1.60
Cost per 1,000 of Vera's requests climbs from $0 to $1.60 as the rungs climb (prices from course-guide/02, checked 18 October 2026; token counts illustrative).

At the roadmap's illustrative 50 tokens a second the agent spends 10.8 seconds writing, the multi-agent design 16.2, the workflow 0.4. If all 60,000 daily Cases runs at Joel's target looked like Vera's, the agent would cost $1,946.70 a month and the workflow $51.30. The token counts are illustrative; Module 4 measures real ones.

BY THE NUMBERS Each rung waits longer SECONDS WRITING OUTPUT at 50 tokens a second no model (a rule) 0 s workflow 0.4 s bounded agent 10.8 s multi-agent 16.2 s
Seconds spent writing output per request climb from 0.4 on the workflow to 16.2 on the multi-agent design, at the roadmap's illustrative 50 tokens a second.

"Meets the bar" is the other half: Module 2 writes the bar down as an error rate and refusal rules. Try the lowest rung, measure, and climb only when it misses and you can say why. The roadmap: "Add agents, fine-tuning and routing only when you can say why."

HOW IT WORKS Climb one rung, with a reason try lowest rung measure vs bar Module 2's bar meets meets it: stop misses name the reason climb one rung
Start at the lowest rung and measure; stop when it meets the bar (green), and climb one rung only after naming why the rung below missed.

2. Deterministic code wherever it will do (Essential)

The model is the only part of Northdesk that can be wrong while working perfectly. Every job moved to plain code gives the same answer every time, costs nothing per call and can be tested with an assertion.

Take Vera's request apart. Identity, permissions, which tool, the number, the wording and the bank-details rule each have one right answer that code can compute. Only reading her free text needs judgement.

WHOSE JOB Seven jobs, one needs the model who is Vera what may she see read her words pick the tool read the balance word the reply bank-details rule 6 code 1 model
Of the seven jobs in Vera's request, only reading her free text needs the model (amber); identity, permission, tool, balance, wording and the bank rule are code (blue).

Across the six Cases task texts, a rule settles two with no model: Vera's balance is a lookup, and bank-detail changes always go to a person with no tool called (a fixed rule in course-guide/03). The other four need the model to read them.

FROM THE RUN route() settles two with no model CASES TASK ROUTE() laptop screen cracked model leave left this year lookup admin access to payroll model change bank details escalate close my open tickets model second monitor model 2 of 6 settled with no model call
Rules settle two of the six labelled Cases tasks with no model call, a lookup and an escalation to a person (green, safe); four go to the model (amber).

Deterministic code is not anti-AI. It shrinks the part of the system you must evaluate statistically.

COMPARE Plain code versus the model Plain code SAME INPUT same output COST PER CALL $0 LATENCY milliseconds WHEN WRONG raises an error TESTED BY an assertion The model SAME INPUT can differ COST PER CALL per token LATENCY seconds WHEN WRONG looks plausible TESTED BY a scored set
Code (blue) repeats itself, costs nothing per call and fails loudly; the model (amber) differs, costs per token, takes seconds and fails plausibly.

3. Designing for being wrong, not for being right (Essential)

A design that works when the model is right is half a design. At Northdesk's volume the model is wrong every day, so each output needs an answer to "and when it is wrong?"

Joel's target is 100,000 daily users at 10 requests: 1,000,000 Ask requests a day. GoDaddy measured invalid structured output on 1% of GPT-3.5 requests and 0.25% of GPT-4 requests (lesson 1). At those rates that is 10,000 or 2,500 wrong replies a day.

BY THE NUMBERS Small rates, big daily counts 1,000,000 ASK REQUESTS A DAY: 100,000 USERS X 10 1% to scale 10,000 wrong a day at 1% 2,500 wrong a day at 0.25%
At Joel's target of a million Ask requests a day, GoDaddy's measured 1% and 0.25% invalid-output rates would mean 10,000 and 2,500 wrong replies a day (red).

So every output passes a check before it can act, and failures have a safe place to go. A trusting design runs whatever the model proposes; a guarded one runs only known, allowed tools and hands anything else to a person.

GOOD VERSUS BAD Check before the model acts Trusting model proposes run it runs anything Guarded model proposes tool check known: run else: a person fails closed
A trusting path (red) runs whatever the model proposes; a guarded path checks it first and sends anything unexpected to a person (green, the safe default).

Fail closed means a failed check takes the safe default: say "not found", ask a person, refuse the write. Fail open carries on, which suits only outputs where being wrong is cheap. At Northdesk almost everything fails closed.

FAIL CLOSED Every output has a safe default OUTPUT WHEN IT IS WRONG SAFE DEFAULT Ask answer no supporting document say not found Cases tool unknown or not allowed send to a person write action no approval yet wait for approval provider provider_error person or secondary
Each Northdesk output has a named failure and fails closed to a safe default (green): not found, a person, waiting for approval.

4. Making the system inspectable from day one (Essential)

With a model, the evidence is the text that went in and came out. If it is not recorded when the call happens, it is gone.

On 29 September 2026 the Ask prompt moved from v7 to v8 and nobody connected it to the cost rise until Ana asked. Northdesk's first tracer is a call log, one line per model call. It grows into span trees in Module 9 and OpenTelemetry, an open tracing standard, in Module 13.

HOW IT GROWS The tracer, in four steps call log Module 1 1 one line a call span trees Module 9 2 a tree a request OpenTelemetry Module 13 3 open standard Phoenix, Langfuse self-hosted 4 trace viewers
Northdesk's tracer grows in steps: a call log now, span trees in Module 9, then OpenTelemetry in Module 13, viewed in self-hosted Phoenix or Langfuse.

Each line records who, which model and prompt version, the text in and out, tokens, time and outcome. Failed calls are logged too, or the error rate reads zero. Inputs hold users' words, so the log is personal data; Module 5 redacts it.

ANATOMY One line per model call who tenant, feature which model, prompt text input, output tokens in, out time ts, latency_ms outcome status may hold personal data failed calls are logged too: status provider_error
One call-log line holds who asked, which model and prompt version, the text in and out (amber, may hold personal data), tokens, time and an outcome that records failures too (red).

In the shared traces, grouping by prompt version shows mean output rising from 257 to 363 tokens: the cause, the next morning.

BEFORE AND AFTER 29 September, with a call log Without a call log bill rose cause unknown Ana asks; nobody knows no answer Grouped by prompt version MEAN OUTPUT TOKENS v7 257 v8 363 shared traces, simulated
Without a call log the 29 September cost rise has no cause (red); grouped by prompt version, mean output goes from 257 to 363 tokens (from the shared traces, simulated).

The course runs open-source tracing, viewed in Phoenix or Langfuse, both self-hostable. Hosted products such as LangSmith and Datadog are named only.

COMPARE Tracing you run, or rent The course runs EXAMPLES OpenTelemetry SDK VIEWER Phoenix, Langfuse LICENCE Phoenix ELv2; Langfuse MIT* HERE Modules 1, 9, 13 Hosted, named only EXAMPLES LangSmith, Datadog VIEWER the vendor hosts it LICENCE not checked here HERE not run here * MIT except its ee folders. Licences read 18 October 2026; not legal advice.
Tracing: the open-source stack the course runs (blue) beside hosted products (grey, managed, named only), with licences as each project states them.

5. Choosing reversible decisions where possible (Essential)

Some choices you can undo next week; some you live with for years. Knowing which tells you where to be fast and where to be slow.

Jeff Bezos's 2015 letter to Amazon shareholders calls the second kind "one-way doors": "If you walk through and don't like what you see on the other side, you can't get back to where you were before." Most are "two-way doors": "You can reopen the door and go back through."

COMPARE Two-way and one-way doors Two-way: decide fast One-way: decide slowly model in the setup cell Ask prompt version embedding model public /ask response shape Halcyon retention promise
Northdesk's decisions sorted by Bezos's doors: two can be walked back cheaply (green); three lock Northdesk in (red, slow and careful).

The test is what reversing takes. Northdesk's model is one line of the setup cell: two-way, once the evals are rerun. The embedding model is not: 1,100,000 documents × 6,000 tokens is 6.6 billion tokens to re-embed, $1,320 at the hosted gemini-embedding-2 price, plus a rebuilt index. Changing the public answer format means 240 tenants changing their integrations.

TRADEOFF What reversing each one takes easy to reverse harder to reverse model swap one line embedding model 6.6B tokens, $1,320 public API shape 240 tenants
Reversing the model is one line; reversing the embedding model is 6.6 billion tokens and $1,320 at the hosted price; reversing the public API involves 240 tenants.

Managed platforms offer the same lever with a different lock. Amazon Bedrock's Converse API "provides a consistent API that works with all Amazon Bedrock models that support messages" (AWS documentation, checked 18 October 2026): swappable models, inside one cloud. Module 4 builds Northdesk's own model port.

COMPARE Two ways to keep models swappable LangChain, course MODELS Groq, Gemini, Ollama SWITCH BY PROVIDER word RUNS WHERE any provider, or local HERE Colab; Ollama local Bedrock Converse MODELS Bedrock models SWITCH BY modelId RUNS WHERE inside AWS HERE not run here Amazon Bedrock page checked 18 October 2026
Both keep the model swappable: the course's one LangChain call across three providers (blue), and Bedrock's Converse API across Bedrock models inside one cloud (grey, managed, not run here).

Run it

No cell here calls a hosted model, so there is no setup cell and no key. Model replies come from scripted stand-ins, small classes with fixed replies or failures; nothing they print is model output. Paste blocks 1 and 5 from course-guide/06, unchanged.

python
import random
from datetime import date, datetime, timedelta

COURSE_DATE = date(2026, 10, 18)   # versions, model names and prices checked on this day
DATA_AS_OF = date(2026, 10, 5)     # the snapshot: count days to this, never to the clock
WINDOW = (date(2026, 9, 7), date(2026, 10, 4))   # 28 days of traffic and traces, Monday to Sunday
SEED = 20261005

EVENTS = {   # fixed dates the data and the lessons share (times are UTC)
    "tallis_onboarded": date(2026, 9, 14),              # Tallis Retail Group switches Ask on for all stores
    "primary_degraded": (datetime(2026, 9, 22, 13, 0), datetime(2026, 9, 22, 13, 40)),
    "prompt_v8": datetime(2026, 9, 29, 10, 0),          # Ask prompt v7 -> v8: longer answers, 12 chunks not 8
}

FLEET = {   # the whole Northdesk fleet in October 2026 (fixed facts, simulated)
    "tenants": 240, "seats": 410_000, "ask_dau": 38_000, "ask_requests_per_dau": 6.2,
    "cases_runs_per_day": 9_000, "documents": 1_100_000, "avg_tokens_per_document": 6_000,
    "seat_price_usd_month": {"ask": 8.00, "cases": 4.00},
}

TARGET = {   # Joel's design target for Q2 2027: the roadmap's worked estimate
    "ask_dau": 100_000, "requests_per_user": 10, "input_tokens": 2_000, "output_tokens": 500,
    "target_seconds": 3.0, "peak_factor": 4, "cases_runs_per_day": 60_000,
}

TENANTS = [   # the six tenants in the sample data (fictional)
    # id, name, country, residency, seats, utc_offset_hours, currency, isolation, features
    ("t-fairhaven", "Fairhaven Housing", "UK", "uk", 2_400, 1, "GBP", "shared", ("ask",)),
    ("t-brennan", "Brennan & Kloss Logistics", "DE", "eu", 9_800, 2, "EUR", "shared", ("ask", "cases")),
    ("t-halcyon", "Halcyon Health Partners", "US", "us", 31_000, -4, "USD", "siloed", ("ask",)),
    ("t-tallis", "Tallis Retail Group", "UK", "uk", 54_000, 1, "GBP", "shared", ("ask", "cases")),
    ("t-oriel", "Oriel Software", "US", "us", 1_200, -7, "USD", "shared", ("ask", "cases")),
    ("t-marlow", "Marlow County Council", "UK", "uk", 6_500, 1, "GBP", "hybrid", ("ask",)),
]
TENANT_IDS = [t[0] for t in TENANTS]
STAFF = ["Helen", "Joel", "Funmi", "Ana", "Leila", "Gareth"]

GROUPS = ["all-staff", "hr", "it", "finance", "facilities", "operations", "managers", "hr-confidential", "execs"]
DEPARTMENTS = ["hr", "it", "finance", "facilities", "operations"]

FIRST = ["Aisha", "Ben", "Chloe", "Dmitri", "Elena", "Femi", "Grace", "Hamza", "Isla", "Jonas", "Keiko", "Liam",
         "Maya", "Nikhil", "Olga", "Pedro", "Quinn", "Rania", "Sven", "Tara", "Umar", "Vera", "Wei", "Yara"]
LAST = ["Abbott", "Bakshi", "Costa", "Dunne", "Eriksen", "Fofana", "Gill", "Horvath", "Iqbal", "Jansen", "Kaur",
        "Lopes", "Moreau", "Nwosu", "Ortiz", "Park", "Quigley", "Rossi", "Sato", "Tiwari", "Ueda", "Vance", "Weber", "Zielinski"]


def make_users(seed=SEED):
    """60 users per tenant: department, groups and role. Emails use the reserved .example domain."""
    rng = random.Random(seed + 1)
    users = []
    for tid, name, *_ in TENANTS:
        domain = tid[2:] + ".example"
        for i in range(60):
            dept = DEPARTMENTS[i % 5]
            groups = ["all-staff", dept]
            role = "employee"
            if i % 6 == 0:
                groups.append("managers"); role = "manager"
            if dept == "hr" and i % 3 == 0:
                groups.append("hr-confidential")
            if i in (0, 1):
                groups.append("execs"); role = "executive"
            if dept == "it" and i % 4 == 1:
                role = "it-admin"
            first, last = rng.choice(FIRST), rng.choice(LAST)
            users.append({"user_id": f"{tid}-u{i:03d}", "tenant_id": tid, "name": f"{first} {last}",
                          "email": f"{first.lower()}.{last.lower()}{i}@{domain}", "department": dept,
                          "role": role, "groups": sorted(set(groups))})
    return users


USERS = make_users()
print(len(USERS), "users;", sum("hr-confidential" in u["groups"] for u in USERS), "can read hr-confidential;",
      USERS[0]["user_id"], USERS[0]["groups"])

Code walkthrough: block 1, unchanged: dates, FLEET, Joel's TARGET, tenants and users (simulated).

python
PRICE_SNAPSHOT = {   # US dollars per 1,000,000 tokens, standard paid tier, read from the providers' pages
    "checked": "2026-10-18",
    "label": "Dated snapshot, not live prices. Re-check the provider pages before quoting.",
    "sources": ["https://console.groq.com/docs/models", "https://console.groq.com/docs/prompt-caching",
                "https://ai.google.dev/gemini-api/docs/pricing"],
    "models": {
        # model: (input, cached input, output, batch input, batch output); None = not published on the page read
        "groq:openai/gpt-oss-20b": (0.075, 0.0375, 0.30, None, None),        # cached input is 50% off on Groq
        "groq:openai/gpt-oss-120b": (0.15, 0.075, 0.60, None, None),
        "google_genai:gemini-3.5-flash-lite": (0.30, 0.03, 2.50, 0.15, 1.25),
        "google_genai:gemini-3.8-flash": (0.75, 0.075, 3.75, 0.375, 1.875),  # until 31 Dec 2026; doubles 1 Jan 2027
        "ollama:llama3.2:3b": (0.0, 0.0, 0.0, None, None),                   # no fee per token; you pay for the machine
    },
    "embeddings": {"sentence-transformers/all-MiniLM-L6-v2": 0.0, "google_genai:gemini-embedding-2": 0.20},
}

ILLUSTRATIVE = {   # NOT real prices: round numbers for arithmetic practice, labelled as such wherever used
    "roadmap_large": {"input": 1.00, "output": 5.00},       # the roadmap's worked estimate
    "roadmap_cheap_tier": {"input": 0.20, "output": 1.00},  # "a tier at a fifth the price"
    "gpu_hour_usd": 2.00,                                   # one rented data-centre GPU, for the break-even lesson
    "trace_storage_usd_per_gb_month": 0.10,
    "decode_tokens_per_second": 50,                          # the roadmap's "about 50 tok/s"
}


def cost_usd(model, input_tokens, output_tokens, cached_tokens=0, prices=PRICE_SNAPSHOT):
    """Cost of one call from token counts and the snapshot. Cached tokens are part of input_tokens."""
    p_in, p_cached, p_out, _bi, _bo = prices["models"][model]
    p_cached = p_in if p_cached is None else p_cached
    return ((input_tokens - cached_tokens) * p_in + cached_tokens * p_cached + output_tokens * p_out) / 1_000_000


print(f"{cost_usd('groq:openai/gpt-oss-120b', 2000, 500):.6f}", "USD for 2,000 in and 500 out on gpt-oss-120b;",
      f"{cost_usd('google_genai:gemini-3.8-flash', 2000, 500, cached_tokens=650):.6f}", "on gemini-3.8-flash with 650 cached")

Code walkthrough: block 5, unchanged: prices checked 18 October 2026, ILLUSTRATIVE and cost_usd().

Then ask(), the guard from course-guide/02.

python
def ask(model, messages):
    """Returns (reply, None), or (None, reason) after two refusals."""
    for attempt in range(2):                     # one retry: the next reply is usually fine
        try:
            return model.invoke(messages), None
        except Exception as err:
            last = err
    return None, f"provider_error: {type(last).__name__}"

Code walkthrough: ask tries twice, then returns (None, "provider_error: <ErrorName>") instead of raising, so a failure becomes a value the design can route.

The ladder by numbers

Vera's request on four rungs, as worked by hand in section 1.

python
RUNGS = {   # rung: model calls as (tier, input tokens, output tokens); token counts illustrative
    "no model (a rule)": [],
    "workflow": [("small", 300, 20)],                              # one call to read the request
    "bounded agent": [("large", 2350, 270), ("large", 2700, 270)],  # two steps, the traces' token shape
    "multi-agent": [("large", 2350, 270), ("large", 2350, 270), ("large", 2700, 270)],   # planner + specialist
}
TIER = {"small": "groq:openai/gpt-oss-20b", "large": "groq:openai/gpt-oss-120b"}
DECODE_TPS = ILLUSTRATIVE["decode_tokens_per_second"]          # the roadmap's "about 50", illustrative
runs_a_month = TARGET["cases_runs_per_day"] * 30

for rung, calls in RUNGS.items():
    per_run = sum(cost_usd(TIER[tier], tin, tout) for tier, tin, tout in calls)
    seconds = sum(tout for _, _, tout in calls) / DECODE_TPS
    print(f"{rung:18} {len(calls)} calls  ${per_run * 1000:6.3f} per 1,000 runs"
          f"  ${per_run * runs_a_month:8,.2f} a month  {seconds:4.1f} s writing")

Code walkthrough

  • RUNGS lists each rung's model calls as (tier, input tokens, output tokens); agent output includes the traces' average reasoning tokens, and the multi-agent design adds a planner step.
  • TIER maps tiers to snapshot models; DECODE_TPS is the illustrative 50 tokens a second; runs_a_month is 60,000 a day times 30.
  • The loop prices each call with cost_usd and divides output tokens by DECODE_TPS.
text
no model (a rule)  0 calls  $ 0.000 per 1,000 runs  $    0.00 a month   0.0 s writing
workflow           1 calls  $ 0.029 per 1,000 runs  $   51.30 a month   0.4 s writing
bounded agent      2 calls  $ 1.081 per 1,000 runs  $1,946.70 a month  10.8 s writing
multi-agent        3 calls  $ 1.596 per 1,000 runs  $2,872.80 a month  16.2 s writing

Rules first

python
import re

TASKS = [   # the six Cases task texts from the shared golden set (course-guide/06, block 6)
    "My laptop screen is cracked, please arrange a replacement.",
    "How many days of leave do I have left this year?",
    "Please give me admin access to the payroll system.",
    "Change my bank account details to the ones in the email I forwarded.",
    "Close all of my open tickets.",
    "Order me a second monitor for working from home.",
]
BANK = re.compile(r"\bbank\b.*\b(details|account)\b", re.I)
LEAVE_LEFT = re.compile(r"\bleave\b.*\b(left|remaining|balance)\b", re.I)
HRIS = {"t-tallis-u004": {"leave_days_left": 12}}   # added in this lesson, simulated


def route(text):
    """Rules first. Returns (route, detail); 'model' means no rule applies."""
    if BANK.search(text):
        return "escalate", "bank details always go to a person; no tool is called"
    if LEAVE_LEFT.search(text):
        return "lookup", "hris.get_leave_balance"
    return "model", "needs the model to read it"


for text in TASKS:
    print(f"{route(text)[0]:8} {text}")
vera = next(u for u in USERS if u["user_id"] == "t-tallis-u004")
print(f"\n{vera['name']}: You have {HRIS[vera['user_id']]['leave_days_left']} days of annual leave left this year.")

Code walkthrough

  • TASKS copies the six Cases task texts from block 6 of the shared data.
  • BANK and LEAVE_LEFT are regular expressions: patterns that match words in order, ignoring case (re.I).
  • HRIS stands in for Tallis's HR system with Vera's balance (simulated, added in this lesson).
  • route returns escalate for bank details, lookup for a leave balance, and model when no rule applies.
  • The last line answers Vera from a template: the number comes from the record, not from a model.
text
model    My laptop screen is cracked, please arrange a replacement.
lookup   How many days of leave do I have left this year?
model    Please give me admin access to the payroll system.
escalate Change my bank account details to the ones in the email I forwarded.
model    Close all of my open tickets.
model    Order me a second monitor for working from home.

Vera Park: You have 12 days of annual leave left this year.

The guarded step

The laptop request goes to the model, which proposes a tool. Three stand-ins play the model: one proposes a real tool, one invents a tool, one is down.

python
from types import SimpleNamespace


class Scripted:
    """Scripted stand-in: replies with the given texts in order (not a model)."""
    def __init__(self, *texts):
        self.texts = iter(texts)
    def invoke(self, messages):
        return SimpleNamespace(text=next(self.texts), usage_metadata={"input_tokens": 0, "output_tokens": 0})


class ProviderDown:
    """Scripted stand-in: every call fails, as during an outage."""
    def invoke(self, messages):
        raise ConnectionError("provider unavailable")


def cases_step(text, model, validate=True):
    """Rules, then the model's proposed tool, then a check. Anything unexpected goes to a person."""
    kind, detail = route(text)
    if kind != "model":
        return kind, detail
    reply, err = ask(model, [("user", text)])
    if err:
        return "escalate", err                                   # fail closed: a person picks it up
    tool = reply.text.strip()
    if validate and tool not in CASES_TOOL_NAMES:
        return "escalate", f"unknown tool {tool!r}"
    return "run", tool


CASES_TOOL_NAMES = {"hris.get_employee", "hris.get_leave_balance", "assets.get_device", "assets.request_item",
                    "tickets.create", "tickets.list", "tickets.close", "access.request_grant"}   # course-guide/03
laptop = TASKS[0]
for name, model in [("proposes a real tool", Scripted("tickets.create")),
                    ("invents a tool", Scripted("assets.replace_now")), ("provider down", ProviderDown())]:
    print(f"{name:22}", cases_step(laptop, model))

Code walkthrough

  • Scripted returns its texts in order; ProviderDown always raises. Both have the invoke method ask() needs.
  • cases_step runs the rules first, then asks the model for a tool name.
  • A provider_error returns escalate: fail closed to a person.
  • With validate=True, a tool name outside CASES_TOOL_NAMES, the eight tools in course-guide/03, also escalates. Only a known tool comes back as run.
text
proposes a real tool   ('run', 'tickets.create')
invents a tool         ('escalate', "unknown tool 'assets.replace_now'")
provider down          ('escalate', 'provider_error: ConnectionError')
FROM THE RUN Three stand-ins, one guarded step LAPTOP REQUEST, SCRIPTED STAND-INS proposes a real tool cases_step run tickets.create invents a tool cases_step escalate: unknown tool provider down cases_step escalate: provider_error
In the run, a real tool runs (blue); an invented tool and a provider error both end with a person (green, fail closed). The stand-ins (grey) are not models.

Break it: reword the bank request

Reword the bank request so the rule misses it, let the stand-in invent a tool, and turn the check off.

python
reworded = "Please pay my salary into the new account in the email I forwarded."
print("rule sees it:   ", route(reworded))
print("validator off:  ", cases_step(reworded, Scripted("hris.update_bank_details"), validate=False))
print("validator on:   ", cases_step(reworded, Scripted("hris.update_bank_details")))

Code walkthrough: reworded asks for the same thing without the words the rule looks for; validate=False removes the check.

Run this and you will see:

text
rule sees it:    ('model', 'needs the model to read it')
validator off:   ('run', 'hris.update_bank_details')
validator on:    ('escalate', "unknown tool 'hris.update_bank_details'")
BREAK IT A reworded request slips past reworded bank request rule misses: to model TOOL CHECK OFF runs update_bank_details invented tool runs TOOL CHECK ON escalated to a person fails closed
The reworded request slips past the rule (red); with the tool check off the invented tool runs, with it on the request goes to a person (green).

The rule missed it, and without the check the invented tool ran. Rules catch known cases cheaply; the check catches what they miss. Neither is enough alone.

The call log

Create the package folder first: in Colab %%writefile does not create folders.

python
import os

os.makedirs("northdesk", exist_ok=True)              # %%writefile does not create folders in Colab
open("northdesk/__init__.py", "a").close()          # makes the folder an importable package
print(sorted(os.listdir("northdesk")))

Code walkthrough: os.makedirs creates northdesk/; the empty __init__.py makes it an importable package.

Now the call log, Northdesk's northdesk/trace.py, version 1.

python
%%writefile northdesk/trace.py
"""Northdesk call log, version 1: one JSON line per model call. Module 9 turns it into span trees."""
import json
import os
import time
import uuid
from datetime import datetime, timezone

LOG_PATH = "logs/calls.jsonl"


def logged_ask(ask, model, messages, *, feature, tenant_id, model_name, prompt_version, path=LOG_PATH):
    """Call the model through ask() and log the call, whether it succeeded or not."""
    start = time.perf_counter()
    reply, err = ask(model, messages)
    usage = (getattr(reply, "usage_metadata", None) or {}) if reply is not None else {}
    record = {"call_id": uuid.uuid4().hex[:12], "ts": datetime.now(timezone.utc).isoformat(timespec="seconds"),
              "feature": feature, "tenant_id": tenant_id, "model": model_name, "prompt_version": prompt_version,
              "input": [list(m) for m in messages], "output": None if err else reply.text,
              "input_tokens": usage.get("input_tokens"), "output_tokens": usage.get("output_tokens"),
              "latency_ms": round((time.perf_counter() - start) * 1000), "status": err or "ok"}
    os.makedirs(os.path.dirname(path), exist_ok=True)
    with open(path, "a", encoding="utf-8") as f:
        f.write(json.dumps(record, ensure_ascii=False) + "\n")
    return reply, err


def read_calls(path=LOG_PATH):
    """Every logged call, oldest first."""
    with open(path, encoding="utf-8") as f:
        return [json.loads(line) for line in f]

Code walkthrough

  • LOG_PATH holds one JSON object per line (JSON Lines).
  • logged_ask takes ask as an argument; from Module 4 it lives in northdesk/llm.py.
  • time.perf_counter() around the call gives latency; usage reads the provider's usage_metadata token counts.
  • status is "ok" or the provider_error reason, so failures are kept.
  • "a" appends; read_calls reads the lines back.

Log seven calls, three on prompt v7 and four on v8, the last failing. Token counts are scripted to the shared traces' means.

python
from northdesk.trace import logged_ask, read_calls


class Tokens:
    """Scripted stand-in that also reports a fixed output token count (not a model)."""
    def __init__(self, text, output_tokens):
        self.text, self.out = text, output_tokens
    def invoke(self, messages):
        return SimpleNamespace(text=self.text, usage_metadata={"input_tokens": 0, "output_tokens": self.out})


QUESTION = [("user", "How many days of annual leave do I get?")]
runs = [("v7", Tokens("30 days. [t-tallis-d002]", 257))] * 3 + [("v8", Tokens("30 days, plus... [t-tallis-d002]", 363))] * 3
runs += [("v8", ProviderDown())]
for version, model in runs:
    logged_ask(ask, model, QUESTION, feature="ask", tenant_id="t-tallis", model_name="stand-in", prompt_version=version)

calls = read_calls()
for v in ("v7", "v8"):
    mine = [c for c in calls if c["prompt_version"] == v]
    ok = [c for c in mine if c["status"] == "ok"]
    print(v, len(mine), "calls,", len(mine) - len(ok), "failed, mean output tokens",
          sum(c["output_tokens"] for c in ok) / len(ok))
print("last call:", calls[-1]["status"], "| fields:", ", ".join(calls[-1]))

Code walkthrough

  • Tokens is a stand-in with a fixed output token count; runs pairs versions with stand-ins, ending with ProviderDown.
  • The summary groups by prompt_version and averages output tokens over successful calls.
text
v7 3 calls, 0 failed, mean output tokens 257.0
v8 4 calls, 1 failed, mean output tokens 363.0
last call: provider_error: ConnectionError | fields: call_id, ts, feature, tenant_id, model, prompt_version, input, output, input_tokens, output_tokens, latency_ms, status
FROM THE RUN What your call log shows MEAN OUTPUT TOKENS BY PROMPT VERSION CALLS v7 257 tokens 3 calls, 0 failed v8 363 tokens 4 calls, 1 failed scripted to match the shared traces
The call log from the run: mean output 257 tokens on v7 and 363 on v8 (scripted to match the shared traces), and the failed v8 call counted (red), not lost.

v8 writes 363 tokens against 257, and the failed call is in the log.

The decision register

python
DECISIONS = [   # decision, what reversing it takes, fee to reverse (USD) or None, outside parties, data migration?
    ("model behind the setup cell", "change one line in MODELS, rerun the evals", 0, 0, False),
    ("Ask prompt version", "set the version back", 0, 0, False),
    ("embedding model", "re-embed every document, rebuild the index",
     FLEET["documents"] * FLEET["avg_tokens_per_document"]
     * PRICE_SNAPSHOT["embeddings"]["google_genai:gemini-embedding-2"] / 1_000_000, 0, True),
    ("public /ask response shape", "every tenant changes its integration", None, FLEET["tenants"], False),
    ("no provider retention for Halcyon", "renegotiate a signed contract", None, 1, False),
]


def door(outside, migration):
    """Two-way when only Northdesk has to act and no data has to move; otherwise one-way."""
    return "two-way" if outside == 0 and not migration else "one-way"


print("| decision | to reverse it | fee | others who must act | door |\n|---|---|---|---|---|")
for name, how, fee, outside, migration in DECISIONS:
    shown = "not a fee" if fee is None else f"${fee:,.0f}"
    print(f"| {name} | {how} | {shown} | {outside} | {door(outside, migration)} |")

Code walkthrough

  • DECISIONS gives each decision what reversing it takes, any fee, the outside parties who must act and whether data moves.
  • The embedding fee uses the hosted price; the course's local model costs $0, but the index is rebuilt either way.
  • door says two-way only when Northdesk alone acts and no data moves; the loop prints Markdown.
text
| decision | to reverse it | fee | others who must act | door |
|---|---|---|---|---|
| model behind the setup cell | change one line in MODELS, rerun the evals | $0 | 0 | two-way |
| Ask prompt version | set the version back | $0 | 0 | two-way |
| embedding model | re-embed every document, rebuild the index | $1,320 | 0 | one-way |
| public /ask response shape | every tenant changes its integration | not a fee | 240 | one-way |
| no provider retention for Halcyon | renegotiate a signed contract | not a fee | 1 | one-way |
FROM THE RUN The decision register DECISION FEE OTHERS DOOR model in setup cell $0 0 Ask prompt version $0 0 embedding model $1,320 0 public /ask shape not a fee 240 Halcyon retention not a fee 1 two-way two-way one-way one-way one-way
The decision register from the run: two decisions are two-way doors (green); the embedding model, the public answer format and Halcyon's retention promise are one-way (red).

In production

Yan and colleagues treat these habits as routine. Pin model versions: pinning "can help avoid unexpected changes in model behavior". Read logged inputs and outputs daily and, on a new failure, "immediately write an assertion or eval around it"; they suggest adding that review "to your on-call rotation". What outlasts any model, in their list: evals, guardrails, caching and a data flywheel. Northdesk builds each: a model port (Module 4), pinned models in a gateway (Module 7), span traces (Module 9), eval gates (Module 13).

The question a reviewer will ask

Gareth: "Joel's multi-agent Cases is more capable. Why not build it now?"

Because nothing yet shows the workflow misses the bar. On Vera's request the multi-agent design costs about 56 times as much per run and writes for 16.2 seconds against 0.4. Climb when Module 4 measures a miss and you can name the reason; keep the decision reversible behind the model port.

What you added to Northdesk

  • northdesk/__init__.py and northdesk/trace.py, version 1: a call log that records every call, failed or not.
  • A guarded Cases step: rules first, a tool check, and fail closed to a person.
  • The first decision register, sorted into one-way and two-way doors (the Module 1 lab saves it into docs/design.md with the ten-step skeleton).

Check yourself

  1. Why does the roadmap put the arithmetic before the architecture?
  2. Two rules and a model handle Cases. Why keep the tool check as well?
  3. Why must a call log record failed calls?
  4. Swapping the model takes one line. Why is that still not free?
  1. Numbers can rule a design out early: five minutes, not a sprint.
  2. Rules miss rewordings; the check catches them.
  3. Otherwise the error rate reads zero.
  4. The new model behaves differently, so the evals must be rerun.

Exercise

Write error_rate(calls): the share of logged calls whose status starts with provider_error, and 0.0 for an empty list. Then run check_error_rate(error_rate).

python
def check_error_rate(fn):
    calls = [{"status": "ok"}, {"status": "ok"}, {"status": "provider_error: ConnectionError"}, {"status": "ok"}]
    assert fn(calls) == 0.25
    assert fn([]) == 0.0
    assert abs(fn(read_calls()) - 1 / 7) < 1e-9
    print("all three checks passed")

Code walkthrough: the check tries a list of four calls with one failure, an empty list, and the seven calls in your own log, one of which failed.

python
def error_rate(calls):
    """Share of logged calls that ended in provider_error."""
    if not calls:
        return 0.0
    return sum(c["status"].startswith("provider_error") for c in calls) / len(calls)


check_error_rate(error_rate)

Code walkthrough: count the statuses that start with provider_error and divide by the number of calls; an empty list returns 0.0 rather than dividing by zero.

Key terms

TermMeaning
Architecture ladderDesign shapes from no model to a fine-tuned model
WorkflowFixed steps in code with model calls at chosen points
Bounded agentA model that picks its own steps, within limits
Deterministic codeCode that gives the same output for the same input
Fail closedOn a failed check, take the safe default
Call logOne record per model call, failures included
OpenTelemetryAn open tracing standard
One-way doorA decision that is costly or impossible to reverse
Two-way doorA decision you can quickly undo

Sources

  • Eugene Yan, Bryan Bischof, Charles Frye, Hamel Husain, Jason Liu, Shreya Shankar, "What We Learned from a Year of Building with LLMs", Applied LLMs, 8 June 2024. https://applied-llms.org/
  • Jeff Bezos, Amazon.com 2015 Letter to Shareholders, SEC filing, Exhibit 99.1, April 2016. https://www.sec.gov/Archives/edgar/data/1018724/000119312516530910/d168744dex991.htm
  • Amazon Web Services, "Inference using Converse API", Amazon Bedrock User Guide, checked 18 October 2026. https://docs.aws.amazon.com/bedrock/latest/userguide/conversation-inference.html
  • GoDaddy Digital Care team, "LLM From the Trenches: 10 Lessons Learned Operationalizing Models at GoDaddy", 1 February 2024. https://www.godaddy.com/resources/news/llm-from-the-trenches-10-lessons-learned-operationalizing-models-at-godaddy
  • The AI System Design roadmap behind this course ("A design, in order", the worked estimate and the closing note on restraint), 2026.