LLM-агент — ненадёжный компонент
STACHKA · Суриков Антон · Сбер
Доклад про уроки проектирования, а не про готовый продукт
Надёжность дают не промпты, а обычная инженерия на Python.
Метки на слайдах: ПРОТОТИП — работает · ТЗ — целевое требование · ИНЦИДЕНТ — реально ломалось.
• контракт задачи · состояние в БД · идемпотентность
• права вне текста · сквозные тесты
Почему здесь вообще нужны несколько агентов
STACHKA · Суриков Антон · Сбер
Повод для сети агентов — не число систем, а разные владельцы и права
за данные и решения отвечают разные команды
HR, Finance, Docs, почта, календарь
задача живёт дольше сессии
Пять агентов: один руководитель и четыре блока
STACHKA · Суриков Антон · Сбер
Руководитель ставит поручение, блоки 1–4 держат свои данные. Шлюз A2A и хранилище взаимодействий отвечают за доставку, состояния, защиту от дублей и аудит.
• ✓ Каждое сообщение идёт через шлюз
• ✓ Права у каждого агента свои
• ✓ Шлюз не читает текст поручения
«Соберите статусы четырёх блоков к 18:00»
STACHKA · Суриков Антон · Сбер
Руководитель — инициатор. Отвечают четыре блока, каждый за свои данные
• Блоки отвечают параллельно: status · blockers · date · help
• Правило завершения ALL_OR_DEADLINE — ждём все четыре или до дедлайна
• Сводка со ссылками на источники, пропуски и противоречия отмечены
До отправки задаём цель, формат, сроки и лимиты
STACHKA · Суриков Антон · Сбер
kind: TASK
goal: статусы 4 блоков к 18:00
expected_result: StatusReport@1
deadline: "18:00"
completion: ALL_OR_DEADLINE
autonomy_mode: PREPARE_DRAFT
budget: {llm_calls: 20, tokens: 200_000}
max_depth: 1
idempotency_key: status-report:v1
статус и критерий готовности
Pydantic ловит форму и ссылки, но не смысл
STACHKA · Суриков Антон · Сбер
Валидный объект ≠ выполненное поручение. Смысл проверяем отдельно
class StatusReport(BaseModel):
model_config = ConfigDict(strict=True, extra="forbid")
status: Literal["on_track", "at_risk", "blocked"]
blockers: list[str]
sources: list[str] = Field(min_length=1)
@field_validator("sources")
@classmethod
def known(cls, v, info: ValidationInfo):
missing = set(v) - info.context["known_ids"]
if missing:
raise ValueError(f"нет источников: {missing}")
return v
Literal, strict, extra=forbid
каждый id есть в known_ids
цель достигнута? схема не знает
Получатель сначала подтверждает, что понял задачу
STACHKA · Суриков Антон · Сбер
Непонятную задачу уточняем до выполнения, а не после
• understood_goal — «статус блока 3 на 18:00»
• expected_output — StatusReport@1
• missing_information — «дедлайн по задаче 3.2»
• clarification_required — true
совпали цель и ограничения?
заполнены поля результата?
Сроки и повторы контролирует код, а не модель
STACHKA · Суриков Антон · Сбер
Модель пишет текст. Когда задача закончена, решает код
• понимает запрос
• пишет сводку по готовым ответам
• считает ответы и сроки
• делает повторы
• правило завершения ALL_OR_DEADLINE
• пишет состояние в БД
QUORUM(N) — другое правило: «хватит N ответов из M». В сценарии со статусами не используется.
asyncio ждёт ответы, код решает, что дальше
STACHKA · Суриков Антон · Сбер
Задача агента должна пережить рестарт — состояние живёт в БД
async def collect(task: Task) -> Summary:
pending = {asyncio.create_task(ask(b, task))
for b in task.blocks}
replies = []
while pending and (left := task.deadline - now()) > 0:
done, pending = await asyncio.wait(
pending, timeout=left,
return_when=asyncio.FIRST_COMPLETED)
for t in done: # ответ или ошибка — сразу в БД
replies.append(await store.save_reply(task.id, t))
for t in pending:
t.cancel() # сами не отменятся
await store.mark(task.id, "COLLECTED", missing=len(pending))
return await summarize(replies) # LLM — только здесь
отменяет всех при первой ошибке, а нужны частичные ответы
asyncio.Task умер вместе с процессом, Recovery scan поднимает COLLECTING из БД
Когда можно подтверждать обработку
STACHKA · Суриков Антон · Сбер
At-least-once: дубли будут, порядок не гарантирован. ACK — про сообщение, а завершение задачи — отдельный статус
• OUTBOX — состояние и событие в одной транзакции
• INBOX — UNIQUE(idempotency_key), дубль вернёт прежний результат
• ПОВТОРЫ — 0/5/30/120 с + jitter → DLQ
Тест: падение в любой точке даёт один эффект
STACHKA · Суриков Антон · Сбер
Идемпотентность доказывает тест с падением в каждой точке
POINTS = ["before_effect", "after_effect", "after_commit"]
@pytest.mark.parametrize("crash_at", POINTS)
async def test_crash_then_redelivery(system, crash_at):
m = msg(task_id="task-42", key="status:42:v1")
system.inject_crash(crash_at)
with pytest.raises(Crash):
await system.deliver(m) # падение в точке
await system.restart()
await system.deliver(m) # повторная доставка
assert system.effects("status:42:v1") == 1
async def test_parallel_duplicates(system):
a, b = await asyncio.gather(deliver(m), deliver(m))
assert a.result_id == b.result_id
эффект → commit → ACK; самая опасная — после эффекта до commit
тот же ключ с другим payload, таймаут с неизвестным исходом, DLQ
Сколько стоил один неудачный цикл
STACHKA · Суриков Антон · Сбер
Задаче нужны лимиты: вызовы, токены, время, повторы, глубина делегирования
Лимит 65 536 не придуман: сервер запущен с контекстом 131 072 и --parallel 2, то есть по 65 536 на слот.
| Метрика | Значение |
| Запрос на ревью | 106 081 токенов |
| Лимит | 65 536 |
| Не помещается | +40 545 |
| Вход за неудачный цикл | 46 583 |
| Ответ локальной модели | 3–5 минут |
От чьего имени действует агент
STACHKA · Суриков Антон · Сбер
Это главная открытая проблема пилота
как передать контекст сотрудника агенту
кто подтверждает связь агента с сотрудником
хранение, ротация, перевыпуск
Направление: RFC 8693 — короткий токен, где sub = сотрудник, act = агент, scope сужен.
Шлюз передаёт сообщение, но не читает его
STACHKA · Суриков Антон · Сбер
Согласие действует только для той версии, которую видел человек
Открыто: кто раздаёт ключи? Каталог ключей должен подписывать не шлюз.
• X25519 — общий секрет · HKDF-SHA256 — ключ из секрета
• ChaCha20-Poly1305 — шифр + целостность · Ed25519 — подпись отправителя
Три вывода + открытые вопросы
STACHKA · Суриков Антон · Сбер
LLM-агент — ненадёжный компонент. Надёжность даёт инженерия вокруг него.
Открыто: контекст сотрудника и OBO · надёжность шлюза (SQLite на одном узле) · данные для проверки смысла · замер успешного прогона.
1. Состояние задачи — в БД, а не в asyncio.Task
2. Идемпотентность доказывает тест с падением в каждой точке
3. Права, сроки и лимиты проверяет код, а не промпт