Первая версия нашей AI-функции работала как обычный API: отправляем запрос, ждём полного ответа, возвращаем JSON. Для короткой задачи - нормально. Для генерации 500-словного резюме - пользователь смотрел на спиннер 8-12 секунд перед тем, как появлялся хоть один символ.
Стриминг решает проблему воспринимаемой задержки: вместо ожидания полного ответа токены приходят к клиенту по мере генерации. Первое слово появляется менее чем за 500ms. Интерфейс кажется мгновенным, даже если генерация занимает 15 секунд.
Почему Edge Runtime
Next.js-роуты могут работать в двух средах:
- Node.js runtime (по умолчанию): полный Node.js API, 150MB памяти на Vercel, cold start ~500ms
- Edge Runtime: V8-изоляты, работают на CDN-нодах рядом с пользователем, cold start ~5ms, только Web API
Разница для AI-стриминга принципиальная:
- Node.js: после периода простоя первый пользователь ждёт 500ms+ до первого токена
- Edge Runtime: первый токен ~80ms независимо от warm/cold состояния
// app/api/generate/route.ts
export const runtime = 'edge'; // Одна строка - переключает на Edge Runtime
Стриминг с Anthropic SDK
import Anthropic from '@anthropic-ai/sdk';
import { NextRequest } from 'next/server';
export const runtime = 'edge';
const anthropic = new Anthropic({
apiKey: process.env.ANTHROPIC_API_KEY,
});
export async function POST(request: NextRequest) {
const { prompt, systemPrompt } = await request.json();
if (!prompt || typeof prompt !== 'string') {
return new Response('Неверный запрос', { status: 400 });
}
const stream = await anthropic.messages.stream({
model: 'claude-opus-4-6',
max_tokens: 1024,
system: systemPrompt ?? 'Вы полезный ассистент.',
messages: [{ role: 'user', content: prompt }],
});
const encoder = new TextEncoder();
// Преобразуем поток Anthropic в ReadableStream с SSE-событиями
const readableStream = new ReadableStream({
async start(controller) {
try {
for await (const chunk of stream) {
if (
chunk.type === 'content_block_delta' &&
chunk.delta.type === 'text_delta'
) {
const data = `data: ${JSON.stringify({ text: chunk.delta.text })}\n\n`;
controller.enqueue(encoder.encode(data));
}
}
controller.enqueue(encoder.encode('data: [DONE]\n\n'));
controller.close();
} catch (error) {
controller.error(error);
}
},
});
return new Response(readableStream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
},
});
}
Клиентский потребитель стрима
'use client';
import { useState } from 'react';
export function AiGenerator() {
const [output, setOutput] = useState('');
const [isStreaming, setIsStreaming] = useState(false);
async function generate(prompt: string) {
setOutput('');
setIsStreaming(true);
try {
const response = await fetch('/api/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt }),
});
if (!response.body) throw new Error('Нет тела ответа');
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
const lines = chunk.split('\n');
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const data = line.slice(6);
if (data === '[DONE]') break;
try {
const { text } = JSON.parse(data);
setOutput(prev => prev + text);
} catch {
// Игнорируем битые чанки
}
}
}
} finally {
setIsStreaming(false);
}
}
return (
<div>
<button onClick={() => generate('Объясни React Server Components')} disabled={isStreaming}>
{isStreaming ? 'Генерирую...' : 'Сгенерировать'}
</button>
<div>{output}</div>
</div>
);
}
Автоматическое переключение: Claude → OpenAI
Anthropic API периодически имеет повышенную задержку. Для продакшена настроили автоматический failover:
// lib/ai/generate.ts
import Anthropic from '@anthropic-ai/sdk';
import OpenAI from 'openai';
const anthropic = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
export async function generateWithFailover(options: {
prompt: string;
system?: string;
maxTokens?: number;
}) {
// Пробуем Anthropic с таймаутом 5 секунд на подключение
try {
const timeoutPromise = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('Anthropic timeout')), 5000)
);
const stream = await Promise.race([
streamFromAnthropic(options),
timeoutPromise,
]);
return { stream, provider: 'anthropic' as const };
} catch (error) {
console.warn('Anthropic недоступен, переключаемся на OpenAI:', error);
const stream = await streamFromOpenAI(options);
return { stream, provider: 'openai' as const };
}
}
5-секундный таймаут - на установку соединения, не на генерацию. Это позволяет быстро обнаружить «Anthropic API недоступен» без преждевременного прерывания медленных, но работающих генераций.
Защита от prompt injection
AI-эндпоинт, принимающий пользовательский ввод, является целью для атак. Пользователи могут пытаться переопределить системный промпт:
// lib/ai/sanitize.ts
const MAX_PROMPT_LENGTH = 2000;
const INJECTION_PATTERNS = [
/ignore (all |previous |)instructions/i,
/you are now/i,
/new (system |)prompt:/i,
/forget everything/i,
/\[system\]/i,
];
export function sanitizePrompt(input: string): { safe: boolean; sanitized: string } {
const trimmed = input.trim().slice(0, MAX_PROMPT_LENGTH);
for (const pattern of INJECTION_PATTERNS) {
if (pattern.test(trimmed)) {
return { safe: false, sanitized: trimmed };
}
}
return { safe: true, sanitized: trimmed };
}
Главное правило: никогда не интерполируйте пользовательский ввод в строку системного промпта. Системный промпт и пользовательский ввод - всегда в разных ролях (system vs user).
Rate limiting на Edge
Edge Runtime не поддерживает прямые TCP-соединения - Redis недоступен напрямую. Используем Upstash Redis с REST API:
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(10, '1 m'), // 10 запросов в минуту с одного IP
});
// В роуте:
const ip = request.headers.get('x-forwarded-for')?.split(',')[0] ?? 'anonymous';
const { success } = await ratelimit.limit(`generate:${ip}`);
if (!success) {
return new Response('Слишком много запросов', { status: 429 });
}
Бенчмарки задержки (Vercel Edge, eu-central-1)
| Метрика | Node.js Runtime | Edge Runtime |
|---|---|---|
| Cold start (после простоя) | 480ms | 8ms |
| Время до первого токена (warm) | 310ms | 95ms |
| Время до первого токена (cold) | 790ms | 103ms |
| P99 время до первого токена | 1200ms | 280ms |
Разница в cold start - главный выигрыш. Пользователь, который заходит первым после 10 минут простоя, при Node.js ждёт ~800ms до первого символа. Edge Runtime - стабильные ~100ms в любом случае.
Для пользователей из Бишкека и Алматы, где серверы зачастую физически далеко, работа через Edge-ноды в Варшаве или Франкфурте даёт дополнительный выигрыш 40-80ms по сравнению с централизованным Node.js-сервером.