AI EngineeringZero to ProductionHome·About·Contact
Engineering Handbook

Building Production-Grade LLM, RAG & Agentic Systems

A full-stack, step-by-step reference for taking large-language-model applications from prototype to reliable, observable, secure production — covering serving, retrieval, agents, evaluation, guardrails, cost and deployment.

🧭 14 chapters⚙️ Step-by-step implementations🔬 Eval & observability🛡️ Guardrails & security💸 Cost & scaling

1 Foundations & the mental model

Understand the tiers before you build. Most failures come from reaching for a heavier tier than the task needs.

Every LLM application is one of four tiers. Choose the simplest tier that solves the problem — complexity is a cost you pay in latency, money, and debuggability.

Tier 1 — Single call

One request → one response. Classification, summarization, extraction, Q&A. Deterministic, cheap, trivially testable.

Tier 2 — Workflow

Multi-step pipeline where your code owns the control flow. Predictable, easy to trace. RAG lives here.

Tier 3 — Agent (tools)

The model decides which tools to call and when, inside a loop you host. Flexible; needs guardrails.

Tier 4 — Managed / autonomous

Server-managed stateful agents with a sandboxed workspace. Long-horizon, high-value tasks.

The four-question gate before building an agent

Only climb to Tier 3+ when all four hold:

  • Complexity — is the task genuinely multi-step and hard to fully specify up front?
  • Value — does the outcome justify higher cost and latency?
  • Viability — is the model actually capable at this task type?
  • Cost of error — can mistakes be caught and recovered (tests, review, rollback)?
REQUEST FLOW — every production LLM app Client / APIgateway + auth Guardrails INvalidate + PII OrchestrationRAG / agent loop LLMprovider GuardrailsOUT Vector DBretrieval Tools / APIsactions Cacheprompt + semantic Observability & tracing wrap every box · logs · metrics · cost
The anatomy shared by every production LLM system, regardless of tier.
🗺️ How to read this diagram

This is the master map of the whole handbook: the parts that every production LLM app is built from, and the order a request travels through them. Read it left to right along the top row — that is the journey of one user request.

  • Follow the top row: a request enters at Client / API (the front door, which checks who you are), passes through Guardrails IN (safety checks on the way in — validating input, stripping personal data), reaches Orchestration (the brain that runs your RAG or agent logic), which calls the LLM (the language model itself), and the reply leaves through Guardrails OUT (safety checks on the way out).
  • The blue arrows are the request moving forward from one box to the next — each box hands its output to the box on its right.
  • The bottom row (Vector DB, Tools / APIs, Cache) are helpers the Orchestration box reaches down to when it needs them: a Vector DB to look up your documents, Tools / APIs to take actions, and a Cache to reuse past work and save money. The teal arrows pointing down show Orchestration calling these helpers.
  • The two coloured boxes (Orchestration and LLM) are highlighted because they are the heart of the system — the code you write and the model it drives.
  • The line along the bottom — "Observability & tracing wrap every box" — means logging and monitoring surround all of these stages, not just one.

In short: Every LLM product, however fancy, is this same pipeline: check the input, do the work (maybe looking things up), call the model, check the output. Learn this shape once and every later chapter is just one box drawn in more detail.

Guiding principleShip the thinnest thing that works, instrument it heavily, then let real failure modes — not speculation — pull you up the tiers.

2 The LLM serving layer

The single API call is the atom of everything above it. Get it robust first.

2.1 Model selection

Match the model tier to the workload, not the ambition. A blended fleet is normal: a capable model for reasoning-heavy paths, a fast/cheap model for classification and high-volume routing.

WorkloadModel classWhy
Reasoning, agents, code, long-horizonFrontier (e.g. claude-opus-4-8)Highest capability & tool-use reliability
Balanced high-volume productionMid (e.g. claude-sonnet-4-6)Best speed/intelligence/cost balance
Classification, routing, simple extractionSmall/fast (e.g. claude-haiku-4-5)Latency- and cost-critical

2.2 A production-grade client wrapper

