# Как мы собрали агента, который сам подаёт твою декларацию по НДС
Source: https://norman.finance/de/ru/blog/agent-avtopodachi-nds
Author: Stan Kharlap
Published: 2026-07-14
Updated: 2026-07-14

---

Каждый квартал, а если ты крупнее, то каждый месяц, немецкий бизнес обязан отправить свою декларацию по НДС, *Umsatzsteuervoranmeldung*, в налоговую через ELSTER. У неё фиксированный срок, она не прощает арифметических ошибок, и ошибка стоит реальных денег. Большинство бухгалтерских программ помогают тебе *заполнить форму*. Мы хотели, чтобы софт просто *делал декларацию*: собирал цифры, проверял их и подавал, пока человек прочно держит под контролем тот единственный момент, который важен.

Эта функция называется **Autofiling**, и самое интересное в её создании почти не имело отношения к языковой модели. Модель, которая умеет читать чеки и складывать НДС, это базовая планка. Инженерная задача была противоположностью демо: как позволить стохастическому агенту совершить *необратимый юридический акт* от чужого имени и при этом иметь возможность потом доказать, что он сделал ровно то, что нужно?

## Подача это юридический акт, а не чат

Первый рефлекс при любой агентной фиче: подключить модель к паре инструментов и отпустить её. Для чат-бота нормально. Для налоговой декларации такой дизайн это ответственность:

- **Подача не идемпотентна.** Отправить UStVA в ELSTER дважды это не дублирующая строка, которую можно потом подчистить, это две декларации. Сбой в неудачную миллисекунду не должен приводить к повторной подаче.
- **«Скорее всего верно» это не планка.** Результат должен быть проверяемым и точным. Никто не подписывает цифру, которую не может увидеть.
- **Это привязано к сроку.** Агент не может тихо зависнуть. Если чего-то не хватает, это должно всплыть как конкретная, решаемая задача: сейчас, а не в день сдачи.

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

## Пайплайн, а не чёрный ящик

Прогон Autofiling проходит через явные, сохранённые состояния: `SCHEDULED → COLLECTING → RECONCILING → READY_FOR_APPROVAL → APPROVED → SUBMITTING → FILED`, плюс два аварийных выхода, `NEEDS_INPUT` и `FAILED`, на случай, когда реальность не идёт навстречу. Состояние лежит в базе данных, а не в голове агента, поэтому прогон всегда можно возобновить и всегда можно проаудировать.

<Figure
  src="/images/blog/autofiling-german-vat-agent/pipeline.svg"
  alt="Пайплайн Autofiling: collect собирает транзакции, чеки и банковские данные; reconcile порождает типизированные блокеры готовности и может увести прогон в Needs input; preview рендерит точную ELSTER-payload и хеширует её; человек подтверждает этот хеш на человеческом гейте; submit коммитится идемпотентно перед внешним вызовом; прогон завершается в Filed с полным аудит-трейлом. Каждый шаг это один трейсируемый AgentRun."
  width={1000}
  height={490}
  caption="Каждый переход сохраняется. Агент готовит; типизированные проверки останавливают; человек подтверждает точную payload; подача построена так, что сбой никогда не сможет отправить дважды."
/>

## Готовность, а не «на глаз»

Прежде чем декларация станет достойна превью, прогон входит в `RECONCILING`, и именно здесь агент отрабатывает свой хлеб, будучи хорошим пессимистом. Вместо того чтобы самоуверенно подать то, что у него есть, он ищет причины, по которым подача может оказаться неверной, и превращает каждую в типизированный **блокер**.

Блокеры это не предупреждения свободным текстом. Это замкнутый набор машиночитаемых типов: `uncategorized_transactions`, `unverified_transactions`, `possible_duplicates`, `unclaimed_input_vat`, `vat_conflict`, `expired_bank_sync`, `missing_tax_settings`, `elster_validation_error` и ещё с десяток, каждый со степенью серьёзности `info`, `warning` или `blocking`. Блокер `blocking` останавливает прогон и толкает его в `NEEDS_INPUT` с конкретной, решаемой задачей («14 транзакций в этом периоде без категории») вместо расплывчатого «что-то не так». Информационные блокеры едут вместе с превью, так что человек их видит, но его не останавливают.

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

