LangChain v1 esencial para quants
El modelo mental Runnable, LCEL, salida estructurada con Pydantic y tool calling de bajo nivel.
Objetivos de aprendizaje
- Describir la arquitectura de paquetes de LangChain v1 (
langchain,langchain-core, partner packages,langchain-classic) y verificar versiones contra PyPI como disciplina de ingeniería. - Inicializar modelos de chat de cualquier proveedor con
init_chat_model, incluyendo modelos configurables en runtime, reintentos y limitación de tasa. - Componer pipelines financieros con LCEL (
prompt | model | parser) usandoRunnablePassthrough,RunnableLambda,RunnableParallelyRunnableBranch. - Seleccionar el método de ejecución correcto (
invoke,batch,stream) según la tarea: análisis unitario, screening masivo de tickers o dashboards de research en vivo. - Diseñar salidas estructuradas con
with_structured_outputy Pydantic para clasificación de sentimiento auditable, y orquestar herramientas deterministas conbind_tools. - Evaluar críticamente cuándo LangChain añade valor y cuándo una llamada directa al SDK del proveedor es la decisión correcta.
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
| Paquete | Rol en v1 | Versión verificada (PyPI, jul-2026) | Uso típico en el stack quant |
|---|---|---|---|
langchain |
Namespace esencial: agentes, modelos, herramientas, embeddings | 1.3.14 | Punto de entrada: init_chat_model, create_agent (M3) |
langchain-core |
Abstracciones base: Runnable, mensajes, prompts, parsers, rate limiters |
1.5.2 | LCEL, with_structured_output, bind_tools, callbacks |
langchain-openai / langchain-anthropic |
Partner packages por proveedor | 1.4.1 / 1.5.3 | Acceso a GPT y Claude con paridad de interfaz |
langchain-classic |
Puente de compatibilidad con código legacy | 1.0.8 | Solo migración; prohibido en código nuevo del curso |
langchain-text-splitters |
Chunking de documentos | serie 1.x | Preparació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:
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) :
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) .
En palabras llanas: init_chat_model es una factoría, no un modelo
init_chat_model no contiene ningún modelo: es la centralita que conecta tu código con el proveedor que elijas, con el mismo conector para más de ochenta de ellos. La analogía útil es la mesa de operaciones con un teléfono universal: el analista marca siempre igual; lo que cambia es la línea al otro lado ("openai:gpt-5.5", "anthropic:claude-sonnet-4-6"). Por eso cambiar de proveedor es un cambio de configuración, no una reescritura del pipeline.
Tres consecuencias prácticas que el snippet del módulo deja entrever:
- La resiliencia viaja dentro del objeto. Reintentos con backoff (
max_retries=6) y presupuesto de llamadas (InMemoryRateLimiter) se configuran una vez y aplican a cadainvokeposterior, sin código extra en cada punto de uso. temperature=0no es un tic estético. En clasificación financiera buscas que el mismo titular produzca la misma etiqueta hoy y dentro de seis meses; la variabilidad es enemiga de la auditoría..textes propiedad en v1 (no método como en 0.3): al migrar código antiguo,respuesta.text()lanza error;respuesta.textdevuelve el string.
El patrón configurable en runtime merece una lectura de costes. init_chat_model(temperature=0, configurable_fields=("model", "model_provider")) convierte la elección del modelo en un dato de la llamada, no del despliegue: el screening masivo de titulares corre sobre gpt-5.4-mini y la redacción de la tesis sobre claude-sonnet-4-6, con el mismo código y el mismo pipeline. Es la versión en una línea de lo que en infraestructura sería un router de coste: cada tarea paga exactamente el modelo que necesita. Cuando una cadena alberga varios modelos configurables, config_prefix evita que sus claves colisionen en el dict configurable.
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:
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:
Trampa común: suponer que las ramas de un RunnableParallel se ven entre sí
El dict literal dentro de una cadena se coacciona a RunnableParallel: todas las ramas reciben el mismo input y se ejecutan concurrentemente; ninguna ve la salida de las demás. El error real de producción es escribir una rama que espera el resultado de otra —por ejemplo, una rama analisis_por_ticker que asume que la rama tickers ya extrajo los símbolos— y descubrir en runtime que recibe el titular original.
Si hay dependencia entre pasos, la composición es secuencial, no paralela:
# Dependencia real: primero extraer, después enriquecer el dict que fluye
pipeline = (
RunnablePassthrough()
| {"tickers": prompt_tickers | model | StrOutputParser()} # paralelo aquí
| RunnablePassthrough.assign( # y secuencial aquí
sentimiento=prompt_sentimiento | model | StrOutputParser()
)
)
Segunda variante de la misma trampa: meter efectos secundarios dentro de una rama (escribir en un dict global, anexar a una lista). Las ramas corren en threads concurrentes; el resultado es una carrera. Las ramas deben ser funciones puras: toda combinación de resultados ocurre después, sobre el dict devuelto.
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étodo | Semántica | Variante async | Uso en trading cuantitativo |
|---|---|---|---|
invoke(input, config) |
Una entrada → una salida | ainvoke |
Señal unitaria: clasificar el titular que acaba de llegar |
batch(inputs, config) |
Lista de entradas → lista de salidas; paralelo con thread pool por defecto | abatch |
Screening masivo: sentiment de 500 titulares pre-apertura |
batch_as_completed |
Como batch, devuelve resultados según terminan |
abatch_as_completed |
Pipelines con latencias heterogéneas por proveedor |
stream(input) |
Generador de chunks conforme se producen | astream |
Dashboards de research: tokens en vivo de un informe |
astream_events |
Stream de eventos intermedios de la cadena | nativo async | Trazabilidad 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 \(t\) segundos, el coste secuencial crece como \(T_{\text{seq}} = N \cdot t\), mientras que el lote con concurrencia \(c\) escala como \(T_{\text{batch}} \approx \lceil N/c \rceil \cdot t\): para \(N = 200\) titulares y \(c = 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:
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) .

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.
En la práctica institucional. El patrón batch-masivo es exactamente el que opera Kensho (S&P Global), que corre un framework de retrieval financiero sobre el stack de LangChain con evaluación multi-etapa basada en exact-match de routing y tool calling: cada noche se clasifican y enrutan miles de documentos, y la calidad se mide contra un golden dataset, no por impresión humana. La lección transferable es doble: el screening masivo se diseña como un problema de throughput (
batch,max_concurrency, rate limiter) y su salida solo entra en producción tras pasar un gate de evaluación con datos de referencia. Un clasificador de sentimiento sin golden dataset es una demo, no un sistema.
Ejemplo trabajado: cuando el rate limiter manda sobre la concurrencia
La fórmula del módulo, \(T_{\text{batch}} \approx \lceil N/c \rceil \cdot t\), asume que el proveedor acepta todo lo que le envías. En producción hay un segundo techo: el presupuesto de llamadas por segundo. Junta las dos restricciones con los números del propio módulo (secciones 2.1.2 y 2.2.2): latencia media \(t = 1{,}15\) s, max_concurrency \(c = 8\), InMemoryRateLimiter a 2 req/s, \(N = 200\) titulares.
- Throughput teórico por concurrencia: \(c/t = 8 / 1{,}15 \approx 6{,}96\) llamadas/s → \(T \approx \lceil 200/8 \rceil \times 1{,}15 = 25 \times 1{,}15 \approx 28{,}8\) s (el módulo lo cita como ~31 s contando el overhead de arranque).
- Throughput permitido por el limitador: 2 llamadas/s → \(T \geq 200 / 2 = 100\) s (la ráfaga inicial de 10 del bucket lo rebaja solo unos segundos).
- Resultado real: \(T \approx \max(28{,}8,\ 100) = 100\) s. El cuello de botella es el limitador, no la concurrencia.
La regla de dimensionado que se deriva: la concurrencia útil es \(c^* \approx \text{req/s} \times t = 2 \times 1{,}15 \approx 2{,}3\), es decir, \(c = 3\). Subir max_concurrency a 8 o 16 no acelera nada y solo aumenta la probabilidad de un HTTP 429 si el limitador falla. En research masivo, el limitador es la fuente de verdad y max_concurrency se fija a su servicio.
El cálculo completo, reproducible en cinco líneas:
import math
t, c, rps, N = 1.15, 8, 2.0, 200
t_secuencial = N * t # 230,0 s — invoke en bucle
t_batch = math.ceil(N / c) * t # 28,75 s — ideal, sin limitador
t_real = max(t_batch, N / rps) # 100,0 s — cota del limitador
c_util = math.ceil(rps * t) # 3 — concurrencia que sí aprovechas

