Головна / Статті

Агент QuickBooks Finance, який працює на локальній моделі ШІ за допомогою Ollama та LangChain.

Агент ReAct на Qwen 3.5 9B та RTX 4070 читає дані QuickBooks в реальному часі через LangChain та Ollama — локальну модель ШІ без жодних витрат на хмарні послуги. Код, результати та обмеження.

Видавець: REACTAPP.TOP13 хв читання2879 слів

Джерела: **Building an Agentic AI App With a Local LLM — No Cloud, No API Costs**

Ось асистент з фінансів, який підключається до QuickBooks Online, отримує актуальні дані бізнесу та дозволяє власнику спілкуватися з ШІ щодо реальних показників: справжніх рахунків-фактур, справжнього грошового потоку, інформації про терміни отримання боргів — без жодних демонстраційних даних. Модель працює повністю на ноутбуці. NVIDIA GeForce RTX 4070 із моделлю Qwen 3.5 9B, яка запускається через Ollama, виконує всі обчислення, тому під час розробки немає потреби у зв’язку з OpenAI чи Anthropic, немає обробки даних у хмарі та жодних рахунків за послуги ШІ.

У наступному описано справжній код, вибір архітектури та результати роботи асистента, а не лише теорію.

Проблема, яку він вирішує

Власник малого бізнесу в QuickBooks має всі фінансові дані в одному місці, але для отримання корисних висновків потрібні або професійні навички бухгалтера, або платні послуги фахівця. Стандартні звіти вказують на те, що сталось, але не кажуть, що робити далі.

Метою було дозволити користувачеві ввести просте запитання англійською мовою щодо власних фінансів та отримати конкретну, підтверджену даними відповідь: номери рахунків-фактур, імена клієнтів та справжню суму грошей, а не загальні поради.

Архітектура

Система складається з трьох шарів:

  • Фронтенд: Next.js 14 з TypeScript та shadcn/ui
  • Бекенд: FastAPI, PostgreSQL та SQLAlchemy
  • Штучний інтелект: агент LangChain ReAct, Qwen 3.5 9B через Ollama та набір інструментів QuickBooks

Найважливішим рішенням було створення шару ШІ на основі фреймворку агентів LangChain. Це дозволяє моделі самостійно вибирати дані, які потрібно отримати перед відповіддю, і саме тому відповіді є конкретними, а не загальними.

Робота на GPU ноутбука

Весь шар ШІ працює на GPU споживчого ноутбука — RTX 4070 з 8 ГБ VRAM. Наведені нижче цифри є результатами вимірювань.

Відповідь приходить приблизно за хвилину. Для фінансового аналізу це прийнятно, адже люди, які ставлять конкретні запитання, очікують ретельності, а не дріб’язкових розмов з чат-ботом. У продакшені використання абстракції LangChain з посиланням на хмарного провайдера інференсу скорочує час до менше ніж п’яти секунд, причому код додатку залишається незмінним.

Три причини зробили локальну реалізацію правильним вибором для розробки:

  • Це не коштує нічого. Кожна зміна запиту, сеанс виправлення помилок та тестування крайніх випадків виконуються без будь-яких витрат та обмежень щодо кількості операцій.
  • Це забезпечує приватність даних. Дані з QuickBooks ніколи не виходять за межі пристрою, тож жодна сума рахунку-фактури, ім’я клієнта чи показник грошових потоків не потрапляє до AI-провайдера третьої сторони.
  • Це доводить певну ідею. Якщо модель з 9 мільярдами параметрів на споживчому GPU може забезпечити таку ж якість результату, яка показана далі, то вимоги до обладнання для створення локальних AI-додатків нижчі, ніж вважають багато розробників.

Оскільки LangChain знаходиться посередині, процес розробки та експлуатація використовують одну й ту саму базу коду. Заміна бекенду для обробки даних вимагає лише двох рядків коду:

Інструменти, пам’ять, логіка агента та запити залишаються незмінними. Модель є лише деталлю реалізації.

Агент

Шар ШІ використовує фреймворк агентів ReAct від LangChain. Агент має набір інструментів — це звичайні функції на Python, які отримують актуальні дані з QuickBooks, — і вибирає ті з них, які потрібно викликати, залежно від запиту користувача. Ось повна налаштування з файлу 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.'
    )
)

Два моменти заслуговують уваги. По-перше, user_id передається під час кожного виклику інструменту через RunnableConfig у LangChain, що і робить систему багатокористувацькою: агент кожного користувача має доступ лише до даних QuickBooks цього користувача. По-друге, агент насправді читає документацію інструментів під час прийняття рішення про те, що викликати, тому її слід писати як інструкції для моделі, а не як описи для людей.

Історія розмови

Кожна сесія зберігає власну історію, щоб подальші запитання могли ґрунтуватися на попередніх діалогах:

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

Користувач може запитати: «Що таке мій DSO?» а потім — «Які клієнти це спричиняють?», і агент розуміє, що означає «це». Історія розмов кодується за допомогою session_id, тож кілька користувачів одночасно мають окремі розмови.

