In the last post, I covered an important difference between workflows and agents: Workflows, or workflow patterns, are systems where LLMs are exercised (used) through predefined code paths. Agents, or agentic systems, are systems in which LLMs direct their own processes and tool use.
Anthropic reminds us clearly that you might not need an agentic system: “We recommend finding the simplest solution possible, and only increasing complexity when needed. This might mean not building agentic systems at all.”
Non-agentic systems that use LLMs to do some of most of their work are called workflows.
They are workflow applications that leverage LLMs to perform specific tasks but aren’t actually driven by the LLM itself. (Much of this post has been adapted from the public whitepaper “Building Effective Agents,” with more detailed explanations and code examples in Python provided by me.)
In the last post, I covered Prompt Chaining and Routing, the first two out of five workflow patterns.
In this post we will patterns #3, #4, and #5:
Parallelization (including section & versioning)
Orchestrator-Workers
Evaluator-Optimizer
Parallelization
Parallelization is like the Routing workflow, but we send things to multiple LLMs at the same time—in “parallel.”
Parallelization is effective when the subtasks can be divided up cleanly. In particular, because the operation happens in parallel, it will happen much faster than if each step were sequential.
Anthropic says: “For complex tasks with multiple considerations, LLMs generally perform better when each consideration is handled by a separate LLM call, allowing focused attention on each specific aspect.”
There are two kinds of parallelization: Sectioning and Voting.
With sectioning, you split one job into different pieces. A common use is guardrails. One call answers the user’s question. A second call, running at the same time, checks that same input for anything you don’t want to respond to. The two calls never see each other’s work. If the screener flags the input, you throw away the answer before it reaches the user. Anthropic notes this beats asking a single call to do both jobs, and the reason is straightforward: a prompt that says “answer this, and also police it” splits the model’s attention. A prompt that says “screen this” does one thing.
Evals work the same way. Say you want to score generated copy on tone, factual accuracy, and length. Three calls, one per criterion, all at once. Each prompt is short and specific. You get three scores back and combine them in your code.
With voting, every call gets the same input and answers the same question. What changes is the prompt, or nothing at all. Then you count the answers. Run five prompts against the same function looking for security problems. If three of the five flag something, you flag it. You set the threshold. One vote catches nearly everything, including many false alarms. Unanimous only surfaces the obvious. Content moderation lives on that dial.
Sectioning and voting can look similar in code. Both fan out, both wait, both collect. The difference is whether the calls could disagree. In sectioning, they can’t, because they’re answering different questions. In voting, they can, and the disagreement is the heart of what you’re measuring.

Sectioning: the guardrail
import anthropic
from concurrent.futures import ThreadPoolExecutor
client = anthropic.Anthropic()
def ask(prompt):
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=1024,
messages=[{"role": "user", "content": prompt}],
)
return response.content[0].text
def answer(query):
return ask(f"You are a helpful support agent. Answer: {query}")
def screen(query):
return ask(
f"Does this message ask for anything harmful, off-topic, or "
f"against policy? Reply with one word: SAFE or BLOCK.\n\n{query}"
).strip().upper()
def handle(query):
with ThreadPoolExecutor() as pool:
answer_future = pool.submit(answer, query)
screen_future = pool.submit(screen, query)
if screen_future.result() == "BLOCK":
return "Sorry, I can't help with that."
return answer_future.result()
print(handle("How do I reset my password?"))
Step through handle. ThreadPoolExecutor is Python’s thread pool. pool.submit hands it a function and its argument, then returns immediately with a future. The call is now running on a background thread. Submit twice and you have two calls in flight.
The with block waits for both threads to finish before it exits. After that, .result() pulls the return value out of each future. By the time you read them, the work is done, so neither .result() blocks.
Then you check the screener first. If it says BLOCK, you return the canned refusal and never touch the answer. The answer already came back and you throw it away.
That waste is deliberate. You paid for tokens you didn’t use. What you bought is time. The user waits for the slower of the two calls, not the sum of both. Run them in sequence and you’d save the tokens but double the wait on every clean request, which is most of them.
Voting: the security review
REVIEWERS = [
"Look for SQL injection. Reply VULNERABLE or SAFE, then one line why.",
"Look for missing authentication or authorization checks. Reply VULNERABLE or SAFE, then one line why.",
"Look for unvalidated user input. Reply VULNERABLE or SAFE, then one line why.",
"Look for secrets or credentials in the code. Reply VULNERABLE or SAFE, then one line why.",
"You are a senior security engineer. What is wrong with this code? Reply VULNERABLE or SAFE, then one line why.",
]
def review(instruction, code):
return ask(f"{instruction}\n\n```python\n{code}\n```")
def audit(code, threshold=2):
with ThreadPoolExecutor() as pool:
results = list(pool.map(lambda r: review(r, code), REVIEWERS))
votes = [r for r in results if r.strip().upper().startswith("VULNERABLE")]
return {
"flagged": len(votes) >= threshold,
"votes": len(votes),
"findings": votes,
}
code = """
def get_user(request):
uid = request.args.get("id")
return db.execute("SELECT * FROM users WHERE id = " + uid)
"""
print(audit(code))
Step through audit. REVIEWERS is a list of five instruction strings. pool.map takes a function and that list, calls the function once per item, and runs all five at the same time. The lambda is there to pin the second argument, since map only passes one thing per call: each reviewer instruction goes in, code stays the same every time.
map returns the results in the order of the input list, not the order the calls finish. Wrapping it in list() waits for all five and collects them.
Then we count it. The filter keeps any result whose text starts with VULNERABLE. len(votes) is the tally. Compare that against threshold and you get a boolean.
Note what is returned: the function returns the flag, the tally, and the actual findings, so the consumer can see which reviewers objected and why.
Orchestrator-Workers
Orchestrator-workers looks similar to parallelization. Both fan work out to multiple LLM calls and collect the results, but with the orchestrator-workers pattern, there’s an orchestrator LLM who decides what work gets done.
In parallelization, your code decides how to split the job into pieces before anything runs.
In orchestrator-workers, an LLM decides. A central “orchestrator” call looks at the input, breaks it into subtasks on the fly, and hands each one to a worker call. A final step pulls the worker outputs back together.
Anthropic frames the distinction this way: parallelization subtasks are “pre-defined,” while orchestrator-workers subtasks are “determined by the orchestrator based on the specific input” (Building Effective Agents). That’s the whole difference in one sentence. You lose the guarantee of parallelization’s fixed shape, but you gain the ability to handle a task where you can’t know the shape in advance.
Research and search tasks work this way. You don’t know how many sources are worth pulling until you’ve started pulling them. The orchestrator can spin up more workers, or fewer, depending on what the first few turn up.

