Prompt Engineering · Module 4
Chain of Thought Prompting
Breaking some problems into intermediate steps instead of jumping straight to an answer.
Visual Experience
Decomposing A Multi-Step Problem Before Answering
Watch the same problem answered two ways: first as a quick leap, then as a sequence of smaller steps that can be checked. The teaching goal of the visual is decomposition and verification, not a claim that visible text exposes the model’s hidden internals perfectly.
Prompt Engineering · Module 4
Chain of Thought Prompting
Thinking before answering
01 ·A Complex Problem Arrives
A Complex Problem Arrives
"Let's give the model a problem that actually has moving parts."
Chain-of-Thought Prompting Examples: What the Technique Is and What Engineers Should Actually Do With It
Chain-of-thought (CoT) prompting is the practice of getting a language model to work through a problem in intermediate steps instead of emitting the final answer in one jump. Historically that meant either showing the model worked examples whose answers contain step-by-step working (Wei et al., 2022, arXiv:2201.11903) or appending an instruction such as "let's think step by step" (Kojima et al., 2022, arXiv:2205.11916). Those are the two classic chain-of-thought prompting examples: a few-shot prompt with worked steps, and a zero-shot prompt with a step-by-step instruction. It can help on multi-step problems, because every generated step becomes context for the next one. It does not change model weights, it does not always help, and the text it produces is a generated explanation, not a guaranteed record of what the network computed.
For an engineer the useful reading is broader than a magic phrase: decompose the task into steps whose outputs can be checked, and let code, not prose, verify the parts that code can verify. Ask the model for the facts and structure it is good at reading from messy text; compute the number with a deterministic function; compare the model's claimed result with the computed one. Intermediate calculations must be verified separately because a fluent step-by-step explanation can contain an arithmetic slip and still read as convincing. The rest of this chapter builds that design on one concrete task and shows where each piece earns its cost, and where it does not.
A Task That Fails Through Premature Conclusions
The support-ticket triage application from the earlier lessons (see Zero-Shot Prompting) now gets a harder ticket. The customer is not asking for classification; they are asking for money back:
Subject: SLA credit for the March 4 checkout outage
Our checkout API was unavailable from 09:10 until 10:40 on 2025-03-04 (all times UTC).
I know you had a scheduled maintenance window from 09:30 to 09:50 that day, which your
contract excludes from downtime. Our Gold plan allows 60 minutes of chargeable downtime
per incident before a credit applies. How many minutes over the limit were you?Everything needed is in the text, and the arithmetic is small. It is still a good test of premature conclusions, because several separate constraints must all be applied: the incident length, the exclusion that must be subtracted, the contractual limit that must be compared against the remaining time, and the direction of the final comparison. Ask a model for just the answer and the failure usually looks like a confident, fluent number:
User: <ticket T-4821> How many minutes over the SLA limit were we?
Reply: The outage lasted from 09:10 to 10:40, which is 90 minutes. The Gold plan allows
60 minutes, so the breach is 30 minutes.This reply is a plausible shape of failure, not a claim about any particular model's rate of error. It skipped a constraint: it never subtracted the 20-minute maintenance window, so it reports 30 minutes of breach where the contractual figure is 10 (derived in the next sections). Nothing in a one-line answer lets a reviewer see which step was skipped, and an SLA credit has real money attached. Notice also what not to conclude. The answer is not wrong because the model "did not think hard enough"; it is wrong because the output gave the application nothing to check. The technique this chapter teaches is about creating checkable structure.
What the Technique Is, Stated Carefully
A language model generates text one token at a time, each token conditioned on the prompt plus everything it has generated so far (see Next-Token Prediction). If the answer is a single token or short phrase, every bit of "work" must happen inside one forward pass for that token. If the model first writes "90 minutes in total; the exclusion is 20 minutes", those tokens are now context, and the final subtraction can attend to them. That is the plausible mechanism by which written intermediate steps can help on problems that need several dependent operations: the text acts as scratch space.
Two limits of that story matter for design. First, scratch space is not verification. A wrong intermediate line is also context, and the next lines will happily build on it. Second, the written steps are not an instrument readout of the model's internals. Research on this (for example Turpin et al., 2023, "Language Models Don't Always Say What They Think", arXiv:2305.04388) has shown that stated explanations can omit the real influence on an answer, such as a biasing cue in the prompt. Treat a rationale as one more piece of model output: sometimes useful to a human reader, sometimes wrong, never self-certifying.
There is also a product-level shift. Many current reasoning-oriented models do internal reasoning before the visible reply, and providers differ on whether, how much, and in what form any of it is exposed (some return a summary, some return nothing, some bill for it as output tokens). Those details change between models and versions, so this chapter does not depend on any of them; the section on reasoning visibility below explains the design consequence. An application should never need the model's private reasoning in order to be correct.
From Verbose Rationale to a Decomposed Contract
The folk version of CoT is to append "think step by step" and read the result. That gives an unstructured paragraph: hard to parse, hard to test, expensive to read, and exactly as trustworthy as any other model prose. It is also not universal. On a simple classification it adds latency and tokens and can talk the model out of a right first impression; on a task where the missing ingredient is a fact the model does not have, more words do not supply it.
A better contract assigns each stage a job that matches who is best at it. The model is good at reading a messy ticket and pulling out which timestamps and exclusions are being claimed. Code is good at interval arithmetic. Neither is good at the other's job: models make arithmetic and date-boundary slips, and regular expressions do not understand "from about nine-ten until twenty to eleven". So split the work:
Green boxes are runtime guarantees: they are ordinary code, and a given input yields the same output every time. Indigo boxes are model behaviour influenced by instructions, and they are the only places where the answer can drift. The model never gets to "decide" the breach minutes; it reads and phrases. This is the same separation the Structured Outputs lesson builds on: a typed intermediate record is what makes a step checkable at all.
The Reference Calculation: Full Interval Arithmetic
Before prompting anything, fix the ground truth with a method that has no model in it. The assumptions are part of the answer, so state them:
- 1
Assumptions (illustrative contract)
All times are UTC on 2025-03-04. Downtime is measured in whole minutes. The Gold plan allows 60 chargeable minutes per incident. Periods excluded by the contract (scheduled maintenance) do not count as chargeable. Clock-hour or business-hours rules are not applied here; they would add a further interval-clipping step. - 2
Gross incident length
10:40 minus 09:10. 10:40 is 100 minutes after 09:00, and 09:10 is 10 minutes after 09:00, so 100 - 10 = 90 minutes. - 3
Exclusion, clipped to the incident
The window 09:30 to 09:50 lies fully inside 09:10 to 10:40, so no clipping is needed. Length: 50 - 30 = 20 minutes. - 4
Chargeable (effective) downtime
90 - 20 = 70 minutes. As a cross-check, add the two surviving segments: 09:10 to 09:30 is 20 minutes, and 09:50 to 10:40 is 50 minutes; 20 + 50 = 70. - 5
Breach
max(0, effective - limit) = max(0, 70 - 60) = 10 minutes.
- Incident (gross)
- Excluded by contract
- Chargeable downtime
- Breach (beyond 60 min)
The figure provides a third, independent view of the same answer. Walk the chargeable clock forward: 20 minutes are consumed between 09:10 and 09:30, the clock pauses for maintenance, and it resumes at 09:50. The remaining 60 - 20 = 40 minutes of allowance run out at 09:50 + 40 min = 10:30. The incident ends at 10:40, so 10:40 - 10:30 = 10 minutes were over the limit. Three methods, one answer: 10. That redundancy is the habit worth copying, because it is exactly the kind of evidence a verifier can reproduce.
The general rule behind the numbers, for an incident interval [s, e), a set of exclusion intervals, and a limit L, in minutes:
gross = e - s
excluded = total length of the UNION of (exclusion ∩ [s, e)), overlaps counted once
effective = gross - excluded
breach = max(0, effective - L)Two details matter as soon as real data appears. Exclusions must be clipped to the incident, because a maintenance window that started before the outage only removes the part that overlaps it. And they must be merged when they overlap, or the same minutes are subtracted twice. A model reading prose is especially likely to skip both, which is another reason to hand this part to a function.
Decomposition Patterns and When Each One Pays Off
"Break it into steps" is not one technique. Five patterns recur, and they solve different problems.
Subtask split
One prompt per sub-question, each with its own narrow instructions and tests: "what is the incident window?", "what exclusions are claimed?", "which plan and limit apply?". It helps when sub-questions need different inputs (the plan limit comes from a contract database, not from the ticket) or when you want to retry only the failing piece. It costs more calls and more latency, so use it when the stages genuinely differ.
Extract facts, then calculate
The pattern used above. Separating reading from computing is the highest-value split in practice, because the two halves have different failure profiles. Extraction errors are about interpretation (wrong date, missed exclusion); calculation errors are about arithmetic. Splitting lets you measure them independently and fix each with the right remedy: a better prompt or example for one, a function for the other.
Plan, then execute
The model first emits a short plan (an ordered list of operations or tool calls), the runtime checks the plan against an allow-list, and only then are steps executed. It helps when the sequence of actions is not known in advance, for instance when a ticket might need an SQL lookup, a calendar query, or neither. Its danger is that a plausible-looking plan is not a correct one, so the plan itself needs validation, and any step with side effects needs authorization from the runtime (see Tools and Tool Calling).
Verify the final answer
A separate check of the result: recompute with code, run a constraint (breach minutes cannot exceed gross minutes), or compare two independent methods. A second model call asked "is this right?" can catch some errors, but it shares the failure modes of the first and should not be mistaken for a deterministic check. Where possible, prefer a verifier that does not read prose at all.
Deterministic tools for arithmetic
Calculators, date libraries, SQL engines and code interpreters give results by construction rather than by likelihood. Use them whenever the computation is exact and expressible: money, dates, unit conversion, counting, aggregation. The model's job becomes choosing the right call with the right arguments, which is itself checkable.
These patterns compose: the pipeline in the earlier figure is extract-then-calculate plus final verification, with a clarification exit. A very small, fixed task usually needs only one of them; adding all five to a classification step is complexity without benefit.
Checkable Intermediate Artifacts and the Verification Record
Once extraction returns structured facts, the application can build a verification record: the extracted facts, the computed values, any model-claimed values, and the outcome of each check. For ticket T-4821 the record looks like this. The reply_claims block holds numbers the composing model wrote into its draft reply, extracted back out of that draft so they can be compared.
{
"ticket_id": "T-4821",
"facts": {
"start": "2025-03-04T09:10",
"end": "2025-03-04T10:40",
"timezone": "UTC",
"excluded_intervals": [{"start": "2025-03-04T09:30", "end": "2025-03-04T09:50"}],
"assumptions_needed": []
},
"contract": {"plan": "Gold", "limit_minutes": 60, "source": "contracts_db"},
"computed": {
"gross_minutes": 90,
"excluded_minutes": 20,
"effective_minutes": 70,
"breach_minutes": 10
},
"reply_claims": {"effective_minutes": 70, "breach_minutes": 10},
"checks": {
"end_after_start": true,
"exclusions_valid": true,
"evidence_quotes_found_in_ticket": true,
"claims_match_computed": true
},
"status": "verified"
}The validation rules fall into three kinds, and keeping them distinct makes failures easy to route:
- Structural: the JSON parses, keys are present, timestamps parse. A failure usually means retry or fall back (see Structured Outputs).
- Logical: end after start, exclusion intervals non-empty,
0 <= breach_minutes <= gross_minutes,effective_minutes = gross_minutes - excluded_minutes. These identities hold for any correct answer, so they catch internal inconsistency without knowing the right answer. - Referential: the numbers equal a recomputation, and the quotes occur in the source. These are the strongest checks and require code that does not trust the model.
Here is the reference calculator and verifier as runnable Python 3 (standard library only). The values from the worked example are the test case at the bottom.
from datetime import datetime
FMT = "%Y-%m-%dT%H:%M"
def parse(s: str) -> datetime:
return datetime.strptime(s, FMT)
def minutes(a: datetime, b: datetime) -> int:
return int((b - a).total_seconds() // 60)
def merge_clip(start, end, intervals):
"""Clip each interval to [start, end], drop empties, merge overlaps."""
clipped = sorted(
(max(s, start), min(e, end)) for s, e in intervals if min(e, end) > max(s, start)
)
merged = []
for s, e in clipped:
if merged and s <= merged[-1][1]:
merged[-1] = (merged[-1][0], max(merged[-1][1], e))
else:
merged.append((s, e))
return merged
def compute_sla(start, end, excluded, limit):
merged = merge_clip(start, end, excluded)
gross = minutes(start, end)
excluded_total = sum(minutes(s, e) for s, e in merged)
effective = gross - excluded_total
return {
"gross_minutes": gross,
"excluded_minutes": excluded_total,
"effective_minutes": effective,
"breach_minutes": max(0, effective - limit),
}
def verify_record(rec: dict, limit: int) -> list[str]:
errors = []
start, end = parse(rec["start"]), parse(rec["end"])
if end <= start:
errors.append("end_not_after_start")
excl = [(parse(x["start"]), parse(x["end"])) for x in rec["excluded_intervals"]]
if any(e <= s for s, e in excl):
errors.append("bad_exclusion_interval")
if errors:
return errors
ref = compute_sla(start, end, excl, limit)
for key in ("effective_minutes", "breach_minutes"):
if rec[key] != ref[key]:
errors.append(f"{key}: model={rec[key]} reference={ref[key]}")
return errors
d = "2025-03-04T"
print(compute_sla(parse(d + "09:10"), parse(d + "10:40"),
[(parse(d + "09:30"), parse(d + "09:50"))], 60))
# {'gross_minutes': 90, 'excluded_minutes': 20, 'effective_minutes': 70, 'breach_minutes': 10}An overlap test shows why clipping and merging are not decoration. With exclusions 09:30 to 09:50 (20 min), 09:45 to 10:00 (15 min), 10:30 to 11:30 (60 min, only 10 of which fall inside the incident) and 08:00 to 09:00 (60 min, wholly outside), a naive sum of lengths is 20 + 15 + 60 + 60 = 155 minutes, which exceeds the 90-minute incident and would give a negative effective time. Clipping and merging gives 09:30 to 10:00 (30 min) plus 10:30 to 10:40 (10 min) = 40 minutes excluded, so effective = 90 - 40 = 50 and the breach is max(0, 50 - 60) = 0. Running the code above on those four intervals prints exactly this.
Tool-Assisted Reasoning: Why a Calculator Beats a Narrative
A narrative rationale offers two kinds of assurance: that it sounds coherent, and that a human can read it. Neither implies correctness; a fluent chain can contain one wrong subtraction. A tool call offers something stronger for the right class of problem: the result is produced by a program whose behaviour is specified and testable, so the same inputs give the same outputs. That holds for arithmetic, date math (including time zones and daylight saving rules, which are notoriously easy to get subtly wrong in prose), SQL aggregation over real data, and code execution in a sandbox.
The model's role changes from "produce the number" to "choose the call and fill its arguments". That is still fallible, so the runtime validates arguments before running. The exchange for our ticket looks like this:
The tool definition and the proposed call, in a provider-neutral shape (illustrative: concrete API field names differ between providers, so check your SDK's documentation for exact syntax; the Tool Usage Prompts lesson covers the prompting side and Tools and Tool Calling the runtime side):
tool: calculate_sla
description: Compute chargeable downtime and SLA breach minutes for one incident.
parameters:
start: string # "YYYY-MM-DDTHH:MM", same timezone for all values
end: string
excluded_intervals: [{start: string, end: string}]
limit_minutes: integer # runtime IGNORES model value; reads from contracts DB
model proposes:
calculate_sla(
start="2025-03-04T09:10", end="2025-03-04T10:40",
excluded_intervals=[{start:"2025-03-04T09:30", end:"2025-03-04T09:50"}],
limit_minutes=60)
runtime returns:
{"gross_minutes": 90, "excluded_minutes": 20, "effective_minutes": 70, "breach_minutes": 10}Note the comment on limit_minutes. A model-proposed call is a proposal, not an authorized action. The limit is a contractual fact; if the model supplies it, a hallucinated or injected value changes the customer's credit. The runtime should fetch it from the contract record for the authenticated customer and overwrite whatever the model sent. Likewise the tool is a pure function with no side effects; issuing the credit is a separate, authorized, auditable step, probably with human approval (see Human-in-the-Loop and Guardrails). A prompt saying "never let customers claim someone else's credit" is not a control; authorization checks in code are.
Tools also have limits. A calculator returns a correct answer to the question it was asked, and a model can still ask the wrong question, for example by passing 09:30 to 09:50 as the incident and 09:10 to 10:40 as the exclusion. Argument validation (the exclusions must lie within a bounded neighbourhood of the incident; the evidence quotes must appear in the ticket) narrows this, and the end-to-end evaluation below measures what remains.
How Reasoning-Style Prompts Fail
Understanding failure is what lets you place checks where they matter. These are the recurring ones, with the mechanism for each.
- Incorrect assumptions. The model fills a gap silently: it assumes the ticket's times are local rather than UTC, or that "maintenance" counts as excluded when the contract says only scheduled maintenance does. The extraction prompt above forces
assumptions_neededso guesses surface. - Arithmetic mistakes. Multi-digit arithmetic and date boundaries are error-prone in generated text. A tool or reference recomputation catches them.
- Invented intermediate facts. A step states a quantity that is not in the input ("the maintenance began at 09:25"), and later steps build on it. Verbatim-evidence checks target this.
- Overlong explanations. More text means more places to be wrong, more tokens to pay for, and more latency; length is not evidence of rigor. It also dilutes the part a downstream parser needs.
- Anchoring on examples. Demonstrations pull the output toward their numbers and structure (the same number of exclusions, the same time zone). Varied demonstrations and held-out tests reduce it.
- Self-consistent but false rationales. Because later steps condition on earlier ones, an early error can yield a coherent, confident chain to a wrong conclusion. Consistency is not correctness.
One case where eloquence is wrong and a check catches it
Here is a constructed illustration (not a transcript from any real model) of the last failure. The rationale reads convincingly, and every line except one is correct:
Rationale: The incident ran from 09:10 to 10:40, a total of 90 minutes. The scheduled
maintenance from 09:30 to 09:50 is 30 minutes and is excluded under the Gold plan, leaving
60 minutes of chargeable downtime. The plan allows 60 minutes, so the limit was met but not
exceeded: breach_minutes = 0.
Record: {"start": "2025-03-04T09:10", "end": "2025-03-04T10:40",
"excluded_intervals": [{"start": "2025-03-04T09:30", "end": "2025-03-04T09:50"}],
"effective_minutes": 60, "breach_minutes": 0}The slip is "09:30 to 09:50 is 30 minutes"; it is 20. Everything after it is internally consistent: 90 - 30 = 60, 60 is not over 60, so the conclusion follows. A human skimming the paragraph may well accept it. The structured record gives the verifier what it needs. Running verify_record on it recomputes the exclusion from its own endpoints and returns:
['effective_minutes: model=60 reference=70', 'breach_minutes: model=0 reference=10']The consequence is a ten-minute breach that would have been denied, caught because the intermediate quantities were fields that could be recomputed rather than sentences that had to be believed. What happens next is a design decision: retry the extraction, route to a human, or (better here) discard the model's arithmetic entirely and use the computed values, since the facts themselves were extracted correctly.
The figure below walks through that case step by step, showing why a check that recomputes from endpoints finds what a reader of the rationale does not.
Reasoning Visibility, Privacy, and What the Product Should Not Depend On
Models differ in how they reason before answering and in what an API exposes. Some produce reasoning only as ordinary visible text when prompted; others do internal reasoning that the provider may return in summarised form, return in some limited form, or not return, and the reasoning tokens may or may not be billed. These policies vary by provider and change by model version, so treat any specific claim as something to verify in the current documentation for the model you use. This chapter makes no specific claim about any vendor.
The design consequence is stable regardless of the details. A product should be correct and auditable without needing private reasoning traces:
- Do not try to extract hidden reasoning. Prompting a model to reveal its internal reasoning is unreliable (what it writes is a new generation, not a readout), may be against provider policy, and is unnecessary for the engineering goal.
- Ask for a concise answer-level explanation when users need one. For the customer, "Chargeable downtime was 70 minutes (90 minus a 20-minute scheduled maintenance window); the limit is 60, so 10 minutes exceeded it" is more useful than a long narrative, and every number in it is checkable against the record.
- Audit the inputs and checks, not the monologue. Log the facts, evidence quotes, tool calls, computed values and verifier results (see AI Observability). That trail explains a decision without relying on a trace whose faithfulness cannot be guaranteed.
- Handle sensitive data deliberately. Rationales and logs can contain customer data from the ticket. Apply the same retention, redaction and access policies to them as to the ticket itself.
Evaluating Decomposition Against Simpler Baselines
Whether decomposition is worth its cost is an empirical question for your task and model, answered on data the prompt was not tuned on. A sound methodology compares three configurations on the same held-out tickets:
- Direct: one prompt, model answers with the breach minutes.
- Decomposed: the extraction contract, with the model also doing the arithmetic and returning a structured record.
- Tool-verified: extraction by the model, calculation by code, verification as in the pipeline figure.
Build the held-out set deliberately. Compute the ground truth for each ticket with the reference function, then stratify so that the hard cases are visible: no exclusion, one exclusion, overlapping exclusions, exclusions partly outside the incident, missing end times, time-zone mentions, and a few tickets that should be sent to clarification. Hold back a portion you never use while writing prompts or demonstrations, otherwise you are measuring memorisation of your own examples.
Score separately, because a single accuracy number hides where the system fails:
- Final correctness: the released breach minutes equal ground truth (exact match, in minutes).
- Intermediate field validity: per-field accuracy of
start,end, and exclusion intervals, plus evidence-quote validity. - Tool correctness: proposed arguments match the gold facts, and the call is made when needed and not otherwise.
- Clarification behaviour: on ambiguous tickets, does the system ask rather than guess? Count wrongly-escalated clear tickets too.
- Latency and cost: measured per ticket, p50 and p95 for latency, input plus output tokens (including any reasoning tokens a model bills) for cost.
A results table is only a summary of that procedure. The following is a hypothetical scorecard with invented numbers, shown only to illustrate how to read one; it is not a benchmark of any model. Suppose a held-out set of 60 tickets:
| Configuration | Correct / 60 | Final accuracy | Median latency (illustrative) | Tokens per ticket (illustrative) |
|---|---|---|---|---|
| Direct | 36 | 60.0% | 1.2 s | 450 |
| Decomposed | 48 | 80.0% | 2.9 s | 1,100 |
| Tool-verified | 59 | 98.3% | 3.4 s | 1,300 |
Percentages are correct / 60: 36/60 = 60.0%, 48/60 = 80.0%, 59/60 = 98.3%. With only 60 tickets the uncertainty is wide (a single ticket moves accuracy by 1.7 points), so a real study needs enough tickets per stratum to support the conclusion, and failure analysis of every miss. In this made-up scenario the decision would hinge on the trade-off, not the ranking: the tool-verified path costs roughly 0.5 s more than decomposed for a large gain, which would be an easy yes for credit calculations and unjustified for a triage label. If instead the decomposed configuration had matched the tool-verified one, you would keep the cheaper design. And a configuration that writes twice as many tokens has not earned credit for it; evaluate outcomes, not output length.
Two figures make that reading concrete: the first shows how wide the uncertainty is at 60 tickets and how it shrinks with more; the second divides token cost by the share of correct answers.
- Tokens per ticket
- Tokens per correct answer = tokens per ticket x 60 / correct
When Not to Decompose
Every step adds a call, a failure point and latency. Skip decomposition when the task does not need it.
Simple classification and extraction. The triage step of the same application, labelling a ticket as billing, outage, or account access and assigning a priority, is a single judgment over short text, and the answer is not the end of a chain of dependent calculations. A direct, schema-constrained answer is cheaper, faster and easier to evaluate; adding "explain your reasoning" tends to add tokens without making the label more verifiable. If accuracy on it is poor, the usual fix is clearer label definitions or a few well-chosen demonstrations (see Few-Shot Prompting), not a longer chain.
Tight latency budgets. A typeahead suggestion or a live-chat routing decision may allow a few hundred milliseconds. Multi-step generation and tool round trips will not fit; use a smaller direct prompt, or run the expensive verification asynchronously after a provisional result and correct later if it disagrees. The sampling settings are a separate matter and do not substitute for verification.
Missing knowledge. If the model lacks a fact (the customer's contract terms), more steps do not create it. Retrieve it from a trusted source and supply it as context (see Context Injection), keeping in mind that retrieved text is evidence to be checked, not truth by virtue of being retrieved.
Where a fixed rule would do. If the ticket arrives with structured timestamps from monitoring, there is nothing to extract: call the calculator directly and use the model only for wording.
Implementation Sketch: Extraction, Calculation, Verified Reply, Clarification
Pulling the pieces together, the control flow is below. The model-calling parts are deliberately left behind a function parameter (extract) because SDK calls differ by provider; the validation, calculation and routing logic is ordinary code, and the stub at the bottom lets you run it end to end. The comments mark the boundary between what the model does and what the runtime guarantees. It reuses parse and compute_sla from the earlier block.
import json
SLA_LIMIT_MINUTES = 60 # RUNTIME: from the customer's contract record, never from the model
def validate_facts(facts: dict) -> list[str]:
"""RUNTIME: structural and logical gate on model-extracted facts."""
try:
start, end = parse(facts["start"]), parse(facts["end"])
excl = [(parse(x["start"]), parse(x["end"])) for x in facts["excluded_intervals"]]
except (KeyError, ValueError, TypeError) as exc:
return [f"unparseable: {exc!r}"]
problems = []
if end <= start:
problems.append("end_not_after_start")
if any(e <= s for s, e in excl):
problems.append("empty_or_reversed_exclusion")
return problems
def handle(ticket_text: str, extract) -> dict:
"""extract(ticket_text) -> JSON string. MODEL BOUNDARY: its output is untrusted."""
try:
facts = json.loads(extract(ticket_text))
except json.JSONDecodeError:
return {"status": "needs_review", "reason": "extraction_not_json"}
problems = validate_facts(facts)
if problems:
return {"status": "needs_clarification", "reason": problems}
if facts.get("assumptions_needed"):
return {"status": "needs_clarification", "reason": facts["assumptions_needed"]}
result = compute_sla( # RUNTIME: deterministic arithmetic
parse(facts["start"]), parse(facts["end"]),
[(parse(x["start"]), parse(x["end"])) for x in facts["excluded_intervals"]],
SLA_LIMIT_MINUTES,
)
# MODEL BOUNDARY again: a reply drafter would receive these numbers; before sending,
# re-extract any numbers in its draft and compare with 'result' (verify_record-style).
return {"status": "verified", **result, "limit_minutes": SLA_LIMIT_MINUTES}
stub = lambda text: json.dumps({
"start": "2025-03-04T09:10", "end": "2025-03-04T10:40",
"excluded_intervals": [{"start": "2025-03-04T09:30", "end": "2025-03-04T09:50"}],
"assumptions_needed": []})
print(handle("ticket text", stub))
# {'status': 'verified', 'gross_minutes': 90, 'excluded_minutes': 20,
# 'effective_minutes': 70, 'breach_minutes': 10, 'limit_minutes': 60}The failure paths are the part most sketches omit. A non-JSON extraction goes to a review queue rather than being patched up by guessing. Reversed timestamps and a missing time zone return a clarification request (to the customer, or to the agent handling the ticket) because computing anyway would produce a confident wrong number. The same function run with a stub returning end earlier than start yields needs_clarification with end_not_after_start, and a stub with "assumptions_needed": ["time zone not stated"] yields a clarification on that assumption. Whatever the pipeline releases is a recommendation for the credit decision; whether the credit is issued remains an authorized business action outside this code.
The shape generalises: swap the calculator for a SQL query and the verifier for a row-count or constraint check, or for generated code run in a sandbox with its output compared against an independent method. In each case the same discipline applies. Ask the model for artifacts a program can check, check them with something other than the model, and give the system a way to say "I need more information". Keep the context budget in mind too; long prompts with many demonstrations compete with the ticket for space (see Context Window).
What's Next After Chain-of-Thought Prompting
The extraction step above worked because its output was a typed record that code could validate. Making that reliable (schemas, validation, retries, and the difference between valid JSON and a correct answer) is the focus of Structured Outputs. To place it within the wider discipline, Hallucinations explains why invented intermediate facts occur at all.
Key Takeaways
- 1
Prompt for checkable intermediate artifacts (extracted facts, intervals, a plan, tool calls), not for a longer rationale. Scratch space is not verification, and a fluent explanation can still contain a wrong step.
- 2
Separate reading from computing: let the model extract structured facts from the messy ticket and let ordinary code do the interval arithmetic, because generated arithmetic and date boundaries are error-prone.
- 3
Verify in three layers and route each failure differently: structural (JSON parses, timestamps parse), logical (end after start, 0 ≤ breach ≤ gross) and referential (numbers equal a recomputation, quotes occur in the source).
- 4
Prefer tools where the answer exists by construction, but treat a model-proposed call as a proposal: contract values such as the limit come from the system, not from the model.
- 5
Watch the characteristic failures: silent assumptions (local versus UTC), invented intermediate facts, anchoring on demonstration numbers, and self-consistent but false rationales where one early slip (20 minutes written as 30) propagates cleanly.
- 6
Keep a baseline: measure the one-shot prompt against the decomposed pipeline on your own cases, since longer outputs cost more tokens and latency and are not automatically more accurate.