← Все кейсы
Бухгалтерский SaaS LLM Observability

Моё дело: observability для LLM-агентов в собственном контуре

Мы построили для сервиса интернет-бухгалтерии «Моё дело» систему, которая показывает, что на самом деле делают его AI-агенты, когда отвечают клиенту: что спросили у нейросети, во сколько обошёлся ответ и верный ли он. Всё работает на собственных серверах компании, данные клиентов не уходят наружу, а её инженеры переносят это решение на каждый новый AI-сервис.

Что отделяет демо от продакшена

LLM-агент, который отвечает на вопросы в ноутбуке у разработчика, выглядит законченным. Тот же агент в продакшене превращается в чёрный ящик. Не видно, какой промпт ушёл в модель, что вернул поиск по базе, сколько стоил один ответ и был ли он вообще верным. Для компании, которая уже запускает агентов, именно эта слепота и есть настоящая проблема, а сам агент тут вторичен.

«Моё дело» — это сервис интернет-бухгалтерии и бухгалтерского аутсорсинга, и агенты у них уже работали. Заказывали они не очередного агента, а слой под ними: возможность видеть, считать и проверять каждое обращение к модели. И делать это внутри собственной инфраструктуры, не отдавая данные наружу.

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

Зрелость в проде начинается там, где демо заканчивается: надо видеть каждое обращение к модели, понимать, во что оно обошлось, и уметь доказать, что ответ верный. Ничего из этого не появляется само, и почти каждая такая задача упёрлась в отдельное инженерное решение.

Приложение не должно знать, чем за ним наблюдают

Самое важное решение в проекте мы сначала приняли неправильно.

Трейсинг записывает всю цепочку действий агента: какой промпт ушёл в модель, что она ответила, сколько токенов потратила, какие инструменты вызвала. Подключить его проще всего готовым SDK от Langfuse, той системы наблюдаемости, которую мы и разворачивали. Так и сделали в первой версии, и заработало с первого раза.

И мы это выкинули. Образец заказчик копирует на каждый свой сервис, поэтому любая привязка внутри него размножится вместе с шаблоном. Сцепи мы приложение с конкретной системой наблюдаемости, и этот вендор-лок переехал бы в каждый сервис: сменить Langfuse на Grafana или Datadog стало бы нельзя без переписывания кода во всех сервисах сразу.

Поэтому приложение отдаёт телеметрию в открытом стандарте OpenTelemetry и не тянет ничей SDK. Что куда направить, решает уже инфраструктура: между приложением и наблюдаемостью стоит отдельный сборщик, и именно он распределяет потоки. Как устроен этот путь целиком — в следующем разделе.

Полной независимости это не даёт: бэкендом для трейсов, оценок и разметки остаётся Langfuse, и сменить его — отдельная работа. Но вендор-нейтральным остаётся самое дорогое, те самые приложения, которых у компании со временем будут десятки и которые копируют чаще всего.

Как работает наблюдаемость

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

Путь одного ответа: от агента до дашборда
1
Агент получает вопрос
Один вопрос пользователя запускает один прогон агента. Возьмём для примера «Сколько карт можно разыграть за один ход?» Внутри прогона он разберёт вопрос, сходит в базу за фрагментами и обратится к модели за формулировкой.
один прогон = один agent run
2
Каждое действие записывается само
Приложение ничего не логирует руками. Библиотека-инструментация заводит отдельную запись на каждый шаг: вызов модели, запрос в базу, входящее обращение к сервису.
OpenTelemetry · авто-инструментация
3
Запись о вызове модели несёт цифры
Какая модель отвечала и сколько токенов ушло на вход и выход — отсюда и берётся стоимость ответа. В самой записи это выглядит так:
gen_ai.systemopenai gen_ai.operation.namechat gen_ai.request.modelvllm-fast-generation gen_ai.usage.input_tokens1843 gen_ai.usage.output_tokens52
4
К записи приклеивается контекст
Кто спросил, в какой сессии, сам вопрос и какой фрагмент вернул поиск. Без этого потом не найти ответ и не понять, на чём он построен:
user.idu-4821 session.idthread-9f3c langfuse.trace.input«Сколько карт можно разыграть за один ход?» langfuse.observation.metadata.rag_context«…за один ход игрок разыгрывает одну карту со своей руки…»
5
Все записи стекаются в один сборщик
Приложение отправляет всё в одну точку и не знает, где это будут хранить. Куда какой поток направить, решает уже сборщик.
OpenTelemetry Collector
6
Сборщик разводит потоки
Всё подряд уходит в общее хранилище для технического мониторинга. Обращения к модели сборщик узнаёт по именам с приставкой gen_ai. и дополнительно отправляет в систему, заточенную под LLM.
ClickHouse · Langfuse
7
Записи собираются обратно в дерево
Из отдельных записей восстанавливается весь ответ, а внутри него вложенные шаги: сам ответ, внутри него обращение к модели, поиск, снова модель. Поэтому это дерево, а не плоский список, и на каждом узле видно модель, токены и стоимость.
трейс
8
Поверх ложатся оценки качества
Модели-судьи и живые люди ставят оценку на каждый шаг ответа, и она прикрепляется к той же записи.
LLM-судьи · ручная разметка

