TALDYN
‹ The Playbook Library
SCH-04 · SCHEMATIC

Tracing and Evals for LLM Workflows in Production

You can't improve what you can't replay. The observability layer every production LLM workflow needs — traces, cost accounting, offline evals, and drift detection — with the code to start.

JUL 4 · 20266 min readEngineeringObservabilityArchitecture

Traditional software fails loudly: exceptions, 500s, alerts. LLM workflows fail politely — the response arrives on time, well-formatted, and wrong. Nothing in your existing monitoring stack notices, because nothing is broken in the way that stack understands. The consequence: observability for LLM systems has to be designed in, and it has to capture semantics (what was asked, what was answered, was it good) rather than just mechanics (status codes, latency).

The architecture has four layers: traces (capture everything), cost accounting (know what each workflow spends), offline evals (measure quality on a fixed set before users do), and drift detection (notice when production quietly changes). None requires a vendor platform to start — a Postgres table and discipline cover the first six months.

01Traces: capture enough to replay

The unit of LLM observability is the workflow run, composed of steps (model calls, retrievals, tool executions). For every model call, capture: the full input (or a pointer to it), the full output, model and version, token counts, latency, and the workflow run it belongs to. The test of sufficiency is replayability — given the trace, could you re-run the exact call and diff the result? If not, you're logging vibes.

Prompt templates change over time, so store a hash of the rendered prompt alongside a template id + version. When quality shifts, the first question is always "what changed?" — and prompt-hash frequencies answer it in one query.

sqlschema.sql — traces without a vendor platform
CREATE TABLE runs (
  id          uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  workflow    text NOT NULL,            -- 'invoice_extract', 'kb_answer', ...
  trigger_ref text,                     -- ticket id, document id, user id
  status      text NOT NULL DEFAULT 'running'
              CHECK (status IN ('running','ok','error')),
  started_at  timestamptz NOT NULL DEFAULT now(),
  ended_at    timestamptz
);

CREATE TABLE steps (
  id            bigserial PRIMARY KEY,
  run_id        uuid NOT NULL REFERENCES runs(id),
  kind          text NOT NULL,          -- 'llm', 'retrieval', 'tool'
  name          text NOT NULL,          -- 'draft_reply', 'hybrid_search'
  model         text,                   -- provider/model@version for llm steps
  prompt_id     text,                   -- template id + semver
  prompt_hash   text,                   -- sha256 of rendered input
  input         jsonb NOT NULL,
  output        jsonb,
  tokens_in     int,
  tokens_out    int,
  cost_microusd bigint,                 -- integers; float money is a bug
  latency_ms    int,
  error         text,
  created_at    timestamptz NOT NULL DEFAULT now()
);

CREATE INDEX steps_run_idx  ON steps (run_id);
CREATE INDEX steps_cost_idx ON steps (name, created_at);
pythontrace.py — a decorator that makes tracing the path of least resistance
import contextvars, functools, hashlib, json, time

current_run = contextvars.ContextVar("current_run", default=None)

def traced_step(kind: str, name: str):
    """Wrap any pipeline step; captures i/o, latency, errors to the ledger."""
    def deco(fn):
        @functools.wraps(fn)
        def wrapper(*args, **kwargs):
            run_id = current_run.get()
            t0 = time.monotonic()
            error, output = None, None
            try:
                output = fn(*args, **kwargs)
                return output
            except Exception as e:
                error = repr(e)
                raise
            finally:
                record_step(                      # single INSERT into steps
                    run_id=run_id, kind=kind, name=name,
                    input=safe_json(args, kwargs),
                    output=safe_json(output),
                    prompt_hash=hashlib.sha256(
                        json.dumps(safe_json(args, kwargs),
                                   sort_keys=True).encode()
                    ).hexdigest()[:16],
                    latency_ms=int((time.monotonic() - t0) * 1000),
                    error=error,
                )
        return wrapper
    return deco

# usage:
# @traced_step("llm", "draft_reply")
# def draft_reply(ticket: dict) -> str: ...

02Cost accounting: per workflow, not per invoice

The provider bill tells you what you spent; it can't tell you what you spent it on. With per-step token and cost capture, the question "which workflow, which step, which prompt version is the money going to?" is a GROUP BY — and the answers are routinely surprising: one noisy retrieval-summarize step quietly costing more than the flagship drafting step, a retry loop nobody remembers doubling a workflow's cost. Store cost in integer micro-dollars at write time, computed from the model's current rates; rates change, and back-computing historical cost from token counts is how finance meetings go wrong.

