«Как устроить одного агента LangGraph — это инструмент для разработчиков, который позволяет строить ИИ-агентов с многошаговыми сценариями и ветвлением, а не с одним разовым ответом. Полное определение →, у которого больше 80 инструментов?» Документация LangGraph показывает механику — StateGraph, ToolNode, bindTools, — но не говорит, что делать, когда каталог инструментов перестаёт помещаться в голове. Наш ответ из продакшена короткий: граф остаётся максимально простым — два узла в цикле, — а вся структура переезжает в каталог инструментов. Так работает агент eKsięgowy AI: 58 базовых инструментов и до 82 с модулями кадров и KSeF — это обязательная в Польше государственная система для выставления и получения электронных счетов напрямую через налоговую. Полное определение →, без роутера и без субагентов. Ниже рассказываем, как мы группируем инструменты, как их называем и описываем, что попадает в системный Промпт — это инструкция, которую вы даёте ИИ-модели: от её точности зависит, будет ответ полезным или общим. Полное определение →, сколько всё это занимает в контексте и по каким признакам поймём, что структуру пора менять. Обзор всего продукта — в статье Как устроен Accounting AI Agent; этот текст — инструкция, которую можно перенести на другой проект.

Короткий ответ: один граф, два узла, 82 инструмента

Граф — это StateGraph на состоянии MessagesAnnotation. Узел agent вызывает модель с привязанными инструментами (model.bindTools), а узел tools — стандартный ToolNode с тем же списком. Условное ребро проверяет одно: есть ли в последнем сообщении модели tool_calls. Если есть — идём к инструментам и возвращаемся к агенту; если нет — конец. У всего прогона recursionLimit равен 25, так что агент не уйдёт в бесконечные вызовы.

Вся структура: каталог инструментов, упорядоченный в коде, один плоский список, привязанный к модели, и цикл агент → инструменты → агент.

Список инструментов собирается на каждый запрос в одной функции, createAllTools, потому что инструменты — замыкания: каждый получает идентификатор пользователя, его язык и его подключение к wFirma. Это важно для безопасности — ни один инструмент не принимает userId в аргументах, поэтому модель не может «попросить» данные другого клиента. И важно для структуры: раз каталог всё равно собирается в коде, именно в коде его и можно упорядочить.

Почему не роутер и не supervisor

В январе 2026 года мы расписали вариант, который подсказывает интуиция: роутер и четыре специалиста — по контрагентам, финансам, счетам и налогам, — у каждого своё подмножество инструментов и свой промпт. Роутер должен был возвращать JSON с именем специалиста и уровнем уверенности. Продакшен-чат, однако, с самого начала работал на одном агенте с полным списком инструментов; код роутера так и не подключили, и в апреле мы удалили его как мёртвый.

Причина видна в самом промпте роутера. Инструкция гласила: если запрос касается двух-трёх областей, выбери доминирующую. Пример из этого промпта: «Покажи счета и посчитай НДС к уплате» → налоговый специалист. В бухгалтерии такие вопросы — норма, а у налогового специалиста не было инструментов для счетов. Роутер превращает сквозной вопрос в вопрос об одной области и вдобавок добавляет отдельный вызов модели перед каждым ответом. Плоский список позволяет модели сделать то, что сделал бы бухгалтер: взять счета за период через get_invoices, а потом посчитать.

Честно: A/B-теста двух вариантов мы не проводили. Это проектное решение, основанное на механизме, а не на бенчмарке. Роутер оправдан, когда области действительно не смешиваются или когда специалистам нужны другие модели либо другие права. У нас ни одно из этих условий не выполнялось.

Группируйте в коде, а не в графе