Кінцева точка чату

Кінцева точка FastAPI, яка обробляє чат, є простою. Ключова деталь полягає у тому, що user_id походить з підтвердженого JWT, а не з тіла запиту, що заважає комусь отримати дані іншого користувача шляхом зміни параметра:

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

Оновлення токенів

Клієнт QuickBooks автоматично оновлює токени OAuth. Вони закінчують свою дію через 60 хвилин, тому клієнт перевіряє термін дії токена перед кожним запитом та оновлює його за потреби:

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
    )

Оновлення через 55 хвилин замість 60 створює запас проти зсуву часу. Оновлення відбувається на серверній стороні, а токен зберігається в PostgreSQL, а не на фронтенді.

Що генерує агент

Саме це має значення. Ось фактичний результат запиту „Який мій DSO та чи є він стабільним?“, виконаного на даних сандбоксу QuickBooks:

Тут немає нічого готового. Агент викликав get_ar_aging, отримав актуальні дані та обробив їх. У відповіді наведені імена клієнтів, оцінюється вплив затримки на грошовий потік та пропонується скласти повідомлення про стягнення боргу. Він може це зробити, оскільки існує інструмент draft_collection_notice, і агент розпізнає, коли його можна використати.

Чому це не простий бот RAG

Те, що відрізняє його від чат-бота для пошуку інформації, — це те, що агент сам обирає інструменти. Коли його запитують «Який у мене найбільший ризик грошових потоків?», він не запускає все одразу. Він аналізує запитання та обирає відповідні інструменти. Ланцюг ReAct виглядає так:

Для цього запитання він пропускає get_invoices та get_profit_loss, оскільки вони не є релевантними. Така селективність дозволяє зберігати відповіді концентрованими та запобігає наповненню вікна контексту непотрібними даними.

Панель керування

Панель керування використовує ті самі інструменти QuickBooks, що й агент. Усі показники є актуальними, оскільки між користувачем та QuickBooks немає шару кешування, тому діаграма старіння та список найбільш прострочених рахунків оновлюються при кожному завантаженні сторінки.

Сторінка аналізу старіння дебіторської заборгованості виходить за межі стандартного звіту QuickBooks. Вона на льоту обчислює DSO, присвоює рівень ризику кожній групі та відображає кожну рахунок-фактуру з точною кількістю днів прострочення. Ці дані надходять від тієї самої функції fetch_ar_aging, яку використовує агент, що забезпечує єдине джерело інформації.

Рішення з дизайну, які варто зазначити

  • Tylko для читання OAuth. Запитаний діапазон не має прав на запис, тому додаток за своєю структурою не може змінювати дані QuickBooks.
  • user_id з JWT. Кінцева точка /ask довіряє перевіреному токену, а не будь-чому іншому, що надсилає клієнт.
  • Жодних фінансових даних не зберігається. Цифри з QuickBooks запитуються за потреби та видаляються після цього. У базі даних зберігаються лише користувачі, токени та графіки.
  • Під час розробки використовується локальний LLM. Жодні фінансові дані не надсилалися до сторонніх постачальників ШІ під час розробки та тестування.

Практичні переваги використання локальних рішень порівняно з підходом, орієнтованим на хмару:

Витратою є затримка — близько 60 секунд при використанні картки 4070 проти менше ніж 5 секунд з API хмари. Для інструменту, користувачі якого очікують конкретних відповідей, це прийнятно під час розробки та тестування, а перехід на хмарну модель для продакшену є досить простим.

Скільки часу це зайняло

Робота, яка зазвичай займає кілька тижнів, скоротилася до кількох днів. Орієнтовний графік для одинокого розробника, який використовує допомогу ШІ у кодуванні протягом усього процесу:

Основна система почала працювати через три–чотири дні, плюс ще один день на доопрацювання та тестування. Найбільше часу забрали три аспекти: недоліки API QuickBooks (формат звіту про старіння даних зовсім не інтуїтивний), налаштування агента LangChain (документація до інструментів виявилась важливішою, ніж очікувалося), та правильна передача значення user_id під час кожного виклику інструменту для підтримки багатьох користувачів. Швидкість роботи забезпечувалась переважно завдяки вбудованій системі верифікації FastAPI, абстракції провайдерів у LangChain та допомозі ШІ у написанні коду, виправленні помилок та вирішенні архітектурних питань.

Що потрібно змінити

