LangChain v1 essentials for quants
The Runnable mental model, LCEL, structured output with Pydantic, and low-level tool calling.
Learning objectives
- Describe the LangChain v1 package architecture (
langchain,langchain-core, partner packages,langchain-classic) and verify versions against PyPI as an engineering discipline. - Initialize chat models from any provider with
init_chat_model, including runtime-configurable models, retries, and rate limiting. - Compose financial pipelines with LCEL (
prompt | model | parser) usingRunnablePassthrough,RunnableLambda,RunnableParallel, andRunnableBranch. - Select the correct execution method (
invoke,batch,stream) for each task: single-item analysis, mass ticker screening, or live research dashboards. - Design structured outputs with
with_structured_outputand Pydantic for auditable sentiment classification, and orchestrate deterministic tools withbind_tools. - Critically assess when LangChain adds value and when a direct call to the provider SDK is the right decision.
Module 1 established the rule that governs the entire course: the LLM reasons, the math decides, the human vetoes. This module turns that rule into code. The primitives presented here —uniformly initialized models, composable LCEL chains, Pydantic-validated structured outputs, and low-level tool calling— are the minimum vocabulary on which Module 3 will build full agents. The idea worth retaining from now on is simple: in LangChain v1 every component, from a prompt to an agent, implements the same Runnable interface, so an agent is nothing more than one additional Runnable that can be invoked, batched, and streamed like any other.
2.1 The v1 package architecture and the Runnable mental model
2.1.1 v1.0 (Oct 22, 2025): reduced namespace, langchain-classic as a bridge, semver commitment
LangChain 1.0 was announced on October 22, 2025 as the framework's first major version, with the explicit commitment of introducing no breaking changes until 2.0 (MDPI) . The langchain==1.0.0 artifact had been published on PyPI a few days earlier, on October 17, 2025 (questdb.com) . The release is designated LTS: it remains in ACTIVE status until 2.0 ships and then enters maintenance for at least one year, while the 0.3 series stays in maintenance until December 2026 (Github) . The official cadence is minors every one to two months and weekly patches (Github) .
As of this course's reference date, the versions verified against the PyPI JSON API are: langchain 1.3.14 (Jul 16, 2026), langchain-core 1.5.2 (Jul 28, 2026), langchain-openai 1.4.1 (Jul 23, 2026), langchain-anthropic 1.5.3 (Jul 28, 2026), and langchain-classic 1.0.8 (Jun 10, 2026) (questdb.com) . All v1 packages require Python 3.10 or higher (Github) .
The structural change in v1 is the reduction of the langchain namespace to essential building blocks: langchain.agents (with create_agent), langchain.messages, langchain.tools, langchain.chat_models (with init_chat_model), and langchain.embeddings, mostly re-exports of langchain-core (Github) . Legacy code —LLMChain, classic retrievers, the indexing API, the hub, and community re-exports— moved to the langchain-classic package, which is installed separately and maintains backward compatibility (MDPI) . Import migration is mechanical: from langchain.chains import LLMChain becomes from langchain_classic.chains import LLMChain (Github) . In this course langchain-classic is used only as a historical reference; no new example requires it.
Table 2.1 — LangChain v1 ecosystem packages: role, verified version, and typical use in the quant stack
| Package | Role in v1 | Verified version (PyPI, Jul 2026) | Typical use in the quant stack |
|---|---|---|---|
langchain |
Essential namespace: agents, models, tools, embeddings | 1.3.14 | Entry point: init_chat_model, create_agent (M3) |
langchain-core |
Base abstractions: Runnable, messages, prompts, parsers, rate limiters |
1.5.2 | LCEL, with_structured_output, bind_tools, callbacks |
langchain-openai / langchain-anthropic |
Per-provider partner packages | 1.4.1 / 1.5.3 | Access to GPT and Claude with interface parity |
langchain-classic |
Compatibility bridge for legacy code | 1.0.8 | Migration only; forbidden in new course code |
langchain-text-splitters |
Document chunking | 1.x series | Preparing filings and transcripts for RAG (M4) |
The table above summarizes the anatomy of the ecosystem, but three practical observations matter more than the list itself. First: the separation between langchain and langchain-core is not cosmetic — the abstractions this module uses daily (Runnable, ChatPromptTemplate, RunnableLambda, InMemoryRateLimiter) live in langchain-core, while langchain provides the convenience facades. An institutional requirements.txt must pin both with strict pins, because the weekly patch cadence means two environments installed one week apart are not identical. Second: partner packages version independently of the core, which makes it possible to update one provider's integration without touching the rest of the pipeline — a valuable property when a provider changes its API in the middle of a model validation cycle. Third: the existence of langchain-classic as a separate package turns deprecation into an explicit installation decision; if a legacy research pipeline still imports LLMChain, the dependency is visible in the lockfile and auditable, rather than hidden inside a monolithic namespace. For a risk manager, that dependency traceability is part of the model inventory required by model risk management practices inherited from SR 11-7, whose principles persist even though the framework has been rescinded.
2.1.2 init_chat_model: unified interface, runtime configuration, built-in resilience
init_chat_model is the entry point to models in v1: a factory that returns a BaseChatModel with the same interface across more than eighty providers, with "provider:model" syntax and kwargs passed inline (alphanova.tech) . The following snippet initializes a primary model with retries and an explicit rate limiter — two controls that are not optional in a mass research environment:
from langchain.chat_models import init_chat_model
from langchain_core.rate_limiters import InMemoryRateLimiter
# Thread-safe limiter: maximum 2 requests/second, burst of 10
rate_limiter = InMemoryRateLimiter(
requests_per_second=2.0, # provider call budget
check_every_n_seconds=0.1, # check every 100 ms
max_bucket_size=10, # maximum burst size
)
model = init_chat_model(
"openai:gpt-5.5",
temperature=0, # financial classification: determinism
timeout=30,
max_tokens=1_000,
max_retries=6, # default; retries 429/5xx/network with backoff
rate_limiter=rate_limiter,
)
respuesta = model.invoke("Resume en una frase el riesgo principal de este titular: "
"ACME Corp recorta su guía de ingresos un 12%.")
print(respuesta.text) # in v1, .text is a property, not a method
Chat models automatically retry with exponential backoff up to six times on network errors, rate limits (HTTP 429), and server errors (5xx); client errors such as 401 or 404 are not retried (alphanova.tech) . The .text property and the AIMessage return type are part of v1's official breaking changes relative to the 0.3 series (Github) .
The second pattern is the runtime-configurable model, directly relevant for cost routing: a lightweight model for mass headline screening and a frontier model for thesis writing, interchangeable without recompiling the chain (alphanova.tech) :
from langchain.chat_models import init_chat_model
# No `model`: remains configurable at runtime via config
router_model = init_chat_model(temperature=0, configurable_fields=("model", "model_provider"))
# Mass screening: cheap model
router_model.invoke(
"Clasifica el sentimiento de este titular: ACME eleva dividendo un 8%.",
config={"configurable": {"model": "gpt-5.4-mini"}},
)
# Investment thesis: frontier model, same call, same code
router_model.invoke(
"Redacta la tesis de riesgo para ACME a 12 meses.",
config={"configurable": {"model": "claude-sonnet-4-6"}},
)
When a chain contains several models, config_prefix prevents key collisions in the configurable dict (alphanova.tech) . Finally, langchain-core 1.0 introduced standard content blocks: the .content_blocks property of messages exposes text, reasoning, citations, tool calls (including server-side ones), and multimodal data (image, audio, video, PDF) with uniform typing and full backward compatibility (MDPI) . For the quant this means that a candlestick chart attached as an image, an earnings call transcript, and the model's reasoning are consumed through the same interface regardless of provider (PyQuant News) .
In plain terms: init_chat_model is a factory, not a model
init_chat_model does not contain any model: it is the switchboard connecting your code to whichever provider you choose, with the same connector for more than eighty of them. The useful analogy is a trading desk with a universal phone: the analyst always dials the same way; what changes is the line on the other end ("openai:gpt-5.5", "anthropic:claude-sonnet-4-6"). That is why switching providers is a configuration change, not a pipeline rewrite.
Three practical consequences the module snippet hints at:
- Resilience travels inside the object. Retries with backoff (
max_retries=6) and the call budget (InMemoryRateLimiter) are configured once and apply to every subsequentinvoke, with no extra code at each call site. temperature=0is not an aesthetic tic. In financial classification you want the same headline to produce the same label today and six months from now; variability is the enemy of audit..textis a property in v1 (not a method as in 0.3): when migrating old code,respuesta.text()raises an error;respuesta.textreturns the string.
The runtime-configurable pattern deserves a cost reading. init_chat_model(temperature=0, configurable_fields=("model", "model_provider")) turns model choice into a property of the call, not of the deployment: mass headline screening runs on gpt-5.4-mini and thesis writing on claude-sonnet-4-6, with the same code and the same pipeline. It is the one-line version of what in infrastructure would be a cost router: each task pays for exactly the model it needs. When a chain hosts several configurable models, config_prefix prevents their keys from colliding in the configurable dict.
2.2 LCEL: composing financial pipelines
2.2.1 The | operator: prompt | model | parser applied to a news-analysis pipeline
LCEL (LangChain Expression Language) is the declarative composition style of langchain-core: components are chained with the | operator, which builds a RunnableSequence where each link's output is the next one's input (Source) . The Runnable interface is the framework's universal contract: any component —prompt, model, parser, custom function, or compiled graph— can be "invoked, batched, streamed, transformed and composed" (Source) . A literal dict inside a sequence is automatically coerced into a RunnableParallel, which runs the branches concurrently over the same input and returns a dict of results (Source) . The composed chain inherits synchronous, asynchronous, batch, and streaming execution for free (Source) .
The following pipeline analyzes a corporate news item and produces two views in parallel: a sentiment classification and an extraction of mentioned metrics:
from langchain.chat_models import init_chat_model
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_core.runnables import RunnablePassthrough
model = init_chat_model("openai:gpt-5.4-mini", temperature=0)
prompt_sentimiento = ChatPromptTemplate.from_messages([
("system", "Eres un analista sell-side. Clasifica el sentimiento del titular "
"como Positive, Negative o Neutral. Responde solo con la etiqueta."),
("user", "Titular: {titular}"),
])
prompt_metricas = ChatPromptTemplate.from_messages([
("system", "Extrae en una línea cada cifra financiera mencionada en el titular, "
"con su unidad y escala. Si no hay ninguna, responde NO_DISPONIBLE."),
("user", "Titular: {titular}"),
])
# Parallel branch: same input, two concurrent analyses
pipeline_noticias = (
RunnablePassthrough()
| {
"sentimiento": prompt_sentimiento | model | StrOutputParser(),
"metricas": prompt_metricas | model | StrOutputParser(),
}
)
resultado = pipeline_noticias.invoke(
{"titular": "ACME Corp reporta ingresos de 1.234,56 millones USD (+9% interanual) "
"y eleva su guía de margen EBIT para 2027."}
)
# {'sentimiento': 'Positive', 'metricas': '1.234,56 millones USD (ingresos); +9% interanual'}
The four composition primitives cover the remaining structural needs. RunnablePassthrough forwards the input unchanged and, with .assign(), adds computed keys to the dict flowing through the chain (NewYorkCityServers) . RunnableLambda wraps any Python callable as a Runnable — the standard way to embed deterministic preprocessing, such as normalizing tickers or truncating text (Source) . RunnableParallel is the concurrent fan-out already seen (Source) . RunnableBranch implements conditional routing as a list of condition→runnable pairs plus a default (Dividend Capture Pro) , a useful pattern for routing the analysis by news type (earnings, M&A, guidance) toward specialized prompts. The following diagram summarizes the pipeline above:
Common pitfall: assuming the branches of a RunnableParallel can see each other
The literal dict inside a chain is coerced into a RunnableParallel: all branches receive the same input and run concurrently; none sees the others' output. The real production mistake is writing a branch that expects another branch's result —for example, an analisis_por_ticker branch that assumes the tickers branch has already extracted the symbols— and discovering at runtime that it receives the original headline.
If there is a dependency between steps, composition is sequential, not parallel:
# Real dependency: extract first, then enrich the flowing dict
pipeline = (
RunnablePassthrough()
| {"tickers": prompt_tickers | model | StrOutputParser()} # parallel here
| RunnablePassthrough.assign( # and sequential here
sentimiento=prompt_sentimiento | model | StrOutputParser()
)
)
A second variant of the same pitfall: putting side effects inside a branch (writing to a global dict, appending to a list). Branches run on concurrent threads; the result is a race. Branches must be pure functions: all result combination happens afterwards, over the returned dict.
2.2.2 invoke/ainvoke/batch/stream: streaming for dashboards, batch for mass screening
The Runnable interface defines a complete family of execution methods; choosing the right one is an engineering decision with direct impact on latency and cost (Source) :
Table 2.2 — Execution methods of the Runnable interface: semantics, async variant, and use in quantitative trading
| Method | Semantics | Async variant | Use in quantitative trading |
|---|---|---|---|
invoke(input, config) |
One input → one output | ainvoke |
Single signal: classifying the headline that just arrived |
batch(inputs, config) |
List of inputs → list of outputs; parallel with a thread pool by default | abatch |
Mass screening: sentiment of 500 pre-open headlines |
batch_as_completed |
Like batch, returns results as they complete |
abatch_as_completed |
Pipelines with heterogeneous per-provider latencies |
stream(input) |
Generator of chunks as they are produced | astream |
Research dashboards: live tokens from a report |
astream_events |
Stream of intermediate chain events | native async | Step-by-step traceability in debugging and audit |
map() |
Applies the runnable to each element of a list | — | Declarative fan-out over a portfolio |
Reading this table in an institutional context involves three points. First, batch is not a cosmetic for loop: by default it runs invoke() in parallel on a thread pool executor, and accepts max_concurrency via config, so the throughput of a ticker screening is bounded by the provider's rate limit and not by per-call latency (Source) . In formal terms, if each call takes \(t\) seconds, sequential cost grows as \(T_{\text{seq}} = N \cdot t\), while the batch with concurrency \(c\) scales as \(T_{\text{batch}} \approx \lceil N/c \rceil \cdot t\): for \(N = 200\) headlines and \(c = 8\), the improvement is close to an order of magnitude. Second, the methods with the a prefix are asynchronous and, by default, run their synchronous counterpart in the asyncio thread pool; chat model integrations override them with native async, which matters when the pipeline coexists with a market data event loop (Source) . Third, streaming is not a demo luxury: in v1 the updates, messages, and custom modes make it possible to emit tokens, per-step progress, and arbitrary data to a research dashboard, and since version 1.3 the documentation recommends typed event streaming as the preferred API (Github) . If a component does not implement streaming, the interface degrades gracefully to invoke (Source) .
The following snippet shows both patterns over the same headline classifier:
from langchain.chat_models import init_chat_model
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
model = init_chat_model("openai:gpt-5.4-mini", temperature=0)
prompt = ChatPromptTemplate.from_messages([
("system", "Clasifica el sentimiento del titular: Positive, Negative o Neutral."),
("user", "{titular}"),
])
clasificador = prompt | model | StrOutputParser()
# 1) Mass pre-open screening: parallel batch over 200 tickers
titulares = [{"titular": f"Titular de mercado nº {i} sobre el ticker T{i:03d}"}
for i in range(200)]
etiquetas = clasificador.batch(
titulares,
config={"max_concurrency": 8, "tags": ["screening", "pre-market"],
"metadata": {"desk": "equity-research", "prompt_version": "v3"}},
)
# 2) Research dashboard: token-by-token streaming of a long report
for chunk in clasificador.stream({"titular": "ACME eleva guidance y anuncia buyback."}):
print(chunk, end="", flush=True)
The config argument with tags and metadata is not decorative: those tags travel with each execution and feed traceability in LangSmith, the foundation of the observability that Module 10 develops as a defense against the agentic AI governance gap (Source) .