03Offline evals: your quality regression suite

An eval is to an LLM workflow what a test suite is to code: a fixed set of real cases, a scoring rule, and a number that must not go down. The discipline that matters most is scorer selection — use the cheapest scorer that captures the failure you fear, and layer them:

  • Mechanical scorers first. Schema validity, required fields present, citation indices in range, length bounds, banned-phrase checks. Free, deterministic, and they catch a surprising share of regressions.
  • Reference scorers second. Exact or fuzzy match against golden answers for extraction and classification; recall@k for retrieval. Deterministic where your task allows it.
  • LLM-as-judge last, with hygiene. For fluency, tone, and answer quality there's no mechanical scorer — a judge model scoring against a written rubric works, but treat it as a noisy instrument: pin the judge model version, randomize A/B presentation order to dodge position bias, spot-check judge verdicts against human ratings quarterly, and never let the judge grade its own model family without checking for self-preference.
pythonevals/runner.py — layered scoring over a golden set
import json, statistics

def run_eval(golden_path: str, workflow_fn, scorers: list) -> dict:
    """golden: [{\"input\": ..., \"expected\": ...}]; scorer -> float in [0,1]"""
    cases = json.load(open(golden_path))
    rows = []
    for case in cases:
        output = workflow_fn(case["input"])
        scores = {s.__name__: s(output, case) for s in scorers}
        rows.append({"case": case.get("id"), "scores": scores,
                     "output": output})
    summary = {
        s.__name__: round(statistics.mean(r["scores"][s.__name__]
                                          for r in rows), 3)
        for s in scorers
    }
    worst = sorted(rows, key=lambda r: sum(r["scores"].values()))[:5]
    return {"summary": summary, "n": len(rows), "worst_cases": worst}

# scorers stay tiny and composable:
def schema_valid(output, case) -> float:
    try:
        TicketReply.model_validate_json(output); return 1.0
    except Exception:
        return 0.0

def cites_real_sources(output, case) -> float:
    reply = TicketReply.model_validate_json(output)
    valid = set(case["retrieved_ids"])
    return 1.0 if all(c in valid for c in reply.citations) else 0.0

04Drift: the failure mode with no error message

Production drifts three ways: inputs drift (the documents users submit change shape), behavior drifts (a provider updates the model behind your API), and standards drift (reviewers get pickier or sloppier). None throws an exception. All show up in trends you can query — if you're capturing traces and review outcomes.

The most reliable early-warning signals, in practice: the human override rate on reviewed outputs (rising = quality falling), the validation-repair rate in extraction loops, the escalation-lane share, and week-over-week token-length distributions of inputs (a cheap proxy for "the inputs changed"). Put them on one dashboard query and look at it weekly — drift caught in week two is a prompt fix; drift caught in month three is a credibility incident.

checklist
  • Every model call in production writes a trace row — no exceptions for "quick internal tools."
  • Evals run in CI on every prompt/model/retrieval change, and the summary lands in the PR.
  • The golden set grows from production: every human override becomes a candidate case.
  • Someone owns the weekly drift query the way someone owns the on-call rotation.
sqldrift.sql — weekly override-rate trend per workflow
SELECT date_trunc('week', r.started_at)              AS week,
       r.workflow,
       count(*)                                       AS reviewed,
       round(avg(CASE WHEN rv.outcome = 'overridden'
                      THEN 1.0 ELSE 0.0 END), 3)      AS override_rate,
       round(avg(s.latency_ms))                       AS avg_latency_ms,
       round(sum(s.cost_microusd) / 1e6, 2)           AS cost_usd
FROM runs r
JOIN steps s   ON s.run_id = r.id AND s.kind = 'llm'
JOIN reviews rv ON rv.run_id = r.id      -- human decisions on outputs
WHERE r.started_at > now() - interval '8 weeks'
GROUP BY 1, 2
ORDER BY 2, 1;
-- Alert rule of thumb: override_rate doubling vs its 4-week median
-- is a page-someone signal, whatever the absolute number is.

OPERATOR NOTE — Vendors will sell you all of this as a platform, and later you may want one. But the discipline is the product: a team that won't maintain a golden set won't be saved by a dashboard.

TRANSMIT

Put this architecture to work.

A 30-minute strategy call with an operator — we'll map your first deployment path, not send a deck.