Probar agentes de LangGraph sin modelo: tests de grafo con un modelo guionizado, pytest y el checkpointer en memoria
Índice
TL;DR
Un agente de LangGraph es un grafo con nodos deterministas y uno que no lo es. Todo lo que no es el modelo se prueba sin modelo: la estructura, las herramientas, el contexto, los límites, la persistencia, las interrupciones y el abanico. Es la mayor parte del código y la parte que rompe en silencio.
El modelo se sustituye por FakeMessagesListChatModel de langchain-core, que devuelve una lista de mensajes en orden. Le falta bind_tools, y create_agent lo llama; un subtipo con bind_tools que devuelva self y guarde las herramientas que vio lo arregla. Ese subtipo es un BaseModel de pydantic, y guardar algo en un atributo no declarado lanza ValueError: el atributo se declara en la clase.
Los tests cuentan checkpoints. InMemorySaver.list(config) devuelve uno por superstep, y el número es una propiedad del grafo que cambia cuando se añade middleware. Once por turno con una herramienta en el caso 1, trece con el resumen; si un cambio lo mueve, el test lo dice antes que la base de datos.
Escribir los tests sacó tres cosas que no estaban en los artículos. Cuando ToolCallLimitMiddleware corta con exit_behavior="end", el último mensaje es un AIMessage sintético en inglés, Tool call limit reached: run limit exceeded (7/6 calls), y va al usuario si la aplicación no lo intercepta. El corte se detecta en stream_mode="updates" porque la actualización del nodo del límite lleva jump_to: "end". Y los contadores por ejecución no quedan en el estado del hilo; solo los del hilo.
Las interrupciones se prueban de principio a fin: se invoca, se comprueba __interrupt__, se reanuda con Command(resume=...) y se comprueba que la herramienta corrió una vez con los argumentos de la decisión. El fallo a medias también: una herramienta que lanza en la primera llamada y el estado que queda antes de reanudar.
Lo que estos tests no prueban es si el modelo elige bien la herramienta o redacta bien. Eso es evaluación, no prueba, y es el siguiente artículo.
Estás aquí: después de construir, antes de medir
La serie de agentes con LangGraph para retail construyó cuatro agentes y los sirvió. Los artículos verificaron cada afirmación ejecutando código contra un modelo falso, y ese código era una sesión de terminal. Este artículo lo convierte en una suite de pytest que corre en cada commit, sin GPU y en menos de un segundo.
La distinción que ordena todo lo que sigue: un test comprueba que el grafo hace lo que el código dice; una evaluación comprueba que el modelo hace lo que el negocio quiere. Lo primero es determinista y se ejecuta en CI. Lo segundo es estadístico, cuesta tokens y va en el artículo de evaluación.
La analogía: el simulacro de caja
Para formar a un dependiente nuevo no hace falta un cliente. Se le pone un compañero que hace de cliente con un guion: pide esto, luego pregunta aquello, luego se enfada. Lo que se comprueba no es si el compañero actúa bien; es si el dependiente sigue el procedimiento, si sabe qué botón del TPV tocar y cuándo llamar al encargado.
El modelo falso es el compañero con guion. Los tests comprueban el procedimiento: las herramientas, el orden, el rol, la firma del encargado. El modelo real se evalúa después, con clientes reales grabados.
El modelo guionizado
from langchain_core.language_models.fake_chat_models import FakeMessagesListChatModel
from langchain_core.messages import AIMessage
class ModeloGuion(FakeMessagesListChatModel):
"""Devuelve los mensajes de `responses` en orden y acepta herramientas."""
tools_vistas: list[str] = []
def bind_tools(self, tools, **kwargs):
self.tools_vistas = [
t.get("function", t).get("name") if isinstance(t, dict) else t.name
for t in tools
]
return self
def llamada(nombre: str, **args) -> AIMessage:
return AIMessage(content="", tool_calls=[{"name": nombre, "args": args, "id": f"c-{nombre}"}])
Tres cosas de estas doce líneas.
FakeMessagesListChatModel viene en langchain-core y devuelve responses[i] en la llamada i. Con un AIMessage que lleva tool_calls, el nodo model del agente lo trata como una petición de herramienta y el grafo va a tools. Con uno que lleva solo content, el grafo termina. Un guion es una lista de esos mensajes en el orden en que el modelo “decidiría”.
bind_tools no existe en el falso y create_agent lo llama en cada vuelta del modelo para pasarle las herramientas. Sin él, NotImplementedError en la primera invocación. El subtipo devuelve self y de paso guarda los nombres de las herramientas, que es un dato que los tests quieren: qué vio el modelo.
tools_vistas está declarado como campo de clase porque el modelo falso es un modelo de pydantic. Asignar self.tools_vistas = ... sin declararlo lanza ValueError: "ModeloGuion" object has no field "tools_vistas". Fue el primer fallo de la suite.
Para el resumen y para profile, el falso admite profile={"max_input_tokens": 32768} en el constructor como cualquier BaseChatModel; se pasa cuando el agente lleva SummarizationMiddleware con disparador por fracción, o el constructor del middleware lanza.
Lo que se prueba del caso 1
El agente del asistente de sala se construye con una función build(modelo, checkpointer), y las herramientas leen de un diccionario de stock en el módulo de pruebas en vez de Postgres. Lo que sigue es la suite entera; corre en medio segundo.
La estructura
def test_estructura_del_grafo():
agente = build(ModeloGuion(responses=[AIMessage(content="x")]))
nodos = set(agente.get_graph().nodes)
assert nodos >= {"__start__", "model", "tools", "__end__"}
assert "ToolCallLimitMiddleware.after_model" in nodos
assert "ModelCallLimitMiddleware.before_model" in nodos
get_graph() no ejecuta nada. Devuelve el grafo compilado con los nodos que el middleware añadió. Un test así falla cuando alguien quita un límite “para probar” y lo deja quitado, y cuando una subida de langchain cambia el nombre de un nodo, que es algo que la aplicación del artículo 5 usa para filtrar el flujo.
get_graph().draw_mermaid() devuelve el diagrama en texto. Guardado como fichero de referencia y comparado en un test, es un test de instantánea del grafo: cualquier cambio de topología aparece en el diff del commit.
Las herramientas y el contexto
@pytest.fixture
def dependiente():
return Contexto(tienda="T001", empleado="E017", rol="dependiente")
def test_stock_usa_la_tienda_del_contexto(dependiente):
modelo = ModeloGuion(responses=[llamada("stock_sku", sku="SKU-0912"), AIMessage(content="No queda.")])
agente = build(modelo, InMemorySaver())
out = agente.invoke({"messages": [HumanMessage("¿queda?")]}, context=dependiente,
config={"configurable": {"thread_id": "t"}})
tool_msgs = [m for m in out["messages"] if isinstance(m, ToolMessage)]
assert json.loads(tool_msgs[0].content) == {
"sku": "SKU-0912", "tienda": "T001", "unidades": 0, "cercanas": {"T002": 14},
}
assert modelo.tools_vistas == ["stock_sku", "procedimiento", "registrar_rotura"]
El guion pide stock_sku sin decir tienda, y la herramienta responde con T001 porque lo leyó del contexto. Si alguien cambia la herramienta para que la tienda venga como argumento del modelo, el test falla porque el guion no lo pasa. Es la garantía de que el contexto no se puede suplantar desde el prompt.
La última aserción fija la lista de herramientas que ve el modelo, en orden. Un test de contrato: si se añade una herramienta, el test la reclama.
def test_dependiente_no_registra_rotura(dependiente):
modelo = ModeloGuion(responses=[
llamada("registrar_rotura", sku="SKU-4471", unidades=6, motivo="caída"),
AIMessage(content="Avisa al encargado."),
])
out = build(modelo, InMemorySaver()).invoke(
{"messages": [HumanMessage("registra")]}, context=dependiente,
config={"configurable": {"thread_id": "t"}})
tool_msg = [m for m in out["messages"] if isinstance(m, ToolMessage)][0]
assert json.loads(tool_msg.content)["error"] == "permiso_denegado"
def test_encargado_si_registra():
encargado = Contexto(tienda="T001", empleado="E001", rol="encargado")
modelo = ModeloGuion(responses=[
llamada("registrar_rotura", sku="SKU-4471", unidades=6, motivo="caída"),
AIMessage(content="Hecho."),
])
out = build(modelo, InMemorySaver()).invoke(
{"messages": [HumanMessage("registra")]}, context=encargado,
config={"configurable": {"thread_id": "t"}})
assert json.loads([m for m in out["messages"] if isinstance(m, ToolMessage)][0].content)["ok"] is True
Son los dos tests que importan de todo el artículo. El guion hace que el modelo pida registrar la rotura en los dos casos; lo que cambia es el contexto. Si la comprobación de rol se moviera al prompt, los dos tests pasarían con el guion y el segundo fallaría con un modelo real que obedeciera al prompt, pero el primero pasaría también con un modelo real que no lo obedeciera. La comprobación tiene que estar donde el guion no puede saltarla.
Los límites
def test_limite_de_llamadas_corta_sin_excepcion(dependiente):
modelo = ModeloGuion(responses=[llamada("stock_sku", sku=f"SKU-{i}") for i in range(20)])
out = build(modelo, InMemorySaver()).invoke(
{"messages": [HumanMessage("bucle")]}, context=dependiente,
config={"configurable": {"thread_id": "t"}})
tool_msgs = [m for m in out["messages"] if isinstance(m, ToolMessage)]
ejecutadas = [m for m in tool_msgs if m.status != "error"]
assert len(ejecutadas) == 6
assert "limit" in tool_msgs[-1].content.lower()
assert isinstance(out["messages"][-1], AIMessage)
Un guion de veinte llamadas seguidas es un modelo en bucle. Con run_limit=6, la primera versión del test comprobaba que había como máximo seis ToolMessage, y falló: hay siete. El middleware, al bloquear la séptima, añade un ToolMessage sintético con status="error" y el texto Tool call limit exceeded. Do not make additional tool calls. para esa llamada, y después un AIMessage sintético: Tool call limit reached: run limit exceeded (7/6 calls). El test correcto cuenta las ejecutadas, que son las que no tienen status="error".
Ese AIMessage final es el que la aplicación del artículo 5 envía al usuario como respuesta del agente, y está en inglés y lo escribe el middleware. ModelCallLimitMiddleware hace lo mismo: Model call limits exceeded: run limit (3/3). La aplicación tiene que detectar el corte y sustituir el texto. La forma fiable no es buscar ese texto, es mirar el flujo:
def test_el_corte_se_ve_en_updates(dependiente):
modelo = ModeloGuion(responses=[llamada("stock_sku", sku=f"SKU-{i}") for i in range(20)])
agente = build(modelo, InMemorySaver())
cortes = []
for update in agente.stream({"messages": [HumanMessage("bucle")]}, context=dependiente,
config={"configurable": {"thread_id": "t"}}, stream_mode="updates"):
for nodo, cambio in update.items():
if cambio and cambio.get("jump_to") == "end":
cortes.append(nodo)
assert cortes == ["ToolCallLimitMiddleware.after_model"]
La actualización del nodo del límite lleva jump_to: "end", run_tool_call_count: {"__all__": 7} y los dos mensajes sintéticos. Verificado en langgraph 1.2.11 y langchain 1.4.1. Es el mismo bucle de updates que el servicio ya recorre para detectar interrupciones, y añade una rama para los cortes.
Un detalle más que salió del test: tras el corte, get_state(config).values tiene thread_tool_call_count y thread_model_call_count, pero no los contadores por ejecución. run_tool_call_count vive en la actualización y no se persiste. Un test que quiera comprobar el contador por ejecución lo lee del flujo, no del estado.
Los checkpoints
def test_checkpoints_por_turno(dependiente):
cp = InMemorySaver()
modelo = ModeloGuion(responses=[llamada("stock_sku", sku="SKU-0912"), AIMessage(content="ok")])
cfg = {"configurable": {"thread_id": "t"}}
build(modelo, cp).invoke({"messages": [HumanMessage("q")]}, context=dependiente, config=cfg)
assert sum(1 for _ in cp.list(cfg)) == 11
Un número fijo que documenta el coste del middleware. Cuando alguien añada SummarizationMiddleware, el test dirá 13 y el commit tendrá que cambiar el número y, con él, la estimación de filas por día del artículo 5. Es un test que existe para obligar a esa conversación.
Lo que se prueba del caso 2
La interrupción de aprobación tiene un contrato de datos y una secuencia, y las dos cosas se prueban sin modelo.
efectos: list[tuple] = []
@tool
def reembolsar(pedido: str, importe: float) -> dict:
"""Emite un reembolso."""
efectos.append((pedido, importe))
return {"ok": True, "reembolso": "R-1"}
def test_reembolso_grande_para_y_no_ejecuta():
efectos.clear()
modelo = ModeloGuion(responses=[
llamada("reembolsar", pedido="P1", importe=120.0),
AIMessage(content="Reembolso emitido."),
])
agente = build_cliente(modelo, InMemorySaver())
cfg = {"configurable": {"thread_id": "h1"}}
r = agente.invoke({"messages": [HumanMessage("devuelve P1")]}, context=cliente, config=cfg)
assert efectos == []
intr = r["__interrupt__"][0].value
assert intr["action_requests"][0]["name"] == "reembolsar"
assert intr["review_configs"][0]["allowed_decisions"] == ["approve", "edit", "reject"]
r2 = agente.invoke(Command(resume={"decisions": [
{"type": "edit", "edited_action": {"name": "reembolsar", "args": {"pedido": "P1", "importe": 90.0}}},
]}), context=cliente, config=cfg)
assert efectos == [("P1", 90.0)]
assert r2["messages"][-1].content == "Reembolso emitido."
def test_reembolso_pequeno_no_para():
efectos.clear()
modelo = ModeloGuion(responses=[llamada("reembolsar", pedido="P2", importe=20.0), AIMessage(content="Hecho.")])
r = build_cliente(modelo, InMemorySaver()).invoke(
{"messages": [HumanMessage("devuelve P2")]}, context=cliente,
config={"configurable": {"thread_id": "h2"}})
assert "__interrupt__" not in r
assert efectos == [("P2", 20.0)]
Los dos tests fijan la política (when por importe), el contrato de la interrupción (action_requests y review_configs), la reanudación con edit, y la propiedad central: la herramienta no corre antes de la decisión y corre una vez después, con los argumentos editados. La lista efectos es el instrumento; en producción es la tabla reembolsos.
El tercer test del caso 2 es el de la re-ejecución, con un nodo propio que registra efectos antes y después de interrupt y comprueba la secuencia antes, antes, después. Existe para que nadie ponga una escritura delante de una interrupción sin que un test lo diga.
Lo que se prueba del caso 3
Del abanico se prueban cuatro propiedades, y ninguna necesita el modelo: el trabajador se sustituye por una función que devuelve un análisis fijo.
def test_consolidar_espera_a_todos():
pasos = [list(u.keys()) for u in grafo.stream(estado(skus=5), config=cfg, stream_mode="updates")]
assert pasos.count(["consolidar"]) == 1
assert pasos.index(["consolidar"]) == len(pasos) - 1
def test_orden_de_analisis_es_el_del_reparto():
r = grafo.invoke(estado(skus=200), config=cfg)
assert [a["sku"] for a in r["analisis"]] == [f"SKU-{i:05d}" for i in range(200)]
def test_fallo_de_un_lote_conserva_los_demas(monkeypatch):
fallos = {"n": 0}
def analizar_con_fallo(lote):
if lote["referencias"][0]["sku"] == "SKU-00050" and fallos["n"] == 0:
fallos["n"] += 1
raise ConnectionError("gateway")
return analizar_lote(lote)
g = construir_grafo(analizar=analizar_con_fallo, retry_policy=None)
with pytest.raises(ConnectionError):
g.invoke(estado(skus=200), config=cfg)
st = g.get_state(cfg)
assert len(st.values["analisis"]) == 150
assert st.next == ("analizar_lote",)
r = g.invoke(None, config=cfg)
assert len(r["analisis"]) == 200
def test_cache_evita_la_segunda_pasada():
llamadas = []
g = construir_grafo(analizar=lambda lote: (llamadas.append(1), analizar_lote(lote))[1], cache=InMemoryCache())
g.invoke(estado(skus=200), config={"configurable": {"thread_id": "a"}})
g.invoke(estado(skus=200), config={"configurable": {"thread_id": "b"}})
assert len(llamadas) == 4
El primero es el test de defer=True: si alguien lo quita, consolidar aparece antes del último lote. El segundo fija el orden determinista del reductor. El tercero es el más valioso: con un lote que falla y sin retry_policy, el estado conserva los 150 análisis de los tres lotes que terminaron, next apunta al trabajador, y invoke(None) completa solo el que faltaba. El cuarto cuenta llamadas reales con y sin caché: cuatro lotes de 50 en la primera pasada y cero en la segunda.
Lo que no se prueba aquí es la concurrencia. Se puede medir el pico de tareas en vuelo con un contador y un candado, como se hizo para el artículo, pero depende del número de CPU de la máquina de CI, y un test que pasa en el portátil y falla en el runner no es un test. La concurrencia se fija con max_concurrency y se mide en el despliegue.
Lo que se prueba del caso 4
El bucle de fichas tiene dos agentes y un contador. Con el modelo guionizado los dos agentes se controlan por separado:
def test_tres_rechazos_van_a_revision_manual():
generador = agente_generador(ModeloGuion(responses=[llamada("Ficha", titulo="Cafetera 1L", descripcion=f"v{i}", atributos={}) for i in range(3)]))
evaluador = agente_evaluador(ModeloGuion(responses=[llamada("Veredicto", aprobada=False, problemas=[{"criterio": "C04", "detalle": "x"}]) for _ in range(3)]))
g = construir_grafo(generador, evaluador)
r = g.invoke(estado_inicial("SKU-1"), config=cfg)
assert r["intentos"] == 3
assert [h["veredicto"]["aprobada"] for h in r["historial"]] == [False, False, False]
assert ultimo_nodo(g, cfg) == "descartar"
def test_titulo_largo_se_rechaza_aunque_el_modelo_apruebe():
generador = agente_generador(ModeloGuion(responses=[llamada("Ficha", titulo="x" * 90, descripcion="d", atributos={})]))
evaluador = agente_evaluador(ModeloGuion(responses=[llamada("Veredicto", aprobada=True, problemas=[])]))
with pytest.raises(StructuredOutputValidationError):
construir_grafo(generador, evaluador).invoke(estado_inicial("SKU-1"), config=cfg)
Con ToolStrategy en los tests, la salida estructurada es una llamada a herramienta con el nombre del esquema, y el guion la produce con llamada("Ficha", ...). En producción los agentes llevan ProviderStrategy; en los tests se usa ToolStrategy(esquema, handle_errors=False) a propósito, porque el modelo falso no genera JSON con esquema y porque con handle_errors=True, el valor por defecto, un fallo de validación se convierte en un ToolMessage de error y el agente vuelve a llamar al modelo, que en un guion es el siguiente mensaje de la lista y no un reintento. Con handle_errors=False la excepción es StructuredOutputValidationError, la misma que lanza ProviderStrategy en producción; verificado con un título de 90 caracteres y max_length=10. Es una de las pocas diferencias entre el agente probado y el desplegado, y se documenta en la fixture.
El segundo test fija que la validación de pydantic manda por encima del evaluador: un título de 90 caracteres no llega a evaluarse.
Dónde corren y qué no cubren
La suite de los cuatro casos corre con InMemorySaver e InMemoryCache, sin Postgres, sin vLLM y sin red; los seis tests del caso 1 de arriba tardan medio segundo. Va en el pre-commit y en CI.
Un segundo grupo, marcado con @pytest.mark.postgres, repite los tests de checkpoint y de retención contra un Postgres real levantado en un contenedor: setup() en una base limpia, el número de filas en checkpoints y checkpoint_writes tras un turno, delete_thread y la consulta de retención por checkpoint->>'ts'. Corre en CI y no en el pre-commit. Es el grupo que detecta una subida de langgraph-checkpoint-postgres que añade una migración o cambia una tabla.
Lo que ninguno de los dos grupos cubre:
- Si el modelo elige
stock_skucuando el dependiente pregunta por stock. El guion lo decide. - Si la respuesta al cliente es correcta, está en castellano y no promete plazos.
- Si el evaluador de fichas aprueba lo que catálogo aprobaría.
- Si el planificador de compras ordena bien los proveedores.
- La latencia, los tokens y el coste.
Todo eso depende del modelo y se mide con datos, no se afirma con aserciones. Es el siguiente artículo.
Checklist
- Hay un
ModeloGuionconbind_toolsy un campo declarado para las herramientas vistas. - Cada agente se construye con una función
build(modelo, checkpointer)que los tests pueden llamar. - Las herramientas leen de un origen sustituible (diccionario en tests, Postgres en producción).
- Hay un test de estructura por agente y una instantánea Mermaid del grafo.
- Cada herramienta con rol tiene dos tests: uno que pasa por el rol y otro que no.
- Los tests de límite cuentan ejecuciones, no
ToolMessage, y compruebanjump_toenupdates. - Hay un test de checkpoints por turno con el número exacto.
- Las interrupciones se prueban con
invoke,__interrupt__,Command(resume)y una lista de efectos. - El abanico tiene tests de
defer, de orden, de fallo parcial y de caché. - Los agentes de salida estructurada se prueban con
ToolStrategyy la diferencia está documentada. - Los tests que necesitan Postgres van en un grupo aparte que corre en CI.
Trampas y cosas que no son lo que parecen
El modelo falso no tiene bind_tools. NotImplementedError en la primera invocación de create_agent. Un subtipo de tres líneas.
El subtipo es pydantic. Un atributo no declarado lanza ValueError. Declararlo en la clase.
El corte por límite deja siete ToolMessage con run_limit=6. El séptimo es sintético, con status="error". Contar las ejecutadas.
El mensaje final del corte es inglés fijo del middleware. Tool call limit reached: run limit exceeded (7/6 calls). Detectarlo por jump_to en updates, no por el texto, y sustituirlo antes de enviarlo.
Los contadores por ejecución no se persisten. run_tool_call_count está en la actualización del nodo, no en get_state. Los del hilo sí.
Un test de concurrencia depende de la máquina. El pool de hilos por defecto es cpu+4. No se afirma en CI; se mide en el despliegue.
ToolStrategy en los tests, ProviderStrategy en producción. Es una diferencia real y hay que escribirla en la fixture, no descubrirla en un incidente.
Un guion que pasa no dice que el modelo pasaría. Los tests prueban el grafo con las decisiones fijadas. Las decisiones se evalúan aparte.
Cierre
Cuatro agentes probados en segundos y sin modelo. Lo que se prueba es todo lo que un modelo no puede romper y un desarrollador sí: qué herramienta ve el modelo, qué hace cada una con cada rol, cuándo se para, cuántas filas deja, y qué queda cuando algo falla a medias. Lo que no se prueba es lo que solo el modelo decide, y para eso hacen falta datos reales, un evaluador y una forma de comparar dos versiones. Es lo que viene a continuación.
Ver también
- Evaluar agentes de LangGraph con datasets de Langfuse
- Agentes con LangGraph para retail: el marco de la serie
- Agente de empleado de tienda con LangGraph: procedimientos, stock y roturas
- Agente de atención al cliente con LangGraph: el límite de aprobación humana
- Agente de cadena de suministro con LangGraph: incidencias y reposición con map-reduce
- Servir agentes de LangGraph sin langgraph-api
Fuentes
- Modelos falsos de
langchain-core(FakeMessagesListChatModel,GenericFakeChatModel): https://reference.langchain.com/python/langchain_core/language_models/fake_chat_models/ - Middleware de LangChain 1.x (
ToolCallLimitMiddleware,ModelCallLimitMiddleware): https://docs.langchain.com/oss/python/langchain/middleware - Persistencia en LangGraph (
InMemorySaver,get_state,list): https://docs.langchain.com/oss/python/langgraph/persistence - Código de
langchain.agents.middleware.tool_call_limit(langchain 1.4.1), métodoafter_model - pytest: https://docs.pytest.org/en/stable/