What if your AI system's biggest problem isn't the AI?
I've watched teams spend months fine-tuning prompts, swapping models, and chasing benchmark improvements — only to realize their actual bottleneck was architecture. The model was fine. The way they wired it together was the problem.
After building production multi-agent systems with LangGraph and LangChain across financial analysis, document processing, and operational automation, I've converged on three reusable agent patterns that handle the vast majority of agentic workflows. They're not novel research. They won't trend on AI Twitter. But they quietly eliminated entire categories of bugs, cut development time on new pipelines by half, and — most importantly — made the systems predictable enough that non-AI engineers on the team could reason about them.
This article walks through each pattern with simplified code samples and practical examples. Whether you're a CTO evaluating agentic architectures or an engineer knee-deep in LangChain, you should walk away with something you can use on Monday morning.
The Problem with "Just Use an Agent"
Most LangGraph tutorials show you a single agent doing everything: reasoning, tool-calling, routing, and output formatting. That works for demos. In production, it falls apart.
Why? Because a single mega-agent conflates three fundamentally different cognitive tasks:
- Analysis — understanding data using tools
- Decision-making — choosing what happens next in a workflow
- Structured extraction — converting unstructured reasoning into validated output
Mixing these in one prompt leads to brittle behavior: the model tries to analyze and route and format simultaneously, and gets confused. Splitting them into specialized agents with clear contracts between them made everything more reliable.
Here are the three patterns I now use as building blocks.
Pattern 1: The Analyzer Agent
What it does: Takes a prompt and a set of tools, reasons over data, and produces a free-text summary.
When to use it: Any time you need an LLM to investigate something — read financial filings, scan customer support tickets, evaluate vendor contracts — and produce a human-readable analysis.
The key insight is that the Analyzer is generic. The same class handles wildly different tasks depending on which prompt and tools you inject. Need to assess a company's quarterly earnings? Pass it SEC filing tools and a financial analysis prompt. Need to review insurance claims? Same class, different prompt and tools.
The Architecture
from langchain_core.language_models import BaseChatModel
from langchain_core.tools import BaseTool
from typing import List
class AnalyzerAgent:
"""
A generic analysis agent. Give it a prompt and tools,
and it will reason over data to produce a text summary.
"""
def __init__(
self,
llm: BaseChatModel,
tools: List[BaseTool],
prompt: str,
):
self._llm = llm
self._tools = tools
self._prompt = prompt
async def analyze(self, context: str) -> str:
"""Run the analysis loop: LLM reasons, calls tools, summarizes."""
# Bind tools to the LLM so it can call them during reasoning
llm_with_tools = self._llm.bind_tools(self._tools)
messages = [
{"role": "system", "content": self._prompt},
{"role": "user", "content": context},
]
# Agentic loop: let the LLM call tools until it's done
while True:
response = await llm_with_tools.ainvoke(messages)
messages.append(response)
if not response.tool_calls:
# No more tool calls — the LLM is done reasoning
return response.content
# Execute each tool call and feed results back
for tool_call in response.tool_calls:
tool = next(
t for t in self._tools if t.name == tool_call["name"]
)
result = await tool.ainvoke(tool_call["args"])
messages.append({
"role": "tool",
"content": str(result),
"tool_call_id": tool_call["id"],
})
Example: Financial Earnings Analysis
EARNINGS_ANALYSIS_PROMPT = """
You are a financial analysis agent specializing in public company
earnings. Your job is to examine quarterly filings and earnings
call transcripts to produce an investment-relevant summary.
## PROCESS
1. Use the fetch_filing tool to retrieve the latest 10-Q data.
2. Use the get_transcript tool to pull the most recent earnings call.
3. Cross-reference reported figures against analyst consensus.
## OUTPUT
Return a clear summary covering:
- Revenue and EPS vs. consensus estimates
- Management guidance changes (raised, maintained, or lowered)
- Key risk factors mentioned in the filing or call
- Notable shifts in segment performance
"""
The same class, different configuration — here's a customer support use case:
TICKET_TRIAGE_PROMPT = """
You are a support ticket analysis agent. Examine the incoming
ticket and any related customer history to assess urgency and topic.
## PROCESS
1. Use the get_customer_history tool to pull past interactions.
2. Use the check_sla tool to determine the customer's service tier.
3. Analyze the ticket content for severity indicators.
## OUTPUT
Return a summary covering:
- Issue category (billing, technical, account access, feature request)
- Severity assessment (critical, high, medium, low)
- Relevant customer context (tenure, tier, recent issues)
- Recommended routing
"""
Why This Pattern Works
The beauty is in the separation of concerns. The AnalyzerAgent class knows nothing about finance, support tickets, or any specific domain. All domain knowledge lives in the prompt and tool selection. This means:
- Reusability: One class, unlimited use cases
- Testability: Swap the LLM for a mock, test tool interactions independently
- Composability: Chain analyzers together in a LangGraph workflow, each one adding context for the next
In production, I run analyzers for financial document review, compliance checking, data quality assessment, and more — all using the same class with different configurations.
Pattern 2: The Router Agent
What it does: Reads upstream context and routes the workflow to the correct next step by calling a special routing tool.
When to use it: Any time your workflow needs to branch — different processing paths based on analysis results, document type classification, or risk level assessment.
Most routing in LangGraph tutorials is done with conditional edges and deterministic functions. That's fine for simple cases. But when the routing decision requires understanding unstructured text — e.g., "based on the financial analysis, does this company need a full due diligence review or a standard summary?" — you need an LLM to make the call.
The Routing Tool
The trick is a dedicated RoutingTool that stores the LLM's decision as state:
from typing import Optional
class RoutingTool:
"""
A tool that captures the LLM's routing decision.
The selected route is stored and can be read by the
LangGraph workflow to determine the next node.
"""
def __init__(self):
self._route: Optional[str] = None
@property
def route(self) -> Optional[str]:
return self._route
async def select_route(
self,
route: Optional[str] = None,
error_details: Optional[str] = None,
) -> str:
"""
Call this tool to select which path the workflow should take.
Args:
route: The chosen route (e.g., "full_review", "standard").
error_details: If routing fails, explain why.
"""
if error_details:
self._route = None
return f"Routing failed: {error_details}"
self._route = route
return f"Route selected: {route}"
The Router Agent
class RouterAgent:
"""
Reads previous context and selects a workflow route
by calling the RoutingTool.
"""
def __init__(
self,
llm: BaseChatModel,
routing_tool: RoutingTool,
prompt: str,
):
self._llm = llm
self._routing_tool = routing_tool
self._prompt = prompt
@property
def route(self) -> Optional[str]:
"""The route selected by the LLM after execution."""
return self._routing_tool.route
async def decide(self, context: str) -> str:
"""Run the router: LLM reads context and calls select_route."""
llm_with_tools = self._llm.bind_tools(
[self._routing_tool.select_route]
)
messages = [
{"role": "system", "content": self._prompt},
{"role": "user", "content": context},
]
response = await llm_with_tools.ainvoke(messages)
# Execute the tool call to store the route
if response.tool_calls:
tool_call = response.tool_calls[0]
await self._routing_tool.select_route(**tool_call["args"])
return self._routing_tool.route
Example: Financial Document Routing
DOCUMENT_ROUTER_PROMPT = """
You are a routing agent for a financial document processing pipeline.
Read the previous agent's analysis and call select_route with the
appropriate processing path.
## ROUTES
- route="earnings_deep_dive" → Revenue miss >5% OR guidance lowered
- route="standard_summary" → Results in line with expectations
- route="risk_alert" → Material risk factors flagged (litigation,
restatement, going concern, covenant breach)
- route=None → Cannot determine (provide error_details)
## INSTRUCTIONS
1. Read the upstream analysis in the conversation history
2. Evaluate against the route criteria above
3. Call select_route EXACTLY ONCE
## EXAMPLES
Analysis shows revenue missed consensus by 12%, guidance cut
→ select_route(route="earnings_deep_dive")
Analysis shows EPS beat by $0.02, guidance maintained
→ select_route(route="standard_summary")
Analysis flags ongoing SEC investigation and auditor concerns
→ select_route(route="risk_alert")
"""
Here's another example — routing in an HR automation pipeline:
CANDIDATE_ROUTER_PROMPT = """
You are a routing agent for a recruitment pipeline. Read the
candidate screening summary and route to the appropriate
next step.
## ROUTES
- route="technical_interview" → Strong technical match, meets requirements
- route="culture_screen" → Technical skills borderline, strong soft signals
- route="reject_with_feedback" → Clear mismatch on must-have criteria
- route=None → Insufficient data to decide (provide error_details)
Call select_route EXACTLY ONCE based on the screening analysis.
"""
Wiring It Into LangGraph
In your LangGraph workflow, the router agent's decision directly controls the graph's conditional edge:
from langgraph.graph import StateGraph
def route_decision(state):
"""LangGraph conditional edge function."""
route = state["selected_route"]
routing_map = {
"earnings_deep_dive": "deep_analysis",
"standard_summary": "quick_summary",
"risk_alert": "risk_pipeline",
}
return routing_map.get(route, "handle_error")
# In the graph definition:
graph.add_conditional_edges(
"router",
route_decision,
{
"deep_analysis": "detailed_review_agents",
"quick_summary": "summary_generator",
"risk_pipeline": "risk_assessment_agents",
"handle_error": "error_handler",
},
)
Why Not Just Use a Classifier?
You could classify with a simple function or even keyword matching. But LLM-based routing shines when:
- The decision requires interpreting nuanced, unstructured context — a 2,000-word earnings analysis isn't something you regex through
- Routes aren't purely deterministic — the same data could warrant different paths depending on subtle signals like management tone on the earnings call
- You want the routing logic to be expressed in natural language (the prompt), not code — product managers can read and adjust routing criteria without touching Python
- The set of possible routes may change and you want to update a prompt, not refactor a decision tree
The RoutingTool pattern also gives you observability: you can log every routing decision, inspect the LLM's reasoning, and debug misroutes by looking at the conversation history.
Pattern 3: The Report Compiler
What it does: Takes all upstream conversation context and extracts structured data into a validated Pydantic schema — no tools, no reasoning loops, just extraction.
When to use it: At the end of any multi-agent pipeline where you need clean, typed, validated output. Think: generating a JSON report for a dashboard, populating a database record, or returning structured results to an API caller.
This is the pattern I'm most proud of because it's the most boring. And boring is exactly what you want at the output stage.
The Core Idea
LangChain's with_structured_output() forces the LLM to return data matching a Pydantic schema. But the prompt engineering matters enormously. Feed it a vague prompt and you'll get hallucinated field values. Feed it a dynamically generated prompt that mirrors the schema exactly, and extraction becomes remarkably reliable.
The Report Agent
from pydantic import BaseModel
from langchain_core.language_models import BaseChatModel
from langchain_core.messages import BaseMessage
from typing import List, Type
class ReportCompiler:
"""
Extracts structured data from conversation history
into a validated Pydantic schema.
No tools, no reasoning loops — pure extraction.
"""
def __init__(
self,
llm: BaseChatModel,
schema: Type[BaseModel],
prompt: str,
):
self._llm = llm
self._schema = schema
self._prompt = prompt
async def compile(self, messages: List[BaseMessage]) -> BaseModel:
"""
Takes conversation history and returns a populated schema instance.
"""
structured_llm = self._llm.with_structured_output(self._schema)
all_messages = [
{"role": "system", "content": self._prompt},
] + messages
return await structured_llm.ainvoke(all_messages)
The Dynamic Prompt Generator
Here's where the magic lives. Instead of manually writing extraction prompts for every schema, I generate them automatically from the Pydantic model:
import json
from pydantic import BaseModel
from typing import get_origin, get_args, Literal, Type, List
def build_extraction_prompt(schema: Type[BaseModel]) -> str:
"""
Dynamically generates an extraction prompt from a Pydantic schema.
Handles nested models, Literal constraints, Optional fields, and lists.
"""
field_descriptions = []
example_output = {}
for idx, (name, field) in enumerate(schema.model_fields.items(), 1):
annotation = field.annotation
origin = get_origin(annotation)
# Resolve Optional[X] → X
is_optional = False
if origin is type(None) or (origin and type(None) in get_args(annotation)):
is_optional = True
annotation = next(
a for a in get_args(annotation) if a is not type(None)
)
origin = get_origin(annotation)
# Build type string and example value
if origin is Literal:
allowed = get_args(annotation)
type_str = f"one of: {', '.join(repr(v) for v in allowed)}"
example_output[name] = allowed[0]
elif origin is list:
inner = get_args(annotation)[0]
type_str = f"List[{inner.__name__}]"
example_output[name] = []
elif annotation is str:
type_str = "string"
example_output[name] = "extracted_value"
elif annotation is int:
type_str = "integer"
example_output[name] = 0
elif annotation is bool:
type_str = "boolean"
example_output[name] = False
else:
type_str = str(annotation)
example_output[name] = None
opt = " (optional)" if is_optional else ""
desc = field.description or "No description"
field_descriptions.append(f"{idx}. {name} ({type_str}{opt}) — {desc}")
return f"""You are a report compilation agent. Extract information
from the conversation history into the structured output below.
CRITICAL: Do NOT invent values. Only extract what is explicitly
present in the conversation. If a value is not found, use the
default for its type (string→null, int→null, list→[]).
FIELDS:
{chr(10).join(field_descriptions)}
Expected structure:
{json.dumps(example_output, indent=2)}
Extract all fields now. Every field must have a value."""
Example: Financial Report Compilation
from pydantic import BaseModel, Field
from typing import Optional, Literal, List
class EarningsReport(BaseModel):
"""Structured output for quarterly earnings analysis."""
company_name: str = Field(description="Company legal name")
ticker: str = Field(description="Stock ticker symbol")
quarter: str = Field(description="Fiscal quarter, e.g. Q3 2024")
revenue_actual: float = Field(description="Reported revenue in millions USD")
revenue_consensus: float = Field(description="Analyst consensus revenue estimate")
eps_actual: float = Field(description="Reported earnings per share")
eps_consensus: float = Field(description="Analyst consensus EPS estimate")
guidance_direction: Literal["raised", "maintained", "lowered", "withdrawn"] = Field(
description="Direction of forward guidance change"
)
has_material_risks: bool = Field(
description="Whether material risk factors were identified"
)
key_risks: Optional[str] = Field(
description="Summary of material risks if any were found"
)
sentiment: Literal["bullish", "neutral", "bearish"] = Field(
description="Overall analyst sentiment based on the analysis"
)
# Generate the prompt automatically — no manual prompt writing
prompt = build_extraction_prompt(EarningsReport)
# Create the compiler
compiler = ReportCompiler(
llm=my_llm,
schema=EarningsReport,
prompt=prompt,
)
# At the end of the pipeline, pass all accumulated messages
report = await compiler.compile(conversation_history)
# report is a validated EarningsReport instance
print(report.company_name) # "Acme Corp"
print(report.guidance_direction) # "lowered"
print(report.has_material_risks) # True
And here's a completely different domain — same compiler, different schema:
class InsuranceClaimAssessment(BaseModel):
"""Structured output for insurance claim triage."""
claim_id: str = Field(description="Unique claim identifier")
claimant_name: str = Field(description="Name of the person filing the claim")
incident_type: Literal["auto", "property", "liability", "health"] = Field(
description="Category of the insurance claim"
)
estimated_amount: float = Field(description="Estimated claim amount in USD")
fraud_risk: Literal["low", "medium", "high"] = Field(
description="Assessed fraud risk level"
)
requires_adjuster: bool = Field(description="Whether a field adjuster visit is needed")
notes: Optional[str] = Field(description="Additional context from the analysis")
# Same pattern — schema drives the prompt, compiler does the rest
prompt = build_extraction_prompt(InsuranceClaimAssessment)
compiler = ReportCompiler(llm=my_llm, schema=InsuranceClaimAssessment, prompt=prompt)
Why Dynamic Prompts Matter
You might ask: why not just let with_structured_output() handle everything? It does work without a custom prompt. But in practice, I found that:
- Explicit field descriptions in the prompt dramatically reduce hallucination
- Example output structures help the model understand the expected format, especially for nested objects and lists
- Default value instructions prevent the model from inventing data when information is genuinely missing
- Constraint reminders (for Literal fields) reduce mismatches between what the model generates and what Pydantic accepts
The dynamic prompt generator means I never write extraction prompts by hand. Define a schema, call build_extraction_prompt(), and the prompt stays perfectly in sync with the data model. When a product manager asks to add a new field to the report, I add it to the Pydantic class and the prompt updates itself.
How They Compose: A Complete Pipeline
Here's how these three patterns fit together in a real LangGraph workflow — using the financial analysis example end to end:
Each component has a single, well-defined job. The conversation history flows through the graph as messages, and each agent adds its contribution. By the time context reaches the Report Compiler, all the analysis and decisions are already in the message history — the compiler just extracts and structures.
LangGraph Integration
from langgraph.graph import StateGraph, MessagesState
workflow = StateGraph(MessagesState)
# Add nodes
workflow.add_node("analyze_earnings", run_earnings_analyzer)
workflow.add_node("route_by_risk", run_risk_router)
workflow.add_node("deep_dive", run_deep_analysis)
workflow.add_node("standard_track", run_standard_summary)
workflow.add_node("compile_report", run_report_compiler)
# Wire the flow
workflow.add_edge("analyze_earnings", "route_by_risk")
workflow.add_conditional_edges("route_by_risk", route_decision, {
"earnings_deep_dive": "deep_dive",
"standard_summary": "standard_track",
"risk_alert": "deep_dive",
})
workflow.add_edge("deep_dive", "compile_report")
workflow.add_edge("standard_track", "compile_report")
graph = workflow.compile()
The Deeper Lesson: Agents Are Functions, Not Personalities
If there's one idea I'd want to stick, it's this: stop thinking of agents as autonomous entities with personalities, and start thinking of them as specialized functions with natural-language interfaces.
The Analyzer is a function that takes (prompt, tools, context) and returns analysis_text. The Router is a function that takes (context, route_options) and returns selected_route. The Compiler is a function that takes (context, schema) and returns structured_data. The fact that an LLM powers each one is an implementation detail.
This mental shift has three practical consequences:
First, it makes architecture decisions obvious. When you need to branch a workflow, you don't ask "how do I make my agent smarter?" — you add a Router. When you need structured output, you don't tune the analysis prompt to also produce JSON — you add a Compiler. Each problem has a pattern, and each pattern has a single responsibility.
Second, it makes testing tractable. You can test each agent in isolation with known inputs and expected outputs. You can mock the LLM and verify that tool calls happen in the right order. You can validate that the Router's output correctly drives the conditional edge. These are normal software engineering practices, applied to AI systems.
Third, it makes the system legible to your entire team. A product manager can read a Router prompt and understand the business logic. A QA engineer can look at the Pydantic schema and know exactly what the output should contain. An ops engineer can trace a misrouted document by reading the conversation history. The system is transparent because each piece does one thing and documents it in plain language.
The multi-agent systems that actually work in production aren't the ones with the cleverest prompts or the most autonomous agents. They're the ones built from simple, composable, well-tested parts — where the architecture does the heavy lifting and each agent is just good enough at its one job.
Build boring agents. Compose them well. Ship on Monday.
I'm a finance and AI professional building production agentic systems at the intersection of enterprise workflows and modern AI. If you're working on similar problems, let's connect — I'm always interested in comparing notes on what actually works.

Top comments (0)