Strona główna / Artykuły

Agent QuickBooks Finance działający na lokalnym modelu językowym przy użyciu Ollamy i LangChain

Agent ReAct na Qwen 3.5 9B oraz RTX 4070 odczytuje dane z QuickBooks w czasie rzeczywistym za pośrednictwem LangChain i Ollamy, lokalnego modelu językowego bez żadnych kosztów chmurowych. Kod, wyniki i ograniczenia.

Wydawca: REACTAPP.TOP13 min czytania2879 słów

Źródła: **Building an Agentic AI App With a Local LLM — No Cloud, No API Costs**

Oto asystent finansowy, który łączy się z QuickBooks Online, pobiera aktualne dane księgowe firmy i umożliwia właścicielowi rozmowę z sztuczną inteligencją na temat rzeczywistych wartości: prawdziwych faktur, rzeczywistego przepływu pieniędzy, aktualnego stanu należności – i to bez żadnych danych demonstracyjnych. Model działa w całości na laptopie; karta NVIDIA GeForce RTX 4070 z modelem Qwen 3.5 9B dostępnym przez Ollama wykonuje całe obliczenia, więc podczas rozwoju nie ma żadnych połączeń z OpenAI ani Anthropic, brakuje też obliczeń w chmurze oraz rachunków za korzystanie z AI.

Następna część dotyczy rzeczywistego kodu, wyborów architektonicznych oraz tego, co generuje asystent, a nie tylko teorii.

Problem, który rozwiązuje

Właściciel małej firmy korzystający z QuickBooks ma wszystkie dane finansowe w jednym miejscu, ale przekształcenie ich w praktyczne informacje wymaga albo umiejętności księgowych, albo płatnej pomocy profesjonalisty. Standardowe raporty pokazują, co się wydarzyło, ale nie wskazują, co robić dalej.

Celem było umożliwienie komuś wpisania prostego pytania w języku angielskim dotyczącego jego własnych finansów i otrzymania konkretnej, opartej na danych odpowiedzi: numerów faktur, nazw klientów oraz rzeczywistej sytuacji finansowej, a nie ogólnych rad.

Architektura

System składa się z trzech warstw:

  • Frontend: Next.js 14 z TypeScript i shadcn/ui
  • Backend: FastAPI, PostgreSQL i SQLAlchemy
  • SZT: agent LangChain ReAct, Qwen 3.5 9B przez Ollama oraz zestaw narzędzi QuickBooks

Najważniejszą decyzją było zbudowanie warstwy SZT na bazie frameworku agentów LangChain. Dzięki temu model może sam decydować, jakie dane pobrać przed udzieleniem odpowiedzi, i właśnie dlatego odpowiedzi są konkretne, a nie ogólne.

Ruch na GPU laptopa

Celowa warstwa SZT działa na GPU laptopa konsumenckiego, modelu RTX 4070 z 8 GB pamięci VRAM. Poniżej przedstawiono uzyskane wartości.

Odpowiedź przychodzi w ciągu około minuty. W przypadku analiz finansowych jest to akceptowalne, ponieważ osoby zadające istotne pytania oczekują dokładności, a nie żartów od chatbota. W środowisku produkcyjnym skierowanie abstrakcji LangChain na usługodawcę inferencji w chmurze skraca ten czas do mniej niż pięciu sekund, przy czym kod aplikacji pozostaje nietknięty.

Trzy powody sprawiły, że lokalne rozwiązanie okazało się najlepszym wyborem do rozwoju:

  • Nie kosztuje nic. Każda modyfikacja promptu, sesja debugowania oraz test przypadków krawędziowych przeprowadzane są bez żadnych wydatków i bez ograniczeń szybkości.
  • Zapewnia prywatność danych. Dane z QuickBooks nigdy nie opuszczają maszyny, więc żadna kwota faktury, nazwa klienta ani liczba przepływów pieniężnych nie trafia do usługodawcy AI zewnętrznego.
  • Dowodzi możliwości. Jeśli model z 9 miliardami parametrów na karcie graficznej konsumenckiej może zapewnić jakość wyników pokazanych później, to wymagania sprzętowe do tworzenia lokalnych aplikacji AI są niższe, niż przypuszczają wielu programistów.

Ponieważ LangChain znajduje się pomiędzy nimi, proces rozwijania oraz produkcja korzystają z tej samej bazy kodu. Zmiana backendu do wykonywania zadań wymaga zaledwie dwóch linijek kodu:

