AI EngineeringZero to ProductionHome·About·What’s new·Contact
Multi-Agent Orchestration · Chapter M5

Multi-agent RAG pipelines

Single-agent agentic RAG (M3) lets one agent decide when to retrieve. A multi-agent RAG pipeline splits that job across specialized agents — a router, retriever(s), a grader, a synthesizer, and a verifier — that hand off through shared state. This lesson is the orchestration pattern: how the agents connect, where it loops, how it fails, and how to evaluate the whole pipeline — not just retrieval.

⏱️ ~60 min🧪 4 labs🎯 Advanced → Industry

Learning objectives

  • Decide when a multi-agent RAG pipeline earns its cost — and when one agent + tools (M3) is enough.
  • Name the canonical roles: router/planner, retriever(s), grader/re-ranker, synthesizer, verifier.
  • Wire the agents through shared state with conditional routing and a bounded retrieve→grade→re-retrieve loop.
  • Guard the failure modes: loop-forever, cost blow-up, grader/verifier disagreement, honest 'I don't know'.
  • Evaluate the pipeline end-to-end — per-stage metrics plus a grounded-answer gate — not retrieval recall alone.
Builds on M3, Chapter 3, L4 & FA5This assumes classic RAG from Chapter 3 (chunk/embed/retrieve/generate) and single-agent agentic RAG + GraphRAG from M3. We do not re-teach those. Here retrieval is the backbone of a multi-agent pipeline: the M3 loop becomes one stage among several specialized agents. Routing/cycle primitives come from L4; end-to-end evaluation extends FA5.

1 · One agent with tools vs a multi-agent RAG pipeline

Picture the moment this lesson exists for. You built a RAG agent in M3, it worked, and then the requests piled on: "also search the wiki, not just the docs"; "stop making things up — check the answer first"; "the judging step is burning our expensive model on trivial relevance calls." So you kept bolting instructions onto one system prompt until it read like a job description for five people. It half-plans, half-retrieves, half-judges, half-writes, half-verifies — and does none of them reliably. That bloated, do-everything prompt is the pain a multi-agent RAG pipeline cures.

The fix is the oldest trick in engineering: divide the labour. Think of a newsroom instead of a lone blogger. A blogger researches, writes, and fact-checks the same piece alone — fine for a small blog, but it doesn't scale and nobody's checking the writer's own claims. A newsroom splits those into an editor who assigns the story, reporters who gather sources, a copy editor who cuts the weak material, a writer who drafts, and a fact-checker with the authority to send a piece back. Each role is simpler, individually good at its job, separately measurable, and — crucially — the fact-checker is not the same person who wrote the story. A multi-agent RAG pipeline is that newsroom: router, retriever, grader, synthesizer, verifier, each a small focused agent handing work down the line through a shared notepad.

The common mistake — and it is common — is reaching for the pipeline too early. "Multi-agent" sounds sophisticated, so people split a perfectly good single agent into five just to have an impressive diagram. Every agent you add is another prompt to maintain, more tokens, more latency, and one more component that can misbehave — you have multiplied your surface area for bugs. The honest rule: M3's single agent with a retrieve→grade→rewrite loop is the right default. Split into a pipeline only when a specific stage genuinely needs its own prompt, its own model tier, or its own evaluation gate — the signals in the table below. If you can't point at which stage needs to be separate and why, you don't need a pipeline yet.

In M3 a single agent owns the whole flow: it decides when to retrieve, grades what came back, and answers. That is the right default. A multi-agent RAG pipeline is different — it splits the work across separate, specialized agents that each do one job well and hand off through a shared state object. You reach for it only when a single agent's one prompt is doing too many jobs at once.

M3: one prompt, five jobs one overloaded agent plan retrieve judge relevance write verify its own work split pipeline: five agents, one job each router · plan retriever · pull grader · drop junk synthesizer · write verifier · check + send back Divide the labour. The left is M3's single agent whose one prompt tries to be five people at once; the right splits the same five jobs into focused agents — each simpler, separately measurable, and (crucially) the verifier is not the writer.
🗺️ How to read this diagram

Read left-to-right: the one crowded box on the left is what you start with; the arrow "splits" it into the stack of single-job agents on the right.

  • The red box on the left is M3's single agent. The blue strips inside it are the five jobs — plan, retrieve, judge, write, verify — all crammed into one prompt, which is why it gets brittle.
  • The split arrow is the design decision this lesson is about: break the one prompt into separate agents.
  • The five stacked boxes on the right are those jobs given their own agent (router, retriever, grader, synthesizer, verifier). Each is simpler and independently measurable — and the verifier being a separate box is the whole point: nobody grades their own homework.

In short: You split into a pipeline only when one prompt is juggling too many jobs — the five boxes on the right are what "divide the labour" actually produces.

SignalOne agent + tools (M3)Multi-agent pipeline (this lesson)
Retrieval is a simple lookup✅ Just call a retriever toolOverkill
One prompt is juggling 4+ jobs (plan, retrieve, judge, write)Prompt gets brittle✅ Split into specialized agents
Multiple sources / indexes to fuseHard to reason about in one prompt✅ A dedicated retriever/fusion stage
You need a distinct faithfulness/hallucination gateBolted onto the answer step✅ A separate verifier agent that can send back
Each stage needs its own model / cost tierOne model for everything✅ Cheap grader, strong synthesizer
You want per-stage evals & observabilityOne opaque trace✅ Each agent is independently measurable
Don't over-engineerEvery agent you add is another prompt, more tokens, more latency, and one more thing that can misbehave. If a single agent with a retrieve→grade→rewrite loop (M3) already answers your questions well, ship that. Split into a pipeline only when a specific stage genuinely needs its own model, its own prompt, or its own eval gate — not because multi-agent sounds impressive.

2 · The pipeline roles — a canonical decomposition

Here is the good news that saves you a design meeting. You do not have to invent the roles from a blank page — the same five keep showing up in every serious RAG pipeline because they map to the five distinct jobs hidden inside "answer this question from documents." Learn them once and you'll recognize them in any framework, any codebase, any vendor's diagram: router, retriever, grader, synthesizer, verifier. Different names sometimes (planner, re-ranker, critic), same jobs.

The analogy that makes them stick is a restaurant kitchen taking an order. The router is the expediter reading the ticket and deciding which stations it goes to. The retriever is the line cooks pulling ingredients from the walk-in — maybe several at once from different fridges. The grader is the cook who inspects and throws out anything wilted before it hits the pan — junk in equals junk out. The synthesizer is the chef plating the dish from the good ingredients only. And the verifier is the head chef who inspects the plate before it leaves the pass and can send it back to be redone. Notice the pattern: work flows one direction, each station does one thing, and the last station has a veto.

The subtle point people miss is why the grader and the verifier both exist — they look like the same "quality check" and the instinct is to merge them to save an agent. Resist it. They guard opposite ends of the pipeline. The grader guards the input: it protects the synthesizer from irrelevant retrieved chunks so the answer is written from clean context. The verifier guards the output: it protects the user from a fluent, confident answer the context never actually supported — a hallucination. An answer can pass the grader (the chunks were relevant) and still fail the verifier (the draft claimed something not in those chunks). Collapsing them is exactly the "the writer fact-checks their own story" mistake from §1, and it is the single most valuable reason to run a pipeline at all. The table below is your reference card: each agent, its one job, and precisely what it reads from and writes to the shared state.

