Fundamentals & Architecture
What an agent actually is, when a loop earns its cost, and how to pick between ReAct, Planner-Executor, and multi-agent.
Asked at Anthropic · OpenAI · Sierra
open question"A chatbot is a single LLM call — input in, text out, stateless. An agent is an LLM inside a loop. The loop gives it tools, memory, and the ability to take actions in the world. The agent decides what to do next based on observations. The key difference is autonomy — an agent can reason, act, observe, and iterate until a task is complete. The 'agent' is actually the while-loop your code runs around the LLM, not the LLM itself."
What the loop actually looks like
Same model in both rows. The only difference is what your code does with the model's output — return it, or feed it back in.
Side-by-side, the way an interviewer wants it
| Dimension | Chatbot | Agent |
|---|---|---|
| Control flow | Fixed by your code: one call, text out | Chosen by the model each iteration — it decides the next step |
| State | Stateless (or raw chat history re-sent) | Working memory carried across steps |
| Tools | None, or retrieval only | Calls APIs, databases, code — can write to the world |
| Latency / cost | ~600ms, ~$0.001 per question | 3–8 loop iterations, seconds, cents per task |
| Failure mode | Wrong answer — user reads it and moves on | Wrong action — refund issued, email sent. Needs guardrails |
| Ship it when… | Q&A, FAQ deflection, drafting | Multi-step tasks with side effects |
A support copilot runs both, tiered. The FAQ tier is a chatbot — one call, ~400 tokens, ~600ms, ~$0.001/question — it deflects “what’s your refund policy?”. The resolution tier is an agent: for “refund my last order” it loops lookup_order → check_policy → issue_refund → send_confirmation — ~5 iterations, ~6K tokens, ~8s, ~$0.03 — but it closes the ticket end-to-end. Same base model; the wrapper decides which one it is. Routing between the two tiers is where most of the money is saved.
“So is RAG an agent?” — No. Vanilla RAG is a fixed pipeline: retrieve → stuff → generate, same path every time. It becomes agentic when the model decides whether to retrieve, what to retrieve, and when to stop. Quick litmus test: if your control flow lives in Python if statements, it’s a pipeline; if it lives in the model’s next-token distribution, it’s an agent.
Asked at Anthropic · Google · Glean
open question"If the task is single-turn retrieval + generation — user asks a question, you find the answer in docs — RAG is cheaper, faster, and more predictable. I'd reach for agents only when:
- (1)the task requires multiple steps
- (2)it needs tool use (write operations, calculations, API calls), or
- (3)the solution path is not known upfront and requires reasoning. An agent adds 3-10x the cost and latency of RAG. The tradeoff is autonomy vs predictability."
The three gates
Ask them in order and stop at the first yes. Most “we need an agent” requests fail all three — they are a retrieval problem wearing a loop.
What you give up when you add the loop
| Dimension | RAG pipeline | Agent |
|---|---|---|
| Control flow | Fixed in your code: retrieve → stuff → generate | Chosen by the model at every step |
| Latency | One round trip, p50 ≈ 0.8s | 3–8 round trips, seconds |
| Cost | 1× baseline | 3–10× — history is re-sent every iteration |
| Reproducibility | Same input, same path, every time | Path varies per run — you debug traces, not code |
| Failure mode | Wrong answer; the user reads it and moves on | Wrong action; the refund is already issued |
| Eval effort | Retrieval hit rate + answer accuracy | Outcome and trajectory scoring, per task type |
The middle ground most teams skip
Between the two there is a pipeline that keeps its fixed shape but lets the model make one bounded decision:
- Conditional retrieval — the model decides whether to search before answering. One extra call, no loop.
- Query rewriting — one rewrite pass before retrieval; fixes most “retrieval missed it” complaints for ~$0.0002.
- Bounded re-query — if the top chunk scores below threshold, retrieve once more with a different query. Hard cap of two.
- Read-only tools — a loop with lookups but no writes. You get multi-step reasoning with none of the blast radius.
An internal docs assistant handles ~60K questions/month. Classification showed 82% are single-hop lookups — those run as plain RAG at ~$0.002 and ~0.8s. The remaining 18% (“compare our Q2 and Q3 policy and tell me what changed for contractors”) route to an agent at ~$0.04 and ~7s. Running everything through the agent would have cost ~$2.4K/month for ~$430 of actual work, and pushed p50 latency from 0.8s to 7s for the 82% who never needed it.
“Retrieval keeps missing the answer — won’t an agent fix that?” — No. An agent that searches a broken index just pays 5× to fail more slowly, and now it fails non-deterministically so you cannot reproduce the bug. Fix chunking, add hybrid keyword + vector search, and measure recall@5 first. Reach for the loop when recall is good and the task still needs several steps — not when retrieval is bad.
Asked at OpenAI · Microsoft · Sierra
open question"Decision tree: ReAct when the task is exploratory and the path isn't known upfront (research, diagnosis). Planner-Executor when the task has clear phases and I want auditability — the plan is a human-readable artifact I can approve before execution. Great for compliance-heavy workflows. Multi-Agent only when there are genuinely separable domains of expertise — e.g., a researcher who searches the web and an analyst who runs SQL shouldn't share a context window. Multi-agent is a tool, not a default — it adds coordination overhead and debugging complexity."
The three shapes, side by side
The pattern is not a taste question — it follows from whether the path is known upfront and whether anyone must approve it.
Choose on the failure you can live with
| Pattern | Reach for it when | What it costs | How it fails |
|---|---|---|---|
| ReAct | The path is discovered as you go — diagnosis, research, triage | Unbounded steps; latency and cost you cannot quote in advance | Wanders: re-calls tools, never converges, stops at the iteration cap |
| Planner-Executor | Phases are known and a human must sign off before anything runs | One extra planning call, plus human wait time in the middle | A wrong plan is executed faithfully — the executor never questions it |
| Multi-Agent | Genuinely separate expertise whose contexts should not mix | N× the tokens, plus handoff serialization and cross-agent tracing | Facts get lost in handoffs; the orchestrator cannot arbitrate a disagreement |
Why the plan artifact is the whole point
Planner-Executor wins compliance reviews because the plan is inspectable before anything happens — a diff a human can approve, not a trace they read afterwards:
{
"task": "close out the Q3 vendor invoice dispute",
"plan": [
{ "step": 1, "tool": "fetch_invoices", "args": { "vendor_id": "V-8841" },
"reads": ["billing.invoices"], "writes": [] },
{ "step": 2, "tool": "diff_against_contract", "args": { "contract_id": "C-119" },
"reads": ["legal.contracts"], "writes": [] },
{ "step": 3, "tool": "open_credit_memo", "args": { "amount_cap_usd": 5000 },
"writes": ["billing.credit_memos"], "requires_approval": true }
],
"approval": { "because": "step 3 moves money", "approver_role": "ap_manager" }
}
Every step declares what it reads and writes, so the approval gate is derived rather than remembered. ReAct cannot produce this document: step 3 does not exist until step 2 returns.
An incident-triage system started as three agents — logs, metrics, deploy history — behind an orchestrator. Median resolution burned ~31K tokens and ~48s, and 1 run in 5 lost a fact across a handoff: the log agent found the bad deploy, the orchestrator’s summary dropped the timestamp. One ReAct agent holding all three tools took it to ~12K tokens and ~19s, with lost-context failures near zero. Multi-agent came back for one case: the customer-data agent, which had to run under a different service account.
“More specialists must be better — why not five agents?” — Because every handoff is a lossy summary. One agent passes full observations from step N to step N+1; a handoff passes only what the orchestrator wrote down. Split when contexts must not mix — different credentials, different data residency — not because the org chart has specialists.
Asked at Sierra · Cursor · Anthropic
open question"I'd diagnose in this order:
- (1)Check tool descriptions — are they ambiguous? If the agent can't tell which tool to use, it tries them all. Fix the descriptions.
- (2)Check if it's re-calling the same tool — add deduplication.
- (3)Check context bloat — after 10 calls, the context is so long the model loses track. Add observation summarization.
- (4)Consider a planner step — if the agent is wandering, a plan upfront constrains the path.
- (5)Check the system prompt — add explicit guidance like 'you should need at most 5 tool calls for this type of task.'"
Read the trace before you touch the prompt
Fifteen calls is not one bug. Pull ten traces and count what each call contributed — the shape of the waste names the fix.
Five shapes of waste and what each one means
| What you see in the trace | Root cause | Fix |
|---|---|---|
| Same tool, same arguments, three times | No dedup — the answer is in context but buried | Cache by (tool, sorted(args)); reuse the result |
| Cycles through four similar-sounding tools | Descriptions overlap; it is guessing between near-ties | Add a “not for X — use Y” clause to each |
| Calls get vaguer after step 8 | Context bloat — raw output crowded out the goal | Summarize observations to 2–3 lines; re-state the goal |
| Never takes the same path twice | No plan — every step is an independent decision | Plan first, execute it, re-plan only on failure |
| Only ever stops at the iteration cap | No stop condition the model can reach | State the budget in the prompt and cap it in code |
Two guards, both in your code
def step(state, call):
sig = (call.tool, json.dumps(call.args, sort_keys=True))
if sig in state.seen: # never pay twice for one question
return Obs(state.seen[sig], note="identical args — reusing the earlier result")
if state.calls >= HARD_CAP: # a cap the model cannot argue with
return Obs(None, note="tool budget spent — answer from what you have")
state.seen[sig] = execute(call)
state.calls += 1
return summarize(state.seen[sig], max_lines=3)
The note matters as much as the guard — told only “no result”, the model retries with a cosmetic argument change.
A billing-support agent averaged 14.6 tool calls on a 3-call task. Traces showed 61% were exact duplicates and 22% were get_account vs fetch_customer ping-pong — descriptions differing by one adjective. Dedup, merged descriptions, and a stated 5-call budget took it to 3.4 calls, p50 9.0s → 2.1s, $0.21 → $0.05 per task. Success went up, 68% to 94%: each extra call was another chance to derail, not to succeed.
“Just cap it at 3 calls — problem solved?” — A cap alone turns a slow success into a fast failure: the agent hits the wall mid-task and answers from partial data, which is worse because nothing looks broken. Cap and fix the cause — dedup so the budget is not spent on repeats, and make the cap a graceful exit that names what it could not finish.
Asked at OpenAI · Databricks · Scale AI
open question“Three levers, and they fix different failures. Prompting fixes instruction-following — the model can do the task, it just isn’t doing it your way. Retrieval fixes missing knowledge — the model doesn’t know your data, or your data changes daily. Fine-tuning fixes consistency — the model knows how, but drifts on format or tone across thousands of calls, or you need a smaller model to hit a latency and cost target.
The order matters: prompt, then retrieval, then fine-tune. Most teams reach for fine-tuning first because it sounds like the serious answer, spend six weeks building a dataset, and find out the real problem was a vague system prompt.
In production they combine. Fine-tune for shape, retrieve for facts. What I never do is fine-tune facts into a model, because then every fact change is a retraining job.”
Match the lever to the failure
The symptom tells you which one you need
The mistake is almost always reaching one rung too high.
| Symptom | What teams reach for | What actually fixes it |
|---|---|---|
| Output format wanders | fine-tune | schema-constrained decoding plus two examples |
| Answers are a week stale | fine-tune | retrieval with a freshness filter |
| Cites documents that don’t exist | fine-tune on the docs | retrieval with the id checked against the index |
| Tone is off-brand 1 reply in 5 | more prompt rules | fine-tune on 2–5K approved replies |
| p95 is 4s on a frontier model | caching | distil to a small fine-tuned model |
| Cannot do the domain reasoning at all | fine-tune | a stronger base model — tuning does not add reasoning |
What a fine-tune actually buys
The honest case for fine-tuning is rarely quality. It is holding quality while the model gets small enough to be fast and cheap.
| Frontier model, prompted | 8B model, fine-tuned | |
|---|---|---|
| Format compliance | ~71% | ~98% |
| p95 latency | 3.4s | 0.9s |
| Cost / 1K replies | $4.10 | $0.38 |
| Time to change behaviour | minutes | a retrain cycle |
| Facts come from | retrieval | retrieval — still |
A support-reply system had a tone problem: about one reply in five read wrong for the brand, and no amount of prompt rules held it. We fine-tuned an 8B model on ~12K human-approved replies — format compliance went ~71% → ~98%, p95 3.4s → 0.9s, and cost per thousand replies $4.10 → $0.38. Every fact in those replies still came from retrieval, so a pricing change is a reindex, not a retrain.
“Why not fine-tune on your docs and drop RAG entirely?” — because you retrain on every document change, you cannot cite a source, you cannot apply per-user permissions at retrieval time, and you cannot delete a fact when a customer asks you to. Fine-tuning teaches shape; retrieval supplies facts. A model with the facts baked in is confidently wrong the day the facts move.
Asked at Sierra · Databricks · Glean
open question“Six stages — chunk, embed, index, retrieve, rerank, generate. The interesting decisions are at the two ends.
Chunking: I start at about 512 tokens with 10–15% overlap for prose, but I don’t chunk structured documents on a token count at all. I chunk them on their own boundaries — a section, a whole table, a function. The failure everyone hits once is splitting a table so that neither half means anything.
Retrieval: dense alone misses exact strings — part numbers, error codes, names. So hybrid. BM25 and vector search in parallel, fused with reciprocal rank fusion, then a cross-encoder rerank over the top 50 down to the five that go in the prompt.
And the number I actually track isn’t similarity score, it’s whether the chunk containing the answer made it into the context. I measure recall on a labelled set before I tune anything else.”
Where quality is lost
Chunk on the boundary, not on the number
512 tokens is a default for prose and wrong for everything else.
| Content | Chunk on | Size | Why |
|---|---|---|---|
| Policies, prose docs | heading + paragraph | ~512 tok, 64 overlap | keeps a claim with its qualifier |
| API reference | one endpoint | whatever it is | half an endpoint is worse than none |
| Tables, spreadsheets | the whole table + caption | whole | a split row loses its header row |
| Code | function or class | whole | a signature without its body retrieves nothing |
| Transcripts | turn windows | 6–10 turns | pronouns need their antecedent in scope |
Retrieve wide, rerank narrow
Each stage buys recall; the rerank spends a little of it back to buy prompt space.
| Stage | Returns | Added p50 | Answer chunk present |
|---|---|---|---|
| BM25 only | 50 | 12 ms | 71% |
| Vector only | 50 | 28 ms | 78% |
| Hybrid, fused with RRF | 50 | 34 ms | 91% |
| + cross-encoder → top 5 | 5 | 170 ms | 88% |
The last row gives up three points of recall and cuts prompt tokens roughly tenfold. That is the trade, and it is usually worth taking.
An enterprise policy corpus — ~240K chunks, heavy on tables. Recall@5 sat at ~61% and the agent’s answers were right ~68% of the time. Two changes: stop splitting tables (re-chunk on document structure) and add BM25 alongside the vector search with a cross-encoder rerank. Recall@5 went to ~88% and answer accuracy to ~84%, with ~200 ms added to p50. No prompt or model change was involved.
“Context windows are huge now — why not skip retrieval and paste everything in?” — cost scales linearly with tokens on every single call, attention degrades in the middle of a long context, and you lose the two things retrieval gives you for free: a citation, and a place to enforce per-user permissions. Retrieval is an access-control boundary, not only a cost trick.
Asked at OpenAI · Stripe · Amazon
open question“First, don’t ask for JSON in prose and hope. Use the provider’s constrained decoding — attach a schema to the request so invalid tokens are masked at sampling time and the output parses by construction.
That buys you syntactic validity, not semantic validity. The model will happily return a perfectly-shaped object with a date in the wrong format, an enum value it invented, or line items that don’t add up to the total it also gave you. So there are two layers: constrained decoding for shape, then my own validator for meaning.
When validation fails, I retry exactly once with the validation error echoed back — ‘currency must be one of USD, EUR, GBP; you sent US Dollars’. That fixes most of them. If the second attempt fails, it goes to a fallback path or a human. It does not loop.”
Two gates, two different failures
What still goes wrong after the JSON parses
| Failure | What it looks like | Guard |
|---|---|---|
| Invented enum | "status": "in-progress-ish" | literal enum in the schema; reject and echo the allowed values |
| Ambiguous unit | "amount": 1250 — cents or dollars? | require an explicit currency and minor-unit field |
| Doesn’t reconcile | line items sum to 1180, total says 1250 | compute the total yourself; never trust the model’s arithmetic |
| Hallucinated id | an order_id that has never existed | resolve every id against the system of record before acting on it |
| Silent truncation | 3 of 47 line items, and it parses | check the stop reason, not just whether it parsed |
The retry that works
One repair attempt, carrying the specific error. Not a loop, and not a bare retry of the same prompt — that just resamples the same mistake.
def extract(doc, attempt=1):
resp = client.messages.create(model=MODEL, tools=[INVOICE_SCHEMA],
messages=build(doc))
if resp.stop_reason == "max_tokens":
raise Truncated(doc.id) # parses fine, is missing rows
obj = resp.content[0].input # shape already guaranteed
errors = check_business_rules(obj) # enums, units, totals, ids
if not errors:
return obj
if attempt == 2:
return escalate(doc, errors) # human queue; do not keep looping
return extract(doc, attempt + 1, repair_hint=render(errors))
Invoice extraction, ~40K documents a month. Moving from “respond in JSON” to schema-constrained decoding took hard parse failures from ~4.2% to zero. The business-rule validator then caught another ~6.1% that parsed perfectly — mostly invented currency codes and totals that didn’t match the line items. One repair retry cleared ~81% of those, leaving ~1.2% for a human. The truncation check was added after a month of invoices that were quietly missing their last rows.
“If decoding is constrained, why validate at all?” — the grammar only guarantees the shape you asked for. Its most dangerous failure is the one that looks like success: hit the token limit mid-array and you can still get a well-formed object with three of forty-seven rows. It parses, it validates against a loose schema, and it is wrong. Check the stop reason on every call.
Asked at Anthropic · Cursor · Sierra
open question“The agent loop is about forty lines — call the model, if it asked for tools run them, append the results, call again, stop on a final answer or a step cap. That’s not the hard part, and adopting a framework doesn’t remove the hard part.
What a framework does give you is the machinery around the loop: retries, streaming, tracing, checkpointing, human-in-the-loop pauses, durable state. Writing all of that yourself is real work, and that’s the honest argument for using one.
What it costs you is a layer between you and the model. When tool selection goes wrong you need to see the exact request that went over the wire, and that’s harder through three abstractions — especially when a version bump quietly edits the prompt underneath you.
So: own the loop and own the prompt, adopt libraries for the plumbing. If a framework won’t show me what the model was actually sent, that’s disqualifying.”
What is actually in the loop
Decide per concern, not per framework
This is not an all-or-nothing choice, and treating it as one is how teams end up rewriting a workflow engine badly.
| Concern | Default | Why |
|---|---|---|
| Loop control and step cap | own | forty lines, and it is where your policy lives |
| System prompt, tool descriptions | own | this is the product — no upgrade should edit it |
| Retries, backoff, rate limits | adopt | solved, boring, and easy to get subtly wrong |
| Tracing and spans | adopt | you want OpenTelemetry, not a bespoke logger |
| Durable state, resume after crash | adopt | a real workflow engine beats a status column |
| Human-in-the-loop pause | split | adopt the mechanism, own the policy that triggers it |
The loop, in full
def run(messages, tools, max_steps=8):
for _ in range(max_steps):
reply = model.call(messages, tools=tools) # your prompt, your schemas
messages.append(reply)
if not reply.tool_calls:
return reply.text
for call in reply.tool_calls:
messages.append(execute(call)) # timeouts and retries here
raise StepBudgetExceeded(max_steps) # never spin forever
A support agent on an off-the-shelf framework was picking the wrong tool about 9% of the time and nobody could say why — the framework was injecting its own tool-choice preamble ahead of ours. Dropping to a hand-written loop (~60 lines) made the request inspectable, and the actual fix was one sentence in a tool description. Selection errors fell to ~2%. Retries, tracing and the workflow store all stayed on libraries; the loop and the prompt came in-house.
“So frameworks are bad?” — no. The bad version is adopting one so you don’t have to understand the loop. If you can’t draw what goes over the wire on each step, the framework is a liability the first time production misbehaves. Teams that understand the loop use frameworks well, and switch off the parts that fight them.