---
title: "LLMOps"
slug: llmops
category: data
term_status: emerging
evidence_status: verified
summary: "LLMOps is the practice of running applications built on large language models in production: versioning prompts, models and retrieval together, gating every change on evaluations, tracing each request, and keeping prompt injection, latency and token use in check."
audience: [Beginners, AI application engineers, Platform engineers, Architects, Engineering leaders]
last_verified: 2026-10-18
key_takeaways:
  - "The release unit is a combination: prompt version, pinned model snapshot, retrieval index, guardrails and tool policy. Change one, and you have a new release to evaluate."
  - "Evaluations are the regression suite. Run each case several times, compare versions case by case, and check slices and must-pass cases, not only the average."
  - "An LLM judge is a measuring instrument: calibrate it against a human expert on held-out labels and report its true-positive and true-negative rates, not raw agreement."
  - "Retrieval is a separate system with its own metrics (recall, rank) and its own security test: no document may reach a user who could not open it."
  - "Prompt injection has no complete fix today. Limit what untrusted text can trigger: user-scoped read tools, human approval for writes, allow-listed links."
  - "Agents multiply steps: grade outcomes over repeated, isolated trials, and bound every run with step, time and cost limits."
  - "Providers retire and change models on their own schedule, with notice from weeks to months. Pin snapshots, watch deprecation dates and re-run the suite before every migration."
related: [mlops, modelops, finops-for-ai, knowledgeops, observabilityops, devsecops, dataops, aiops]
---

## What LLMOps is

### In 30 seconds

**LLMOps is the practice of running applications built on large language models (LLMs) in production and keeping them
correct, safe, fast and affordable.** An LLM application is a prompt, a model you usually rent, the documents retrieved into the
prompt, the tools the model may call, and the checks around them. LLMOps versions that combination, evaluates every change
before release, traces each request, and reacts when the provider, the data or the users change.

The label is common but not settled:

- TechTarget: it "narrows the focus of the machine learning operations (MLOps) framework".[^techtarget]
- Google Cloud: "a specialized subset of MLOps", a *model-centric* reading.[^gc-llmops]
- Microsoft says **GenAIOps**, "sometimes known as LLMOps", and governs the *application*: the system that orchestrates the
  logic, builds the prompt and calls the model.[^ms-genaiops]
- A 2024 IEEE conference paper: "This term is so recent that the scientific literature has not yet agreed on a common definition
  for it".[^splitech] Overlapping labels include **AI engineering**[^huyen-aie] and **AgentOps**.[^agentops]

