// FUNDAMENTOS · MÓDULO 02/12

LangChain v1 esencial para quants

El modelo mental Runnable, LCEL, salida estructurada con Pydantic y tool calling de bajo nivel.

Lectura ~30 minNivel intermedio-avanzadoEdición jul 2026Read in English

El Módulo 1 estableció la regla que gobierna todo el curso: el LLM razona, la matemática decide, el humano veta. Este módulo convierte esa regla en código. Los primitivos que se presentan aquí —modelos inicializados de forma uniforme, cadenas LCEL composables, salidas estructuradas validadas con Pydantic y tool calling de bajo nivel— son el vocabulario mínimo sobre el que el Módulo 3 construirá agentes completos. La idea que conviene retener desde ahora es simple: en LangChain v1 todo componente, desde un prompt hasta un agente, implementa la misma interfaz Runnable, de modo que un agente no es más que un Runnable adicional que se puede invocar, ejecutar en lote y streamear como cualquier otro.

2.1 Arquitectura de paquetes v1 y el modelo mental Runnable

2.1.1 v1.0 (22-oct-2025): namespace reducido, langchain-classic como puente, promesa semver

LangChain 1.0 se anunció el 22 de octubre de 2025 como el primer major version del framework, con el compromiso explícito de no introducir breaking changes hasta la 2.0 (MDPI). El artefacto langchain==1.0.0 se había publicado en PyPI unos días antes, el 17 de octubre de 2025 (questdb.com). La release está designada como LTS: permanece en estado ACTIVE hasta la publicación de 2.0 y entra después en mantenimiento por al menos un año, mientras la serie 0.3 queda en mantenimiento hasta diciembre de 2026 (Github). La cadencia oficial es de minors cada uno o dos meses y patches semanales (Github).

A la fecha de referencia de este curso, las versiones verificadas contra la API JSON de PyPI son: langchain 1.3.14 (16-jul-2026), langchain-core 1.5.2 (28-jul-2026), langchain-openai 1.4.1 (23-jul-2026), langchain-anthropic 1.5.3 (28-jul-2026) y langchain-classic 1.0.8 (10-jun-2026) (questdb.com). Todos los paquetes v1 requieren Python 3.10 o superior (Github).

El cambio estructural de v1 es la reducción del namespace de langchain a bloques esenciales: langchain.agents (con create_agent), langchain.messages, langchain.tools, langchain.chat_models (con init_chat_model) y langchain.embeddings, en su mayoría re-exports de langchain-core (Github). El código legado —LLMChain, retrievers clásicos, la API de indexing, el hub y los re-exports de community— se trasladó al paquete langchain-classic, que se instala aparte y mantiene compatibilidad hacia atrás (MDPI). La migración de imports es mecánica: from langchain.chains import LLMChain pasa a from langchain_classic.chains import LLMChain (Github). En este curso langchain-classic se usa únicamente como referencia histórica; ningún ejemplo nuevo lo requiere.

Tabla 2.1 — Paquetes del ecosistema LangChain v1: rol, versión verificada y uso típico en el stack quant

PaqueteRol en v1Versión verificada (PyPI, jul-2026)Uso típico en el stack quant
langchainNamespace esencial: agentes, modelos, herramientas, embeddings1.3.14Punto de entrada: init_chat_model, create_agent (M3)
langchain-coreAbstracciones base: Runnable, mensajes, prompts, parsers, rate limiters1.5.2LCEL, with_structured_output, bind_tools, callbacks
langchain-openai / langchain-anthropicPartner packages por proveedor1.4.1 / 1.5.3Acceso a GPT y Claude con paridad de interfaz
langchain-classicPuente de compatibilidad con código legacy1.0.8Solo migración; prohibido en código nuevo del curso
langchain-text-splittersChunking de documentosserie 1.xPreparación de filings y transcripts para RAG (M4)

