Inicio / Artículos

Un agente financiero de QuickBooks que funciona con un LLM local mediante Ollama y LangChain

Un agente ReAct en Qwen 3.5 9B y una RTX 4070 lee datos en tiempo real de QuickBooks a través de LangChain y Ollama, un modelo LLM local sin costos en la nube. Código, salida y límites.

Editor: REACTAPP.TOP13 min de lectura2879 palabras

Fuentes: **Building an Agentic AI App With a Local LLM — No Cloud, No API Costs**

Aquí hay un asistente financiero que se conecta a QuickBooks Online, obtiene los registros contables en tiempo real de la empresa y permite al propietario conversar con una IA sobre las cifras reales: facturas reales, flujo de efectivo real, vencimientos de cuentas por cobrar reales, y nada de datos de demostración. El modelo funciona completamente en una laptop; una NVIDIA GeForce RTX 4070 con Qwen 3.5 9B, accesible a través de Ollama, se encarga de todo el razonamiento, por lo que no hay llamadas a OpenAI ni a Anthropic, ni inferencia en la nube y tampoco se generan facturas por uso de IA durante el desarrollo.

Lo que sigue aborda el código real, las decisiones arquitectónicas y lo que produce el asistente, en lugar de la teoría.

El problema que resuelve

Un propietario de una pequeña empresa en QuickBooks tiene todos los datos financieros en un solo lugar, pero convertirlos en información útil requiere habilidades contables o la ayuda de un profesional pagado. Los informes estándar indican qué ocurrió, pero no dicen qué hacer a continuación.

El objetivo era permitir que alguien escribiera una pregunta en inglés sencillo sobre su propio dinero y recibiera una respuesta específica, basada en datos: números de factura, nombres de clientes y la situación real del efectivo, en lugar de consejos genéricos.

Arquitectura

El sistema está compuesto por tres capas:

  • Frontend: Next.js 14 con TypeScript y shadcn/ui
  • Backend: FastAPI, PostgreSQL y SQLAlchemy
  • IA: un agente de LangChain ReAct, Qwen 3.5 9B a través de Ollama, y un conjunto de herramientas de QuickBooks

La decisión más importante fue construir la capa de IA sobre el marco de agentes de LangChain. Esto permite que el modelo elija por sí mismo qué datos obtener antes de responder, y esa es la razón por la que las respuestas son específicas en lugar de genéricas.

Ejecutarlo en la GPU de una laptop

Toda la capa de IA se ejecuta en la GPU de una laptop de consumo, una RTX 4070 con 8 GB de VRAM. Los valores medidos se indican a continuación.

La respuesta tarda aproximadamente un minuto. Para el análisis financiero eso es aceptable, ya que quienes plantean preguntas sustantivas esperan un trato exhaustivo y no simples respuestas automáticas. En entornos de producción, al dirigir la abstracción de LangChain hacia un proveedor de inferencia en la nube, el tiempo se reduce a menos de cinco segundos, y el código de la aplicación permanece sin cambios.

Tres razones hicieron que el desarrollo local fuera la opción adecuada:

  • No cuesta nada. Cada ajuste en los prompts, sesión de depuración y prueba de casos límite se realizan sin gastos ni limitaciones de velocidad.
  • Mantiene la privacidad de los datos. Las cifras de QuickBooks nunca salen de la máquina, por lo que ni los montos de las facturas, ni los nombres de los clientes ni los datos de flujo de efectivo llegan a un proveedor de IA externo.
  • Demuestra algo importante. Si un modelo con 9 mil millones de parámetros en una GPU de consumo puede generar la calidad de salida que se muestra más adelante, el nivel técnico necesario para crear aplicaciones de IA locales es menor de lo que muchos desarrolladores imaginan.

Dado que LangChain se encuentra en el medio, el desarrollo y la producción comparten una misma base de código. Cambiar el backend de inferencia solo requiere dos líneas de código:

Las herramientas, la memoria, la lógica del agente y los prompts permanecen igual. El modelo es solo un detalle de implementación.