Раз граф не делит инструменты, их делит код. У каждого домена свой файл с фабриками: invoice.tools.ts экспортирует десять функций createXxxTool, contractor.tools.ts — четыре и так далее. Фабрика принимает зависимости (сервис wFirma, Кэш — это повторное использование неизменной части запроса: провайдеры моделей берут за неё меньше, поэтому продуманный промпт может стоить в несколько раз дешевле. Полное определение →, сервис подписки), идентификатор пользователя и язык и возвращает готовый инструмент LangChain со схемой Zod. createAllTools складывает всё в один массив, с комментарием над каждой группой.

Когда файл разрастается, мы делим его дальше, не меняя того, что видит модель. Кадры — это 15 инструментов в четырёх файлах: сотрудники (5), договоры (3), зарплата (4), отсутствия и сводка (3), — за одним сводным файлом hr.tools.ts. Для модели это по-прежнему 15 позиций в плоском списке; для разработчика — четыре файла по 170–330 строк вместо одного большого.

Опциональные группы добавляются по условию: 15 кадровых инструментов попадают в список, только если подключён сервис HR, а 9 инструментов KSeF — только если подключён сервис KSeF. Это самый дешёвый способ уменьшить каталог: инструмент, который нельзя выполнить, не должен занимать место в контексте. Два инструмента — календарь налоговых сроков и проверка счёта по Белому списку — не зависят от wFirma и доступны всегда.

ДоменИнструментовПримерыКогда в списке
Компания4get_company_info, lookup_company_by_nipвсегда
Финансы1get_financial_summaryвсегда
Контрагенты4get_contractors, create_contractorвсегда
Счета-фактуры10get_invoices, send_invoice, download_invoiceвсегда
Пользователи wFirma3get_users, get_user_companiesвсегда
Платежи5get_payments, add_paymentвсегда
Расходы3get_expenses, create_expense_from_receiptвсегда
Автомобили5get_vehicles, add_vehicleвсегда
Сроки и группы сроков10get_terms, get_term_groupsвсегда
Декларации и KPiR3get_jpk_vat_declaration, get_pit_declaration, get_tax_registersвсегда
Налоговый календарь и Белый список2get_tax_deadlines, verify_bank_account_white_listвсегда, без wFirma
Документы4get_documents, download_documentвсегда
Книга: финансовые годы и схемы4get_fiscal_years, get_accounting_schemasвсегда
Кадры и зарплата15get_employees, calculate_payroll, save_payroll_recordесли подключён сервис HR
KSeF9send_invoice_to_ksef, create_and_send_to_ksef, get_incoming_ksef_invoicesесли подключён сервис KSeF
Итого8258 базовых + 15 + 9—

Имена: глагол_существительное и пары список–детали

Все 82 имени уникальны и имеют вид глагол_существительное в snake_case: get_invoices, get_invoice_details, create_invoice, update_invoice, delete_invoice. Список и детали всегда идут парой — get_payments и get_payment_details, get_vehicles и get_vehicle_details, — поэтому модель, освоившая один домен, предсказывает имена в следующем.

Наш каталог здесь не идеален, и это стоит показать. Контрагентов и счета создаёт create_, а платежи, автомобили и сроки — add_; это след того, как модули появлялись один за другим. Есть и близкие имена: send_invoice отправляет счёт по e-mail, а send_invoice_to_ksef — в государственную систему электронных счетов. В плоском списке такие близкие пары приходится обезвреживать описанием, потому что имя само их не разведёт. Если начинаете с нуля, зафиксируйте словарь глаголов до первого инструмента, а не после пятидесятого.

Описания инструментов делают роутинг

В агенте без роутера именно описания решают, какой инструмент выберет модель. Медианная длина описания в нашем каталоге — 130 символов, самое короткое — 33 (add_invoice_note), самое длинное — 543 (verify_bank_account_white_list). Разброс намеренный: длину мы тратим там, где инструменты сталкиваются, а не поровну на все.