Раньше после ответа агента не оставалось ничего, кроме самого текста. Теперь весь этот путь виден по любому ответу: его можно открыть, разобрать по шагам, посчитать и сравнить с другими.

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

Сколько стоит один ответ

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

Со стоимостью всплыла честная неприятность. Langfuse считает цену вызова по встроенному справочнику моделей, а собственных моделей заказчика в этом справочнике нет, поэтому стоимость показывается нулевой. Чтобы учёт денег заработал на своих моделях, цены нужно прописывать вручную. Такую мелочь видно только на боевом стеке с собственными моделями, а на чужих облачных моделях её просто не бывает, поэтому в чек-листах она обычно и не появляется.

Откуда известно, что ответ хороший

Качество ответов оценивается автоматически и руками одновременно. На автоматической стороне в Langfuse работают LLM-судьи — отдельные модели-оценщики, которые смотрят на шаги агента и ставят оценки: полезность, краткость, токсичность, совпадение языка ответа с языком вопроса и релевантность найденного контекста. Релевантность контекста при этом меряется двумя способами, по текущему вопросу и по всей истории диалога, потому что это разные вещи.

Оценки ставятся на уровне отдельного шага, а не только на финальном ответе. Это важная деталь: когда ответ плохой, видно, где именно сломалось, в поиске или в генерации.

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

Это работает в их контуре

Весь стек — наблюдаемость, шлюз моделей, оркестрация фоновых задач, оценка — развёрнут в собственном Kubernetes заказчика. Трейсы оседают в их Langfuse и ClickHouse, запросы к моделям идут через их LiteLLM. В их боевом контуре модели работают на их же железе, и данные не выходят наружу. Демо мы гоняли на безобидных данных про настольные игры, поэтому для показа модель могла быть и внешней, но для домена, где за агентом стоят бухгалтерия и персональные данные клиентов, контур данных становится жёстким условием.

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

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

