Structured Output and Function Calling

Course Content

Combining LLMs with APIs and Databases


A logistics company gives its internal assistant one tool: run_sql, read-only, pointed at a replica of the warehouse database. The demo is excellent. "How many parcels are late in Leeds?" comes back correct in four seconds, and everyone agrees this is the future.

Nine days later an operations manager asks "which orders shipped last quarter?" The model writes exactly the SQL a human would write:

SQL
SELECT * FROM ordersWHERE shipped_at >= '2026-01-01' AND shipped_at < '2026-04-01';

That query returns 24,180 rows. The tool serialises them to JSON — about 1.9 MB — and hands the lot back to the model as a tool result. The next API call dies before it starts:

Text
BadRequestError: 400 - This model's maximum context length is 200000tokens, however your messages resulted in 623441 tokens.

The conversation is now permanently broken: the oversized tool result is already in the message history, so every retry produces the same 400. Note what was not wrong. The reasoning was right, the SQL was right, the database was healthy. The tool was wrong — no row limit, no result shaping, no ceiling on what it would push into a context window.

Wiring a model to a real API or database is roughly ten per cent plumbing and ninety per cent boundary design. The ninety per cent is what follows.

Who is allowed to write the queryModel writes the SQL• Any table, any join, any predicate• Injection arrives as a valid string• Read-only role is the only real fence• Row and byte capsmust be enforced outsideModel fills declared parameters• One query per tool, written by you• Arguments bind, they never concatenate• Scope is fixed at declaration time• Result shaping cuts tokens enormously
Parameterised tools trade some flexibility for the only boundary the model cannot argue its way past.

Three shapes of integration

Almost every integration is one of three shapes, and they fail in genuinely different ways. Recognising which one you are in tells you what to worry about.

Shape 1: model decides, you call an external service

Text
User      "Is it going to rain in Bristol tomorrow?"  vModel     emits get_forecast{location:"Bristol", days:1}  vHandler   validates args, adds the API key, sets a timeout  vUpstream  350 ms, 200 OK, 14 KB of JSON  vHandler   shapes it to 6 fields, ~90 tokens  vModel     reads the real numbers, writes the answer  vUser      "Yes - 8 mm expected between 14:00 and 18:00."

Shape 2: model decides, you touch your own data

Same skeleton, but the service is your database and some calls write. The difference is not technical: a bad weather lookup is embarrassing, a bad UPDATE is an incident. Reads and writes deserve separate treatment from line one.

Shape 3: multi-step workflow

Text
User: "Order the blue mug and text me when it's confirmed."   |   vModel -> search_products{query:"blue mug"}          [read]   |   vModel -> create_order{sku:"MUG-BL-01", qty:1}       [write]   |   vModel -> charge_card{amount:12.50, order:"O-8871"}  [write, irreversible]   |   vModel -> send_sms{to:"+44...", body:"Confirmed"}    [write, external]   |   vFinal answer

Four steps, four chances to fail, and the failures are not symmetric. If step 4 fails you have a paid-for order and a silent customer. If step 3 fails after the card was charged but before your code saw the response, you have taken money for nothing.

ShapeTypical latencyWhat usually breaksMitigation that actually helps
External read-only API1 extra round trip (~2–3 s total)Timeouts, rate limits, oversized payloadsHard timeout, result shaping, cache
Own databaseFast (10–100 ms per query)Unbounded result sets, injection, accidental writesRead-only role, forced LIMIT, parameterised queries
Multi-step workflow2–10 s, grows linearly with stepsPartial completion, double execution, loopsIdempotency keys, step cap, compensating actions

The tool boundary is an API design problem

The useful mental shift: you are not "giving the model database access", you are publishing an API whose consumer happens to be a language model. That consumer has unusual properties.

  • It reads only the tool name, description and argument schema. It cannot open your docs, see your table definitions, or ask a colleague.
  • When it is unsure, it does not error — it produces something plausible. Ambiguity in your description becomes invented arguments.
  • Every byte you return costs money and latency, on this turn and every subsequent turn, because tool results stay in the conversation history.
  • It will call your tool with arguments no human would type, because a user asked.

Design the tool for the question the user is asking, not for the shape of your internal database. A tool is a product surface, not a thin RPC wrapper.

