Почему обычный REST не подходит
Классический синхронный эндпоинт устроен так: принял запрос → посчитал → вернул результат. Для агента это ломается сразу по нескольким причинам:
- Долго. Агент отвечает 10–60 секунд. Всё это время клиент видит пустоту, а соединение висит. Прокси и балансировщики любят рвать такие запросы по своему таймауту.
- Ответ появляется постепенно. LLM генерирует токен за токеном. Ждать полной готовности, чтобы отдать всё разом, — терять главное преимущество: пользователь мог бы читать уже через полсекунды (TTFT из урока про дашборды).
- Некоторые задачи длиннее любого разумного HTTP. Глубокий research на 5 минут не должен зависеть от того, не закрыл ли пользователь вкладку.
- Нужен прогресс. «Ищу…», «Анализирую 12 документов…» — без этого долгое ожидание выглядит как зависание.
Отсюда — три разных паттерна под три разных сценария. Выбор между ними и есть главное архитектурное решение агентского API.
Streaming через SSE
Базовый и самый частый паттерн. SSE (Server-Sent Events) — простой способ отдавать данные порциями поверх обычного HTTP: сервер держит ответ открытым и пишет в него события по мере готовности. В FastAPI это StreamingResponse с асинхронным генератором.
from fastapi import FastAPI, Request
from fastapi.responses import StreamingResponse
import json
app = FastAPI()
async def agent_events(question: str, request: Request):
try:
async for chunk in agent.astream(question): # агент стримит шаги/токены
if await request.is_disconnected(): # клиент ушёл — прекращаем
break # (не жжём токены впустую)
payload = {"type": chunk.type, "content": chunk.content}
yield f"data: {json.dumps(payload, ensure_ascii=False)}\n\n" # формат SSE
except Exception as e:
yield f"data: {json.dumps({'type': 'error', 'content': str(e)})}\n\n"
finally:
yield "data: [DONE]\n\n" # явный маркер конца
@app.post("/chat/stream")
async def chat_stream(question: str, request: Request):
return StreamingResponse(
agent_events(question, request),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, # не буферизовать
)
Классическая ситуация: локально стриминг работает, за nginx — нет, ответ приходит целиком в конце. Причина — прокси буферизует ответ, накапливая его перед отправкой. Лечится заголовком X-Accel-Buffering: no (для nginx) и отключением буферизации в конфиге прокси/балансировщика. Проверяй стриминг через реальный прод-стек, а не только локально — иначе весь смысл (быстрый TTFT) теряется незаметно.
Пользователь закрыл вкладку — а агент продолжает работать и жечь токены. await request.is_disconnected() в цикле стрима позволяет вовремя остановиться. Это прямое продолжение урока про таймауты: «перестал ждать» ≠ «остановил» — здесь ты именно останавливаешь работу, экономя деньги и мощности.
Фоновые задачи: job-паттерн
Когда работа длиннее любого разумного HTTP-запроса (deep research, обработка батча документов), стриминг не спасёт — соединение всё равно оборвётся. Нужен job-паттерн: запрос лишь ставит задачу и мгновенно возвращает идентификатор, а результат забирается отдельно.
from fastapi import BackgroundTasks, HTTPException
import uuid
@app.post("/research", status_code=202) # 202 Accepted — «принято в работу»
async def start_research(topic: str, bg: BackgroundTasks):
job_id = str(uuid.uuid4())
await store.set(job_id, {"status": "queued", "topic": topic})
bg.add_task(run_research, job_id, topic) # выполнится ПОСЛЕ отправки ответа
return {"job_id": job_id, "status_url": f"/research/{job_id}"}
async def run_research(job_id: str, topic: str):
await store.update(job_id, {"status": "running", "progress": 0})
try:
result = await agent.deep_research(topic,
on_progress=lambda p: store.update(job_id, {"progress": p}))
await store.update(job_id, {"status": "done", "result": result})
except Exception as e:
await store.update(job_id, {"status": "failed", "error": str(e)})
@app.get("/research/{job_id}")
async def get_status(job_id: str):
job = await store.get(job_id)
if not job:
raise HTTPException(404, "job not found")
return job # клиент опрашивает (или подписывается на SSE со статусом)
Встроенные BackgroundTasks удобны, но у них есть жёсткое ограничение: задача выполняется в том же процессе веб-сервера. Значит — она погибнет при рестарте/деплое, не переживёт падение воркера, не распределится между инстансами и будет отъедать ресурсы у обработки HTTP. Для по-настоящему долгих и важных задач нужна внешняя очередь (Celery, RQ, Dramatiq, ARQ) с отдельными воркерами и персистентным хранилищем состояния (Redis/БД, а не словарь в памяти — он не переживёт рестарт и не виден другим инстансам).
WebSocket: двусторонний канал
SSE — улица с односторонним движением: сервер шлёт, клиент только слушает. Если агенту нужно спрашивать пользователя посреди работы (human-in-the-loop из модуля 03) или пользователь должен иметь возможность прервать/уточнить в любой момент — нужен WebSocket: постоянное двустороннее соединение.
from fastapi import WebSocket, WebSocketDisconnect
@app.websocket("/ws/agent")
async def agent_ws(ws: WebSocket):
await ws.accept()
try:
while True:
msg = await ws.receive_json() # ждём сообщение клиента
if msg["type"] == "cancel": # ← клиент может прервать в ЛЮБОЙ момент
await current_task.cancel()
continue
async for chunk in agent.astream(msg["content"]):
await ws.send_json({"type": "token", "content": chunk})
if chunk.needs_approval: # ← агент СПРАШИВАЕТ пользователя (HITL)
await ws.send_json({"type": "approval_request",
"action": chunk.action})
answer = await ws.receive_json() # ждём решения человека
await agent.resume(approved=answer["approved"])
await ws.send_json({"type": "done"})
except WebSocketDisconnect:
await cleanup(ws) # обязательная очистка ресурсов
Цена гибкости — сложность: WebSocket требует продумывать переподключение (сеть на мобильных рвётся постоянно), хранение состояния сессии и балансировку (соединение «прилипает» к конкретному инстансу — при нескольких репликах нужен sticky-роутинг или общее хранилище состояния). Поэтому правило простое: WebSocket — только если действительно нужна двусторонность. Если клиент лишь читает ответ, SSE проще и надёжнее.
Главная ловушка: блокирующий код
FastAPI выигрывает на агентах именно потому, что асинхронный: пока один запрос ждёт ответа LLM (а он ждёт почти всё время), сервер обслуживает десятки других. Но это работает только если ожидание действительно асинхронное. Одна синхронная блокирующая строка внутри async def — и весь event loop встаёт колом для всех запросов.
time.sleep(5), requests.post(...), синхронный SDK-клиент, тяжёлый CPU-расчёт прямо в async def. Event loop заблокирован — все остальные запросы ждут, сервер «умирает» под нагрузкой при почти нулевом CPU.
await asyncio.sleep(5), httpx.AsyncClient, async-клиенты LLM. Пока запрос ждёт сети, loop обслуживает других. Легаси-синхронный код — через run_in_executor / asyncio.to_thread.
Если библиотека только синхронная (легаси-SDK, тяжёлый парсер), не тащи её в async def напрямую. Варианты: await asyncio.to_thread(blocking_call, args) — выполнит в отдельном потоке, не блокируя loop; либо объяви эндпоинт обычным def (без async) — FastAPI сам выполнит его в пуле потоков. Худший вариант — синхронный вызов внутри async-функции: он останавливает всё. Это прямое продолжение урока про asyncio из модуля 01.
Типичные ошибки
Клиент 30 секунд смотрит в пустоту, прокси рвёт соединение по таймауту. Для ответов агента — стриминг (SSE), для долгих задач — job-паттерн.
requests, time.sleep, синхронный SDK останавливают event loop и с ним весь сервер. Используй async-клиенты или asyncio.to_thread.
Задача в процессе веб-сервера погибнет при деплое/падении и не распределится между инстансами. Для прода — внешняя очередь и персистентное хранилище состояния.
Пользователь закрыл вкладку, а агент работает и жжёт токены. Проверяй request.is_disconnected() в стриме и обрабатывай WebSocketDisconnect.
Словарь jobs = {} не переживёт рестарт и не виден другим репликам — клиент получит 404 по своему job_id. Храни состояние в Redis/БД.
Шпаргалка
ПОЧЕМУ НЕ ОБЫЧНЫЙ REST: ответ ДОЛГИЙ (10–60с) · идёт ПО КУСОЧКАМ ·
часть задач ДОЛЬШЕ HTTP · нужен прогресс
3 ПАТТЕРНА:
① SSE-СТРИМИНГ (по умолчанию) — StreamingResponse + async-генератор
media_type="text/event-stream" · "data: {...}\\n\\n" · маркер [DONE]
✓ TTFT, просто · ✗ односторонний, живёт пока висит запрос
⚠️ ПРОКСИ БУФЕРИЗУЕТ → X-Accel-Buffering: no + проверять на ПРОДЕ, не локально
✓ request.is_disconnected() → клиент ушёл, не жжём токены
② JOB-ПАТТЕРН (дольше HTTP) — POST → 202 + job_id, работа в фоне, GET /jobs/{id}
✓ переживает закрытие вкладки/таймауты · ✗ нужно хранилище состояния
⚠️ BackgroundTasks = В ТОМ ЖЕ ПРОЦЕССЕ: погибнет при рестарте, не распределится
→ прод: внешняя очередь (Celery/RQ/Dramatiq/ARQ) + Redis/БД (НЕ словарь в памяти)
③ WEBSOCKET (нужна двусторонность) — HITL, отмена, уточнения в любой момент
✗ reconnect, состояние сессии, sticky-балансировка → только если ДЕЙСТВИТЕЛЬНО нужно
ГЛАВНАЯ ЛОВУШКА — БЛОКИРУЮЩИЙ КОД в async def:
❌ time.sleep · requests · синхронный SDK · тяжёлый CPU → встаёт ВЕСЬ event loop
✓ await asyncio.sleep · httpx.AsyncClient · async-клиенты LLM
✓ легаси-синхронное → asyncio.to_thread(...) ИЛИ обычный def (FastAPI сам в пул потоков)
ГРАБЛИ: синхронный REST · блокирующий код · BackgroundTasks для важного ·
не проверять отключение клиента · состояние задач в памяти процесса
Практическое задание
Задание: оберни агента в API
- Заверни агента из модулей 03–05 в FastAPI-эндпоинт со стримингом (SSE). Замерь TTFT: через сколько приходит первый токен против полного ответа?
- Добавь обработку отключения клиента. Проверь: если закрыть соединение на середине, останавливается ли работа агента?
- Реализуй job-паттерн для «долгой» задачи:
POSTвозвращает 202 + job_id, статус опрашивается отдельно. Где хранишь состояние и почему не в словаре? - Найди в своём коде блокирующие вызовы внутри
async def(requests, синхронные SDK, тяжёлые вычисления). Переведи на async илиasyncio.to_thread. - Проверь стриминг за реальным прокси/nginx: приходит ли ответ по частям или буферизуется целиком? Что нужно поправить в конфиге?
- Для какого сценария твоего агента нужен именно WebSocket, а где хватит SSE? Обоснуй.
- Со звёздочкой: добавь в стрим события прогресса («ищу», «анализирую») и свяжи их с трейсингом: чтобы каждый запрос имел trace_id, видимый и в логах, и в ответе API.
Что дальше
API есть, агент доступен снаружи. Теперь пора посмотреть на него как на нагруженный сервис: где узкие места по времени, сколько стоит запрос и сколько пользователей мы выдержим. Следующий урок — оптимизация latency, стоимости и пропускной способности: как ускорить, удешевить и масштабировать агента, не потеряв качество.