Почему один большой prompt плохо масштабируется
Самый очевидный способ получить от большой языковой модели длинный документ - передать ей требования и попросить сгенерировать весь результат одним запросом. Для небольшого текста такой подход работает нормально, но по мере увеличения объема начинают проявляться архитектурные ограничения.
Модель должна одновременно удерживать структуру документа, исходные требования, терминологию, уже созданные фрагменты и правила оформления. Чем больше задач объединено в одном prompt, тем сложнее контролировать результат. Отдельные разделы начинают повторяться, важные ограничения забываются, а ближе к концу документа содержание может заметно отклоняться от первоначального плана.
В прикладных AI-системах эту проблему часто решают иначе: вместо одного огромного запроса используется последовательность небольших LLM-вызовов. Сначала система строит структуру, затем обрабатывает ее по разделам, сохраняет промежуточное состояние и только после этого собирает итоговый документ.
Такой подход можно рассматривать как обычный pipeline:
Исходное задание
↓
Анализ требований
↓
Построение структуры
↓
Генерация раздела 1
↓
Проверка результата
↓
Генерация раздела 2
↓
Проверка результата
↓
...
↓
Финальная сборка
↓
Общая валидация
С точки зрения backend-разработки это значительно более управляемая архитектура, чем единичный вызов LLM с огромным prompt.
Что происходит при генерации всего документа одним запросом
Представим задачу: нужно подготовить технический материал объемом 15-20 тысяч символов с десятью разделами, примерами команд, ограничениями по стилю и заранее определенной структурой.
Наивная реализация может выглядеть следующим образом:
prompt = f"""
Подготовь подробный технический материал.
Требования:
{requirements}
Структура:
{structure}
Исходные данные:
{source_data}
Напиши весь документ целиком.
"""
result = llm.generate(prompt)
У такого решения есть несколько проблем.
- Сложно повторно выполнить только часть задачи. Если неудачным оказался один раздел, приходится генерировать весь документ заново.
- Сложнее контролировать структуру. Модель может объединять разделы, менять порядок или уделять одной части значительно больше внимания, чем другой.
- Растет объем контекста. Чем больше исходных данных передается модели, тем выше стоимость запроса и тем сложнее определить, какая информация действительно нужна на конкретном этапе.
- Ошибки распространяются дальше. Если в начале документа появилась неверная предпосылка, модель может использовать ее во всех последующих разделах.
- Неудобны retries. Повтор большого запроса обходится дороже, чем повтор отдельного небольшого этапа.
Для production-систем это особенно важно. LLM-вызов стоит рассматривать примерно так же, как обращение к внешнему API: он может завершиться ошибкой, вернуть невалидную структуру, превысить timeout или дать результат, который не проходит бизнес-валидацию.
Поэтапная генерация как LLM pipeline
Более надежная архитектура разделяет одну большую задачу на несколько независимых стадий.
Например:
1. parse_requirements
2. generate_outline
3. validate_outline
4. generate_sections
5. validate_sections
6. assemble_document
7. final_review
Каждый этап имеет ограниченную ответственность и получает только необходимые ему данные.
Сначала модель может построить структуру документа:
{
"title": "Диагностика проблем Kubernetes",
"sections": [
{
"id": 1,
"title": "Проверка состояния pod",
"goal": "Объяснить базовую диагностику"
},
{
"id": 2,
"title": "Анализ событий",
"goal": "Показать работу kubectl get events"
},
{
"id": 3,
"title": "Диагностика сети",
"goal": "Разобрать Service, Endpoint и DNS"
}
]
}
Затем backend проходит по этому списку и генерирует каждый раздел отдельно.
for section in outline["sections"]:
content = generate_section(
section=section,
requirements=requirements,
document_context=document_context
)
save_section(section["id"], content)
В результате каждый LLM-вызов становится небольшой и понятной операцией.
Главное преимущество - контроль контекста
В LLM-приложениях важно различать максимальное окно контекста модели и реально полезный контекст. Технически модель может поддерживать очень большое количество токенов, но это не означает, что в каждый запрос нужно передавать всю доступную информацию.
Для генерации одного раздела обычно достаточно:
- общих требований к документу;
- названия и цели текущего раздела;
- краткого описания предыдущих разделов;
- необходимых исходных данных;
- правил оформления результата.
Например:
{
"document_goal": "Практическое руководство по диагностике Kubernetes",
"current_section": {
"title": "Диагностика DNS",
"goal": "Показать проверку CoreDNS и service discovery"
},
"previous_summary": [
"Ранее разобрана диагностика Pod",
"Проверены Service и Endpoint"
],
"constraints": [
"Не повторять предыдущие разделы",
"Добавлять готовые команды",
"Объяснять результат каждой команды"
]
}
Такой prompt значительно компактнее полного документа, но при этом содержит достаточно информации для продолжения текста.
Не обязательно передавать модели весь предыдущий текст
Одна из типичных ошибок при реализации последовательной генерации - после создания каждого раздела добавлять его целиком в prompt следующего этапа.
В итоге контекст снова начинает быстро расти:
section_1
section_1 + section_2
section_1 + section_2 + section_3
section_1 + section_2 + section_3 + section_4
Через несколько итераций система практически возвращается к исходной проблеме большого prompt.
Лучше хранить два представления документа:
FULL CONTENT
Полный текст всех созданных разделов
SUMMARY STATE
Краткое описание уже раскрытых тезисов
Полный контент сохраняется в базе данных, но следующему LLM-вызову передается только summary.
Например:
{
"completed_sections": [
{
"section": "Pod diagnostics",
"summary": "Разобраны get pods, describe и logs"
},
{
"section": "Service diagnostics",
"summary": "Разобраны Service, Endpoint и selector"
}
]
}
Это похоже на механизм state в обычном workflow: система хранит полное состояние задачи, но конкретный worker получает только необходимую его часть.
Пользователь может вмешиваться между этапами
Последовательная архитектура дает еще одно важное преимущество: между отдельными стадиями можно добавить human-in-the-loop.
Например, после построения структуры система не начинает генерацию автоматически, а показывает пользователю план:
1. Введение
2. Архитектура решения
3. Работа с Kubernetes
4. Мониторинг
5. Логирование
6. Резервное копирование
7. Типичные ошибки
Пользователь может:
- переименовать раздел;
- удалить ненужную часть;
- изменить порядок;
- добавить дополнительный раздел;
- уточнить требования к конкретному этапу.
Только после подтверждения структуры запускается следующая стадия.
Этот принцип применим не только к технической документации. Например, в ориентированном на студентов онлайн-сервисе для работы с учебными материалами СитиАвтор используется похожая логика: большой текст формируется поэтапно, начиная со структуры и переходя к отдельным частям. С инженерной точки зрения интересен именно сам паттерн взаимодействия - пользователь контролирует промежуточное состояние задачи, а не получает единственный монолитный ответ после одного большого запроса.
State machine вместо одной функции generate()
Если такая система становится частью production-приложения, процесс удобно представить как конечный автомат.
CREATED
↓
ANALYZING
↓
OUTLINE_READY
↓
WAITING_FOR_APPROVAL
↓
GENERATING
↓
REVIEWING
↓
COMPLETED
Дополнительно нужны состояния ошибок:
FAILED
CANCELLED
RETRY_REQUIRED
Например, модель Django может хранить текущее состояние задачи:
class GenerationTask(models.Model):
class Status(models.TextChoices):
CREATED = "created"
OUTLINE = "outline"
WAITING = "waiting"
GENERATING = "generating"
REVIEWING = "reviewing"
COMPLETED = "completed"
FAILED = "failed"
status = models.CharField(
max_length=32,
choices=Status.choices,
default=Status.CREATED,
)
current_section = models.PositiveIntegerField(default=0)
context = models.JSONField(default=dict)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
Такой подход дает backend возможность продолжить задачу после перезапуска процесса или временного сбоя AI-провайдера.
Почему промежуточные результаты нужно сохранять
Если генерация десяти разделов занимает несколько минут, нельзя держать весь процесс исключительно в памяти одного worker.
Представим, что успешно созданы восемь разделов, а на девятом AI API возвращает timeout. Если промежуточные данные нигде не сохранены, всю операцию придется запускать сначала.
Поэтому после каждого этапа стоит фиксировать checkpoint:
Task #8421
outline: completed
section_1: completed
section_2: completed
section_3: completed
section_4: completed
section_5: completed
section_6: completed
section_7: completed
section_8: completed
section_9: failed
section_10: pending
После retry система продолжает работу с девятого раздела.
Это стандартный принцип fault-tolerant архитектуры: успешно выполненные операции не должны повторяться без необходимости.
Идемпотентность LLM-задач
Особая проблема связана с повторным выполнением фоновых задач. Например, Celery worker успел получить ответ модели и сохранить часть данных, но соединение с брокером оборвалось до подтверждения задачи.
Worker может получить ту же задачу повторно.
Если генерация реализована без проверки состояния, в базе появятся два варианта одного раздела.
Поэтому задача должна быть идемпотентной:
def generate_section(task_id, section_id):
section = Section.objects.get(
task_id=task_id,
id=section_id,
)
if section.status == "completed":
return
result = call_llm(section)
section.content = result
section.status = "completed"
section.save()
В более сложной системе дополнительно можно хранить request ID каждого обращения к AI-провайдеру и уникальный ключ операции.
Retries становятся значительно дешевле
Один из наиболее практичных плюсов разделения задачи - возможность повторять только неудачный этап.
Пусть полный документ требует десяти LLM-вызовов:
outline OK
section 1 OK
section 2 OK
section 3 OK
section 4 OK
section 5 ERROR
section 6 pending
...
Повторять первые четыре раздела нет необходимости. Worker выполняет retry только для section 5.
Например, можно использовать exponential backoff:
@shared_task(
autoretry_for=(LLMTimeoutError,),
retry_backoff=True,
retry_backoff_max=120,
max_retries=5,
)
def generate_section(section_id):
...
Для API, работающего нестабильно под нагрузкой, это намного эффективнее повторной генерации всего документа.
Параллельная или последовательная генерация?
После разделения документа на части возникает естественный вопрос: можно ли генерировать все разделы параллельно?
Технически можно:
section_1 ─┐
section_2 ─┤
section_3 ─┼──→ final assembly
section_4 ─┤
section_5 ─┘
Такой вариант сильно сокращает общее время выполнения, но подходит только для независимых разделов.
Если каждая следующая часть должна учитывать предыдущую, предпочтительнее последовательная схема:
section_1
↓
summary
↓
section_2
↓
summary
↓
section_3
На практике часто используется гибридная архитектура. Независимые разделы выполняются параллельно, а связанные между собой - последовательно.
Промежуточная валидация лучше финальной
Если проверять результат только после завершения всего документа, исправление ошибок становится дорогим.
Гораздо удобнее валидировать каждый этап сразу.
Например, для раздела можно задать требования:
{
"min_length": 1500,
"max_length": 4000,
"required_terms": [
"Kubernetes",
"kubectl"
],
"forbidden_terms": [
"гарантированно",
"100% безопасно"
],
"require_code_example": true
}
После генерации backend выполняет автоматическую проверку.
result = generate_section()
errors = validate(result)
if errors:
result = regenerate_section(
previous_result=result,
validation_errors=errors,
)
Таким образом модель получает конкретный feedback:
Повтори генерацию раздела.
Обнаружены проблемы:
- отсутствует практический пример;
- длина меньше 1500 символов;
- дважды повторяется объяснение kubectl describe.
Исправь только указанные проблемы.
Это намного надежнее запроса "перепиши лучше".
Структурированный вывод вместо свободного текста
Еще один способ повысить стабильность pipeline - заставить промежуточные стадии возвращать не обычный текст, а структурированные данные.
Например, этап построения плана:
{
"title": "...",
"sections": [
{
"title": "...",
"purpose": "...",
"keywords": [],
"requires_code": true
}
]
}
Backend может проверить JSON по schema еще до начала генерации.
Условный пример с Pydantic:
from pydantic import BaseModel
class Section(BaseModel):
title: str
purpose: str
keywords: list[str]
requires_code: bool
class Outline(BaseModel):
title: str
sections: list[Section]
Если AI возвращает неправильную структуру, задача останавливается на раннем этапе, а не обнаруживает проблему после создания всего материала.
Очередь задач и отдельные workers
Для небольшого приложения генерацию можно выполнять непосредственно в request/response цикле, но длительные операции лучше переносить в очередь.
Архитектура может выглядеть так:
Browser
↓
Django / FastAPI
↓
PostgreSQL
↓
Redis
↓
Celery workers
↓
LLM API
HTTP-запрос пользователя только создает задачу:
POST /api/generations/
{
"topic": "...",
"requirements": "..."
}
Сервер отвечает:
{
"id": 8421,
"status": "created"
}
Дальнейшая генерация происходит в background workers.
Клиент может получать состояние через polling:
GET /api/generations/8421/
или через WebSocket/SSE:
{
"status": "generating",
"current_section": 4,
"total_sections": 8,
"progress": 50
}
Для пользователя такой интерфейс тоже понятнее одного индикатора, который несколько минут находится в состоянии "Генерация".
Как считать прогресс
Если pipeline состоит из заранее известных стадий, можно рассчитывать приблизительный процент выполнения.
outline 10%
section 1 20%
section 2 30%
section 3 40%
section 4 50%
section 5 60%
section 6 70%
validation 85%
finalization 100%
Для документов с динамическим количеством разделов формула может быть проще:
progress =
completed_steps /
total_steps *
100
Важно учитывать не только генерацию текста, но и подготовительные операции: анализ входных данных, построение структуры, validation и финальную сборку.
Версионирование отдельных частей
Еще одно преимущество поэтапной модели заключается в том, что пользователь может изменить один раздел, не затрагивая остальные.
Например:
Section 1
version 1
Section 2
version 3
Section 3
version 1
Для каждого раздела можно хранить историю:
class SectionRevision(models.Model):
section = models.ForeignKey(Section, on_delete=models.CASCADE)
content = models.TextField()
prompt = models.TextField()
created_at = models.DateTimeField(auto_now_add=True)
Это позволяет реализовать rollback и сравнение вариантов практически так же, как при работе с исходным кодом.
Когда поэтапная генерация особенно полезна
Этот подход имеет смысл не для каждого LLM-запроса. Если требуется получить краткий ответ из двух абзацев, создание сложного workflow будет избыточным.
Pipeline становится полезным, когда присутствует хотя бы несколько факторов:
- результат состоит из нескольких логических частей;
- генерация занимает значительное время;
- есть вероятность timeout или rate limit;
- пользователь должен контролировать структуру;
- части результата можно редактировать независимо;
- важно сохранять прогресс;
- нужно повторять только неудачные стадии;
- существуют строгие правила валидации;
- результат слишком велик для надежной генерации одним запросом.
В таком случае оркестрация нескольких небольших обращений к модели обычно оказывается надежнее одного большого вызова.
Недостатки staged generation
У подхода есть и цена. Вместо простой функции generate() появляется полноценный workflow.
Необходимо хранить:
- текущее состояние задачи;
- структуру документа;
- промежуточные результаты;
- summary контекста;
- статус каждого этапа;
- ошибки и retries;
- версии разделов.
Также увеличивается количество обращений к модели. Иногда десять коротких запросов могут стоить дороже одного большого, особенно если одинаковая часть system prompt передается каждый раз и провайдер не поддерживает эффективное кэширование контекста.
Поэтому архитектуру нужно выбирать исходя из требований приложения, а не использовать многоэтапность автоматически.
Практическая схема production-сервиса
Для реального приложения разумная последовательность может выглядеть следующим образом:
1. Пользователь создает задачу
2. Backend сохраняет исходные параметры
3. Worker анализирует требования
4. LLM формирует outline
5. Backend валидирует JSON
6. Пользователь подтверждает структуру
7. Worker генерирует section 1
8. Результат сохраняется в БД
9. Создается краткий summary раздела
10. Генерируется section 2
11. Процесс повторяется
12. Выполняется финальная валидация
13. Документ собирается из сохраненных частей
14. Задача получает status=completed
Если на любом этапе происходит ошибка, система знает точку последнего успешного checkpoint и может продолжить работу с нее.
Что мониторить в LLM pipeline
После переноса такой системы в production полезно собирать отдельные метрики для каждого этапа.
Минимальный набор:
llm_requests_total
llm_request_duration_seconds
llm_request_errors_total
llm_input_tokens_total
llm_output_tokens_total
generation_tasks_total
generation_tasks_failed_total
generation_section_retries_total
Также полезно фиксировать:
- модель и AI-провайдера;
- длительность запроса;
- число входных и выходных токенов;
- стоимость вызова;
- тип этапа;
- количество retries;
- причину ошибки.
Это позволяет увидеть, например, что 70% стоимости приходится на финальную проверку или что определенный этап значительно чаще остальных попадает под timeout.
Итог
Проблема длинных LLM-запросов заключается не только в ограничении количества токенов. Один большой prompt плохо управляется: сложнее повторять отдельные операции, контролировать структуру, валидировать результат, сохранять прогресс и восстанавливаться после ошибок.
Разделение генерации на последовательные стадии превращает обращение к языковой модели в нормальный backend workflow. Появляются checkpoints, retries, валидация, state machine, возможность вмешательства пользователя и контроль контекста.
Для небольших запросов такая архитектура избыточна. Но если AI-сервис формирует крупный структурированный результат, который пользователь должен последовательно просматривать и корректировать, staged generation становится значительно более предсказуемой моделью.
По сути, здесь работают те же принципы, которые давно используются в DevOps и backend-разработке: большую операцию разбивают на небольшие наблюдаемые стадии, сохраняют промежуточное состояние, делают операции повторяемыми и проектируют систему так, чтобы сбой одного этапа не требовал начинать весь процесс заново.