The Uncomfortable Truth
After shipping several fintech agent systems, my strongest opinion is this: most teams reach for multi-agent architectures before they've earned them. Multi-agent is not a maturity milestone—it's a cost center. Every additional agent adds an LLM round-trip (1–3s latency), token spend, a new failure surface, and an interaction you must trace. The professional default is the simplest architecture that satisfies the requirements, with multi-agent reserved for cases where specific structural forces demand it. This article gives you a concrete decision framework, the anti-patterns to recognize, and—crucially—an adaptive architecture that routes each request to either a simple pipeline or a multi-agent graph. That routing pattern is the real answer: you rarely choose one globally; you choose per-request.
Part 1: The Decision Framework
Avoid Multi-Agent When…
| # | Condition | Why Simpler Wins | Recommended Alternative |
|---|
| 1 | Single knowledge domain, one tool surface | No heterogeneous capability to separate | Single RAG chain with tools |
| 2 | Latency SLA < ~1 second | Each agent adds 1–3s LLM round-trip | Single-node chain, cached retrieval |
| 3 | High volume, low complexity | Per-query agent cost explodes at scale | Fine-tuned single model or chain |
| 4 | Linear, deterministic steps | No branching/looping = not a graph | Sequential pipeline |
| 5 | No separation-of-duties mandate | No regulatory need to isolate functions | Unified agent |
| 6 | "Agents" share identical prompts/tools | Pseudo-multi-agent is pure overhead | Merge into one agent |
| 7 | No observability infrastructure | Untrowable agents are liabilities | Wait until you can trace |
Multi-Agent Is Justified When…
| # | Trigger | Fintech Example |
|---|
| 1 | Heterogeneous permissions | Fraud system, core banking, and comms require different credentials/scopes |
| 2 | Adversarial / maker-checker review | Compliance agent must independently validate advisor output |
| 3 | Independent model selection | Use a reasoning model for analysis, a cheap model for routing |
| 4 | Regulatory separation of duties | RBI/PCI-DSS requires function isolation |
| 5 | Long-running stateful workflows | Disputes spanning days with human-in-the-loop |
The key insight: conditions 1–4 in the "justify" column all describe structural differences—different permissions, different models, different regulatory owners. If your "agents" don't differ structurally, they aren't agents; they're expensive function calls.
Part 2: Real-Time Use Case — Banking Dispute Resolution
Why this case: Dispute handling perfectly demonstrates the decision boundary within one system.
Simple query: "What's the status of my dispute #4471?" → This needs one retrieval and one answer. A multi-agent graph here would be pure waste.
Complex query: "I see three unauthorized transactions from a merchant I've never used, and I think my card is compromised." → This needs fraud analysis, card freezing, chargeback filing, and customer notification—across three different systems with different permissions. Multi-agent is mandatory.
The architecture below routes each request to the appropriate path. This is the pattern that resolves the "when to avoid" question operationally.
Part 3: Implementation
![403]()
Step 1: Shared State Schema
Both paths read/write the same typed state, so memory and audit trails are unified regardless of which path executes.
# state.py
from typing import Annotated, Literal, Optional
from typing_extensions import TypedDict
from langgraph.graph.message import add_messages
from langchain_core.documents import Document
class DisputeState(TypedDict):
messages: Annotated[list, add_messages]
# Routing decision (the complexity classification)
complexity: Optional[Literal["simple", "complex"]]
routing_rationale: Optional[str]
# Customer & case context
customer_id: str
dispute_case_id: Optional[str]
transaction_ids: list[str]
# RAG artifacts
retrieved_policies: list[Document]
# Multi-agent artifacts (only populated on complex path)
fraud_findings: Optional[dict]
card_frozen: bool
chargeback_reference: Optional[str]
# Output
final_response: Optional[str]
# Memory & audit
thread_id: str
regulatory_snapshot_date: str
Step 2: The Complexity Router — The Decision Gate
This node is the "should we use multi-agent?" logic made executable. It uses structured output so the decision is parseable and auditable.
# router.py
import json
from langchain_openai import ChatOpenAI
from dispute_resolution.state import DisputeState
router_llm = ChatOpenAI(model="gpt-4o-mini", temperature=0) # Cheap, fast classifier
ROUTER_PROMPT = """You classify banking dispute requests by architectural complexity.
Classify as "simple" if ALL of these are true:
- Request is informational (status lookup, policy question, definition)
- Requires retrieval from ONE knowledge source
- Requires NO state-changing actions (no card freeze, no chargeback, no transfers)
- Requires access to only ONE system
Classify as "complex" if ANY of these are true:
- Suspected fraud or unauthorized transactions
- Requires state-changing actions across systems
- Requires coordination between fraud, disputes, and notification functions
- Requires human approval or maker-checker review
Output ONLY JSON:
{
"complexity": "simple" | "complex",
"rationale": "one sentence citing the triggering rule",
"detected_actions": ["freeze_card", "file_chargeback", ...]
}
NEVER classify fraud allegations as simple. When uncertain, choose complex."""
async def complexity_router(state: DisputeState) -> dict:
response = await router_llm.ainvoke([
{"role": "system", "content": ROUTER_PROMPT},
*state["messages"]
])
decision = json.loads(response.content)
return {
"complexity": decision["complexity"],
"routing_rationale": decision["rationale"]
}
def route_by_complexity(state: DisputeState) -> Literal["simple_pipeline", "supervisor"]:
return "simple_pipeline" if state["complexity"] == "simple" else "supervisor"
Step 3: The Simple Pipeline Path
For informational queries, a single node retrieves policy context and answers. No agents, no supervisor, no orchestration overhead.
# simple_pipeline.py
from langchain_openai import ChatOpenAI
from dispute_resolution.state import DisputeState
from dispute_resolution.rag import policy_retriever
answer_llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
SIMPLE_PROMPT = """You are a banking dispute support assistant. Answer the customer's
question using ONLY the retrieved policy context. If the answer is not in the context,
say you'll escalate to a specialist. Cite the policy reference exactly as written.
Context: {context}"""
async def simple_pipeline_node(state: DisputeState) -> dict:
# Single retrieval + single generation. That's the whole pipeline.
docs = await policy_retriever.asimilarity_search(
state["messages"][-1].content, k=4
)
context = "\n".join(f"[{d.metadata['policy_id']}] {d.page_content}" for d in docs)
response = await answer_llm.ainvoke([
{"role": "system", "content": SIMPLE_PROMPT.format(context=context)},
*state["messages"]
])
return {
"retrieved_policies": docs,
"final_response": response.content,
"messages": [{"role": "assistant", "content": response.content}]
}
Step 4: The Multi-Agent Path (Supervisor + Specialists)
Only complex requests reach this subgraph. Each specialist has distinct tool permissions—the structural justification for being a separate agent.
# agents/supervisor.py
from typing import Literal
from langchain_openai import ChatOpenAI
supervisor_llm = ChatOpenAI(model="gpt-4o", temperature=0)
SUPERVISOR_PROMPT = """You orchestrate dispute resolution. Available specialists:
- fraud_agent: analyzes unauthorized transactions, can freeze cards (core banking perms)
- disputes_agent: files chargebacks per card network rules (dispute system perms)
- comms_agent: sends customer notifications (comms platform perms)
Given the current state, decide the NEXT specialist to invoke, or FINISH.
Output JSON: {"next": "fraud_agent"|"disputes_agent"|"comms_agent"|"FINISH", "instruction": "..."}"""
async def supervisor_node(state: DisputeState) -> dict:
response = await supervisor_llm.ainvoke([
{"role": "system", "content": SUPERVISOR_PROMPT},
{"role": "user", "content": summarize_state(state)}
])
decision = json.loads(response.content)
return {"messages": [{"role": "assistant", "content": decision["instruction"],
"additional_kwargs": {"routing": decision}}]}
def route_supervisor(state: DisputeState) -> Literal["fraud_agent", "disputes_agent", "comms_agent", "__end__"]:
routing = state["messages"][-1].additional_kwargs.get("routing", {})
return routing.get("next", "__end__")
# agents/fraud_agent.py — distinct permissions: core banking + fraud detection
FRAUD_TOOLS = [freeze_card, flag_transactions, get_transaction_history]
async def fraud_agent_node(state: DisputeState) -> dict:
response = await fraud_llm.ainvoke([
{"role": "system", "content": FRAUD_PROMPT},
{"role": "user", "content": f"Analyze transactions: {state['transaction_ids']}"}
])
findings = parse_fraud_findings(response)
# Only this agent holds core-banking permission to freeze cards
if findings["action_required"] == "freeze":
await freeze_card(customer_id=state["customer_id"],
reason="suspected_unauthorized_activity")
return {"fraud_findings": findings, "card_frozen": True}
return {"fraud_findings": findings, "card_frozen": False}
# agents/disputes_agent.py — distinct permissions: chargeback/dispute system
async def disputes_agent_node(state: DisputeState) -> dict:
# Retrieve applicable card-network rules via RAG
rules = await policy_retriever.asimilarity_search(
f"chargeback rules unauthorized transaction {state['fraud_findings']['network']}", k=3
)
chargeback_ref = await file_chargeback(
transaction_ids=state["transaction_ids"],
reason_code="4837_unauthorized", # No cardholder participation
regulatory_basis=rules[0].metadata["policy_id"]
)
return {"chargeback_reference": chargeback_ref, "retrieved_policies": rules}
Step 5: Graph Assembly with Checkpointed Memory
# graph.py
from langgraph.graph import StateGraph, END
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
from dispute_resolution.state import DisputeState
def build_adaptive_dispute_graph():
g = StateGraph(DisputeState)
# Router decides per-request which architecture to use
g.add_node("router", complexity_router)
g.add_node("simple_pipeline", simple_pipeline_node)
# Multi-agent subgraph
g.add_node("supervisor", supervisor_node)
g.add_node("fraud_agent", fraud_agent_node)
g.add_node("disputes_agent", disputes_agent_node)
g.add_node("comms_agent", comms_agent_node)
g.set_entry_point("router")
g.add_conditional_edges("router", route_by_complexity, {
"simple_pipeline": "simple_pipeline",
"supervisor": "supervisor"
})
# Simple path terminates directly
g.add_edge("simple_pipeline", END)
# Complex path: supervisor orchestrates specialists in a loop
g.add_conditional_edges("supervisor", route_supervisor, {
"fraud_agent": "fraud_agent",
"disputes_agent": "disputes_agent",
"comms_agent": "comms_agent",
"__end__": END
})
# Each specialist reports back to supervisor
for agent in ["fraud_agent", "disputes_agent", "comms_agent"]:
g.add_edge(agent, "supervisor")
# Unified memory: same checkpointer for both paths
checkpointer = AsyncPostgresSaver.from_conn_string(
"postgresql://dispute_svc:***@pg-cluster:5432/langgraph_checkpoints"
)
return g.compile(checkpointer=checkpointer)
Step 6: Production Invocation
# main.py
import asyncio
from datetime import date
async def handle_dispute(customer_message: str, thread_id: str, customer_id: str):
app = build_adaptive_dispute_graph()
config = {"configurable": {"thread_id": thread_id}}
result = await app.ainvoke({
"messages": [{"role": "user", "content": customer_message}],
"customer_id": customer_id,
"thread_id": thread_id,
"regulatory_snapshot_date": date.today().isoformat()
}, config=config)
# Log routing decision for cost/correctness auditing
await log_routing_decision(
thread_id=thread_id,
complexity=result["complexity"],
rationale=result["routing_rationale"]
)
return result["final_response"]
# Simple query → routes to single-node pipeline (~1.5s, low cost)
asyncio.run(handle_dispute(
"What's the status of my dispute #4471?",
"thread-001", "cust-88231"
))
# Complex query → routes to multi-agent graph (~15s, higher cost, but necessary)
asyncio.run(handle_dispute(
"Three unauthorized transactions from a merchant I've never used. Card may be compromised.",
"thread-002", "cust-88231"
))
Part 4: The Cost/Latency Reality
| Metric | Simple Pipeline | Multi-Agent Graph |
|---|
| LLM round-trips | 1–2 | 6–12 |
| Typical latency | 1–2 s | 12–25 s |
| Relative token cost | 1x | 8–15x |
| Failure surfaces | 1 | 5+ (each agent, each edge) |
| Debugging effort | Low (linear trace) | High (graph state inspection) |
If 85% of your dispute traffic is informational, routing those through a multi-agent graph means you're paying 10x latency and cost for no capability gain. The router exists to ensure you only pay the multi-agent tax when structural complexity genuinely requires it.
Part 5: Key Takeaways
Default to simple, escalate on evidence. Multi-agent is not the starting point; it's the escalation path triggered by specific structural forces (heterogeneous permissions, adversarial review, regulatory separation).
Make the decision executable, not rhetorical. The complexity router turns "should we use agents?" into a classified, logged, auditable routing decision—so you can measure whether your routing rules are correct.
Agents must differ structurally to justify existing. If two agents share the same model, prompt, and tools, merge them. Separation without differentiation is pure overhead.
Match architecture to the query, not the product. The same dispute system serves a 1.5-second status lookup and a 20-second fraud investigation. One global architecture will over-serve one and under-serve the other.
Don't deploy what you can't trace. If you lack graph-state observability, postpone multi-agent. Untrowable agent interactions are how fintech incidents become regulatory findings.
Latency and cost are product requirements. In banking, a 25-second response for a balance question is a defect, not sophistication. The router protects your SLA.
The most mature multi-agent architecture is the one that knows when not to be multi-agent. Build the decision gate first; build the agents second.