InstinctWhat it producesBetter
Mirror the internal API one-to-one18 tools, the model picks wrongly4–8 coarse tools matching real user intents
query(table, where, fields)The model writes your data layer, badlyfind_customer_by_email(email)
Free-text status argument"in-transit", "In Transit", "shipped?""enum": ["pending","shipped","delivered"]
Return the raw upstream JSON14 KB per lookup, 90% irrelevantReturn 6 fields, ~90 tokens
Raise exceptions on failureLoop crashes mid-conversationReturn a structured error the model can read

Calling an external API properly

The naive weather handler almost every tutorial shows:

Python
def get_weather(location):    r = requests.get(URL, params={"q": location, "appid": KEY})    return r.json()          # 14 KB of upstream JSON, straight into the context

No timeout, so a hung upstream hangs your request thread indefinitely. No error handling, so a 404 becomes an exception mid-loop. No shaping, so you pay for fields nobody reads. And no validation of the argument you were handed. The hardened version:

Python
import os, requestsfrom typing import AnyWEATHER_URL = "https://api.openweathermap.org/data/2.5/weather"API_KEY = os.environ["OPENWEATHER_API_KEY"]   # never a literal in sourcedef get_weather(location: str, units: str = "metric") -> dict[str, Any]:    """Current conditions. Always returns a dict; never raises."""    if not isinstance(location, str) or not 1 <= len(location) <= 80:        return {"ok": False, "error": "invalid_location",                "message": "location must be a city name, 1-80 characters"}    try:        r = requests.get(            WEATHER_URL,            params={"q": location, "units": units, "appid": API_KEY},            timeout=(3.0, 5.0),        # (connect, read) - always a tuple        )    except requests.Timeout:        return {"ok": False, "error": "upstream_timeout",                "message": "weather service did not respond in 5s"}    except requests.RequestException as e:        return {"ok": False, "error": "upstream_unreachable",                "message": type(e).__name__}    if r.status_code == 404:        return {"ok": False, "error": "location_not_found",                "message": f"no city matched {location!r}; try 'City, CC'"}    if r.status_code >= 400:        return {"ok": False, "error": "upstream_error",                "message": f"HTTP {r.status_code}"}    d = r.json()    return {                                  # shaped: 6 fields, not 40        "ok": True, "location": f"{d['name']}, {d['sys']['country']}",        "temperature_c": round(d["main"]["temp"], 1),        "feels_like_c": round(d["main"]["feels_like"], 1),        "condition": d["weather"][0]["main"],        "humidity_pct": d["main"]["humidity"]}

The shaped result the model actually sees:

JSON
{  "ok": true,  "location": "Bristol, GB",  "temperature_c": 11.4,  "feels_like_c": 9.8,  "condition": "Rain",  "humidity_pct": 87}

Roughly 45 tokens instead of 3,500 for the raw body. Over a 10-turn conversation, where the result is resent every turn, the raw version costs about 35,000 tokens of repeated context; the shaped one about 450.

Why the error must be a return value, not an exception

When get_weather("Atlantis") returns {"ok": false, "error": "location_not_found"} and you feed that back, the model says "I couldn't find Atlantis — did you mean Atlanta?" and the conversation survives. Raise instead and your loop crashes with the model never getting a chance to recover. Tool handlers are total functions: every input yields a JSON-serialisable output.

Several APIs at once, and what changes

Add a payment API and an SMS API and two problems appear that a single read-only service never has: double execution and partial completion.

Double execution and idempotency keys

A charge has a 5-second read timeout; the upstream takes 5.4 seconds. Your client raises Timeout at 5.0 s, the retry fires, the second attempt succeeds. The customer is charged 89.99 twice, because the first attempt also succeeded — you never saw the response.

The fix is an idempotency key: a value derived from the business intent, not from the attempt.