A multi-agent RAG pipeline is a small assembly line of specialists. Each agent reads the shared state, does one job, and writes its result back for the next stage. The canonical five:

Router / Planner classify + plan Retriever(s) multi-source Grader / Re-rank drop junk Synthesizer grounded + cited Verifier faithful?
🗺️ How to read this diagram

Read it straight across left-to-right: five boxes in a row, each an arrow feeding the next, so the question flows from the leftmost stage to the rightmost.

  • The leftmost box is the Router / Planner ("classify + plan") — it is where a question enters and gets assigned to sources.
  • The arrows between the boxes are the one-directional handoffs: Router → Retriever(s) ("multi-source") → Grader / Re-rank ("drop junk") → Synthesizer ("grounded + cited"). Each stage does one job and passes its result to the next.
  • The rightmost box is the Verifier ("faithful?") — the payoff stage: it is the separate final check that asks whether the draft is actually faithful, the role that makes this a pipeline rather than one overloaded agent.

In short: The canonical five roles — router, retriever, grader, synthesizer, verifier — laid out as a left-to-right assembly line, each doing one job and handing off to the next.

AgentJob (one thing)Reads / writes in shared state
Router / PlannerClassify the question; pick sources; optionally split into sub-questionsreads question → writes plan, sources
Retriever agent(s)Pull candidate chunks from one or more indexes/sources (may be several, in parallel)reads plan → writes candidates
Grader / re-rankerScore each candidate for relevance; drop junk; keep the top-kreads candidates → writes context
SynthesizerWrite a grounded answer from context, with citationsreads context → writes draft, cites
Verifier / criticCheck the draft is faithful to context; can send back to retrieve or re-synthesizereads draft, context → writes verdict
This is separation of concerns, not more magicThe grader and the verifier look similar but guard different failures. The grader protects the synthesizer from bad input (irrelevant chunks). The verifier protects the user from a bad output (a fluent answer the context doesn't support). Keeping them as separate agents is exactly why you'd choose a pipeline over M3's single agent.

3 · Orchestration & shared state

You have five specialists. Now: how do they actually pass work to each other? This is the question that separates a pipeline that you can debug at 2am from one that becomes an untraceable tangle of who-called-whom. The wiring matters as much as the agents, because a bug in a five-agent system is rarely "the synthesizer is wrong" — it's "the grader dropped everything and the synthesizer never noticed." How the agents communicate decides whether you can see that.

Think of the two coordination styles as two ways an office team works. One team shares a single whiteboard: everyone reads the current state off it, does their bit, and writes their result back for the next person. Anyone walking past can glance at the board and know exactly where the work stands. The other team fires emails at each other — flexible and great when the flow is a free-for-all, but reconstructing "what happened" means digging through everyone's inbox. The whiteboard is the blackboard / shared state pattern (one State object every agent reads and writes — what LangGraph's StateGraph gives you); the emails are message passing (AutoGen-style, M2). For a RAG pipeline, whose work flows mostly in one direction, the whiteboard wins: the code reads top-to-bottom like a data flow, and the state object is your debugging view.

Now the part that turns a straight line into a real pipeline, and the part beginners most often get dangerously wrong: the back-edges. A RAG pipeline is not purely one-directional. The grader might look at what it kept, decide the context is too thin, and send control back to the retriever to try again with a rewritten query — that is M3's retrieve→grade loop, now living as an edge between two separate stages. The verifier might reject a draft as unfaithful and send it back to re-synthesize or re-retrieve. So the pipeline is really a small state machine: mostly forward, with a couple of loops. The common — and costly — mistake is to add a back-edge and forget to bound it. An ungroundable question then loops retrieve→grade→retrieve forever, or until your token bill does the stopping for you. Every back-edge must carry a shared counter (the tries field in the lab below) so it can spin only a fixed number of times before the pipeline gives up honestly. A loop without a bound isn't a feature; it's an outage waiting for the wrong input.

How do the agents talk? Two common shapes. In a blackboard / shared scratchpad, every agent reads and writes one state object — simple, inspectable, and what LangGraph's StateGraph gives you. In message passing, agents send messages to each other (AutoGen-style, M2). For a linear-ish RAG pipeline the shared state object is usually the cleaner choice — the pipeline reads like a data flow.

The interesting part is conditional routing: the grader may decide the context is too weak and send control back to retrieve (the M3 loop, now a stage-to-stage edge); the verifier may reject the draft and send back to synthesize or re-retrieve. That is the whole pipeline — a state machine with a couple of back-edges, every one of them bounded.

shared State (blackboard) question · plan · candidates · context · draft · verdict · tries router retriever grader synthesizer verifier weak context → re-retrieve (bounded by tries) A blackboard with two bounded loops. Every agent reads/writes one shared State object (top); work flows left→right, but the grader and verifier can send control back — and each back-edge is capped by the shared tries counter so it can't spin forever.
🗺️ How to read this diagram

Follow the blue arrows left-to-right along the bottom row for the normal flow, then notice the two amber curves looping backward.

  • The top box is the one shared State object — the "blackboard." The dashed lines to it mean every agent reads the current state and writes its result back, so the state object is your debugging view.
  • The bottom row of blue arrows is the forward path: router → retriever → grader → synthesizer → verifier. Most work flows this one direction.
  • The amber back-edges are the loops: the grader can send weak context back to re-retrieve, and the verifier (dashed) can reject an unfaithful draft and send it back too. Each carries the shared tries counter — a back-edge without that bound is an outage waiting for the wrong input.

In short: The pipeline is a small state machine — mostly forward over a shared blackboard, with two bounded back-edges that let it retry instead of shipping a bad answer.

Lab M5.1 · The orchestration, offline & runnable

You do not need a cluster or an API key to get the orchestration logic right. Here the whole pipeline runs on deterministic fakes — a hash-based fake embed/retrieve, a term-overlap grader, a template synthesizer, a claim-support verifier — so the control flow (routing, the bounded loop, the send-back) is genuinely exercisable. Swap the fakes for real models later; the wiring is what this lab pins down.

router retriever (tries+1) grader synthesizer verifier faithful → return answer budget out → "I don't know" no context → re-retrieve unfaithful &budget left →send back The run() loop, drawn. Router once, then a bounded loop: retrieve → grade (empty context re-retrieves), synthesize → verify — faithful returns the answer, and exhausting max_tries returns an honest "I don't know" instead of looping.
🗺️ How to read this diagram

Follow the blue arrows top-to-bottom for one pass of the loop; the amber curves are the two send-backs, and the two boxes at the bottom are the two ways the run ends.

  • The vertical blue chain is the happy path: router runs once, then each loop pass goes retriever → grader → synthesizer → verifier.
  • The amber loop from grader back to retriever fires when the grader kept no context and tries is still under max_tries — it rewrites the query and retrieves again. The dashed amber loop from the verifier does the same when a draft is unfaithful but budget remains.
  • The two end boxes are the exits: a faithful verdict returns the grounded answer (green), while running out of max_tries returns the explicit "I don't know" (red) — never a fabricated answer.

In short: This is the code below as a picture — one router, a bounded retrieve→grade→synth→verify loop with two send-backs, and an honest give-up when the budget is spent.

