Skip to content
Chapter 11 of 13

Observability, cost and latency of loops

Narrated walkthrough · Observability, cost and latency of loops
A loop's cost is not linear in its steps. Every turn resends the transcript, so step one pays for one message and step ten pays for ten. The lab measures a tenth step costing 4.0 times the first for identical work, which is why doubling a loop's length more than doubles the bill, and why the strongest cost lever is a shorter transcript rather than a cheaper model. Seeing where the money and the milliseconds went needs a trace shaped as a tree, not a flat log: one turn branches into the model call, the tool calls it decided on, and any sub-agent with children of its own. Flat lines cannot tell you that a 410 ms tool call inside a 2100 ms turn is the thing to fix. Three levers sit on top of that, and the lab prices all of them: caching the stable prefix so the system prompt and tool schemas bill at a tenth, routing so an expensive model plans once and a cheap one executes the rest, and a kill switch checked before each call. The first two alone take a $0.6750 run to $0.0436.

The lab: read it, then run it

labs/agentic-engineering/ae11-trace-cost-killswitch.py
#!/usr/bin/env python3
"""
LAB AE11: What a loop costs. Traces as trees, per-step attribution, kill switch.

A loop's cost is not linear in its steps, it is roughly quadratic in them,
because every turn resends the whole transcript. Step 1 pays for one message and
step 8 pays for eight. That is why a loop that "only" ran twice as long costs
four times as much, and why the single most effective cost lever is not a cheaper
model, it is a shorter transcript.

This lab builds a real trace TREE, attributes tokens and dollars per step, prices
the same run three ways (naive, cached prefix, routed models), and arms a kill
switch that stops the loop before the ceiling instead of after.

Run: python3 modules/academy-content/labs/agentic-engineering/ae11-trace-cost-killswitch.py
"""
import sys, os
_cands = [os.path.join(os.path.dirname(__file__), "..") if "__file__" in globals() else None,
          os.path.join(os.getcwd(), "..", "labs"), os.path.join(os.getcwd(), "labs")]
for _c in _cands:
    if _c and os.path.exists(os.path.join(_c, "academy_llm.py")):
        sys.path.insert(0, os.path.abspath(_c)); break

# Per-million-token prices. PLAN is the expensive reasoning model, EXEC is the
# cheap one. Cached input is the same tokens at a tenth the price.
PRICE = {"plan": {"in": 15.00, "out": 75.00}, "exec": {"in": 0.80, "out": 4.00}}
CACHE_DISCOUNT = 0.10
SYSTEM_TOKENS = 900        # the fixed prefix: system prompt plus tool schemas
TURN_TOKENS = 600          # what each completed turn adds (model text plus tool result)
OUT_TOKENS = 180           # tokens the model writes per step

USD_CEILING = 0.40         # hard stop, in dollars
STEP_CEILING = 12          # hard stop, in steps


def price(model, tok_in, tok_out, cached_in=0):
    """Cost of one call. Cached input tokens bill at a tenth, so a long stable
    prefix stops being the thing that hurts."""
    p = PRICE[model]
    fresh = tok_in - cached_in
    return (fresh * p["in"] + cached_in * p["in"] * CACHE_DISCOUNT + tok_out * p["out"]) / 1e6


# ── STEP 1: the trace is a TREE, not a flat log. ──────────────────────────────
# One agent turn branches: the model call, then the tool calls it decided on,
# and a sub-agent that has its own children. A flat log cannot show you that the
# 0.41s tool call is what made the 2.10s turn slow.
TRACE = ("turn 2", 2100, [
    ("model call (plan)", 1400, []),
    ("tool: search", 410, [("http GET", 380, [])]),
    ("sub-agent: summarize", 260, [("model call (exec)", 240, [])]),
])


def show(node, depth=0):
    name, ms, kids = node
    bar = "#" * max(1, round(ms / 100))
    print("  %s%-22s %5d ms %s" % ("  " * depth, name, ms, bar))
    for kid in kids:
        show(kid, depth + 1)


print("STEP 1: one turn of the trace, as a tree")
show(TRACE)
_, root_ms, children = TRACE
child_ms = sum(ms for _, ms, _ in children)
print("  self time in the turn itself : %d ms of %d" % (root_ms - child_ms, root_ms))
print("  slowest child                : %s" % max(children, key=lambda c: c[1])[0])