Narzędzia, pamięć, logika agenta oraz prompty pozostają bez zmian. Model jest jedynie szczegółem implementacji.

Agent

Szczegóły związane z obsługą AI opierają się na frameworku ReAct od LangChain. Agent posiada zbiór narzędzi, którymi są proste funkcje w Pythonie służące do pobierania aktualnych danych z QuickBooks, i decyduje, które z nich wywołać w zależności od pytania użytkownika. Oto pełna konfiguracja z pliku 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.'
    )
)

Dwie kwestie wymagają uwagi. Po pierwsze, user_id jest przekazywany przy każdym wywołaniu narzędzia za pośrednictwem RunnableConfig z LangChain, co sprawia, że system jest wielo użytkownikowy – agent każdego użytkownika ma dostęp tylko do jego danych w QuickBooks. Po drugie, agent faktycznie czyta dokumentację narzędzi podczas decydowania o tym, co wywołać, więc powinna ona być napisana jako instrukcje dla modelu, a nie jako opisy przeznaczone dla ludzi.

Historia rozmowy

Każda sesja przechowuje własną historię, dzięki czemu kolejne pytania mogą opierać się na wcześniejszych odpowiedziach:

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

Użytkownik może zapytać „Jaki jest mój DSO?” a następnie „Którzy klienci to powodują?”, a agent rozumie, co oznacza „to”. Historia jest identyfikowana za pomocą session_id, dzięki czemu użytkownicy działający jednocześnie mają oddzielne rozmowy.

Koniec punktu czatu

Punkt końcowy FastAPI odpowiadający za czat jest prosty. Kluczowym elementem jest to, że user_id pochodzi z zweryfikowanego JWT, a nie z ciała żądania, co uniemożliwia dostęp do danych innego użytkownika poprzez modyfikację parametru:

@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
    }

Odnowa tokenów

Klient QuickBooks automatycznie odnawia tokeny OAuth. Wygasają one po 60 minutach, dlatego klient sprawdza ich termin ważności przed każdym żądaniem i odnawia je w razie potrzeby:

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
    )

Odnawianie po 55 minutach zamiast po 60 daje zapas na ewentualne odchylenia czasowe. Odnowa następuje po stronie serwera, a token jest przechowywany w PostgreSQL, nigdy na stronie klienta.

Co generuje agent

To jest ta istotna część. Oto rzeczywisty wynik dla pytania „Jaki jest mój DSO i czy jest w dobrym stanie?“, przeprowadzonego na danych z środowiska testowego QuickBooks:

Nic tutaj nie jest przygotowane z góry. Agent wywołał funkcję get_ar_aging, otrzymał aktualne dane i je przetworzył. Odpowiedź wskazuje na konkretnych klientów, ocenia wpływ opóźnień na przepływy pieniężne i sugeruje sporządzenie zawiadomień o windykacji. Może to zrobić, ponieważ istnieje narzędzie draft_collection_notice, a agent potrafi ocenić, kiedy jest to stosowne.

Dlaczego to nie jest zwykły bot RAG

To, co odróżnia go od chatbota do wyszukiwania informacji, to fakt, że agent sam wybiera swoje narzędzia. Gdy zostanie zapytany „Jaki jest mój największy ryzyko w zakresie przepływów pieniężnych?”, nie uruchamia wszystkich funkcji. Analizuje pytanie i wybiera te istotne. Pętla ReAct wygląda w ten sposób:

Dla tego pytania pomija funkcje get_invoices i get_profit_loss, ponieważ są one nieistotne. Ta selektywność sprawia, że odpowiedzi są skoncentrowane i zapobiega wypełnianiu okna kontekstowego niepotrzebnymi danymi.

Panel sterowania

Panel sterowania korzysta z tych samych narzędzi QuickBooks, których używa agent. Wszystkie dane są aktualne – nie ma warstwy cache pomiędzy użytkownikiem a QuickBooks – więc wykres wieku faktur oraz lista najważniejszych przeterminowanych faktur aktualizują się przy każdym załadowaniu strony.

Strona pokazująca wiek faktur w technologii AR wykracza poza standardowy raport QuickBooks. Oblicza ona DSO w czasie rzeczywistym, przypisuje poziom ryzyka do każdej kategorii oraz wyświetla wszystkie faktury wraz z dokładną liczbą dni opóźnienia. Dane pochodzą z tej samej funkcji fetch_ar_aging, którą wykorzystuje agent, co zapewnia jedno źródło prawdy.

