AI Agents  

When to Avoid Multi-Agent Systems: A Decision Framework for Fintech Architectures

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…

#ConditionWhy Simpler WinsRecommended Alternative
1Single knowledge domain, one tool surfaceNo heterogeneous capability to separateSingle RAG chain with tools
2Latency SLA < ~1 secondEach agent adds 1–3s LLM round-tripSingle-node chain, cached retrieval
3High volume, low complexityPer-query agent cost explodes at scaleFine-tuned single model or chain
4Linear, deterministic stepsNo branching/looping = not a graphSequential pipeline
5No separation-of-duties mandateNo regulatory need to isolate functionsUnified agent
6"Agents" share identical prompts/toolsPseudo-multi-agent is pure overheadMerge into one agent
7No observability infrastructureUntrowable agents are liabilitiesWait until you can trace

Multi-Agent Is Justified When…

#TriggerFintech Example
1Heterogeneous permissionsFraud system, core banking, and comms require different credentials/scopes
2Adversarial / maker-checker reviewCompliance agent must independently validate advisor output
3Independent model selectionUse a reasoning model for analysis, a cheap model for routing
4Regulatory separation of dutiesRBI/PCI-DSS requires function isolation
5Long-running stateful workflowsDisputes 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

MetricSimple PipelineMulti-Agent Graph
LLM round-trips1–26–12
Typical latency1–2 s12–25 s
Relative token cost1x8–15x
Failure surfaces15+ (each agent, each edge)
Debugging effortLow (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

  1. 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).

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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.