Figure 2.1 — Source: own elaboration with synthetic illustrative data (assumed mean latency of 1.15 s per call, max_concurrency=8), July 2026. The shape of the curves is structural; absolute values depend on the provider and the model.
The figure above quantifies the point with synthetic illustrative data: with a mean latency of 1.15 s per call, classifying 200 headlines in sequence would cost about 230 s, versus about 31 s with batch and max_concurrency=8. The exact numbers depend on the provider and the model, but the shape of the curves —linear versus stepped— is structural, not empirical.
In institutional practice. The mass-batch pattern is exactly what Kensho (S&P Global) operates, running a financial retrieval framework on the LangChain stack with multi-stage evaluation based on exact-match routing and tool calling: every night thousands of documents are classified and routed, and quality is measured against a golden dataset, not by human impression. The transferable lesson is twofold: mass screening is designed as a throughput problem (
batch,max_concurrency, rate limiter), and its output only enters production after passing an evaluation gate with reference data. A sentiment classifier without a golden dataset is a demo, not a system.
Worked example: when the rate limiter rules over concurrency
The module formula, \(T_{\text{batch}} \approx \lceil N/c \rceil \cdot t\), assumes the provider accepts everything you send. In production there is a second ceiling: the per-second call budget. Put both constraints together with the module's own numbers (Sections 2.1.2 and 2.2.2): mean latency \(t = 1{,}15\) s, max_concurrency \(c = 8\), InMemoryRateLimiter at 2 req/s, \(N = 200\) headlines.
- Theoretical throughput from concurrency: \(c/t = 8 / 1{,}15 \approx 6{,}96\) calls/s → \(T \approx \lceil 200/8 \rceil \times 1{,}15 = 25 \times 1{,}15 \approx 28{,}8\) s (the module cites it as ~31 s including startup overhead).
- Throughput allowed by the limiter: 2 calls/s → \(T \geq 200 / 2 = 100\) s (the bucket's initial burst of 10 shaves off only a few seconds).
- Actual result: \(T \approx \max(28{,}8,\ 100) = 100\) s. The bottleneck is the limiter, not concurrency.
The sizing rule that follows: useful concurrency is \(c^* \approx \text{req/s} \times t = 2 \times 1{,}15 \approx 2{,}3\), i.e., \(c = 3\). Raising max_concurrency to 8 or 16 speeds nothing up and only increases the probability of an HTTP 429 if the limiter fails. In mass research, the limiter is the source of truth and max_concurrency is set in its service.
The full calculation, reproducible in five lines:
import math
t, c, rps, N = 1.15, 8, 2.0, 200
t_secuencial = N * t # 230.0 s — invoke in a loop
t_batch = math.ceil(N / c) * t # 28.75 s — ideal, no limiter
t_real = max(t_batch, N / rps) # 100.0 s — limiter bound
c_util = math.ceil(rps * t) # 3 — concurrency you actually use

Figure — Own elaboration with synthetic illustrative data consistent with Figure 2.1 of the module (latency 1.15 s per call, max_concurrency=8, limit 2 req/s), July 2026. The curve with the limiter is the only one a real provider will let you run.
2.2.3 Robustness: with_retry with backoff, with_fallbacks across providers, callbacks
Every Runnable exposes .with_retry(...), which returns a new runnable that retries on exceptions with a configurable policy, and .with_fallbacks([...]), which chains backup alternatives on failure (Source) . Because chat models already internally retry 429/5xx/network errors up to six times, with_retry is reserved for application-logic failures —for example, a parse that fails validation— while with_fallbacks resolves the major contingency: a complete provider outage (alphanova.tech) . The canonical institutional pattern is primary OpenAI → backup Anthropic:
from langchain.chat_models import init_chat_model
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
prompt = ChatPromptTemplate.from_messages([
("system", "Clasifica el sentimiento del titular: Positive, Negative o Neutral."),
("user", "{titular}"),
])
primario = init_chat_model("openai:gpt-5.5", temperature=0, timeout=30)
respaldo = init_chat_model("anthropic:claude-sonnet-4-6", temperature=0, timeout=30)
cadena_resiliente = (
prompt
| primario.with_fallbacks([respaldo]) # failover on outage
| StrOutputParser().with_retry(stop_after_attempt=3) # application logic
)
# Per-call callbacks for ad hoc observability
from langchain_core.tracers import ConsoleCallbackHandler
etiqueta = cadena_resiliente.invoke(
{"titular": "ACME suspende su dividendo trimestral citando preservación de liquidez."},
config={"callbacks": [ConsoleCallbackHandler()],
"tags": ["sentiment", "fallback-enabled"]},
)
Three production details complete the picture. Fallbacks are also migrating to infrastructure: LangSmith offers an LLM Gateway (in private beta as of Jul 2026) where the backup order is defined once and applied by HTTP error code (429, 500, 502, 503, 504), without touching each pipeline's code (Quant Memo) . Development callbacks (ConsoleCallbackHandler, set_debug(True)) are for local debugging; in production the path is centralized tracing (Source) . And switching between providers is only safe if the output contract is stable — a weighty reason for the structured output of the next section: a StrOutputParser receiving free text from two different models offers no parity guarantee whatsoever.
The resilience map: three mechanisms, three different enemies
It is easy to memorize with_retry and with_fallbacks as synonyms for "retrying". They are not: each layer covers a failure of a different nature, and applying the wrong layer makes things worse — retrying a deterministic parse against a down provider, or switching providers on an error in your own logic. The healthy chaining, from the inside out:
- The chat model's internal retry (
max_retries=6): absorbs the transient — HTTP 429, 5xx, and network errors — with exponential backoff. Client errors (401, 404) pass straight through: they are your configuration, not a temporary failure. with_fallbacks([respaldo]): resolves the major contingency, a complete provider outage, switching for example from OpenAI to Anthropic without touching the rest of the chain.with_retry(stop_after_attempt=3)on the parser: reserved for your application logic — a parse or validation that can fail even when the model responds well.
One detail that closes the circle with the next section: switching between providers is only safe if the output contract is identical on both — a StrOutputParser receiving free text from two different models guarantees no parity —, a weighty reason for the structured output of Section 2.3.
2.3 Structured output and low-level tool calling
2.3.1 with_structured_output with Pydantic: the first financial schema, include_raw for audit
with_structured_output(schema) wraps a chat model so that its outputs match a Pydantic, TypedDict, or JSON schema, and returns validated instances instead of text (wallstreetmojo.com) . Internally it chooses the strategy according to provider capability: if the provider supports tool calling or native JSON schema, the schema travels as a tool definition or response_format; if not, it falls back to the prompt+parser pattern (Quant Memo) . One design detail with consequences: the class name, the docstring, and the field descriptions are effectively injected into the model's prompt, so designing the Pydantic schema is designing prompting (Quant Memo) .
This is the point where the Module 1 rule becomes code. The institutional sentiment classifier does not return a loose word: it returns a typed contract with a bounded score and textual evidence that an auditor can verify:
from typing import Literal, Optional
from pydantic import BaseModel, Field
from langchain.chat_models import init_chat_model
class SentimientoNoticia(BaseModel):
"""Sentiment classification of a financial headline with auditable evidence."""
etiqueta: Literal["positive", "negative", "neutral"] = Field(
description="Sentimiento del titular para el precio de la acción a 1-5 días")
score: float = Field(
ge=-1.0, le=1.0,
description="Intensidad del sentimiento normalizada en [-1, 1]; "
"0.0 solo si la etiqueta es neutral")
evidencia: str = Field(
description="Frase VERBATIM del titular que justifica la clasificación")
tickers_mencionados: list[str] = Field(
description="Tickers explícitos en el titular; lista vacía si no hay ninguno")
confianza: Optional[Literal["baja", "media", "alta"]] = Field(
default=None,
description="Auto-evaluación de confianza; null si el titular es ambiguo")
model = init_chat_model("openai:gpt-5.5", temperature=0)
# include_raw=True: dict output with 'raw', 'parsed', and 'parsing_error'
extractor = model.with_structured_output(SentimientoNoticia, include_raw=True)
salida = extractor.invoke(
"Clasifica este titular: ACME Corp desploma un 18% tras revelarse "
"una investigación regulatoria sobre sus cuentas de 2025."
)
assert salida["parsing_error"] is None
analisis: SentimientoNoticia = salida["parsed"]
mensaje_crudo = salida["raw"] # full AIMessage: complete traceability
print(analisis.etiqueta, analisis.score, analisis.evidencia)
With include_raw=True the output is always a dict with keys raw (the original AIMessage, with usage_metadata and response_metadata), parsed (the validated instance), and parsing_error (the captured exception, without raising it) (wallstreetmojo.com) . For a desk subject to audit requirements, persisting raw alongside parsed is the difference between being able and not being able to reconstruct exactly what the model said six months later. The schema can also be forced into strict mode on OpenAI with method="json_schema", strict=True (manishgoelstocks.com) , with the documented restriction that in strict mode fields cannot carry validation metadata or default values on some providers (wallstreetmojo.com) . There is also a known open bug (issue #38223, June 2026): with the Responses API, streaming, and nested Pydantic schemas, the streaming path builds an invalid JSON schema where the non-streaming path works — a reminder that structured-output edge cases are still active (ryanoconnellfinance.com) .
Risk note. Constrained decoding guarantees form, not truth. Structured output evaluations report schema compliance close to 100%, versus ~86% for function calling and less for JSON mode (A.L. Capital Advisory) ; but a well-typed field can contain a made-up value: when the data point is not in the document, the constrained model cannot refuse and emits a plausible figure — the "confidently wrong" failure (metricgate.com) . The mitigation is schema design:
nullas a legitimate value for every data field, mandatory verbatim evidence, and two-step verification (the cited figure must appear literally in the evidence). Second warning, counterintuitive and documented: in simple three-class classification, chain-of-thought does not help and can hurt — in classification with exceptions it increased the required iterations by up to 331% and stalled the model on the generalizable rule (Day Trading) . Headline sentiment: direct, temperature zero, no CoT. Third: never let the LLM compute. The analysis of the PAL paper showed that in 16 of 25 mathematical failures the verbal reasoning was identical and only the numbers changed — the dominant failure mode is arithmetic, not reasoning; delegating computation to code raised GSM-Hard from ~20% to 61.2% (Financial Wisdom TV) . The schema'sscoreis proposed by the LLM; any aggregation of scores (sector average, z-score, composite signal) is computed by Python.
In plain terms: the schema is customs control — and it is also the prompt
A Pydantic schema in with_structured_output works like airport customs control: it inspects the shape of every item —does it have etiqueta? is it one of the three allowed values? is the score between −1 and 1?— but it does not open the suitcase to check that the contents are true. That is why the module insists on the risk note: constrained decoding guarantees structure, not truth; truth is defended with legitimate null, verbatim evidence, and verification in code.
The second point is less intuitive: the class name, its docstring, and every Field(description=...) are injected into the prompt the model receives. Writing """Frase VERBATIM del titular que justifica la clasificación""" is not documentation for the next developer: it is an operative instruction the model reads and follows. Designing the schema is designing prompting, and it deserves the same review.
And include_raw=True is the entry stamp customs keeps: the original AIMessage with usage_metadata and response_metadata, so that months later you can reconstruct exactly what the model said, not only what Pydantic validated.
Two border cautions the module documents and worth keeping in mind before hardening the schema: strict mode (method="json_schema", strict=True) prohibits, on some providers, validation metadata and default values on fields — design the schema with that restriction in mind if you will need it — and there is an open bug (issue #38223) where the streaming path with nested Pydantic schemas builds an invalid JSON schema while the non-streaming path works. If your pipeline streams, test it with your real schema, not a toy one.
2.3.2 bind_tools and the manual tool-calling loop: the LLM as a router of deterministic tools
bind_tools is the low-level BaseChatModel method for attaching tools —functions, Pydantic classes, dicts, or BaseTool— and returns a Runnable whose output is an AIMessage with the tool_calls list (arXiv.org) . The contract is the second half of the course rule: the LLM proposes, the code disposes. The model never executes the tool; it only emits dicts {'name', 'args', 'id', 'type': 'tool_call'} that the application validates and executes (alphanova.tech) . In a trading system this is not a technical nuance: it is the boundary between the non-deterministic component (reasoning) and the deterministic, auditable components (computation, data, execution):
from langchain.chat_models import init_chat_model
from langchain.messages import HumanMessage, ToolMessage
from langchain_core.tools import tool
@tool
def get_market_data(ticker: str, field: str) -> dict:
"""Returns a point-in-time market data point (price, volume, beta)
for a ticker. Deterministic source: never invented by the LLM."""
datos = {"ACME": {"precio": 184.32, "beta": 1.21, "volumen_medio": 8_400_000}}
return {"ticker": ticker, "field": field, "valor": datos.get(ticker, {}).get(field)}
@tool
def calculator(expression: str) -> float:
"""Only authorized path for arithmetic. The LLM does not compute in text."""
import ast, operator as op
OPS = {ast.Add: op.add, ast.Sub: op.sub, ast.Mult: op.mul,
ast.Div: op.truediv, ast.USub: op.neg}
def ev(n):
if isinstance(n, ast.Constant): return n.value
if isinstance(n, ast.BinOp): return OPS[type(n.op)](ev(n.left), ev(n.right))
if isinstance(n, ast.UnaryOp): return OPS[type(n.op)](ev(n.operand))
raise ValueError("expresión no permitida")
return ev(ast.parse(expression, mode="eval").body)
tools = {t.name: t for t in [get_market_data, calculator]}
model = init_chat_model("openai:gpt-5.5", temperature=0).bind_tools(list(tools.values()))
# Manual tool-calling loop: the LLM routes, the code executes
historial = [HumanMessage(
"Con el precio y la beta de ACME, calcula su coste de equity aproximado "
"si rf = 4% y la prima de mercado es 5% (CAPM: rf + beta * prima).")]
respuesta = model.invoke(historial)
historial.append(respuesta)
while respuesta.tool_calls: # is the model proposing tools?
for call in respuesta.tool_calls:
resultado = tools[call["name"]].invoke(call["args"]) # the CODE executes
historial.append(ToolMessage(content=str(resultado), tool_call_id=call["id"]))
respuesta = model.invoke(historial) # new round with results
historial.append(respuesta)
print(respuesta.text) # final answer: 4% + 1.21 * 5% = 10.05% (computed by the tool)
Three observations close the section. First, tool_choice="any" forces the model to call a tool — a pattern the documentation itself uses for guardrailed structured classification (Github) . Second, in streaming, tool calls arrive as tool_call_chunk with partial JSON that must be aggregated before executing anything; executing half-parsed arguments is an operational security failure (Github) . Third, this thirty-line manual loop is, in essence, what create_agent encapsulates with state, persistence, and middleware; Module 3 will formalize it, but it is worth arriving knowing that an agent does not add magic but orchestration over this same contract — and that the resulting agent is still a composable Runnable.
Worked example: the tool-calling loop, message by message
The module's CAPM snippet looks dense until it is unfolded as what it is: a conversation with strict turns. This is the full trace of the solved case (price 184.32, beta 1.21; rf = 4%, premium 5%):
Three verifications worth doing when debugging your own trace:
- Arithmetic never appears in the LLM's free text. The model chose the tool and the arguments (
"0.04 + 1.21 * 0.05"); the number 0.1005 was produced by the Python AST. It is the risk note from 2.3.1 turned into code: delegating computation raised GSM-Hard from ~20% to 61.2% in the PAL paper. - Every
ToolMessagematches itstool_call_id. If an id is left unanswered or duplicated, the next round fails or, worse, the model hallucinates the data it did not receive. - The history grows every round — that is the state. The
while respuesta.tool_callsloop is exactly whatcreate_agentwill encapsulate in Module 3 with persistence and middleware; whoever understands this trace understands the agent.
2.4 When NOT to use LangChain
2.4.1 Documented criticisms, comparison with direct calls, and the dominant hybrid pattern
The canonical criticism is the Octomind post (June 2024), which after twelve months in production abandoned LangChain because its high-level abstractions made the code more expensive to understand and maintain; it reached 480 points on Hacker News and drew a direct response from LangChain's CEO (lilys.ai) . Its iconic example: translating a text is a direct API call, but in classic LangChain it required four abstractions (prompt template, chat model, output parser, chain) (arXiv.org) . Anthropic, in its December 2024 engineering guide, formulates the positive version of the same advice: the most successful implementations are built with "simple, composable patterns" instead of complex frameworks, starting with direct calls and adding layers only when they demonstrably improve results — although the guide notes that frameworks are useful for getting started quickly (arXiv.org) . Later criticisms add hidden costs from implicit LLM calls and stack traces five or more abstraction layers deep (arXiv.org) .
The 2026 consensus is nuanced and practical: for a single call or a trivial linear pipeline, the provider's direct SDK plus Pydantic is more readable and free of versioning risk; LangChain is justified when there is multi-model orchestration, one-line provider swapping, stateful agents, or a need for built-in observability (arXiv.org) . The framework's own trajectory —from monolithic namespace to v1's slimmed-down package, with the legacy exiled to langchain-classic— is a direct response to those criticisms, acknowledged in the official announcement (MDPI) . In document retrieval, the dominant pattern combines frameworks: LlamaIndex as a retrieval layer wrapped as a tool inside LangChain orchestration (arXiv.org) . And adoption is real: the top five frameworks concentrate more than 93% of segment downloads, with LangChain at around 233M monthly downloads (QASkills.sh) , and 57.3% of organizations surveyed in the State of Agent Engineering 2026 report agents in production — 67% at companies with more than 10,000 employees — with independent Datadog telemetry showing agent framework adoption nearly doubling year over year (AlphaCreek) .
The decision guide for the quant is summarized as follows: direct SDK for the trivial; short LCEL for repeatable analysis pipelines over many assets; structured output and langchain-core tool calling as the stable contract between providers; agents (Module 3) only where a genuine tool-decision loop exists. Over-engineering — putting an agent where a batch is enough — is the most expensive and most common architecture mistake.
Common pitfall: over-engineering — an agent where a batch is enough
The module says it plainly: putting an agent where a batch suffices is the most expensive and most common architecture mistake. The real pattern: a team must classify 500 headlines every morning —an identical, independent task per headline— and wraps it in an agent with a tool-calling loop "because it's the modern thing". Measurable consequences:
- Multiplied cost: each headline consumes several LLM rounds, and the growing history resends tokens on each one — the cost per document multiplies by three or more versus a single call per headline.
- Heavy-tailed latency: the per-item time distribution stops being ~constant and acquires a long tail of cases that "decide" to take an extra round.
- Per-item failure surface: every additional round is an opportunity for a malformed tool call or a failed parse, across 500 items a day.
- Harder evaluation: a
prompt | model | parserclassifier is measured with exact-match against a golden dataset; an agent additionally requires evaluating its intermediate decisions.
The decision rule fits in one line: if you can enumerate the inputs before running, it is batch/map; if the next action depends on the previous output, it is a loop; and only a loop with genuine decisions about tools justifies an agent (Module 3). The 2026 consensus the module cites —direct SDK for the trivial, short LCEL for repeatable pipelines— is not timidity toward the framework: it is cost engineering.
Exercises
-
Version verification. Write a script that queries the PyPI JSON API (
https://pypi.org/pypi/langchain/json) and checks that the installed versions oflangchain,langchain-core, and one partner package match the pins inrequirements.txt. It must fail with a non-zero exit code if there is version drift. -
Configurable model. Using
init_chat_modelandconfigurable_fields, build a headline classifier that routes to a lightweight or frontier model according to the value ofconfig["configurable"]. Measure the latency of ten invocations in each configuration withtime.perf_counterand tabulate the relative cost assuming prices of USD 0.40 and USD 5.00 per million input tokens (illustrative). -
Parallel LCEL pipeline. Extend the news pipeline from 2.2.1 with a third branch that extracts the mentioned tickers and a downstream
RunnableLambdathat normalizes the output dict to lowercase and adds anas_offield with the cutoff date. Run the result with.batchover a list of 20 synthetic headlines withmax_concurrency=4. -
Robustness. Wrap the pipeline from exercise 3 with
with_fallbackstoward a second provider andwith_retry(stop_after_attempt=3)on the parser. Simulate the primary's failure with aRunnableLambdathat raisesConnectionErrorand verify that the chain fails over to the backup without exception. -
Auditable classifier. Implement the
SentimientoNoticiaschema from 2.3.1 withinclude_raw=True, add a two-step verification in code —theevidenciastring must appear literally in the input headline— and persist each record to a JSONL withparsed,raw.usage_metadata, and a hash of the prompt. Classify 30 headlines and report the fraction that passes verification. -
Tool router. Implement the manual
bind_toolsloop from 2.3.2 with three tools (get_market_data,calculator, a simulatedxbrl_lookup) and a five-round limit. Demonstrate with a case that the model chains two tool calls (lookup → computation) and that no arithmetic appears in the model's free text.
Module 2 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.
Elite resources to go deeper
- LangChain v1 — official release notes: what exactly changed in v1, the semver commitment, and the destination of every API moved to
langchain-classic. Reference document before migrating any 0.3 code. - Chat models documentation (docs.langchain.com): the living reference for
init_chat_model,configurable_fields, rate limiters, and retry policy — everything in Section 2.1.2, from the primary source. - API reference for
langchain.chat_models: the exact signature of every parameter (max_retries,rate_limiter,config_prefix), indispensable when memory and 2024 tutorials diverge. - LCEL conceptual guide: the
Runnablecontract explained by its authors: composition with|, dict coercion toRunnableParallel, and the execution methods of Table 2.2. - Structured output in LangChain v1:
with_structured_outputstrategies by provider, strict mode, andinclude_raw, with the documented restrictions the module summarizes. - Anthropic — Building effective agents: the December 2024 guide behind the "simple, composable patterns" advice of Section 2.4; read it before justifying any framework in an institutional design.
- arXiv 2211.10435 — PAL: Program-aided Language Models: the paper behind "the LLM does not compute": 16 of 25 mathematical failures were arithmetic with correct reasoning, and delegating computation to code raised GSM-Hard from ~20% to 61.2%.
- PyPI JSON API: the endpoint for the module's exercise 1; verifying versions against the source is an engineering discipline, not a formality.
Check your understanding
Self-assessment with instant feedback. No scores are stored: it is just for you.