Decyzje projektowe warte uwagi

  • Tylko odczytowy OAuth. Zapytany zakres nie zawiera uprawnień do zapisu, więc aplikacja strukturalnie nie może modyfikować danych w QuickBooks.
  • user_id z tokena JWT. Koniec punktu dostępu /ask ufa zweryfikowanemu tokenowi, a nie żadnym innym danym wysyłanym przez klienta.
  • Nic finansowego nie jest przechowywane. Dane z QuickBooks są pobierane w razie potrzeby i następnie usuwane. W bazie danych znajdują się jedynie użytkownicy, tokeny oraz harmonogramy.
  • Podczas tworzenia używano lokalnego LLM. Żadne dane finansowe nie trafiły do żadnego zewnętrznego dostawcy AI podczas procesu tworzenia i testowania.

Praktyczne zalety pozostania lokalnie w porównaniu z podejściem opartym wyłącznie na chmurze:

Kosztem jest opóźnienie – około 60 sekund przy użyciu karty 4070 w porównaniu z mniej niż 5 sekundami przy API chmurowym. Dla narzędzia, którego użytkownicy oczekują konkretnych odpowiedzi, jest to akceptowalne podczas rozwoju i testowania, a przejście na model chmurowy w środowisku produkcyjnym jest proste.

Ile to zajęło czasu

Praca, która zwykle zajęłaby kilka tygodni, skróciła się do zaledwie kilku dni. Oryginalny harmonogram dla samodzielnego programisty korzystającego przez cały czas z pomocy AI do programowania:

Podstawowy system zaczął działać po trzech do czterech dniach, plus jeszcze jeden dzień na dopracowanie i testy. Najwięcej czasu pochłonęły trzy kwestie: nieprawidłowości w API QuickBooks (format raportu o stanie zaległości jest daleki od intuicyjnego), dostosowywanie agenta LangChain (docstrings narzędzi mają większe znaczenie, niż się spodziewano), oraz prawidłowe przenoszenie wartości user_id przy każdym wywołaniu narzędzia w celu obsługi wielu użytkowników. Szybkość realizacji projektu wynikała głównie z wbudowanej funkcji walidacji FastAPI, abstrakcji dostawców w LangChain oraz pomocy AI przy pisaniu standardowych kodów, debugowaniu błędów i rozwiązywaniu problemów z architekturą.

Co powinno się zmienić