## Ты подтверждаешь payload, а не обещание

Когда прогон чист, он рендерит **превью**: точную ELSTER-payload, которая была бы передана, отрисованную через тот же движок отчётов, что делает настоящую подачу, просто в тестовом режиме. Затем эта payload хешируется, и хеш сохраняется на прогоне как `preview_payload_hash`.

Подтверждение привязывается к этому хешу. Когда человек подтверждает, мы записываем `approved_payload_hash` вместе с тем, кто подтвердил и когда. С этого момента оба хеша должны совпадать. Если что-то выше по потоку меняется: приходит поздний чек, исправляется категория, сдвигаются настройки НДС, payload перерендеривается в другой хеш, сохранённое подтверждение аннулируется, и прогон отказывается идти дальше на устаревшем согласии. Ты никогда не подтверждаешь «план агента». Ты подтверждаешь *именно этот набор цифр*, и система обеспечивает это буквально.

## Подача, которая не может отправить дважды

Эта часть нравится мне больше всего, потому что она скучна ровно правильным образом. Шаг подачи намеренно *не* обёрнут в одну большую транзакцию. Вот настоящий docstring из раннера:

```python
def submit_approved_run(run: AutoFilingRun) -> AutoFilingRun:
    """Submit an approved run to ELSTER.

    Deliberately NOT one big transaction: the ELSTER POST is a non-idempotent
    external effect. SUBMITTING is committed *before* the external call, and
    the result is committed in its own transaction afterwards — so a crash
    can never roll the DB back to a state that would re-file the same UStVA.
    """
```

Ход, по порядку:

1. В транзакции под блокировкой строки перепроверяется всё: статус, что подтверждённый хеш всё ещё совпадает с текущей payload, что аккаунт всё ещё имеет право подавать, затем прогон переводится в `SUBMITTING` и **коммитится**.
2. Только после этого делается внешний вызов ELSTER, вне какой-либо транзакции.
3. Итог, поданная декларация или конкретная ошибка, коммитится в собственной транзакции.

Поскольку `SUBMITTING` устойчиво *до* сетевого вызова, процесс, умерший в середине подачи, просыпается в состоянии, которое говорит «это уже было в полёте», и путь восстановления проверяет, принял ли ELSTER отчёт, прежде чем вообще отправить снова. Временный сбой провайдера откатывает прогон обратно в `APPROVED`, чтобы свипер мог его повторить, до ограниченного числа попыток, ни разу не потеряв подтверждение. Единственное, чего не может случиться никогда, это подать одну и ту же декларацию дважды. Всё остальное восстановимо.

## Каждый шаг задокументирован

Под Autofiling лежит наш агентный слой, и вся его работа в том, чтобы сделать агента *читаемым*. Каждая единица работы модели: чтение документа, набросок исправления, подготовка декларации, записывается как `AgentRun`: к какому воркфлоу она относилась, какой провайдер и модель отработали, хеш входа и выхода, стоимость, задержка и точная `PromptVersion`, которая её произвела. Выбор модели на воркфлоу это сам по себе политика: `ModelPolicy` с потолками по стоимости, задержке и уровню риска плюс запасная модель, а не константа, закопанная в коде.

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

## Скучные части и есть продукт

Было бы легко собрать более броскую версию этого: чат-окно, где ты просишь агента «подай мою декларацию по НДС», и он это делает. Это было бы и неправильной вещью для выпуска, потому что в тот момент, когда декларация оказывается неверной, демо-магия становится чьей-то проблемой с налоговой.

Поэтому дизайн клонится в другую сторону. Агент делает нудную, чреватую ошибками работу по сбору и проверке, которую не любит ни один человек. Он отказывается угадывать, когда должен спросить. Он показывает тебе точную payload и делает подписантом тебя. И он относится к акту подачи с паранойей, которой заслуживает юридически значимая декларация. Такую форму имеет каждый агент, которого мы строим в Norman: полностью автоматизировать скучную работу, оставить человеку решение, которое несёт риск, и иметь возможность доказать каждый шаг между ними.