Ein QuickBooks-Finanz-Agent, der mit Ollama und LangChain auf einem lokalen LLM läuft.
Ein ReAct-Agent auf Qwen 3.5 9B und einer RTX 4070 liest Echtzeit-QuickBooks-Daten über LangChain und Ollama – ein lokales LLM ohne Cloud-Kosten – ein. Code, Ausgabe und Grenzen.
Quellen: **Building an Agentic AI App With a Local LLM — No Cloud, No API Costs**
Hier handelt es sich um einen Finanzassistenten, der in QuickBooks Online integriert wird, die aktuellen Bücher eines Unternehmens abruft und es dem Inhaber ermöglicht, mit einer KI über die tatsächlichen Zahlen zu sprechen: echte Rechnungen, echter Cashflow, aktuelle Fälligkeitsprofile der Forderungen – und keine Demo-Daten. Das Modell läuft vollständig auf einem Laptop. Eine NVIDIA GeForce RTX 4070 mit Qwen 3.5 9B, die über Ollama bereitgestellt wird, übernimmt alle Berechnungen; daher gibt es während der Entwicklung weder Anrufe bei OpenAI noch bei Anthropic, keine Cloud-Berechnungen und auch keine Rechnungen für KI-Dienste.
Im Folgenden wird der eigentliche Code, die gewählte Architektur sowie das, was der Assistent erzeugt, behandelt – anstatt nur Theorie.
Das Problem, das er löst
Ein Inhaber eines kleinen Unternehmens in QuickBooks hat alle finanziellen Daten an einem Ort, doch um daraus Erkenntnisse zu gewinnen, sind entweder Buchhaltungskenntnisse oder bezahlte professionelle Hilfe erforderlich. Standardberichte geben nur an, was passiert ist – sie sagen nicht, was als Nächstes zu tun ist.
Ziel war es, es jemandem zu ermöglichen, eine einfache Frage auf Englisch zu seiner eigenen Finanzsituation einzugeben und eine spezifische, datengestützte Antwort zu erhalten: Rechnungsnummern, Kundennamen sowie die tatsächliche Bargeldsituation – und nicht allgemeine Ratschläge.
Architektur
Das System setzt sich aus drei Schichten zusammen:
- Frontend: Next.js 14 mit TypeScript und shadcn/ui
- Backend: FastAPI, PostgreSQL und SQLAlchemy
- AI: ein LangChain ReAct-Agent, Qwen 3.5 9B über Ollama sowie eine Reihe von QuickBooks-Tools
Die wichtigste Entscheidung war es, die AI-Schicht auf LangChains Agent-Framework zu bauen. Dadurch kann das Modell selbst entscheiden, welche Daten es vor der Antwort abrufen soll – weshalb die Antworten spezifisch statt allgemein ausfallen.
Ausführung auf der GPU eines Laptops
Die gesamte AI-Schicht läuft auf der GPU eines Consumer-Laptops, einer RTX 4070 mit 8 GB VRAM. Die gemessenen Werte sind unten aufgeführt.
Eine Antwort dauert etwa eine Minute. Für eine finanzielle Analyse ist das in Ordnung, da diejenigen, die fundierte Fragen stellen, Sorgfalt erwarten und nicht nur Smalltalk von einem Chatbot. In der Produktion führt die Verwendung der LangChain-Abstraktion zusammen mit einem Cloud-Inferenzanbieter dazu, dass die Zeit unter fünf Sekunden liegt, und der Anwendungscode bleibt unverändert.
Drei Gründe machen die lokale Entwicklung zur richtigen Wahl:
- Es kostet nichts. Jede Anpassung des Prompts, jede Debugging-Sitzung sowie jeder Test von Randfällen werden ohne Kosten und ohne Gebührenbeschränkungen durchgeführt.
- Es schützt die Daten. Die Daten aus QuickBooks verlassen niemals das Gerät, sodass weder Rechnungsbeträge, noch Kundennamen oder Cashflow-Werte an einen Drittanbieter für KI-Systeme gelangen.
- Es beweist eine These. Wenn ein Modell mit 9 Milliarden Parametern auf einer Consumer-GPU bereits die später gezeigte Ausgabequalität liefern kann, dann ist die Hürde für die Entwicklung lokaler KI-Apps niedriger, als viele Entwickler annehmen.
Weil LangChain in der Mitte steht, teilen sich Entwicklung und Produktion denselben Codebase. Der Austausch des Inferenz-Backends erfordert nur zwei Zeilen Code:
Tools, Speicher, Agentenlogik und Anfragen bleiben unverändert. Das Modell ist lediglich ein Implementierungsdetail.
Der Agent
Die KI-Schicht verwendet LangChains ReAct-Agentenframework. Der Agent verfügt über eine Reihe von Tools, die einfache Python-Funktionen sind, die aktuelle QuickBooks-Daten abrufen, und wählt je nach Frage des Benutzers aus, welche dieser Funktionen aufgerufen werden sollen. Hier ist die vollständige Konfiguration aus 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.'
)
)
Zwei Punkte verdienen Aufmerksamkeit. Erstens wird user_id über jede Tool-Aufrufung via LangChain’s RunnableConfig übertragen, was das System multi-tenant macht: Der Agent jedes Benutzers greift nur auf die QuickBooks-Daten dieses Benutzers zu. Zweitens liest der Agent bei der Entscheidung, welches Tool aufgerufen werden soll, tatsächlich die Dokumentationen der Tools – daher sollten diese als Anweisungen für das Modell verfasst werden und nicht als Beschreibungen für Menschen.
Gesprächshistorie
Jede Sitzung behält ihre eigene Historie bei, sodass nachfolgende Fragen auf frühere Antworten zurückgreifen können:
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
Ein Benutzer kann fragen: „Was ist mein DSO?“ und anschließend: „Welche Kunden tragen dazu bei?“, wobei der Agent versteht, was mit „dazu“ gemeint ist. Die Historie wird durch session_id gekennzeichnet, sodass mehrere Benutzer gleichzeitig separate Gespräche führen können.
Der Chat-Endpunkt
Der FastAPI-Endpunkt hinter dem Chat ist einfach. Der entscheidende Punkt ist, dass user_id aus dem verifizierten JWT stammt und niemals aus dem Anfragekörper, wodurch verhindert wird, dass jemand durch Änderung eines Parameters auf die Daten eines anderen Benutzers zugreift:
@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
}
Token-Aufräumen
Der QuickBooks-Client aktualisiert die OAuth-Tokens automatisch. Sie verfallen nach 60 Minuten, weshalb der Client vor jeder Anfrage das Alter des Tokens überprüft und es bei Bedarf erneuert:
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
)
Das Erneuern alle 55 Minuten anstelle von 60 bietet einen Puffer gegen Zeitverschiebungen. Das Erneuern erfolgt auf Serverseite und das Token befindet sich in PostgreSQL, niemals im Frontend.
Was der Agent erzeugt
Das ist der entscheidende Teil. Hier ist die tatsächliche Ausgabe für die Frage „Was ist mein DSO und ist er gesund?“, ausgeführt mit Daten aus dem QuickBooks-Sandbox-Umfeld:
Nichts davon ist vorgefertigt. Der Agent rief get_ar_aging auf, erhielt aktuelle Daten zurück und verarbeitete sie. Die Antwort weist auf bestimmte Kunden hin, schätzt die Auswirkungen der Verzögerung auf den Cashflow ein und schlägt vor, Mahnbescheide auszustellen. Das kann er tun, weil es ein Tool namens draft_collection_notice gibt und der Agent erkennt, wann es angebracht ist.
Warum das kein einfacher RAG-Bot ist
Was es von einem Such-Chatbot unterscheidet, ist, dass der Agent seine Werkzeuge selbst auswählt. Wenn gefragt wird: „Welches ist mein größte Cashflow-Risiko?“, führt er nicht alle Prozesse aus. Er überlegt sich die Frage und wählt die relevanten Werkzeuge aus. Der ReAct-Zyklus sieht so aus:
Für diese Frage überspringt er get_invoices und get_profit_loss, weil sie irrelevant sind. Diese Selektivität sorgt dafür, dass die Antworten fokussiert bleiben und das Kontextfenster nicht mit unnötigen Daten gefüllt wird.
Das Dashboard
Das Dashboard nutzt dieselben QuickBooks-Werkzeuge wie der Agent. Jede Zahl ist aktuell, da es keine Caching-Schicht zwischen Benutzer und QuickBooks gibt; daher werden der Fälligkeitsverlauf und die Liste der wichtigsten überfälligen Rechnungen bei jedem Seitenaufruf aktualisiert.
Die AR-Aging-Seite geht über den Standard-QuickBooks-Bericht hinaus. Sie berechnet den DSO in Echtzeit, weist jedem Zeitraum ein Risikoniveau zu und listet jede Rechnung zusammen mit der genauen Anzahl der überfälligen Tage auf. Die Zahlen stammen von derselben fetch_ar_aging-Funktion, die vom Agenten aufgerufen wird, wodurch es eine einzige Quelle der Wahrheit gibt.
Designentscheidungen, die erwähnt werden sollten
- Lese-only OAuth. Der angeforderte Zugriffsbereich enthält keine Schreibrechte, sodass die App strukturell keine QuickBooks-Daten ändern kann.
user_idaus dem JWT. Der/ask-Endpunkt vertraut auf das geprüfte Token und nicht auf etwas, was der Client sendet.- Es werden keine finanziellen Daten gespeichert. Die QuickBooks-Zahlen werden nur bei Bedarf abgerufen und danach wieder verworfen. Nur Benutzer, Token und Zeitpläne befinden sich in der Datenbank.
- Während der Entwicklung wird ein lokales LLM verwendet. Keine finanziellen Daten wurden während der Entwicklung und Tests an externe KI-Anbieter übermittelt.
Die praktischen Vorteile des Lokalverbleibs im Vergleich zu einem cloud-first-Ansatz:
Der Kostenfaktor ist die Latenz – etwa 60 Sekunden bei der 4070 im Vergleich zu weniger als 5 Sekunden mit einer Cloud-API. Für ein Tool, dessen Nutzer fundierte Antworten erwarten, ist das in der Entwicklung und im Testing in Ordnung, und der Wechsel zu einem Cloud-Modell für die Produktion ist unkompliziert.
Wie lange es gedauert hat
Arbeit, die normalerweise mehrere Wochen in Anspruch nehmen würde, wurde auf wenige Tage reduziert. Eine grobe Zeitleiste für einen einzelnen Entwickler, der die ganze Zeit über KI-gestützte Programmierhilfen nutzt:
Das Kernsystem war nach drei bis vier Tagen im Betrieb, plus ein weiterer Tag für Feinabstimmung und Tests. Drei Aspekte verbrauchten die meisten Stunden: Unregelmäßigkeiten in der QuickBooks-API (die Darstellung des Altersberichts ist alles andere als intuitiv), die Anpassung des LangChain-Agenten (Docstrings zu den Tools sind wichtiger, als man erwarten würde), sowie die korrekte Übermittlung des user_id bei jedem Toolaufruf zur Unterstützung mehrerer Nutzer. Die Geschwindigkeit wurde hauptsächlich durch die eingebauten Validierungen von FastAPI, die Abstraktion der Anbieter durch LangChain sowie die KI-gestützte Codehilfe bei Standardcode, Fehlerbehebung und Architekturfragen erreicht.
Was sollte sich ändern
Mehrere Dinge würden bei einer zweiten Durchführung anders angegangen werden:
- Die Tools und den Agenten sollten von Anfang an miteinander verbunden werden. Die Datenschicht wurde zuerst fertiggestellt und anschließend mit dem Agenten verbunden, wodurch Interfacedissonanzen erst spät auftauchten.
Bekannte Einschränkungen
Jede ehrliche Beschreibung sollte angeben, was noch unvollendet ist. Hier sind die Faktoren, die die aktuelle Version von einer größeren Skalierung abhalten, sowie wie jede Lücke geschlossen werden könnte.
In-Memory-Historie
Die Konversationshistorie wird in einem einfachen Python-Dictionary im RAM gespeichert:
Das ist in Ordnung für einen Entwicklungsserver, hat aber drei Probleme im Produktivbetrieb. Der Historieverlauf verschwindet bei jedem Neustart. Es funktioniert nicht über mehrere Serverinstanzen, was eine horizontale Skalierung ausschließt. Zudem bleibt nichts zwischen den Sitzungen erhalten, sodass das Schließen des Browsers das Gespräch beendet.
Die Lösung im Produktivbetrieb besteht darin, die Historie in Redis zu übertragen, um schnelle Lese- und Schreibvorgänge mit TTL-Ablauf zu ermöglichen, oder in PostgreSQL für vollständige Persistenz. LangChains Abstraktionen für den Nachrichtenverlauf unterstützen beide Optionen, wodurch der Wechsel einfach ist.
Absichtlich kein RAG
Das System verwendet keine Abrufmechanismen: weder Embeddings noch Vektordatenbanken noch eine Aufteilung in Blöcke. Das ist beabsichtigt. Finanzdaten ändern sich ständig – Rechnungen werden bezahlt, neue entstehen und Kontostände verschieben sich – daher können Embeddings, die gestern erstellt wurden, heute bereits veraltet sein. Das Herunterladen aktueller Daten aus QuickBooks bei jeder Anfrage stellt sicher, dass die Antworten den aktuellen Zustand des Unternehmens widerspiegeln.
RAG eignet sich für statische oder langsam verändernde Materialien wie Verträge, Richtlinien und historische Berichte. Für aktuelle Transaktionsdaten sind direkte Toolaufrufe in Bezug auf Genauigkeit besser als Abrufmechanismen mit Embeddings.
Die Kosten entstehen durch Latenz und Tokenverbrauch, da frische Datenabrufe langsamer sind und mehr Kontext liefern als eine kompakte Zusammenfassung aus einer Vektordatenbank. Bei aktuellen Volumina von zehn bis einigen hundert Rechnungen pro Unternehmen ist das akzeptabel. In größeren Skalen macht ein Hybridansatz Sinn – RAG für historische Daten und aktuelle Tools für aktuelle Daten.
Hohes Volumen an Rechnungen
Derzeit werden alle Rechnungen vollständig in den Kontext des Agenten eingegeben. Bei 30 bis 50 Rechnungen funktioniert das gut. Bei Tausenden wird jedoch die Token-Limit erreicht und die Qualität sinkt. Die Lösung besteht darin, vor dem Zugriff des Modells zu filtern: Nur Rechnungen heranzuziehen, die zur Frage relevant sind, große Datensätze zunächst zusammenzufassen oder zwei Schritte zu verwenden, bei denen eine schnelle Abfragephase den Umfang vor Beginn der Analyse einschränkt.
Noch kein Streaming
Der Server gibt die gesamte Antwort auf einmal zurück, wodurch Nutzer mit lokaler GPU etwa eine Minute lang auf einem leeren Bildschirm sitzen. FastAPI bietet StreamingResponse, Ollama kann Tokens über seine API streamen und LangChain-Agenten unterstützen Streaming-Callbacks. Die Verbindung dieser Funktionen ist einfach, wurde jedoch aufgeschoben, um die Kernfunktionen erst zum Laufen zu bringen – dies hat Vorrang vor dem Release.
Sitzungen, die nicht lange andauern
Die session_id wird im Frontend erstellt, wenn die Chat-Seite geladen wird. Ein neues Tab-Fenster, ein Neuladen oder ein Zurückkehren nach Schließen des Browsers erzeugt eine neue session_id und startet den Verlauf von vorn, selbst wenn der Server nie neu gestartet wurde. Ein Finanzassistent könnte viel von persistenter Speicherung profitieren – beispielsweise indem er sich merkt, dass ein Kunde einen Ratenzahlungsplan hat oder dass letzte Woche eine Rechnung besprochen wurde. Um dies richtig umzusetzen, müssen Sessions in der Datenbank gespeichert werden und die Nutzer sollen in der Lage sein, frühere Gespräche zu benennen, fortzusetzen oder zu löschen.
Fazit
LangChains Abstraktion, Ollamas lokale Inferenzfähigkeit sowie QuickBooks Datenzugriff ergeben zusammen etwas Nützlicheres als jede einzelne Komponente für sich. Der Agent zieht nicht nur Daten ab, sondern analysiert sie, wählt das Wichtige aus und antwortet im Kontext der Finanzen jedes einzelnen Nutzers.
Die Nutzung lokaler Lösungen ist kein Kompromiss. Es handelt sich um eine legitime Entwicklungsstrategie mit echten Vorteilen hinsichtlich Privatsphäre, Kosten und Iterationsgeschwindigkeit. Ein Verbraucher-GPU wie die RTX 4070 kann leistungsstarke 9B-Modelle ausführen. Für alle, die eine künstliche-intelligenzgestützte Anwendung planen, lautet die wichtigere Frage nicht, welchen Cloud-Anbieter man wählt, sondern ob leistungsstarke Modelle während der Entwicklung lokal ausgeführt werden können. So kann das Produkt ohne Berechnungskosten validiert werden, und die Entscheidung bezüglich des Anbieters kann bis zum Eintreffen echter Nutzungsdaten aufgeschoben werden. Mit einer RTX 4070 lautet die Antwort ja.