Если ваш продукт на 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":"Привет"}}
Два правила, на которых спотыкаются все:
- Записи разделяются двумя переводами строки (
\n\n), а не одним. - Одно логическое событие может содержать несколько строк
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в опциюsignalSDK. - 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
и катите в продакшн.