Під час другої спроби кілька речей було б зроблено інакше:

  • Інструменти та агент мали б бути поєднані з самого початку. Шар даних було створено спочатку, а потім підключено до агента, тому розбіжності в інтерфейсах проявилися пізно.
  • Прототипи запитів у моделі на 3 млрд параметрів. Модель Qwen на 9 млрд параметрів забезпечує кращу якість, але працює повільно на цьому GPU, тож використання невеликої моделі для швидких експериментів з запитами та 9-мільярдної моделі для фінальної перевірки дозволило б заощадити години.
  • Трансляція з самого початку. Чекати хвилину без жодного токена на екрані — це жахливо, і функція потокової передачі токенів мала бути вимогою вже з першого дня.
  • Тестування інструментів QuickBooks. Нестандартні ситуації з’являються лише з реальними даними, а невеликий набір тестів на данах сандбоксу допоміг би уникнути кількох тривалих сеансів виправлення помилок.
  • Відомі обмеження

    Будь-який чесний опис має зазначати, що залишилося недоробленим. Ось що стримує поточну версію від масштабування та як можна подолати кожну з цих проблем.

    Історія в пам’яті

    Історія розмов зберігається у звичайному словнику Python у оперативній пам’яті:

    Це підходить для одного сервера розробки, але має три проблеми у продакшені. Історія зникає після кожної перезавантаження. Він не може працювати на кількох інстанціях серверів, що виключає горизонтальне масштабування. Крім того, нічого не зберігається між сеансами, тож закриття браузера завершує взаємодію.

    Рішенням у продакшені є переміщення історії до Redis для швидкого читання та запису з таймером закінчення терміну дії, або до PostgreSQL для повного зберігання даних. Абстракції історії повідомлень у LangChain підтримують обидва варіанти, тому заміна є простою.

    Без RAG — навмисно

    Система не використовує механізми пошуку: немає ембеддингів, немає векторної бази даних, немає процесу розділення на частини. Це робиться навмисно. Фінансові дані постійно змінюються: інвойси оплачуються, з’являються нові, змінюються баланси, тож ембеддинги, створені вчора, сьогодні можуть вже бути застарілими. Отримання актуальних даних з QuickBooks з кожним запитом гарантує, що відповіді відображатимуть ситуацію в компанії у її поточному стані.

    RAG підходить для статичних або повільно змінюваних матеріалів, таких як контракти, політики та історичні звіти. Для актуальних транзакційних даних прямі виклики інструментів є точнішими за пошук за допомогою ембеддингів.

    Ціна залежить від затримки та кількості використаних токенів, адже нові запити виконуються повільніше та заповнюють більше контексту, ніж компактний огляд з векторного сховища. При поточних обсягах — від десятків до кількох сотень інвойсів на компанію — це є прийнятним. У більших масштабах доцільний гібридний підхід: RAG для історичних даних та прямі інструменти для актуальних даних.

    Велика кількість рахунків-фактур

    Наразі всі рахунки-фактури надходять у контекст агента повністю. З 30–50 рахунками-фактурами це працює добре. Але при тисячах рахунків-фактур досягаються межі кількості токенів, що призводить до погіршення якості. Рішенням є фільтрація перед тим, як модель щось побачить: отримувати лише ті рахунки-фактури, які стосуються запитання, спочатку узагальнювати великі набори даних або використовувати два етапи, коли на початку відбувається швидке отримання інформації для звуження кола можливих варіантів перед початком обробки.

    Ще немає стрімінгу

    Сервер повертає всю відповідь одразу, тому користувачі на локальній GPU доводиться чекати близько хвилини на порожньому екрані. FastAPI пропонує StreamingResponse, Ollama може стрімувати токени через свій API, а агенти LangChain підтримують стрімінгові виклики. Підключення їх є простим, але його відклали, щоб спочатку налагодити основні функції, і це є пріоритетом перед запуском.

    Сеанси, які не тривають довго

    session_id створюється на фронтенді під час завантаження сторінки чату. Нова вкладка, оновлення або повернення після закриття браузера створює новий сеанс, і історія починається заново, навіть якщо сервер так і не перезапускався. Фінансовий асистент отримає велику користь від постійної пам’яті – наприклад, від здатності пам’ятати, що клієнт користується планом оплати чи що про рахунок-фактуру йшлося минулого тижня. Правильне використання цього означає зберігання сеансів у базі даних та надання користувачам можливості називати, відновлювати та видаляти попередні розмови.

    Висновок

    Абстракція LangChain, локальні обчислення Ollama та доступ до даних QuickBooks разом створюють щось корисніше, ніж будь-яка з цих складових окремо. Агент не просто отримує дані – він аналізує їх, обирає те, що має значення, та відповідає з урахуванням фінансової ситуації кожного користувача.

    Робота локально — це не компроміс. Це легітимна стратегія розвитку з реальними перевагами у сфері конфіденційності, витрат та можливостей для поступового вдосконалення, а споживча GPU на кшталт RTX 4070 здатна запускати моделі обсягом 9 мільярдів параметрів. Для тих, хто планує створити додаток на основі ШІ, краще запитання полягає не в тому, якого постачальника хмарних послуг обрати, а в тому, чи можна під час розробки запускати потужні моделі локально, щоб продукт міг бути перевірений без жодних витрат на обробку даних, а рішення щодо постачальника можна прийняти лише після отримання реальних даних про використання. На RTX 4070 відповідь — так.