// 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.
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.
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:
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)
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:
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.
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)
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
| Scenario | Checkpointer | Advantage | Risk / limit |
|---|---|---|---|
| Unit tests, local development | MemorySaver / InMemorySaver | Zero setup, fast, restarts clean between tests | All state is lost when the process restarts; forbidden in production |
| Single-process server, low concurrency | SqliteSaver | Durability in a local file, no external dependency, ACID writes | SQLite write-locks serialize concurrent threads; does not scale horizontally |
| Multi-process / container deployment | PostgresSaver / AsyncPostgresSaver | ACID, SQL-queryable, doubles as an audit log, scales with pooling | Requires connection pooling (asyncpg/psycopg_pool); blocking connections stall the event loop |
| HITL flows with an audit requirement | PostgresSaver / AsyncPostgresSaver | SQL queries over the checkpoint table replace separate audit-log infrastructure | No native TTL for paused threads: store the interrupt timestamps in state |
Synthesis of the official persistence documentation and specialized deployment guides. (IDEAS/RePEc)
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 policyCheckpointer 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)
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)
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:
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_brokerFor 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)
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)
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)
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:
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
| Criterion | FSM / deterministic graph (static edges) | Graph with LLM guards (conditional edges) | Autonomous agent (create_agent) | Multi-agent (supervisor / swarm / debate) |
|---|---|---|---|---|
| Flow control | Code, 100% auditable | LLM chooses among predefined routes | LLM decides route and tools | Several LLMs decide routing and content |
| Cost / latency | Minimal (LLM only inside nodes) | Low-medium | Medium (N-iteration loop) | High: 30-50+ calls; 5-agent swarm ≈ 3× tokens per call (context overhead) |
| End-to-end reliability | High (enumerable paths) | Medium-high | Medium (chained errors; ) | Medium-low without guardrails |
| Auditability | Excellent (checkpoint + fixed paths) | Good | Requires HITL + logging | Requires HITL + per-agent tracing |
| When to use in trading | Order execution, risk checks, scheduled rebalancing | Regime classification, signal triage | Ad-hoc research, Q&A over data, hypothesis generation | Thesis debate, simulated investment committee, deep per-ticker analysis |
| Course equivalent | Execution state machine (M7, M12) | Strategy router | Analyst 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)
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
-
Research agent with middleware. Extend the agent from 3.1.1 with (a)
SummarizationMiddlewarewithtrigger={"tokens": 4000}, (b) theretry_modelmiddleware from 3.1.2, and (c) a customstate_schemaaddingwatchlist: list[str]. Verify that the custom state persists across calls withInMemorySaverand a fixedthread_id. -
The reducer bug. Build the graph from 3.2.1 with four analysts in parallel writing
reportswithout 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 useOverwriteon the same key in one super-step. -
HITL with editing. Implement the
risk_gatefrom 3.2.3 withinterrupt()and a SQLite checkpointer. Run three scenarios: direct approval, human editing of the quantity (verify thatsend_to_brokerreceives the edited order), and rejection (verify that the graph ends without calling the broker). Then locate the interrupt's checkpoint in the SQLite database. -
Forensic time travel. On the graph from exercise 3, use
get_state_historyto locate the checkpoint before therisk_gate, fork withupdate_statechanging the threshold, and compare the two trajectories. Write the compliance paragraph explaining what was tested and on which exact market state. -
Bounded debate. Complete the debate
StateGraphfrom 3.3.2 with thetradernode and a downstreamrisk_gatethat interrupts if the judge's verdict isBUYwith notional above a threshold injected viacontext_schema. Measure LLM calls per analysis with a counter in a@wrap_model_callmiddleware and contrast with the 30-50 range of the TradingAgents case. (langchain.js) -
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 per LLM step and per deterministic step: . 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.
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.
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.
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.
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.
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.
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.
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.
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.