Перейти к содержимому
СТРИМИНГ

Стриминг ответов Claude API через SSE в 2026: TypeScript и Python

Стриминг через Server-Sent Events вдвое сокращает воспринимаемую латентность Claude API. Как корректно читать стрим в TypeScript и Python через Claudexia.

Если ваш продукт на Claude кажется медленным, проблема почти никогда не в общей пропускной способности — она во времени до первого токена (TTFT). Нестриминговый ответ, который приходит за 8 секунд и содержит 600 токенов, ощущается сломанным. Те же 600 токенов в режиме стриминга ощущаются мгновенными, потому что первые слова появляются менее чем за 400 мс, а остальное прокручивается как живой набор. Стриминг не ускоряет модель; он делает воспринимаемую латентность примерно вдвое меньше реальной — а это та метрика, которую чувствуют ваши пользователи.

Этот гайд покрывает то, как Claude отдаёт ответы через Server-Sent Events (SSE), таксономию событий Anthropic, которую вы обязаны обрабатывать, и продакшн-паттерны на TypeScript (Next.js Edge runtime) и Python (httpx async). Все примеры используют Anthropic-совместимый шлюз Claudexia по адресу https://api.claudexia.tech/v1, который является drop-in заменой для api.anthropic.com/v1.

TTFT против полной латентности

Когда вы бенчмаркаете вызов Claude, фиксируйте две цифры:

  • TTFT — миллисекунды до прихода первой content-дельты.
  • Total — миллисекунды до message_stop.

Для вызова Sonnet, который генерирует ~800 выходных токенов, TTFT обычно составляет 300–600 мс, а Total — 4–8 секунд. Без стриминга пользователь смотрит на спиннер все 8 секунд. Со стримингом он видит текст через 400 мс и читает его по мере генерации. Общий tokens-per-second (TPS) одинаков в обоих случаях; вы покупаете восприятие, а не пропускную способность.

Формат SSE

Server-Sent Events — это однонаправленный HTTP-стриминговый протокол. Ответ имеет Content-Type: text/event-stream, а тело состоит из последовательности записей, разделённых пустыми строками. Каждая запись выглядит так:

event: content_block_delta
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Привет"}}

Два правила, на которых спотыкаются все:

  1. Записи разделяются двумя переводами строки (\n\n), а не одним.
  2. Одно логическое событие может содержать несколько строк data:, которые потребитель склеивает через \n перед JSON-парсингом.

Большинство SDK скрывают это за вас, но как только вы парсите SSE руками — внутри Edge Worker, на Go или для отладки — это правило надо уважать.

Типы событий Anthropic

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

  • message_start — стартовый конверт сообщения с id, model и обнулённым usage. Здесь же фиксируйте message id для логов.
  • content_block_start — начинается новый блок контента. Блоки бывают text, tool_use, thinking или redacted_thinking. Индекс важен, если модель эмитит несколько блоков.
  • content_block_delta — инкрементальная нагрузка. Для текста несёт text_delta; для tool-вызовов — input_json_delta (частичные JSON-фрагменты, которые надо конкатенировать перед парсингом).
  • content_block_stop — блок завершён.
  • message_delta — апдейты на уровне сообщения, важнее всего stop_reason (end_turn, max_tokens, tool_use, stop_sequence) и финальные счётчики usage.
  • message_stop — терминальное событие. Закрывайте читатель.
  • ping — keep-alive каждые ~15 секунд. Игнорируйте полезную нагрузку, но не закрывайте стрим; пинги существуют именно для того, чтобы reverse-proxy не убил idle-соединение.
  • error — да, ошибки могут прийти посреди стрима как обычное SSE-событие (перегруженная модель, остановка по content policy, upstream timeout). Ваш обработчик должен трактовать error-as-event так же, как брошенное исключение.

TypeScript: Next.js Edge Route Handler

Самый чистый паттерн для Next.js — Edge Route Handler, который проксирует стрим Claude прямо в браузер. Edge runtime даёт нативный ReadableStream без cold-start налога.

// app/api/chat/route.ts
import Anthropic from "@anthropic-ai/sdk";

export const runtime = "edge";

const client = new Anthropic({
  apiKey: process.env.CLAUDEXIA_API_KEY!,
  baseURL: "https://api.claudexia.tech/v1",
});

export async function POST(req: Request) {
  const { messages } = await req.json();
  const controller = new AbortController();
  req.signal.addEventListener("abort", () => controller.abort());

  const stream = await client.messages.stream(
    {
      model: "claude-sonnet-4.6",
      max_tokens: 1024,
      messages,
    },
    { signal: controller.signal },
  );

  const encoder = new TextEncoder();
  const body = new ReadableStream({
    async start(ctrl) {
      try {
        for await (const event of stream) {
          if (event.type === "content_block_delta" && event.delta.type === "text_delta") {
            ctrl.enqueue(encoder.encode(event.delta.text));
          }
        }
      } catch (err) {
        ctrl.enqueue(encoder.encode(`\n[error] ${(err as Error).message}`));
      } finally {
        ctrl.close();
      }
    },
    cancel() {
      controller.abort();
    },
  });

  return new Response(body, {
    headers: {
      "Content-Type": "text/plain; charset=utf-8",
      "Cache-Control": "no-cache, no-transform",
      "X-Accel-Buffering": "no",
    },
  });
}