El agente

La capa de IA utiliza el marco de trabajo ReAct para agentes de LangChain. El agente alberga un conjunto de herramientas, que son funciones sencillas en Python que obtienen datos en tiempo real de QuickBooks, y elige cuáles utilizar según la pregunta del usuario. Esta es la configuración completa de agent.py:

from langchain_ollama import ChatOllama
from langchain_core.tools import tool
from langchain.agents import create_agent
from langchain_core.runnables import RunnableConfig
from quickbooks_client import fetch_invoices, fetch_profit_loss, \
                             fetch_cash_flow_summary, fetch_ar_aging

llm = ChatOllama(
    model='qwen3.5:9b',
    base_url='http://127.0.0.1:11434'
)

@tool
def get_invoices(config: RunnableConfig) -> str:
    """Fetch all invoices from QuickBooks including amounts and balances."""
    user_id = config.get('configurable', {}).get('user_id')
    return json.dumps(fetch_invoices(user_id=user_id), default=str)

@tool
def get_ar_aging(config: RunnableConfig) -> str:
    """Get AR aging report with buckets and DSO calculation."""
    user_id = config.get('configurable', {}).get('user_id')
    return json.dumps(fetch_ar_aging(user_id=user_id), default=str)

@tool
def draft_collection_notice(customer_name: str, amount: float,
                            days_overdue: int) -> str:
    """Drafts a reminder for an overdue invoice based on severity."""
    if days_overdue > 60:
        tone = 'Firm & Urgent'
        msg = f'Final reminder: your balance of ${amount} is significantly overdue.'
    elif days_overdue > 30:
        tone = 'Standard Follow-up'
        msg = f'Checking on your ${amount} payment, now 30+ days past due.'
    else:
        tone = 'Friendly Reminder'
        msg = f'Quick note: your payment of ${amount} was due recently.'
    return json.dumps({'tone': tone, 'draft': msg})

tools = [get_invoices, get_profit_loss, get_cash_flow_summary,
         get_ar_aging, draft_collection_email, draft_collection_notice]

agent = create_agent(
    model=llm,
    tools=tools,
    system_prompt=(
        'You are an AI CFO assistant for small businesses. '
        'Analyze financial data and give clear, actionable advice. '
        'Always highlight cash flow risks and overdue invoices. '
        'Be concise and specific with numbers.'
        '\n\n--- COLLECTIONS SPECIALIZATION ---'
        '\nWhen asked about unpaid invoices or specific customers:'
        '\n1. Use get_ar_aging to identify debt seniority.'
        '\n2. Use draft_collection_notice to generate professional follow-ups.'
        '\n3. Explain the impact on liquidity, not just the invoice.'
    )
)

Dos puntos merecen atención. Primero, user_id se transmite en cada llamada a la herramienta a través de RunnableConfig de LangChain, lo que hace que el sistema sea multiinquilino: el agente de cada usuario solo accede a los datos de QuickBooks de ese usuario. Segundo, el agente realmente lee las documentaciones de las herramientas al decidir qué invocar, por lo que deben redactarse como instrucciones para el modelo y no como descripciones dirigidas a los humanos.

Historial de conversación

Cada sesión mantiene su propio historial para que las preguntas posteriores puedan basarse en los intercambios anteriores:

conversation_history: dict[str, list] = {}

def ask(question: str, session_id: str = 'default',
        user_id: str = None) -> str:
    if session_id not in conversation_history:
        conversation_history[session_id] = []

    conversation_history[session_id].append(
        {'role': 'user', 'content': question}
    )

    config = {'configurable': {
        'user_id': user_id,
        'session_id': session_id
    }}

    response = agent.invoke(
        {'messages': conversation_history[session_id]},
        config=config
    )

    answer = response['messages'][-1].content
    conversation_history[session_id].append(
        {'role': 'assistant', 'content': answer}
    )
    return answer