La tabla anterior resume la anatomía del ecosistema, pero tres observaciones prácticas importan más que la lista en sí. Primera: la separación entre langchain y langchain-core no es cosmética — las abstracciones que este módulo usa a diario (Runnable, ChatPromptTemplate, RunnableLambda, InMemoryRateLimiter) viven en langchain-core, mientras que langchain aporta las fachadas de conveniencia. Un requirements.txt institucional debe fijar ambos con pins estrictos, porque la cadencia de patches semanales hace que dos entornos instalados con una semana de diferencia no sean idénticos. Segunda: los partner packages versionan de forma independiente del core, lo que permite actualizar la integración de un proveedor sin tocar el resto del pipeline — una propiedad valiosa cuando un proveedor cambia su API a mitad de un ciclo de validación de modelos. Tercera: la existencia de langchain-classic como paquete separado convierte la deprecación en una decisión explícita de instalación; si un pipeline de research heredado aún importa LLMChain, la dependencia es visible en el lockfile y auditable, en lugar de estar escondida en un namespace monolítico. Para un risk manager, esa trazabilidad de dependencias es parte del inventario de modelos exigido por las prácticas de model risk management heredadas de SR 11-7, cuyos principios persisten aunque el marco haya sido rescindido.

2.1.2 init_chat_model: interfaz unificada, configuración en runtime, resiliencia integrada

init_chat_model es la puerta de entrada a los modelos en v1: una factoría que devuelve un BaseChatModel con la misma interfaz para más de ochenta proveedores, con sintaxis "provider:model" y kwargs que se pasan inline (alphanova.tech). El siguiente snippet inicializa un modelo primario con reintentos y un limitador de tasa explícito — dos controles que en un entorno de research masivo no son opcionales:

python
from langchain.chat_models import init_chat_model
from langchain_core.rate_limiters import InMemoryRateLimiter

# Limitador thread-safe: máximo 2 requests/segundo, ráfaga de 10
rate_limiter = InMemoryRateLimiter(
    requests_per_second=2.0,      # presupuesto de llamadas al provider
    check_every_n_seconds=0.1,    # comprobación cada 100 ms
    max_bucket_size=10,           # tamaño máximo de ráfaga
)

model = init_chat_model(
    "openai:gpt-5.5",
    temperature=0,                # clasificación financiera: determinismo
    timeout=30,
    max_tokens=1_000,
    max_retries=6,                # defecto; reintenta 429/5xx/red con 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)             # en v1, .text es propiedad, no método

Los chat models reintentan automáticamente con backoff exponencial hasta seis veces ante errores de red, límites de tasa (HTTP 429) y errores de servidor (5xx); los errores de cliente como 401 o 404 no se reintentan (alphanova.tech). La propiedad .text y el tipo de retorno AIMessage son parte de los breaking changes oficiales de v1 respecto a la serie 0.3 (Github).

El segundo patrón es el modelo configurable en runtime, directamente relevante para el routing de coste: un modelo ligero para el screening masivo de titulares y un modelo frontera para la redacción de la tesis, intercambiables sin recompilar la cadena (alphanova.tech):

python
from langchain.chat_models import init_chat_model

# Sin `model`: queda configurable en runtime vía config
router_model = init_chat_model(temperature=0, configurable_fields=("model", "model_provider"))

# Screening masivo: modelo barato
router_model.invoke(
    "Clasifica el sentimiento de este titular: ACME eleva dividendo un 8%.",
    config={"configurable": {"model": "gpt-5.4-mini"}},
)

# Tesis de inversión: modelo frontera, misma llamada, mismo código
router_model.invoke(
    "Redacta la tesis de riesgo para ACME a 12 meses.",
    config={"configurable": {"model": "claude-sonnet-4-6"}},
)

Cuando una cadena contiene varios modelos, config_prefix evita colisiones de claves en el dict configurable (alphanova.tech). Finalmente, langchain-core 1.0 introdujo los standard content blocks: la propiedad .content_blocks de los mensajes expone con tipado uniforme texto, razonamiento, citas, tool calls (incluidos los server-side) y datos multimodales (imagen, audio, vídeo, PDF) con compatibilidad total hacia atrás (MDPI). Para el quant esto significa que un gráfico de velas adjunto como imagen, una transcripción de earnings call y el razonamiento del modelo se consumen con la misma interfaz independientemente del proveedor (PyQuant News).

2.2 LCEL: composición de pipelines financieros

2.2.1 El operador |: prompt | model | parser aplicado a un pipeline de análisis de noticias

LCEL (LangChain Expression Language) es el estilo de composición declarativa de langchain-core: los componentes se encadenan con el operador |, que construye una RunnableSequence donde la salida de cada eslabón es la entrada del siguiente (Source). La interfaz Runnable es el contrato universal del framework: cualquier componente —prompt, modelo, parser, función propia o grafo compilado— puede ser "invoked, batched, streamed, transformed and composed" (Source). Un dict literal dentro de una secuencia se coacciona automáticamente a RunnableParallel, que ejecuta las ramas concurrentemente sobre el mismo input y devuelve un dict de resultados (Source). La cadena compuesta hereda gratis ejecución síncrona, asíncrona, por lotes y streaming (Source).

El siguiente pipeline analiza una noticia corporativa y produce en paralelo dos vistas: una clasificación de sentimiento y una extracción de métricas mencionadas:

python
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}"),
])