Python
import stripedef charge_order(order_id: str, amount_gbp: float,                 customer_id: str, payment_method_id: str) -> dict:    try:        intent = stripe.PaymentIntent.create(            amount=int(round(amount_gbp * 100)),   # pence, integer - never floats            currency="gbp",            customer=customer_id,            payment_method=payment_method_id,      # saved card            off_session=True, confirm=True,        # charge now, no redirect            metadata={"order_id": order_id},            idempotency_key=f"charge:{order_id}",  # same key on every retry        )        return {"ok": True, "transaction_id": intent.id, "amount_gbp": amount_gbp}    except stripe.CardError as e:        return {"ok": False, "error": "card_declined", "message": e.user_message}    except stripe.StripeError:        return {"ok": False, "error": "payment_provider_error",                "message": "payment could not be confirmed; not retried"}

With idempotency_key="charge:O-8871" the retry returns the original payment rather than creating a second. (The code uses Stripe's Payment Intents API. Stripe has deprecated creating card payments with the older Charges API, and new integrations cannot use it.) Note int(round(amount_gbp * 100)) too: 19.99 * 100 in floating point is 1998.9999999999998, and bare int() truncates it to 1998 pence. Money is integer pence at the boundary.

Partial completion

Payment succeeds, SMS fails. Refunding the card because Twilio was down is the wrong reaction. The right structure: irreversible steps commit, and everything downstream becomes a retriable background job.

StepReversible?If it failsIf a later step fails
search_productsn/a (read)Retry freelyNothing to undo
create_orderYes (cancel)Retry with same client refCancel if payment fails
charge_cardOnly via refundDo not blind-retry; reconcile by keyKeep it; do not auto-refund
send_smsNo (but harmless)Enqueue for retry, report successn/a (last step)

Talking to a SQL database

There are two designs here, and choosing badly is expensive.

Fixed intent toolsModel-generated SQL
Model suppliesTyped parameters onlyA whole query string
Query authorshipYou (parameterised, reviewed)The model, at run time
CoverageOnly questions you anticipatedAnything expressible in SQL
Injection riskEffectively zeroStructural — the query is the payload
Cost blow-up riskBounded by your queryUnbounded without hard caps
Use whenProduction, customer data, writesInternal analytics, read replica, cost caps

Injection, via arguments you did not write

String interpolation is unsafe even in the "fixed intent" design, because the arguments still come from the model, which got them from a user. A user types "look up the customer whose email is ' OR '1'='1"; the model passes it through, and this handler:

Python
cursor.execute(f"SELECT id, name, email FROM customers WHERE email = '{email}'")

executes:

SQL
SELECT id, name, email FROM customers WHERE email = '' OR '1'='1'

Every customer row is returned, dumped into the model's context, and summarised back to whoever asked. That is a data breach with a polite tone of voice. The fix is the same as it has been for twenty-five years — bind parameters:

Python
import sqlite3from contextlib import closingdef find_customer(email: str) -> dict:    if not isinstance(email, str) or "@" not in email or len(email) > 254:        return {"ok": False, "error": "invalid_email"}    with closing(sqlite3.connect(DB_PATH)) as conn:        conn.row_factory = sqlite3.Row        row = conn.execute(            "SELECT id, name, email, phone FROM customers WHERE email = ? LIMIT 1",            (email,),                       # bound, never interpolated        ).fetchone()    if row is None:        return {"ok": False, "error": "customer_not_found",                "message": f"no customer with email {email}"}    return {"ok": True, "customer": dict(row)}

A model-supplied argument is user input that has been through a paraphrasing machine. Treat it exactly as you would treat a raw form field from the public internet.

If you do let the model write SQL

Sometimes the analytics value is worth it. Then the guardrails are not optional, and none of them is "ask nicely in the prompt":

  1. A read-only database role. GRANT SELECT on named tables, nothing else. This is the only control that survives a clever prompt injection, because the database enforces it, not your Python.
  2. A statement timeout (SET LOCAL statement_timeout = '5s'), or one accidental cross join pins a CPU for an hour.
  3. A forced row cap: wrap it as SELECT * FROM (…user query…) AS q LIMIT 200, and tell the model when you truncated.
  4. A byte cap on the serialised result, checked before it goes near the context.
  5. One statement only. Reject anything with a second semicolon-terminated statement.

Shaping the result: the 1,550× difference

Return the answer, not the data. The opening failure returned 24,180 rows to a question whose useful answer is an aggregate:

SQL
SELECT region, COUNT(*) AS orders, ROUND(SUM(total_gbp), 2) AS revenue_gbpFROM ordersWHERE shipped_at >= '2026-01-01' AND shipped_at < '2026-04-01'GROUP BY regionORDER BY revenue_gbp DESC;
JSON
{  "ok": true,  "row_count": 8,  "truncated": false,  "rows": [    {"region": "London",     "orders": 6104, "revenue_gbp": 481203.55},    {"region": "North West", "orders": 3877, "revenue_gbp": 298440.10},    {"region": "Scotland",   "orders": 2210, "revenue_gbp": 176992.40}  ]}
Raw rowsAggregate
Rows returned24,1808
Tokens in context~620,000~400
Fits in a 200k window?No — hard 400 errorYes, 0.2% of it
Input cost at 3 dollars per million tokens1.86 dollars per turn0.0012 dollars per turn
Answer qualityModel must count 24,180 rows itselfDatabase already counted, exactly

That is a factor of 1,550 in tokens, and the aggregate is also more accurate, because arithmetic over thousands of rows is precisely the thing a language model does badly and a database does perfectly.

Document databases have the same holes, differently shaped

With MongoDB the injection vector is not concatenation but operator objects. If a tool accepts a filter document and a user coaxes the model into producing {"email": {"$ne": null}}, that filter matches every document in the collection. Never let the model supply a filter document: it supplies scalars, you build the filter.

Python
customers = MongoClient(MONGO_URI, serverSelectionTimeoutMS=3000)["shop"]["customers"]customers.create_index([("email", 1)], unique=True)   # index, or it scansdef find_customer_mongo(email: str) -> dict:    if not isinstance(email, str):          # blocks {"$ne": null} outright        return {"ok": False, "error": "invalid_email"}    doc = customers.find_one({"email": email},                             projection={"_id": 1, "name": 1, "email": 1})    if doc is None:        return {"ok": False, "error": "customer_not_found"}    doc["_id"] = str(doc["_id"])            # ObjectId is not JSON-serialisable    return {"ok": True, "customer": doc}

The isinstance check is the actual security control here — an operator object is a dict, not a str. And ObjectId raises TypeError: Object of type ObjectId is not JSON serializable the moment you hand the result to the model, killing the loop.

Connections, pools, and a little queueing theory

Opening a database connection costs 5–50 ms and a chunk of server memory. Doing it inside every tool call is fine at one request per second and catastrophic at fifty.

How big should the pool be? Little's law answers it: connections in use equals arrival rate times holding time, L=λWL = \lambda W.

At 50 requests per second, each holding a connection for 120 ms:

L=50×0.12=6L = 50 \times 0.12 = 6

Six connections are busy on average. A pool of 5 therefore queues permanently — arrivals outpace releases and the wait grows without bound. A pool of exactly 6 is critical, which means terrible tail latency. Allow headroom for bursts and slow queries: 10 to 12 here. Higher is not free either — every connection is memory and a scheduling slot, and 300 connections fighting over 8 cores is slower than 20.

The pool implementation people usually reach for first has three defects:

Python
class DatabasePool:               # do not ship this    def __init__(self, db_path, pool_size=5):        self.connections = [sqlite3.connect(db_path) for _ in range(pool_size)]    @contextmanager    def get_connection(self):        conn = self.connections.pop()      # (1) IndexError when exhausted        try:            yield conn                     # (2) no rollback on exception        finally:            self.connections.append(conn)
  1. Under load, pop() on an empty list raises IndexError: pop from empty list — a crash instead of a wait.
  2. If the caller's transaction failed, the connection goes back into the pool mid-transaction and poisons the next borrower.
  3. list.pop() / append() are not a synchronisation primitive; use a queue.Queue, which blocks with a timeout, or a real pooler.

Use your driver's pool (psycopg_pool.ConnectionPool, SQLAlchemy's QueuePool) rather than writing one. And a SQLite connection is bound to its creating thread unless you pass check_same_thread=False and serialise access yourself; sharing them naively raises ProgrammingError: SQLite objects created in a thread can only be used in that same thread.

Failing well: timeouts, retries, and a bug worth studying

Here is a retry decorator of the kind that circulates widely. It contains a bug that makes it fail on the first retry, every time.

Python
def retry_on_failure(max_retries=3, delay=1.0):    def decorator(func):        @wraps(func)        def wrapper(*args, **kwargs):            for attempt in range(max_retries):                try:                    return func(*args, **kwargs)                except Exception as e:                    if attempt < max_retries - 1:                        time.sleep(delay)     # UnboundLocalError                        delay *= 2                    else:                        return {"ok": False, "error": str(e)}        return wrapper    return decorator

Because delay is assigned anywhere inside wrapper, Python treats it as local to wrapper for the whole body — an assignment on the line after the read is enough. So time.sleep(delay) raises UnboundLocalError: cannot access local variable 'delay' where it is not associated with a value, from inside an except block, so the traceback foregrounds the original error and buries the real one. The fix is one line: bind a local copy.

Python
RETRYABLE = (requests.Timeout, requests.ConnectionError)def retry_on_failure(max_retries=3, base_delay=1.0):    def decorator(func):        @wraps(func)        def wrapper(*args, **kwargs):            wait = base_delay                       # local copy, no closure write            for attempt in range(1, max_retries + 1):                try:                    return func(*args, **kwargs)                except RETRYABLE as e:              # only retryable errors                    if attempt == max_retries:                        return {"ok": False, "error": "upstream_unavailable",                                "message": f"{max_retries} attempts failed"}                    time.sleep(wait + random.uniform(0, 0.3 * wait))  # jitter                    wait *= 2        return wrapper    return decorator

The retry budget is latency you are spending: three attempts at a 5-second read timeout plus 1 s + 2 s of backoff is a worst case of 3 × 5 + 3 = 18 seconds before the model starts writing. Decide whether the user would rather wait 18 seconds or hear "the weather service is down" after 6. Jitter matters too — without it every worker retries at the same instant, delivering a synchronised second wave to a struggling upstream.

FailureRetry?Why
Connection timeout, DNS failureYesTransient; nothing happened upstream
HTTP 503 / 502Yes, with backoffUpstream restarting or overloaded
HTTP 429Yes, honouring Retry-AfterRetrying sooner makes it worse
HTTP 400 / 422NoYour arguments are wrong; identical retry fails identically
HTTP 401 / 403NoCredentials or permissions; retrying just logs more denials
Read timeout on a write endpointOnly with an idempotency keyThe write may already have committed

When an upstream is properly down, retries stop helping and start hurting: every request now burns 18 seconds of a worker before failing. A circuit breaker fixes that — count consecutive failures, and after five, fail instantly for 30 seconds without calling out at all, then let one probe through. Users get a fast honest error instead of a slow one, and the upstream gets room to recover.

Error envelopes, logging, and what not to say out loud

Tool results have two audiences with opposite needs. The model needs enough detail to recover, your logs enough to debug, and neither should contain a database password.

Python
# Good - short, actionable, no internals; the model can recover from these{"ok": False, "error": "customer_not_found", "message": "No customer with that email; ask the user to check the spelling."}{"ok": False, "error": "rate_limited", "retry_after_s": 60, "message": "Quota exhausted; try again in 60 seconds."}# Bad - leaks internals into a context the user may see, and is unactionable{"ok": False, "error": "psycopg.OperationalError: connection to server at "                       "10.0.4.19, port 5432 failed: FATAL: password auth "                       "failed for user app_rw"}

That second envelope tells an attacker your internal IP, port, and service account name, and tells the model nothing it can act on. Log the full exception with a correlation id; return the id and a plain sentence.

Audit logging is the other half — one structured line per tool invocation, with redaction applied where it is written rather than hopefully later:

Python
REDACT = {"token", "card", "password", "api_key", "phone", "email"}def redact(args: dict) -> dict:    return {k: ("[redacted]" if k.lower() in REDACT else v) for k, v in args.items()}log.info(json.dumps({    "event": "tool_call", "trace_id": trace_id,   # ties call to conversation turn    "user_id": user_id, "tool": name, "args": redact(args),    "ok": result.get("ok"), "error": result.get("error"),    "duration_ms": round(elapsed * 1000),}))

The trace_id makes this usable. Without it you have a pile of tool calls; with it you can reconstruct which user turn produced which sequence of calls — the only way to debug an agent that did something strange three days ago.

If you cannot reconstruct, from logs alone, every external effect a conversation had, you cannot safely give that conversation write access to anything.

What to do before you wire up your first tool

What separates a demo from something you can leave running is a set of decisions made before the handler is written:

  • Decide the result-size ceiling first. 200 rows, 8 KB, whatever fits your budget — enforced in code, with a truncated: true flag so the model knows it has a subset. That single decision would have prevented the failure that opened this lesson.
  • Split read tools from write tools and give them different credentials. A read tool holding a read-only role cannot be talked into deleting anything, whatever appears in the context.
  • Give every outbound call a timeout as you write it. Timeouts get retrofitted after the first outage, never before.
  • Make every write idempotent with a key derived from the business intent. You will retry, whether you plan to or not.
  • Return errors, never raise them, in words the model can act on.
  • Shape results in the handler, not the prompt. "Please summarise the data" is a request; a projection is a guarantee.

Every one of those is a property of your code, not of the model. The model proposes actions in a well-defined format; whether those actions turn out fast, cheap, correct and safe is decided entirely on your side of the boundary. That is good news, because your side is the side you can test.