Un usuario puede preguntar “¿Cuál es mi DSO?”, y luego “¿Qué clientes lo están utilizando?”, y el agente entiende a qué se refiere “eso”. El historial se identifica mediante session_id, por lo que los usuarios concurrentes mantienen conversaciones separadas.

El endpoint de chat

El endpoint de FastAPI detrás del chat es sencillo. El detalle clave es que user_id proviene del JWT verificado y nunca del cuerpo de la solicitud, lo que impide que alguien acceda a los datos de otro usuario editando un parámetro:

@app.post('/ask')
async def ask_agent(
    request: AskRequest,
    current_user: User = Depends(get_current_user)
):
    # Always use the verified user from JWT, never trust the request body
    user_id = str(current_user.id)

    answer = ask(
        question=request.question,
        session_id=request.session_id,
        user_id=user_id
    )

    return {
        'question': request.question,
        'answer': answer,
        'session_id': request.session_id
    }

Actualización de tokens

El cliente de QuickBooks actualiza automáticamente los tokens OAuth. Estos caducan después de 60 minutos, por lo que el cliente verifica la antigüedad del token antes de cada solicitud y lo renueva cuando es necesario:

def get_qb_client(user_id: str = None):
    db = SessionLocal()
    qb_token = db.query(QBToken).filter(
        QBToken.user_id == uuid.UUID(user_id)
    ).first()

    if not qb_token:
        raise Exception(f'No QB token found for user {user_id}')

    auth_client = AuthClient(
        client_id=os.getenv('QB_CLIENT_ID'),
        client_secret=os.getenv('QB_CLIENT_SECRET'),
        redirect_uri=os.getenv('QB_REDIRECT_URI'),
        environment=os.getenv('QB_ENVIRONMENT'),
        access_token=qb_token.access_token,
        refresh_token=qb_token.refresh_token,
    )

    # Only refresh if token is older than 55 minutes
    token_age = datetime.utcnow() - qb_token.updated_at
    if token_age > timedelta(minutes=55):
        auth_client.refresh()
        qb_token.access_token  = auth_client.access_token
        qb_token.refresh_token = auth_client.refresh_token
        qb_token.updated_at    = datetime.utcnow()
        db.commit()

    return QuickBooks(
        auth_client=auth_client,
        refresh_token=auth_client.refresh_token,
        company_id=qb_token.realm_id,
        minorversion=75
    )

Al actualizar los datos cada 55 minutos en lugar de 60, se crea un margen de seguridad contra las desviaciones en el tiempo. La actualización se realiza en el servidor y el token permanece en PostgreSQL, nunca en la interfaz frontal.

Qué produce el agente

Esta es la parte importante. A continuación se muestra el resultado real para la pregunta “¿Cuál es mi DSO y está en buen estado?”, ejecutada con datos de entorno de pruebas de QuickBooks:

Nada aquí es predefinido. El agente invocó a get_ar_aging, obtuvo los datos en tiempo real y los procesó. La respuesta señala a clientes específicos, estima el impacto del retraso en el flujo de efectivo y sugiere redactar notificaciones de cobro. Puede hacerlo porque existe una herramienta draft_collection_notice y el agente detecta cuándo es apropiado utilizarla.

Por qué esto no es un simple bot RAG

Lo que lo diferencia de un chatbot de recuperación de información es que el agente elige sus herramientas. Al preguntársele “¿Cuál es mi mayor riesgo en el flujo de efectivo?”, no ejecuta todas las operaciones; analiza la pregunta y selecciona las relevantes. El ciclo ReAct funciona de esta manera:

Para esa pregunta omite get_invoices y get_profit_loss porque son irrelevantes. Esa selectividad mantiene las respuestas enfocadas y evita que la ventana de contexto se llene con datos innecesarios.

El panel de control

El panel de control utiliza las mismas herramientas de QuickBooks que emplea el agente. Cada cifra está actualizada en tiempo real, sin ninguna capa de caché entre el usuario y QuickBooks, por lo que el gráfico de vencimientos y la lista de las facturas vencidas más importantes se actualizan con cada carga de página.