# ── STEP 2: per-step cost, growing with the transcript. ──────────────────────
print("")
print("STEP 2: per-step cost of a naive loop (full transcript resent every turn)")
print("  step  prompt_tok  out_tok      usd   cum_usd")
naive_total = 0.0
rows = []
for step in range(1, 11):
    tok_in = SYSTEM_TOKENS + TURN_TOKENS * (step - 1)
    usd = price("plan", tok_in, OUT_TOKENS)
    naive_total += usd
    rows.append((step, tok_in, usd))
    print("  %4d  %10d  %7d  %7.4f  %8.4f" % (step, tok_in, OUT_TOKENS, usd, naive_total))

first, last = rows[0][2], rows[-1][2]
print("  last step costs %.1fx the first, on identical work" % (last / first))
print("  most expensive step          : step %d" % max(rows, key=lambda r: r[2])[0])

# ── STEP 3: the same ten steps, two levers applied. ──────────────────────────
print("")
print("STEP 3: same ten steps, priced three ways")
cached_total = 0.0
routed_total = 0.0
for step in range(1, 11):
    tok_in = SYSTEM_TOKENS + TURN_TOKENS * (step - 1)
    # Lever 1: the stable prefix is cached, so only the new turn is fresh input.
    cached_total += price("plan", tok_in, OUT_TOKENS, cached_in=SYSTEM_TOKENS)
    # Lever 2: plan once on the expensive model, execute the rest on the cheap one.
    model = "plan" if step == 1 else "exec"
    routed_total += price(model, tok_in, OUT_TOKENS, cached_in=SYSTEM_TOKENS)
print("  naive                        : $%.4f" % naive_total)
print("  cached prefix                : $%.4f  (-%.0f%%)"
      % (cached_total, 100 * (1 - cached_total / naive_total)))
print("  cached + model routing       : $%.4f  (-%.0f%%)"
      % (routed_total, 100 * (1 - routed_total / naive_total)))

# ── STEP 4: the kill switch. Checked BEFORE the call, not after. ─────────────
print("")
print("STEP 4: kill switch armed at $%.2f / %d steps" % (USD_CEILING, STEP_CEILING))
spent = 0.0
step = 0
halted = None
while True:
    step += 1
    tok_in = SYSTEM_TOKENS + TURN_TOKENS * (step - 1)
    projected = spent + price("plan", tok_in, OUT_TOKENS)
    # The check that matters: would THIS call breach the ceiling? A budget check
    # that runs after the call is a receipt, not a limit. The money is gone.
    if projected > USD_CEILING:
        halted = "cost"
        print("  step %2d refused: would reach $%.4f, over the ceiling" % (step, projected))
        break
    if step > STEP_CEILING:
        halted = "steps"
        print("  step %2d refused: over the step ceiling" % step)
        break
    spent = projected
    print("  step %2d ran    : spent $%.4f" % (step, spent))

print("")
print("  halted by      : %s ceiling" % halted)
print("  final spend    : $%.4f (never exceeded $%.2f)" % (spent, USD_CEILING))

ok = (halted == "cost"
      and spent <= USD_CEILING
      and last > first * 2
      and routed_total < cached_total < naive_total
      and (root_ms - child_ms) > 0)
print("")
print("COST GREW WITH THE TRANSCRIPT AND THE KILL SWITCH STOPPED IT UNDER BUDGET: %s"
      % ("YES" if ok else "NO"))
if not ok:
    sys.exit(1)
print("Shorten the transcript, cache the prefix, route the cheap steps, and cap the run.")
Runnable lab
ae11-trace-cost-killswitch.py

Walk a trace tree to find the slow child, attribute per-step token cost across a growing transcript, price the same run three ways, then arm a kill switch that refuses the step that would breach the budget.

Proves: COST GREW WITH THE TRANSCRIPT AND THE KILL SWITCH STOPPED IT UNDER BUDGET: YES

Open the notebook

Runs in your browser via Pyodide. First run loads the runtime once; no install, no server.

Read the three prices again: $0.6750 naive, $0.5535 with the prefix cached, $0.0436 once routing is added, a 94% reduction on identical work. Routing is the large lever because most steps in a loop are mechanical, and paying reasoning-model rates to call a tool is waste. The kill switch matters more than any of it. It refused step 8 because the projected total of $0.4680 was over the $0.40 ceiling, so the run ended at $0.3780 having never breached the budget. A ceiling enforced after the call has already spent the money is a report, not a control, which is exactly why runaway loops show up as a surprise invoice instead of an alert. Latency budgets work the same way: set the per-step and total deadline first, then let the trace tell you which child blew it. Next you take a loop to production, where the process itself can die.
Check your understanding
  1. 1. Why does a loop's per-step cost grow as it runs?

  2. 2. Why is a trace tree better than a flat log for one agent turn?

  3. 3. When must a cost kill switch check the budget?