Лучше всего это видно на паре счетов и расходов. До апреля 2026 года описание get_invoices звучало так: «Get list of invoices from wFirma. Can filter by year, month, and payment status». Модель путала продажные счета с расходами и могла пропустить фильтр по датам, хотя пользователь назвал месяц. Исправление затронуло только описания и схему. get_invoices теперь прямо говорит, что возвращает ИСХОДЯЩИЕ счета, выставленные клиентам, а счета от поставщиков нужно брать через get_expenses; описание get_expenses говорит то же самое в обратную сторону. Оба добавляют правило: если в вопросе есть период, передавать dateFrom и dateTo обязательно, а результат без фильтра — это ошибка.

Описание может и указать инструмент, который нужно вызвать рядом. get_terms возвращает сроки, которые пользователь сам завёл в wFirma; его описание добавляет, что когда спрашивают о сроках вообще (в том числе по-русски — «сроки»), нужно вызвать и get_tax_deadlines с установленными законом сроками по VAT, CIT, ZUS и PIT. Без этой одной фразы агент отвечал бы на вопрос о сроках одними заметками пользователя.

Системный промпт: только правила поверх нескольких инструментов

Описание инструмента говорит об этом инструменте. Правила, которые связывают несколько инструментов или зависят от хода разговора, живут в разделе «Tool Usage Guidelines» системного промпта. Их три вида.

Первый — граница между инструментами и знаниями: инструменты — когда пользователь спрашивает о своих данных (счета, контрагенты, зарплата), знания модели — когда он спрашивает о законах вообще. Второй — многошаговые сценарии. calculate_payroll только считает предпросмотр, а save_payroll_record считает и сохраняет; промпт говорит, что слова «save», «zapisz», «сохранить», «записать» всегда означают save_payroll_record, а запись заменяется через delete_payroll_record и новое сохранение — никогда не через инструменты платежей. Третий — юридические обязанности: перед записью платежа от 15 000 злотых польскому контрагенту агент вызывает verify_bank_account_white_list, потому что этого требует ст. 117ba Налогового кодекса Польши (Ordynacja podatkowa).

Обратите внимание: правила-триггеры перечисляют слова на нескольких языках. Агент отвечает по-польски, по-английски и по-русски и определяет язык по самому сообщению, так что правило, написанное только по-английски, не сработает на «zapisz wypłatę». Кроме этих правил промпт инструменты не перечисляет. Это делает bindTools, а список инструментов в промпте был бы второй копией, которая рано или поздно разойдётся с кодом.

Параметры: одна форма для инструментов-братьев

То же апрельское исправление унифицировало схемы. Раньше get_invoices принимал год и месяц, а get_expenses — другое; теперь оба принимают dateFrom и dateTo в формате YYYY-MM-DD, а описание параметра содержит пример: для «April 2026» передай 2026-04-01 и 2026-04-30. Модель, научившаяся фильтровать один список, фильтрует любой.

Необязательные параметры мы записываем в Zod как .nullable().optional() — в каталоге больше 200 таких полей. Это подстройка под strict-режим OpenAI: согласно руководству по function calling, все поля должны быть помечены как обязательные, а необязательность выражается допуском типа null. Где набор значений закрыт, мы используем enum — например, статус счёта: all, paid, unpaid, overdue, draft, issued, sent, — чтобы модель не угадывала написание. Описаний параметров в каталоге 304, и именно они несут примеры форматов.

Бюджет промпта: сколько занимают 82 инструмента

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

Что уходит в каждый вызов моделиРазмерОткуда цифра
Системный промпт: роль, язык, правила инструментов, форматирование, налоговые данные 2026около 9 500 символовизмеренный результат сборки промпта для pl, en и ru
Долговременная памятьдо 20 записей и 6000 символовконстанты в сервисе памяти
Описания 82 инструментовоколо 12 900 символов; медиана 130, от 33 до 543измерено в файлах инструментов
Описания параметров304 описания, около 10 900 символовизмерено в файлах инструментов
История диалогатолько сообщения пользователя и ответы агентарезультаты инструментов не сохраняются
Результаты инструментов в текущем ходесписки до 100 счетов, расходов или контрагентов, 50 документов, 20 кадровых записейлимиты в коде инструментов
Шагов на один вопросне больше 25recursionLimit графа