Три детали, на которых обжигаются продакшн-команды:

  • X-Accel-Buffering: no отключает буферизацию ответа в nginx. Без него ваш стрим буферизуется в один кусок и TTFT возвращается к нестриминговому случаю.
  • Cache-Control: no-transform запрещает посредникам gzip-ить и переразбивать ответ.
  • Подключение req.signal к AbortController — это то, что позволяет отменить upstream-вызов Claude, когда вкладка браузера закрывается. Без этого вы продолжаете платить за токены, которые никто не прочитает.

Python: асинхронный стриминг через httpx

Для бэкенд-сервисов официальный SDK anthropic уже умеет стримить. Когда нужен более низкий уровень контроля — кастомный прокси, broadcast fan-out или инструментирование — спускайтесь к httpx напрямую.

import json
import httpx

URL = "https://api.claudexia.tech/v1/messages"

async def stream_claude(prompt: str, api_key: str):
    headers = {
        "x-api-key": api_key,
        "anthropic-version": "2023-06-01",
        "content-type": "application/json",
        "accept": "text/event-stream",
    }
    payload = {
        "model": "claude-sonnet-4.6",
        "max_tokens": 1024,
        "stream": True,
        "messages": [{"role": "user", "content": prompt}],
    }

    async with httpx.AsyncClient(timeout=None) as client:
        async with client.stream("POST", URL, headers=headers, json=payload) as resp:
            resp.raise_for_status()
            event_name = None
            async for line in resp.aiter_lines():
                if not line:
                    event_name = None
                    continue
                if line.startswith("event: "):
                    event_name = line[7:].strip()
                elif line.startswith("data: "):
                    data = json.loads(line[6:])
                    if event_name == "content_block_delta":
                        delta = data.get("delta", {})
                        if delta.get("type") == "text_delta":
                            yield delta["text"]
                    elif event_name == "error":
                        raise RuntimeError(data.get("error", {}).get("message", "stream error"))
                    elif event_name == "message_stop":
                        return

Замечания:

  • timeout=None у клиента обязателен. Дефолтный 5-секундный read timeout убьёт любой стрим длиннее пяти секунд.
  • aiter_lines() нормализует \r\n против \n за вас.
  • Проверка на пустую строку сбрасывает event_name, и это важно: записи SSE разделены пустой строкой, а следующая запись может опустить поле event: (тогда дефолт — message).

Backpressure и отмена

Claude может выдавать токены быстрее, чем ваш downstream-потребитель успевает их писать — в браузер, в БД, в другой сервис. Если не уважать backpressure, runtime ставит байты в очередь в памяти и при нагрузке вы получаете OOM.

В Node ReadableStream со стандартной byteLength-стратегией очереди даёт backpressure из коробки. В Python предпочитайте async for сбору в список. Для broadcast fan-out (один стрим Claude → много WebSocket-клиентов) используйте ограниченную asyncio.Queue на клиента и дропайте медленного клиента, никогда не upstream.

Отмена — вторая половина задачи. Когда пользователь закрывает вкладку, вы обязаны прервать upstream-запрос:

  • TypeScript: пробросьте request.signal в опцию signal SDK.
  • Python: оберните цикл по телу в try/finally и положитесь на context manager httpx, чтобы закрыть соединение, — или явно вызовите await resp.aclose(), когда upstream-клиент отвалился.

Вы платите за каждый токен, который сгенерировала модель, даже за те, которые никто не прочитает. Отмена — это инструмент контроля себестоимости, а не только UX-удобство.

Типичные баги

  • Забыли flush. Express, Fastify и любой кастомный Node-фреймворк по умолчанию буферизуют запись. Поставьте Content-Type: text/event-stream, Cache-Control: no-cache, X-Accel-Buffering: no и сразу же вызовите res.flushHeaders().
  • Нет пингов → 60-секундный таймаут прокси. Cloudflare, nginx и большинство ALB закрывают idle HTTP-соединения через 60 секунд. Пинги Claude держат соединение тёплым; если вы фильтруете их и модель долго думает перед началом текста, прокси разрывает соединение. Либо пробрасывайте пинги, либо эмитьте свои keep-alive комментарии (: keepalive\n\n) каждые 15 секунд.
  • Неправильно склеиваете JSON tool-use. Куски input_json_delta — это частичные JSON-фрагменты. Накапливайте строку и парсите JSON.parse только после content_block_stop для этого блока.
  • Считаете mid-stream ошибку транспортной. Событие error — это обычная SSE-запись, а не HTTP-ошибка. Ваш reader не бросит исключение сам — вы должны проверять тип события и бросать вручную.
  • Синхронно логируете дельты. console.log на каждом text_delta убивает пропускную способность. Буферизуйте логи и эмитьте на message_stop.

Итог

Стриминг — единственное UX-изменение с самым высоким рычагом, которое вы можете внести в интеграцию с Claude. Используйте SDK там, где можно, спускайтесь к голому SSE там, где надо, и помните: обрабатывайте пинги, отменяйте при дисконнекте и никогда не доверяйте дефолтным таймаутам прокси. Укажите base_url на https://api.claudexia.tech/v1, оставьте имеющийся код Anthropic SDK и катите в продакшн.