Prompt Caching de LLM #5: configuraciones de LangChain que realmente aciertan
Contenido
- Primero: ¿qué “caching” estás buscando?
- La solución: bloques de contenido, no cadenas
- Dónde colocas las variables de tu plantilla decide tu tasa de aciertos
- Las definiciones de herramientas también se cachean
- Multi-turno: mueve el marcador al último mensaje
- Lee los medidores y aprende cómo se llaman
- Cachés implícitas: un mal orden falla en silencio, así que vigílalo aún más
- Checklist
- Disclaimer
- Sources
Este es un system prompt de LangChain que parece del todo razonable y no cachea nada:
from langchain_core.prompts import ChatPromptTemplate
prompt = ChatPromptTemplate.from_messages([
("system", BIG_STABLE_SYSTEM_PROMPT), # the syntax every tutorial uses
("human", "{question}"),
])
Lo ejecutamos dos veces contra claude-sonnet-5 con un system prompt idéntico de 1.800 tokens y leímos los campos de usage. En ambas llamadas: cache writes 0, cache reads 0. Ni un acierto parcial, ni un cache fragmentado. Nada. El motivo: Anthropic solo cachea lo que marcas con cache_control, y un string plano dentro de una tupla ("system", ...) no tiene dónde poner el marcador. La sintaxis más cómoda de LangChain es también la que deja todo el descuento sin aprovechar, y ningún error te avisa.
TL;DR
- La tupla
("system", "string")de LangChain no puede transportarcache_control, así que Claude no almacena nada en caché: un prompt de sistema idéntico de 1,800 tokens registró 0 escrituras y 0 lecturas de caché enclaude-sonnet-5. - La solución es un
SystemMessageconcache_controlen el bloque de contenido; un solo marcador en el bloque de sistema también cubre las herramientas vinculadas conbind_tools. - Con
langchain-anthropic1.4.8,input_token_details.cache_creationpermanece en 0 incluso en escrituras reales; el recuento verdadero está enephemeral_5m_input_tokens. - Un prompt RAG mal ordenado (contexto variable antes de reglas estables) paga el sobrecosto de escritura de caché, alrededor de 1.25x, en cada llamada: más caro que no usar caché en absoluto.
Esta es la parte 5 de la serie sobre caching. La parte 1 explica cómo funciona el prefix caching, y la parte 3 es el tutorial con el SDK crudo. Esta parte trata de qué cambia cuando es LangChain quien arma tus prompts. Todo lo que sigue se midió el 2026-07-04 a través del gateway de Synthorai con langchain-core 1.4.8, langchain-anthropic 1.4.8 y langchain-openai 1.3.3.
Primero: ¿qué “caching” estás buscando?
Dos funciones sin relación comparten la palabra, y la página de la documentación de LangChain que aparece al buscar suele ser la equivocada.
Response caching (InMemoryCache de LangChain) | Prompt caching (esta serie) | |
|---|---|---|
| Qué almacena | La completion entera, en tu app | El estado KV del prefijo del prompt, en el proveedor |
| Cuándo ahorra dinero | Se repite exactamente la misma petición | Peticiones distintas comparten un prefijo |
| Dónde | set_llm_cache(InMemoryCache()), SQLite, Redis | Marcadores cache_control o matching automático de prefijos |
| Bucles de agente, RAG, chat | Casi inútil (cada petición es distinta) | La palanca principal, ya que system + tools se repiten en cada turno |
Y “exactamente la misma petición” significa exacta: los built-ins usan como clave el par (prompt serializado, string de config del modelo). Medido: una repetición idéntica devolvió resultado en 0 ms sin llamada a la API; añadir un solo espacio al prompt falló; el mismo prompt con max_tokens cambiado en uno también falló. (El replay cacheado además devuelve los números de usage de la llamada original, así que un conteo ingenuo de tokens cuenta doble.) Existen caches semánticos como integraciones de terceros; los built-ins solo hacen match exacto.
Así que set_llm_cache sirve para deduplicar llamadas idénticas en tests; el system prompt de 2.000 tokens que reenvías en cada turno del agente es trabajo del prompt caching, y necesita que el prompt se arme de la forma correcta.
La solución: bloques de contenido, no cadenas
cache_control viaja dentro de un bloque de contenido, así que el mensaje de sistema tiene que ser un SystemMessage con contenido en bloques, no una cadena a secas:
from langchain_anthropic import ChatAnthropic
from langchain_core.messages import SystemMessage
from langchain_core.prompts import ChatPromptTemplate
llm = ChatAnthropic(
model="claude-sonnet-5",
base_url="https://synthorai.io", # any Anthropic-compatible endpoint
)
prompt = ChatPromptTemplate.from_messages([
SystemMessage(content=[{
"type": "text",
"text": BIG_STABLE_SYSTEM_PROMPT,
"cache_control": {"type": "ephemeral"}, # a bare string has nowhere to put this
}]),
("human", "{question}"),
])
chain = prompt | llm
El mismo prompt de sistema de 1.800 tokens, medido a través del mismo gateway:
| Llamada | Sintaxis de string-tupla | Sintaxis de bloque de contenido |
|---|---|---|
| 1.ª (fría) | write 0 / read 0 | write 1,875 / read 0 |
| 2.ª, pregunta distinta | write 0 / read 0 | write 0 / read 1,875 |
Una lectura en caliente se factura a alrededor del 10% del precio de entrada, así que en Claude este único cambio estructural marca la diferencia entre pagar el precio completo para siempre y un 90% de descuento en la mitad estable de cada llamada. La parte económica está en la parte 1; la mecánica del marcador coincide con el uso del SDK en crudo que aparece en la documentación de la integración de LangChain con Anthropic y en la guía de prompt caching de Anthropic.
Dónde colocas las variables de tu plantilla decide tu tasa de aciertos
Las plantillas de LangChain hacen trivial interpolar variables en cualquier parte, y ahí está justamente el peligro. La clave de la caché es el prefijo byte a byte. Metimos una fecha dentro del bloque cacheado y medimos:
SystemMessage(content=[{
"type": "text",
"text": f"Today is {today}. " + BIG_STABLE_SYSTEM_PROMPT, # variable INSIDE the block
"cache_control": {"type": "ephemeral"},
}])
| Llamada | Resultado |
|---|---|
| día A, pregunta 1 | write 1,865 (frío para este valor) |
| día A, pregunta 2 | read 1,865 (mismo valor, acierto) |
| día B, pregunta 1 | write 1,865 (valor nuevo, frío otra vez) |
La caché no se rompió. Quedó indexada por la variable. Un valor que se repite, como una fecha, cuesta una escritura de caché por valor y a partir de ahí acierta. Un valor único por llamada, como un timestamp o un request ID, convierte cada llamada en una escritura en frío y deja la tasa de aciertos exactamente en cero.
La versión cara de este error en el mundo real es RAG. La plantilla a la que recurren muchas chains coloca el contexto recuperado al principio del prompt de sistema, antes de las instrucciones estáticas. Medimos ambos órdenes con un contexto recuperado de 800 tokens que cambia por consulta y un bloque de sistema marcado:
| Orden dentro del prompt | Llamada 1 | Llamada 2 (consulta nueva, contexto nuevo) |
|---|---|---|
| Contexto primero, luego reglas | write 3,133 | write 3,133 otra vez, read 0 |
| Reglas primero (marcadas), contexto en el turno humano | write 1,852 | read 1,852 |
La fila incorrecta no es solo “sin descuento”: cada llamada paga la prima de escritura de caché, unas 1,25× el precio normal de entrada, sobre los 3.133 tokens completos, y nunca se lee nada de vuelta. Un prompt de RAG mal ordenado con caching activado sale más caro que no cachear en absoluto. El contenido fijo queda detrás del variable, así que es como si no existiera.
La regla que se desprende de las mediciones:
- El texto estático primero, dentro del bloque marcado. Reglas de sistema, definiciones de tools, ejemplos few-shot.
- Todo lo que varíe va después del marcador, idealmente en el turno humano: contexto recuperado, fechas, preguntas del usuario.
- Una variable dentro del bloque solo es aceptable si se repite lo suficiente como para amortizar su propia escritura de caché.
Las definiciones de herramientas también se cachean
Un agente reenvía sus esquemas de herramientas en cada llamada, y en el layout de peticiones de Anthropic las tools van antes del system prompt. Como un marcador significa “cachear todo desde el inicio de la petición hasta aquí”, surgen dos preguntas prácticas. ¿Un marcador en el bloque de system cubre también las tools que están delante? ¿Y bind_tools de LangChain convierte tus tools exactamente en los mismos bytes en cada llamada? Porque si la serialización varía, el prefijo cambia y todas las llamadas fallan el cache.
Ambas preguntas tienen respuesta medida. Con el mismo system prompt marcado, la lectura de cache en caliente fue de 1.861 tokens sin tools y 2.389 tokens con dos tools bindeadas: los 528 tokens extra son los esquemas de las tools saliendo del cache. Y esos 2.389 se repitieron exactamente en tres llamadas consecutivas, lo que confirma que bind_tools serializa igual siempre; el framework no mete ruido en el prefijo. Para dejarlo claro: mientras el bloque de system lleve el marcador, las tools no necesitan su propio cache_control; ese único marcador detrás de ellas hace todo el trabajo.
Existe la disposición inversa para un caso muy concreto: las tools son lo más grande y estable que envías, y el system prompt es mínimo o inexistente. Ahí la petición sigue necesitando un marcador en algún sitio, y puede ir sobre una tool. Esto solo funciona con el dict en formato Anthropic crudo, porque una función decorada con @tool no tiene ningún campo donde ponerlo; bind_tools deja pasar el dict sin tocarlo:
# variant: NO marked system block anywhere; the tool carries the request's only marker
llm.bind_tools([{
"name": "get_weather",
"description": LONG_TOOL_DESCRIPTION,
"input_schema": {...},
"cache_control": {"type": "ephemeral"}, # passes through bind_tools verbatim
}])
Medido: 3.002 de escritura en frío, 3.002 de lectura en caliente, sin ningún mensaje de system marcado en la petición.
Multi-turno: mueve el marcador al último mensaje
Una conversación parece otro problema de ordenación, pero es justo lo contrario: el orden ya es perfecto, porque el historial solo se va añadiendo, así que toda la transcripción es un prefijo estable. Aquí el problema es la cobertura. Un marcador en el bloque de system cachea el system y nada más: a medida que crece el historial, la lectura en caliente se queda plana en el tamaño del system mientras cada turno acumulado se factura como input normal.
El patrón que lo resuelve es el mismo que usa el SDK crudo: poner el marcador en el último mensaje, para que el breakpoint se desplace hacia adelante y toda la conversación acumulada se convierta en el prefijo cacheado:
def marked(text):
return HumanMessage(content=[{
"type": "text", "text": text,
"cache_control": {"type": "ephemeral"},
}])
# each turn: history stays plain, only the newest human message carries the marker
llm.invoke([system, *history, marked(new_question)])
Medido en dos turnos: el turno 1 escribió 1.864; el turno 2 leyó 1.864 y solo escribió el delta de 15 tokens (la respuesta anterior más la nueva pregunta), facturando el prefijo previo a la tarifa de lectura de ≈10%. Esa es la forma que quiere un loop de agente, y LangChain la expresa con una lista de mensajes normal. Anthropic permite hasta cuatro marcadores por petición, así que el marcador deslizante se combina con un marcador fijo en el bloque de system o en las tools.
Lee los medidores y aprende cómo se llaman
LangChain estandariza el uso dentro de usage_metadata, y aquí está la trampa con la que chocamos: con langchain-anthropic 1.4.8, en cada respuesta de nuestras pruebas, el campo estándar input_token_details.cache_creation se quedó en 0 incluso cuando sí ocurría una escritura en la caché. El conteo real de la escritura aparece en una clave no estándar:
r = chain.invoke({"question": "..."})
det = r.usage_metadata["input_token_details"]
det["cache_read"] # correct on hits (1875 above)
det["cache_creation"] # 0 even on a cold write; do not alert on this
det["ephemeral_5m_input_tokens"] # the actual write count (1875)
El proveedor reportó la escritura correctamente (cache_creation_input_tokens: 1875 en la respuesta cruda, visible mediante r.response_metadata["usage"]); el mapeo estandarizado simplemente la archiva bajo la clave del bucket de TTL. Un dashboard de costos que vigile cache_creation te dirá que el caching es gratis mientras la prima por escritura se va acumulando en silencio. Confía en el objeto de uso crudo o aprende las claves de los buckets. Es el mismo tipo de problema que cuando los gateways reportan mal los campos de caché, algo que auditamos en ¿Miente tu gateway de LLM sobre la caché?.
Cachés implícitas: un mal orden falla en silencio, así que vigílalo aún más
La caché de Claude es explícita. GPT y la mayoría de los proveedores open-weight cachean automáticamente al coincidir el prefijo, sin marcadores, y en LangChain la misma cadena funciona tras un solo cambio en el constructor:
llm = ChatOpenAI(model="glm-5.2", base_url="https://synthorai.io/v1")
System prompt como string plano, sin marcadores: la segunda llamada de GLM 5.2 leyó 1.088 tokens del prefijo de unos 1.850 tokens. (No todo: las cachés automáticas coinciden por bloques gruesos en lugar de byte a byte hasta el final; OpenAI, por ejemplo, documenta una granularidad de 128 tokens.) Hasta aquí, dinero gratis. Pero el riesgo del mal orden que vimos en la tabla de RAG se aplica aquí con toda su fuerza, y con un modo de fallo más desagradable. Repetimos el mismo experimento de orden en la ruta automática, con contexto recuperado nuevo en cada llamada:
| Orden (sin marcadores, caché automática) | Llamada 1 | Llamada 2 (nueva consulta, nuevo contexto) |
|---|---|---|
| Contexto primero, luego reglas | leyó 0 | leyó 0 |
| Reglas primero, contexto en el turno humano | leyó 0 | leyó 1.088 |
El orden equivocado da un cero incondicional: el contexto cambiante queda al frente, dos llamadas nunca comparten prefijo y el descuento jamás llega. En la ruta explícita el mismo error al menos aparece en la factura como una prima por escritura de caché en cada llamada; en la ruta implícita no hay prima, ni error, ni señal alguna. El prompt simplemente nunca califica, en silencio, mientras tú das por hecho que “automático” significa “funciona”. Y como no hay ningún marcador que colocar, el orden del prompt es la única palanca que te da la ruta implícita.
Así que verifícalo desde los medidores, en producción y no una sola vez en una prueba: input_token_details.cache_read (LangChain) o prompt_tokens_details.cached_tokens (crudo). El caching automático de OpenAI documenta además un prefijo mínimo de 1.024 tokens, y el TTL y la elegibilidad varían por proveedor, terreno de la parte 2.
Checklist
- En Claude, una tupla de strings
("system", "...")no tiene dónde llevar elcache_control: no se cachea nada y no recibes ningún aviso. Los system prompts cacheables van en unSystemMessagecon content blocks y el marcador. - La clave de caché es el prefijo byte a byte: primero el contenido estático, las variables después del marcador o en el turno humano. El contexto RAG antes de las reglas no solo falla el cache; paga el sobrecosto de escritura en cada llamada.
- Una variable dentro del bloque cacheado crea una entrada de caché por cada valor: los valores que se repiten amortizan, los únicos por llamada (timestamps, request IDs) nunca hacen hit.
- Las tools van antes del system prompt en el prefijo, así que el marcador del system también cachea las tools vinculadas (
bind_toolsserializa de forma determinista). Si tu bloque estable más grande son las tools, el marcador puede ir directamente en un dict de tool con formato Anthropic. - En conversaciones, un marcador fijo en el bloque system deja el historial creciente a precio completo; ponlo en el último mensaje para que cada turno lea el prefijo anterior y escriba solo el delta.
- No monitorices
input_token_details.cache_creation: se queda en 0 incluso en las escrituras, así que un dashboard concluye que el caching es gratis mientras se acumulan los sobrecostos de escritura. El conteo real está enephemeral_5m_input_tokens, o lee elresponse_metadata["usage"]crudo. - En modelos de caché automática (GPT, GLM, DeepSeek), el orden del prompt es la única palanca y ordenarlo mal falla en silencio: sin sobrecosto, sin error, solo un descuento que nunca llega. Verifica los hits en los campos de usage.
set_llm_cacheguarda respuestas completas indexadas por el prompt exacto y la config del modelo; solo compensa cuando se repiten peticiones idénticas, nunca en un loop de agente.
Los hábitos son pequeños: un content block en vez de un string, lo estático antes de lo variable, un marcador que se desplaza con la conversación, un campo de usage leído correctamente. La diferencia medida fue un 90% de descuento en cada token estable frente a nada, y en el caso del RAG mal ordenado, frente a pagar de más. LangChain no estorba al prompt caching; simplemente hace que escribir la forma de prompt equivocada sea tan fácil como escribir la correcta.
Disclaimer
Medido el 2026-07-04 contra https://synthorai.io/ con langchain-core 1.4.8, langchain-anthropic 1.4.8, langchain-openai 1.3.3, los modelos claude-sonnet-5 y glm-5.2, un prefijo de system en inglés de unos 1.800 tokens, muestras pequeñas y una separación de 1–2 segundos entre llamadas consecutivas para que las escrituras de caché tengan tiempo de asentarse. Cada experimento usó un prefijo aleatorizado nuevo para garantizar una caché fría, y por eso los conteos de tokens base varían un poco entre tablas (de 1.852 a 1.875). El mapeo de campos de las librerías y el comportamiento de la caché de los proveedores cambian entre versiones; vuelve a medir contra tu propio stack antes de depender de estos números.