AI EngineeringZero to ProductionHome·About·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

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.

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

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?
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

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.

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.

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:

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

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 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.

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

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:

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.

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)

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:

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