import json
import anthropic
from concurrent.futures import ThreadPoolExecutor
client = anthropic.Anthropic()
def ask(prompt, system=None):
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=1500,
system=system,
messages=[{"role": "user", "content": prompt}],
)
return response.content[0].text
def plan(topic):
prompt = f"""You're planning research for a briefing document.
Topic: {topic}
Decide which subtopics need separate research to cover this well.
Reply as JSON: a list of objects with "subtopic" and "angle" keys,
where "angle" is one sentence on what to focus on for that subtopic.
Use 3 to 6 subtopics, no more than needed."""
raw = ask(prompt, system="Reply with JSON only, no markdown fences.")
return json.loads(raw)
def research(subtopic, angle, topic):
prompt = f"""You're researching one piece of a larger briefing on: {topic}
Your subtopic: {subtopic}
Your angle: {angle}
Write three or four sentences covering this subtopic. Be specific.
If you're not confident about a fact, say so instead of guessing."""
return ask(prompt)
def synthesize(topic, findings):
combined = "\n\n".join(f"{f['subtopic']}:\n{f['content']}" for f in findings)
prompt = f"""Topic: {topic}
Here's research gathered on separate subtopics:
{combined}
Write a briefing document. Open with a two sentence summary, then
cover each subtopic in its own short section. Flag anywhere the
research disagreed or left gaps."""
return ask(prompt)
def run(topic):
subtopics = plan(topic)
with ThreadPoolExecutor() as pool:
futures = {
pool.submit(research, s["subtopic"], s["angle"], topic): s
for s in subtopics
}
findings = []
for future, subtopic in futures.items():
findings.append({
"subtopic": subtopic["subtopic"],
"content": future.result(),
})
return synthesize(topic, findings)
briefing = run("Our main competitor's recent product launch")
print(briefing)
Let’s look at the run function above. The plan call (which is doing the orchestration), gets one input: the topic.
It returns a list of subtopics and an angle for each. Ask about a product launch, and you might get pricing, feature set, market reception, and positioning against your own product. Ask about a different topic, and you get a different list.
Each subtopic becomes a research call, and those run in parallel through the thread pool, same as parallelization. Each worker only sees its own subtopic and angle. It doesn’t see what the other workers are finding, so there’s no risk of one worker’s guess contaminating another’s.
synthesize is the piece that makes this pattern more than parallelization with extra steps. It concatenates the findings, then reads across them and writes something coherent, including calling out where two pieces of research contradict each other or where a subtopic came back thin. Since that’s a judgment call and not a formatting task, it gets an LLM call instead of a template.
When you don’t know how many angles a topic needs until you’ve looked at the topic, this pattern fits research better than a fixed pipeline. The orchestrator decides how much research the topic needs. A minor update gets two workers. A full launch gets six. You don’t pick that number in advance; you let the orchestrator decide.
Evaluator-Optimizer
The last pattern is Evaluator-Optimizer. It’s a loop. One LLM generates a result. A second LLM evaluates it and either accepts it or sends it back with feedback.
This pattern works well when you have a clear standard to check against, something closer to a pass or fail than a judgment call. When the evaluator rejects a result, it sends the generator all the previous attempts along with specific feedback on what was wrong. The generator uses both as input for the next attempt. That’s the refinement loop.
Anthropic uses the analogy of an author and an editor. The author sends a chapter. The editor reads it, marks it up, and sends it back. The author revises. That cycle repeats until the editor is satisfied.
The pattern also fits search well. A single search might not fully answer a real question. In this pattern, you run a query, look at what came back, and decide if you have enough to work with or if to try another pass.
An evaluator makes that call instead of you hardcoding a fixed number of search rounds. Some queries might resolve in one search. Others need three or four, each one narrower than the last based on what the previous round turned up.