Never call the raw SDK from business logic. Wrap it once with retries, timeouts, streaming, structured logging and cost accounting.

  1. Resolve credentials from the environment — never hardcode keys. Use env vars or a secrets manager.
  2. Set explicit timeouts & retries. SDKs retry 429/5xx/connection errors with backoff by default (2 retries) — tune per route.
  3. Stream anything with large output. Non-streaming requests risk HTTP timeouts above ~16K output tokens.
  4. Emit a request ID + token usage on every call for tracing and cost dashboards.
  5. Handle the refusal / max_tokens stop reasons explicitly — don't assume content[0] is text.

Requires: pip install anthropic

# llm_client.py — the only place raw SDK calls live
import anthropic, logging, time
from anthropic import Anthropic

log = logging.getLogger("llm")
client = Anthropic()  # reads ANTHROPIC_API_KEY from env

def complete(messages, *, system=None, model="claude-opus-4-8",
             max_tokens=4096, effort="high", tools=None):
    t0 = time.monotonic()
    with client.messages.stream(         # stream = timeout-safe
        model=model, max_tokens=max_tokens, system=system,
        messages=messages, tools=tools or [],
        thinking={"type": "adaptive"},   # let the model decide depth
        output_config={"effort": effort},  # low|medium|high|max
    ) as stream:
        msg = stream.get_final_message()

    if msg.stop_reason == "refusal":
        log.warning("refusal req=%s cat=%s", msg._request_id,
                    msg.stop_details.category if msg.stop_details else None)
        raise RefusalError(msg)

    log.info("req=%s in=%d out=%d cache_read=%d ms=%d",
        msg._request_id, msg.usage.input_tokens, msg.usage.output_tokens,
        msg.usage.cache_read_input_tokens, (time.monotonic()-t0)*1000)
    return msg
▶ How this works

This is the one wrapper function every other chapter calls to talk to the model. Instead of sprinkling raw model calls all over your app, you funnel them through a single hardened complete() so retries, logging, and cost tracking live in exactly one place.

  1. client = Anthropic() creates the object that talks to the model; it reads your secret API key from an environment variable, so the key never appears in the code.
  2. with client.messages.stream(...) sends the request in streaming mode — the reply arrives in pieces. The comment "stream = timeout-safe" is why: a long non-streaming reply can hit a network timeout, whereas a stream keeps the connection alive.
  3. thinking={"type":"adaptive"} lets the model decide how hard to think, and output_config={"effort": effort} is the dial (lowmax) trading answer quality against cost and speed.
  4. if msg.stop_reason == "refusal" checks why the model stopped. If it declined, we log it and raise a clear error instead of pretending we got an answer.
  5. The final log.info(...) records the request ID, how many tokens went in and out, cache hits, and how long it took — the raw material for cost dashboards and debugging.

What the output means: Nothing prints for a user; instead a structured log line is emitted per call (request id, input/output token counts, cache reads, milliseconds) and the full reply object is returned to the caller.

Try this: Notice there is no bare except: — it handles the named refusal case explicitly. In production you always want to know exactly which kind of failure happened.

Adaptive thinking, not budget_tokensOn current models use thinking:{type:"adaptive"} and control depth via output_config.effort. The old fixed budget_tokens is deprecated (and rejected on the newest models).

2.3 Resilience patterns

Timeout budget

Set a per-request wall-clock budget. Timeouts are retried, so worst case ≈ timeout × (retries+1). Plan for it.

Circuit breaker

Trip on sustained 5xx/529; fall back to a cheaper model or a cached/canned answer rather than hanging.

Idempotency

Key expensive calls by input hash so retries and duplicate submits don't double-bill or double-act.

Graceful degradation

Overloaded? Downgrade model tier, shrink context, or serve a deterministic fallback — never a spinner forever.

3 Prompt engineering & context management

The prompt is code. Version it, test it, and keep its structure cache-friendly.

3.1 Structure a system prompt

  1. Role & objective — who the model is and what "done" means.
  2. Operating constraints — tone, length, formatting, what it must never do.
  3. Tools & when to use them — be prescriptive about when to call, not just what a tool does.
  4. Few-shot examples — positive examples of the desired output beat lists of prohibitions.
  5. Dynamic context last — retrieved docs / user data go after the frozen prefix (see caching).
Modern-model promptingNewer models follow instructions literally. Prompts written to overcome older models' reluctance ("CRITICAL: YOU MUST…") now over-trigger. Soften to "Use X when…". State the reason behind a request — the model uses intent to act correctly.

3.2 Prompt versioning & registry

