О насБлогКонтакты
ИИ и машинное обучение15 июля 2024 г. 5 мин 106Обновлено: 22 июня 2026 г.

Serverless AI: стриминг Claude и OpenAI в Next.js 15 через Edge Runtime

AunimedaAunimeda
📋 Содержание

Первая версия нашей 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-сервером.

Читайте также

AI чатбот для сайта и бизнеса в Бишкеке: GPT, обученные боты и что реально работает в 2026aunimeda
ИИ и машинное обучение

AI чатбот для сайта и бизнеса в Бишкеке: GPT, обученные боты и что реально работает в 2026

Разбираем AI чатботы для бизнеса в Бишкеке: GPT-4 vs Gemini vs обученные боты, реальные кейсы, цены в сомах, подводные камни и когда AI чатбот окупается.

Как внедрить AI в существующий бизнес - пошаговый планaunimeda
ИИ и машинное обучение

Как внедрить AI в существующий бизнес - пошаговый план

Практический план внедрения искусственного интеллекта в бизнес без замены всего стека. С чего начать, что автоматизировать первым и как измерить эффект.

AI-агенты для бизнеса в Бишкеке: что умеют, сколько стоят и как внедрить в 2026 годуaunimeda
ИИ и машинное обучение

AI-агенты для бизнеса в Бишкеке: что умеют, сколько стоят и как внедрить в 2026 году

AI-агент - это не просто чат-бот. Разбираем, как автономные AI-агенты помогают бизнесу в Бишкеке автоматизировать продажи, поддержку и бэк-офис.

Нужна IT-разработка для вашего бизнеса?

Разрабатываем сайты, мобильные приложения и AI-решения для бизнеса в Кыргызстане. Бесплатная консультация.

Получить консультацию Все статьи