Что под капотом — инженерная карта слоя
ПРИЛОЖЕНИЕ
отдаёт всю телеметрию стандартным OpenTelemetry · не тянет SDK Langfuse · не фильтрует ничего у себя
Автоинструментация FastAPI · SQLAlchemy · pydantic-ai
спаны HTTP-запросов, обращений к базе и вызовов модели создаются сами, из вендорной библиотеки
Обогащение контекстом SpanProcessor + ContextVar
кто спросил, сессия, вопрос дописываются в спан при создании — через зависимость FastAPI, после разбора маршрута, не в middleware
ответ и контекст из поиска дописываются позже: RAG-контекст — в ещё открытый спан «agent run» по живой ссылке (observation-level)
OpenTelemetry Collector — единая точка маршрутизации
ВСЯ ТЕЛЕМЕТРИЯ → ClickHouse
инфраструктурный мониторинг: HTTP, SQL, вызовы модели — без фильтрации
ТОЛЬКО МОДЕЛЬ → Langfuse
LLM-наблюдаемость: только обращения к модели
Фильтр instrumentation_scope + gen_ai-атрибуты
маски нет, приметы перечислены по одной; у вызова инструмента — только имя инструмента
в Langfuse виден трейс →
дерево вызовов токены и стоимость модель оценки качества
ЗАГРУЗКА ДОКУМЕНТОВ В ПОИСК Temporal
пайплайн должен пережить сбой и не начинать с нуля
Воркфлоу по шагам retry + timeout на шаг
скачать из S3 · распарсить и нарезать на фрагменты · сгенерировать эмбеддинги и записать в Qdrant
Эмбеддинги и запись слиты в один шаг payload limit
большие векторы нельзя гонять через сервер Temporal, поэтому генерация и запись в Qdrant идут вместе
Видимость по документу
статус каждого документа виден в Temporal, повторяется сбойный шаг, а не весь пайплайн
ОЦЕНКА КАЧЕСТВА Langfuse
LLM-судьи на уровне шага observation-level
полезность · краткость · токсичность · язык ответа · релевантность контекста (по вопросу и по истории)
Ручная разметка annotation queues
верность ответа и обоснованность фрагментами — по шкалам людей, через публичный API
Повторяемый провижининг идемпотентный скрипт
публичного API для оценщиков нет → логин как пользователь; повторный прогон не дублирует и восстанавливает настройку
весь стек в собственном Kubernetes заказчика · модели на их железе · данные не покидают контур
OpenTelemetry · Langfuse · ClickHouse · Temporal · LiteLLM · Qdrant · pydantic-ai · FastAPI

Что осталось у заказчика

За скучными словами «слой наблюдаемости» стоит конкретная новая возможность. Раньше агент в проде был чёрным ящиком; теперь инженеры заказчика видят каждое обращение к модели, его стоимость и его оценку качества и могут подключить следующий сервис, скопировав готовый образец, без привязки к вендору и не выводя данные из своего контура.

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

Что мы вынесли из пилота

Рабочий образец-эталон, который команда заказчика копирует на свои сервисы
Приложение отдаёт стандартный OpenTelemetry и не зависит от SDK вендора наблюдаемости
Маршрутизация и фильтрация телеметрии живёт в инфраструктуре, а не в коде
Сквозной учёт стоимости по токенам — даже для собственных моделей заказчика
Качество оценивается на каждом шаге агента, а не только на финальном ответе
Весь стек работает в их кластере — данные не покидают контур
Рабочий образец-эталон, который команда заказчика копирует на свои сервисы
Приложение отдаёт стандартный OpenTelemetry и не зависит от SDK вендора наблюдаемости
Маршрутизация и фильтрация телеметрии живёт в инфраструктуре, а не в коде
Сквозной учёт стоимости по токенам — даже для собственных моделей заказчика
Качество оценивается на каждом шаге агента, а не только на финальном ответе
Весь стек работает в их кластере — данные не покидают контур

Модули платформы в этом проекте

Observability OpenTelemetry · Langfuse · ClickHouse

Трейсинг каждого обращения к модели: дерево вызовов, токены, стоимость, привязка к пользователю и сессии. Контекст запроса дописывается в телеметрию отдельным обработчиком, чтобы бизнес-код ничего не знал о системе наблюдаемости

LLM Router LiteLLM

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

Evaluation Langfuse

LLM-судьи оценивают каждый шаг агента (полезность, краткость, токсичность, язык, релевантность контекста), а не только финальный ответ. Рядом — очереди ручной разметки. Весь набор оценщиков разворачивается одним повторяемым скриптом

Документы Temporal · Qdrant

Загрузка документов в поиск обёрнута в Temporal-воркфлоу: ретраи и таймауты на каждый шаг, видимость статуса по документу, повтор сбойного шага без рестарта всего пайплайна

Чат и агенты pydantic-ai

Демо-агент на pydantic-ai как эталон интеграции с платформой. Намеренно тривиальный по задаче — ценность в продакшн-обвязке вокруг него, которую заказчик копирует на свои сервисы

Guardrails

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

Расскажите, какой процесс хотите разобрать.

Ответим, подходит ли задача для AI-агентов, и если да, предложим конкретный план.

или напишите напрямую — ilya@manaraga.ai