Treat prompts as deployable artifacts, not string literals buried in code.

  • Store prompts in a versioned file/registry with an ID and semver.
  • Log the prompt version on every request so you can attribute quality regressions.
  • Roll out prompt changes behind the same flags you'd use for code (canary %, A/B).

3.3 Context-window management

Context is finite and the front of it is the most expensive to change. Three levers for long-running sessions:

TechniqueWhat it doesUse when
Context editingPrunes stale tool results / thinking blocks (removes, doesn't summarize)Old tool output no longer relevant
CompactionSummarizes earlier history server-side into a compaction blockConversation approaches the window limit
MemoryPersists state to files across sessionsState must survive process restarts
Compaction gotchaWhen compaction is on, append the whole response.content back to your messages each turn — the compaction block carries the summarized state. Appending only the text silently loses it.

4 RAG pipelines — the full build

Retrieval-Augmented Generation grounds the model in your data. Most "hallucination" bugs are actually retrieval bugs.

INGESTION (offline / batch) Sources Parse &clean Chunk Embed+ metadata Vector DB QUERY (online / per-request) User query Rewrite /expand Hybridvector + BM25 Re-rank Assemblecontext LLMgrounded Answer returned with citations → evaluated for groundedness & relevance Each stage is independently measurable. Instrument retrieval recall separately from answer quality.
Two-phase RAG: offline ingestion builds the index; online query retrieves, re-ranks, and grounds the answer.
🗺️ How to read this diagram

RAG means Retrieval-Augmented Generation: before the model answers, you fetch relevant snippets from your documents and hand them over, so the answer is grounded in real data instead of guessed. The diagram has two rows separated by a dashed line, and the trick is that they happen at different times.

  • The top row (INGESTION) happens ahead of time, in batch — think of it as building a library index once. Read it left to right: Sources (your raw documents) → Parse & clean (tidy them up) → Chunk (cut them into bite-sized passages) → Embed (turn each passage into numbers a computer can search) → store in the Vector DB (the searchable index).
  • The dashed horizontal line separates "done in advance" (top) from "done live, for each question" (bottom).
  • The bottom row (QUERY) runs every time a user asks something: User queryRewrite / expand (clean up the question) → Hybrid search (find matching passages using both meaning and exact keywords) → Re-rank (keep only the best few) → Assemble context (bundle them up) → LLM (which now answers using those passages).
  • The purple dashed arrow connecting the Vector DB (top) down to the Hybrid search (bottom) is the key link: the live question searches the index you built earlier. That is the whole point of the two phases.
  • The note at the bottom — "Each stage is independently measurable" — is the takeaway: if answers are bad, you can test each box separately to find which one (usually retrieval) is at fault.

In short: Most "the AI made something up" bugs are really "the search step fetched the wrong passages." The build order matters: get the top row (a good index) and the search right before you fuss over how the model is prompted.

4.1 Ingestion — build the index

  1. Collect & normalize sources. PDFs, HTML, wikis, tickets. Strip boilerplate; preserve structure (headings, tables) — it carries meaning.
  2. Chunk deliberately. Not fixed 512-token windows blindly. Chunk on semantic boundaries (headings, paragraphs), keep 10–20% overlap, and store a chunk's parent-section as metadata. Target 200–500 tokens for precision; add a "parent-document" fallback for context.
  3. Attach rich metadata. source, title, section, url, updated_at, ACL/tenant tags. Metadata powers filtering and citations.
  4. Embed. Choose an embedding model sized to your latency/quality budget; keep the same model for query and index (mismatched embedders = garbage retrieval).
  5. Upsert into a vector store with the vector, the raw text, and metadata. Make ingestion idempotent (dedupe by content hash) so re-runs don't duplicate.
The #1 RAG bugChunking that splits a fact across two chunks, so neither chunk alone answers the question. Test retrieval with real questions before touching the generation prompt.

4.2 Retrieval — find the right context

  • Hybrid search. Combine dense (vector) with sparse (BM25/keyword). Dense catches semantics; sparse catches exact terms, codes, names. Fuse with Reciprocal Rank Fusion.
  • Query transformation. Rewrite conversational queries into standalone search queries; optionally expand into multiple sub-queries for multi-hop questions.
  • Metadata filtering. Always scope by tenant/ACL before semantic search — never rely on the model to respect permissions.
  • Re-ranking. Over-fetch (e.g. top-30) with cheap retrieval, then re-rank to top-5 with a cross-encoder or an LLM. This is the single highest-leverage quality lever.

4.3 Generation — ground the answer

Setup to run this snippet
class _Any:
    '''stands in for any undefined demo value; supports call/attr/index/
    iteration and basic arithmetic (as 0.7) so demo snippets run.'''
    def __call__(self, *a, **k): return _Any()
    def __getattr__(self, k): return _Any()
    def __getitem__(self, k): return _Any()
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __contains__(self, o): return True
    def __enter__(self, *a): return _Any()
    def __exit__(self, *a): return False
    def __float__(self): return 0.7
    def __int__(self): return 1
    def __lt__(self, o): return True
    def __gt__(self, o): return False
    def __le__(self, o): return True
    def __ge__(self, o): return False
    def __add__(self, o): return o
    def __radd__(self, o): return o
    def __bool__(self): return True
    def __repr__(self): return 'demo'
    def __str__(self): return 'demo'
def complete(*a, **k):  # demo stub
    return _Any()
query = _Any()
reranked_chunks = _Any()
# Assemble retrieved chunks into a grounded prompt with citations
context = "\n\n".join(
    f"[{i}] (source: {c['title']} — {c['url']})\n{c['text']}"
    for i, c in enumerate(reranked_chunks, 1)
)

system = (
    "Answer ONLY from the numbered context below. "
    "Cite sources inline as [n]. If the context does not contain "
    "the answer, say you don't know — do not use outside knowledge."
)

msg = complete(
    system=system,
    messages=[{"role":"user",
               "content": f"Context:\n{context}\n\nQuestion: {query}"}],
    effort="medium",
)
▶ How this works

This is the heart of RAG's generation step: after search has found the best passages, you paste them into the prompt and firmly tell the model to answer only from them. That instruction is what keeps the answer grounded in your data.

  1. The first block builds context by numbering each retrieved chunk ([1], [2]…) and tagging it with its title and URL, so the model can cite exactly where a fact came from.
  2. The system string is the rulebook: answer only from the numbered context, cite sources as [n], and if the answer isn't there, say you don't know. That last rule is what prevents the model from inventing an answer.
  3. complete(...) is the wrapper from the serving-layer chapter; the user message stitches the context and the question together into one prompt.
  4. effort="medium" picks a moderate amount of reasoning — grounded answering is usually not the hardest task, so you don't pay for maximum effort.

What the output means: A grounded answer that quotes its sources inline as [1], [2], etc. — or an honest "I don't know" when the retrieved passages don't cover the question.

Try this: The whole reliability of RAG hinges on two words here: ONLY (don't use outside knowledge) and the instruction to admit ignorance. Remove them and the model will confidently fill gaps with guesses.

Native citationsMany providers expose a built-in citations mode that returns cited spans with character/page offsets — prefer it over prompt-engineered [n] markers when available: it's verifiable and machine-parseable.

4.4 Advanced RAG patterns

Parent-document retrieval

Search on small precise chunks, but feed the model the larger parent section for context.

Contextual chunk headers

Prepend a doc/section summary to each chunk before embedding so isolated chunks stay self-describing.

Multi-hop / agentic RAG

Let the model issue follow-up retrievals when the first pass is insufficient (Tier-3 territory).

Graph RAG

Add an entity/relationship graph for questions that require connecting facts across documents.

4.5 What to measure

StageMetricDefinition
RetrievalRecall@k / MRRDid we fetch the chunk that contains the answer?
RetrievalContext precisionWhat fraction of retrieved chunks are actually relevant?
GenerationGroundedness / faithfulnessIs every claim supported by the retrieved context?
GenerationAnswer relevanceDoes the answer address the question asked?

5 Agents & tool use

An agent is a loop: model proposes a tool call → your harness executes it → result goes back → repeat until done.

5.1 Designing the tool surface

The shape of your tools decides what your harness can control. A bash tool gives breadth but an opaque command string; a dedicated typed tool gives the harness a hook to gate, render, audit, and parallelize.

Rule of thumbStart with broad tools for reach. Promote an action to a dedicated typed tool when you need to gate it (irreversible actions), validate it (staleness checks), render it (custom UI), or parallelize it (mark read-only tools parallel-safe).

5.2 A manual agentic loop

Use the manual loop (over the SDK's auto tool-runner) when you need approval gates, custom logging, or conditional execution.

Uses the client / objects set up earlier in this lesson, and needs ANTHROPIC_API_KEY set. Run the earlier blocks first.

messages = [{"role":"user", "content": user_input}]
for _ in range(MAX_STEPS):        # hard cap — never an unbounded loop
    resp = client.messages.create(model="claude-opus-4-8",
             max_tokens=4096, tools=TOOLS, messages=messages)

    if resp.stop_reason == "end_turn":
        break

    messages.append({"role":"assistant", "content": resp.content})
    results = []
    for block in resp.content:
        if block.type == "tool_use":
            if is_destructive(block.name):     # human-in-the-loop gate
                if not approve(block): continue
            out = execute_tool(block.name, block.input)  # validate inside!
            results.append({"type":"tool_result",
                            "tool_use_id": block.id, "content": out})
    messages.append({"role":"user", "content": results})  # all results, one message
▶ How this works

An "agent" is just a loop: the model asks to use a tool, your code runs it, the result goes back, and it repeats until the job is done. This snippet is that loop, written by hand so you keep control of every step.

  1. for _ in range(MAX_STEPS) is the safety belt — the comment "hard cap — never an unbounded loop" means the agent is forced to stop after a set number of turns so it can't spin forever (and rack up cost) if it gets confused.
  2. Each turn calls the model with the tools it may use. if resp.stop_reason == "end_turn": break exits the loop when the model signals it is finished.
  3. The model's reply is appended to messages, then the code walks its content looking for tool_use blocks — requests to run a specific tool.
  4. if is_destructive(...) pauses for human approval before anything irreversible; execute_tool(...) actually runs the tool (validating its inputs first).
  5. All tool results are collected into one results list and appended as a single message — the comment "all results, one message" matters, because splitting them teaches the model to stop calling tools in parallel.

What the output means: Over several turns the model calls tools, your code runs them and feeds results back, and the loop ends either when the model says it's done or when the step cap is hit.

Try this: The three non-negotiables are all visible here: a step cap, a gate before destructive actions, and validating tool inputs before running them. Treat every tool input as untrusted.

Non-negotiables for agents
  • Hard step cap — every loop must terminate.
  • Tool inputs are untrusted — validate before executing; sandbox anything that runs code or shell.
  • Gate irreversible actions behind confirmation or policy.
  • Return all parallel tool results in one message — splitting them trains the model out of parallel calls.

5.3 Long-running agents

Combine context editing (prune stale turns), compaction (summarize near the limit), and file-based memory (cross-session state). Use tool search when the tool count is large so schemas load on demand instead of bloating the prefix.

6 Structured output & function calling

If a downstream system parses the output, constrain it to a schema. Never regex free text.

6.1 Schema-constrained JSON

Uses the client / objects set up earlier in this lesson, and needs ANTHROPIC_API_KEY set. Run the earlier blocks first.

from pydantic import BaseModel

class Ticket(BaseModel):
    category: str
    priority: str        # enum in the JSON schema
    summary: str
    needs_human: bool

resp = client.messages.parse(          # validates & returns typed object
    model="claude-opus-4-8", max_tokens=1024,
    messages=[{"role":"user", "content": ticket_text}],
    output_format=Ticket,
)
ticket = resp.parsed_output            # a validated Ticket instance
▶ How this works

When another program has to read the model's answer, you don't want a paragraph you have to parse — you want clean, predictable data. This shows how to make the model return a validated object that matches an exact shape you defined.

  1. class Ticket(BaseModel) uses Pydantic to declare the exact fields you want back — category, priority, summary, needs_human — and their types. This is your contract with the model.
  2. client.messages.parse(..., output_format=Ticket) tells the model to produce output matching that shape, and the SDK validates it for you (the comment: "validates & returns typed object").
  3. resp.parsed_output hands you a ready-to-use Ticket object — you can read ticket.priority directly, with no fragile text parsing or regex.

What the output means: A validated Ticket Python object whose fields are guaranteed to exist and match the declared types — safe to store in a database or branch on in code.

Try this: The lesson: if a downstream system consumes the output, constrain it to a schema. Defining a small class up front eliminates a whole category of "the model formatted it slightly differently today" bugs.

Best practices
  • Set additionalProperties: false and mark truly-required fields.
  • Use enum for fixed value sets — it eliminates a whole class of parse errors.
  • For tools, strict: true guarantees inputs validate against the schema.
  • Still handle refusal and max_tokens — a truncated response won't match the schema.

6.2 When to use which

NeedUse
Model's final answer must be JSONStructured output (output_config.format)
Model must call your function reliablyTool with strict: true
Classification into fixed labelsEither — enum field or forced tool

7 Evaluation & testing

You cannot improve what you cannot measure. Evals are the CI of LLM systems.

7.1 The evaluation pyramid

  1. Unit-level (deterministic) — assertions on structured output: valid JSON, required fields, enum membership, no PII leak. Fast, run on every commit.
  2. Reference-based — a curated dataset of inputs with known-good outputs. Score with exact-match, F1, or embedding similarity for open-ended answers.
  3. LLM-as-judge — a stronger model grades quality against a rubric (groundedness, helpfulness, tone). For confidence, use a panel with diverse lenses and majority vote.
  4. Human review — sample real traffic; label failures; feed them back into the reference set.
Write gradeable rubrics"Is the answer good?" is unmeasurable. "Does the CSV have a numeric price column per SKU?" is. Judges score explicit criteria independently — vague rubrics produce noisy scores.

7.2 A minimal eval harness

Illustrative fragment — defines demo values / files are needed before this runs standalone.

for case in golden_set:                     # {input, expected, rubric}
    got = run_pipeline(case["input"])
    scores["schema_ok"].append(validate(got))       # deterministic
    scores["grounded"].append(judge(got, case["rubric"]))  # LLM judge
    scores["sim"].append(cosine(embed(got), embed(case["expected"])))

report(scores)   # fail the build if any metric regresses vs. baseline
▶ How this works

This is an eval harness — the automated test suite for an LLM system. It runs a set of known examples through your pipeline and scores the results three different ways, so a quality drop is caught automatically instead of by an unhappy user.

  1. for case in golden_set loops over a curated list of test cases, each with an input, an expected answer, and a rubric (grading criteria).
  2. validate(got) is a deterministic check — is the output well-formed? — the cheapest and most reliable kind of test.
  3. judge(got, rubric) uses a stronger model as an LLM judge to grade fuzzy qualities like groundedness against the rubric.
  4. cosine(embed(got), embed(expected)) measures how similar the answer is to the known-good one, as a number.
  5. report(scores) then does the important part — the comment says it will "fail the build if any metric regresses", i.e. block a release whose quality dropped versus the last known-good baseline, exactly like a failing unit test.

What the output means: A scorecard across all test cases; if any metric falls below the baseline the build fails, stopping a quality regression from shipping.

Try this: This is why the chapter calls evals "the CI of LLM systems." Every production incident should become a new case in the golden_set so the same bug can never ship twice.

7.3 Testing discipline

  • Golden set in version control. Grow it from every production incident.
  • Regression gate. A prompt or model change that drops a metric fails CI, like any test.
  • Offline before online. Prove gains on the eval set before shipping; confirm with online A/B.
  • Cost & latency are metrics too. Track them alongside quality — a 2% quality gain at 3× cost may not ship.

8 Observability

In production you debug from traces, not from reproducing a stochastic bug locally.

8.1 Trace everything

Capture a full trace per request: the rendered prompt, retrieved chunks, every tool call and result, the model's output, token usage, latency per stage, cost, and the resolved prompt/model versions.

Logs

Structured, per-request: request ID, prompt version, model, stop reason, token counts. Redact PII at write time.

Metrics

p50/p95/p99 latency, error rate by type, token throughput, cache-hit rate, cost per request, refusal rate.

Traces

Span tree across retrieval → rerank → LLM → tools. Adopt OpenTelemetry-style semantic conventions for GenAI.

Feedback

Thumbs up/down, edits, escalations. This is your richest online quality signal — wire it to traces.

8.2 The dashboards that matter

SignalAlert when
Error / refusal rateSpikes above baseline — often a prompt or upstream change
p95 latencyBreaches SLO — usually context bloat or a slow tool
Cache-hit rateDrops to ~0 — a silent cache invalidator (timestamp/UUID in prefix)
Cost per requestCreeps up — larger contexts, higher effort, retry storms
Negative feedback rateRises — quality regression; pull failing traces into the eval set

9 Guardrails & safety

Validate on the way in and on the way out. Both are cheap insurance against expensive incidents.

9.1 Input guardrails

  • PII detection & redaction before the prompt reaches the model.
  • Prompt-injection screening — treat retrieved content and tool output as untrusted; never let it silently override system instructions.
  • Rate limiting & abuse detection per user/tenant.
  • Topic / policy filters for out-of-scope or disallowed requests.

9.2 Output guardrails

  • Schema & format validation (covered in §6) — reject malformed output before it reaches users.
  • Groundedness check for RAG — flag or block claims not supported by retrieved context.
  • PII / secret egress scan — don't return data the user isn't entitled to.
  • Content safety classifier on the response.
Prompt injection is the top LLM riskRetrieved documents, web pages, and tool outputs can contain instructions aimed at your model ("ignore previous instructions and…"). Defenses: keep untrusted content in clearly-delimited user-role blocks, use the operator/system channel for real instructions, apply least-privilege to tools, and gate any action the injected text could trigger.

9.3 Human-in-the-loop

For high-stakes actions (financial transactions, sending communications, deleting data), require explicit confirmation. Design the confidence threshold so the system escalates to a human when uncertain rather than guessing.

10 Caching, cost & scaling

Cost is an architecture decision made early, not a knob turned late.

10.1 Prompt caching — the biggest lever

Caching is a prefix match: any byte change anywhere in the prefix invalidates everything after it. Render order is tools → system → messages.

  1. Freeze the prefix. Keep the large stable content (system prompt, tool defs, shared context) first and byte-identical across requests.
  2. Move volatile content to the end. Timestamps, per-request IDs, the user's actual question go after the last cache breakpoint.
  3. Place a breakpoint on the last stable block. Cache reads cost ~10% of base input price.
  4. Verify with cache_read_input_tokens. If it's zero across identical-prefix requests, hunt the silent invalidator (a datetime.now() or unsorted JSON in the prefix).
Silent cache killersdatetime.now() / uuid4() in the system prompt · unsorted json.dumps() · a per-user tool set · conditional system sections. Any of these puts a unique byte in the prefix and drops your hit rate to zero.

10.2 Other cost levers

Model routing

Route simple requests to a small model; reserve the frontier model for hard ones. A cheap classifier decides.

Semantic caching

Cache full answers keyed by query embedding — serve near-duplicate questions without a model call.

Batch API

For non-latency-sensitive jobs (evals, backfills), batch processing runs at ~50% cost.

Right-size effort & max_tokens

Lower effort and tighter max_tokens on simple paths cut spend directly.

10.3 Scaling

  • Respect rate limits (RPM/TPM). Use a token-bucket limiter and queue overflow rather than hammering 429s.
  • Concurrency caps on fan-out — parallel requests with identical prefixes all miss the cache (nothing to read yet); warm it with one request first, then fan out.
  • Async I/O for high-throughput services; stream to keep connections healthy under large outputs.

11 Security

LLM apps inherit every classic app-sec concern plus a few new ones.

Secrets

Keys in a secrets manager, never in code or prompts. Rotate on exposure. Prompts/messages are logged — never embed credentials there.

Least privilege

Scope tool credentials to the minimum. An agent can do anything its keys allow — size the blast radius.

Tenant isolation

Enforce ACLs at retrieval time and in output guardrails. Never trust the model to keep tenants apart.

Sandboxing

Run model-generated code/shell in an isolated, network-restricted environment with resource limits.

Injection defense

Treat all external content as untrusted input (see §9.2). Gate actions injected text could reach.

Data governance

Know your provider's retention policy; use zero-data-retention / regional controls where required by law.

ReferenceMap your review against the OWASP Top 10 for LLM Applications — prompt injection, insecure output handling, supply chain, sensitive-info disclosure, excessive agency, and more.

12 Deployment & LLMOps

Prompts, models, retrieval indices and eval sets are all versioned artifacts in your release process.

  1. CI: run the eval suite. Deterministic checks on every commit; full eval on prompt/model changes. Regression = failed build.
  2. Version everything. Prompt version, model ID, embedding-model version, index snapshot. Log them per request for attribution.
  3. Canary & A/B. Roll new prompts/models to a small % first; compare quality, latency, cost against control.
  4. Feature-flag model swaps. A model change invalidates prompt caches and shifts behavior — roll it like any risky change, with instant rollback.
  5. Migration discipline. When upgrading models, re-baseline token counts and cost, re-tune prompts, and re-run the eval set — behavior shifts even when the API is compatible.
  6. Index refresh pipeline. Schedule re-ingestion; version index snapshots so you can roll back a bad rebuild.
Rollback planKeep the previous prompt version and model ID one flag-flip away. LLM regressions are often subtle and only visible in aggregate feedback — you'll want to revert fast.

13 Reference architectures

Three blueprints assembled from the building blocks above.

13.1 Production RAG assistant

API gateway → input guardrails (PII, injection) → query rewrite → hybrid retrieval (vector + BM25, ACL-filtered) → re-rank → assemble cited context (cache the frozen instruction prefix) → LLM with groundedness output-guard → response + citations. Traced end-to-end; nightly eval on a golden set; index refreshed on a schedule.

13.2 Tool-using agent

Gateway → guardrails → agent loop (hard step cap) with a small typed tool surface; irreversible tools gated by human-in-the-loop; context editing + compaction for long runs; every tool call traced and every input validated/sandboxed. Fall back to a cheaper model under load.

13.3 High-volume classification service

Gateway → small/fast model with a forced tool or enum schema (strict: true); aggressive prompt caching of the frozen instruction block; semantic cache in front for repeat inputs; batch API for backfills. Deterministic evals gate every deploy.

CROSS-CUTTING — present in all three architectures Observability Guardrails Prompt cache Evals in CI Versioning Security / ACL Cost controls Rate limit / retry Canary / rollback
The concerns you build once and reuse across every LLM system you ship.
🗺️ How to read this diagram

The earlier chapters showed three example systems (a RAG assistant, an agent, a classifier). This diagram lists the nine things that appear in all three — the plumbing you build once and reuse everywhere. There is no flow here; it is a checklist of concerns, not a sequence of steps.

  • The top row (purple boxes) are the day-to-day operational concerns: Observability (watching what happens), Guardrails (safety checks), Prompt cache (reusing work to cut cost), Evals in CI (automated quality tests), and Versioning (tracking which prompt/model you shipped).
  • The bottom row (teal boxes) are the protective / reliability concerns: Security / ACL (who is allowed to see what), Cost controls, Rate limit / retry (surviving overload and hiccups), and Canary / rollback (rolling out changes safely and undoing bad ones fast).
  • There are no arrows on purpose — these are not stages a request passes through. Each one wraps around or sits beside the main pipeline from the first diagram.
  • Two colours simply group related ideas (operate-and-improve on top, protect-and-recover on the bottom); the split is for readability, not order.

In short: Think of these as the seatbelts and dashboard of your system. They do not change what the app does, but skipping them is what turns a working demo into a 3am outage. Build them once and every product reuses them.

14 Production go-live checklist

Before you flip the flag to 100%.

Reliability

✓ Timeouts & retries
✓ Circuit breaker + fallback
✓ Handles refusal/max_tokens
✓ Streaming for large outputs

Quality

✓ Golden eval set in CI
✓ Regression gate wired
✓ Online feedback captured
✓ Groundedness checked (RAG)

Guardrails

✓ Input PII + injection screening
✓ Output schema + safety validation
✓ Human-in-loop on risky actions
✓ Rate limiting per tenant

Cost

✓ Prompt caching verified (>0 reads)
✓ Model routing in place
✓ Cost-per-request dashboard
max_tokens/effort right-sized

Security

✓ Secrets in a manager
✓ Least-privilege tool creds
✓ Tenant isolation at retrieval
✓ Code/shell sandboxed

Ops

✓ Full request tracing
✓ SLO alerts (latency, errors)
✓ Prompt + model versioned
✓ One-flip rollback ready

The one-line summaryStart at the simplest tier, ground the model in your data with well-tested retrieval, constrain outputs with schemas, gate risky actions, cache aggressively, measure everything with evals and traces, and roll changes out behind flags with instant rollback.
© 2026 studybydoing.in · AI Engineering: Zero to Production · All rights reserved. · About · Privacy Policy · Terms · Contact
Educational content, provided as-is and without warranty. Code samples are examples — review, test, and adapt them before using in production. See the Terms of Use & Disclaimer. Use at your own risk.
© studybydoing.in