Как мы отслеживаем каждый прогон ИИ-агента, не сохраняя твои данные
Каждый вызов модели в Norman оставляет аудиторский след: какой воркфлоу, какая модель, во сколько обошёлся, сколько занял. Чего он не оставляет, это твои чеки и банковские данные. Мы храним хеши, а не payload. Вот слой наблюдаемости за нашими агентами.
- Категория
- Общее
- Обновлено
- Автор
- Stan Kharlap
Каждая ИИ-функция, которую мы выпускаем, натравливает модель на что-то приватное. Категоризация читает банковскую транзакцию. OCR читает чек. Сверка сравнивает счёт с платежом. Autofiling собирает целую декларацию по НДС. Если ты хочешь запускать агентов в продакшене и всё равно спокойно спать, ты должен уметь ответить, даже месяцы спустя: «что именно сделал этот прогон и почему?» Наивный способ получить ответ, это логировать всё: каждый промпт, каждый ответ, каждый документ, на который смотрела модель.
Мы делаем наоборот. Мы отслеживаем десятки тысяч прогонов агентов и сознательно не оставляем почти никаких данных. Что мы храним, это форму прогона, его стоимость, его провайдера и криптографический отпечаток входа и выхода. Чеки и суммы остаются в тех таблицах, которым принадлежат. Этот пост про этот размен и про небольшую кучку скучной механики, которая его держит.
Один прогон, одна строка
Под каждой ИИ-функцией сидит одно приложение, чья единственная задача, сделать работу модели читаемой. Каждая единица этой работы становится одной строкой, прогоном агента. Интересные колонки, это не payload, потому что его нет. Грубо прогон выглядит так:
AgentRun
workflow ocr | categorization | reconciliation | autofiling | ...
status running | needs_input | succeeded | failed | ...
provider, model who ran it
input_hash fingerprint of what went in
output_hash fingerprint of what came out
estimated_cost in micro-USD
latency_ms
prompt_version which prompt produced it
У прогона есть дети. Каждый шаг, это один вызов модели или инструмента внутри него, со своими счётчиками токенов и своей стоимостью. Каждый артефакт, это структурированный вывод, который стоит сохранить (поля, которые извлёк OCR, ответ, который вернул копайлот). Места вызова не собирают ничего из этого вручную. Они оборачивают вызов модели в контекст-менеджер и устанавливают результат на выходе, грубо:
with traced_agent_run(workflow="categorization", input_payload=payload) as trace:
result = call_model(payload)
trace.set_output(result, response=response)
Это весь контракт. Запусти прогон, сделай работу, верни вывод. Слой записывает шаг, сворачивает стоимость и задержку, закрывает прогон и пишет артефакт, если ты его попросил. Шесть настоящих воркфлоу в продакшене проходят сегодня ровно этим путём.
Мы храним хеш, а не данные
Посмотри ещё раз на input_payload выше. Мы передаём в трейсер настоящий вход модели, и всё же строка всегда хранит только input_hash. В этом вся идея. Прежде чем что-либо записывается, payload сводится к отпечатку, грубо:
input_hash = sha256(canonical_json(payload))
Слово, которое несёт весь вес, это канонический: мы сериализуем с отсортированными ключами, так что один и тот же логический вход всегда даёт одни и те же байты и, значит, один и тот же отпечаток, независимо от порядка ключей в словаре. 64-символьный дайджест заменяет чек, имя контрагента или целый список транзакций.
Хеш нельзя прочитать обратно в документ, и это ровно то, чего мы хотим. Но бесполезным он не является. Он позволяет ответить на вопросы, которые реально возникают в продакшене:
- Изменился ли вход? Если прогон запускается заново и
input_hashсовпадает, выше по потоку ничего не сдвинулось. Если он отличается, значит, сдвинулось. Это основа для идемпотентности и для обнаружения тихого дрейфа. - Дала ли модель дважды один и тот же ответ? Равные значения
output_hashозначают идентичный вывод, без сравнения двух блобов клиентских данных. - Какое исправление относится к какому прогону? Когда пользователь правит категоризацию, мы можем привязать исправление по отпечатку к тому самому прогону, который её выдал, и нам ни разу не нужно хранить то, что модель при этом видела.
Каждая строка трейса несёт ещё и собственную метку политики, так что намерение самодокументируется и обеспечивается в одном месте: политика хранения «только метаданные и хеши» и политика payload, которая говорит, что входы и выходы хешируются, а не хранятся. Артефакты, это единственное исключение, и исключение сознательное. Когда мы всё-таки сохраняем структурированный вывод, это маленький объект известной формы, который мы сами произвели (извлечённые поля, короткая сводка), никогда не сырой исходный документ, и он тоже хешируется. Сырой чек уже лежит в своей собственной таблице со своими правами доступа. Трейс не получает вторую копию.
Трейсинг никогда не должен ломать то, что он отслеживает
Слой наблюдаемости, который может уронить функцию, которую он наблюдает, хуже, чем полное отсутствие слоя. Поэтому весь трейсер стоит на одном правиле: сбой трейсинга невидим для вызывающего кода. Оно проявляется в трёх местах.
Во-первых, слой можно выключить целиком, и каждая точка входа проверяет этот флаг, прежде чем трогать базу данных. Во-вторых, каждая запись защищена: сбой при записи ловится, логируется и превращается в результат «нет трейса» вместо исключения, так что плохая запись в таблицу трейсов никогда не сможет прорваться наверх в категоризацию. В-третьих, каждый нижележащий помощник трактует отсутствующий прогон как «трейсинг не идёт» и тихо ничего не делает.
Единственное, что трейсер не проглатывает, это твоя ошибка. Если вызов модели внутри блока бросает исключение, прогон записывается как проваленный, а исключение перебрасывается тебе без изменений. Мы прячем собственные сбои, никогда твои. В итоге категоризация продолжает работать независимо от того, хороший ли день у таблицы трейсов, и это единственно приемлемое поведение для того, что оборачивает каждый вызов модели в продукте.
Стоимость это колонка первого класса, а не ежемесячный сюрприз
Поскольку слой видит каждый вызов модели, он естественное место, чтобы считать, во сколько они обходятся. Каждый шаг записывает счётчики входных, выходных и кешированных токенов и превращает их в оценочную стоимость в микро-USD, а не в долларах: одна категоризация может стоить крошечную долю цента, а целочисленные микродоллары позволяют суммировать миллионы таких значений без дрейфа округления с плавающей точкой.
Цены не зашиты в код. Строка политики на каждую модель держит цены за 1000 токенов, потолок задержки, уровень риска и опциональный бюджет по стоимости, так что изменить, во сколько нам обходится модель, это изменение данных, а не деплой. Когда шаги сворачиваются в свой прогон, текущие суммы по стоимости и задержке пишутся одним атомарным инкрементом, а не через read-modify-write, так что параллельные шаги одного прогона не могут затереть цифры друг друга. Если прогон вылетает за бюджет своей политики, мы помечаем его, а не убиваем на лету, чтобы аномалию можно было запросить постфактум. Поверх этого мы считаем удельные стоимости, которые реально волнуют финансиста: стоимость на тысячу категоризированных транзакций, стоимость на документ OCR, стоимость на поданную декларацию по НДС. Когда всплывает вопрос «во сколько нам обходится ИИ», ответ, это запрос, а не догадка. При нашем текущем объёме этот запрос идёт почти по миллиону транзакций в книгах наших клиентов.
Промпты это данные, а не деплои
Есть ещё одна вещь, на которую указывает прогон: точная версия промпта, которая его произвела. Промпты живут в базе данных, с ключом по воркфлоу и версии, а активный разрешается во время вызова за коротким кешем. Свойство безопасности маленькое, но важное: пустое переопределение означает «откатись к промпту, вшитому в код», так что худший случай неправильно настроенной строки промпта, это что мы тихо используем ту версию, которая была выпущена. Код всегда безопасное значение по умолчанию; активация или деактивация строки никогда не может сломать пайплайн.
Это покупает две вещи. Ты можешь дорабатывать формулировки без релиза. И поскольку каждый прогон проштампован версией, которая его сделала, позже можно сказать, какой промпт выдал какой вывод. Именно эта штамповка превращает «мы поменяли промпт, и стало вроде лучше» в нечто, что реально можно измерить.
Отдача: агент, которого можно оценивать
Всё это существует ради одной цели: делать агента измеримо лучше, не позволяя ему менять себя самому. Тот же прогон, который несёт хеш и версию промпта, собирает и человеческий сигнал: лайк или дизлайк и структурированные исправления, когда пользователь переопределяет то, что выдала модель. Мы храним несколько сотен таких исправлений как типизированные строки ревью, каждая с проставленной причиной (промах извлечения, баг маппинга, неподдерживаемый случай, просто предпочтение пользователя), так что паттерн настоящих ошибок можно отличить от шума и заморозить в оценочный кейс.
Этот оценочный цикл молод. У нас есть вся обвязка (findings, оценочные кейсы, оценённые результаты), и мы всё ещё наполняем датасет, так что я не буду делать вид, будто мы уже гоняем зрелый набор регрессов. Но суть в подложке. Поскольку каждый прогон снабжён отпечатком, версией и стоимостью, в тот момент, когда исправление стоит того, чтобы на нём учиться, мы можем превратить его в повторяемый тест и доказать, что следующий промпт справляется лучше, где решает, что выкатывать, человек, а никогда не модель.
Скучно намеренно
Ничего из этого не является захватывающей частью ИИ-продукта. Захватывающая часть, это модель, которая читает чек и понимает его правильно. Но модель, которая понимает его правильно в демо, и модель, которая понимает его правильно в чьей-то налоговой декларации, это разные утверждения, и расстояние между ними, это ровно такая негламурная бухгалтерия: строка на прогон, хеш вместо payload, стоимость, которую можно запросить, промпт, который можно назвать, и трейсер, который скорее исчезнет, чем утащит функцию за собой. Автоматизируй скучную работу, оставь человека на решении, которое несёт риск, и умей доказать каждый шаг между ними. Слой наблюдаемости, это то, как мы держим последнее обещание.
Norman берет операционную финансовую работу на себя
От invoicing до bookkeeping: Norman организует повторяющиеся финансовые процессы так, чтобы вы успевали к дедлайнам с меньшим объемом ручной работы.