Lesson 3: Five habits that keep an AI design boring
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.
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."
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.
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."
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.
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.
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.
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.
"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."
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.
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.
Deterministic code is not anti-AI. It shrinks the part of the system you must evaluate statistically.
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.
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.
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.
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.
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.
In the shared traces, grouping by prompt version shows mean output rising from 257 to 363 tokens: the cause, the next morning.
The course runs open-source tracing, viewed in Phoenix or Langfuse, both self-hostable. Hosted products such as LangSmith and Datadog are named only.
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."
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.
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.
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.
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).
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.
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.
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
RUNGSlists 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.TIERmaps tiers to snapshot models;DECODE_TPSis the illustrative 50 tokens a second;runs_a_monthis 60,000 a day times 30.- The loop prices each call with
cost_usdand divides output tokens byDECODE_TPS.
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 writingRules first
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
TASKScopies the six Cases task texts from block 6 of the shared data.BANKandLEAVE_LEFTare regular expressions: patterns that match words in order, ignoring case (re.I).HRISstands in for Tallis's HR system with Vera's balance (simulated, added in this lesson).routereturnsescalatefor bank details,lookupfor a leave balance, andmodelwhen no rule applies.- The last line answers Vera from a template: the number comes from the record, not from a model.
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.
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
Scriptedreturns its texts in order;ProviderDownalways raises. Both have theinvokemethodask()needs.cases_stepruns the rules first, then asks the model for a tool name.- A
provider_errorreturnsescalate: fail closed to a person. - With
validate=True, a tool name outsideCASES_TOOL_NAMES, the eight tools in course-guide/03, also escalates. Only a known tool comes back asrun.
proposes a real tool ('run', 'tickets.create')
invents a tool ('escalate', "unknown tool 'assets.replace_now'")
provider down ('escalate', 'provider_error: ConnectionError')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.
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:
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'")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.
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.
%%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_PATHholds one JSON object per line (JSON Lines).logged_asktakesaskas an argument; from Module 4 it lives innorthdesk/llm.py.time.perf_counter()around the call gives latency;usagereads the provider'susage_metadatatoken counts.statusis"ok"or theprovider_errorreason, so failures are kept."a"appends;read_callsreads 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.
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
Tokensis a stand-in with a fixed output token count;runspairs versions with stand-ins, ending withProviderDown.- The summary groups by
prompt_versionand averages output tokens over successful calls.
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, statusv8 writes 363 tokens against 257, and the failed call is in the log.
The decision register
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
DECISIONSgives 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.
doorsays two-way only when Northdesk alone acts and no data moves; the loop prints Markdown.
| 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 |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__.pyandnorthdesk/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.mdwith the ten-step skeleton).
Check yourself
- Why does the roadmap put the arithmetic before the architecture?
- Two rules and a model handle Cases. Why keep the tool check as well?
- Why must a call log record failed calls?
- Swapping the model takes one line. Why is that still not free?
- Numbers can rule a design out early: five minutes, not a sprint.
- Rules miss rewordings; the check catches them.
- Otherwise the error rate reads zero.
- 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).
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.
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
| Term | Meaning |
|---|---|
| Architecture ladder | Design shapes from no model to a fine-tuned model |
| Workflow | Fixed steps in code with model calls at chosen points |
| Bounded agent | A model that picks its own steps, within limits |
| Deterministic code | Code that gives the same output for the same input |
| Fail closed | On a failed check, take the safe default |
| Call log | One record per model call, failures included |
| OpenTelemetry | An open tracing standard |
| One-way door | A decision that is costly or impossible to reverse |
| Two-way door | A 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.