La página de envejecimiento AR va más allá del informe estándar de QuickBooks. Calcula el DSO en tiempo real, asigna un nivel de riesgo a cada categoría y muestra cada factura con los días exactos de vencimiento. Las cifras provienen de la misma función fetch_ar_aging que llama el agente, lo que garantiza una única fuente de verdad.

Decisiones de diseño destacables

  • OAuth de solo lectura. El alcance solicitado no incluye permisos de escritura, por lo que la aplicación estructuralmente no puede modificar los datos de QuickBooks.
  • user_id del JWT. El endpoint /ask confía únicamente en el token verificado y no en nada que envíe el cliente.
  • No se almacenan datos financieros. Las cifras de QuickBooks se solicitan cuando son necesarias y se descartan posteriormente. Solo los usuarios, tokens y horarios permanecen en la base de datos.
  • Se utilizó un LLM local durante el desarrollo. Ningún dato financiero fue enviado a ningún proveedor de IA externo durante las fases de construcción y pruebas.

Los beneficios prácticos de mantenerse local en comparación con un enfoque basado en la nube:

El costo es la latencia: alrededor de 60 segundos con la tarjeta 4070, frente a menos de 5 segundos con una API en la nube. Para una herramienta cuyos usuarios buscan respuestas concretas, esto está bien en la fase de desarrollo y pruebas, y pasar a un modelo en la nube para producción es sencillo.

Cuánto tiempo tomó

Un trabajo que normalmente tomaría varias semanas se redujo a unos pocos días. Un cronograma aproximado para un desarrollador individual que utiliza asistencia de programación con IA durante todo el proceso:

El sistema principal comenzó a funcionar después de tres o cuatro días, más un día adicional para perfeccionarlo y probarlo. Tres aspectos consumieron la mayor cantidad de tiempo: las peculiaridades de la API de QuickBooks (el formato del informe de vencimientos está lejos de ser intuitivo), el ajuste del agente LangChain (las documentaciones de las herramientas son más importantes de lo esperado) y la transmisión correcta del user_id en cada llamada a una herramienta para lograr el modelo multiinquilino. La velocidad se logró principalmente gracias a la validación integrada de FastAPI, la abstracción de proveedores de LangChain y la asistencia de IA para código genérico, depuración de errores y cuestiones arquitectónicas.

Qué debería cambiar

