Este proyecto empezó como un entregable académico: un chatbot que respondía preguntas sobre planes y precios usando RAG. Terminó siendo un co-piloto multiagente que opera dentro de un ERP de facturación electrónica peruano, genera comprobantes ante SUNAT y mueve stock — con un humano confirmando cada escritura.

Lo interesante no es que use LangGraph. Es cómo se sostiene: un solo proceso FastAPI sirve dos subsistemas que no se conocen entre sí, y el segundo se agregó sin tocar el primero.

El punto de partida

El subsistema A es el chatbot de preventa. Un POST /chat que responde sobre planes, precios y obligaciones SUNAT usando RAG sobre una base de 13 documentos. Flujo fijo, escrito a mano:

text
                recuperar documentos → cargar memoria → armar prompt → llamar al LLM → persistir el turno
              

Eso vive en core/application/presales_service.py y son cinco pasos imperativos. Yo escribí el orden. El LLM solo redacta la respuesta final.

Funciona. Sigue funcionando hoy, y esa continuidad no es casualidad.

El salto: de responder preguntas a operar el ERP

El subsistema B es otra cosa. El usuario ya está adentro del ERP y pide "generá la nota de venta de este pedido" o "movéme 20 unidades del almacén central a la sucursal". No hay una secuencia fija que sirva para todos los casos: cada pedido necesita una combinación distinta de consultas y escrituras.

Ahí es donde el caso de uso deja de ser una función que escribí y pasa a ser un grafo de capacidades donde el LLM decide la secuencia en runtime.

La regla que ordenó todo

Antes de escribir una línea del multiagente, la arquitectura hexagonal ya estaba puesta. Las flechas de dependencia apuntan siempre hacia adentro:

  • core/domain.py — entidades puras. Nunca importa un framework.
  • core/ports.py — interfaces ABC. Nunca importa un framework.
  • core/application/ — casos de uso. Acá sí vive LangChain y LangGraph, por diseño.
  • adapters/ — implementan los puertos contra infraestructura real.
  • entrypoints/ — FastAPI, el composition root.
Las cinco capas. Los dos subsistemas comparten dominio y puertos, pero ninguno importa al otro.

La pregunta que clasifica cualquier archivo nuevo es "¿quién llama a quién?". Si el adapter llama hacia adentro del hexágono, es un driving adapter y va en entrypoints/. Si el core lo llama a través de un puerto, es un driven adapter y va en adapters/. entrypoints/ nunca implementa un puerto; adapters/ siempre implementa uno.

Esto suena a ceremonia hasta que agregás el segundo subsistema. Los cinco especialistas del co-piloto hablan con el ERP a través de los mismos puertos que ya existían — ItemsPort, SalesPort, InventoryPort, DispatchPort, FinancePort — y ninguno de ellos sabe que HTTP existe.

Dos sabores de caso de uso

Esta es la distinción que más me costó articular y la que más ordena el código:

Imperativo (presales_service.py): vos escribís los pasos fijos. El LLM es una llamada más dentro de una función que controlás.

Agéntico (orchestration/graph.py): vos definís capacidades — tools, agentes, routing — y el LLM decide la secuencia en runtime con un ciclo ReAct: razonar, actuar, observar. El grafo compilado ES el caso de uso. No hay una función que orqueste; la orquestación es la topología.

Los dos son válidos. Elegir mal es lo que duele: un flujo determinístico armado como agente es impredecible sin necesidad, y un flujo abierto escrito a mano se convierte en un árbol de if que nadie mantiene.

LangChain y LangGraph: qué hace cada uno

Se confunden todo el tiempo, así que vale separarlos por lo que hacen acá:

LangChain aporta las piezas: el cliente ChatOpenAI apuntando a Qwen3 vía DashScope, bind_tools() para exponerle las herramientas al modelo, with_structured_output() para forzar salidas tipadas con Pydantic, y las abstracciones de mensajes.

LangGraph aporta la máquina de estados: el StateGraph con sus nodos y aristas condicionales, el AgentState que viaja entre nodos con un reducer que acumula mensajes, el checkpointer que persiste el estado entre turnos, y — lo más importante para este proyecto — interrupt(), que pausa la ejecución a mitad de una función y la reanuda después.

LangChain te da un modelo que sabe llamar tools. LangGraph te da el control de flujo alrededor de eso.

El supervisor: no gastes un LLM en algo que ya sabés

El grafo entra siempre por un nodo supervisor que decide a cuál de los cinco especialistas rutear. Tiene dos caminos, en este orden:

Fast-path, cero llamadas al LLM. El frontend del ERP sabe en qué módulo está parado el usuario y lo manda como context_module. Si viene y es uno de los cinco válidos, se usa directo. Sin latencia, sin costo, sin margen de error.