Example code for learning — review, test, and adapt it before running against real or production systems. Commands can create, change, or delete resources. See the Terms & Disclaimer.
Lab M5.1
pipeline.py"""Multi-agent RAG pipeline — ORCHESTRATION only, fully offline & deterministic.
No API key, no cluster: fake embed/retrieve + grader/synth/verify agents + the loop.
Run: python3 pipeline.py"""
from dataclasses import dataclass, field

# ---- shared state (the blackboard every agent reads/writes) ----------------
@dataclass
class State:
    question: str
    plan: str = ""
    sources: tuple = ()
    candidates: list = field(default_factory=list)   # retriever output
    context: list = field(default_factory=list)       # grader-kept chunks
    draft: str = ""
    cites: list = field(default_factory=list)
    verdict: str = ""
    tries: int = 0
    trace: list = field(default_factory=list)

CORPUS = {
    "refund":  "Refunds are accepted within 30 days of purchase.",
    "reset":   "Reset your password in Settings -> Security.",
    "shipping":"Standard shipping takes 3-5 business days.",
}

def _score(query, text):            # deterministic fake "relevance": prefix-term overlap
    q = query.lower().split(); t = text.lower().split()
    # count query words that prefix-match some doc word ("refund" ~ "refunds")
    return sum(any(w.startswith(qw) or qw.startswith(w) for w in t) for qw in q)

# ---- the specialized agents (each does ONE job) ----------------------------
def router(s: State) -> State:
    s.plan = "lookup"; s.sources = ("vector",)
    s.trace.append(("router", s.plan)); return s

def retriever(s: State) -> State:                     # pull candidates from the index
    s.candidates = [(k, v, _score(s.question, v)) for k, v in CORPUS.items()]
    s.tries += 1
    s.trace.append(("retriever", f"try#{s.tries} pulled {len(s.candidates)}")); return s

def grader(s: State, keep_min=1) -> State:            # drop junk, keep the relevant
    s.context = [(k, v) for k, v, sc in s.candidates if sc >= keep_min]
    s.trace.append(("grader", f"kept {len(s.context)}/{len(s.candidates)}")); return s

def synthesizer(s: State) -> State:                   # grounded, cited draft
    if s.context:
        s.draft = " ".join(v for _, v in s.context)
        s.cites = [k for k, _ in s.context]
    else:
        s.draft = ""; s.cites = []
    s.trace.append(("synth", f"draft={bool(s.draft)} cites={s.cites}")); return s

def verifier(s: State) -> State:                      # faithful to context? every
    ctx = " ".join(v for _, v in s.context).lower()   # answer word must appear in ctx
    unsupported = [w for w in s.draft.lower().split() if w not in ctx]
    s.verdict = "faithful" if s.draft and not unsupported else "unfaithful"
    s.trace.append(("verifier", s.verdict)); return s

# ---- orchestrator: the state machine with BOUNDED back-edges ---------------
def run(question, max_tries=3):
    s = State(question=question)
    s = router(s)
    while True:
        s = retriever(s)
        s = grader(s)
        if not s.context and s.tries < max_tries:     # weak retrieval -> loop back
            s.question += " policy"                    # (a real router would rewrite)
            continue
        s = synthesizer(s)
        s = verifier(s)
        if s.verdict == "faithful":
            return {"answer": s.draft, "cites": s.cites, "trace": s.trace}
        if s.tries >= max_tries:                        # bound the send-back loop
            return {"answer": "I don't have enough grounded information to answer.",
                    "cites": [], "trace": s.trace}
        # unfaithful but budget left -> send back to retrieve for more context

if __name__ == "__main__":
    import json
    print(json.dumps(run("what is the refund window"), indent=2))
    print(json.dumps(run("who is the president"), indent=2))   # ungroundable -> refuse
{
  "answer": "Refunds are accepted within 30 days of purchase.",
  "cites": ["refund"],
  "trace": [["router","lookup"],["retriever","try#1 pulled 3"],
            ["grader","kept 1/3"],["synth","draft=True cites=['refund']"],
            ["verifier","faithful"]]
}
{
  "answer": "I don't have enough grounded information to answer.",
  "cites": [], "trace": [ ... router/retriever/grader loop x3 ... ]
}
The trace IS the pipelineEach agent appends to trace, so a single run shows every handoff and every loop. That per-stage log is not decoration — it is how you debug a misroute, a grader that drops everything, or a verifier stuck in a send-back cycle. Keep it in production too (I4).

The same shape in a real framework (needs the lib / API key)

The offline lab above is the control flow. A real deployment swaps the fakes for models and expresses the graph in a framework. Here is the LangGraph shape — needs pip install langgraph and a model/API key; the node functions are the same agents:

START router retriever grader synth verify END conditional conditional add_conditional_edges: else loop back to retriever (bounded by tries≥3) The same state machine as a LangGraph graph. Plain add_edge calls draw the straight blue path; the two add_conditional_edges at grader and verifier are the amber branches that either move forward (synth / END) or loop back to the retriever — the exact code below.
🗺️ How to read this diagram

Read it left-to-right as the graph LangGraph compiles: START on the left, END on the right, one node per agent in between.

  • The blue arrows are the unconditional g.add_edge(...) calls — the fixed forward wiring from START through router, retriever, grader, synth, verify to END.
  • The two amber "conditional" labels mark the nodes wired with add_conditional_edges: the grader and the verifier each run a small lambda that chooses the next node.
  • The amber curves back to retriever are the "else" branch of each choice — grader loops back when context is thin, verifier loops back when the draft is unfaithful — but both are capped by tries ≥ 3 in the state, so the graph terminates.

In short: Straight edges = fixed hops; conditional edges = the two bounded branches. This picture is a one-to-one map of the code in the block below.

Framework · LangGraph
graph_langgraph.py# needs: pip install langgraph  + a model/API key. Node fns = the agents above.
from langgraph.graph import StateGraph, START, END

g = StateGraph(dict)                       # dict state == the blackboard
g.add_node("router", router)
g.add_node("retriever", retriever)
g.add_node("grader", grader)
g.add_node("synth", synthesizer)
g.add_node("verify", verifier)

g.add_edge(START, "router")
g.add_edge("router", "retriever")
g.add_edge("retriever", "grader")
# grader decides: enough context -> synth, else loop back to retriever (bounded in state)
g.add_conditional_edges("grader",
    lambda s: "synth" if s["context"] or s["tries"] >= 3 else "retriever",
    {"synth": "synth", "retriever": "retriever"})
g.add_edge("synth", "verify")
# verifier decides: faithful -> END, else send back to retriever (bounded)
g.add_conditional_edges("verify",
    lambda s: END if s["verdict"] == "faithful" or s["tries"] >= 3 else "retriever",
    {END: END, "retriever": "retriever"})
app = g.compile()
# CrewAI (needs: pip install crewai + key) models the same as role-based agents with a
# manager process; see M1. AutoGen (M2) would use message-passing between the agents.
Honesty on framework APIsThe LangGraph StateGraph / add_conditional_edges API and the CrewAI/AutoGen references above are real but move fast — verify against current docs before you rely on exact signatures. The orchestration logic (the offline lab) is framework-independent and won't rot. Cross-links: M1 CrewAI, M2 AutoGen, L4 LangGraph.

4 · Failure modes & control

Every agent you added in §2 was a feature. Every agent you added is also a new way to fail. That is the tax nobody prints on the multi-agent brochure: more moving parts means more moving parts that can jam. A single agent has a small blast radius — it does its one prompt and returns. A five-agent pipeline with a couple of loops has feedback, budget, and hand-off failures a single agent simply cannot have. This section is the honest counterweight to §1's enthusiasm: before you ship a pipeline, you must know the ways it breaks and have a control for each.