Habría varias cosas que se harían de manera diferente en una segunda intentona:

  • Vincular las herramientas con el agente desde el primer día. La capa de datos se completó primero y luego se conectó al agente, por lo que los desajustes en la interfaz aparecieron tarde.
  • Los prompts de prototipo en un modelo de 3 mil millones. El Qwen de 9 mil millones ofrece mejor calidad, pero funciona lentamente en esta GPU; por lo tanto, usar un modelo pequeño para experimentos rápidos con prompts y el de 9 mil millones para las verificaciones finales habría ahorrado horas.
  • Transmisión desde el inicio. Esperar un minuto sin que aparezca ni un solo token en la pantalla es frustrante, y la transmisión de tokens debería haber formado parte de los requisitos desde el primer día.
  • Probar las herramientas de QuickBooks. Los casos límite solo se presentan con datos reales, y un conjunto pequeño de pruebas con datos de entorno aislado habría evitado varias sesiones largas de depuración.
  • Limitaciones conocidas

    Cualquier descripción honesta debería indicar qué está por terminar. Aquí se detallan los factores que impiden que la versión actual escale, y cómo se resolvería cada uno de esos problemas.

    Historial en memoria

    El historial de las conversaciones se almacena en un diccionario simple de Python en la RAM:

    Eso está bien para un servidor de desarrollo, pero presenta tres problemas en entornos de producción. El historial desaparece cada vez que se reinicia el servidor. No puede funcionar en varias instancias de servidor, lo que descarta la escalabilidad horizontal. Además, nada se mantiene entre sesiones; al cerrar el navegador, la conversación termina.

    La solución en producción consiste en mover el historial a Redis, para lecturas y escrituras rápidas con vencimiento TTL, o a PostgreSQL para una persistencia total. Las abstracciones de historial de mensajes de LangChain son compatibles con ambos, por lo que el cambio es sencillo.

    Sin RAG, a propósito

    El sistema no utiliza ningún mecanismo de recuperación: ni embeddings, ni base de datos vectoriales, ni pipeline de fragmentación. Eso es intencional. Los datos financieros cambian constantemente, con facturas que se pagan, nuevas que aparecen y saldos que varían, por lo que los embeddings creados ayer pueden estar desactualizados hoy. Obtener datos en tiempo real desde QuickBooks con cada solicitud garantiza que las respuestas reflejen la situación actual de la empresa.

    RAG sería adecuado para material estático o de cambio lento, como contratos, pólizas e informes históricos. Para datos transaccionales en tiempo real, las llamadas directas a las herramientas son más precisas que la recuperación mediante embeddings.

    El costo está relacionado con la latencia y el uso de tokens, ya que las obtenciones frescas son más lentas y ocupan más contexto que un resumen compacto de una base de datos vectorial. Con volúmenes actuales de decenas a pocas cientos de facturas por empresa, eso es aceptable. A escala mayor, tiene sentido utilizar un enfoque híbrido, con RAG para el historial y herramientas en tiempo real para los datos actuales.

    Volumen elevado de facturas

    Actualmente todas las facturas se incluyen por completo en el contexto del agente. Con 30 a 50 facturas funciona bien, pero con miles se alcanzan los límites de tokens y disminuye la calidad. La solución es filtrar antes de que el modelo vea cualquier cosa: obtener solo las facturas relevantes para la pregunta, resumir primero los grandes conjuntos de datos o utilizar dos etapas en las que un paso rápido de recuperación reduzca el conjunto antes de comenzar el razonamiento.

    Aún sin transmisión en tiempo real

    El servidor devuelve toda la respuesta de una sola vez, por lo que los usuarios con GPU local se quedan mirando una pantalla en blanco durante aproximadamente un minuto. FastAPI ofrece StreamingResponse, Ollama puede transmitir tokens a través de su API y los agentes de LangChain admiten callbacks en tiempo real. Conectarlos es sencillo, pero se pospuso para que funcionara la funcionalidad básica, y es una prioridad antes del lanzamiento.

    Sesiones que no duran

    El session_id se crea en el frontend cuando se carga la página de chat. Una pestaña nueva, un refresco o un regreso después de cerrar el navegador generan uno nuevo y el historial comienza de cero, incluso si el servidor nunca se reinicia. Un asistente financiero se beneficiaría mucho de una memoria persistente, como recordar que un cliente está en un plan de pago o que se discutió una factura la semana pasada. Hacerlo correctamente implica almacenar las sesiones en la base de datos y permitir que los usuarios den nombre, reanuden y eliminen conversaciones anteriores.

    Conclusión

    La abstracción de LangChain, la inferencia local de Ollama y el acceso a datos de QuickBooks juntos crean algo más útil que cualquiera de sus componentes por separado. El agente no solo recupera datos; los analiza, selecciona lo importante y responde en función de las finanzas propias de cada usuario.

    Trabajar de forma local no es un compromiso. Es una estrategia de desarrollo legítima que ofrece reales ventajas en privacidad, costos e iteraciones. Una GPU para consumidores como la RTX 4070 es capaz de ejecutar modelos de 9 mil millones de parámetros. Para quienes planean crear una aplicación basada en IA, la mejor pregunta no es cuál proveedor de nube elegir, sino si los modelos adecuados pueden ejecutarse localmente durante el desarrollo, de modo que el producto pueda validarse sin costos de inferencia y la decisión sobre el proveedor se posponga hasta que haya datos reales de uso. Con una RTX 4070, la respuesta es sí.