// AGENTS · MODULE 03/12

Agents and LangGraph: orchestration for investment decisions

create_agent, middleware, StateGraph, persistence, real human-in-the-loop, and multi-agent patterns.

Level intermediate-advancedEdition jul 2026Leer en español


Module 2 built deterministic pipelines with LCEL: composable chains of Runnables with structured output. This module's leap is small on the surface and deep in its consequences: an agent is a compiled graph that inherits the Runnable interface. What changes is not the invocation mechanism — it is still invoke/stream/batch — but who decides the path: no longer the programmer, but the model, turn by turn. That transfer of control is what must be learned to bound, audit, and, at the critical points, revoke. These patterns will reappear in module 5 (RAG as an agent tool), module 7 (the risk gate as a deterministic node), and the module 12 capstone.

3.1 create_agent: the v1 standard

3.1.1 The factory on top of LangGraph: model + tools + system_prompt → CompiledStateGraph

Since the LangChain 1.0 GA (October 2025), create_agent —in langchain.agents— is the standard way to build agents. (Github) Underneath, it compiles the classic agentic loop (call the model, let it choose tools, execute them, return the result to the model, and finish when there are no more tool calls) as a LangGraph graph: create_agent runs on LangGraph; LangChain to get started fast, LangGraph for bespoke orchestration. (Github)

The pedagogically decisive point is the return type: create_agent does not return an opaque object but a CompiledStateGraph. (Github) Three practical consequences. First, the agent is invoked like any graph: agent.invoke({"messages": [...]}). Second, it can be nested as a node or subgraph inside your own StateGraph — the foundation of the multi-agent teams of section 3.3. Third, everything LangGraph offers is inherited with no additional code: durability (checkpoints after every step), streaming of tokens and states, human-in-the-loop (interruptions and resumption), and time travel (replay and fork from historical checkpoints). (PrivacyFilter)

The two APIs this course does not teach: AgentExecutor, which lives in the langchain-classic compatibility package, (MDPI) and langgraph.prebuilt.create_react_agent, deprecated in LangGraph v1 with removal planned for v2.0. (is4.ai) The official migration guide documents the correspondence: pre_model_hook/post_model_hook hooks → middleware (before_model/after_model); prompt → system_prompt; dynamic prompts → @dynamic_prompt; custom state → TypedDict only; runtime context → the context argument instead of config["configurable"]. (Github)

The course's first agent is a research analyst with two deterministic tools — a price query and a metrics calculator — under the course's governing principle: the LLM never computes; it orchestrates tools that do.

python
from langchain.agents import create_agent
from langchain.tools import tool

@tool
def get_ohlcv(ticker: str, start: str, end: str) -> dict:
    """Returns daily OHLCV for a ticker between two dates (point-in-time)."""
    ...  # implementation against the market data API from module 4

@tool
def compute_rsi(prices: list[float], window: int = 14) -> float:
    """Computes Wilder's RSI over a series of closes."""
    ...  # deterministic math, testable on its own

analyst = create_agent(
    model="claude-sonnet-4-6",
    tools=[get_ohlcv, compute_rsi],
    system_prompt=(
        "Eres un analista cuantitativo senior. Usa las herramientas para "
        "obtener datos y calcular métricas; nunca estimes cifras de memoria. "
        "Cierra con un veredicto BULLISH / BEARISH / NEUTRAL justificado."
    ),
)

result = analyst.invoke({
    "messages": [{"role": "user",
                  "content": "Evalúa el momentum de NVDA en el último trimestre."}]
})

The loop that invoke runs — model, tools, model — is now a graph with persistent state on every edge:

Agent graph: the model calls deterministic tools until no tool_calls remain, and the checkpointer saves state after every step.START → model LLM decides; model LLM decides → tools deterministic execution (tool_calls); tools deterministic execution → model LLM decides; model LLM decides → END (no tool_calls); model LLM decides → checkpointer state after each step (persists); tools deterministic execution → checkpointer state after each step (persists)tool_callsno tool_callspersistspersistsSTARTmodelLLM decidestoolsdeterministic executionENDcheckpointerstate after each step
Agent loop as a graph. The model decides whether to call tools or finish, and the checkpointer persists state after every step of the loop.

3.1.2 Middleware: the loop's control layer

Middleware is the official v1 way to control what happens inside the agent: logging, prompt transformation and tool selection, retries and early termination, rate limits, guardrails, and PII detection. (futureagi.com) The extension model is six hooks — node-style (@before_agent, @before_model, @after_model, @after_agent) and wrap-style (@wrap_model_call, @wrap_tool_call) — plus the @dynamic_prompt decorator. (langchain.js)

