Главная / Статьи

Агент QuickBooks Finance, работающий на локальном LLM с использованием 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 ГБ видеопамяти. Приведены ниже полученные показатели.

Ответ появляется примерно за минуту. Для финансового анализа это приемлемо, поскольку те, кто задаёт содержательные вопросы, ожидают тщательности, а не шуток от чат-бота. В производственных условиях использование абстракции LangChain с облачным провайдером инференса сокращает время до пяти секунд, причём код приложения остаётся нетронутым.

Три причины сделали использование локальных решений оптимальным вариантом для разработки:

  • Это не стоит ничего. Каждая корректировка запроса, сессия отладки и тест на крайние случаи выполняются без затрат и без ограничений по количеству запросов.
  • Это обеспечивает конфиденциальность данных. Данные из QuickBooks никогда не покидают устройство, поэтому ни сумма счёта, ни имя клиента, ни показатели денежных потоков не попадают к стороннему провайдеру ИИ.
  • Это демонстрирует возможности. Если модель с 9 миллиардами параметров на потребительской GPU может обеспечить качество вывода, показанное далее, значит, требования к аппаратному обеспечению для создания локальных ИИ-приложений ниже, чем предполагают многие разработчики.

Поскольку 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, которую вызывает агент, что обеспечивает единый источник информации.

Решения по дизайну, заслуживающие внимания

  • Tолько чтение через OAuth. Запрошенный диапазон полномочий не включает прав на запись, поэтому приложение по своей структуре не может изменять данные QuickBooks.
  • user_id из JWT. Конечная точка /ask доверяет проверенному токену, а не любым другим данным, отправляемым клиентом.
  • Никаких финансовых данных не хранится. Цифры из QuickBooks запрашиваются по мере необходимости и сразу удаляются. В базе данных хранятся только пользователи, токены и расписания.
  • Во время разработки используется локальный LLM. Никакие финансовые данные не передавались во внешние сервисы искусственного интеллекта во время разработки и тестирования.

Практические преимущества использования локальных решений по сравнению с подходом, ориентированным на облака:

Затратой является задержка: около 60 секунд при использовании карты 4070 против менее 5 секунд при работе с API облака. Для инструмента, пользователи которого ожидают конкретных ответов, это приемлемо на этапах разработки и тестирования, а переход на облачную модель для производственного использования — дело простое.

Сколько времени это заняло

Работа, которая обычно занимает несколько недель, сократилась до нескольких дней. Примерный график для одиночного разработчика, использующего помощь ИИ в написании кода на протяжении всего процесса:

Основная система заработала через три-четыре дня, плюс ещё один день на отладку и тестирование. Больше всего времени заняли три вещи: необычности в API QuickBooks (формат отчета о старении данных совсем не интуитивен), настройка агента LangChain (документация к инструментам оказалась важнее, чем ожидалось), и правильная передача значения user_id при каждом вызове инструмента для реализации многоклиентской архитектуры. Ускорение работы было достигнуто в основном благодаря встроенной проверке данных в FastAPI, абстракции провайдеров в LangChain и помощи ИИ при написании шаблонного кода, отладке ошибок и решении вопросов архитектуры.

Что следует изменить