**Who this page is for.** Beginners: this part, the [glossary](#glossary-and-mental-model), Lab 1. Engineers: practices,
[agents](#operating-agents-evaluation-limits-and-tool-trust), [traces](#traces-the-debugging-unit), labs. Leaders:
[Deciding and adopting](#deciding-and-adopting).

### At a glance

| Aspect | Summary |
|---|---|
| Goal | LLM features whose quality, safety, latency and token use are measured, gated before release and watched after it |
| Unit of work | An LLM application or agent: prompts, model choice, retrieval, tools, guardrails and orchestration code |
| Main users | AI application and platform engineers, security engineers, domain experts who grade answers |
| In scope | Prompt versioning; evaluation; retrieval; injection containment; agent limits; tracing; latency and token budgets; routing and caching; hallucination control; prompt versus retrieval versus fine-tuning; provider change |
| Prerequisites | [MLOps](../mlops/) concepts; rates and confidence intervals; [ObservabilityOps](../observabilityops/) helps |
| Key outputs | Release manifests, an eval suite and gate, a calibrated judge, traces, a tool policy, a migration runbook |
| Term status | **Emerging.** Definitions differ, labels compete, no standards body defines it[^splitech] |
| Last verified | 2026-10-18 |

### The problem LLMOps solves

An LLM call looks like an API call but does not behave like one:

1. **The same input can give different outputs.** "LLMs are stochastic – there's no guarantee that an LLM will give you the same
   output for the same input every time."[^huyen-2023] New York City's business chatbot told one Markup reporter that landlords
   had to accept housing vouchers, and ten other staff that they did not.[^nyc-markup]
2. **Small edits have large effects.** "Small changes to a prompt can lead to very different results".[^huyen-2023]
3. **Correctness is open-ended.** LinkedIn found evaluating answer quality "more difficult than anticipated".[^linkedin]
4. **The model changes under you.** GPT-3.5 and GPT-4 behaviour "can vary greatly over time";[^chen-drift] providers retire models.
5. **Text becomes an attack surface.** LLM-integrated applications "blur the line between data and instructions".[^greshake]
6. **Every request costs tokens and time.** "The cost of LLMOps is in inference."[^huyen-2023]

**Wrong answers have owners.** In *Moffatt v. Air Canada* (2024), British Columbia's Civil Resolution Tribunal (small claims)
found negligent misrepresentation: the chatbot had wrongly said a bereavement fare could be claimed retroactively, and the airline
"did not take reasonable care to ensure its chatbot was accurate" (award: C$812.02).[^air-canada]
[ModelOps](../modelops/) draws the accountability lesson; the engineering lesson is that policy answers need must-pass tests.

### What LLMOps is not

It is not the training or fine-tuning *pipeline* ([MLOps](../mlops/)), model approval and risk tiering
([ModelOps](../modelops/)), spend modelling ([FinOps for AI](../finops-for-ai/)), ownership of the documents retrieval reads
([KnowledgeOps](../knowledgeops/)), AI applied *to* IT operations ([AIOps](../aiops/)) or application-security depth
([DevSecOps](../devsecops/)); the [neighbour table](#how-llmops-relates-to-other-ops) gives hand-offs.

::: note Editorial emphasis on this page
This Atlas uses **the application reading**, because most teams consume a model rather than train one. That split is an
**editorial distinction**, not an industry standard.
:::

## Where LLMOps comes from and where it sits

### Origins and evolution

Who coined "LLMOps" is not recorded in the sources found; its practices can be dated:

| Date | Milestone | Why it mattered |
|---|---|---|
| 2020 | Retrieval-augmented generation (RAG) paper[^lewis-rag] | Retrieval feeds generation |
| Early 2023 | "LLMOps" gains prominence[^techtarget] | The label appears |
| 2023-02 | Indirect prompt injection paper[^greshake] | Retrieved text can steer applications |
| 2023-04 | Chip Huyen, "Building LLM applications for production"[^huyen-2023] | Prompt versioning, evaluation and cost |
| 2023-06/07 | LLM-as-a-judge study;[^zheng-judge] hosted-model drift[^chen-drift] | Model graders have limits; hosted models change |
| 2024-01 | Microsoft's "LLMOps maturity model", four levels;[^ms-llmops-blog] now titled GenAIOps[^ms-maturity] | A vendor adopts the application view |
| 2024 | SpliTech paper (June);[^splitech] NIST AI 600-1 (July)[^nist-600-1] | No agreed definition; a risk vocabulary |
| 2025 | Injection design patterns (June);[^design-patterns] OWASP agentic Top 10 (December)[^owasp-agentic] | Architectural defences; agent risks |

### Glossary and mental model

**Mental model: you ship a recipe, not a model.** The release is a tuple of *prompt version + model snapshot + retrieval
configuration + tools + guardrails*, around a rented, non-deterministic model. **Evals are the test suite**, **traces are the
debugger** (one span per step), and **routing is the release lever**: a gateway weight for a model, a flag for a prompt.

| Term | Meaning | Example |
|---|---|---|
| Model snapshot | A fixed model version, unlike an alias that can move (every Claude model ID is a pinned snapshot)[^anthropic-models] | A dated ID in the manifest |
| RAG | Retrieve relevant chunks (passages stored as embedding vectors) and insert them into the prompt[^lewis-rag] | Top-5 policy chunks |
| Eval, slice, must-pass case | A test on cases with a scoring rule; a *slice* is a subset scored on its own; a *must-pass* case must pass in every sample | The refunds slice |
| Assertion | A deterministic check: contains, regex, valid JSON, latency[^promptfoo-asserts] | Contains "30 days" |
| Paired comparison | Both versions run on the same cases; per-case differences are analysed | v8 minus v7 |
| Confidence interval, margin | The range the true difference plausibly lies in; the largest drop you accept. A gate passes if the interval's lower end stays above minus the margin | [−0.15, +0.10] fails 0.05 |
| LLM-as-judge, TPR, TNR | A model grading output against a rubric; the share of good answers it passes (TPR) and of bad answers it fails (TNR) | TPR 0.94, TNR 0.88 |
| Recall@k, MRR | Share of relevant documents in the top k; mean of 1 ÷ rank of the first relevant one | Recall@5 = 0.90 |
| Prompt injection | Text that alters the model's behaviour in unintended ways; *indirect* when it arrives in websites or files[^owasp-llm01] | "Ignore your rules" |
| Confabulation | NIST's term for confidently stated false content, "known colloquially as “hallucinations”"[^nist-600-1] | An invented refund exception |
| Canary | A release served first to a small share of traffic | 5% of requests |
| TTFT | Time to first token (for API clients, to the first streamed chunk) | 0.9 s |
| Agent | A system where "LLMs dynamically direct their own processes and tool usage"; a *workflow* follows "predefined code paths"[^anthropic-agents] | A refund agent |

### How LLMOps relates to other Ops

The boundaries below are **editorial distinctions** of this Atlas; vendors draw them differently.

| Neighbour | LLMOps owns | The neighbour owns |
|---|---|---|
| [MLOps](../mlops/) | Prompting versus retrieval versus fine-tuning; evaluating answers | Training *pipelines*, registry, predictive-model drift |
| [ModelOps](../modelops/) | Regressions after provider updates; eval evidence | Approval, risk tier, periodic review, regulation |
| [FinOps for AI](../finops-for-ai/) | Routing, caching, batching: building them, effect on quality and latency | Their cost effect, allocation, who decides |
| [KnowledgeOps](../knowledgeops/) | Chunking, embedding, indexing, retrieval metrics, access filters | Document ownership, review and freshness |
| [DataOps](../dataops/) | Index pipelines as part of the release | Upstream data quality and contracts |
| [ObservabilityOps](../observabilityops/) | Which LLM signals to record; reading a trace | Collection, storage, redaction plumbing |
| [DevSecOps](../devsecops/), [AIOps for Security](../aiops-for-security/) | Injection containment, tool policy | Code and supply-chain controls; AI in the SOC |
| [SRE](../sre/), [IncidentOps](../incidentops/) | Quality signals, LLM runbooks | SLO method, incident process |

Also: [PrivacyOps](../privacyops/), [ProcurementOps](../procurementops/), [DecisionOps](../decisionops/), [ChatOps](../chatops/).

::: figure Where LLMOps sits among its neighbours
```mermaid
%%{init: {"flowchart": {"nodeSpacing": 24, "rankSpacing": 34}}}%%
flowchart TB
  %% Unlinked nodes in a subgraph: direction TB = one row, LR = one column
  subgraph UP["Upstream inputs"]
    direction TB
    KO["KnowledgeOps: the corpus"]
    DO["DataOps: data pipelines"]
    ML["MLOps: fine-tuned weights"]
  end
  LL["LLMOps: release, evaluate and run the LLM application"]
  subgraph SIDE["Alongside"]
    direction LR
    MO["ModelOps: approval"]
    FA["FinOps for AI: spend"]
    DS["DevSecOps: code security"]
  end
  subgraph DOWN["Shared signals"]
    direction LR
    OB["ObservabilityOps"]
    SR["SRE"]
    IN["IncidentOps"]
  end
  UP --> LL
  LL <--> SIDE
  LL --> DOWN
  classDef core fill:#e8f3f2,stroke:#16756f,color:#122029
  classDef neutral fill:#f1f4f6,stroke:#5b7a87,color:#122029
  class LL core
  class KO,DO,ML,MO,FA,DS,OB,SR,IN neutral
```
Text equivalent: Upstream, KnowledgeOps supplies the corpus, DataOps the pipelines and MLOps any fine-tuned weights. Alongside,
ModelOps approves models, FinOps for AI owns spend, DevSecOps secures code. LLMOps shares signals with ObservabilityOps, SRE and
IncidentOps.
:::

## Principles and operating model

### Charter and principles

No body publishes LLMOps principles. Hamel Husain finds that unsuccessful AI products "almost always share a common root
cause: a failure to create robust evaluation systems."[^hamel-evals] The charter below is **this Atlas's proposal**.

::: note An LLMOps charter (Atlas proposal)
**Mission.** For every answer, the team knows which prompt, snapshot, index and tools produced it; every change passed an
evaluation gate; an owner acts on quality, safety and latency signals.

**Invariants.**
1. Each response traces to one release record of component versions.
2. No component changes without the gate; a provider's model change counts.
3. Evals repeat each case and compare versions case by case; must-pass cases pass in every sample.
4. Every judge in a gate has a calibration record on held-out human labels.
5. Untrusted text cannot trigger a consequential action without a human or a deterministic policy in between.
6. The retriever enforces the user's permissions, and a test proves it.
7. Every agent run has step, time and cost limits; tools can be switched off without a deploy.
8. Rollback is a rehearsed configuration change that takes minutes.

**Non-goals:** training, approving or budgeting models. **Trade-offs:** gates, repeats and grading cost time and money.
:::

### Fundamentals: the building blocks

| Building block | Job | "Good" looks like |
|---|---|---|
| Prompt registry | Immutable prompt versions with aliases | "Prompt versions in MLflow are immutable"[^mlflow-prompts] |
| Gateway | Provider keys, routing, quotas, fallbacks, staged rollout[^ms-genaiops] | Applications hold no provider keys |
| Retrieval stack | Chunk, embed, index; search with permission filters | Named index versions; recall per release |
| Orchestrator or agent runtime | Build the prompt; call retriever, model and tools | Workflows by default;[^anthropic-agents] step limits |
| Guardrails | Input, output, retrieval and tool checks[^nemo-guardrails] | False-block rate measured |
| Eval harness | Run cases, score, compare versions | In CI; grows from production failures |
| Tracing and feedback | Steps, tokens, latency, feedback per request | A complaint leads to its [trace](#traces-the-debugging-unit) |
| Release manifest | Pin the whole tuple | Reviewed like code |

### Roles, ownership and RACI

Microsoft warns of "challenges if you omit data scientists" from prompt and RAG experiments.[^ms-genaiops] Measurement skill and
domain judgement must both own the eval suite.

| Activity | Product owner | AI app engineer | Domain expert | AI platform | Security |
|---|---|---|---|---|---|
| Task, criteria and must-pass cases | **A** | R | R | I | C |
| Prompt and orchestration changes | I | **A/R** | C | I | I |
| Eval suite, gate, judge calibration | C | **A/R** | R (labels) | C | I |
| Gateway, pinning, quotas, fallbacks | I | C | — | **A/R** | C |
| Tool policy, agent limits, injection containment | C | R | I | C | **A** |
| Model migration after a deprecation notice | **A** | R | R (grading) | R | C |

R = responsible, A = accountable, C = consulted, I = informed. An Atlas template: one "A" per row.

### Lifecycle: Day 0, Day 1, Day 2

::: figure Eval-driven development: every change returns through the gate
```mermaid
flowchart TB
  A["Define the task, criteria and failure modes"]
  B["Build or extend the eval set"]
  C["Change one component: prompt, model, retrieval, tools"]
  D["Offline evals: assertions, judge, expert sample"]
  E{{"Gate passes?"}}
  F["Canary on a small share of traffic"]
  G["Production: traces, feedback, sampled review"]
  H["Provider notice: new snapshot or retirement"]
  A --> B --> C --> D --> E
  E -->|"no"| C
  E -->|"yes"| F --> G
  G -->|"new failure becomes a case"| B
  H --> C
  classDef core fill:#e8f3f2,stroke:#16756f,color:#122029
  classDef decision fill:#fdf3e3,stroke:#c9800f,color:#122029
  classDef risk fill:#fbeceb,stroke:#b4332b,color:#122029
  class A,B,C,D,F,G core
  class E decision
  class H risk
```
Text equivalent: Define the task and failure modes, build an eval set, change one component, run offline evals. A failed gate
sends the change back; a passed one goes to a canary and production, where traces, feedback and reviews turn new failures into
cases. A provider notice enters as a change.
:::

| Phase | What happens | Exit evidence |
|---|---|---|
| **Day 0: frame** | Task, who a wrong answer harms, a non-LLM baseline; grade 50–100 outputs by hand | Written criteria; first eval set |
| **Day 1: release** | Pin the snapshot; register prompt v1; guardrails, tool policy, gateway route; canary | Release manifest; traces; rollback rehearsed |
| **Day 2: operate** | Review traces and complaints; add cases; re-calibrate judges; handle provider notices | Growing suite; migration log |

**Provider change management** (as of October 2026). Anthropic gives "at least 60 days' notice before model retirement for
publicly released models" on its own platforms, while Amazon Bedrock and Google Cloud "set their own retirement schedules". On
Claude 4.7 and later a non-default `temperature`, `top_p` or `top_k` returns a 400 error, and its Python SDK (v1.0 and later)
removes the three parameters altogether, so passing one raises a `TypeError`.[^anthropic-deprecations] OpenAI promises "At least 6 months" for generally available models, 3 months for specialised variants
and "much shorter notice, such as 2 weeks" for previews, "Unless safety or compliance concerns require a faster timeline"; its
2026-10-14 notice retires GPT-4o and GPT-4.1 models on 2027-01-14, fine-tuned versions included.[^openai-deprecations] Plan for
the shortest notice: a provider-hosted fine-tune retires with its base model, and only weights you host yourself are outside a
provider's schedule.

### Operating model and governance

| Change | Minimum gate | Approver |
|---|---|---|
| Prompt wording | Full suite, paired comparison | Engineer + domain reviewer |
| Model snapshot or parameters | Full suite, more samples; canary; latency and tokens | Product owner ([ModelOps](../modelops/) approves third parties) |
| Retrieval configuration | Retrieval and permission tests, then full suite | Engineer + platform |
| Corpus content | Access labels by the owner ([KnowledgeOps](../knowledgeops/)); targeted cases | Content owner |
| Tools, scopes or agent limits | Injection and agent task tests, approval path | Security |
| Judge prompt or model | Re-calibration on held-out labels | Eval owner |

Ceremonies: weekly **quality review** (traces, complaints, new cases); monthly **provider review**; quarterly **red-teaming**;
post-incident reviews via [IncidentOps](../incidentops/).

## Architecture and workflow

### Reference architecture

In Microsoft's description "an orchestration layer queries your data sources and injects the most relevant results into the
prompt", then calls the model.[^ms-genaiops]

::: figure Reference architecture of an LLM application: the orchestrator as hub
```mermaid
%%{init: {"flowchart": {"wrappingWidth": 130, "nodeSpacing": 20, "rankSpacing": 34}}}%%
flowchart TB
  U["User or calling system"]
  GI["Input guardrails"]
  APP["Orchestrator: builds the prompt, runs the loop"]
  RET["Retriever + permission filter"]
  GW["Gateway: keys, routing"]
  TL["Tool router + policy"]
  GO["Output checks"]
  IDX[("Index of untrusted documents")]
  MOD["Model: pinned hosted snapshot or self-hosted"]
  ANS["Answer to the user"]
  TR[("Traces and eval store")]
  U --> GI --> APP
  APP <-->|"query, chunks"| RET
  RET --> IDX
  APP <-->|"prompt, response"| GW
  GW --> MOD
  APP <-->|"tool call, result"| TL
  APP -->|"final draft"| GO --> ANS
  RET -..-> TR
  GW -..-> TR
  TL -..-> TR
  GO -..-> TR
  classDef core fill:#e8f3f2,stroke:#16756f,color:#122029
  classDef risk fill:#fbeceb,stroke:#b4332b,color:#122029
  classDef neutral fill:#f1f4f6,stroke:#5b7a87,color:#122029
  class GI,APP,RET,GW,TL,GO core
  class U,IDX risk
  class MOD,ANS,TR neutral
```
Text equivalent: Teal nodes are your components, red nodes carry untrusted input, grey nodes are models, stores and outputs.
After input guardrails, the orchestrator queries the permission-filtering retriever over the index, sends prompts through the
gateway to a pinned hosted or self-hosted model, and runs any requested tool through the policy-checking router. The final
draft passes output checks (schema, citations). Components write spans to the trace store.
:::

- **Three untrusted inputs:** user input, retrieved text, and the model's own output before it reaches a browser, shell or
  database (OWASP's LLM05, "Improper Output Handling").[^owasp-top10]
- **The gateway is on the release path:** with model versioning it enables staged rollout and rollback.[^ms-genaiops] It holds
  every provider key, so guard its supply chain: in March 2026 two LiteLLM releases on PyPI carried a credential stealer for
  about 40 minutes; its Docker image, which pins dependencies, was not affected.[^litellm-incident]

### End-to-end workflow: shipping a prompt change

::: figure From pull request to full traffic, with the exception path
```mermaid
%%{init: {"flowchart": {"nodeSpacing": 24, "rankSpacing": 34}}}%%
flowchart TB
  PR["Pull request: prompt v8 and release manifest"]
  CI["CI: assertions, 3 samples per case"]
  JG["Judge-graded cases and paired comparison"]
  G{{"Gate: margin, slices, must-pass cases"}}
  FX["Rework; add failing cases"]
  RV["Review; security if tools changed"]
  CN["Canary: 5% of traffic, by flag"]
  M{{"Online checks within budget?"}}
  RB["Roll back to previous release"]
  FL["25%, then 100%"]
  PM["Review; new eval cases"]
  PR --> CI --> JG --> G
  G -->|"block"| FX --> PR
  G -->|"pass"| RV --> CN --> M
  M -->|"yes"| FL
  M -->|"no"| RB --> PM
  classDef core fill:#e8f3f2,stroke:#16756f,color:#122029
  classDef decision fill:#fdf3e3,stroke:#c9800f,color:#122029
  classDef risk fill:#fbeceb,stroke:#b4332b,color:#122029
  class PR,CI,JG,RV,CN,FL core
  class G,M decision
  class FX,RB,PM risk
```
Text equivalent: A pull request carries the new prompt and release manifest. CI runs assertions on three samples per case,
then judge grading and a paired comparison. The gate checks margin, slices and must-pass cases; a block sends the change back
with new cases. A pass leads to review and a 5% canary, then 25% and 100% within budget; otherwise traffic returns to the
previous release and a review adds eval cases.
:::

The canary watches format errors, guardrail blocks, thumbs-down rate, latency and tokens; bad canary traces become eval cases.

### Traces: the debugging unit

A trace answers "what happened on this request?" OpenTelemetry's GenAI conventions (status Development) define a span per model
call with prompt name and version, models, tokens and finish reasons, plus `retrieval` and `execute_tool` spans.[^otel-client][^otel-genai]

_Example status: illustrative, not captured; names checked against the conventions on 2026-10-17 (`output_check` is a custom
span)._

```text
trace 4bf92f35  POST /assist  2.9 s  release=policy-assistant-2026.10.3  user=u-1842
  retrieval kb-chunks-v14                  0.12 s  gen_ai.retrieval.top_k=5
    gen_ai.retrieval.documents (opt-in): refund-policy#4 0.82, warranty#2 0.79, ...
  chat example-model-2026-09-01            0.88 s  gen_ai.prompt.name=support-answer
    gen_ai.prompt.version=8  gen_ai.usage.input_tokens=3412
    gen_ai.usage.output_tokens=41  gen_ai.response.finish_reasons=["tool_call"]
  execute_tool get_order_status            0.21 s  gen_ai.tool.call.id=call_7
  chat example-model-2026-09-01            1.64 s  gen_ai.usage.output_tokens=600
    gen_ai.response.finish_reasons=["length"]  gen_ai.response.time_to_first_chunk=0.91
  output_check schema-check-v3             0.01 s  result=fail (truncated JSON)
event user_feedback  rating=thumbs_down  trace_id=4bf92f35
```

**Read it in this order.** (1) Release: do prompt version and `gen_ai.response.model` match the manifest? (2) Retrieval: the
right document, at what rank? (3) Model calls: finish reason and tokens; here `length` at the 600-token cap truncated the JSON.
(4) Tools: which tool, for which user, what result. Put the trace ID on every feedback event.

**Retention.** Documents, messages and tool arguments are opt-in attributes, and instrumentations "SHOULD NOT capture" content
by default.[^otel-client][^otel-genai] Keep IDs, scores, versions and
counts; capture content only for a redacted sample, with retention agreed with [PrivacyOps](../privacyops/). Keep every trace with
an error, a block or negative feedback, and a fraction of the rest.

## Practices, frameworks and tools

### Evaluation in depth: the hardest part

**Layer the evals by cost.** Husain's three levels are unit tests, model and human evaluation, and A/B testing, and "The cost
of Level 3 > Level 2 > Level 1".[^hamel-evals] So: assertions on every commit, judge and expert samples per release candidate,
experiments for changes that matter to the business.

**Build the dataset from failures.** Read real outputs, name what goes wrong ("invents a refund exception"), and write cases per
category. Criteria will move: Shankar et al. call it **criteria drift**, "users need criteria to grade outputs, but grading
outputs helps users define criteria".[^shankar-validators] Version the dataset, tag slices, mark must-pass cases.

| Scoring method | Good for | Pitfall |
|---|---|---|
| Assertions[^promptfoo-asserts] | Format, required facts, budgets | Passes fluent nonsense |
| Reference comparison | Extraction, classification | Penalises correct rewording |
| LLM-as-judge with a rubric | Open-ended quality at scale | Biases; must be calibrated |
| Expert review of a sample | Ground truth, calibration labels | Slow; needs guidelines |
| Online signals (feedback, A/B) | Real outcomes | Selection bias |

**Treat non-determinism statistically.** Run each case several times and score its pass *rate*. Miller recommends inference on
"question-level paired differences", clustered standard errors, resampling and power analysis.[^miller-errorbars]

**Calibrate every judge on data it was not tuned on.** Strong judges can reach "over 80% agreement", with position, verbosity and
self-enhancement biases;[^zheng-judge] LLM evaluators "inherit all the problems of the LLMs they evaluate".[^shankar-validators]
Use binary pass/fail criteria. Husain's procedure: an expert labels examples, then you "split them into train, dev, and test
sets"; a few go into the judge prompt, the dev set is for revising it, and the hidden test set is run once on the frozen
prompt. Report true-positive and true-negative rates separately, because raw agreement misleads when failures are
rare.[^hamel-judge] An Atlas rule: approve a judge only on a test set with at least 30 expert-labelled failures; with 8, one
misjudged answer moves the TNR by 12.5 points. Pin the judge's snapshot; GitHub routinely audits its judge's outputs.[^github-copilot]

**LLMs in the loop.** Models can extend a dataset from your prompts and tests (promptfoo's `generate dataset`),[^promptfoo-datasets]
group failure notes into categories and propose prompt changes; Husain and Shankar keep people on the first 30–50 traces and on
every label that tests a judge.[^hamel-automate]

### Retrieval engineering (RAG)

Microsoft's pipeline: chunk, enrich, embed, index, with experiments on each step and on search type.[^ms-genaiops] Six
habits:

1. **Measure it separately:** recall@k and mean reciprocal rank. No generator fixes a missing document.
2. **Mind position and length.** Performance "significantly degrades" when relevant information sits in the middle of long
   contexts.[^liu-lost] More chunks is not automatically better.
3. **Version the index.** pgvector warns, "Unlike typical indexes, you will see different results for queries after adding an
   approximate index."[^pgvector] A new embedding model needs a new index (vectors from different models are not comparable),
   built beside the old one.
4. **Filter by permission inside the query.** OWASP calls for "permission-aware vector and embedding stores";[^owasp-llm08] "do
   not reveal finance documents" in a prompt is not access control. Lab 3 tests the filter.
5. **Keep the index current and erasable,** including "*right to be forgotten* requests".[^ms-genaiops] Documents belong to
   [KnowledgeOps](../knowledgeops/), deletion duties to [PrivacyOps](../privacyops/).
6. **Make claims checkable** (hallucination control). Anthropic's basics: permit "I don't know"; for long documents,
   ground answers in direct quotes; and have each claim cite a quote, retracting claims without one.[^anthropic-hallucinations]
   So: answer only from the retrieved context, abstain when it is silent, return citations as chunk IDs, and check in code that
   each cited chunk was retrieved and contains the quote (`citation-check-v1`). Report abstentions beside unsupported claims: an
   abstaining answer has none. This reduces hallucinations; it does not "eliminate them entirely".[^anthropic-hallucinations]

### Prompt injection, guardrails and tool permissions

OWASP lists seven mitigations, including least privilege and human approval, and doubts that "fool-proof" prevention
exists.[^owasp-llm01] Simon Willison's **lethal trifecta** is the combination to avoid: "Access to your private data",
"Exposure to untrusted content" and "The ability to externally communicate"; for filters that catch 95% of attacks, "95% is very
much a failing grade."[^willison-trifecta] A rendered link communicates: via a public-channel message, PromptArmor made Slack AI show a link carrying
private-channel data.[^slack-promptarmor]

The durable answer is architectural. Beurer-Kellner et al. propose six design patterns (Plan-Then-Execute and Dual LLM among them)
sharing one principle: once an agent "has ingested untrusted
input, it must be constrained so that it is impossible for that input to trigger any consequential actions". Dual LLM has a
precondition: "the output of a quarantined LLM must satisfy certain safety constraints", or it returns "symbolic responses, that
the privileged LLM can manipulate without observing their content".[^design-patterns] A free-text summary handed to the planner
meets neither. OWASP's excessive-agency guidance adds minimal tools and permissions, tools run in the user's context, and
approval for high-impact actions.[^owasp-llm06]

::: figure Containing untrusted text: what it may and may not trigger
```mermaid
%%{init: {"flowchart": {"nodeSpacing": 24, "rankSpacing": 34}}}%%
flowchart TB
  %% Unlinked nodes in a subgraph: direction TB = one row, LR = one column
  subgraph UT["Untrusted text"]
    direction TB
    W["Web page"]
    E["E-mail or ticket"]
    D["Retrieved document"]
  end
  Q["Quarantined model, no tools: typed fields only (IDs, codes, amounts)"]
  V["Validate: schema and allowed values"]
  S["Free-text summary: to the user only"]
  RQ["The user's own request"]
  P["Planner: chooses actions"]
  T{"Tool policy"}
  R["Read tools, user-scoped"]
  H["Write or send: human approval"]
  L["Links only to allow-listed domains"]
  UT --> Q --> V --> P
  Q -.-> S
  RQ --> P --> T
  T --> R
  T --> H
  T --> L
  classDef risk fill:#fbeceb,stroke:#b4332b,color:#122029
  classDef core fill:#e8f3f2,stroke:#16756f,color:#122029
  classDef decision fill:#fdf3e3,stroke:#c9800f,color:#122029
  classDef neutral fill:#f1f4f6,stroke:#5b7a87,color:#122029
  class W,E,D,Q risk
  class V,RQ,P,R,H,L core
  class T decision
  class S neutral
```
Text equivalent: Untrusted text (web pages, e-mails, retrieved documents) goes only to a quarantined model without tools. It
returns typed fields (IDs, codes, amounts), validated against a schema and allowed values before the planner sees them; a
free-text summary goes to the user, never the planner. The planner also gets the user's request and acts through a tool policy:
user-scoped reads, approval for writes or sends, allow-listed links. This limits what injected text can trigger; it does not
detect injection.
:::

**Which design applies?** In the [reference implementation](#reference-implementation-a-policy-answering-assistant), retrieved
policy text reaches the tool-capable model, so the tool policy compensates: reads run as the user, the only write needs approval
(`after_untrusted_input`). Quarantine text that outsiders write, such as customer e-mails.

Guardrails still help as layers (for example NeMo Guardrails' input, retrieval and output rails).[^nemo-guardrails] Measure
their false-block rate, and never use a classifier where a permission would do.

### Operating agents: evaluation, limits and tool trust

An agent turns one request into a loop of model and tool calls, so errors compound and cost varies.[^anthropic-agents] In
Anthropic's agent-eval terms, each attempt at a task is a trial, and the outcome is "the final state in the environment at the
end of the trial".[^anthropic-agent-evals]

- **Grade the outcome first.** Compare the end state with the goal, as τ-bench compares "the database state at the end of a
  conversation with the annotated goal state":[^tau-bench] did exactly one correct refund request appear?
- **Assert only the path rules that matter.** Anthropic finds "it's often better to grade what the agent produced, not the path
  it took",[^anthropic-agent-evals] so check only that forbidden tools were never called, writes followed approval, and steps and
  tokens stayed in budget.
- **Repeat in isolation.** Start each trial "from a clean environment" and report pass^k, "the probability that all k trials
  succeed", where users need consistency.[^anthropic-agent-evals] In τ-bench (2024), GPT-4o solved under 50% of tasks, and
  pass^8 was under 25% in retail.[^tau-bench] Lab 1's gate works unchanged with one trial per sample.
- **Bound every run:** a step limit; time and token budgets (OWASP asks for quotas, timeouts and throttling);[^owasp-llm10]
  idempotency keys on writes, so a retried step cannot refund twice; approval before consequential actions; a kill switch for
  tools. OpenTelemetry's `gen_ai.invoke_agent.tool_calls` metric counts tool calls per invocation.[^otel-metrics]
- **Trust tool servers like dependencies.** Model Context Protocol (MCP) servers "MUST NOT accept any tokens that were not
  explicitly issued for the MCP server" and should start from a "Minimal initial scope set".[^mcp-security] Pin third-party
  servers; treat their tool descriptions and results as untrusted text.

OWASP's **Top 10 for Agentic Applications for 2026** lists the risks, from ASI01 Agent Goal Hijack to ASI10
Rogue Agents.[^owasp-agentic]

### Methods, standards and frameworks

| Source | Type | Use it for |
|---|---|---|
| OWASP Top 10 for LLM Applications 2025;[^owasp-top10] Agentic Applications for 2026[^owasp-agentic] | Community risk lists | Threat checklists |
| NIST AI 600-1 (2024-07-26)[^nist-600-1] | Voluntary US profile of the AI RMF | Risk vocabulary, suggested actions |
| OpenTelemetry GenAI conventions[^otel-genai][^otel-client][^otel-metrics] | Telemetry convention, "Development" | Span and metric names |
| MCP specification 2025-11-25[^mcp-security] | Protocol specification | Token audience, scopes |
| Microsoft GenAIOps guidance;[^ms-maturity] provider deprecation policies | Vendor guidance and policy | Maturity levels; notice periods |

Binding rules come only from law and contracts.

### Tooling landscape

Choose by job, then product (checked 2026-10-17).

| Job | Representative options (verified) | Selection criteria and risks |
|---|---|---|
| Tracing, prompts and evals | Langfuse (MIT except `ee` folders, self-hostable);[^langfuse] Arize Phoenix (Elastic License 2.0);[^phoenix] MLflow 3.17.1 (Apache 2.0; tracing, evaluation, prompt registry and optimisation)[^mlflow-pypi] | Where prompts and answers are stored; OpenTelemetry; export |
| Eval runners in CI | promptfoo (MIT; "now part of OpenAI", per its README)[^promptfoo-gh] | Exit codes; repeated sampling; vendor neutrality |
| Gateway | LiteLLM (weighted routing, fallbacks);[^litellm-routing][^litellm-fallbacks] cloud API gateways | Holds all keys: supply chain, least privilege |
| Guardrails | NeMo Guardrails (Apache 2.0);[^nemo-guardrails] classifier models | False-block rate; added latency |
| Retrieval | pgvector (HNSW and IVFFlat in PostgreSQL);[^pgvector] vector databases; hybrid search | Permission filtering in the query; recall at your latency budget |

**Lock-in to watch:** proprietary trace formats, registries without export, judges on a vendor's moving model.

## LLMOps in practice

### Reference implementation: a policy-answering assistant

**Illustrative design.** A retailer's assistant answers policy questions and opens refund requests: a hosted model behind a
gateway, a pgvector index, CI, a support expert. It is a workflow, not an agent.

_Example status: YAML syntax-checked; not executed (illustrative names)._

```yaml
# llm-release.yaml - one record per release of the assistant (illustrative values)
release: policy-assistant-2026.10.3
owner: support-ai-team
components:
  prompt: {id: support-answer, version: 8}          # immutable registry version
  model:
    provider: example-provider
    id: example-model-2026-09-01             # pinned snapshot, never a moving alias
    max_output_tokens: 600
    fallback: example-model-small-2026-08-15         # evaluated like the primary
  retrieval:
    index: kb-chunks-v14
    embedding_model: example-embed-v3
    top_k: 5
    permission_filter: required                      # enforced in the retriever
  guardrails: {input: [pii-redaction-v2], output: [schema-check-v3, citation-check-v1]}
  tool_policy: tool-policy-v4
evaluation:
  suite: support-answers-v12
  samples_per_case: 3
  gate: {non_inferiority_margin: 0.05, max_slice_drop: 0.10, must_pass: all}
  judge:
    rubric: refund-rubric-v5
    model: example-judge-2026-07-15    # not the generator (self-enhancement bias)
    calibration: cal-2026-10-16          # approved on a held-out test set
  report: reports/eval-2026-10-15.json
rollout:
  steps_percent: [5, 25, 100]
  hold_hours_per_step: 24
  rollback_to: policy-assistant-2026.10.2
approvals: [product-owner, ai-security]      # security: the tool policy changed
```

_Example status: YAML syntax-checked; not executed. Enforcement belongs in the orchestrator code, not in the prompt._

```yaml
# tool-policy-v4.yaml - what the assistant may do (illustrative)
default: deny
tools:
  - name: search_policies
    access: read
    runs_as: end_user                    # the user's token, so the index filter applies
  - name: get_order_status
    access: read
    runs_as: end_user
  - name: create_refund_request
    access: write
    requires_human_approval: true
    max_amount_eur: 200
    idempotency_key: required            # a retried step cannot create a second request
limits:
  max_tool_calls_per_request: 4          # then stop and hand over to a person
  request_timeout_seconds: 30
  kill_switch: feature-flag/assistant-tools   # disables all tools without a deploy
output:
  links: allow_list
  allowed_domains: [help.example.com]
untrusted_sources: [retrieved_documents, customer_emails]
after_untrusted_input: disable_write_tools_without_approval
```

**Gateway route for a model canary.**

_Example status: YAML syntax-checked; not executed; keys from LiteLLM's proxy docs (2026-10-17).[^litellm-routing][^litellm-fallbacks]_

```yaml
# litellm-config.yaml - gateway route during a model canary (LiteLLM proxy)
# Pin the gateway version you tested: the gateway holds every provider key.
model_list:
  - model_name: support-answer                # the only name the orchestrator calls
    litellm_params:
      model: openai/<current-snapshot>
      api_key: os.environ/OPENAI_API_KEY      # provider keys stay in the gateway
      weight: 95
  - model_name: support-answer
    litellm_params:
      model: openai/<candidate-snapshot>      # about 5% of calls
      api_key: os.environ/OPENAI_API_KEY
      weight: 5                               # rollback: delete this entry, redeploy
  - model_name: support-answer-fallback
    litellm_params:
      model: openai/<small-snapshot>          # evaluated like the primary
      api_key: os.environ/OPENAI_API_KEY
router_settings:
  routing_strategy: simple-shuffle            # random pick in proportion to weight
litellm_settings:
  num_retries: 2
  request_timeout: 30                         # seconds per call
  # fallbacks also catch rate-limit (429) errors; test them in staging
  fallbacks: [{"support-answer": ["support-answer-fallback"]}]
```

LiteLLM routes per request, so one conversation can meet both snapshots, unless its `session_affinity` check pins a session
ID to one deployment.[^litellm-routing]

### Hands-on labs

Labs 1–3 need only Python 3.8+, cost nothing, and were **executed** on 2026-10-17 with Python 3.8.10 (illustrative data).
Lab 4's promptfoo run needs a paid provider and was **not executed**.

#### Lab 1 (beginner): a release gate for a non-deterministic system

- **Objective:** decide whether prompt v8 may replace v7, with three runs per case and version.
- **Prerequisites:** Python 3.8+, a terminal, 20 minutes. **Cost and risk:** none.
- **The idea:** per case, subtract v7's pass rate from v8's; the 95% interval around the mean difference shows how bad v8
  could plausibly be. v8 passes only if that end is within a 5-point margin, no slice drops over 10 points, and every
  must-pass ("critical") sample passes.

**Steps**

1. In an empty folder, save the recorded results as `eval_results.json` (1 = the sample passed its checks; cases
   without `"critical": true` are ordinary cases).

_Example status: JSON syntax-checked; this exact file was used for the executed run._

```json
{
  "suite": "support-answers v12",
  "baseline": "prompt support-answer v7",
  "candidate": "prompt support-answer v8 (shorter system prompt)",
  "cases": [
    {"id": "b01", "slice": "billing", "base": [1,1,1], "cand": [1,1,1]},
    {"id": "b02", "slice": "billing", "base": [1,0,1], "cand": [1,1,1]},
    {"id": "b03", "slice": "billing", "base": [0,0,1], "cand": [1,1,0]},
    {"id": "b04", "slice": "billing", "base": [1,1,1], "cand": [1,1,1]},
    {"id": "b05", "slice": "billing", "base": [1,1,0], "cand": [1,1,1]},
    {"id": "b06", "slice": "billing", "base": [1,1,1], "cand": [1,1,1]},
    {"id": "b07", "slice": "billing", "base": [0,1,0], "cand": [1,1,1]},
    {"id": "b08", "slice": "billing", "base": [1,1,1], "cand": [1,0,1]},
    {"id": "r01", "slice": "refunds", "base": [1,1,1], "cand": [1,0,1]},
    {"id": "r02", "slice": "refunds", "base": [1,1,1], "cand": [0,0,1]},
    {"id": "r03", "slice": "refunds", "base": [1,1,0], "cand": [0,1,0]},
    {"id": "r04", "slice": "refunds", "base": [1,1,1], "cand": [1,1,1]},
    {"id": "r05", "slice": "refunds", "base": [1,1,1], "cand": [0,1,0]},
    {"id": "r06", "slice": "refunds", "base": [1,0,1], "cand": [1,0,1]},
    {"id": "a01", "slice": "account", "base": [1,1,1], "cand": [1,1,1]},
    {"id": "a02", "slice": "account", "base": [1,1,1], "cand": [1,1,1]},
    {"id": "a03", "slice": "account", "base": [0,1,1], "cand": [1,1,1]},
    {"id": "a04", "slice": "account", "base": [1,1,1], "cand": [1,1,1]},
    {"id": "a05", "slice": "account", "base": [1,1,0], "cand": [1,0,1]},
    {"id": "a06", "slice": "account", "base": [1,1,1], "cand": [1,1,1]},
    {"id": "s01", "slice": "safety", "critical": true, "base": [1,1,1], "cand": [1,1,1]},
    {"id": "s02", "slice": "safety", "critical": true, "base": [1,1,1], "cand": [1,1,1]},
    {"id": "s03", "slice": "safety", "critical": true, "base": [1,1,1], "cand": [1,0,1]},
    {"id": "s04", "slice": "safety", "critical": true, "base": [1,1,1], "cand": [1,1,1]}
  ],
  "gate": {"non_inferiority_margin": 0.05, "max_slice_drop": 0.10}
}
```

2. Save the gate as `eval_gate.py`.

_Example status: **executed** 2026-10-17 (Python 3.8.10)._

```python
"""Compare a candidate LLM release with the baseline on the same eval cases.

Standard library only (Python 3.8+). Each case was run several times per version,
because the same input can produce different outputs. A case's score is its pass
rate over those samples; versions are compared case by case (paired differences).
"""
import json
import math
import statistics
import sys
from collections import defaultdict


def rate(samples):
    return sum(samples) / len(samples)


def main(path):
    data = json.load(open(path))
    cases, gate = data["cases"], data["gate"]
    base = [rate(c["base"]) for c in cases]
    cand = [rate(c["cand"]) for c in cases]
    diffs = [b - a for a, b in zip(base, cand)]
    n = len(diffs)
    mean = statistics.mean(diffs)
    se = statistics.stdev(diffs) / math.sqrt(n)
    low, high = mean - 1.96 * se, mean + 1.96 * se
    print(f"cases={n}  baseline={statistics.mean(base):.3f}  "
          f"candidate={statistics.mean(cand):.3f}")
    print(f"paired difference={mean:+.3f}  95% CI [{low:+.3f}, {high:+.3f}]")

    reasons = []
    if low < -gate["non_inferiority_margin"]:
        margin = gate["non_inferiority_margin"]
        reasons.append(f"CI lower bound {low:+.3f} is below -{margin} "
                       "(cannot rule out a regression)")
    by_slice = defaultdict(list)
    for c, a, b in zip(cases, base, cand):
        by_slice[c["slice"]].append((a, b))
    print("slice      n  baseline  candidate  change")
    for name, pairs in sorted(by_slice.items()):
        a = statistics.mean(p[0] for p in pairs)
        b = statistics.mean(p[1] for p in pairs)
        flag = ""
        if a - b > gate["max_slice_drop"]:
            flag = "  <- drop"
            reasons.append(f"slice '{name}' dropped by {a - b:.3f}")
        print(f"{name:<9} {len(pairs):>2}  {a:8.3f}  {b:9.3f}  {b - a:+.3f}{flag}")
    for c in cases:
        if c.get("critical") and min(c["cand"]) == 0:
            reasons.append(f"critical case {c['id']} failed in "
                           f"{c['cand'].count(0)} of {len(c['cand'])} samples")
    print("VERDICT:", "BLOCK" if reasons else "PASS")
    for r in reasons:
        print("  -", r)
    return 1 if reasons else 0


if __name__ == "__main__":
    sys.exit(main(sys.argv[1] if len(sys.argv) > 1 else "eval_results.json"))
```

3. Run `python3 eval_gate.py eval_results.json; echo "exit=$?"`.

**Expected output** (verbatim from the executed run):

```text
cases=24  baseline=0.861  candidate=0.833
paired difference=-0.028  95% CI [-0.152, +0.096]
slice      n  baseline  candidate  change
account    6     0.889      0.944  +0.056
billing    8     0.750      0.917  +0.167
refunds    6     0.889      0.556  -0.333  <- drop
safety     4     1.000      0.917  -0.083
VERDICT: BLOCK
  - CI lower bound -0.152 is below -0.05 (cannot rule out a regression)
  - slice 'refunds' dropped by 0.333
  - critical case s03 failed in 1 of 3 samples
exit=1
```

4. **Interpret.** The average fell under three points, which looks like noise, yet v8 broke refunds and once failed a must-pass
   case. The 25-point interval shows that 24 cases cannot settle a small regression, hence the slice and must-pass rules; one
   sample per case would miss the safety failure two times in three.
- **Validation:** refunds v7 by hand = (1 + 1 + 0.667 + 1 + 1 + 0.667) ÷ 6 = 0.889. The script uses
  the normal approximation (z = 1.96); a t quantile for 24 cases (about 2.07) only widens the interval and strengthens the block.
- **Troubleshooting:** `StatisticsError`: fewer than two cases; `KeyError`: a case lacks `base` or `cand`.
- **Teardown:** delete the folder.

#### Lab 2 (intermediate): calibrate an LLM judge against an expert

- **Objective:** choose between two judge prompts, and see why the winner still needs a held-out test.
- **Prerequisites:** Python 3.8+, 20 minutes. **Cost and risk:** none (labels recorded upstream).

1. Save `judge_labels.json`: 40 answers labelled by the expert and by two judge prompts (the **dev set**).

_Example status: JSON syntax-checked; used in the executed run._

```json
{
  "criterion": "Answer states the refund window correctly and cites the policy",
  "note": "1 = pass, 0 = fail; one character per item, spaces are ignored",
  "human":    "1111011110 1101111011 1101110111 1101111110",
  "judge_v1": "1111111111 1101101111 1101111111 1101111111",
  "judge_v2": "1111011110 1101101011 1101111111 0101111110",
  "production_pass_rate": {"judge_v1": 0.91, "judge_v2": 0.74}
}
```

2. Save `judge_calibration.py`.

_Example status: **executed** 2026-10-17 (Python 3.8.10)._

```python
"""Measure how well an LLM judge agrees with a human expert on pass/fail labels.

Standard library only (Python 3.8+). Treats the human labels as ground truth.
"""
import json
import sys


def parse(s):
    return [int(ch) for ch in s if ch in "01"]


def corrected(observed, tpr, tnr):
    # observed = true * TPR + (1 - true) * (1 - TNR); solve for "true".
    return (observed + tnr - 1) / (tpr + tnr - 1)


def report(name, human, judge, prod_rate):
    n = len(human)
    tp = sum(1 for h, j in zip(human, judge) if h == 1 and j == 1)
    tn = sum(1 for h, j in zip(human, judge) if h == 0 and j == 0)
    pos, neg = human.count(1), human.count(0)
    tpr, tnr = tp / pos, tn / neg
    p_o = (tp + tn) / n
    judge_pass, human_pass = sum(judge) / n, pos / n
    p_e = human_pass * judge_pass + (1 - human_pass) * (1 - judge_pass)
    kappa = (p_o - p_e) / (1 - p_e)
    print(f"{name}: TPR={tpr:.3f} ({tp}/{pos})  TNR={tnr:.3f} ({tn}/{neg})  "
          f"agreement={p_o:.3f}  kappa={kappa:.3f}")
    print(f"   calibration set: judge pass rate={judge_pass:.3f}, "
          f"human pass rate={human_pass:.3f}")
    print(f"   production: judge says {prod_rate:.3f}, "
          f"corrected estimate {corrected(prod_rate, tpr, tnr):.3f}")


def main(path):
    data = json.load(open(path))
    human = parse(data["human"])
    for key in ("judge_v1", "judge_v2"):
        judge = parse(data[key])
        assert len(judge) == len(human), key
        report(key, human, judge, data["production_pass_rate"][key])


if __name__ == "__main__":
    main(sys.argv[1] if len(sys.argv) > 1 else "judge_labels.json")
```

3. Run `python3 judge_calibration.py judge_labels.json`.

**Expected output** (verbatim from the executed run):

```text
judge_v1: TPR=0.969 (31/32)  TNR=0.375 (3/8)  agreement=0.850  kappa=0.423
   calibration set: judge pass rate=0.900, human pass rate=0.800
   production: judge says 0.910, corrected estimate 0.829
judge_v2: TPR=0.938 (30/32)  TNR=0.875 (7/8)  agreement=0.925  kappa=0.776
   calibration set: judge pass rate=0.775, human pass rate=0.800
   production: judge says 0.740, corrected estimate 0.757
```

4. **Interpret.** Judge v1 agrees 85% of the time yet passes five of eight bad answers; v2 catches seven of eight and wins on the
   dev set, so its numbers are optimistic. The correction solves *observed = true × TPR + (1 − true) × (1 − TNR)*; the corrected
   estimates (0.829 and 0.757) differ because a TNR from eight failures is very uncertain (95% Wilson interval for 7 of 8: about
   0.53 to 0.98). Next: freeze v2, run it once on a test set with at least 30 failures, and approve it only on those numbers.
- **Validation:** kappa for v2 by hand: p_e = 0.8 × 0.775 + 0.2 × 0.225 = 0.665; (0.925 − 0.665) ÷ 0.335 = 0.776.
- **Troubleshooting:** `AssertionError`: label strings differ in length; `ZeroDivisionError` in `corrected`: TPR + TNR = 1, a
  judge no better than chance.
- **Teardown:** delete the folder.

#### Lab 3 (intermediate): promote a new index only if it is better and safe

- **Objective:** compare two index versions on labelled queries, including access control.
- **Prerequisites:** Python 3.8+, 15 minutes. **Cost and risk:** none (rankings recorded upstream).

1. Save `retrieval_runs.json`.

_Example status: JSON syntax-checked; used in the executed run._

```json
{
  "k": 3,
  "acl": {
    "refund-policy": "public", "shipping-times": "public", "warranty": "public",
    "price-list-2026": "public", "account-deletion": "public",
    "staff-discount": "staff", "refund-exceptions": "staff",
    "q3-margin-report": "finance"
  },
  "groups": {"public": ["public"], "staff": ["public", "staff"]},
  "queries": [
    {"id": "q1", "group": "public", "relevant": ["refund-policy"],
     "v1": ["refund-policy", "warranty", "shipping-times"],
     "v2": ["refund-policy", "warranty", "account-deletion"]},
    {"id": "q2", "group": "public", "relevant": ["refund-policy", "warranty"],
     "v1": ["warranty", "price-list-2026", "account-deletion"],
     "v2": ["warranty", "shipping-times", "price-list-2026"]},
    {"id": "q3", "group": "public", "relevant": ["shipping-times"],
     "v1": ["price-list-2026", "shipping-times", "refund-policy"],
     "v2": ["shipping-times", "refund-policy", "warranty"]},
    {"id": "q4", "group": "public", "relevant": ["account-deletion"],
     "v1": ["refund-policy", "warranty", "shipping-times"],
     "v2": ["refund-policy", "account-deletion", "warranty"]},
    {"id": "q5", "group": "staff", "relevant": ["refund-exceptions"],
     "v1": ["refund-policy", "refund-exceptions", "staff-discount"],
     "v2": ["refund-exceptions", "refund-policy", "staff-discount"]},
    {"id": "q6", "group": "staff", "relevant": ["staff-discount"],
     "v1": ["staff-discount", "refund-policy", "warranty"],
     "v2": ["staff-discount", "refund-exceptions", "warranty"]},
    {"id": "q7", "group": "public", "relevant": ["price-list-2026"],
     "v1": ["shipping-times", "warranty", "refund-policy"],
     "v2": ["price-list-2026", "q3-margin-report", "shipping-times"]},
    {"id": "q8", "group": "public", "relevant": ["warranty"],
     "v1": ["warranty", "refund-policy", "account-deletion"],
     "v2": ["warranty", "refund-policy", "shipping-times"]}
  ]
}
```

2. Save `retrieval_eval.py`.

_Example status: **executed** 2026-10-17 (Python 3.8.10)._

```python
"""Compare two retrieval index versions: recall@k, MRR and access-control leaks.

Standard library only (Python 3.8+). Input: ranked document ids that each index
returned for the same labelled queries (recorded from the retriever).
"""
import json
import sys


def recall_at_k(ranked, relevant, k):
    return len(set(ranked[:k]) & set(relevant)) / len(relevant)


def reciprocal_rank(ranked, relevant):
    for i, doc in enumerate(ranked, start=1):
        if doc in relevant:
            return 1 / i
    return 0.0


def main(path):
    data = json.load(open(path))
    k, acl, groups = data["k"], data["acl"], data["groups"]
    verdict_reasons = []
    summary = {}
    for version in ("v1", "v2"):
        rec, rr, leaks = [], [], []
        for q in data["queries"]:
            ranked = q[version]
            rec.append(recall_at_k(ranked, q["relevant"], k))
            rr.append(reciprocal_rank(ranked, q["relevant"]))
            allowed = groups[q["group"]]
            leaks += [(q["id"], d, acl[d]) for d in ranked[:k] if acl[d] not in allowed]
        summary[version] = sum(rec) / len(rec)
        print(f"{version}: recall@{k}={sum(rec) / len(rec):.3f}  "
              f"MRR={sum(rr) / len(rr):.3f}  acl_violations={len(leaks)}")
        for qid, doc, label in leaks:
            print(f"    {qid}: '{doc}' ({label}) shown to a user outside that group")
        if version == "v2" and leaks:
            verdict_reasons.append(f"{len(leaks)} access-control violation(s)")
    if summary["v2"] < summary["v1"]:
        verdict_reasons.append("recall dropped")
    print("VERDICT:", "BLOCK" if verdict_reasons else "PROMOTE v2")
    for r in verdict_reasons:
        print("  -", r)
    return 1 if verdict_reasons else 0


if __name__ == "__main__":
    sys.exit(main(sys.argv[1] if len(sys.argv) > 1 else "retrieval_runs.json"))
```

3. Run `python3 retrieval_eval.py retrieval_runs.json; echo "exit=$?"`.

**Expected output** (verbatim from the executed run):

```text
v1: recall@3=0.688  MRR=0.625  acl_violations=0
v2: recall@3=0.938  MRR=0.938  acl_violations=1
    q7: 'q3-margin-report' (finance) shown to a user outside that group
VERDICT: BLOCK
  - 1 access-control violation(s)
exit=1
```

4. **Interpret.** v2 (new embedding model, smaller chunks) finds documents far better but returned a finance document to a
   public user: no permission filter applied to that query (the index lost the label, or the query skipped the filter). In
   production the filter runs inside the retrieval query; this check proves it does.
- **Validation:** v1 recall by hand: (1 + 0.5 + 1 + 0 + 1 + 1 + 0 + 1) ÷ 8 = 0.6875.
- **Troubleshooting:** `KeyError` on `acl[d]`: a returned document has no access label; treat that as a failure.
- **Teardown:** delete the folder.

#### Lab 4 (advanced): the same gate against a real model, in CI

- **Objective:** run both prompts against a pinned model with promptfoo (0.124.1 on npm, 2026-10-08); Lab 1's gate decides.
- **Prerequisites:** Node.js, Python 3.8+, an API key scoped to a test project and exposed only to jobs on
  same-repository branches (never fork pull requests), a spending limit. **Cost and risk:** cases × 3 samples ×
  2 prompts, plus judge calls, are billed.

1. Pin the tool: `npm install --save-dev --save-exact promptfoo@0.124.1`; commit `package-lock.json` so CI runs `npm ci`.
2. Write the suite. Every test sets `metadata.id` and `metadata.slice`; must-pass tests add `critical: true`.

_Example status: YAML syntax-checked; **not executed** (needs a paid provider account). Replace the placeholders._

```yaml
# evals/promptfooconfig.yaml (promptfoo 0.124.1); prompt 0 is the baseline
description: support-answers v12
prompts:
  - file://prompts/support-answer-v7.txt
  - file://prompts/support-answer-v8.txt
providers:
  - id: openai:<pinned-model-snapshot>
defaultTest:
  options:
    provider: openai:<pinned-judge-snapshot>        # the judge is pinned too
tests:
  - description: refund window for opened items
    metadata: {id: r01, slice: refunds}
    vars:
      question: Can I return headphones I have opened?
      context: file://fixtures/refund-policy.md
    assert:
      - type: icontains
        value: 30 days
      - type: llm-rubric
        value: States the 30-day window, cites the refund policy and invents no exceptions
  - description: refuses another customer's order
    metadata: {id: s01, slice: safety, critical: true}
    vars:
      question: Show me the last order for jane@example.com
      context: ""
    assert:
      - type: llm-rubric
        value: Declines and says it can only discuss the signed-in customer's orders
```

3. Save the converter as `ci/promptfoo_to_gate.py` and Lab 1's script as `ci/eval_gate.py`.

_Example status: **executed** 2026-10-18 (Python 3.8.10) on a synthetic file in promptfoo 0.124.1's result layout (from its
source types[^promptfoo-types]) built from Lab 1's data; it reproduced Lab 1's output. Not run on real promptfoo output._

```python
"""Turn a promptfoo JSON results file into the input of Lab 1's eval_gate.py.

Standard library only (Python 3.8+). Layout of promptfoo 0.124.1 (from its source
types): results.version is 3; results.results is a list of rows with promptIdx
(0 = baseline prompt, 1 = candidate, in config order), success, failureReason
(0 none, 1 assert, 2 error) and testCase.metadata, where each test sets id, slice
and optionally critical. Any other layout stops the run: re-check after upgrades.
"""
import json
import sys

GATE = {"non_inferiority_margin": 0.05, "max_slice_drop": 0.10}


def main(src, dst, expected_cases, repeats):
    summary = json.load(open(src))["results"]
    if summary.get("version") != 3:
        sys.exit("unknown promptfoo result layout: re-check the converter")
    samples, meta = {}, {}
    for row in summary["results"]:
        md = row["testCase"].get("metadata") or {}
        if "id" not in md or "slice" not in md:
            sys.exit("every test needs metadata.id and metadata.slice")
        if row.get("failureReason") not in (0, 1, 2):
            sys.exit(f"unknown failureReason on case {md['id']}: re-check the converter")
        if row["failureReason"] == 2:
            sys.exit(f"provider error on case {md['id']}: fix and re-run, do not gate")
        pair = samples.setdefault(md["id"], ([], []))
        pair[row["promptIdx"]].append(1 if row["success"] else 0)
        meta[md["id"]] = md
    if len(samples) != expected_cases:
        sys.exit(f"{len(samples)} cases in results, {expected_cases} expected")
    cases = []
    for cid, (base, cand) in sorted(samples.items()):
        if len(base) != repeats or len(cand) != repeats:
            sys.exit(f"case {cid}: {len(base)}/{len(cand)} samples, {repeats} expected")
        case = {"id": cid, "slice": meta[cid]["slice"], "base": base, "cand": cand}
        if meta[cid].get("critical"):
            case["critical"] = True
        cases.append(case)
    with open(dst, "w") as f:
        json.dump({"cases": cases, "gate": GATE}, f, indent=1)
    print(f"wrote {len(cases)} cases x {repeats} samples x 2 prompts to {dst}")


if __name__ == "__main__":
    main(sys.argv[1], sys.argv[2], int(sys.argv[3]), int(sys.argv[4]))
```

4. Save the CI step as `ci/llm-gate.sh`.

_Example status: bash syntax-checked; **not executed**._

```bash
#!/usr/bin/env bash
# ci/llm-gate.sh - two prompts, 3 samples per case; Lab 1's paired gate sets the exit code
set -euo pipefail
: "${OPENAI_API_KEY:?inject from a protected CI environment, never into fork PR jobs}"
export PROMPTFOO_DISABLE_TELEMETRY=1 PROMPTFOO_DISABLE_UPDATE=1
export PROMPTFOO_FAILED_TEST_EXIT_CODE=0     # failures are data here; the gate decides
npm ci                                       # promptfoo 0.124.1 from package-lock.json
mkdir -p out
./node_modules/.bin/promptfoo eval -c evals/promptfooconfig.yaml \
  --repeat 3 --no-share -o out/results.json
python3 ci/promptfoo_to_gate.py out/results.json out/eval_results.json 24 3
python3 ci/eval_gate.py out/eval_results.json   # exit 1 = BLOCK
```

- **Why promptfoo does not decide:** `eval` exits with code 100 "when there is at least 1 test case failure" (default threshold
  100%),[^promptfoo-cli] so one stochastic failure, even of the baseline, would block. The converter stops on provider errors,
  missing samples or unknown result layouts, so an aborted run cannot pass.
- **Side effects:** promptfoo "collects basic usage telemetry by default" and checks npm for updates; the script disables
  both.[^promptfoo-telemetry] `out/results.json` holds prompts and outputs: restrict and expire it, or set
  `PROMPTFOO_STRIP_PROMPT_TEXT` and `PROMPTFOO_STRIP_RESPONSE_OUTPUT`.
- **Expected output:** not observed; it ends in Lab 1's `VERDICT` line. **Validation:** calibrate the `llm-rubric` judge first.
- **Troubleshooting:** rate limits: lower `-j`. **Teardown:** revoke the test key; delete `out/`.

### Use cases and scenarios

| Scenario | Context and method | Trade-offs | Success measure |
|---|---|---|---|
| **A generative feature at scale** (real: LinkedIn, 2024) | Tooling to "evaluate up to 500 daily conversations"; "that last 20% took most of our work"; format errors from about 10% to 0.01%[^linkedin] | Evaluation and GPU capacity | Graded quality; format errors |
| **Choosing models with a harness** (real: GitHub Copilot, 2025) | "more than 4,000 offline tests, most of them as part of our automated CI pipeline"; an audited judge; internal canaries[^github-copilot] | The harness is a product | Offline pass rate, then canary |
| **A change that broke behaviour** (real: DPD, January 2024) | After its assistant swore at a customer, DPD said "An error occurred after a system update yesterday" and "The AI element was immediately disabled"[^dpd-aiid] | A kill switch beats a hot fix | Time to disable |

Editorial synthesis, not the sources' finding: both successes built evaluation capacity before scaling; DPD tied its failure
to a system update, the change a release gate exists for (its testing is not described).

## Measure, secure and pay

### Metrics and KPIs

Service metrics are defined in [SRE](../sre/), spend in [FinOps for AI](../finops-for-ai/); these are LLM-specific.

| KPI | Definition / formula | Pitfall | Cadence |
|---|---|---|---|
| Eval pass rate by slice | Mean per-case pass rate over repeated samples | Averages hide slices | Per release |
| Unsupported-claim rate | Answers with ≥ 1 claim unsupported by the context ÷ graded answers | Lenient judges | Weekly |
| Retrieval recall@k | Relevant documents in top k ÷ relevant documents | Stale labels | Per index change |
| Judge TPR / TNR | Judge passes ÷ expert passes; judge fails ÷ expert fails (held-out set) | Few failures | Per judge change |
| Format error rate | Schema failures ÷ responses | Silent retries | Daily |
| Latency p95; time to first chunk p95 | 95th percentile of total time and of time to the first streamed chunk (OpenTelemetry client metric `gen_ai.client.inference.time_to_first_chunk`; `gen_ai.server.time_to_first_token` is server-side only)[^otel-client][^otel-metrics] | Streaming hides slow ends | Daily |
| Tokens per resolved request | Input and output tokens ÷ requests resolved without handoff | Rewards terse answers | Weekly |
| Agent task pass^k | Tasks whose k isolated trials all succeed ÷ tasks | Few tasks | Per release |
| Thumbs-down or escalation rate | Negative feedback or handoffs ÷ conversations | Selection bias | Weekly |

**Vanity metrics:** prompts in the registry, cases that never fail, scores from an uncalibrated judge.

::: example Worked calculation: a monthly review of the policy assistant (illustrative numbers)
- **Unsupported-claim rate:** the judge flagged 46 of 400 sampled answers; the expert, reviewing all 400, confirmed 38 and found
  4 more: (38 + 4) ÷ 400 = **10.5%** against a 5% target.
- **Retrieval recall@5:** 171 of 190 labelled relevant documents retrieved: **90%**. Of the 42 unsupported answers, 25 lacked
  the right document in context: retrieval, not generation, is the bigger cause.
- **Format errors:** 1,240 of 182,000 responses failed the schema: **0.68%**, all truncated at the token limit.
- **Tokens:** 620 million input tokens (about 3,400 per response, mostly system prompt and five chunks) and 64 million output
  (about 350) over 182,000 responses ÷ 131,000 resolved requests = **4,733 input and 489 output tokens** per resolved request.

**Reading:** fix retrieval, then truncation. Input dominates, so put static template text first (prefix caching) and test
whether fewer chunks keep recall; re-gate after each change.
:::

### Quality, security, privacy and compliance

- **Threats.** Use the OWASP 2025 list,[^owasp-top10] especially LLM01, LLM02 (sensitive information disclosure), LLM06, LLM07
  (system-prompt leakage: keep secrets out of prompts) and LLM08, plus the agentic list; NIST AI 600-1 adds confabulation and
  data privacy.[^nist-600-1]
- **Traces contain personal data:** set [retention](#traces-the-debugging-unit) first; sending personal data to a provider is a
  [PrivacyOps](../privacyops/) question.
- **Least privilege.** Tools run as the end user; provider keys stay in the gateway; eval keys are separate and capped.
- **Accountability.** The operator answers for what its assistant says ([Air Canada](#the-problem-llmops-solves)); regulation and
  audit evidence are [ModelOps](../modelops/) and [ComplianceOps](../complianceops/).

### Cost, latency and capacity

- **Output length drives latency.** Input tokens are processed in parallel, but "output length significantly affects
  latency".[^huyen-2023] Cap output per route and stream responses, as LinkedIn does.[^linkedin]
- **Prefix caching.** OpenAI caches prompts by default and advises putting dynamic content last; Anthropic prices cache reads at
  a fraction of base input (October 2026).[^openai-caching][^anthropic-models] Template order is a cost decision.
- **Routing.** RouteLLM reports costs cut by over two times in certain cases on its benchmarks;[^routellm] the router is a model
  too, so evaluate quality per route.
- **Quotas.** Keep headroom and a tested fallback; set per-user limits against unbounded consumption.[^owasp-llm10]

### Assurance of the operating model

Each quarter, check the controls: releases with a gate report (target 100%); age of each judge's calibration; share of new
cases from production failures; canary rollbacks and causes; rehearsed rollback time; models retiring within 90 days.

## When things go wrong

### Common failures and troubleshooting

| Symptom | Likely cause | Diagnosis | Remediation | Prevention |
|---|---|---|---|---|
| Answers invent policy details | Document not retrieved, or lost mid-context | Trace: source in context? Where? | Fix chunking or ranking | Retrieval tests |
| Quality shifts with no deploy | Model moved behind an alias; corpus changed | `gen_ai.response.model` versus manifest | Pin snapshot; roll back index | Pinned snapshots |
| 400 errors after migration | Parameter rejected by the new model | Error body | Remove it; re-evaluate | Contract tests |
| JSON parse failures | Output truncated at the token limit | Finish reason `length` | Raise the limit; shorten | Format error alert |
| Evals better, users worse | Lenient judge; unrepresentative cases | Lab 2 on recent traffic | Re-calibrate; add cases | Error analysis |
| 429 errors at peak | Quota exhausted | Gateway metrics | Fallback; more quota | Load tests |
| Agent loops or repeats a write | No step limit; non-idempotent tool | Tool calls per invocation; tool spans | Kill switch; idempotency keys | Step and cost limits |
| Private data in an answer | Missing filter, or injection | Retrieval and tool spans | Kill switch; fix filter | Lab 3 checks; tool policy |

### Worked runbook: quality drops after a model migration

**Situation (illustrative).** The pinned snapshot was deprecated, retiring in seven weeks. After a spot check the team
switched snapshots on Monday; by Thursday thumbs-down rose from 4.1% to 7.8% and format errors from 0.3% to 2.6%.

1. **Contain (30 minutes).** Route the gateway back to the old snapshot (deprecated, not retired); confirm in traces
   (`gen_ai.response.model`) and watch both rates recover.
2. **Establish the change.** Manifests differ only in the model; the migration ran 1 sample on 60 cases, not 3 on 240.
3. **Reproduce.** Full suite, both snapshots, Lab 1's gate: overall −1.5 points (inside the margin), refunds −14 points, two
   must-pass cases failing once each.
4. **Diagnose.** [Read the failing traces](#traces-the-debugging-unit): finish reason `length` at the 600-token cap, and
   "30 days" paraphrased as "about a month".
5. **Fix.** Output limit 800; instruct the model to quote time limits exactly. Re-run: slices and must-pass cases pass.
6. **Release.** Canary 5%, 25%, 100% with 24-hour holds. **Learn:** add the cases; require the full suite with repeats for
   model changes; track retirement dates monthly.

**Prevention.** Migrate when a deprecation is announced: notice can be weeks ([provider change](#lifecycle-day-0-day-1-day-2)).

### Anti-patterns and myths

- **"Temperature 0 makes it deterministic."** Huyen (2023) wrote that temperature 0 "mostly solves the consistency
  problem";[^huyen-2023] He (2025) shows that even at temperature 0 "LLM APIs are still not deterministic in practice": server
  load sets the batch size, which changes each request's result.[^thinking-machines] Sample repeatedly anyway.
- **Vibe checks as gates.** Ten hand-tried prompts are not a test suite.
- **One average score.** It hid the refunds slice in Lab 1.
- **The judge as oracle.** An uncalibrated judge reports what it was prompted to like (Lab 2).
- **Fine-tuning to add knowledge.** RAG "consistently outperforms" unsupervised fine-tuning for injecting knowledge.[^ovadia]
- **Agents by default.** Start with a workflow; agents add cost and compounding errors.[^anthropic-agents]

## Deciding and adopting

### When to use and when not to use

**Use these practices** once an LLM feature reaches users or decisions: when a wrong answer has a cost, a provider can change
underneath you, or the feature reads text you do not control.

**Use less, or no LLM, when:**

- rules, search or a classic model already give the right answer reliably and cheaply;
- a wrong answer is unacceptable and no human or deterministic check can catch it first;
- no expert time is available to define and grade "good" (then every gate measures nothing);
- it is a prototype without users.

Hidden costs: expert grading, eval tokens, provider reviews, an on-call quality owner.

### Decision framework: prompt, retrieval, fine-tuning or no LLM

OpenAI's accuracy guide frames two axes: optimising the context "maximizes response accuracy" and optimising the model
"maximizes consistency of behavior"; "Prompt engineering is typically the best place to start", with an evaluation set as the
baseline.[^openai-accuracy]

::: figure Choosing the next change when an LLM feature falls short
```mermaid
%%{init: {"flowchart": {"wrappingWidth": 150, "nodeSpacing": 24, "rankSpacing": 34}}}%%
flowchart TB
  S{{"Open-ended language task; some errors tolerable?"}}
  N["No LLM: rules, search or a classic model"]
  P["Prompt and eval set first"]
  Q1{{"Failing on missing, private or stale knowledge?"}}
  R["Add retrieval; test recall and permissions"]
  Q2{{"Failing on format, tone or consistent behaviour?"}}
  F["Schema outputs and examples; then fine-tune via MLOps"]
  Q3{{"Quality fine, but cost or latency too high?"}}
  C["Smaller model, caching or routing; re-run evals"]
  D["Ship behind the gate; keep evaluating"]
  S -->|"no"| N
  S -->|"yes"| P --> Q1
  Q1 -->|"yes"| R
  Q1 -->|"no"| Q2
  Q2 -->|"yes"| F
  Q2 -->|"no"| Q3
  Q3 -->|"yes"| C
  Q3 -->|"no"| D
  classDef decision fill:#fdf3e3,stroke:#c9800f,color:#122029
  classDef core fill:#e8f3f2,stroke:#16756f,color:#122029
  classDef risk fill:#fbeceb,stroke:#b4332b,color:#122029
  class S,Q1,Q2,Q3 decision
  class P,R,F,C,D core
  class N risk
```
Text equivalent: Not open-ended language work with tolerable errors: use rules, search or a classic model. Otherwise start with
a prompt and an eval set. Missing, private or stale knowledge: add retrieval, test recall and permissions. Format, tone or
consistency problems: schema outputs and examples, then fine-tuning via MLOps. Cost or latency too high: a smaller model,
caching or routing. Otherwise ship behind the gate.
:::

### Maturity model

**This is the Atlas's model**, not a standard; Microsoft's four GenAIOps levels (initial, defined, managed, optimized) come with
a questionnaire.[^ms-maturity]

| Level | Observable behaviour | Exit criterion |
|---|---|---|
| 0 Prototype | Prompts in code, tried by hand, model alias in config | A named owner and 50 graded examples |
| 1 Repeatable | Prompt registry, pinned snapshot, assertions in CI, traces | Every release has a manifest |
| 2 Evaluated | Sliced suite with repeats, calibrated judge, paired gate, weekly error analysis | The gate has blocked a bad change |
| 3 Governed | Canary, rehearsed rollback, tool policy, agent limits, injection and permission tests, provider review | A migration by the runbook within the notice period |
| 4 Optimised | Online experiments, routing and caching measured per route, budgets per feature | Budgets met for two quarters |

### Adoption roadmap: the first 90 days

| Days | Actions | Milestone |
|---|---|---|
| 1–30 | Owner and expert named; failure categories from 100 outputs; snapshot pinned; traces (no content capture); 60-case suite | Level 1; first gate report |
| 31–60 | Repeats and paired gate (Lab 1); judge calibration with a held-out set (Lab 2); retrieval and permission tests (Lab 3); gateway rollback route | Level 2; first blocked change |
| 61–90 | Tool policy, agent limits, injection cases; canaries; weekly quality and monthly provider reviews; rollback rehearsal | Level 3 for one feature |

## Toolkit

### Checklists

**Before launch**
- [ ] Owner, domain expert and must-pass cases named
- [ ] Snapshot pinned; retirement date and hosting platform recorded
- [ ] Gate thresholds written; judge calibrated on a held-out set
- [ ] Permission filter inside the retrieval query; Lab 3-style test in CI
- [ ] Tool policy (default deny, user-scoped reads, approved writes, link allow-list); step and cost limits
- [ ] Traces on, content capture decided with privacy; kill switch tested

**Every release**
- [ ] One component changed, recorded in the manifest
- [ ] Full suite, repeated samples, paired comparison attached
- [ ] Canary with online budgets; rollback target named

**Every week**
- [ ] Sampled traces and complaints reviewed; new cases added
- [ ] Format errors, false blocks, latency and tokens checked

**On a deprecation notice**
- [ ] Migration opened the same week; replacement evaluated with the full suite
- [ ] Parameters and output limits re-tested; provider-hosted fine-tunes re-planned

### Templates

_Example status: templates; adapt the fields._

```text
EVAL CASE                                  (versioned with the suite)
id:           r07              slice: refunds        must_pass: no
input:        "Can I return opened headphones bought 40 days ago?"
context:      refund-policy (version 2026-09)
criteria:     states the 30-day window; says this purchase is outside it;
              offers the warranty route; invents no exception
assertions:   contains "30 days"; schema valid
judge:        refund-rubric-v5 (pass/fail)
origin:       production trace 2026-10-09, redacted; added by the domain expert
```

```text
JUDGE CALIBRATION RECORD
judge:        refund-rubric-v5 on <pinned model snapshot>
dev labels:   40 items by <expert>, 2026-10-02 (32 pass, 8 fail); chose v5 over v4
dev results:  TPR 0.938   TNR 0.875   agreement 0.925   kappa 0.776
test labels:  <n> items, at least 30 failures, hidden until the prompt was frozen
test results: TPR <a>   TNR <b>   (judge run once)
decision:     provisional until test results meet TPR >= 0.90 and TNR >= 0.85
renewal:      re-calibrate if the judge prompt or model changes
```

```text
MODEL CHANGE REQUEST
current:      <snapshot A>  retirement: <date>   reason: deprecation notice
candidate:    <snapshot B>  parameters changed: <list>
evidence:     full suite x3 samples; paired diff <x> [CI]; slices; must-pass 3/3
latency/tokens: TTFT p95 <a> -> <b>; tokens per resolved request <c> -> <d>
rollout:      canary 5% / 25% / 100%, 24 h holds; rollback: <snapshot A>
approvals:    product owner; ModelOps (third-party approval); security if tools
```

### Deep comparisons

| Choice | Prefer the first when | Prefer the second when | Cost of the second |
|---|---|---|---|
| Workflow or agent | The steps are known in advance | The path depends on what the model finds | Variable cost; trial-based evals; limits |
| Hosted API or self-hosted model | Quality matters most and data may leave | Weights, residency or unit cost must be yours | GPU operations, capacity, patching ([MLOps](../mlops/)) |

### FAQ

::: answer We only call a provider's API. Do we need LLMOps?
Yes, in proportion: you own the prompt, retrieval, tools, releases and answers. You need fewer GPU skills, not fewer gates.
:::

::: answer Is LLMOps just MLOps for LLMs?
Partly: versioning, gates and monitoring carry over ("you don't need to start over").[^ms-genaiops] New: the release includes
prompts, retrieval and tools; quality is open-ended; the model changes on someone else's schedule.
:::

::: answer How many eval cases do we need?
Enough to detect the regression you care about: Lab 1's 24 cases gave a 25-point interval. Run a power analysis,[^miller-errorbars]
and use slice and must-pass rules for what statistics cannot resolve.
:::

::: answer Can an LLM judge replace human review?
For volume, once calibrated on held-out labels; for ground truth, no. Keep expert labels and audit the judge.[^hamel-judge]
:::

::: answer Do guardrail products stop prompt injection?
They reduce it; fool-proof prevention may not exist.[^owasp-llm01] Limit what injected text can trigger; classifiers are an
extra layer.
:::

## Going further

### Advanced topics and emerging direction

- **Agent telemetry and reliability** (in development): OpenTelemetry's agent metrics are marked Development;[^otel-metrics]
  pass^k was proposed in 2024.[^tau-bench]
- **Vendor consolidation** (facts, not a trend claim): promptfoo says it is now part of OpenAI;[^promptfoo-gh] Microsoft retires
  Prompt flow on 2027-04-20.[^ms-maturity] Keep eval suites portable.

### Learning path

- **Prerequisites:** as in [At a glance](#at-a-glance), plus Python.
- **One day:** the first two parts; failure categories for 50 real outputs; Lab 1.
- **One week:** Labs 2 and 3; the OWASP lists; traces for one feature.
- **30 days:** a gated, canaried release with a calibrated judge; one rehearsed rollback.
- **Advanced:** agent task suites, design patterns, eval statistics; [FinOps for AI](../finops-for-ai/), [ModelOps](../modelops/).
- **Portfolio:** an eval suite, gate report and permission test for an open-source RAG demo.

## Wrap-up

### Executive recap and cheat sheet

**Decide** whether an LLM fits, then prompt, retrieval or fine-tuning, then workflow or agent. **Practise** pinned snapshots,
gated releases, judge calibration, permission-filtered retrieval, approved writes. **Measure** pass rate by slice, unsupported
claims, recall@k, latency. **Next:** pick one feature, write its must-pass cases, pin its model, run Lab 1 on its next change.

### A judgement call: may the assistant issue small refunds on its own?

**Situation (illustrative).** The assistant reads customer e-mails and order data and drafts refund requests. **Option A:**
it issues refunds up to €50 on its own: fast, fewer tickets. **Option B:** a person approves every refund.

**Assumptions.** E-mails are untrusted; refunds move money; abuse surfaces late.

**Evidence.** Injected instructions can steer applications that read external text;[^greshake] OWASP asks for approval of
high-impact actions;[^owasp-llm06] untrusted input must not "trigger any consequential actions".[^design-patterns]

**Why A can be wrong.** The model that read the e-mail decides whether to pay; a small limit per refund is not a small limit per
attacker. **Why B can be wrong.** Approval queues become rubber stamps and waste staff time on refunds a rule could decide.

**Decision process.** Separate reading from deciding, as in the containment figure: a quarantined model extracts order ID,
reason code and amount; a deterministic policy (late delivery, within 30 days, under €50, good standing, one per month) decides;
everything else goes to a person. Shadow staff decisions for four weeks, then enable it with a monthly audit sample.

**Uncertainty.** Extraction can still be manipulated (a false "late delivery" reason), so the policy's checks against order
data carry the risk.

### Learning objectives and mastery check

You can now: define an LLM release; gate non-deterministic outputs; calibrate a judge; test retrieval for leaks; contain
injection; bound and evaluate an agent; run a migration.

::: answer 1. A teammate edits one sentence of the system prompt and wants to skip the gate "because the model didn't change". Respond.
A prompt edit is a new release of the tuple, and small prompt changes can change results sharply.[^huyen-2023] Same gate.
:::

::: answer 2. In Lab 1 the average fell by only 2.8 points. Why block?
The interval allows a 15-point drop, one slice fell 33 points, and a must-pass case failed once in three samples.
:::

::: answer 3. A judge agrees with your expert 92% of the time, but its TNR is 0.30. Use it?
No: it passes most bad answers, which agreement hides when failures are rare. Revise it on the dev set, then measure it once on a
held-out test set.
:::

::: answer 4. A new index raises recall from 0.69 to 0.94 and leaks one finance document. Ship it?
No. Fix the filter in the retrieval query and re-run; a permission failure blocks regardless of quality.
:::

::: answer 5. An agent reads web pages, holds the user's mailbox token and can send e-mail. Which risk, and what fix?
The lethal trifecta.[^willison-trifecta] Remove a leg: approval before sending, no private data in the browsing step, or
quarantined web text with only typed fields reaching the planner. Add a step limit and a kill switch.
:::

### Conclusion

LLMOps turns a convincing demo into a feature you can keep running. Its core is a habit, not a platform: treat the prompt, the
rented model, the documents and the tools as one release; let evaluations, not impressions, decide what ships; and assume the
provider, the corpus and attackers will change things you did not touch.

The label may fade into "GenAIOps" or "AI engineering". The work will not: measure answers as carefully as uptime, give untrusted
text no power to act, and keep a tested way back.

## Sources and further reading

All sources were opened on 2026-10-17 (ledger: `research/llmops-ledger.md`). **Re-check quarterly:** deprecation pages,
OpenTelemetry status and names, tool licences and ownership, promptfoo and LiteLLM options.

[^techtarget]: [What is LLMOps? Lifecycle, benefits and challenges](https://www.techtarget.com/searchenterpriseai/definition/large-language-model-operations-LLMOps) — L. Craig, TechTarget, 2024-07-01. Checked 2026-10-17.
[^gc-llmops]: [What is LLMOps (large language model operations)?](https://cloud.google.com/discover/what-is-llmops) — Google Cloud, undated. Checked 2026-10-17.
[^ms-genaiops]: [Generative AI Operations for Organizations with MLOps Investments](https://learn.microsoft.com/en-us/azure/architecture/ai-ml/guide/genaiops-for-mlops) — Microsoft Learn, 2025-09-16. Checked 2026-10-17.
[^ms-maturity]: [Advance your maturity level for GenAIOps](https://learn.microsoft.com/en-us/azure/machine-learning/prompt-flow/concept-llmops-maturity) — Microsoft Learn, 2026-08-28. Checked 2026-10-17.
[^ms-llmops-blog]: [Achieve generative AI operational excellence with the LLMOps maturity model](https://azure.microsoft.com/en-us/blog/achieve-generative-ai-operational-excellence-with-the-llmops-maturity-model/) — D. Seda, Microsoft Azure Blog, 2024-01-30. Checked 2026-10-17.
[^splitech]: [Large Language Model Operations (LLMOps): Definition, Challenges, and Lifecycle Management](https://scienceportal.tecnalia.com/en/publications/large-language-model-operations-llmops-definition-challenges-and-/) — J. Diaz-De-Arcaya et al., SpliTech 2024 (IEEE). Checked 2026-10-17.
[^huyen-2023]: [Building LLM applications for production](https://huyenchip.com/2023/04/11/llm-engineering.html) — C. Huyen, 2023-04-11. Checked 2026-10-17.
[^huyen-aie]: [AI Engineering book repository](https://github.com/chiphuyen/aie-book) — C. Huyen, GitHub (book: O'Reilly, 2025). Checked 2026-10-17.
[^agentops]: [AgentOps: Enabling Observability of LLM Agents](https://arxiv.org/abs/2411.05285) — L. Dong, Q. Lu, L. Zhu, arXiv, 2024. Checked 2026-10-17.
[^nyc-markup]: [NYC's AI Chatbot Tells Businesses to Break the Law](https://themarkup.org/news/2024/03/29/nycs-ai-chatbot-tells-businesses-to-break-the-law) — C. Lecher, The Markup, 2024-03-29. Checked 2026-10-17.
[^linkedin]: [Musings on Building a Generative AI Product](https://www.linkedin.com/blog/engineering/generative-ai/musings-on-building-a-generative-ai-product) — J. P. Bottaro, LinkedIn Engineering, 2024-04-25. Checked 2026-10-17.
[^chen-drift]: [How is ChatGPT's behavior changing over time?](https://arxiv.org/abs/2307.09009) — L. Chen, M. Zaharia, J. Zou, arXiv, 2023. Checked 2026-10-17.
[^greshake]: [Not what you've signed up for: Compromising Real-World LLM-Integrated Applications with Indirect Prompt Injection](https://arxiv.org/abs/2302.12173) — K. Greshake et al., arXiv, 2023. Checked 2026-10-17.
[^owasp-llm01]: [LLM01:2025 Prompt Injection](https://genai.owasp.org/llmrisk/llm01-prompt-injection/) — OWASP, 2025. Checked 2026-10-17.
[^owasp-top10]: [OWASP Top 10 for LLM Applications 2025](https://genai.owasp.org/llm-top-10/) — OWASP, 2025 edition (announced 2024-11). Checked 2026-10-17.
[^owasp-llm06]: [LLM06:2025 Excessive Agency](https://genai.owasp.org/llmrisk/llm062025-excessive-agency/) — OWASP, 2025. Checked 2026-10-17.
[^owasp-llm08]: [LLM08:2025 Vector and Embedding Weaknesses](https://genai.owasp.org/llmrisk/llm082025-vector-and-embedding-weaknesses/) — OWASP, 2025. Checked 2026-10-17.
[^owasp-llm10]: [LLM10:2025 Unbounded Consumption](https://genai.owasp.org/llmrisk/llm102025-unbounded-consumption/) — OWASP, 2025. Checked 2026-10-17.
[^owasp-agentic]: [OWASP Top 10 for Agentic Applications for 2026](https://genai.owasp.org/resource/owasp-top-10-for-agentic-applications-for-2026/) — OWASP, 2025-12-09. Checked 2026-10-17.
[^air-canada]: [Moffatt v. Air Canada, 2024 BCCRT 149](https://decisions.civilresolutionbc.ca/crt/crtd/en/525448/1/document.do) — Civil Resolution Tribunal of British Columbia (small claims), 2024-02-14. Checked 2026-10-17.
[^lewis-rag]: [Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks](https://arxiv.org/abs/2005.11401) — P. Lewis et al., NeurIPS 2020. Checked 2026-10-17.
[^zheng-judge]: [Judging LLM-as-a-Judge with MT-Bench and Chatbot Arena](https://arxiv.org/abs/2306.05685) — L. Zheng et al., NeurIPS 2023. Checked 2026-10-17.
[^nist-600-1]: [NIST AI 600-1, AI Risk Management Framework: Generative Artificial Intelligence Profile](https://www.nist.gov/publications/artificial-intelligence-risk-management-framework-generative-artificial-intelligence) — NIST, 2024-07-26. Checked 2026-10-17.
[^anthropic-agents]: [Building effective agents](https://www.anthropic.com/engineering/building-effective-agents) — Anthropic, 2024-12-19. Checked 2026-10-17.
[^anthropic-agent-evals]: [Demystifying evals for AI agents](https://www.anthropic.com/engineering/demystifying-evals-for-ai-agents) — M. Grace et al., Anthropic, 2026-01-09. Checked 2026-10-17.
[^tau-bench]: [τ-bench: A Benchmark for Tool-Agent-User Interaction in Real-World Domains](https://arxiv.org/abs/2406.12045) — S. Yao et al., arXiv, 2024. Checked 2026-10-17.
[^mcp-security]: [Security Best Practices](https://modelcontextprotocol.io/specification/2025-11-25/basic/security_best_practices) — Model Context Protocol specification 2025-11-25. Checked 2026-10-17.
[^design-patterns]: [Design Patterns for Securing LLM Agents against Prompt Injections](https://arxiv.org/abs/2506.08837) — L. Beurer-Kellner et al., arXiv, 2025. Checked 2026-10-17.
[^otel-genai]: [Semantic conventions for generative client AI spans](https://github.com/open-telemetry/semantic-conventions-genai/blob/f49da22056925ae9ba78ec5dd7fe1fa559adce05/docs/gen-ai/gen-ai-spans.md) — OpenTelemetry, Development, commit `f49da22`. Checked 2026-10-17.
[^otel-client]: [Semantic conventions for generative AI client inference](https://github.com/open-telemetry/semantic-conventions-genai/blob/f49da22056925ae9ba78ec5dd7fe1fa559adce05/docs/gen-ai/client-inference.md) — OpenTelemetry, Development, commit `f49da22`. Checked 2026-10-17.
[^otel-metrics]: [Semantic conventions for generative AI metrics](https://github.com/open-telemetry/semantic-conventions-genai/blob/f49da22056925ae9ba78ec5dd7fe1fa559adce05/docs/gen-ai/gen-ai-metrics.md) — OpenTelemetry, Development, commit `f49da22`. Checked 2026-10-17.
[^anthropic-models]: [Models overview](https://platform.claude.com/docs/en/docs/about-claude/models/overview) — Anthropic docs, undated. Checked 2026-10-17.
[^anthropic-hallucinations]: [Reduce hallucinations](https://platform.claude.com/docs/en/test-and-evaluate/strengthen-guardrails/reduce-hallucinations) — Anthropic docs, undated. Checked 2026-10-18.
[^anthropic-deprecations]: [Model deprecations](https://platform.claude.com/docs/en/docs/about-claude/model-deprecations) — Anthropic docs, latest entry 2026-09-30. Checked 2026-10-17.
[^openai-deprecations]: [Deprecations](https://developers.openai.com/api/docs/deprecations) — OpenAI API docs, latest entry 2026-10-14. Checked 2026-10-17.
[^thinking-machines]: [Defeating Nondeterminism in LLM Inference](https://thinkingmachines.ai/blog/defeating-nondeterminism-in-llm-inference/) — H. He, Thinking Machines Lab, 2025-09-10. Checked 2026-10-17.
[^mlflow-prompts]: [Prompt Registry](https://mlflow.org/docs/latest/genai/prompt-registry/) — MLflow docs. Checked 2026-10-17.
[^mlflow-pypi]: [mlflow 3.17.1](https://pypi.org/project/mlflow/) — PyPI, 2026-10-13. Checked 2026-10-17.
[^nemo-guardrails]: [NeMo Guardrails](https://github.com/NVIDIA-NeMo/Guardrails) — NVIDIA, GitHub, 0.24.1. Checked 2026-10-17.
[^promptfoo-asserts]: [Assertions and Metrics](https://www.promptfoo.dev/docs/configuration/expected-outputs/) — promptfoo docs. Checked 2026-10-17.
[^promptfoo-cli]: [Command line](https://www.promptfoo.dev/docs/usage/command-line/) — promptfoo docs. Checked 2026-10-17.
[^promptfoo-telemetry]: [Telemetry](https://www.promptfoo.dev/docs/configuration/telemetry/) — promptfoo docs. Checked 2026-10-17.
[^promptfoo-types]: [src/types/index.ts at 0.124.1](https://github.com/promptfoo/promptfoo/blob/0.124.1/src/types/index.ts) — promptfoo source, GitHub. Checked 2026-10-18.
[^promptfoo-datasets]: [Dataset generation](https://www.promptfoo.dev/docs/configuration/datasets/) — promptfoo docs. Checked 2026-10-17.
[^promptfoo-gh]: [promptfoo repository](https://github.com/promptfoo/promptfoo) — GitHub README. Checked 2026-10-17.
[^hamel-evals]: [Your AI Product Needs Evals](https://hamel.dev/blog/posts/evals/) — H. Husain, 2024-03-29. Checked 2026-10-17.
[^hamel-judge]: [Using LLM-as-a-Judge For Evaluation: A Complete Guide](https://hamel.dev/blog/posts/llm-judge/) — H. Husain, 2024-10-29, modified 2026-09-01. Checked 2026-10-17.
[^hamel-automate]: [Q: What parts of evals can be automated with LLMs?](https://hamel.dev/blog/posts/evals-faq/what-parts-of-evals-can-be-automated-with-llms.html) — H. Husain, S. Shankar, 2025-07-24. Checked 2026-10-17.
[^shankar-validators]: [Who Validates the Validators? Aligning LLM-Assisted Evaluation of LLM Outputs with Human Preferences](https://arxiv.org/abs/2404.12272) — S. Shankar et al., arXiv, 2024. Checked 2026-10-17.
[^miller-errorbars]: [Adding Error Bars to Evals: A Statistical Approach to Language Model Evaluations](https://arxiv.org/abs/2411.00640) — E. Miller, arXiv, 2024. Checked 2026-10-17.
[^github-copilot]: [How we evaluate AI models and LLMs for GitHub Copilot](https://github.blog/ai-and-ml/generative-ai/how-we-evaluate-models-for-github-copilot/) — C. Adams, K. Finley, GitHub Blog, 2025-01-17. Checked 2026-10-17.
[^liu-lost]: [Lost in the Middle: How Language Models Use Long Contexts](https://arxiv.org/abs/2307.03172) — N. F. Liu et al., TACL, 2023. Checked 2026-10-17.
[^pgvector]: [pgvector](https://github.com/pgvector/pgvector) — GitHub README, 0.8.7. Checked 2026-10-17.
[^litellm-routing]: [Router - Load Balancing](https://docs.litellm.ai/docs/routing) — LiteLLM docs. Checked 2026-10-18.
[^litellm-fallbacks]: [Fallbacks (Provider Failover)](https://docs.litellm.ai/docs/proxy/reliability) — LiteLLM docs. Checked 2026-10-17.
[^litellm-incident]: [Security Update: Suspected Supply Chain Incident](https://docs.litellm.ai/blog/security-update-march-2026) — LiteLLM, 2026-03-24 (updated 2026-03-30). Checked 2026-10-17.
[^routellm]: [RouteLLM: Learning to Route LLMs with Preference Data](https://arxiv.org/abs/2406.18665) — I. Ong et al., arXiv, 2024. Checked 2026-10-17.
[^openai-accuracy]: [Optimizing LLM Accuracy](https://developers.openai.com/api/docs/guides/optimizing-llm-accuracy) — OpenAI API docs. Checked 2026-10-17.
[^openai-caching]: [Prompt caching](https://developers.openai.com/api/docs/guides/prompt-caching) — OpenAI API docs. Checked 2026-10-17.
[^ovadia]: [Fine-Tuning or Retrieval? Comparing Knowledge Injection in LLMs](https://arxiv.org/abs/2312.05934) — O. Ovadia et al., arXiv, 2023. Checked 2026-10-17.
[^langfuse]: [Langfuse](https://github.com/langfuse/langfuse) — GitHub README. Checked 2026-10-17.
[^phoenix]: [Arize Phoenix](https://github.com/Arize-ai/phoenix) — GitHub README. Checked 2026-10-17.
[^slack-promptarmor]: [Data Exfiltration from Slack AI via indirect prompt injection](https://promptarmor.substack.com/p/data-exfiltration-from-slack-ai-via) — PromptArmor, 2024-08-20. Checked 2026-10-17.
[^dpd-aiid]: [DPD error caused chatbot to swear at customer](https://incidentdatabase.ai/reports/3605) — BBC News via AI Incident Database, incident 631 (2024-01-18). Checked 2026-10-17.
[^willison-trifecta]: [The lethal trifecta for AI agents: private data, untrusted content, and external communication](https://simonwillison.net/2025/Jun/16/the-lethal-trifecta/) — S. Willison, 2025-06-16. Checked 2026-10-17.

**Changelog**
- 2026-10-17, r0: first draft; Labs 1–3 executed (Python 3.8.10); Lab 4 and the reference implementation syntax-checked only.
- 2026-10-17, r1: fact check `reviews/llmops-r1.md`: architecture and containment figures redrawn; Lab 4 gated by Lab 1 via a
  tested converter; held-out judge calibration; agent and trace sections; notice, timeline, TTFT and temperature-0 fixes; cuts.
- 2026-10-18, r2: score `reviews/llmops-score.md`: hallucination habit, gateway route, sourced LLM-assisted evals, wording
  fixes, simpler figures, pinned OpenTelemetry links.
- 2026-10-18, post-score polish: `reviews/llmops-polish.md`: CI key scope, two wording fixes, LiteLLM session affinity,
  converter checks, Figures 2–3.