Вывод один: при 82 инструментах самая большая постоянная часть контекста — каталог, а не системный промпт. Одни только описания инструментов и параметров — почти 24 тысячи символов, а весь системный промпт вместе с налоговыми данными — около 9,5 тысячи, то есть в два с половиной раза меньше. По грубому правилу «четыре символа на Токен — это фрагмент текста, обычно часть слова: ИИ-модели измеряют в токенах и длину запроса, и стоимость, поэтому счёт приходит за токены, а не за вопросы. Полное определение →», которым наш код пользуется для бюджета памяти, одна проза каталога — порядка 6 тысяч токенов; сериализованные JSON-схемы добавляют к этому имена полей, типы и значения enum, что мы отдельно не измеряли. OpenAI прямо пишет, что определения функций входят в лимит контекста и тарифицируются как входные токены.

Эту цену платят при каждом вызове узла agent, а не один раз на вопрос: ответ, которому нужны два инструмента подряд, — это три вызова модели с полным каталогом. Поэтому мы режем там, где это дёшево. Опциональные группы выпадают, когда ими нельзя воспользоваться. У долговременной памяти жёсткий лимит — 20 записей и 6000 символов. А история диалога, которая хранится в базе, содержит только сообщения пользователя и ответы агента: результаты инструментов живут один ход и не копятся с каждым обменом. Как перевести токены в деньги, мы показываем в статье сколько стоит ИИ-агент в месяц.

Результаты инструментов: готовый markdown, лимиты и ошибки текстом

Вторая сторона бюджета — то, что инструменты возвращают. Наши инструменты не отдают сырой JSON из API — это интерфейс, через который две системы обмениваются данными автоматически, без выгрузок в Excel и копирования руками. Полное определение → wFirma. У каждого есть форматтер, который возвращает готовый markdown — таблицу, заголовок, несколько полужирных полей — сразу на языке пользователя и с польским форматом чисел. Модель получает меньше текста, ей не нужно его переписывать, а промпт велит сохранять это форматирование как есть.

У списков есть лимиты: счета, расходы и контрагенты — 100 позиций, документы — 50, кадровые записи — по умолчанию 20. Без лимита одно «покажи все счета» у клиента с историей за несколько лет могло бы занять больше контекста, чем весь каталог инструментов.

Ошибки не обрывают граф. Инструмент ловит исключение и возвращает короткое сообщение на языке пользователя — что счета получить не удалось или что исчерпан лимит запросов к wFirma, — а промпт велит модели объяснить, что пошло не так, и предложить следующий шаг вместо того, чтобы выдумывать данные. Текст исключений из внешних систем проходит через функцию sanitizeForPrompt: обрезка до 500 символов и удаление заголовков и полужирного markdown, чтобы внешние данные не выглядели для модели как инструкции.

Когда мы изменили бы эту структуру

Руководство OpenAI советует держать в начале хода меньше 20 функций — как мягкую рекомендацию, а не лимит. У нас их больше чем вчетверо, и такая схема работает в продакшене, но у нас нет бенчмарка, который сказал бы, насколько лучше работал бы меньший каталог. Зато мы знаем, по каким признакам поймём, что плоского списка уже мало: два инструмента продолжают путаться, несмотря на ссылки в обоих описаниях; каталог растёт за счёт доменов, которые ни с чем не связаны; или стоимость каталога на каждом шаге начинает доминировать в счёте.

Тогда есть три пути. Первый — выбор инструментов через поиск: библиотека langgraph-bigtool индексирует описания инструментов и даёт агенту инструмент для поиска остальных, а OpenAI предлагает tool search, который откладывает редко используемые инструменты, — но только в моделях начиная с gpt-5.4. Второй — подграфы для доменов, которые действительно не смешиваются. Третий, часто самый дешёвый, — слить CRUD-инструменты одного домена в меньшее число инструментов с параметром операции; у нас кандидатами были бы сроки и группы сроков — десять инструментов очень похожей формы.

