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.
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.
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.
| Signal | One agent + tools (M3) | Multi-agent pipeline (this lesson) |
|---|---|---|
| Retrieval is a simple lookup | ✅ Just call a retriever tool | Overkill |
| One prompt is juggling 4+ jobs (plan, retrieve, judge, write) | Prompt gets brittle | ✅ Split into specialized agents |
| Multiple sources / indexes to fuse | Hard to reason about in one prompt | ✅ A dedicated retriever/fusion stage |
| You need a distinct faithfulness/hallucination gate | Bolted onto the answer step | ✅ A separate verifier agent that can send back |
| Each stage needs its own model / cost tier | One model for everything | ✅ Cheap grader, strong synthesizer |
| You want per-stage evals & observability | One opaque trace | ✅ Each agent is independently measurable |
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:
| Agent | Job (one thing) | Reads / writes in shared state |
|---|---|---|
| Router / Planner | Classify the question; pick sources; optionally split into sub-questions | reads 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-ranker | Score each candidate for relevance; drop junk; keep the top-k | reads candidates → writes context |
| Synthesizer | Write a grounded answer from context, with citations | reads context → writes draft, cites |
| Verifier / critic | Check the draft is faithful to context; can send back to retrieve or re-synthesize | reads draft, context → writes verdict |
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.
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 ... ]
}
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:
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.
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 mode | What it looks like | Control |
|---|---|---|
| Loop forever | grader keeps rejecting → retrieve → grade → retrieve … | Bound every back-edge with a shared tries/iteration cap (M3's lesson, now across stages) |
| Cost blow-up | each agent = tokens; a 3-retry × 5-agent run is 15+ model calls | A budget guard: cap total calls/tokens; use a cheap model for router/grader, a strong one only for synth |
| Grader/verifier disagreement | grader keeps a chunk the verifier then rejects — thrash | Give the verifier authority to end the run; log the disagreement; don't re-loop indefinitely |
| No graceful give-up | pipeline invents an answer rather than admitting defeat | On 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.
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
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:
| Layer | Question it answers | Metric |
|---|---|---|
| Per-stage | Is each agent doing its job? | Retriever: recall@k (FA5). Grader: precision of kept chunks. Verifier: catch-rate on planted-unfaithful drafts. |
| End-to-end | Given 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.
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}
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
- Runs as stages — deploy on the same infra as any LLM service (K7); heavy retrieval/fusion can be its own scalable service.
- Observability per agent — trace each stage separately (I4); the per-stage metrics from §5 are your dashboards.
- 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). - Cost tiers — cheap model for router/grader, strong model for synthesizer/verifier; the budget guard from §4 enforces the ceiling.
- Treat retrieved text as untrusted — more retrieval hops = more prompt-injection surface (T1); keep destructive actions gated.
Common pitfalls
| Pitfall | Fix |
|---|---|
| Multi-agent pipeline where one agent + tools (M3) would do | Start with M3; split only when a stage needs its own prompt/model/eval |
| Any back-edge without a bound | Share a tries/budget cap across all stages; give up gracefully |
| No verifier — the synthesizer marks its own homework | Add a separate faithfulness gate that can send the draft back |
| Grader and verifier fighting forever | Let the verifier end the run; log the disagreement instead of re-looping |
| Evaluating only retrieval recall | Add end-to-end grounded-answer rate + verifier catch-rate (FA5 + §5) |
| Trusting retrieved text across many hops | Every source is untrusted input; guard injection (T1) |
| Assuming the pattern replaces good chunking/rerank | Fix 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.
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.
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.
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.
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.
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).
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.
Knowledge check
When is a multi-agent RAG pipeline worth its extra cost over the single-agent agentic RAG of M3?
Show answer
Why do you need both a grader and a verifier, and what stops the pipeline from looping forever between them?