# Rama paralela: mismo input, dos análisis concurrentes
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'}

Los cuatro primitivos de composición cubren el resto de necesidades estructurales. RunnablePassthrough transmite la entrada sin modificarla y, con .assign(), añade claves calculadas al dict que fluye por la cadena (NewYorkCityServers). RunnableLambda envuelve cualquier callable de Python como Runnable — la vía estándar para incrustar preprocesado determinista, como normalizar tickers o truncar texto (Source). RunnableParallel es el fan-out concurrente ya visto (Source). RunnableBranch implementa routing condicional como lista de pares condición→runnable más un default (Dividend Capture Pro), patrón útil para enrutar el análisis según el tipo de noticia (resultados, M&A, guidance) hacia prompts especializados. El siguiente diagrama resume el pipeline anterior:

Un titular entra por RunnablePassthrough, se reparte en dos ramas de prompt, modelo y parser que no se ven entre sí, y se une en un dict.Input: {titular} → RunnablePassthrough; RunnablePassthrough → RunnableParallel; RunnableParallel → prompt_sentimiento; prompt_sentimiento → model; model → StrOutputParser; RunnableParallel → prompt_metricas; prompt_metricas → model; model → StrOutputParser; StrOutputParser → dict: {sentimiento, metricas}; StrOutputParser → dict: {sentimiento, metricas}; dict: {sentimiento, metricas} → consume: invoke / batch / streamInput: {titular}RunnablePassthroughRunnableParallelprompt_sentimientomodelStrOutputParserprompt_metricasmodelStrOutputParserdict: {sentimiento, metricas}consume: invoke / batch / stream
Pipeline LCEL con RunnableParallel. RunnableParallel lanza dos ramas independientes, sentimiento y métricas, y fusiona sus salidas en un único dict que se consume con invoke, batch o stream.

2.2.2 invoke/ainvoke/batch/stream: streaming para dashboards, batch para screening masivo

La interfaz Runnable define una familia completa de métodos de ejecución; elegir el correcto es una decisión de ingeniería con impacto directo en latencia y coste (Source):

Tabla 2.2 — Métodos de ejecución de la interfaz Runnable: semántica, variante async y uso en trading cuantitativo