Przy drugiej próbie kilka rzeczy zostałoby zrobiono inaczej:

  • Narzędzia i agent powinny być połączone od samego początku. Warstwa danych została ukończona najpierw, a dopiero potem połączona z agentem, przez co problemy z interfejsem pojawiły się późno.
  • Wspomnienia prototypu w modelu 3B. Model 9B Qwen oferuje lepszą jakość, ale działa powoli na tym GPU, więc mały model do szybkich eksperymentów z wspomnieniami oraz model 9B do końcowych sprawdzeń pozwoliłyby zaoszczędzić godziny.
  • Strumieniowanie od samego początku. Czekanie minuty bez żadnego tokena na ekranie jest nie do zniesienia, a funkcja strumieniowania tokenów powinna być już w wymaganiach od pierwszego dnia.
  • Próba narzędzi QuickBooks. Przypadki krawędziowe pojawiają się tylko w rzeczywistych danych, a mały zestaw testów na danych z środowiska sandbox zapobiegłby kilku długim sesjom debugowania.
  • Znane ograniczenia

    Każdy uczciwy opis powinien wskazać, co jest niedokończone. Oto czego brakuje obecnej wersji, by mogła się rozwijać, oraz jak można by zamknąć każdą z tych luk.

    Historia w pamięci

    Historia rozmów znajduje się w zwykłym słowniku Pythona w pamięci RAM:

    To jest w porządku dla jednego serwera rozwojowego, ale ma trzy problemy w środowisku produkcyjnym. Historia zanika przy każdym restartie. Nie może funkcjonować na kilku instancjach serwera, co wyklucza skalowanie poziome. Ponadto nic nie zostaje przechowane między sesjami, więc zamknięcie przeglądarki kończy całą komunikację.

    Rozwiązaniem w środowisku produkcyjnym jest przeniesienie historii do Redis, aby umożliwić szybkie odczyty i zapisy z terminem ważności TTL, lub do PostgreSQL dla pełnej trwałości danych. Abstrakcje historii wiadomości w LangChain obsługują oba rozwiązania, więc zmiana jest prosta.

    Brak RAG – celowo

    System nie wykorzystuje żadnych mechanizmów wyszukiwania: ani embeddingów, ani bazy danych wektorowych, ani procesów dzielenia na fragmenty. To celowe rozwiązanie. Dane finansowe stale się zmieniają – faktury są wypłacane, pojawiają się nowe, a salda ulegają zmianie, więc embeddingi stworzone wczoraj mogą być dziś przestarzałe. Pobieranie aktualnych danych z QuickBooks przy każdej prośbie gwarantuje, że odpowiedzi odzwierciedlają sytuację biznesu w jej obecnym stanie.

    RAG nadaje się do materiałów statycznych lub wolno zmieniających się, takich jak umowy, polisy i raporty historyczne. W przypadku aktualnych danych transakcyjnych bezpośrednie wywołania narzędzi są dokładniejsze od wyszukiwania za pomocą embeddingów.

    Cena zależy od opóźnień i ilości użytych tokenów, ponieważ nowe pobierania są wolniejsze i dostarczają większy zakres kontekstu niż zwięzły podsumowanie z bazy danych wektorowej. Przy obecnych objętościach – od kilkudziesięciu do niewielu setek faktur na firmę – jest to akceptowalne. W większych skali sensowne jest rozwiązanie hybrydowe, z RAG do danych historycznych i narzędziami real-time do aktualnych danych.

    Wielka liczba faktur

    Obecnie wszystkie faktury trafiają w całości do kontekstu agenta. Przy 30 do 50 faktur to działa dobrze. Przy tysiącach występują ograniczenia liczby tokenów i spada jakość. Rozwiązaniem jest filtrowanie przed tym, jak model cokolwiek zobaczy: pobieranie tylko tych faktur, które są istotne dla pytania, najpierw streszczanie dużych zbiorów danych lub używanie dwóch etapów, w których szybki krok wyszukiwania zawęża zbiór przed rozpoczęciem rozumowania.

    Jeszcze bez transmisji strumieniowej

    Server zwraca całą odpowiedź od razu, więc użytkownicy na lokalnej karcie graficznej muszą patrzeć na pusty ekran przez około minutę. FastAPI oferuje StreamingResponse, Ollama może przesyłać tokeny przez swoją API, a agenci LangChain obsługują powroty strumieniowe. Połączenie ich jest proste, ale zostało odłożone, aby najpierw zadziałała podstawowa funkcjonalność, i jest to priorytet przed uruchomieniem.

    Sezje, które nie trwają długo

    session_id jest tworzony na stronie klienta, gdy ładowana jest strona czatu. Nowa karta, odświeżenie lub powrót po zamknięciu przeglądarki tworzy nowy identyfikator, a historia rozmów zaczyna się od nowa, nawet jeśli serwer nigdy nie został restartowany. Asystent finansowy wiele zyskałby dzięki pamięci trwałej – na przykład dzięki możliwości zapamiętywania, że klient korzysta z planu płatności lub że w zeszłym tygodniu omawiano fakturę. Prawidłowe realizowanie tego wymaga przechowywania sesji w bazie danych oraz umożliwiania użytkownikom nadawania nazw, kontynuowania i usuwania wcześniejszych rozmów.

    Wniosek

    Abstrakcja oferowana przez LangChain, lokalne przetwarzanie danych przez Ollamę oraz dostęp do informacji w QuickBooks łączą się, tworząc coś bardziej przydatnego niż pojedynczy z tych elementów. Agent nie tylko pobiera dane – analizuje je, wybiera to, co istotne, i odpowiada z uwzględnieniem indywidualnej sytuacji finansowej każdego użytkownika.

    Praca lokalnie nie jest kompromisem. To prawomocna strategia rozwoju oferująca rzeczywiste korzyści pod względem prywatności, kosztów i możliwości iteracji, a karta graficzna konsumencka taka jak RTX 4070 potrafi uruchamiać modele o wielkości 9 miliardów parametrów. Dla każdego, kto planuje aplikację opartą na sztucznej inteligencji, lepszym pytaniem nie jest to, który dostawca chmury wybrać. Pytanie brzmi raczej, czy modele o odpowiednich możliwościach mogą być uruchamiane lokalnie podczas rozwoju, aby produkt mógł zostać zweryfikowany bez żadnych kosztów obliczeniowych, a decyzja o wyborze dostawcy mogła zostać podjęta dopiero po uzyskaniu rzeczywistych danych o użyciu. Na karcie RTX 4070 odpowiedź brzmi tak.