Одно ограничение у нас особое и может касаться и вас. Клиенты подключают собственные ключи: по умолчанию это gpt-4o в OpenAI или gemini-2.5-flash в Google, и пользователь может выбрать другую модель. Значит, структура агента должна одинаково работать у обоих провайдеров, поэтому механизмы, доступные только у одного из них, вроде tool search в OpenAI, не могут быть её фундаментом.

Чек-лист

Граф. StateGraph с двумя узлами, ToolNode, условное ребро по tool_calls и recursionLimit. Ничего больше, пока нет конкретной причины.

Каталог. Один файл с фабриками на домен, одна функция, собирающая список, опциональные группы — только когда их можно выполнить. Контекст пользователя — в замыкании, а не в аргументах.

Имена и описания. Один словарь глаголов, пары список–детали, ссылки в обе стороны между путающимися инструментами и больше слов там, где случаются ошибки.

Промпт. Только правила, охватывающие несколько инструментов, со словами-триггерами на всех языках ваших пользователей. Без списка инструментов.

Бюджет. Измерьте каталог, ограничьте списки в результатах, отдавайте готовый markdown, не переносите результаты инструментов в историю и помните, что за каталог платят на каждом шаге цикла.

Как этот агент выглядит целиком — с KSeF, OCR (Optical Character Recognition) превращает скан или фото документа в текст, который компьютер может прочитать, — первый шаг к тому, чтобы счёт из письма попадал в систему сам. Полное определение → чеков и долговременной памятью, — мы описали в статье Как устроен Accounting AI Agent, а сам продукт — на странице eKsięgowy AI.

Частые вопросы

Сколько инструментов может быть у одного агента LangGraph?
Сам LangGraph лимита не вводит: ToolNode и bindTools принимают любой список. Ограничение — модель: точность выбора и стоимость контекста, ведь определения всех инструментов уходят при каждом вызове. OpenAI советует меньше 20 функций в начале хода, но как мягкую рекомендацию. Наш агент eKsięgowy AI работает в продакшене с 58 базовыми инструментами и до 82 с модулями кадров и KSeF.
Нужен ли агенту с более чем 80 инструментами роутер или supervisor?
Не всегда. Роутер окупается, когда области не смешиваются или специалистам нужны другие модели либо права. В бухгалтерии вопросы сквозные, вроде «покажи счета и посчитай НДС», а роутер выбирает одну область и добавляет вызов модели. Поэтому мы остались с одним агентом и плоским списком, а структуру перенесли в каталог инструментов: файлы по доменам, имена и описания.
Как сделать, чтобы модель не путала похожие инструменты?
Описаниями, в обе стороны. Каждый из путающихся инструментов называет другой по имени и говорит, когда выбрать тот, — у нас это get_invoices (исходящие счета) и get_expenses (входящие расходы). Плюс одинаковая форма параметров у инструментов-братьев, например dateFrom и dateTo, и правила в системном промпте для многошаговых сценариев вроде предпросмотра зарплаты против её сохранения.
Сколько контекста занимают определения 82 инструментов?
У нас одни описания инструментов и параметров — почти 24 тысячи символов, примерно в два с половиной раза больше всего системного промпта, в котором около 9500 символов. По грубому правилу «четыре символа на токен» это порядка 6 тысяч токенов, без накладных расходов JSON-схем. Платят за это на каждом шаге цикла агента, а не один раз на вопрос.
Когда переходить на динамический выбор инструментов?
Когда похожие инструменты путаются, несмотря на исправленные описания, когда появляются домены, не связанные с остальными, или когда стоимость каталога доминирует в счёте. Варианты — поиск инструментов (langgraph-bigtool, tool search в OpenAI с gpt-5.4), подграфы для несвязанных доменов или слияние CRUD-инструментов. Если пользователи выбирают разных провайдеров моделей, избегайте механизмов, доступных только у одного.