For a quant desk, four built-in middleware cover the minimum defensive stack: SummarizationMiddleware (compresses the history once a token threshold is exceeded — essential in long research sessions), (Github) HumanInTheLoopMiddleware (pauses before sensitive tools; section 3.2.3), (🦜️🔗 LangChain) PIIMiddleware (detects and masks personal data before it leaves the perimeter) (Github) and model retry middleware (retries with exponential backoff on model API failures, added in langchain v1.1.0). (Github) Retry can also be implemented by hand with @wrap_model_call, which illustrates the wrap-style mechanics — the middleware receives the request and a handler, and decides how many times to invoke it: (langchain.js)

python
from langchain.agents import create_agent
from langchain.agents.middleware import (
    wrap_model_call, ModelRequest, ModelResponse,
)
from typing import Callable

@wrap_model_call
def retry_model(
    request: ModelRequest,
    handler: Callable[[ModelRequest], ModelResponse],
) -> ModelResponse:
    for attempt in range(3):
        try:
            return handler(request)
        except Exception as e:
            if attempt == 2:
                raise
            print(f"Retry {attempt + 1}/3 after error: {e}")

Dynamic system prompts are built with @dynamic_prompt on top of ModelRequest, which exposes the conversation state (request.messages), the long-term memory store (request.runtime.store), and the runtime context (request.runtime.context). (Ailog) The distinction to internalize: context_schema defines immutable-per-invocation configuration — user, desk, risk limits — injected with agent.invoke(..., context=...); state_schema defines the graph's mutable state (in v1, TypedDict only); and the store is cross-thread memory. (Ailog) The user's risk profile belongs in the context, not in a hardcoded prompt:

python
from dataclasses import dataclass
from langchain.agents import create_agent
from langchain.agents.middleware import dynamic_prompt, ModelRequest

@dataclass
class DeskContext:
    user_id: str
    desk: str                 # e.g. "equity-ls", "macro"
    max_notional: float       # per-order notional limit, in USD
    risk_profile: str         # "conservative" | "balanced" | "aggressive"

@dynamic_prompt
def risk_aware_prompt(request: ModelRequest) -> str:
    ctx: DeskContext = request.runtime.context
    return (
        "Eres un asistente de trading institucional. "
        f"Mesa: {ctx.desk}. Perfil de riesgo: {ctx.risk_profile}. "
        f"Nunca propongas órdenes con notional superior a {ctx.max_notional:,.0f} USD; "
        "si la tesis lo requiere, propón escalonar la entrada."
    )

agent = create_agent(
    model="claude-sonnet-4-6",
    tools=[get_ohlcv, compute_rsi],
    middleware=[risk_aware_prompt, retry_model],
    context_schema=DeskContext,
)

agent.invoke(
    {"messages": [{"role": "user", "content": "Prepara una propuesta long en MSFT."}]},
    context=DeskContext(user_id="pm-014", desk="equity-ls",
                        max_notional=2_000_000, risk_profile="balanced"),
)

This mechanism is the natural route to regulatory personalization: the same agent serves a PM with a broad mandate and a junior analyst with strict limits, and the difference travels in a typed, auditable, immutable object — not in an editable prompt string.

3.2 LangGraph in depth

3.2.1 StateGraph: channels, reducers, and Command

When the flow must be decided by code rather than the model, you drop one level of abstraction: from create_agent to StateGraph. A StateGraph is a builder whose state is a TypedDict; each field becomes a channel with an optional reducer function; nodes are callables (state) -> partial_update; and edges can be static (add_edge), conditional (add_conditional_edges), or implicit via Command. At compile time, LangGraph validates the graph (no orphan nodes, existing destinations, START with outgoing edges) and builds a Pregel graph with channel-driven, super-step execution. (51CTO)

The reduction rule is the number-one source of bugs in multi-node graphs: without an explicit reducer, writes to a key overwrite each other (last-write-wins); with Annotated[..., reducer] they merge. (Github) The built-in add_messages reducer appends messages and updates existing ones by id; Overwrite bypasses the reducer and sets the channel directly — and only one node may use it on the same key within a super-step, on pain of InvalidUpdateError. (Github) Translated to trading: if four analysts write the report field in parallel, without a reducer only one survives. That is why this course teaches reducers before multi-agent.

python
from typing import Annotated, TypedDict
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages
from langchain_core.messages import AnyMessage

class ResearchState(TypedDict):
    messages: Annotated[list[AnyMessage], add_messages]  # merged
    ticker: str                                          # plain overwrite
    reports: Annotated[list[str], lambda a, b: a + b]    # accumulated

def market_analyst(state: ResearchState) -> dict:
    return {"reports": [f"technical: {state['ticker']} RSI en zona neutral"]}