The mental model is a factory line with a rework loop. On a well-run line, a station that finds a defect sends the part back to be redone — good. But without limits, three specific disasters follow, and they're worth feeling before you meet them in a table. Loop forever: two stations bounce a part back and forth (grader rejects → retrieve → grader rejects again) and the line never produces anything. Cost blow-up: every trip through a station costs money, and a five-station line run three times is fifteen expensive operations — a single user question can quietly cost fifteen model calls. Deadlock by disagreement: the grader keeps passing a part the verifier keeps rejecting, and the two fight forever because neither has final authority. And the quiet fourth one, no graceful give-up: when the line genuinely can't build the part, a bad factory ships a broken one anyway — the pipeline invents an answer rather than admitting defeat.

The controls all share one idea: someone has to be allowed to stop. The beginner instinct is to trust the agents to sort it out among themselves — they won't, because each is optimizing its own narrow job. So you impose limits from outside. Bound every back-edge with a shared iteration cap (the tries counter from §3). Bound total spend with a call/token budget, and shift cheap work to a cheap model — router and grader don't need your strongest, most expensive model; the synthesizer does. Give the verifier final authority to end a run so a grader/verifier disagreement resolves instead of thrashing, and log the disagreement so you can tune it later. And make the give-up path a real, deliberate output: on budget exhaustion, return an explicit "I don't have enough grounded information to answer" — never a fabricated one. An honest "I don't know" is a feature; a confident wrong answer is the worst thing a RAG system can do. The table names each failure and its control.

A multi-agent pipeline has more moving parts than a single agent, so it has more ways to go wrong. The four that bite in production:

failure control Loop forevergrader ↔ retrieve, never produces Cap every back-edgeshared tries / iteration limit Cost blow-up3 retries × 5 agents = 15+ calls Budget guard + cheap tierscap calls/tokens; cheap router/grader Grader/verifier disagreementone keeps, the other rejects — thrash Verifier has final sayend the run; log the disagreement No graceful give-upinvents an answer under pressure Honest "I don't know"explicit refuse on budget exhaustion Every failure has a control, and every control is "someone is allowed to stop." The red boxes are the four ways a looping, multi-agent pipeline breaks; the green box beside each is the bound that fixes it — all imposed from outside the agents.
🗺️ How to read this diagram

Read it in rows, left-to-right: each red failure on the left maps by a green arrow to the control that neutralizes it on the right.

  • The red left column is the four failure modes a pipeline adds that a single agent can't have — loop forever, cost blow-up, grader/verifier deadlock, and shipping a fabricated answer.
  • The green right column is the matching control for each: a shared iteration cap, a call/token budget with cheap model tiers, giving the verifier final authority, and an explicit honest refusal.
  • The green arrows pair them one-to-one. Notice the common thread — every control is a limit imposed from outside; the agents won't stop on their own because each optimizes only its own narrow job.

In short: More agents means more ways to jam — so for each failure you pre-wire a bound that lets the pipeline stop instead of thrash or lie.