Fallback, una sola llamada. Si no vino o es inválido, se clasifica el último mensaje humano con with_structured_output() sobre un Literal de los cinco módulos. Pydantic rechaza cualquier valor que el modelo intente inventar.

Lo que nunca hace es adivinar ni caer en un default fijo. Un routing hardcodeado a "ventas" cuando el usuario preguntaba por stock es peor que fallar con un mensaje claro.

Los especialistas: por qué no usé create_react_agent

Cada especialista es un SpecialistAgent: un system prompt de su dominio, sus tools bindeadas, y un loop ReAct acotado a mano — LLM, ¿pidió tools?, ejecutarlas, volver al LLM, hasta que responda sin tool calls o se agote max_iterations.

LangGraph trae create_react_agent que hace exactamente eso en una línea. No lo usé, y la razón no es purismo: el loop manual son unas veinte líneas que puedo leer, instrumentar y acotar. Un helper que hace lo mismo pero que necesito destripar cuando algo sale raro no me ahorra nada. El costo de la abstracción se paga en el debugging, no en la escritura.

La línea roja: el agente nunca escribe solo

Esta es la regla no negociable del proyecto.

Toda tool que escribe — generar un comprobante, mover stock, registrar una compra, emitir una guía de remisión, abrir o cerrar caja — llama a interrupt() como primera línea del cuerpo, antes del POST real. El grafo se pausa ahí mismo, en el medio de la función.

El HTTP responde 200 con:

json
                {
  "status": "awaiting_confirmation",
  "confirmation": {
    "tool_name": "...",
    "summary": "...",
    "tool_args": { }
  }
}
              

El humano lee qué se va a hacer y decide. Recién con un POST /agent/confirm con approved: true el grafo reanuda dentro del cuerpo de la tool y ejecuta el POST contra el ERP. Con approved: false, la tool aborta y no se persiste nada.

Un turno completo. La rama derecha es la que nunca se salta: interrupt() pausa el grafo antes del POST y solo un POST /agent/confirm lo reanuda.

No es un feature de UX. Es la diferencia entre una herramienta que un contador puede usar y una que emite comprobantes fiscales equivocados a nombre de un cliente real.

Credenciales que no sobreviven al request

El co-piloto es multi-tenant y las credenciales de cada uno llegan en cada llamada — tenant_base_url y tenant_token. Nunca se persisten: viven en memoria de proceso mientras dura una confirmación pendiente y mueren con ella. No van al .env, ni a disco, ni a los logs, ni al checkpointer de LangGraph.

El mecanismo importa. Las credenciales viajan por config["configurable"]["creds"] usando Annotated[RunnableConfig, InjectedToolArg], nunca como un parámetro normal de la tool. Si fueran un parámetro normal, el token entraría en el JSON schema que el LLM ve — y un token en el contexto del modelo es un token que puede terminar en una respuesta.

Cada tool construye su cliente HTTP fresco, por invocación. El grafo compilado es un singleton de larga vida; los adapters con credenciales, no.

Por qué los especialistas no se hablan entre sí

En la topología del grafo, cada especialista va directo a END. No hay aristas entre ellos. Un pedido que toca compras y logística no se encadena automáticamente: el especialista responde, sugiere el siguiente paso, y el usuario lo pide como un turno nuevo.

Esto parece una limitación y es una decisión. Encadenar escrituras automáticas entre dominios multiplica el radio de daño de una sola confirmación humana: el usuario aprueba una compra y termina con una guía de remisión emitida que nunca revisó. La confirmación tiene que ser uno a uno con la escritura.

Lo aditivo como garantía

El subsistema B se agregó sin tocar el A. Si el grafo no compila al arrancar, /agent/* devuelve 503 y /chat sigue funcionando igual que antes. El lifespan() de FastAPI cablea los adapters concretos y arma el grafo dentro de un try; una falla ahí degrada una funcionalidad, no el proceso.

Esa propiedad no salió de la nada. Salió de que los dos subsistemas comparten puertos y dominio, pero ninguno importa al otro.

Lo que falta

El checkpointer es InMemorySaver. Antes de que esto vea usuarios reales tiene que migrar a SQLite o Postgres: hoy un reinicio del proceso se lleva puestas todas las confirmaciones pendientes.

Y varios puertos — DispatchPort, FinancePort — tienen payloads que no están en el openapi.yaml del ERP. Los saqué leyendo el código PHP real: los transforms anidados, los códigos fiscales de establecimiento, los ids de ubigeo. Los docstrings de core/ports.py citan archivo y línea exactos. Es la clase de deuda que solo se paga documentando de dónde salió cada campo.