При повторной работе над проектом несколько моментов было бы сделано иначе:

  • С самого начала необходимо было связать инструменты с агентом. Сначала был завершен слой обработки данных, а затем он был подключен к агенту, из-за чего проблемы с интерфейсом проявились поздно.
  • Прототипы запросов на модели объемом 3 млрд параметров. Модель Qwen объемом 9 млрд параметров обеспечивает лучшее качество, но работает медленно на этой видеокарте; поэтому использование небольшой модели для быстрых экспериментов с запросами и 9-миллиардной модели для окончательной проверки позволило бы сэкономить часы.
  • Трансляция результатов с самого начала. Ожидание минуту без единого токена на экране крайне неприятно, и функция потоковой передачи токенов должна была быть включена уже с первого дня.
  • Тестирование инструментов QuickBooks. Особые случаи проявляются только с реальными данными, и небольшой набор тестов на данных из среды разработки позволил бы избежать множества длительных сеансов отладки.
  • Известные ограничения

    Любой честный отчет должен указывать, что еще не завершено. Вот что сдерживает текущую версию от масштабирования и как можно преодолеть каждый из этих недостатков.

    История в памяти

    История разговоров хранится в обычном словаре Python в оперативной памяти:

    Это подходит для одного сервера разработки, но имеет три проблемы в производственной среде. История диалогов исчезает при каждой перезагрузке. Он не может работать с несколькими экземплярами сервера, что исключает возможность горизонтального масштабирования. Кроме того, ничего не сохраняется между сессиями, поэтому закрытие браузера прерывает весь диалог.

    Решение для производственной среды — перенести историю в Redis для быстрого чтения и записи с временем истечения TTL, или в PostgreSQL для полной сохранности данных. Абстракции истории сообщений в LangChain поддерживают оба варианта, поэтому замена происходит легко.

    Отсутствие RAG — намеренно

    Система не использует механизмы поиска: нет эмбеддингов, нет векторной базы данных, нет процесса разбиения на чанки. Это сделано намеренно. Финансовые данные постоянно меняются: оплачиваются счета-фактуры, появляются новые, меняются остатки, поэтому эмбеддинги, созданные вчера, сегодня могут быть устаревшими. Получение актуальных данных из QuickBooks при каждом запросе гарантирует, что ответы отражают текущее состояние бизнеса.

    RAG подходит для статических или медленно меняющихся материалов, таких как контракты, положения страховых полисов и исторические отчеты. Для актуальных транзакционных данных прямые вызовы инструментов обеспечивают более высокую точность по сравнению с использованием эмбеддингов для поиска.

    Цена связана с задержками и использованием токенов, поскольку новое получение данных происходит медленнее и заполняет больший объем контекста, чем компактное резюме из векторного хранилища. При текущем объеме в десятки-несколько сотен счетов-фактур в месяц это приемлемо. При более крупных масштабах целесообразно использовать гибридный подход: RAG для исторических данных и прямые инструменты для актуальных данных.

    Большое количество счетов-фактур

    В настоящее время все счета-фактуры передаются в контекст агента полностью. При 30–50 счетах-фактурах это работает хорошо. Но при тысячах счетов-фактур наступают ограничения по количеству токенов, что снижает качество ответа. Решением является фильтрация перед тем, как модель увидит данные: необходимо загружать только те счета-фактуры, которые связаны с вопросом, сначала обрабатывать крупные наборы данных в виде кратких резюме или использовать двухэтапный подход, при котором на первом этапе происходит быстрый поиск, сужающий объем данных перед началом логических вычислений.

    Пока нет стриминга

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

    Сессии, которые не длительны

    session_id генерируется на фронтенде при загрузке страницы чата. Новая вкладка, обновление или возврат после закрытия браузера создают новый session_id, и история переговоров сбрасывается заново, даже если сервер так и не перезагружался. Финансовому ассистенту было бы очень полезно использовать постоянную память, позволяющую запоминать, что клиент использует план оплаты или что инвойс обсуждался на прошлой неделе. Для этого необходимо хранить информацию о сессиях в базе данных и предоставлять пользователям возможность присваивать названия прошлым разговорам, возобновлять их и удалять.

    Вывод

    Абстракция LangChain, возможности локальной обработки данных Ollama и доступ к данным в QuickBooks в совокупности дают результат, более полезный, чем любой из этих компонентов по отдельности. Агент не просто извлекает данные — он анализирует их, выбирает важную информацию и отвечает с учетом финансового положения конкретного пользователя.

    Работа в локальной среде — это не компромисс. Это законная стратегия разработки, обеспечивающая реальные преимущества с точки зрения конфиденциальности, затрат и возможностей для постепенных улучшений. Графические процессоры для потребителей, такие как RTX 4070, способны запускать модели объемом 9 миллиардов параметров. Для тех, кто планирует создать приложение на основе ИИ, более важный вопрос заключается не в том, какой облачный провайдер выбрать, а в том, можно ли запускать функциональные модели локально во время разработки, чтобы продукт мог быть проверен без каких-либо затрат на обработку данных, а решение о выборе провайдера отложить до появления реальных данных использования. На RTX 4070 ответ — да.