MétodoSemánticaVariante asyncUso en trading cuantitativo
invoke(input, config)Una entrada → una salidaainvokeSeñal unitaria: clasificar el titular que acaba de llegar
batch(inputs, config)Lista de entradas → lista de salidas; paralelo con thread pool por defectoabatchScreening masivo: sentiment de 500 titulares pre-apertura
batch_as_completedComo batch, devuelve resultados según terminanabatch_as_completedPipelines con latencias heterogéneas por proveedor
stream(input)Generador de chunks conforme se producenastreamDashboards de research: tokens en vivo de un informe
astream_eventsStream de eventos intermedios de la cadenanativo asyncTrazabilidad paso a paso en depuración y auditoría
map()Aplica el runnable a cada elemento de una lista—Fan-out declarativo sobre una cartera

La interpretación de esta tabla en contexto institucional pasa por tres puntos. Primero, batch no es un bucle for cosmético: por defecto ejecuta invoke() en paralelo sobre un thread pool executor, y admite max_concurrency vía config, de modo que el throughput de un screening de tickers queda acotado por el límite de tasa del proveedor y no por la latencia unitaria (Source). En términos formales, si cada llamada tarda tt segundos, el coste secuencial crece como Tseq=N⋅tT_{\text{seq}} = N \cdot t, mientras que el lote con concurrencia cc escala como Tbatch≈⌈N/c⌉⋅tT_{\text{batch}} \approx \lceil N/c \rceil \cdot t: para N=200N = 200 titulares y c=8c = 8, la mejora es cercana a un orden de magnitud. Segundo, los métodos con prefijo a son asíncronos y, por defecto, ejecutan su contraparte síncrona en el thread pool de asyncio; las integraciones de chat models los sobreescriben con async nativo, lo que importa cuando el pipeline convive con un event loop de market data (Source). Tercero, el streaming no es un lujo de demo: en v1 los modos updates, messages y custom permiten emitir tokens, progreso por paso y datos arbitrarios hacia un dashboard de research, y desde la versión 1.3 la documentación recomienda el event streaming tipado como API preferida (Github). Si un componente no implementa streaming, la interfaz degrada con elegancia a invoke (Source).

El siguiente snippet muestra ambos patrones sobre el mismo clasificador de titulares:

python
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) Screening masivo pre-apertura: batch paralelo sobre 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) Dashboard de research: streaming token a token de un informe largo
for chunk in clasificador.stream({"titular": "ACME eleva guidance y anuncia buyback."}):
    print(chunk, end="", flush=True)

El argumento config con tags y metadata no es decorativo: esas etiquetas viajan con cada ejecución y alimentan la trazabilidad en LangSmith, base de la observabilidad que el Módulo 10 desarrolla como defensa ante el vacío de gobernanza de la IA agéntica (Source).

Latencia total de un screening de sentimiento: invoke secuencial frente a batch paralelo (datos ilustrativos sintéticos)invoke secuencial (bucle for): 5 → 6 s, 10 → 12 s, 25 → 29 s, 50 → 57 s, 100 → 115 s, 200 → 230 s. Runnable.batch (paralelo, max_concurrency = 8): 5 → 1,40 s, 10 → 2,60 s, 25 → 5,10 s, 50 → 8,80 s, 100 → 16 s, 200 → 31 sinvoke secuencial (bucle for)Runnable.batch (paralelo, max_concurrency = 8)0 s50 s100 s150 s200 s250 s0255075100125150175200Número de tickers en el screeningTiempo total (segundos)57 s115 s230 s16,3 s31,2 s
Figura 2.1 — Fuente: elaboración propia con datos sintéticos ilustrativos (latencia media asumida de 1,15 s por llamada, max_concurrency=8), julio de 2026. La forma de las curvas es estructural; los valores absolutos dependen del proveedor y del modelo.

La figura anterior cuantifica el punto con datos ilustrativos sintéticos: con una latencia media de 1,15 s por llamada, clasificar 200 titulares en secuencia costaría unos 230 s, frente a unos 31 s con batch y max_concurrency=8. Los números exactos dependen del proveedor y del modelo, pero la forma de las curvas —lineal frente a escalonada— es estructural, no empírica.

