When a pipeline beats an agent: three shapes that don't need a loop A developer's field notes argue that many LLM tasks are over-engineered as agents, and instead propose three fixed pipeline shapes—sequential steps, single classification, and parallel fan-out—that avoid the quadratic token cost and failure surface of model-controlled loops. The post includes code examples showing how to implement these patterns with multiple model calls but no runtime branching, and advises handling rare deviations as explicit branches rather than turning the whole pipeline into a loop. Originally published on Loop & Retry — field notes on building LLM agents that survive production. When not to build an agent https://loopandretry.github.io/posts/when-not-to-build-an-agent/?ref=devto made the case in the abstract: an agent is an LLM that controls its own control flow, and that control costs you quadratic tokens, serial latency, and a failure surface no unit test can cover, on every single run. What that post didn't give you is the thing you reach for instead . This is that post — three pipeline shapes, each a fixed sequence of calls with no model-decided branching, that between them cover most of the tasks I've seen get a loop by default. The shared property across all three: you can draw the flowchart of every possible run before you execute a single one. That's the actual dividing line, not "does it call an LLM more than once" — all three shapes below call a model multiple times. What they don't do is let the model decide, at runtime, what step comes next. The simplest shape and the most commonly reached-for-a-loop task: a fixed sequence of steps, each feeding the next, where the order is known in advance even though the content isn't. Extract, then validate, then format is the canonical example. php def process ticket raw text: str - dict: extracted = client.messages.create model="claude-sonnet-4-5", max tokens=512, messages= {"role": "user", "content": f"Extract fields as JSON: {raw text}"} , fields = json.loads extracted.content 0 .text validated = client.messages.create model="claude-sonnet-4-5", max tokens=256, messages= {"role": "user", "content": f"List any missing/invalid fields: {fields}"} , issues = json.loads validated.content 0 .text if issues: return {"status": "needs review", "fields": fields, "issues": issues} formatted = client.messages.create model="claude-sonnet-4-5", max tokens=256, messages= {"role": "user", "content": f"Format for the ticketing API: {fields}"} , return {"status": "ok", "payload": formatted.content 0 .text} Three model calls, zero loops. Nothing here decides "what to do next" at runtime beyond a single if issues branch, and that branch has exactly two known destinations. Compare this to an agentic version of the same task — a loop where the model decides after each step whether to extract again, validate again, or call a different tool — and you're paying for a decision that, in the actual failure data, almost always resolves to "proceed to the next fixed step anyway." You're running an agent to reimplement a straight line. The tell that you've over-built this into an agent: if you trace real runs and the "decide what's next" step picks the same next step upward of, say, 95% of the time, you've built a loop around a straight line and paid the loop's tax for the 5% case. Handle that 5% as an explicit branch like issues above , not as license for the whole pipeline to become a loop. The task genuinely needs a decision — but the decision is a single classification, not an open-ended sequence. A support ticket needs to go to one of five fixed playbooks; a document needs one of three fixed extraction templates. Bound the model's discretion to exactly one classification call, then hand off to ordinary code: HANDLERS = { "billing": handle billing ticket, "bug report": handle bug ticket, "access request": handle access ticket, "feature request": handle feature ticket, "other": handle general ticket, } def route ticket raw text: str - dict: classification = client.messages.create model="claude-haiku-4-5", max tokens=32, messages= {"role": "user", "content": f"Classify into exactly one of {list HANDLERS }: {raw text}"} , .content 0 .text.strip handler = HANDLERS.get classification, handle general ticket return handler raw text each handler is its own fixed chain Shape 1 This is the shape people mean when they say "the agent decides what to do" — and it's true, narrowly. It decides once , among a known, enumerable set of outcomes, and every outcome routes to code you wrote and can test independently of the model. Compare that to a real agent loop, where the model can in principle keep re-deciding after every step, with a branching factor that compounds with every turn. A five-way classification with five fixed downstream chains is testable exhaustively — five cases, five expected handlers. A loop with five tools available at every one of ten steps has, in the worst case, five-to-the-tenth possible trajectories, and you will test approximately none of them. Independent sub-tasks over a known list, with no dependency between them, then a fixed combine step. Summarizing forty documents and writing one digest is the standard example — each summary doesn't need to know about the others, and the number of summaries is known before you start. php async def digest documents docs: list str - str: summaries = await asyncio.gather summarize one doc for doc in docs N independent calls, fixed N, no loop return client.messages.create model="claude-sonnet-4-5", max tokens=1024, messages= {"role": "user", "content": f"Combine these {len summaries } summaries into one digest:\n" + "\n---\n".join summaries } , .content 0 .text This is the shape most likely to get mislabeled as needing an agent purely because it involves "a lot of LLM calls." It doesn't need control flow — it needs concurrency, which is a much cheaper problem. The fan-out calls parallelize wall-clock cost is one call deep, not forty calls deep , each one fails and retries independently without touching the others, and the reduce step is a single deterministic hand-off. None of that requires anything to decide what happens next at runtime. All three shapes share the same limit: they work exactly as long as the sequence of steps is knowable ahead of time, even when the content of each step isn't. The real test for whether a task needs a loop is whether the branching factor is knowable in advance — not whether the task is "complex," not whether it calls a model more than once, but whether you can enumerate the graph of possible next-steps before you've seen this run's intermediate results. A debugging agent that has to decide, based on what a failing test actually says, whether to read a file, run a different test, or grep the codebase — and where that decision genuinely depends on content you can't predict, and can recur an unknown number of times — is a real case for a loop. You can't pre-draw that flowchart, because the number of nodes in it depends on what today's failure looks like. That's the difference between "the model picks one of five known destinations once" Shape 2 and "the model picks the next of an unknown number of destinations, repeatedly, based on results it hasn't seen yet" an actual agent — and it's worth writing down the expected graph for your task before you build either one, because most tasks that people bring a loop to turn out, on inspection, to have already fully enumerable graphs.