def news_analyst(state: ResearchState) -> dict:
    return {"reports": [f"news: {state['ticker']} sin catalizadores 48h"]}

graph = (
    StateGraph(ResearchState)
    .add_node("market_analyst", market_analyst)
    .add_node("news_analyst", news_analyst)
    .add_edge(START, "market_analyst")
    .add_edge(START, "news_analyst")   # parallel fan-out: both write `reports`
    .add_edge("market_analyst", END)
    .add_edge("news_analyst", END)
    .compile()
)

For nodes that must update state and route at the same time, LangGraph offers Command, a primitive with four parameters (update, goto, graph, resume) on which the multi-agent handoffs of section 3.3 are built. A warning from the docs themselves: do not mix static edges and goto for the same node — both would fire. (arXiv.org)

python
from typing import Literal
from langgraph.types import Command

def regime_router(state: ResearchState) -> Command[Literal["bull_researcher", "bear_researcher"]]:
    vol_regime = classify_regime(state["ticker"])  # deterministic function
    return Command(
        update={"reports": [f"regime: {vol_regime}"]},
        goto="bull_researcher" if vol_regime == "risk-on" else "bear_researcher",
    )

Finally, subgraphs: any compiled StateGraph — including the one returned by create_agent — can be added as a node of a parent graph; shared keys flow parent↔child, and each subgraph manages its own checkpoint namespace. (PrivacyFilter) It is the composition mechanism behind the research/risk/execution teams of case 3.3.2.

3.2.2 Persistence: checkpoints, store, and time travel

The checkpointer serializes the complete graph state after every step — enabling pause/resume, failure recovery, and auditing — indexed by thread_id (one thread = one conversation or one analysis). (IDEAS/RePEc) The backend choice is not an infrastructure detail: it is a compliance decision.

Table 3.1 — Checkpointer decision criteria

ScenarioCheckpointerAdvantageRisk / limit
Unit tests, local developmentMemorySaver / InMemorySaverZero setup, fast, restarts clean between testsAll state is lost when the process restarts; forbidden in production
Single-process server, low concurrencySqliteSaverDurability in a local file, no external dependency, ACID writesSQLite write-locks serialize concurrent threads; does not scale horizontally
Multi-process / container deploymentPostgresSaver / AsyncPostgresSaverACID, SQL-queryable, doubles as an audit log, scales with poolingRequires connection pooling (asyncpg/psycopg_pool); blocking connections stall the event loop
HITL flows with an audit requirementPostgresSaver / AsyncPostgresSaverSQL queries over the checkpoint table replace separate audit-log infrastructureNo native TTL for paused threads: store the interrupt timestamps in state

Synthesis of the official persistence documentation and specialized deployment guides. (IDEAS/RePEc)

python
from langgraph.checkpoint.postgres import PostgresSaver

checkpointer = PostgresSaver.from_conn_string("postgresql://langgraph:****@db.internal/lg")
checkpointer.setup()  # creates tables and indexes
# Schedule a cron job that deletes checkpoints older than the retention policy