2.2.3 Robustez: with_retry con backoff, with_fallbacks entre providers, callbacks

Todo Runnable expone .with_retry(...), que devuelve un nuevo runnable que reintenta ante excepciones con política configurable, y .with_fallbacks([...]), que encadena alternativas de respaldo ante fallos (Source). Como los chat models ya reintentan internamente hasta seis veces los errores 429/5xx/red, with_retry se reserva para fallos de lógica de aplicación —por ejemplo, un parseo que no valida— mientras with_fallbacks resuelve la contingencia mayor: la caída completa de un proveedor (alphanova.tech). El patrón canónico institucional es primario OpenAI → respaldo Anthropic:

python
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])          # conmutación ante outage
    | StrOutputParser().with_retry(stop_after_attempt=3)  # lógica de aplicación
)

# Callbacks por llamada para observabilidad puntual
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"]},
)

Tres detalles de producción completan el cuadro. Los fallbacks también están migrando a infraestructura: LangSmith ofrece un LLM Gateway (en beta privada a jul-2026) donde el orden de respaldo se define una vez y se aplica por código de error HTTP (429, 500, 502, 503, 504), sin tocar el código de cada pipeline (Quant Memo). Los callbacks de desarrollo (ConsoleCallbackHandler, set_debug(True)) son para depuración local; en producción la vía es el tracing centralizado (Source). Y la conmutación entre proveedores solo es segura si el contrato de salida es estable — razón de peso para la salida estructurada de la siguiente sección: un StrOutputParser que recibe texto libre de dos modelos distintos no ofrece ninguna garantía de paridad.

2.3 Salida estructurada y tool calling de bajo nivel

2.3.1 with_structured_output con Pydantic: el primer schema financiero, include_raw para auditoría

with_structured_output(schema) envuelve un chat model para que sus salidas casen con un esquema Pydantic, TypedDict o JSON schema, y devuelve instancias validadas en lugar de texto (wallstreetmojo.com). Internamente elige la estrategia según la capacidad del proveedor: si soporta tool calling o JSON schema nativo, el esquema viaja como definición de tool o response_format; si no, cae al patrón prompt+parser (Quant Memo). Un detalle de diseño con consecuencias: el nombre de la clase, el docstring y las descripciones de los campos se inyectan efectivamente en el prompt del modelo, así que diseñar el schema Pydantic es diseñar prompting (Quant Memo).

Este es el punto donde la regla del Módulo 1 se hace código. El clasificador de sentimiento institucional no devuelve una palabra suelta: devuelve un contrato tipado con score acotado y evidencia textual que un auditor puede verificar:

python
from typing import Literal, Optional
from pydantic import BaseModel, Field
from langchain.chat_models import init_chat_model

class SentimientoNoticia(BaseModel):
    """Clasificación de sentimiento de un titular financiero con evidencia auditable."""

    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: salida dict con 'raw', 'parsed' y '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"]          # AIMessage completo: trazabilidad total
print(analisis.etiqueta, analisis.score, analisis.evidencia)