Failure modeWhat it looks likeControl
Loop forevergrader keeps rejecting → retrieve → grade → retrieve …Bound every back-edge with a shared tries/iteration cap (M3's lesson, now across stages)
Cost blow-upeach agent = tokens; a 3-retry × 5-agent run is 15+ model callsA budget guard: cap total calls/tokens; use a cheap model for router/grader, a strong one only for synth
Grader/verifier disagreementgrader keeps a chunk the verifier then rejects — thrashGive the verifier authority to end the run; log the disagreement; don't re-loop indefinitely
No graceful give-uppipeline invents an answer rather than admitting defeatOn budget exhaustion, return an explicit 'I don't know' — never a fabricated answer

Lab M5.2 · A control / budget guard

One small object enforces the bounds: a cap on iterations and on total model calls, shared across all agents. It is offline and deterministic — the point is the accounting, not the model behind each call.

shared Budget iters ≤ max_iters · calls ≤ max_calls next_iter() spend(n) within budget → run exceeded → stop bounds the loop bounds the spend Two bounds, not one. One shared Budget exposes two gates — next_iter() caps how many times the loop spins, spend() caps total model calls — and either one hitting its limit stops the run.
🗺️ How to read this diagram

Start at the top box (the one shared budget) and follow its two gates down to the two outcomes on the right.

  • The top box is a single Budget object threaded through every agent, holding two independent limits: an iteration cap and a call cap.
  • The two amber boxes are its gates: next_iter() is checked at the top of each loop pass (bounds looping), and spend(n) is called on every model call (bounds cost).
  • The green vs red boxes are the two outcomes: while both limits hold the run continues, but the moment either is exceeded the pipeline stops — the red edge from spend() is the hard stop a loop-count alone would miss.

In short: Iterations and calls are separate failure axes, so you cap both in one shared object — bounding only one still lets the other blow up.

Lab M5.2
budget.py"""A shared budget guard for the pipeline. Offline & deterministic.
Bounds BOTH the loop (iterations) and the spend (model calls). Run: python3 budget.py"""
class Budget:
    def __init__(self, max_iters=3, max_calls=12):
        self.max_iters = max_iters; self.max_calls = max_calls
        self.iters = 0; self.calls = 0

    def spend(self, n=1):                 # every agent call goes through here
        self.calls += n
        if self.calls > self.max_calls:
            raise BudgetExceeded(f"call budget blown: {self.calls}/{self.max_calls}")

    def next_iter(self) -> bool:          # False -> stop looping, give up gracefully
        self.iters += 1
        return self.iters <= self.max_iters

class BudgetExceeded(Exception):
    pass

def guarded_pipeline(question, budget: Budget):
    """Model the loop with the guard deciding when to stop."""
    trace = []
    while budget.next_iter():
        budget.spend(2)                   # e.g. retriever + grader call
        grounded = "refund" in question.lower()   # fake: only 'refund' is groundable
        trace.append((budget.iters, budget.calls, "hit" if grounded else "miss"))
        if grounded:
            budget.spend(2)               # synth + verify
            return {"answer": "Refunds within 30 days.", "cost": budget.calls, "trace": trace}
        question += " policy"             # rewrite & retry within budget
    # loop exhausted WITHOUT a grounded hit -> the honest fallback
    return {"answer": "I don't know — no grounded context found.",
            "cost": budget.calls, "trace": trace}

if __name__ == "__main__":
    print(guarded_pipeline("what is the refund window", Budget()))
    print(guarded_pipeline("what is the meaning of life", Budget()))
    try:
        guarded_pipeline("x", Budget(max_iters=99, max_calls=3))
    except BudgetExceeded as e:
        print("STOPPED:", e)
{'answer': 'Refunds within 30 days.', 'cost': 4, 'trace': [(1, 2, 'hit')]}
{'answer': "I don't know — no grounded context found.", 'cost': 6, 'trace': [(1,2,'miss'),(2,4,'miss'),(3,6,'miss')]}
STOPPED: call budget blown: 4/3
Two bounds, not oneBounding iterations alone doesn't stop cost — a single iteration with many agents and big contexts can still be expensive. Bounding calls/tokens alone doesn't stop a tight loop from thrashing within budget. You need both, shared across every agent, and an honest give-up when either is hit.

5 · Evaluating the whole pipeline

Imagine the pipeline's grounded-answer rate drops from 90% to 70% overnight. Something regressed. Which agent? If your only measurement is "did the final answer come out right," you're standing in front of a five-station assembly line that just started producing defects and you have no idea which station broke. You'll spend a day guessing. This section is about never being in that position — and it hinges on one insight beginners routinely miss: evaluating a multi-agent pipeline is not one number, it is two layers of numbers.

Here's the analogy. A car has to pass an end-to-end test — does it drive from A to B safely? — but a mechanic diagnosing a problem also reads per-component gauges: tire pressure, oil level, brake wear. The end-to-end test tells you that something is wrong; the gauges tell you what. A pipeline is identical. The end-to-end metric is the grounded-answer rate: given a real question, is the final answer both grounded and correct? That's the number your lead cares about, the one on the dashboard. But it's the per-stage metrics — retriever recall@k, grader precision, verifier catch-rate — that tell you which agent to fix when the end-to-end number drops. Measure only the end-to-end number and you're the driver who knows the car is sluggish but can't pop the hood.

The trap here is subtle and specific to pipelines, so name it plainly: measuring retrieval recall alone and calling it done. Recall@k is a great retriever gauge, and it's tempting to stop there because M3 and Chapter 3 leaned on it. But a pipeline's whole selling point over M3 was the verifier — and a verifier that rubber-stamps everything is worse than useless, because it makes a single-agent RAG feel safe while adding latency and cost. So the most important pipeline-specific metric is the verifier catch-rate: deliberately feed it drafts you know are ungrounded and measure the fraction it rejects. That number is the honest answer to "does this pipeline actually guard against hallucination, or does it just look like it does?" You reuse the FA5 machinery per stage rather than reinventing it — recall@k for the retriever, faithfulness scoring for the verifier — and layer the end-to-end grounded-answer rate on top. The table splits the two layers so you always know which question each number is answering.

FA5 covers RAG evaluation — retrieval recall@k, faithfulness, and the eval-set scorecard. Do not repeat that work; reuse it per stage. A multi-agent pipeline needs two layers of evaluation:

End-to-end: grounded-answer rate (the number leaders watch) retriever grader verifier answer per-stage gauges tell you WHICH agent to fix recall@k kept-chunk precision catch-rate verifier catch-rate = the pipeline-specific metric Two layers, two questions. The top band is the one end-to-end number (is the final answer grounded?); the bottom gauges are per-stage (recall@k, kept-chunk precision, and — the metric a pipeline lives or dies by — the verifier's catch-rate).
🗺️ How to read this diagram

Read it in two bands: the wide bar across the top is the whole-pipeline number; the small gauges hanging under each agent are the per-stage numbers.

  • The top band is the end-to-end metric — the grounded-answer rate over the whole pipeline. It tells you that something regressed but not where.
  • The per-agent gauges below (recall@k under the retriever, kept-chunk precision under the grader, catch-rate under the verifier) tell you which agent to fix when the top number drops — like a mechanic's per-component gauges versus a single drive test.
  • The highlighted verifier catch-rate is the metric unique to pipelines: feed it drafts you know are ungrounded and measure the fraction it rejects — that separates a real hallucination guard from one that just rubber-stamps.

In short: One end-to-end number says something broke; the per-stage gauges say which agent — and the verifier catch-rate is the one that proves the pipeline was worth building.

LayerQuestion it answersMetric
Per-stageIs each agent doing its job?Retriever: recall@k (FA5). Grader: precision of kept chunks. Verifier: catch-rate on planted-unfaithful drafts.
End-to-endGiven a real question, is the final answer grounded and correct?Grounded-answer rate: fraction of answers the verifier passes AND that a held-out gold check confirms.

The end-to-end number is the one leaders care about, but it's the per-stage numbers that tell you which agent to fix when it drops. The most important pipeline-specific metric is the verifier catch-rate: feed it drafts you know are ungrounded and measure how many it rejects — that is the difference between a pipeline that guards hallucination and one that just looks like it does.

Lab M5.3 · Recall@k + a faithfulness gate, offline

Two offline evals that run without a judge model. recall_at_k reuses the FA5 set-overlap idea for the retriever stage; faithfulness_gate is the end-to-end check the verifier stage performs. Deterministic — swap in a real judge later.

per-stage: recall@k (retriever) retrieved[:k] gold ids overlap ∩ recall@k = |∩| / |gold| end-to-end: faithfulness gate (verifier) answer words context words supported fraction score ≥ threshold? pass (grounded) FAIL (ungrounded) both run offline — no judge model, fully deterministic Two evals, two layers. Left: recall@k is pure set overlap — how much of the gold set the retriever's top-k caught. Right: the faithfulness gate scores what fraction of the answer's words are supported by the context and passes only above the threshold.
🗺️ How to read this diagram

Read the two halves independently — the dashed divider splits the per-stage retriever eval (left) from the end-to-end verifier gate (right).

  • The left half is recall_at_k: intersect the retriever's retrieved[:k] with the gold ids, and divide the overlap size by the gold size — a pure set-overlap number for the retriever stage.
  • The right half is faithfulness_gate: take the answer's content words, check what fraction appear in the context words, and compute the supported fraction.
  • The green vs red boxes are the gate's verdict — the score passes only when it clears the threshold; below it the draft is flagged ungrounded, which is exactly what the verifier agent rejects and sends back.

In short: recall@k gauges the retriever with set overlap; the faithfulness gate gauges the final answer with word support against a threshold — both deterministic and offline.

Lab M5.3
eval.py"""Pipeline evaluation, offline & deterministic (no judge model needed).
Per-stage recall@k for the retriever + an end-to-end faithfulness gate. Run: python3 eval.py"""

# ---- per-stage: retriever recall@k (FA5 set-overlap idea) ------------------
def recall_at_k(retrieved_ids, gold_ids, k):
    top = set(retrieved_ids[:k]); gold = set(gold_ids)
    return len(top & gold) / len(gold) if gold else 0.0

# ---- end-to-end: faithfulness gate (what the verifier agent enforces) ------
import re
STOP = {"is","are","the","a","an","of","in","to","and","our","within"}

def _words(text):                    # lowercase, strip punctuation ("days." -> "days")
    return re.findall(r"[a-z0-9]+", text.lower())

def faithfulness(answer, context):
    """Fraction of answer content-words supported by the retrieved context."""
    ctx = set(w for c in context for w in _words(c)) - STOP
    ans = [w for w in _words(answer) if w not in STOP]
    if not ans:
        return 0.0
    return sum(w in ctx for w in ans) / len(ans)

def faithfulness_gate(answer, context, threshold=0.8):
    score = faithfulness(answer, context)
    return {"score": round(score, 2),
            "verdict": "pass" if score >= threshold else "FAIL (ungrounded)"}

def evaluate_pipeline(cases):
    """cases: list of (retrieved_ids, gold_ids, answer, context)."""
    recalls, passes = [], 0
    for rid, gid, ans, ctx in cases:
        recalls.append(recall_at_k(rid, gid, k=3))
        if faithfulness_gate(ans, ctx)["verdict"] == "pass":
            passes += 1
    return {"mean_recall@3": round(sum(recalls)/len(recalls), 2),
            "grounded_answer_rate": round(passes/len(cases), 2)}

if __name__ == "__main__":
    print(faithfulness_gate("Refunds are accepted within 30 days.",
                            ["Refunds are accepted within 30 days of purchase."]))
    print(faithfulness_gate("The CEO is Bob.",                       # ungrounded
                            ["Refunds are accepted within 30 days."]))
    cases = [
        (["refund","reset","x"], ["refund"], "Refunds within 30 days.",
         ["Refunds are accepted within 30 days."]),
        (["a","b","c"], ["shipping"], "The CEO is Bob.",             # bad retrieval + ungrounded
         ["Refunds are accepted within 30 days."]),
    ]
    print(evaluate_pipeline(cases))
{'score': 1.0, 'verdict': 'pass'}
{'score': 0.33, 'verdict': 'FAIL (ungrounded)'}
{'mean_recall@3': 0.5, 'grounded_answer_rate': 0.5}
Evaluate the guard, not just the answerThe pipeline-specific eval is the verifier catch-rate: build a small set of drafts you know are ungrounded and confirm the faithfulness gate rejects them. A pipeline whose verifier passes everything is a single-agent RAG with extra latency. See FA5 for the full scorecard and eval-set discipline.

6 · Production shape (optional)

The offline lab runs on your laptop with fakes. Production is where the fakes become real models, real users, and a real bill — and where a design that looked clean in a diagram meets the unglamorous questions of where it runs, how you watch it, and what happens when it's wrong on a high-stakes answer. The reassuring news: you have already learned most of this. A multi-agent RAG pipeline is a set of stages, and stages deploy exactly like any other LLM service you've shipped in this course. There is no exotic new infrastructure here — just a handful of notes specific to the fact that you now have several agents instead of one.

The instinct to worry about is treating the pipeline as one indivisible black box in production. Don't. Its greatest operational strength is the same thing that made it worth building: it comes pre-divided into stages, and each stage is separately deployable, separately scalable, and separately observable. Heavy retrieval/fusion can be its own scalable service on the infra you know (K7). Each agent gets its own trace (I4) so the per-stage metrics from §5 become real dashboards, not aspirations. Cost tiers from §4 map straight onto deployment — cheap model for router/grader, strong for synthesizer/verifier, budget guard enforcing the ceiling.

Two production concerns deserve special care because they're easy to skip and expensive to skip. First, the human-in-the-loop gate: for high-stakes answers, a verifier FAIL (or a low-confidence pass) should route to a person, not auto-answer — the pipeline's honesty about uncertainty is only useful if something acts on it (L5's HITL pattern). Second, and most under-appreciated: every retrieved chunk is untrusted input. More retrieval hops means more surface for prompt injection — a poisoned document in your index can carry instructions the synthesizer might obey. Treat retrieved text as data, never as commands, and keep any destructive tool actions gated behind their own checks (T1). A pipeline that fuses six sources has six times the injection surface of a single lookup; the checklist below is how you deploy it without shipping that risk to your users.

Where does this run? The pipeline is a set of stages, so it maps cleanly onto the deployment patterns you already know. A few production notes specific to multi-agent RAG:

each stage deploys on its own — separately scalable + traced routercheap tier retrieverscalable svc gradercheap tier synthstrong tier verifierstrong tier retrieved chunks = UNTRUSTED input(injection surface) FAIL → human gate low-risk → auto-answer Deploy the pipeline pre-divided. Each stage is its own deployable, traced service on a cheap or strong model tier; a verifier FAIL on a high-stakes answer routes to a human, and every retrieved chunk is treated as untrusted input.
🗺️ How to read this diagram

Read the top row as the deployed pipeline, then the two callouts below it as the production-specific concerns hanging off it.

  • The top row is the five stages, each its own separately-scalable, separately-traced service — labelled with its model tier (cheap for router/grader, strong for synth/verifier, retriever as a scalable service).
  • The red box (bottom-left) flags the biggest new risk: every retrieved chunk is untrusted input, so fusing many sources multiplies the prompt-injection surface — treat retrieved text as data, never commands.
  • The amber human-gate box (bottom-right) is the HITL control: a verifier FAIL or low-confidence pass on a high-stakes answer routes to a person, while low-risk answers auto-return.

In short: Production doesn't need new infra — deploy each stage on its own tier, trace it, gate the risky answers to a human, and treat every retrieved chunk as untrusted.

Production checklist

  1. Runs as stages — deploy on the same infra as any LLM service (K7); heavy retrieval/fusion can be its own scalable service.
  2. Observability per agent — trace each stage separately (I4); the per-stage metrics from §5 are your dashboards.
  3. Human-in-the-loop gate — for high-stakes answers, route a verifier FAIL (or a low-confidence pass) to a human instead of auto-answering (L5's HITL pattern).
  4. Cost tiers — cheap model for router/grader, strong model for synthesizer/verifier; the budget guard from §4 enforces the ceiling.
  5. Treat retrieved text as untrusted — more retrieval hops = more prompt-injection surface (T1); keep destructive actions gated.
This is the crew project's backboneThe Multi-Agent Research Crew project (P12) builds a full crew end-to-end with roles, a critic, and phased rollout. This lesson is the pipeline pattern underneath it: understand the router→retrieve→grade→synth→verify flow here, then go build the whole thing there. We teach the pattern; the project ships it.

Common pitfalls

PitfallFix
Multi-agent pipeline where one agent + tools (M3) would doStart with M3; split only when a stage needs its own prompt/model/eval
Any back-edge without a boundShare a tries/budget cap across all stages; give up gracefully
No verifier — the synthesizer marks its own homeworkAdd a separate faithfulness gate that can send the draft back
Grader and verifier fighting foreverLet the verifier end the run; log the disagreement instead of re-looping
Evaluating only retrieval recallAdd end-to-end grounded-answer rate + verifier catch-rate (FA5 + §5)
Trusting retrieved text across many hopsEvery source is untrusted input; guard injection (T1)
Assuming the pattern replaces good chunking/rerankFix retrieval quality first (Chapter 3, A7) — orchestration can't rescue garbage

🪜 Practice ladder beginner → industry

Six graded exercises, easy to real-world. Try each before opening its solution.

Exercise 1 · The grader that drops low-relevance chunksBeginner

Context: The grader is the first specialized agent — it protects the synthesizer from junk by scoring each retrieved candidate and keeping only what clears a bar.

Your task: Write grade(candidates, keep_min) that scores candidate chunks against a query and returns only the relevant ones, dropping the rest.

Requirements:

  • Take a list of (id, text) candidates and a query
  • Score by term overlap between query and chunk (stdlib only)
  • Drop any candidate scoring below keep_min
  • Return the kept chunks in score order (best first)
  • Show a mix of relevant and junk candidates being filtered

💡 Hint: This is the M3 grade step promoted to its own agent; term overlap is a fine stand-in for a relevance model offline.

Show solution

The grader is a filter with a bar. Runnable (stdlib):

def grade(query, candidates, keep_min=1):
    q = set(query.lower().split())
    scored = [(cid, text, len(q & set(text.lower().split()))) for cid, text in candidates]
    kept = [(cid, text) for cid, text, sc in scored if sc >= keep_min]
    # best first
    kept.sort(key=lambda ct: -len(q & set(ct[1].lower().split())))
    return kept

cands = [("a", "Refunds are accepted within 30 days"),
         ("b", "Our office is in Berlin"),
         ("c", "Refund requests must include the order id")]
print(grade("how long is the refund window", cands))
# -> keeps a and c (mention refund), drops b

Dropping junk before synthesis is the grader's whole job — a smaller, cleaner context beats a big noisy one every time.

Exercise 2 · The retrieve → grade → re-retrieve loop, boundedIntermediate

Context: Weak first retrieval shouldn't sink the answer — the grader can send control back to retrieve with a better query, but only within a bound so it can't spin forever.

Your task: Model the retrieve→grade→re-retrieve loop offline with a fake retriever and the grader from Exercise 1, capped at max_tries.

Requirements:

  • Fake retriever returns candidates for known query terms, empty otherwise
  • If the grader keeps nothing, rewrite/expand the query and retry
  • Bound the loop with max_tries
  • On exhausting the bound with no context, return the honest give-up
  • Show a first-try hit, a rewrite-then-hit, and an ungroundable give-up

💡 Hint: This is M3's loop, but now it lives between two specialized stages rather than inside one agent; the cap is the correctness detail.

Show solution

The loop with a bound — the send-back is a stage-to-stage edge. Runnable:

CORPUS = {"refund": "Refunds within 30 days.", "reset": "Reset in Settings."}

def retrieve(query):
    return [(k, v) for k, v in CORPUS.items()
            if any(w in query.lower() for w in k.split())]

def grade(query, cands, keep_min=1):
    q = set(query.lower().split())
    return [(k, v) for k, v in cands if len(q & set(v.lower().split())) >= keep_min or k in query.lower()]

def loop(query, max_tries=3):
    for attempt in range(1, max_tries + 1):
        context = grade(query, retrieve(query))
        if context:
            return f"answer from {[k for k,_ in context]} (try {attempt})"
        query += " refund"                 # rewrite & retry
    return "give up: no grounded context (would say 'I don't know')"

print(loop("what is the refund window"))
print(loop("money back"))            # rewritten -> refund
print(loop("what is the weather"))    # ungroundable -> give up

The bound is not optional: without it an ungroundable question loops forever. The give-up path is the honest answer.

Exercise 3 · A verifier that sends unfaithful drafts backAdvanced

Context: The synthesizer can write a fluent answer the context doesn't support. A separate verifier agent checks faithfulness and, if the draft fails, sends it back rather than shipping a hallucination.

Your task: Add a verify(draft, context) agent to the loop that passes faithful drafts and, on failure, triggers a bounded re-synthesis / re-retrieval.

Requirements:

  • Verifier scores what fraction of the draft's content words appear in the context
  • Pass when the score clears a threshold; otherwise mark unfaithful
  • On unfaithful, send back to synthesize (or re-retrieve) within a shared bound
  • On exhausting the bound, return the honest 'I don't know'
  • Show a faithful draft passing and an ungrounded draft being rejected then given up

💡 Hint: The verifier is the grader's mirror image: the grader guards the input, the verifier guards the output. Give it authority to end the run.

Show solution

The verifier guards the output; the send-back is bounded. Runnable:

STOP = {"is","are","the","a","an","of","within","our"}

def faithfulness(draft, context):
    ctx = set(w for c in context for w in c.lower().split()) - STOP
    ans = [w for w in draft.lower().split() if w not in STOP]
    return sum(w in ctx for w in ans) / len(ans) if ans else 0.0

def verify(draft, context, threshold=0.8):
    return "faithful" if faithfulness(draft, context) >= threshold else "unfaithful"

def synth_and_verify(context, bad_synth, max_tries=2):
    for attempt in range(1, max_tries + 1):
        # attempt 1 uses a (possibly bad) synth; later attempts stick to context
        draft = bad_synth if attempt == 1 else " ".join(context)
        if verify(draft, context) == "faithful":
            return f"ship: '{draft}' (try {attempt})"
    return "give up: could not produce a faithful draft -> 'I don't know'"

ctx = ["Refunds are accepted within 30 days."]
print(synth_and_verify(ctx, bad_synth="Refunds are accepted within 30 days."))  # faithful
print(synth_and_verify(["Shipping takes 3-5 days."], bad_synth="The CEO is Bob."))  # rejected

Without the verifier the synthesizer marks its own homework. Giving a separate agent authority to reject-and-resend is exactly why you'd choose a pipeline over M3.

Exercise 4 · Multi-source retriever fusion (RRF) feeding the pipelineExpert

Context: Real pipelines retrieve from several sources/indexes and must fuse the ranked lists into one. Reciprocal Rank Fusion (RRF) combines rankings without needing comparable scores.

Your task: Implement rrf(rankings, k) that fuses several ranked candidate lists into a single ranking, then feed the fused list into the grader stage.

Requirements:

  • Take several ranked lists (each a list of doc ids, best first)
  • Score each doc by the RRF formula: sum of 1/(k + rank) across lists
  • Return doc ids sorted by fused score (best first)
  • Use only the standard library
  • Show two sources whose disagreement is resolved by the fusion

💡 Hint: RRF score for a doc = sum over lists of 1/(k + rank_in_that_list); k≈60 is the common default. Docs that rank well in multiple lists float to the top.

Show solution

RRF fuses rankings without comparable scores. Runnable (stdlib):

from collections import defaultdict

def rrf(rankings, k=60):
    """rankings: list of ranked id-lists (best first). Returns fused id list."""
    score = defaultdict(float)
    for ranked in rankings:
        for rank, doc_id in enumerate(ranked):     # rank 0 = best
            score[doc_id] += 1.0 / (k + rank + 1)
    return sorted(score, key=lambda d: -score[d])

vector_hits = ["d1", "d2", "d3"]     # source A's ranking
graph_hits  = ["d3", "d1", "d4"]     # source B disagrees
fused = rrf([vector_hits, graph_hits])
print(fused)   # d1 & d3 rank well in BOTH -> float to the top
# feed the fused list into the grader from Exercise 1

RRF is the workhorse for combining multi-source retrieval: no score calibration needed, and docs both sources like win. The fused list becomes the retriever agent's output that the grader then filters.

Exercise 5 · Cost / iteration budget + per-stage observabilityProfessional

Context: A production pipeline must not blow its cost or latency ceiling, and when a stage regresses you need to know which one. A shared budget guard plus per-stage timing/counting gives you both.

Your task: Wrap the pipeline stages with a shared budget (iterations + calls) and a per-stage counter, and emit a run report showing spend and where time/calls went.

Requirements:

  • Share one budget across all stages: cap iterations AND total calls
  • Count calls (and optionally timing) per stage
  • Raise/stop when either bound is exceeded — no silent overrun
  • Emit a per-stage report (calls per agent) plus total spend
  • Show a normal run and a run that hits the budget

💡 Hint: One Budget object threaded through every stage is enough; a dict keyed by stage name gives you the per-stage breakdown for the dashboard.

Show solution

Shared budget + per-stage accounting. Runnable:

from collections import defaultdict

class Budget:
    def __init__(self, max_iters=3, max_calls=12):
        self.max_iters, self.max_calls = max_iters, max_calls
        self.iters = 0; self.by_stage = defaultdict(int)
    @property
    def calls(self): return sum(self.by_stage.values())
    def spend(self, stage, n=1):
        self.by_stage[stage] += n
        if self.calls > self.max_calls:
            raise RuntimeError(f"budget blown: {self.calls}/{self.max_calls}")
    def next_iter(self):
        self.iters += 1
        return self.iters <= self.max_iters

def run(question, budget):
    while budget.next_iter():
        budget.spend("router"); budget.spend("retriever"); budget.spend("grader")
        if "refund" in question.lower():
            budget.spend("synth"); budget.spend("verifier")
            return {"answer": "Refunds within 30 days.",
                    "spend": budget.calls, "per_stage": dict(budget.by_stage)}
        question += " policy"
    return {"answer": "I don't know.", "spend": budget.calls,
            "per_stage": dict(budget.by_stage)}

print(run("refund window", Budget()))
print(run("meaning of life", Budget(max_iters=2)))
try:
    run("x", Budget(max_calls=2))
except RuntimeError as e:
    print("STOPPED:", e)

The per-stage counts are your observability dashboard in miniature: when cost regresses, the breakdown tells you the router is over-calling or the loop is thrashing. Trace each stage in production (I4).

Exercise 6 · Design a production multi-agent research assistant under a latency + cost SLOIndustry scenario

Context: You are asked to design a research assistant: given a question, it retrieves from an internal index and the web, drops junk, writes a cited answer, and verifies faithfulness — all under a p95 latency SLO and a per-query cost ceiling, with an honest 'I don't know' when it can't ground the answer.

Your task: Produce a design + an offline reference orchestrator that models the whole pipeline (router → multi-source retrieve → fuse → grade → synth → verify) under a shared budget, then sketch the framework mapping labelled as needing the SDK.

Requirements:

  • Model all five stages with a shared budget (iterations + calls) enforcing the SLO
  • Fuse multi-source retrieval (reuse RRF from Exercise 4) before grading
  • Verifier can send back once within budget; else return honest 'I don't know'
  • Emit a per-stage trace + total spend for observability
  • State the end-to-end eval you'd gate on (grounded-answer rate + verifier catch-rate, FA5 + §5)
  • Include a commented LangGraph/CrewAI mapping clearly labelled 'needs the lib / API key'

💡 Hint: Everything except the model calls is offline-runnable. The SLO is enforced by the budget object; the design doc is the deliverable that ties per-stage evals to the ceiling.

Show solution

The offline reference pipeline under an SLO budget, framework mapping labelled. Runnable:

from collections import defaultdict

def rrf(rankings, k=60):
    s = defaultdict(float)
    for r in rankings:
        for i, d in enumerate(r): s[d] += 1/(k+i+1)
    return sorted(s, key=lambda d: -s[d])

INDEX = {"refund": "Refunds within 30 days.", "ship": "Shipping 3-5 days."}
WEB   = {"refund": "Blog: refunds honored in 30 days."}

class Budget:
    def __init__(self, max_iters=2, max_calls=14):
        self.max_iters, self.max_calls, self.iters = max_iters, max_calls, 0
        self.by_stage = defaultdict(int)
    @property
    def calls(self): return sum(self.by_stage.values())
    def spend(self, stage, n=1):
        self.by_stage[stage]+=n
        if self.calls>self.max_calls: raise RuntimeError("SLO cost ceiling hit")
    def next_iter(self):
        self.iters+=1; return self.iters<=self.max_iters

STOP={"is","are","the","a","of","within","in"}
def faithful(draft, ctx):
    c=set(w for t in ctx for w in t.lower().split())-STOP
    a=[w for w in draft.lower().split() if w not in STOP]
    return (sum(w in c for w in a)/len(a) if a else 0)>=0.7

def assistant(question, budget):
    trace=[]
    while budget.next_iter():
        budget.spend("router")
        key=next((k for k in INDEX if k in question.lower()), None)
        # multi-source retrieve + fuse
        budget.spend("retriever", 2)
        idx=[key] if key in INDEX else []
        web=[key] if key in WEB else []
        fused=rrf([idx, web]) if (idx or web) else []
        budget.spend("grader")
        ctx=[INDEX.get(d) or WEB.get(d) for d in fused if (INDEX.get(d) or WEB.get(d))]
        trace.append((budget.iters, "hit" if ctx else "miss", budget.calls))
        if not ctx:
            question+=" policy"; continue
        budget.spend("synth"); draft=" ".join(ctx)
        budget.spend("verifier")
        if faithful(draft, ctx):
            return {"answer": draft, "cost": budget.calls,
                    "per_stage": dict(budget.by_stage), "trace": trace}
        # unfaithful -> loop back within budget
    return {"answer": "I don't know — could not ground the answer.",
            "cost": budget.calls, "per_stage": dict(budget.by_stage), "trace": trace}

import json
print(json.dumps(assistant("what is the refund window", Budget()), indent=2))
print(json.dumps(assistant("who won the election", Budget()), indent=2))
# needs the lib / an API key -- production mapping (verify against current docs):
# LangGraph: StateGraph(dict) with nodes router/retriever/grader/synth/verify;
#   conditional edges enforce the bounded loop; compile() -> app. (L4)
# CrewAI:   role-based agents (researcher/grader/writer/critic) + a manager process;
#   the budget becomes a max_iter / step cap. (M1)
# Enforce the SLO with the Budget object; gate deploys on end-to-end grounded-answer
# rate AND verifier catch-rate from a held-out eval set (FA5 + section 5).

Design notes: the SLO is enforced by the shared Budget (iterations bound latency, calls bound cost); multi-source retrieval is fused with RRF before grading; the verifier gets one bounded send-back before the honest give-up. Ship it only when the end-to-end grounded-answer rate and verifier catch-rate clear your bar — that is the gate the crew project (P12) formalizes.

✓ Checkpoint — you can move on when you can…

  • Say when a multi-agent RAG pipeline earns its cost — and when M3's single agent is enough.
  • Name the five roles and what each reads/writes in shared state.
  • Wire the pipeline with conditional routing and a bounded retrieve→grade→re-retrieve loop.
  • Guard loop-forever, cost blow-up, and grader/verifier disagreement — and give up honestly.
  • Evaluate the pipeline end-to-end (grounded-answer rate + verifier catch-rate), not recall alone.
🏗️ Toward the capstoneThe DevOps agent's runbook flow is a natural multi-agent RAG pipeline: a router picks runbook vs. dependency-graph retrieval, a grader drops irrelevant runbooks, a synthesizer drafts the fix with citations, and a verifier checks the fix is grounded before the L5 safety gate ever sees it. See the RAG + safety-gate build →

Knowledge check

✓ Knowledge check

When is a multi-agent RAG pipeline worth its extra cost over the single-agent agentic RAG of M3?

Show answer
When one agent's single prompt is doing too many jobs at once — planning, retrieving from several sources, judging relevance, writing, and verifying — so splitting them into specialized agents lets each get its own prompt, its own (cheaper or stronger) model, and its own eval gate. If a single agent with a bounded retrieve→grade→rewrite loop (M3) already answers well, that is the right default; the pipeline only pays off when a specific stage genuinely needs to be its own thing (e.g. a distinct faithfulness verifier that can send a draft back).
✓ Knowledge check

Why do you need both a grader and a verifier, and what stops the pipeline from looping forever between them?

Show answer
The grader protects the synthesizer from bad input (it drops irrelevant retrieved chunks before synthesis); the verifier protects the user from a bad output (it checks the finished draft is faithful to the context and can send it back). They guard different failures, so both are needed. What bounds the loop is a shared iteration/budget cap across every back-edge plus giving the verifier authority to end the run — when the budget is exhausted the pipeline returns an honest 'I don't know' rather than re-looping or fabricating an answer.
© 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