Checkpointer and store are not interchangeable: the checkpointer persists state per thread (today's analysis session); the Store (BaseStore: InMemoryStore, PostgresStore) is the official cross-thread memory layer, with tuple-style namespaces and semantic search. (estudy247.com) The separation in trading is sharp: the checkpointer holds this morning's bull/bear debate; the store holds thesis memory — lessons per ticker and strategy, accessible from any future session. It is the productionized version of the reflective log TradingAgents uses as memory. (langchain.js)

python
from langgraph.store.memory import InMemoryStore

store = InMemoryStore()
graph = builder.compile(checkpointer=checkpointer, store=store)

# When a thesis closes, a node distills the lesson into the store (cross-thread)
await store.aput(
    ("thesis", "NVDA"), "postmortem-2026Q2",
    {"lesson": "El debate ignoró la señal de inventarios; añadir check de supply chain."},
)
# Weeks later, any agent retrieves it before reopening the name
results = await store.asearch(("thesis", "NVDA"), query="riesgos de supply chain")

The third pillar is time travel: replay and fork from historical checkpoints, with three documented uses — debugging (rerun a failed run), A/B evaluation (same history, different prompt or model), and error recovery (rewind to just before the node that failed). (DevPress官方社区) For a desk, the fork is the quantitative audit tool: it answers "would the decision have changed with a more conservative analyst prompt?" by re-executing from the checkpoint before the debate, on the same market state. A property to know before designing HITL: interrupt() calls fire again during time travel. (arXiv.org)

3.2.3 Real human-in-the-loop: the order never leaves without sign-off

There are two HITL mechanisms, and they should not be confused. HumanInTheLoopMiddleware is configured with interrupt_on (a tool → configuration mapping), supports approve/edit/reject/respond decisions, and requires a checkpointer; since langchain>=1.3.3 it accepts a conditional predicate to interrupt only the calls that exceed a threshold — the "interrupt only orders with notional > X" pattern. (🦜️🔗 LangChain)

python
from langchain.agents import create_agent
from langchain.agents.middleware import HumanInTheLoopMiddleware
from langgraph.checkpoint.memory import InMemorySaver

agent = create_agent(
    model="gpt-5.5",
    tools=[get_ohlcv, place_order, cancel_order],
    middleware=[
        HumanInTheLoopMiddleware(
            interrupt_on={
                "place_order": {"allowed_decisions": ["approve", "edit", "reject"]},
                "cancel_order": {"allowed_decisions": ["approve", "reject"]},
                "get_ohlcv": False,  # safe read: no approval
            },
            description_prefix="Operación pendiente de aprobación del trader",
        ),
    ],
    # HITL requires checkpointing. In production: AsyncPostgresSaver.
    checkpointer=InMemorySaver(),
)

The second, more surgical mechanism is interrupt() inside a node: it pauses execution conditionally at an exact point and resumes with Command(resume=...) on the same thread_id. (arXiv.org) It is the right pattern when the pause is part of the business logic — for example, between the trader's plan and the transmission to the broker:

python
from langgraph.types import interrupt, Command
from langgraph.graph import StateGraph, START, END
from langgraph.checkpoint.memory import InMemorySaver
from typing import TypedDict

class ExecutionState(TypedDict):
    order: dict          # {"ticker", "side", "qty", "limit_price"}
    status: str

def risk_gate(state: ExecutionState) -> dict:
    order = state["order"]
    if order["qty"] * order["limit_price"] > 500_000:
        # Pause: the human trader decides on THIS exact state
        decision = interrupt({"pending_order": order,
                              "reason": "notional > 500k USD"})
        if decision["action"] == "reject":
            return {"status": "rejected_by_trader"}
        if decision["action"] == "edit":
            return {"order": decision["order"]}  # the human fixes quantity/price
    return {"status": "approved"}

def send_to_broker(state: ExecutionState) -> dict:
    ...  # FIX/REST to the OMS; only reached with status == "approved"
    return {"status": "sent"}

graph = (
    StateGraph(ExecutionState)
    .add_node("risk_gate", risk_gate)
    .add_node("send_to_broker", send_to_broker)
    .add_edge(START, "risk_gate")
    .add_edge("risk_gate", "send_to_broker")
    .compile(checkpointer=InMemorySaver())
)

config = {"configurable": {"thread_id": "order-2026-07-29-0147"}}
graph.invoke({"order": {...}, "status": "new"}, config)   # pauses at interrupt()
# After human review in the supervision UI:
graph.invoke(Command(resume={"action": "approve"}), config)  # continues to send_to_broker
An order above the notional threshold pauses with a checkpoint; it reaches the broker only after human approval or edit, and everything lands in the audit trail.trader_agent proposes order → risk_gate deterministic checks; risk_gate deterministic checks → status = approved (notional ≤ threshold); risk_gate deterministic checks → interrupt PAUSE + checkpoint (notional > threshold); interrupt PAUSE + checkpoint → supervision UI streaming v2; supervision UI streaming v2 → status = approved (approve); supervision UI streaming v2 → order updated status = approved (edit); supervision UI streaming v2 → status = rejected end + audit log (reject); status = approved → send_to_broker; order updated status = approved → send_to_broker; send_to_broker → PostgresSaver audit trail; status = rejected end + audit log → PostgresSaver audit trailnotional ≤ thresholdnotional > thresholdapproveeditrejecttrader_agentproposes orderrisk_gatedeterministic checksstatus = approvedinterruptPAUSE + checkpointsupervision UIstreaming v2order updatedstatus = approvedstatus = rejectedend + audit logsend_to_brokerPostgresSaveraudit trail
Risk gate with human interrupt. Orders under the notional threshold go straight through; those above it pause on interrupt until the supervision UI approves, edits or rejects them.

For the supervision UI fed by that interrupt, LangGraph exposes streaming with node- and token-level granularity: since ≥1.1 the unified v2 format (version="v2", StreamPart {type, ns, data} chunks) and since v1.2 an event-streaming API with per-projection iterators. (PrivacyFilter) The number-one pitfall with nested agents — create_agent returns a compiled graph, so every agent inside a graph is a subgraph — is that without subgraphs=True the parent's messages stream does not emit the inner LLM's tokens: (PrivacyFilter)

python
for chunk in graph.stream(
    {"messages": [{"role": "user", "content": "Analiza NVDA y propone acción."}]},
    stream_mode="messages",
    subgraphs=True,   # without this, silence from the inner agent
    version="v2",
):
    print(chunk["type"])  # "messages"
    print(chunk["ns"])    # () root, ("agent:<task_id>",) subgraph → routing by UI panel
    print(chunk["data"])  # (token, metadata)

3.3 Multi-agent patterns for trading

3.3.1 The official v1 taxonomy: subagents, handoffs, supervisor, swarm, deepagents, and MCP

The LangChain v1 documentation recognizes four canonical multi-agent patterns: (arXiv.org) subagents (a main agent coordinates subagents invoked as tools; stateless by design — strong context isolation at the cost of repeating the flow on every call), handoffs (agents transfer control to each other via tool calls), skills (a single agent loads specialized knowledge on demand without giving up control), and router (a classification step routes the input to the specialist). The key economic data point: stateful patterns (handoffs, skills) save 40-50% of model calls on repeated requests. (arXiv.org) The mapping to trading: subagents are "analysts as the PM's tools", handoffs are "bull → bear → trader", and router is the market-regime classifier.

On top of that taxonomy, the official ecosystem publishes three orchestration libraries. langgraph-supervisor creates a hierarchical supervisor whose LLM decides which agent to delegate to via handoff tools; the returned StateGraph can be nested as an agent of another supervisor (hierarchical teams). (arXiv.org) langgraph-swarm implements the swarm: create_handoff_tool generates transfer_to_<agente> tools, and an active_agent field in the shared state acts as the relay baton — whoever holds it, executes. (genai.qa) The measurable operational difference: in the supervisor every step goes through the central coordinator (more tokens); in the swarm the handoff is direct agent→agent (fewer tokens). (genai.qa) The third, deepagents (create_deep_agent), packages a coordinated research harness — planning with write_todos, a virtual filesystem with a tool allowlist, isolated-context subagents, HITL with per-subagent interrupt_on (LangChain Forum) — whose restricted filesystem is the least-privilege pattern applicable to broker tools: the research agent is only shown read_file, ls, glob, grep, never place_order. (LangChain Forum)

The piece that connects everything to real data is MCP (Model Context Protocol), the open protocol that standardizes how applications expose tools to LLMs: with langchain-mcp-adapters, MultiServerMCPClient loads tools from multiple servers (stdio/HTTP) as standard BaseTools. (Github) It is stateless by default (each invocation creates a new ClientSession); with handle_tool_errors=True tool errors return to the model as a ToolMessage with status="error" for self-correction; and it supports interceptors with access to context, state, and store to block sensitive tools. (Github) Institutional financial-data MCP servers already exist — production forks of TradingAgents integrate FSI servers (Kensho, Aiera, FactSet, Morningstar, LSEG) as tools for the analysts. (qiankunli.github.io)

python
import asyncio
from langchain_mcp_adapters.client import MultiServerMCPClient
from langchain.agents import create_agent

async def main():
    client = MultiServerMCPClient(
        {
            "market_data": {   # the desk's internal MCP server (ohlcv, fundamentals)
                "transport": "http",
                "url": "http://mcp-marketdata.internal:8000/mcp",
            },
            "news": {          # external vendor via MCP
                "transport": "http",
                "url": "https://mcp.vendor.com/mcp",
            },
        }
    )
    tools = await client.get_tools()   # standard BaseTool, prefixed per server
    analyst = create_agent("claude-sonnet-4-6", tools)
    return await analyst.ainvoke({
        "messages": [{"role": "user",
                      "content": "Cruce de momentum y noticias de MSFT, última semana."}]
    })

asyncio.run(main())

3.3.2 Canonical case: TradingAgents' bull/bear/judge debate

TradingAgents (Tauric Research; arXiv 2412.20138, AAAI 2025) is the reference multi-agent trading framework and this module's worked case. (LangChain Forum) It replicates the org chart of a real firm: four analysts in parallel (market/technical, sentiment/social, news, fundamentals) write their reports into the shared state; two researchers, bull and bear, debate them for max_debate_rounds rounds; a research manager judges the debate; a trader turns the thesis into a plan; a second three-way debate (aggressive, conservative, neutral) examines the risk; and a portfolio manager issues the final approval. (langchain.js) The implementation is a StateGraph whose nodes are the roles — closures that read and write a typed state (InvestDebateState, RiskDebateState) — with SQLite checkpoints to resume long analyses and two LLM tiers (deep_think_llm for synthesis, quick_think_llm for reading). (langchain.js) The cost of a full analysis is on the order of 30-50 LLM calls per ticker. (langchain.js)

The central architectural lesson is that the debate, despite involving several LLMs, is a deterministic graph: edges fixed by code, bounded rounds, the judge as just another node with explicit routing. The LLM generates content inside each node; the graph decides who speaks next — the materialization of the pattern "the FSM governs the process, the LLM solves the sub-problem inside each state". (Morph AI)

The multi-agent debate is a deterministic graph: parallel analysts, bounded bull/bear rounds, judge, trader, risk debate and portfolio manager approval.START → parallel fan-out; parallel fan-out → market_analyst; parallel fan-out → sentiment_analyst; parallel fan-out → news_analyst; parallel fan-out → fundamentals_analyst; market_analyst → bull_researcher; sentiment_analyst → bull_researcher; news_analyst → bull_researcher; fundamentals_analyst → bull_researcher; bull_researcher → bear_researcher (max_debate_rounds); bear_researcher → research_manager debate judge; research_manager debate judge → trader action plan; trader action plan → risk debate aggressive / conservative / neutral; risk debate aggressive / conservative / neutral → portfolio_manager final approval; portfolio_manager final approval → END; portfolio_manager final approval → SqliteSaver per ticker (checkpoint per step)max_debate_roundscheckpoint per stepSTARTparallel fan-outmarket_analystsentiment_analystnews_analystfundamentals_analystbull_researcherbear_researcherresearch_managerdebate judgetraderaction planrisk debateaggressive / conservative / neutralportfolio_managerfinal approvalENDSqliteSaverper ticker
TradingAgents debate graph. Four parallel analysts feed a bounded bull/bear debate, a judge, the trader and the risk debate; code decides who speaks next.

A minimal version of the debate core — two researchers alternating with a round counter and a judge — shows how little machinery is needed on top of the primitives from section 3.2:

python
from typing import Annotated, Literal, TypedDict
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages
from langgraph.types import Command
from langchain_core.messages import AnyMessage, HumanMessage

MAX_ROUNDS = 2

class InvestDebateState(TypedDict):
    messages: Annotated[list[AnyMessage], add_messages]  # debate history
    reports: list[str]        # reports from the 4 analysts (input)
    round: int
    verdict: str

def make_researcher(role: str, llm):
    def researcher(state: InvestDebateState) -> Command[Literal["bull", "bear", "judge"]]:
        argument = llm.invoke([
            HumanMessage(content=(
                f"Eres el investigador {role.upper()}. Informes: {state['reports']}. "
                f"Historial del debate: {state['messages']}. Contraargumenta en <200 palabras."
            ))
        ])
        nxt = "bear" if role == "bull" else ("bull" if state["round"] < MAX_ROUNDS else "judge")
        return Command(
            update={"messages": [argument], "round": state["round"] + (role == "bear")},
            goto=nxt,
        )
    return researcher

def judge(state: InvestDebateState) -> dict:
    # research_manager: structured synthesis of the debate → verdict
    return {"verdict": synthesize_verdict(state["messages"])}

debate = (
    StateGraph(InvestDebateState)
    .add_node("bull", make_researcher("bull", deep_llm))
    .add_node("bear", make_researcher("bear", deep_llm))
    .add_node("judge", judge)
    .add_edge(START, "bull")
    .add_edge("judge", END)
    .compile(checkpointer=sqlite_checkpointer)  # resumable per ticker
)

The case also demands a critical reading. The paper reports CR of 26.62% and Sharpe of 8.21 on AAPL over three months of 2024, and it is the authors themselves who warn in a footnote that this Sharpe exceeds their expected empirical range (SR>2 very good, SR>3 excellent) due to the absence of pullbacks in the period. (langchain.js) The window also falls inside the training data of the GPT-4-class models used — parametric leakage risk — and the independent large-scale replication (FINSABER: two decades, more than 100 symbols) finds that the advantages of LLM strategies deteriorate significantly outside those narrow windows. (arXiv.org) Academic criticism adds the structural nuance: existing financial multi-agent systems are broad architectures, not controlled tests of the value of multi-agent reasoning — the superior performance may come from more information or better risk management, not from the debate itself. (Github) Pedagogical verdict: TradingAgents is taught as a design pattern (roles, typed state, bounded cycles, checkpoints, LLM tiers), never as evidence of alpha. Serious forks point the same way: ThesisAgent separates reasoning (LLM) from decision (deterministic math) and reports identical decisions between the full LLM pipeline and the math-only engine. (LangChain中文文档)

3.4 The architectural decision matrix

The debate is no longer "agents, yes or no?" but "how much autonomy does each step of the pipeline justify?". The canonical reference is Anthropic's distinction between workflows — LLMs and tools orchestrated along predefined code paths — and agents — the LLM dynamically directs its own process — with the recommendation to find the simplest possible solution, because agentic systems trade latency and cost for performance. (Grepture) The winning condition: agents win when the number of steps is not known in advance and there is verifiable per-turn feedback; workflows win when the process is repeatable and auditability and fixed SLAs rule (LangChain Forum) — an exact description of a regulated desk.

The architectural layer on which the industry has converged in 2026 is "model your agent as a state machine first": the FSM governs the process flow deterministically while the LLM operates freely inside each state; you can draw the diagram, enumerate every path, and verify safety properties with deterministic tests. (Morph AI) The point that closes the circle: a StateGraph with static edges is an executable FSM; conditional edges turn it into a statechart with guards; and create_agent is a minimal FSM (model → tools → model) whose transition the LLM decides. (Morph AI) The full spectrum — pure FSM → graph with LLM guards → autonomous agent → multi-agent — is what the following matrix quantifies.

Table 3.2 — FSM → graph → agent → multi-agent matrix

CriterionFSM / deterministic graph (static edges)Graph with LLM guards (conditional edges)Autonomous agent (create_agent)Multi-agent (supervisor / swarm / debate)
Flow controlCode, 100% auditableLLM chooses among predefined routesLLM decides route and toolsSeveral LLMs decide routing and content
Cost / latencyMinimal (LLM only inside nodes)Low-mediumMedium (N-iteration loop)High: 30-50+ calls; 5-agent swarm ≈ 3× tokens per call (context overhead)
End-to-end reliabilityHigh (enumerable paths)Medium-highMedium (chained errors; 0,910≈0,350{,}9^{10}\approx0{,}35)Medium-low without guardrails
AuditabilityExcellent (checkpoint + fixed paths)GoodRequires HITL + loggingRequires HITL + per-agent tracing
When to use in tradingOrder execution, risk checks, scheduled rebalancingRegime classification, signal triageAd-hoc research, Q&A over data, hypothesis generationThesis debate, simulated investment committee, deep per-ticker analysis
Course equivalentExecution state machine (M7, M12)Strategy routerAnalyst with tools (M5: RAG as a tool)TradingAgents-style "trading firm"

Synthesis of Anthropic, the FSM-orchestration literature, and cost/reliability measurements of production topologies. (Grepture)

Figure 3.1 — Token cost by agentic architecture (illustrative data)Single agent (research): 18; Bull/bear debate (bounded graph): 95; Supervisor (hierarchical): 130; 5-agent swarm (handoffs): 210 · Single agent (research): $0.35; Bull/bear debate (bounded graph): $1.85; Supervisor (hierarchical): $2.55; 5-agent swarm (handoffs): $4.10Tokens per full analysis050100150200250thousand tokens (input + output)18kSingleagent(research)95kBull/beardebate(boundedgraph)130kSupervisor(hierarchical)210k5-agentswarm(handoffs)Estimated cost per analysis (Jul 2026)012345USD$0.35Singleagent(research)$1.85Bull/beardebate(boundedgraph)$2.55Supervisor(hierarchical)$4.105-agentswarm(handoffs)
Figure 3.1 uses illustrative data built from the published benchmark of 30-50 LLM calls per analysis (langchain.js) and the ~3× tokens-per-call multiplier for five-agent swarms; (CSDN博客) unit prices are indicative as of July 2026 and must be recalculated at current rates before any real budget.

The module's intellectual close is a sentence you can defend before a committee: the LLM reasons, the graph governs, the math decides the sizing, and a human signs the order. Later modules instantiate this matrix: module 5 turns RAG into a tool of the research agent; module 7 implements the risk gate as a deterministic node with CVaR computed in code; module 12 integrates everything in the capstone.

Exercises

  1. Research agent with middleware. Extend the agent from 3.1.1 with (a) SummarizationMiddleware with trigger={"tokens": 4000}, (b) the retry_model middleware from 3.1.2, and (c) a custom state_schema adding watchlist: list[str]. Verify that the custom state persists across calls with InMemorySaver and a fixed thread_id.

  2. The reducer bug. Build the graph from 3.2.1 with four analysts in parallel writing reports without a reducer; run it and check which report survives. Add the concatenation reducer and repeat. Document in two sentences why last-write-wins is unacceptable in a research pipeline and which exception LangGraph raises if two nodes use Overwrite on the same key in one super-step.

  3. HITL with editing. Implement the risk_gate from 3.2.3 with interrupt() and a SQLite checkpointer. Run three scenarios: direct approval, human editing of the quantity (verify that send_to_broker receives the edited order), and rejection (verify that the graph ends without calling the broker). Then locate the interrupt's checkpoint in the SQLite database.

  4. Forensic time travel. On the graph from exercise 3, use get_state_history to locate the checkpoint before the risk_gate, fork with update_state changing the threshold, and compare the two trajectories. Write the compliance paragraph explaining what was tested and on which exact market state.

  5. Bounded debate. Complete the debate StateGraph from 3.3.2 with the trader node and a downstream risk_gate that interrupts if the judge's verdict is BUY with notional above a threshold injected via context_schema. Measure LLM calls per analysis with a counter in a @wrap_model_call middleware and contrast with the 30-50 range of the TradingAgents case. (langchain.js)

  6. Design exercise — FSM vs graph. On paper (state diagram, no code), design the "signal → proposal → approval → execution → reconciliation" pipeline of a long-only desk, assigning each transition one of three categories: static edge (code), LLM guard (conditional edge), autonomous turn (agent). Justify each assignment with the corresponding row of Table 3.2, mark the exact point of the mandatory interrupt(), and estimate end-to-end reliability with p=0,95p=0{,}95 per LLM step and p=1,0p=1{,}0 per deterministic step: P=∏ipiP = \prod_i p_i. Deliver the diagram and the arithmetic.


Module 3 of 15 — LangChain for Quantitative Trading. Data and versions verified as of July 2026; prices and market figures subject to change. LangChain 1.3.14 / langchain-core 1.5.2.


// check

Check your understanding

Self-assessment with instant feedback. No score is stored.

  1. What does it mean in practice that `create_agent` returns a `CompiledStateGraph`?

    Show answer

    B. The return type is the decisive pedagogical point of 3.1.1: the agent is invoked like any graph (invoke with messages), nests as a subgraph — the basis of the multi-agent patterns of 3.3 — and inherits LangGraph's durability, streaming, interrupts, and time travel. AgentExecutor lives in langchain-classic and create_react_agent is deprecated in v1.

  2. A PM with a broad mandate and a junior analyst share the same trading agent. Where should each one's notional limit travel?

    Show answer

    C. The key distinction of 3.1.2: context_schema is immutable-per-invocation configuration (user, desk, risk limits), the state is the graph's mutable state, and the store is cross-thread memory. A limit in the prompt is editable; in the state it could be overwritten mid-session; the context makes it immutable and auditable.

  3. Four analysts in parallel fan-out each write their report to the `reports` key, declared as list[str] with no reducer. What does the state contain at the end?

    Show answer

    B. This is the reduction rule of 3.2.1 and the number-one source of bugs in multi-node graphs: without Annotated[..., reducer], writes to a key overwrite each other. InvalidUpdateError only appears if two nodes use Overwrite on the same key in the same super-step. With the concatenation reducer, all four reports survive.

  4. A regulated desk requires that human approval of every agent-proposed order be SQL-queryable years later. Which persistence backend fits?

    Show answer

    C. This is the decisive row of Table 3.1: for HITL flows with an audit requirement, a SELECT over the Postgres checkpoint table reconstructs the exact graph state at the moment of approval. SqliteSaver serializes concurrent threads with write-locks, InMemorySaver loses everything on process restart, and the store is cross-thread memory, not per-thread evidence.

  5. A risk_gate paused the graph with interrupt() on a 600,000 USD order. How is it correctly resumed after human review?

    Show answer

    C. The 3.2.3 pattern: interrupt() pauses on an exact state indexed by thread_id, and only Command(resume=...) on that same thread continues execution toward send_to_broker. Re-running invoke with new input creates a different execution and leaves the paused one hanging — potential order duplication — and it also requires a configured checkpointer.

  6. A research pipeline chains 10 autonomous steps at 90% reliability per step. What is the end-to-end reliability?

    Show answer

    C. The compound reliability from the 3.2.3 risk note: steps multiply, they do not average. 0.9^10 ≈ 0.35, meaning one in three runs fails. It is the quantitative argument behind the 3.4 matrix: moving a step to 'autonomous agent' adds multiplicative, not linear, risk, and justifies the interrupt() between decision and order.

  7. For the classifier that decides at the start of the pipeline whether the market regime is risk-on or risk-off and routes to the corresponding researcher, which architecture does the module's matrix recommend?

    Show answer

    B. Table 3.2 assigns regime classification and signal triage to the 'graph with LLM guards' column: the number of routes is known and enumerable, the LLM only chooses among them, and auditability is preserved at low cost. The autonomous agent is for ad-hoc research with no known step count, and the market regime is not a closed deterministic function.

  8. What is the correct reading of the 8.21 Sharpe that TradingAgents reports on AAPL?

    Show answer

    D. This is the critical reading of 3.3.2: SR>3 is already excellent according to the authors, the three-month 2024 window had no pullbacks, parametric leakage risk exists, and the two-decade, 100+ symbol replication erodes the advantages. The case's pedagogical value is architectural — roles, typed state, bounded cycles, LLM tiers — not its backtest.