Как на самом деле пишется длинный документ
Отчёт на сорок страниц — это не один длинный ответ. Это фоновая задача с конечным автоматом — план, вопросы, исследование, посекционное письмо, — где каждый шаг можно перезапустить после падения, не повторяя работу и не сжигая бюджет; где смещения для правки вычисляет код, а не выбирает модель; и где сохраняется каждая версия.
Обычный ответ — это один запрос к модели. Документ — нет: он идёт от минут до десятков минут, должен пережить закрытую вкладку и должен оставаться связным на сорока страницах, которые не поместятся ни в один запрос. Поэтому он выполняется как фоновая задача с конечным автоматом, и почти каждое решение ниже вытекает из одного вопроса: что будет, если это умрёт на середине?
Шаги
| Шаг | Что делает | Если умрёт на середине |
|---|---|---|
| Выбор типа | Сопоставить запрос с одним из ваших типов документов | Перезапуск с перезаписью |
| План | Получить разделы | Строки вставляются с пропуском конфликтов |
| Вопросы | Один раз спросить нужное — до начала письма | Ждёт: ваш ответ и пробуждение задачи — одна транзакция |
| Исследование | Собирать факты по разделам, раундами | Продолжает со следующего раунда |
| Письмо | По одному разделу за раз | Забирает раздел, пишет, идёт дальше |
| Завершение | Итоги, уведомление, доступный экспорт | Идемпотентно |
Лучше всего замысел объясняет шаг исследования — единственный, который по-настоящему нельзя
сделать идемпотентным: повторите тот же раунд, и поиск может вернуть другие страницы, а извлечённые
предложения могут отличаться. Поэтому вместо идемпотентности используются две вещи: факты уникальны
по (документ, раздел, факт) после нормализации, так что повтор не ляжет дважды; и счётчик раундов
увеличивается в той же транзакции, что и факты этого раунда.
Вывод стоит сказать прямо: задача, которая раз за разом падает на 3-м раунде, продолжает с 4-го. Без этого каждый перезапуск начинался бы с 1-го раунда, а число перезапусков ничем не ограничено. Цикл падений не может сжечь ваш бюджет.
Почему по одному разделу за раз
Каждый раздел забирается операцией compare-and-set (ready → writing) и пишется в одной транзакции,
которая сохраняет текст, версию и счётчики вместе. Именно поэтому «сервер перезапустился посреди
документа» перестаёт быть событием: готовые разделы не тронуты, а тот, что был в работе, забирается
заново, а не дописывается наполовину дважды.
Это же держит запрос небольшим. Раздел пишется из собственного задания, собственных фактов и короткого контекста — не из всего документа. Это важнее, чем кажется: мы измеряли, как рушится надёжность вызова инструментов по мере роста материала — с 7 из 8 вызовов на небольшом объёме до 2 из 8, а затем 0 из 8 после примерно 5 000 слов. Промпт, в котором лежит всё, не даёт лучший раздел: он даёт раздел, который пересказывает материал вместо того, чтобы выполнять задание.
Правка: смещения даёт код
Выделите фрагмент на холсте, скажите, что изменить, — заменится только он. Механизм намеренно узкий:
- Клиент присылает диапазон символов —
[start, end). - Сервер берёт
body[start:end]как фрагмент для изменения. - Модель получает этот фрагмент плюс ограниченный контекст и возвращает только текст замены.
- Сервер склеивает:
body[:start] + замена + body[end:].
Модель никогда не спрашивают о диапазоне и она никогда не возвращает diff. Модель, которую просят выдать диапазон правки, не падает громко, когда не уверена, — она выдаёт диапазон, выглядящий вполне разумно, и из вашего документа тихо исчезает предложение. Диапазоны приходят от разбивщика, который отрисовал этот текст, поэтому они точны по построению.
Если раздел изменился под вами — потому что агент в этот момент его переписывал — правка вернёт конфликт, а не перезапишет. Какая версия останется, решаете вы; это ваше право, а альтернатива — узнать об этом через несколько дней.
Факты и что бывает с числом без источника
Разделы пишутся из таблицы фактов, а не из памяти модели. Каждый факт хранится вместе с источником, поэтому любую цифру в готовом документе можно проследить до страницы или абзаца.
После переписывания результат просматривает код: числа, коды и имена собственные, которых нет в цитируемом материале, помечаются. Если такие нашлись, фрагмент перегенерируется один раз; если они остались, фрагмент возвращается помеченным и не применяется автоматически. Отдельно стоит сказать: эта проверка не делается вопросом к самой модели — на вопрос «подтверждается ли этот фрагмент» она ответит «да», глядя на материал, который ему противоречит.
Версии
Каждое изменение раздела — это версия: первый черновик, ваши ручные правки, каждое запрошенное переписывание, каждое восстановление. Восстановление старой версии добавляет версию, а не удаляет что-либо, поэтому история остаётся записью того, что происходило, а не того, что уцелело.
Что появляется в переписке
Письмо оставляет в разговоре три вида сообщений, и ни одно из них не написано моделью:
- Начало — заголовок и число разделов; публикуется в момент, когда задача действительно начинает тратить токены, а не когда поступил запрос.
- Обновлено — какие разделы изменились и что с ними произошло; публикуется, когда ход действительно изменил документ.
- Готово — разделы, слова, затраченное время.
Числа во всех трёх вычисляются из базы. Это не вопрос стиля: модель, которую просят отчитаться о том, что она только что сделала, выдаст уверенный, конкретный и иногда ложный отчёт — включая «я добавил этот график в документ» на ходу, где документ не изменился. О том, что сделала система, говорит система.
Ограничения, о которых стоит знать
- На одну базу данных работает один writing-worker. Задачи забираются через
FOR UPDATE SKIP LOCKED, то есть сама семантика захвата уже допускает несколько воркеров, но текущее развёртывание запускает один. - У отмены две половины: внутрипроцессная быстро останавливает текущий шаг, и сохранённый флаг в строке задачи — чтобы задача, пережившая процесс, тоже остановилась.
- Если у вашего агента отключены публичные источники, шаг исследования вообще не выходит в интернет: он пропускается, а не деградирует. Разрешение может только сужать.