Un agent QuickBooks Finance fonctionnant sur un LLM local avec Ollama et LangChain
Un agent ReAct sur Qwen 3.5 9B et une RTX 4070 lit en temps réel les données de QuickBooks via LangChain et Ollama, un LLM local sans aucun coût cloud. Code, résultats et limites.
Sources: **Building an Agentic AI App With a Local LLM — No Cloud, No API Costs**
Voici un assistant financier qui se connecte à QuickBooks Online, récupère les données financières en temps réel de l’entreprise et permet au propriétaire d’échanger avec une intelligence artificielle au sujet des chiffres réels : factures authentiques, flux de trésorerie réels, tableau d’âge des créances réel, sans aucune donnée de démonstration. Le modèle fonctionne entièrement sur un ordinateur portable. Une carte NVIDIA GeForce RTX 4070 équipée de Qwen 3.5 9B, accessible via Ollama, effectue tout le traitement logique ; il n’y a donc aucune requête vers OpenAI, aucune requête vers Anthropic, pas d’inférence en cloud et aucune facture liée à l’intelligence artificielle pendant le développement.
Ce qui suit porte sur le code réel, les choix architecturaux et ce que produit l’assistant, plutôt que sur la théorie.
Le problème qu’il résout
Un propriétaire d’entreprise de petite taille utilisant QuickBooks dispose de toutes les données financières en un seul endroit, mais pour en tirer des informations utiles, il faut soit des compétences comptables, soit l’aide d’un professionnel payé. Les rapports standard indiquent ce qui s’est passé, mais ils ne disent pas quoi faire ensuite.
L’objectif était de permettre à quelqu’un de taper une question en anglais simple concernant ses propres finances et d’obtenir une réponse précise, étayée par des données : numéros de facture, noms de clients et la situation réelle du cash, plutôt que des conseils génériques.
Architecture
Le système se compose de trois couches :
- Frontend : Next.js 14 avec TypeScript et shadcn/ui
- Backend : FastAPI, PostgreSQL et SQLAlchemy
- IA : un agent LangChain ReAct, Qwen 3.5 9B via Ollama, ainsi qu’un ensemble d’outils QuickBooks
La décision la plus importante a été de construire la couche d’IA sur le framework d’agents de LangChain. Cela permet au modèle de choisir lui-même quels données récupérer avant de répondre, ce qui explique pourquoi les réponses sont précises plutôt que génériques.
Lancement sur une GPU de laptop
Toute la couche d’IA fonctionne sur une GPU de laptop grand public, un RTX 4070 doté de 8 GB de VRAM. Les chiffres mesurés sont indiqués ci-dessous.
Une réponse prend environ une minute. Cela est acceptable pour une analyse financière, car les personnes qui posent des questions substantielles s’attendent à une réponse approfondie, et non à des banalités de chatbot. En environnement de production, en orientant l’abstraction LangChain vers un fournisseur d’inférence cloud, ce temps est ramené en dessous de cinq secondes, sans avoir à modifier le code de l’application.
Trois raisons ont fait du développement local le choix approprié :
- Cela ne coûte rien. Chaque modification de prompt, chaque session de débogage et chaque test de cas limite s’exécute sans frais ni limites de fréquence.
- Cela garantit la confidentialité des données. Les informations de QuickBooks ne quittent jamais l’appareil, de sorte que ni le montant des factures, ni le nom des clients, ni les chiffres de trésorerie ne parviennent à un fournisseur d’IA externe.
- Cela prouve un point important : si un modèle de 9 milliards de paramètres fonctionnant sur une GPU grand public peut produire une qualité de résultats comparable à celle présentée ci-dessus, les exigences matérielles pour développer des applications d’IA locales sont plus basses que ce que beaucoup de développeurs imaginent.
Puisque LangChain se situe au milieu, le développement et la production partagent une même base de code. Changer le backend d’inférence ne nécessite que deux lignes de code :
Les outils, la mémoire, la logique de l’agent et les prompts restent inchangés. Le modèle n’est qu’un détail d’implémentation.
L’agent
La couche d’intelligence artificielle utilise le framework d’agent ReAct de LangChain. L’agent dispose d’un ensemble d’outils, qui sont des fonctions Python simples permettant de récupérer des données en temps réel depuis QuickBooks, et choisit ceux à appeler en fonction de la question posée par l’utilisateur. Voici la configuration complète issue 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.'
)
)
Deux points méritent attention. Premièrement, user_id est transmis à chaque appel d’outil via RunnableConfig de LangChain, ce qui permet au système d’être multi-locataire : l’agent de chaque utilisateur n’accède qu’aux données QuickBooks de cet utilisateur. Deuxièmement, l’agent lit réellement les docstrings des outils pour décider de ce qu’il faut invoquer, il convient donc que celles-ci soient rédigées comme des instructions destinées au modèle, et non comme des descriptions pour les humains.
Histoire de la conversation
Chaque session conserve sa propre histoire, ce qui permet aux questions ultérieures de s’appuyer sur les échanges précédents :
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 utilisateur peut demander « Quel est mon DSO ? » puis « Quels clients en sont responsables ? », et l’agent comprend ce que signifie « cela ». L’historique est identifié par session_id, de sorte que les utilisateurs simultanés conservent des conversations distinctes.
L’endpoint de chat
L’endpoint FastAPI derrière le chat est simple. Le détail clé est que user_id provient du JWT vérifié et jamais du corps de la demande, ce qui empêche quiconque d’accéder aux données d’un autre utilisateur en modifiant un paramètre :
@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
}
Rafraîchissement des jetons
Le client QuickBooks met à jour automatiquement les jetons OAuth. Ceux-ci expirent après 60 minutes, c’est pourquoi le client vérifie l’ancienneté du jeton avant chaque demande et le renouvelle si nécessaire :
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
)
Un renouvellement à 55 minutes au lieu de 60 offre une marge de manœuvre face aux écarts horaires. Le renouvellement a lieu du côté serveur et le jeton est stocké dans PostgreSQL, jamais dans l’interface utilisateur.
Ce que génère l’agent
C’est la partie importante. Voici le résultat réel pour la question « Quel est mon DSO et est-il sain ? », exécutée sur des données de sandbox QuickBooks :
Rien ici n’est prédéfini. L’agent a appelé la fonction get_ar_aging, obtenu des données en temps réel et les a analysées. La réponse cite des clients spécifiques, estime l’impact du retard sur le flux de trésorerie et suggère d’établir des avis de recouvrement. Il peut proposer cela car un outil draft_collection_notice existe et l’agent sait quand il convient de l’utiliser.
Pourquoi ce n’est pas un simple bot RAG
Ce qui le distingue d’un chatbot de recherche, c’est que l’agent choisit lui-même ses outils. Lorsqu’on lui demande « Quel est mon plus grand risque de trésorerie ? », il n’exécute pas toutes les opérations ; il analyse la question et sélectionne uniquement celles qui sont pertinentes. Le cycle ReAct fonctionne de la manière suivante :
Pour cette question, il saute get_invoices et get_profit_loss car ils sont irrelevants. Cette sélectivité permet d’obtenir des réponses ciblées et d’éviter que la fenêtre de contexte ne se remplisse de données inutiles.
Le tableau de bord
Le tableau de bord utilise les mêmes outils QuickBooks que l’agent. Chaque chiffre est mis à jour en temps réel, sans couche de mise en cache entre l’utilisateur et QuickBooks, de sorte que le graphique des échéances et la liste des factures en retard les plus importantes se réactualisent à chaque chargement de page.
La page de suivi du vieillissement des dettes en RA dépasse les rapports standard de QuickBooks. Elle calcule le DSO en temps réel, attribue un niveau de risque à chaque catégorie et liste chaque facture avec le nombre exact de jours de retard. Les chiffres proviennent de la même fonction fetch_ar_aging que l’agent appelle, ce qui assure une source unique de vérité.
Choix de conception à souligner
- OAuth en mode lecture seule. La portée demandée ne comporte aucune permission d’écriture, donc l’application ne peut pas modifier les données de QuickBooks.
user_idprovenant du JWT. Le point de terminaison/askfait confiance au jeton vérifié et non à ce que l’client envoie.- Aucune donnée financière n’est stockée. Les chiffres de QuickBooks sont demandés au besoin puis supprimés par la suite. Seuls les utilisateurs, les jetons et les plannings sont conservés dans la base de données.
- Utilisation d’un LLM local pendant le développement. Aucune donnée financière n’a été envoyée à un fournisseur d’IA externe pendant les phases de développement et de test.
Les avantages pratiques de rester local par rapport à une approche axée sur le cloud :
Le coût réside dans la latence : environ 60 secondes avec la carte 4070 contre moins de 5 secondes avec une API cloud. Pour un outil dont les utilisateurs attendent des réponses concrètes, cela est acceptable en phase de développement et de test, et passer à un modèle cloud pour la production est une opération simple.
Le temps nécessaire
Un travail qui prendrait normalement plusieurs semaines a été réduit à quelques jours. Un calendrier approximatif pour un développeur solo utilisant une assistance de codage par IA tout au long du processus :
Le système de base a fonctionné après trois à quatre jours, plus un jour supplémentaire pour les ajustements et les tests. Trois éléments ont exigé le plus de temps : des anomalies dans l’API QuickBooks (la mise en page du rapport d’évolution est loin d’être intuitive), l’ajustement de l’agent LangChain (les descriptions des outils comptent plus que prévu) et la transmission correcte de user_id à chaque appel d’outil pour assurer le multi-tenancy. La vitesse a principalement été améliorée grâce à la validation intégrée de FastAPI, à l’abstraction des fournisseurs de LangChain, ainsi qu’à l’aide en codage par l’IA pour les éléments standard, le débogage des erreurs et les questions d’architecture.
Quelles modifications sont nécessaires
Divers aspects seraient abordés différemment lors d’une deuxième tentative :
- Relier les outils et l’agent dès le premier jour. La couche de données a été finalisée en premier puis connectée à l’agent, ce qui a entraîné des incohérences d’interface plus tard dans le processus.
Limites connues
Toute description honnête devrait indiquer ce qui n’est pas encore terminé. Voici ce qui retarde actuellement le développement de cette version, ainsi que la manière dont chaque lacune pourrait être comblée.
Histoire en mémoire
L’historique des conversations est stocké dans un dictionnaire Python classique en RAM :
Cela convient pour un serveur de développement, mais présente trois problèmes en environnement de production. L’historique disparaît à chaque redémarrage. Il ne fonctionne pas sur plusieurs instances de serveur, ce qui exclut l’échelle horizontale. De plus, rien n’est conservé entre les sessions, de sorte que la fermeture du navigateur met fin à la conversation.
La solution en production consiste à stocker l’historique dans Redis, pour des lectures et écritures rapides avec expiration TTL, ou dans PostgreSQL pour une persistance complète. Les abstractions relatives à l’historique des messages de LangChain prennent en charge les deux solutions, ce qui rend le changement facile.
Aucun RAG, intentionnellement
Le système ne fait pas appel à de la récupération de données : ni embeddings, ni base de données vectorielle, ni pipeline de segmentation. C’est intentionnel. Les données financières évoluent constamment, avec le paiement de factures, l’apparition de nouvelles factures et des variations de solde ; par conséquent, les embeddings créés hier peuvent être obsolètes aujourd’hui. La récupération en temps réel de données depuis QuickBooks à chaque demande garantit que les réponses reflètent l’état actuel de l’entreprise.
RAG conviendrait à des documents statiques ou peu changeants tels que des contrats, des politiques et des rapports historiques. Pour les données transactionnelles en temps réel, les appels directs aux outils sont plus précis que la récupération via des embeddings.
Le coût réside dans le temps de réponse et l’utilisation de tokens, car les récupérations fraîches sont plus lentes et fournissent davantage de contexte qu’un résumé compact provenant d’une base de données vectorielle. Avec des volumes actuels allant de dizaines à quelques centaines de factures par entreprise, cela est acceptable. À plus grande échelle, une approche hybride s’avère judicieuse, avec RAG pour les données historiques et des outils en temps réel pour les données actuelles.
Grand nombre de factures
Actuellement, toutes les factures sont intégrées dans le contexte de l’agent en totalité. Avec 30 à 50 factures, cela fonctionne bien. Mais avec des milliers de factures, les limites de tokens sont atteintes et la qualité diminue. La solution consiste à filtrer avant que le modèle ne voie quoi que ce soit : ne récupérer que les factures pertinentes pour la question, résumer d’abord de gros ensembles de données, ou utiliser deux étapes où une phase de récupération rapide réduit le nombre de factures avant que la réflexion ne commence.
Aucun streaming pour l’instant
Le serveur renvoie toute la réponse d’un coup, ce qui fait que les utilisateurs disposant d’une GPU locale doivent attendre environ une minute devant un écran vide. FastAPI propose StreamingResponse, Ollama peut diffuser des tokens via son API, et les agents LangChain prennent en charge les appels en streaming. Les connecter est simple, mais cela a été reporté afin de faire fonctionner d’abord les fonctionnalités de base, et c’est une priorité avant le lancement.
Séances qui ne durent pas
Le session_id est créé côté client lorsque la page de chat s’affiche. Une nouvelle fenêtre, un renouvellement ou un retour après fermeture du navigateur génère un nouveau session_id et la chronologie des conversations redevient vierge, même si le serveur n’a jamais été redémarré. Un assistant financier tirerait grand profit d’une mémoire persistante, permettant par exemple de se souvenir qu’un client est sur un plan de paiement ou qu’une facture a été abordée la semaine dernière. Pour y parvenir correctement, il faut stocker les sessions dans une base de données et permettre aux utilisateurs de nommer, reprendre ou supprimer des conversations antérieures.
En résumé
L’abstraction offerte par LangChain, l’inference locale assurée par Ollama et l’accès aux données via QuickBooks forment ensemble une solution plus utile que chacun de ces éléments pris isolément. L’agent ne se contente pas de récupérer des données ; il les analyse, sélectionne ce qui est pertinent et répond en fonction de la situation financière propre à chaque utilisateur.
Passer à une solution locale n’est pas un compromis. C’est une stratégie de développement légitime offrant de réels avantages en termes de confidentialité, de coûts et de capacité d’itération, et une carte graphique grand public comme la RTX 4070 permet de faire fonctionner des modèles de 9 milliards de paramètres. Pour quiconque envisage de créer une application basée sur l’IA, la bonne question n’est pas de savoir quel fournisseur de cloud choisir, mais plutôt de savoir si des modèles performants peuvent être exécutés localement pendant le développement, afin que le produit puisse être validé sans aucun coût d’inference, et que la décision concernant le fournisseur puisse être prise une fois que des données d’utilisation réelles sont disponibles. Avec une RTX 4070, la réponse est oui.