{"slug": "ai-intro-part-4-workflow-patterns-part-2", "title": "AI Intro Part 4: Workflow Patterns (Part 2)", "summary": "Anthropic's whitepaper 'Building Effective Agents' outlines five workflow patterns for using large language models, and this post covers the final three: Parallelization, Orchestrator-Workers, and Evaluator-Optimizer. Parallelization splits tasks into simultaneous calls, either by sectioning (e.g., guardrails) or voting (e.g., content moderation), and Anthropic recommends using the simplest solution, not necessarily building agentic systems.", "body_md": "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.\n\nAnthropic 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.”\n\nNon-agentic systems that use LLMs to do some of most of their work are called **workflows**.\n\nThey 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](https://www.anthropic.com/engineering/building-effective-agents),” with more detailed explanations and code examples in Python provided by me.)\n\nIn the last post, I covered Prompt Chaining and Routing, the first two out of five workflow patterns.\n\nIn this post we will patterns #3, #4, and #5:\n\nParallelization (including section & versioning)\n\nOrchestrator-Workers\n\nEvaluator-Optimizer\n\n# Parallelization\n\nParallelization is like the Routing workflow, but we send things to multiple LLMs at the same time—in “parallel.”\n\nParallelization 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.\n\nAnthropic 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.”\n\nThere are two kinds of parallelization: Sectioning and Voting.\n\nWith 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.\n\nEvals 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.\n\nWith 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.\n\nSectioning 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.\n\n**Sectioning: the guardrail**\n\n``` python\nimport anthropic\nfrom concurrent.futures import ThreadPoolExecutor\n\nclient = anthropic.Anthropic()\n\ndef ask(prompt):\n    response = client.messages.create(\n        model=\"claude-sonnet-4-6\",\n        max_tokens=1024,\n        messages=[{\"role\": \"user\", \"content\": prompt}],\n    )\n    return response.content[0].text\n\ndef answer(query):\n    return ask(f\"You are a helpful support agent. Answer: {query}\")\n\ndef screen(query):\n    return ask(\n        f\"Does this message ask for anything harmful, off-topic, or \"\n        f\"against policy? Reply with one word: SAFE or BLOCK.\\n\\n{query}\"\n    ).strip().upper()\n\ndef handle(query):\n    with ThreadPoolExecutor() as pool:\n        answer_future = pool.submit(answer, query)\n        screen_future = pool.submit(screen, query)\n\n    if screen_future.result() == \"BLOCK\":\n        return \"Sorry, I can't help with that.\"\n    return answer_future.result()\n\nprint(handle(\"How do I reset my password?\"))\n```\n\nStep through handle. `ThreadPoolExecutor`\n\nis 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.\n\nThe with block waits for both threads to finish before it exits. After that, `.result()`\n\npulls the return value out of each future. By the time you read them, the work is done, so neither `.result()`\n\nblocks.\n\nThen you check the screener first. If it says `BLOCK`\n\n, you return the canned refusal and never touch the answer. The answer already came back and you throw it away.\n\nThat 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.\n\n**Voting: the security review**\n\n```\nREVIEWERS = [\n    \"Look for SQL injection. Reply VULNERABLE or SAFE, then one line why.\",\n    \"Look for missing authentication or authorization checks. Reply VULNERABLE or SAFE, then one line why.\",\n    \"Look for unvalidated user input. Reply VULNERABLE or SAFE, then one line why.\",\n    \"Look for secrets or credentials in the code. Reply VULNERABLE or SAFE, then one line why.\",\n    \"You are a senior security engineer. What is wrong with this code? Reply VULNERABLE or SAFE, then one line why.\",\n]\n\ndef review(instruction, code):\n    return ask(f\"{instruction}\\n\\n``` python\\n{code}\\n```\")\n\ndef audit(code, threshold=2):\n    with ThreadPoolExecutor() as pool:\n        results = list(pool.map(lambda r: review(r, code), REVIEWERS))\n\n    votes = [r for r in results if r.strip().upper().startswith(\"VULNERABLE\")]\n\n    return {\n        \"flagged\": len(votes) >= threshold,\n        \"votes\": len(votes),\n        \"findings\": votes,\n    }\n\ncode = \"\"\"\ndef get_user(request):\n    uid = request.args.get(\"id\")\n    return db.execute(\"SELECT * FROM users WHERE id = \" + uid)\n\"\"\"\n\nprint(audit(code))\n```\n\nStep through audit. `REVIEWERS`\n\nis a list of five instruction strings. `pool.map`\n\ntakes a function and that list, calls the function once per item, and runs all five at the same time. The `lambda`\n\nis 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.\n\nmap 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.\n\nThen we count it. The filter keeps any result whose text starts with `VULNERABLE`\n\n. `len(votes)`\n\nis the tally. Compare that against threshold and you get a boolean.\n\nNote what is returned: the function returns the flag, the tally, and the actual findings, so the consumer can see which reviewers objected and why.\n\n# Orchestrator-Workers\n\nOrchestrator-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.\n\nIn parallelization, your code decides how to split the job into pieces before anything runs.\n\nIn 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.\n\nAnthropic 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.\n\nResearch 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.\n\n``` python\nimport json\nimport anthropic\nfrom concurrent.futures import ThreadPoolExecutor\n\nclient = anthropic.Anthropic()\n\ndef ask(prompt, system=None):\n    response = client.messages.create(\n        model=\"claude-sonnet-4-6\",\n        max_tokens=1500,\n        system=system,\n        messages=[{\"role\": \"user\", \"content\": prompt}],\n    )\n    return response.content[0].text\n\ndef plan(topic):\n    prompt = f\"\"\"You're planning research for a briefing document.\n\nTopic: {topic}\n\nDecide which subtopics need separate research to cover this well.\nReply as JSON: a list of objects with \"subtopic\" and \"angle\" keys,\nwhere \"angle\" is one sentence on what to focus on for that subtopic.\nUse 3 to 6 subtopics, no more than needed.\"\"\"\n    raw = ask(prompt, system=\"Reply with JSON only, no markdown fences.\")\n    return json.loads(raw)\n\ndef research(subtopic, angle, topic):\n    prompt = f\"\"\"You're researching one piece of a larger briefing on: {topic}\n\nYour subtopic: {subtopic}\nYour angle: {angle}\n\nWrite three or four sentences covering this subtopic. Be specific.\nIf you're not confident about a fact, say so instead of guessing.\"\"\"\n    return ask(prompt)\n\ndef synthesize(topic, findings):\n    combined = \"\\n\\n\".join(f\"{f['subtopic']}:\\n{f['content']}\" for f in findings)\n    prompt = f\"\"\"Topic: {topic}\n\nHere's research gathered on separate subtopics:\n{combined}\n\nWrite a briefing document. Open with a two sentence summary, then\ncover each subtopic in its own short section. Flag anywhere the\nresearch disagreed or left gaps.\"\"\"\n    return ask(prompt)\n\ndef run(topic):\n    subtopics = plan(topic)\n\n    with ThreadPoolExecutor() as pool:\n        futures = {\n            pool.submit(research, s[\"subtopic\"], s[\"angle\"], topic): s\n            for s in subtopics\n        }\n        findings = []\n        for future, subtopic in futures.items():\n            findings.append({\n                \"subtopic\": subtopic[\"subtopic\"],\n                \"content\": future.result(),\n            })\n\n    return synthesize(topic, findings)\n\nbriefing = run(\"Our main competitor's recent product launch\")\nprint(briefing)\n```\n\nLet’s look at the `run`\n\nfunction above. The `plan`\n\ncall (which is doing the orchestration), gets one input: the topic.\n\nIt 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.\n\nEach 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.\n\n`synthesize`\n\nis 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.\n\nWhen 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.\n\n# Evaluator-Optimizer\n\nThe 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.\n\nThis 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.\n\nAnthropic 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.\n\nThe 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.\n\nAn 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.\n\n``` python\nimport json\nimport anthropic\nclient = anthropic.Anthropic()\ndef ask(prompt, system=None):\n    response = client.messages.create(\n        model=\"claude-sonnet-4-6\",\n        max_tokens=1500,\n        system=system,\n        messages=[{\"role\": \"user\", \"content\": prompt}],\n    )\n    return response.content[0].text\n\ndef search(query):\n    # stand-in for a real search call\n    return f\"[search results for: {query}]\"\n\ndef generate_query(topic, history):\n    if not history:\n        prompt = f\"Write a search query to research: {topic}\"\n    else:\n        last = history[-1]\n        prompt = f\"\"\"Topic: {topic}\n\nPrevious query: {last['query']}\nPrevious results: {last['results']}\nEvaluator feedback: {last['feedback']}\n\nWrite a better search query that addresses the feedback.\"\"\"\n    return ask(prompt, system=\"Reply with just the search query, nothing else.\")\n\ndef evaluate(topic, history):\n    combined = \"\\n\\n\".join(\n        f\"Query: {h['query']}\\nResults: {h['results']}\" for h in history\n    )\n    prompt = f\"\"\"Topic: {topic}\n\nSearch history so far:\n{combined}\nDo 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).\"\"\"\n    raw = ask(prompt, system=\"Reply with JSON only, no markdown fences.\")\n    return json.loads(raw)\n\ndef synthesize(topic, history):\n    combined = \"\\n\\n\".join(\n        f\"Query: {h['query']}\\nResults: {h['results']}\" for h in history\n    )\n    prompt = f\"\"\"Topic: {topic}\n\nSearch history:\n{combined}\n\nWrite a complete answer to the topic, drawing on all the search\nresults above.\"\"\"\n    return ask(prompt)\n\ndef run(topic, max_rounds=4):\n    history = []\n    for round_num in range(max_rounds):\n        query = generate_query(topic, history)\n        results = search(query)\n        history.append({\"query\": query, \"results\": results, \"feedback\": None})\n        verdict = evaluate(topic, history)\n        history[-1][\"feedback\"] = verdict[\"feedback\"]\n        if verdict[\"sufficient\"]:\n            break\n    return synthesize(topic, history)\nanswer = run(\"What caused the 2024 slowdown in EV sales growth?\")\nprint(answer)\n```\n\nEach pass through the loop does three things: generate a query, run the search, and evaluate the results. `generate_query`\n\nbehaves 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.\n\n`evaluate`\n\nis 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.)\n\nThe 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`\n\ncall reads.\n\n`max_rounds`\n\nis 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.\n\nOnce the loop ends, either because the evaluator was satisfied or because it ran out of rounds, `synthesize`\n\nwrites the final answer from the full search history.\n\nEarly 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.\n\nOne 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.\n\n—-\n\nThat’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.\n\nThe 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.", "url": "https://wpnews.pro/news/ai-intro-part-4-workflow-patterns-part-2", "canonical_source": "https://jasonfleetwoodboldt.com/2026/08/25/ai-intro-part-4-workflow-patterns-part-2/", "published_at": "2026-08-24 20:48:29+00:00", "updated_at": "2026-08-24 21:15:06.789916+00:00", "lang": "en", "topics": ["artificial-intelligence", "large-language-models", "ai-agents"], "entities": ["Anthropic", "Claude Sonnet 4.6"], "alternates": {"html": "https://wpnews.pro/news/ai-intro-part-4-workflow-patterns-part-2", "markdown": "https://wpnews.pro/news/ai-intro-part-4-workflow-patterns-part-2.md", "text": "https://wpnews.pro/news/ai-intro-part-4-workflow-patterns-part-2.txt", "jsonld": "https://wpnews.pro/news/ai-intro-part-4-workflow-patterns-part-2.jsonld"}}