Figura — Elaboración propia con datos sintéticos ilustrativos coherentes con la figura 2.1 del módulo (latencia 1,15 s por llamada, max_concurrency=8, límite 2 req/s), julio de 2026. La curva con limitador es la única que un proveedor real te dejará ejecutar.
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:
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.
El mapa de la resiliencia: tres mecanismos, tres enemigos distintos
Es fácil memorizar with_retry y with_fallbacks como sinónimos de «reintentar». No lo son: cada capa cubre un fallo de naturaleza distinta, y aplicar la capa equivocada empeora las cosas — reintentar un parseo determinista contra un proveedor caído, o conmutar de proveedor ante un error de tu propia lógica. El encadenamiento sano, de dentro hacia afuera:
- Retry interno del chat model (
max_retries=6): absorbe lo transitorio — HTTP 429, 5xx y errores de red — con backoff exponencial. Los errores de cliente (401, 404) pasan directamente: son tu configuración, no un fallo temporal. with_fallbacks([respaldo]): resuelve la contingencia mayor, la caída completa del proveedor, conmutando por ejemplo de OpenAI a Anthropic sin tocar el resto de la cadena.with_retry(stop_after_attempt=3)en el parser: se reserva para tu lógica de aplicación — un parseo o una validación que puede fallar aunque el modelo responda bien.
Detalle que cierra el círculo con la sección siguiente: la conmutación entre proveedores solo es segura si el contrato de salida es idéntico en ambos — un StrOutputParser que recibe texto libre de dos modelos distintos no garantiza paridad —, razón de peso para la salida estructurada de la sección 2.3.
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:
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) .
Nota de riesgo. La decodificación restringida garantiza la forma, no la verdad. Las evaluaciones de structured outputs reportan cumplimiento de schema cercano al 100%, frente a ~86% de function calling y menos de JSON mode (A.L. Capital Advisory) ; pero un campo bien tipado puede contener un valor inventado: cuando el dato no está en el documento, el modelo restringido no puede negarse y emite una cifra plausible — el fallo "confidently wrong" (metricgate.com) . La mitigación es de diseño de schema:
nullcomo valor legítimo de cada campo de datos, evidencia verbatim obligatoria y verificación de dos pasos (la cifra citada debe aparecer literalmente en la evidencia). Segunda advertencia, contraintuitiva y documentada: en clasificación simple de tres clases, el chain-of-thought no ayuda y puede dañar — en clasificación con excepciones aumentó las iteraciones necesarias hasta un 331% y estancó al modelo en la regla generalizable (Day Trading) . Sentiment de titulares: directo, temperatura cero, sin CoT. Tercera: nunca dejar que el LLM calcule. El análisis del paper PAL mostró que en 16 de 25 fallos matemáticos el razonamiento verbal era idéntico y solo cambiaban los números — el modo de fallo dominante es la aritmética, no el razonamiento; delegar el cómputo en código subió GSM-Hard de ~20% a 61,2% (Financial Wisdom TV) . Elscoredel schema lo propone el LLM; cualquier agregación de scores (media por sector, z-score, señal compuesta) la calcula Python.
En palabras llanas: el schema es aduana, y también es el prompt
Un schema Pydantic en with_structured_output funciona como el control de aduanas de un aeropuerto: inspecciona la forma de cada bulto —¿tiene etiqueta? ¿es uno de los tres valores permitidos? ¿el score está entre −1 y 1?— pero no abre la maleta para comprobar que el contenido sea verdadero. Por eso el módulo insiste en la nota de riesgo: la decodificación restringida garantiza estructura, no verdad; la verdad se defiende con null legítimo, evidencia verbatim y verificación en código.
El segundo punto es menos intuitivo: el nombre de la clase, su docstring y cada Field(description=...) se inyectan en el prompt que recibe el modelo. Escribir """Frase VERBATIM del titular que justifica la clasificación""" no es documentación para el próximo desarrollador: es una instrucción operativa que el modelo lee y sigue. Diseñar el schema es diseñar prompting, y merece la misma revisión.
Y include_raw=True es el sello de entrada que conserva la aduana: el AIMessage original con usage_metadata y response_metadata, para reconstruir meses después qué dijo exactamente el modelo, no solo qué validó Pydantic.
Dos cautelas de frontera que el módulo documenta y conviene tener presentes antes de endurecer el schema: el modo estricto (method="json_schema", strict=True) prohíbe en algunos proveedores metadata de validación y valores por defecto en los campos — diseña el schema pensando en esa restricción si vas a necesitarlo — y existe un bug abierto (issue #38223) por el que la ruta de streaming con esquemas Pydantic anidados construye un JSON schema inválido donde la ruta no-streaming funciona. Si tu pipeline streamea, pruébalo con tu schema real, no con uno de juguete.
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):
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.
Ejemplo trabajado: el bucle de tool calling, mensaje a mensaje
El snippet CAPM del módulo parece denso hasta que se despliega como lo que es: una conversación con turnos estrictos. Esta es la traza completa del caso resuelto (precio 184,32, beta 1,21; rf = 4 %, prima 5 %):
Tres verificaciones que conviene hacer al depurar tu propia traza:
- La aritmética nunca aparece en texto libre del LLM. El modelo eligió la herramienta y los argumentos (
"0.04 + 1.21 * 0.05"); el número 0,1005 lo produjo el AST de Python. Es la nota de riesgo de 2.3.1 hecha código: delegar el cómputo subió GSM-Hard de ~20 % a 61,2 % en el paper PAL. - Cada
ToolMessagecasa con sutool_call_id. Si un id queda sin respuesta o se duplica, la siguiente ronda falla o, peor, el modelo alucina el dato que no recibió. - El historial crece cada ronda — eso es el estado. El bucle
while respuesta.tool_callses exactamente lo quecreate_agentencapsulará en el Módulo 3 con persistencia y middleware; quien entiende esta traza entiende el agente.
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.
Trampa común: sobredimensionar — un agente donde basta un batch
El módulo lo dice sin rodeos: meter un agente donde basta un batch es el error de arquitectura más caro y más común. El patrón real: un equipo debe clasificar 500 titulares cada mañana —tarea idéntica e independiente por titular— y lo envuelve en un agente con bucle de tool calling «porque es lo moderno». Consecuencias medibles:
- Coste multiplicado: cada titular consume varias rondas de LLM, y el historial creciente reenvía tokens en cada una — el coste por documento se multiplica por tres o más frente a una sola llamada por titular.
- Latencia con cola pesada: la distribución de tiempos por ítem deja de ser ~constante y adquiere una cola larga de casos que «deciden» hacer una ronda extra.
- Superficie de fallo por ítem: cada ronda adicional es una oportunidad de tool call mal formado o parseo fallido, sobre 500 ítems diarios.
- Evaluación más difícil: un clasificador
prompt | model | parserse mide con exact-match contra un golden dataset; un agente exige evaluar además sus decisiones intermedias.
La regla de decisión es de una línea: si puedes enumerar las entradas antes de ejecutar, es batch/map; si la siguiente acción depende de la salida anterior, es un bucle; y solo un bucle con decisiones genuinas sobre herramientas justifica un agente (Módulo 3). El consenso de 2026 que cita el módulo —SDK directo para lo trivial, LCEL corto para pipelines repetibles— no es timidez ante el framework: es ingeniería de costes.
Ejercicios
-
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 delangchain,langchain-corey un partner package coinciden con los pins derequirements.txt. Debe fallar con exit code distinto de cero si hay deriva de versión. -
Modelo configurable. Construye con
init_chat_modelyconfigurable_fieldsun clasificador de titulares que enrute a un modelo ligero o frontera según el valor deconfig["configurable"]. Mide contime.perf_counterla 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). -
Pipeline LCEL paralelo. Extiende el pipeline de noticias de 2.2.1 con una tercera rama que extraiga los tickers mencionados y una
RunnableLambdaposterior que normalice el dict de salida a minúsculas y añada un campoas_ofcon la fecha de corte. Ejecuta el resultado con.batchsobre una lista de 20 titulares sintéticos ymax_concurrency=4. -
Robustez. Envuelve el pipeline del ejercicio 3 con
with_fallbackshacia un segundo proveedor ywith_retry(stop_after_attempt=3)en el parser. Simula el fallo del primario con unRunnableLambdaque lanceConnectionErrory verifica que la cadena conmuta al respaldo sin excepción. -
Clasificador auditable. Implementa el schema
SentimientoNoticiade 2.3.1 coninclude_raw=True, añade una verificación de dos pasos en código —la cadenaevidenciadebe aparecer literalmente en el titular de entrada— y persiste en un JSONL cada registro conparsed,raw.usage_metadatay un hash del prompt. Clasifica 30 titulares y reporta la fracción que supera la verificación. -
Router de herramientas. Implementa el bucle manual de
bind_toolsde 2.3.2 con tres herramientas (get_market_data,calculator,xbrl_lookupsimulada) 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.
Recursos de élite para seguir profundizando
- LangChain v1 — release notes oficiales: qué cambió exactamente en v1, el compromiso semver y el destino de cada API movida a
langchain-classic. Documento de referencia antes de migrar cualquier código 0.3. - Documentación de modelos de chat (docs.langchain.com): la referencia viva de
init_chat_model,configurable_fields, rate limiters y política de reintentos — todo lo de la sección 2.1.2, de la fuente primaria. - API reference de
langchain.chat_models: la signatura exacta de cada parámetro (max_retries,rate_limiter,config_prefix), imprescindible cuando la memoria y los tutoriales de 2024 divergen. - Guía conceptual de LCEL: el contrato
Runnableexplicado por sus autores: composición con|, coerción de dicts aRunnableParallely los métodos de ejecución de la tabla 2.2. - Structured output en LangChain v1: estrategias de
with_structured_outputpor proveedor, modo estricto einclude_raw, con las restricciones documentadas que el módulo resume. - Anthropic — Building effective agents: la guía de diciembre de 2024 detrás del consejo «simple, composable patterns» de la sección 2.4; léela antes de justificar cualquier framework en un diseño institucional.
- arXiv 2211.10435 — PAL: Program-aided Language Models: el paper que sustenta «el LLM no calcula»: 16 de 25 fallos matemáticos eran aritmética con razonamiento correcto, y delegar el cómputo en código subió GSM-Hard de ~20 % a 61,2 %.
- API JSON de PyPI: el endpoint del ejercicio 1 del módulo; verificar versiones contra la fuente es una disciplina de ingeniería, no una formalidad.
Comprueba lo aprendido
Autoevaluación con feedback inmediato. No se guarda ninguna puntuación: es solo para ti.