Con include_raw=True la salida es siempre un dict con claves raw (el AIMessage original, con usage_metadata y response_metadata), parsed (la instancia validada) y parsing_error (la excepción capturada, sin lanzarla) (wallstreetmojo.com). Para una mesa sujeta a requisitos de auditoría, persistir raw junto a parsed es la diferencia entre poder y no poder reconstruir qué dijo exactamente el modelo seis meses después. El schema también puede forzarse a modo estricto en OpenAI con method="json_schema", strict=True (manishgoelstocks.com), con la restricción documentada de que en modo estricto los campos no pueden llevar metadata de validación ni valores por defecto en algunos providers (wallstreetmojo.com). Existe además un bug abierto conocido (issue #38223, junio 2026): con la Responses API, streaming y esquemas Pydantic anidados, la ruta de streaming construye un JSON schema inválido donde la ruta no-streaming funciona — un recordatorio de que los edge cases de salida estructurada siguen activos (ryanoconnellfinance.com).

2.3.2 bind_tools y el bucle de tool calling manual: el LLM como router de herramientas deterministas

bind_tools es el método de bajo nivel de BaseChatModel para adjuntar herramientas —funciones, clases Pydantic, dicts o BaseTool— y devuelve un Runnable cuya salida es un AIMessage con la lista tool_calls (arXiv.org). El contrato es la segunda mitad de la regla del curso: el LLM propone, el código dispone. El modelo nunca ejecuta la herramienta; solo emite dicts {'name', 'args', 'id', 'type': 'tool_call'} que la aplicación valida y ejecuta (alphanova.tech). En un sistema de trading esto no es un matiz técnico: es la frontera entre el componente no determinista (razonamiento) y los componentes deterministas y auditables (cálculo, datos, ejecución):

python
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:
    """Devuelve un dato de mercado point-in-time (precio, volumen, beta)
    para un ticker. Fuente determinista: nunca la inventa el 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:
    """Única vía autorizada para aritmética. El LLM no calcula en texto."""
    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()))

# Bucle manual de tool calling: el LLM enruta, el código ejecuta
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:                       # ¿el modelo propone herramientas?
    for call in respuesta.tool_calls:
        resultado = tools[call["name"]].invoke(call["args"])  # ejecuta el CÓDIGO
        historial.append(ToolMessage(content=str(resultado), tool_call_id=call["id"]))
    respuesta = model.invoke(historial)           # nueva ronda con resultados
    historial.append(respuesta)

print(respuesta.text)  # respuesta final: 4% + 1.21 * 5% = 10.05% (calculado por la tool)

Tres observaciones cierran la sección. Primera, tool_choice="any" fuerza al modelo a llamar a una herramienta — patrón que la propia documentación usa para clasificación estructurada con guardrails (Github). Segunda, en streaming los tool calls llegan como tool_call_chunk con JSON parcial que hay que agregar antes de ejecutar nada; ejecutar argumentos a medio parsear es un fallo de seguridad operativa (Github). Tercera, este bucle manual de treinta líneas es, en esencia, lo que create_agent encapsula con estado, persistencia y middleware; el Módulo 3 lo formalizará, pero conviene llegar sabiendo que un agente no añade magia sino orquestación sobre este mismo contrato — y que el agente resultante sigue siendo un Runnable componible.

2.4 Cuándo NO usar LangChain

2.4.1 Críticas documentadas, comparación con llamadas directas y el patrón híbrido dominante

La crítica canónica es el post de Octomind (junio 2024), que tras doce meses en producción abandonó LangChain porque sus abstracciones de alto nivel encarecían entender y mantener el código; alcanzó 480 puntos en Hacker News y provocó respuesta directa del CEO de LangChain (lilys.ai). Su ejemplo icónico: traducir un texto es una llamada directa a la API, pero en LangChain clásico exigía cuatro abstracciones (prompt template, chat model, output parser, chain) (arXiv.org). Anthropic, en su guía de ingeniería de diciembre de 2024, formula la versión positiva del mismo consejo: las implementaciones con más éxito se construyen con "simple, composable patterns" en lugar de frameworks complejos, empezando con llamadas directas y añadiendo capas solo cuando demuestran mejorar resultados — aunque la guía matiza que los frameworks sirven para empezar rápido (arXiv.org). Críticas posteriores añaden costes ocultos por llamadas implícitas al LLM y stack traces de cinco o más capas de abstracción (arXiv.org).

El consenso de 2026 es matizado y práctico: para una sola llamada o un pipeline lineal trivial, el SDK directo del proveedor más Pydantic es más legible y sin riesgo de versionado; LangChain se justifica cuando hay orquestación multi-modelo, intercambio de proveedores con una línea, agentes con estado o necesidad de observabilidad integrada (arXiv.org). La propia trayectoria del framework —del namespace monolítico al paquete adelgazado de v1, con el legado exiliado a langchain-classic— es una respuesta directa a esas críticas, reconocida en el anuncio oficial (MDPI). En retrieval documental, el patrón dominante combina frameworks: LlamaIndex como capa de retrieval envuelta como herramienta dentro de la orquestación de LangChain (arXiv.org). Y la adopción es real: los cinco frameworks principales concentran más del 93% de las descargas del segmento, con LangChain en torno a 233M descargas mensuales (QASkills.sh), y el 57,3% de las organizaciones encuestadas en el State of Agent Engineering 2026 declara agentes en producción — 67% en empresas de más de 10.000 empleados — con telemetría independiente de Datadog mostrando la adopción de frameworks de agentes casi duplicada interanualmente (AlphaCreek).

La guía de decisión para el quant se resume así: SDK directo para lo trivial; LCEL corto para pipelines de análisis repetibles sobre muchos activos; salida estructurada y tool calling de langchain-core como contrato estable entre proveedores; agentes (Módulo 3) solo donde exista un bucle genuino herramienta-decisión. Sobredimensionar — meter un agente donde basta un batch — es el error de arquitectura más caro y más común.

Ejercicios

  1. Verificación de versiones. Escribe un script que consulte la API JSON de PyPI (https://pypi.org/pypi/langchain/json) y compruebe que las versiones instaladas de langchain, langchain-core y un partner package coinciden con los pins de requirements.txt. Debe fallar con exit code distinto de cero si hay deriva de versión.

  2. Modelo configurable. Construye con init_chat_model y configurable_fields un clasificador de titulares que enrute a un modelo ligero o frontera según el valor de config["configurable"]. Mide con time.perf_counter la latencia de diez invocaciones en cada configuración y tabula el coste relativo asumiendo precios de 0,40 USD y 5,00 USD por millón de tokens de entrada (ilustrativos).

  3. Pipeline LCEL paralelo. Extiende el pipeline de noticias de 2.2.1 con una tercera rama que extraiga los tickers mencionados y una RunnableLambda posterior que normalice el dict de salida a minúsculas y añada un campo as_of con la fecha de corte. Ejecuta el resultado con .batch sobre una lista de 20 titulares sintéticos y max_concurrency=4.

  4. Robustez. Envuelve el pipeline del ejercicio 3 con with_fallbacks hacia un segundo proveedor y with_retry(stop_after_attempt=3) en el parser. Simula el fallo del primario con un RunnableLambda que lance ConnectionError y verifica que la cadena conmuta al respaldo sin excepción.

  5. Clasificador auditable. Implementa el schema SentimientoNoticia de 2.3.1 con include_raw=True, añade una verificación de dos pasos en código —la cadena evidencia debe aparecer literalmente en el titular de entrada— y persiste en un JSONL cada registro con parsed, raw.usage_metadata y un hash del prompt. Clasifica 30 titulares y reporta la fracción que supera la verificación.

  6. Router de herramientas. Implementa el bucle manual de bind_tools de 2.3.2 con tres herramientas (get_market_data, calculator, xbrl_lookup simulada) y un límite de cinco rondas. Demuestra con un caso que el modelo encadena dos tool calls (lookup → cálculo) y que ninguna aritmética aparece en el texto libre del modelo.


Módulo 2 de 15 — LangChain para Trading Cuantitativo. Datos y versiones verificados a julio de 2026; precios y cifras de mercado sujetos a cambio. LangChain 1.3.14 / langchain-core 1.5.2.


// check

Comprueba lo aprendido

Autoevaluación con respuesta inmediata. No se guarda ninguna puntuación.

  1. Un pipeline falla repetidamente con HTTP 401 al invocar el modelo inicializado con max_retries=6. ¿Qué ocurre según la política de reintentos de v1?

    Ver respuesta

    B. La sección 2.1.2 lo explicita: los chat models reintentan con backoff ante errores de red, límites de tasa (429) y errores de servidor (5xx); los errores de cliente como 401 o 404 no se reintentan. Un 401 indica credenciales inválidas: reintentar quemaría presupuesto sin arreglar nada, y la conmutación a respaldo exige with_fallbacks explícito.

  2. En la cadena RunnablePassthrough() | {"sentimiento": rama_a, "metricas": rama_b}, ¿qué entrada recibe cada rama?

    Ver respuesta

    B. Un dict literal dentro de una secuencia LCEL se coacciona a RunnableParallel: fan-out concurrente sobre el mismo input que devuelve un dict de resultados (sección 2.2.1). Las dependencias entre pasos se expresan con composición secuencial o RunnablePassthrough.assign(), nunca dentro del dict paralelo.

  3. Screening pre-apertura de 500 titulares, latencia media de 1,2 s por llamada y un InMemoryRateLimiter de 2 req/s. ¿Qué cota inferior de tiempo total es ineludible?

    Ver respuesta

    B. batch paraleliza con un thread pool, pero el throughput queda acotado por el presupuesto de llamadas del proveedor: a 2 req/s, 500 llamadas exigen al menos 250 s. La fórmula T_batch ≈ ⌈N/c⌉·t del módulo asume margen en ese presupuesto; la concurrencia útil es c* ≈ req/s × latencia ≈ 2,4.

  4. Según la sección 2.2.3, ¿qué asignación de mecanismos de robustez es la correcta?

    Ver respuesta

    B. Los chat models ya reintentan internamente los fallos transitorios (429/5xx/red), así que with_retry se reserva para la lógica de aplicación y with_fallbacks resuelve la contingencia mayor: el outage del proveedor. Y la conmutación solo es segura con un contrato de salida estable entre ambos, razón de peso para la salida estructurada.

  5. ¿Por qué include_raw=True es decisivo en un clasificador de sentimiento sujeto a auditoría?

    Ver respuesta

    B. Con include_raw=True la salida es un dict con raw (el AIMessage completo), parsed (la instancia validada) y parsing_error (2.3.1). Persistir raw junto a parsed es la diferencia entre poder y no poder reconstruir la respuesta exacta del modelo seis meses después ante un auditor.

  6. Al definir el schema SentimientoNoticia, ¿por qué importan tanto el docstring de la clase y los Field(description=...)?

    Ver respuesta

    B. La sección 2.3.1 lo advierte como detalle de diseño con consecuencias: el nombre de la clase, el docstring y las descripciones de los campos viajan al prompt que recibe el modelo. Escribir «Frase VERBATIM del titular» en la descripción de evidencia es una instrucción operativa, no un comentario.

  7. Un extractor con schema estricto rellena el campo ebitda con una cifra plausible que no aparece en el filing. ¿Qué mitigación propone la nota de riesgo del módulo?

    Ver respuesta

    C. La decodificación restringida garantiza la forma, no la verdad: un campo bien tipado puede contener un valor inventado (el fallo «confidently wrong»). La mitigación es de diseño de schema —null legítimo, evidencia verbatim obligatoria— más verificación de dos pasos en código (2.3.1). JSON mode, además, tiene peor cumplimiento documentado.

  8. Hay que clasificar el sentimiento de 300 titulares cada mañana; la tarea es idéntica e independiente para cada titular. ¿Qué arquitectura es la correcta según la sección 2.4?

    Ver respuesta

    B. Si las entradas se pueden enumerar antes de ejecutar, el patrón es batch/map: throughput gobernado por max_concurrency y el limitador. Sobredimensionar —meter un agente donde basta un batch— es, en palabras del módulo, el error de arquitectura más caro y más común; los agentes (Módulo 3) se reservan para bucles genuinos herramienta-decisión.