import json
import anthropic
client = anthropic.Anthropic()
def ask(prompt, system=None):
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=1500,
system=system,
messages=[{"role": "user", "content": prompt}],
)
return response.content[0].text
def search(query):
# stand-in for a real search call
return f"[search results for: {query}]"
def generate_query(topic, history):
if not history:
prompt = f"Write a search query to research: {topic}"
else:
last = history[-1]
prompt = f"""Topic: {topic}
Previous query: {last['query']}
Previous results: {last['results']}
Evaluator feedback: {last['feedback']}
Write a better search query that addresses the feedback."""
return ask(prompt, system="Reply with just the search query, nothing else.")
def evaluate(topic, history):
combined = "\n\n".join(
f"Query: {h['query']}\nResults: {h['results']}" for h in history
)
prompt = f"""Topic: {topic}
Search history so far:
{combined}
Do we have enough information to write a complete, well-supported answer on this topic? Reply as JSON with keys "sufficient" (true or false) and "feedback" (one or two sentences: if sufficient, say why; if not, say specifically what's missing or what to search next)."""
raw = ask(prompt, system="Reply with JSON only, no markdown fences.")
return json.loads(raw)
def synthesize(topic, history):
combined = "\n\n".join(
f"Query: {h['query']}\nResults: {h['results']}" for h in history
)
prompt = f"""Topic: {topic}
Search history:
{combined}
Write a complete answer to the topic, drawing on all the search
results above."""
return ask(prompt)
def run(topic, max_rounds=4):
history = []
for round_num in range(max_rounds):
query = generate_query(topic, history)
results = search(query)
history.append({"query": query, "results": results, "feedback": None})
verdict = evaluate(topic, history)
history[-1]["feedback"] = verdict["feedback"]
if verdict["sufficient"]:
break
return synthesize(topic, history)
answer = run("What caused the 2024 slowdown in EV sales growth?")
print(answer)
Each pass through the loop does three things: generate a query, run the search, and evaluate the results. generate_query behaves differently depending on whether history is empty. On the first round it just writes a starting query from the topic. On every round after that, it sees the previous query, what came back, and the evaluator’s feedback, and writes a query meant to close that specific gap.
evaluate is the core of the machine: It instructs the LLM to respond with ‘sufficient’ or ‘feedback’ (These can be configured to respond directly in the JSON result of what the LLM returns, since unlike web LLM interfaces that most peolpe are used to, LLM API can be defined with specific, structured responses – you tell the AI specific data points. The entire response is in a JSON JavaScript object so it can be easily parsed (consumed) by your code.)
The Evaluator looks at everything gathered so far, not just the latest result, and decides if it’s enough to answer the topic well. It returns a boolean and a reason. The boolean controls the loop. The reason becomes the feedback the next generate_query call reads.
max_rounds is there so a topic that never quite satisfies the evaluator doesn’t loop forever. Four rounds is arbitrary, but some ceiling is necessary any time an LLM controls the exit condition, to avoid creating an LLM infinite loop.
Once the loop ends, either because the evaluator was satisfied or because it ran out of rounds, synthesize writes the final answer from the full search history.
Early rounds often surface context that later rounds build on, and the final answer should read like it used all of it, not just the most recent search.
One downside of this pattern is variable runtime. A well-scoped topic might resolve in one round. A broad or ambiguous one could burn through all four. That’s the trade-off: you give up a predictable number of calls in exchange for not stopping short on a question that needed more digging.
—-
That’s all five workflow patterns: Prompt Chaining, Routing, Parallelization, Orchestrator-Workers, and Evaluator-Optimizer. They share a common trait. In every case, your code controls the structure. The LLM does work inside that structure, but it doesn’t choose the structure itself. That’s what makes these workflows and not agents.
The patterns also build on each other logically. Chaining is sequential. Routing adds a decision point. Parallelization removes the sequence. Orchestrator-workers let the LLM decide the shape of the parallelization. Evaluator-optimizer adds a feedback loop. Each one trades simplicity for flexibility, and you should only make that trade when the simpler pattern can’t do the job.