Как это устроено

Как на самом деле пишется длинный документ

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

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

Шаги

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

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

Вывод стоит сказать прямо: задача, которая раз за разом падает на 3-м раунде, продолжает с 4-го. Без этого каждый перезапуск начинался бы с 1-го раунда, а число перезапусков ничем не ограничено. Цикл падений не может сжечь ваш бюджет.

Почему по одному разделу за раз

Каждый раздел забирается операцией compare-and-set (ready → writing) и пишется в одной транзакции, которая сохраняет текст, версию и счётчики вместе. Именно поэтому «сервер перезапустился посреди документа» перестаёт быть событием: готовые разделы не тронуты, а тот, что был в работе, забирается заново, а не дописывается наполовину дважды.

Это же держит запрос небольшим. Раздел пишется из собственного задания, собственных фактов и короткого контекста — не из всего документа. Это важнее, чем кажется: мы измеряли, как рушится надёжность вызова инструментов по мере роста материала — с 7 из 8 вызовов на небольшом объёме до 2 из 8, а затем 0 из 8 после примерно 5 000 слов. Промпт, в котором лежит всё, не даёт лучший раздел: он даёт раздел, который пересказывает материал вместо того, чтобы выполнять задание.

Правка: смещения даёт код

Выделите фрагмент на холсте, скажите, что изменить, — заменится только он. Механизм намеренно узкий:

  1. Клиент присылает диапазон символов — [start, end).
  2. Сервер берёт body[start:end] как фрагмент для изменения.
  3. Модель получает этот фрагмент плюс ограниченный контекст и возвращает только текст замены.
  4. Сервер склеивает: body[:start] + замена + body[end:].

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

Если раздел изменился под вами — потому что агент в этот момент его переписывал — правка вернёт конфликт, а не перезапишет. Какая версия останется, решаете вы; это ваше право, а альтернатива — узнать об этом через несколько дней.

Факты и что бывает с числом без источника

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

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

Версии

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

Что появляется в переписке

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

  • Начало — заголовок и число разделов; публикуется в момент, когда задача действительно начинает тратить токены, а не когда поступил запрос.
  • Обновлено — какие разделы изменились и что с ними произошло; публикуется, когда ход действительно изменил документ.
  • Готово — разделы, слова, затраченное время.

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

Ограничения, о которых стоит знать

  • На одну базу данных работает один writing-worker. Задачи забираются через FOR UPDATE SKIP LOCKED, то есть сама семантика захвата уже допускает несколько воркеров, но текущее развёртывание запускает один.
  • У отмены две половины: внутрипроцессная быстро останавливает текущий шаг, и сохранённый флаг в строке задачи — чтобы задача, пережившая процесс, тоже остановилась.
  • Если у вашего агента отключены публичные источники, шаг исследования вообще не выходит в интернет: он пропускается, а не деградирует. Разрешение может только сужать.