1. Зачем ChatGPT App вообще нагрузочные тесты?
В классическом вебе нагрузочное тестирование часто ассоциируется с картинкой «миллионы RPS, гигантский кластер, пицца для SRE». Для ChatGPT App и MCP‑серверов реальность попроще и, к счастью, дешевле. В принципе вы уже знакомы с SLO, но давайте посмотрим, как SLO/observability и качество фида взаимодействуют под нагрузкой.
Главная особенность: ChatGPT ждёт завершения tool call, чтобы продолжить генерацию ответа. Пользователь видит красивый стрим токенов, но как только модель решает вызвать инструмент, магия стрима заканчивается — пока backend не ответит. Если ваш MCP или ACP‑сервер иногда отвечает 8–10 секунд вместо целевых 2–4, UX превращается из «волшебный ассистент» в «ещё один медленный сайт».
Плюс есть жёсткий таймаут‑бюджет: для вызовов инструментов OpenAI держит верхнюю границу порядка десятков секунд (точные числа зависят от режима, но думать нужно в пределах 30–60 секунд, а по UX — вообще до 5–10 секунд). Если при пике нагрузки ваши tool calls вдруг начинают укладываться в 25–30 секунд, вы формально ещё в лимите, но с точки зрения пользователя уже «сломались».
Второй момент: нам важна не столько абстрактная RPS, сколько конкурентность. Для App из Store вполне реалистично иметь 50–100 одновременных активных пользователей; именно это и хочется проверить, а не «выдержит ли 50k RPS синтетического GET /health».
И наконец, ChatGPT App — это стек:
flowchart LR User --> ChatGPT ChatGPT -->|tools/call| MCP["MCP сервер GiftGenius"] MCP --> DB["База с фидом подарков"] MCP --> ACP["Checkout / ACP backend"] ACP --> PSP["Платёжка / Stripe"]
Если мы не проверим, как этот стек живёт под небольшой, но реалистичной нагрузкой, то любая промо‑рассылка или попадание в подборку Store может быстро превратить его в слайд «как не надо делать LLM‑продукты».
В этой лекции под «лёгкими нагрузочными тестами» мы будем понимать короткие прогоны (обычно 1–10 минут), которые проверяют:
- выдерживает ли система ожидаемый пиковый онлайн;
- не уезжает ли p95/p99 латентности выше SLO;
- не сыпятся ли ошибки, таймауты и rate‑limit’ы от внешних API.
И параллельно мы посмотрим на вторую сторону качества — данные товарного фида (product feed, далее просто «фид»), без которых никакой GiftGenius не будет ни «Gift», ни «Genius».
В этой лекции мы сначала разберёмся с лёгкими нагрузочными тестами для MCP/ACP (что именно и как нагружать, какие метрики смотреть), потом приземлим это на observability (латентность, ошибки, ресурсы, webhooks и логи), а во второй половине поговорим про качество фида и то, как оно под нагрузкой неожиданно стреляет.
2. Что именно нагружать: не ChatGPT, а свои API
Важно зафиксировать одну мысль, чтобы потом её не путать: нагрузочное тестирование мы проводим напрямую на наш backend — MCP‑сервер, ACP‑эндпоинты, webhooks — а не через ChatGPT UI.
Причин несколько.
- Во‑первых, экономия. Если гонять реальные tool calls через ChatGPT, вы будете платить за токены и одновременно упираться в лимиты ChatGPT, хотя тестируете вы свой код.
- Во‑вторых, предсказуемость. При прямых вызовах /mcp или /api/checkout вы контролируете сценарий, не завися от решений модели, будет ли она сейчас вызывать этот инструмент или нет.
- В‑третьих, прозрачность. Под нагрузкой вы хотите ясно видеть: вот 2000 запросов в MCP за 5 минут, вот распределение латентности, вот график CPU. Если прогонять нагрузку через ChatGPT, дополнительный слой шумов и ограничений только усложнит картину.
Типичный набор endpoint’ов для нагрузочного теста GiftGenius:
- endpoint MCP сервера, который реализует JSON‑RPC tools (/mcp или аналогичный);
- один‑два ACP‑эндпоинта для создания и завершения checkout (в sandbox‑режиме платежки);
- возможно — endpoint, который обрабатывает webhooks от платёжки, чтобы посмотреть, как он ведёт себя при пике событий.
Мы будем считать, что у нас есть Next.js 16 backend, на котором живёт MCP‑сервер, доступный по /api/mcp, и ACP‑сервер с endpoint’ом /api/checkout/create.
3. Мини‑сценарий smoke‑load для GiftGenius
Представим, что наши product‑менеджеры верят в светлое будущее и говорят: «Реалистичный пик — 50 одновременных пользователей, каждый заходит, выбирает подарок и иногда доходит до оплаты».
Для лёгкого нагрузочного теста нам достаточно смоделировать, скажем, 30–50 «виртуальных пользователей» (VU), каждый из которых делает последовательность:
- Вызов инструмента giftgenius.search_gifts (поиск подарков по профилю и бюджету).
- Вызов giftgenius.get_gift_details для пары товаров из результата.
- (Иногда) вызов ACP‑эндпоинта create_checkout_session для одного товара.
Всё это напрямую через HTTP к нашему MCP/ACP, без ChatGPT.
JSON‑RPC вызов к MCP
Пример тела запроса к MCP (упрощённо):
const body = {
jsonrpc: "2.0",
id: "test-" + Math.random(),
method: "tools/call",
params: {
toolName: "giftgenius.search_gifts",
arguments: {
occasion: "birthday",
budget: 50,
interests: ["sport", "books"],
},
},
};
В реальном проекте структура может чуть отличаться, но принцип тот же: один JSON‑RPC метод, внутри — tool и аргументы.
4. Пишем простой нагрузочный скрипт на TypeScript
В качестве первого шага реализуем самую простую часть нашего сценария — вызов giftgenius.search_gifts к MCP. Сначала сделаем минимальный Node.js‑скрипт на TypeScript, который шлёт такие запросы к /api/mcp и замеряет латентность, а потом уже добавим checkout и более сложные пути.
Базовый HTTP‑клиент
Допустим, у нас есть .env с MCP_URL=http://localhost:3000/api/mcp.
// scripts/loadTest.ts
import "dotenv/config";
const MCP_URL = process.env.MCP_URL!;
async function callSearchGifts() {
const body = {
jsonrpc: "2.0",
id: `search-${Date.now()}-${Math.random()}`,
method: "tools/call",
params: {
toolName: "giftgenius.search_gifts",
arguments: { occasion: "birthday", budget: 50 },
},
};
const started = Date.now();
const res = await fetch(MCP_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const latencyMs = Date.now() - started;
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return latencyMs;
}
Здесь же можно добавить простейший парсинг JSON‑ответа, но для целей latency/error rate этого достаточно.
Конкурентный запуск нескольких запросов
Нам нужно управлять количеством одновременных запросов. Для простоты возьмём фиксированное число «виртуальных пользователей» и попросим каждого сделать N запросов подряд.
async function runVirtualUser(iterations: number) {
const latencies: number[] = [];
for (let i = 0; i < iterations; i++) {
try {
const ms = await callSearchGifts();
latencies.push(ms);
} catch (e) {
console.error("Error in VU:", e);
latencies.push(-1); // пометим ошибку
}
}
return latencies;
}
Теперь можно запустить, скажем, 20 таких виртуальных пользователей:
async function main() {
const users = 20;
const iterations = 10;
const tasks = Array.from({ length: users }, () =>
runVirtualUser(iterations),
);
const results = await Promise.all(tasks);
const all = results.flat();
// ...подсчёт метрик
}
main().catch((e) => console.error(e));
Это уже обеспечит примерно 200 вызовов MCP, часть из которых будет выполняться параллельно, то есть с достаточно высокой конкурентностью.
Подсчёт p95 и error rate
Добавим маленькую утилиту для расчёта перцентиля и ошибок. Напомним: p95 — это значение, ниже которого укладывается 95% запросов.
function percentile(values: number[], p: number) {
const sorted = values.filter(v => v >= 0).sort((a, b) => a - b);
if (!sorted.length) return 0;
const idx = Math.floor((p / 100) * (sorted.length - 1));
return sorted[idx];
}
function errorRate(values: number[]) {
const total = values.length;
const errors = values.filter(v => v < 0).length;
return (errors / total) * 100;
}
И в main добавим вывод:
const p95 = percentile(all, 95);
const p99 = percentile(all, 99);
const errRate = errorRate(all);
console.log(`Total: ${all.length}`);
console.log(`p95: ${p95} ms, p99: ${p99} ms`);
console.log(`Error rate: ${errRate.toFixed(2)}%`);
Теперь вы получили минимальный smoke‑load скрипт, который можно запускать локально или на staging перед релизом. При этом вы никак не трогаете ChatGPT, не жжёте токены, а всё внимание на вашем MCP.
Что делать с ACP и checkout
Аналогично можно добавить ещё один helper callCreateCheckoutSession, который будет бить по ACP‑эндпоинту. Здесь важно использовать тестовый/песочный режим платежей, чтобы не накручивать реальные заказы. Типичный вызов будет выглядеть как обычный POST с JSON:
async function callCreateCheckoutSession(productId: string) {
const started = Date.now();
const res = await fetch("http://localhost:3000/api/checkout/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ productId, test: true }),
});
const latencyMs = Date.now() - started;
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return latencyMs;
}
Затем вы можете в runVirtualUser сделать паттерн: 3 раза поиск → 1 раз checkout, чтобы симулировать воронку «поисков больше, чем покупок».
5. Инструменты посерьёзнее: k6 (но по‑простому)
Node‑скрипт хорош как «минимальный вход», но иногда удобно использовать специализированный инструмент, вроде k6, где сценарии пишутся на JavaScript, а сам рантайм — на Go (то есть быстрый).
Пример маленького k6‑скрипта для MCP:
// loadtest-mcp.js
import http from "k6/http";
import { check, sleep } from "k6";
export const options = {
stages: [
{ duration: "30s", target: 30 },
{ duration: "2m", target: 30 },
],
};
export default function () {
const payload = JSON.stringify({
jsonrpc: "2.0",
id: `search-${Math.random()}`,
method: "tools/call",
params: {
toolName: "giftgenius.search_gifts",
arguments: { occasion: "birthday", budget: 50 },
},
});
const res = http.post(__ENV.MCP_URL, payload, {
headers: { "Content-Type": "application/json" },
});
check(res, { "status is 200": (r) => r.status === 200 });
sleep(1);
}
Команда запуска:
MCP_URL=http://localhost:3000/api/mcp k6 run loadtest-mcp.js
k6 сам посчитает p95/p99 и error rate, нарисует красивые отчеты — дальше вы можете экспортировать их в Grafana и другие системы.
Важно, что даже с такими инструментами наша цель остаётся прежней: не выдержать миллион RPS, а убедиться, что при 5–10× от ожидаемого пика система не разваливается и p95 остаётся в пределах SLO.
6. Что смотреть во время (и после) нагрузочного прогона
Мы уже обсуждали метрики и SLO, сейчас просто «приземлим» их на нагрузочный контекст.
Во-первых, latency. Для MCP инструментов типа search_gifts вы заранее задавали себе цель в духе «p95 < 2–3 секунд». Во время smoke‑load вы смотрите: не поползли ли p95/p99 вверх в 2–3 раза. При этом важно сравнивать с baseline: если до изменения кода p95 была 400 мс, а после — 1500 мс, даже если вы формально ещё в SLO, уже повод задуматься.
Во-вторых, error rate. Под нагрузкой часто вылезают неожиданные вещи: исчерпанный пул коннекций к БД, неожиданные 429 от внешнего API, таймауты при обращении к платёжке. При нормальной нагрузке error rate должен быть близок к нулю; в smoke‑load допускаются отдельные сбои, но уж точно не 5–10%.
В‑третьих, ресурсные метрики: CPU, память, иногда — количество открытых файловых дескрипторов и соединений. Они уже зависят от вашей инфраструктуры, но ключевая идея проста: вы не хотите видеть, как при 30 VU CPU 100% и GC ест половину времени.
В‑четвёртых, webhooks. Если у вас commerce‑сценарий, то финишная точка заказа часто зависит от успешной обработки webhook от платежной системы. Важно смотреть не только на скорость запроса в ACP, но и на задержку «webhook пришёл → мы его успешно обработали».
И наконец, логи. Структурированные логи с trace_id/checkout_session_id позволяют после нагрузочного прогона взять пару самых медленных или упавших запросов и пройти по цепочке: MCP → внешнее API → ACP → webhook. Это особенно полезно, если под нагрузкой вы видите странные хвосты p99.
7. Качество данных фида: от структуры к смыслу
Мы посмотрели, как под нагрузкой ведут себя латентность, ошибки и ресурсы. Но даже если по всем этим SLO вы укладываетесь в цели, пользовательский опыт всё равно может «сыпаться» из‑за плохих данных.
Переходим ко второй большой теме: данные. В commerce‑App вроде GiftGenius product feed (фид товаров) — это не «что‑то на диске», а буквально топливо для LLM и агентов. Если в фиде мусор, модель не «придумает» за вас цену и наличие.
Удобно думать про качество фида в трёх слоях.
Структурный уровень
Это базовая валидность данных:
- JSON корректно парсится.
- Все обязательные поля присутствуют: id, name, price, currency, imageUrl, availability и т.д.
- Типы значений соответствуют ожиданиям: цена — число, availability — enum, categories — массив строк.
- Нет дубликатов id.
Часть этого вы уже покрыли контрактными тестами, когда описывали JSON Schema/Zod‑схему для фида. Теперь нужно применять эти схемы к реальным объёмам данных.
Пример простой Zod‑схемы для элемента фида GiftGenius:
import { z } from "zod";
export const giftItemSchema = z.object({
id: z.string().min(1),
name: z.string().min(3),
description: z.string().optional(),
price: z.number().positive(),
currency: z.enum(["USD", "EUR", "GBP"]),
imageUrl: z.string().url(),
inStock: z.boolean(),
tags: z.array(z.string()).default([]),
});
А схема всего фида — просто z.array(giftItemSchema).
Бизнес‑уровень (семантика)
Структурно товар может быть валиден, но с точки зрения бизнеса — абсурден:
- Цена 0 или 0.01 для дорогого товара.
- Валюта не соответствует рынку (USD для товаров, продаваемых только в EUR).
- inStock = true, но дата последнего обновления полгода назад.
- Категории из 1000 вариантов без унификации.
Для этого уровня полезно добавить дополнительные проверки и «правила здравого смысла». Например:
const businessRules = (item: GiftItem) => {
const problems: string[] = [];
if (item.price > 10000) {
problems.push("подозрительно высокая цена");
}
if (!item.inStock && item.tags.includes("bestseller")) {
problems.push("bestseller, но не в наличии");
}
return problems;
};
Эти проверки можно запускать как часть nightly job или при генерации нового фида.
LLM‑уровень
Модель — очень умная, но у неё есть свои «косяки»:
- Описание, забитое HTML, лишними тегами и техническим текстом.
- Перемешанные языки (пол‑фида на русском, пол‑фида на английском) без указания locale.
- Очень длинные «SEO‑названия» в стиле «Купить лучший супер‑пупер подарок срочно дешево».
На этом уровне важно привести данные к дружественному формату:
- Убрать HTML‑теги или привести их к plaintext.
- Нормализовать язык описаний (или хотя бы явно указывать locale).
- Подрезать чрезмерно длинные названия и дубликатную информацию.
Эти задачи можно частично автоматизировать (например, через pre‑processing скрипты), а частично — договориться с командой, которая наполняет фид.
8. Практика: валидатор фида для GiftGenius
Добавим к нашему проекту простой скрипт validateFeed.ts, который будет читать JSON с фидом, валидировать его через Zod и считать базовые метрики качества.
// scripts/validateFeed.ts
import { readFile } from "fs/promises";
import { giftItemSchema } from "../src/schema/giftItem";
async function main() {
const raw = await readFile("data/gift-feed.json", "utf-8");
const data = JSON.parse(raw);
const items = giftItemSchema.array().parse(data);
console.log(`Всего товаров: ${items.length}`);
const missingImages = items.filter(i => !i.imageUrl).length;
console.log(`Без картинок: ${missingImages}`);
}
main().catch((e) => {
console.error("Feed validation failed:", e);
process.exit(1);
});
Здесь мы используем тот же контракт, что и MCP‑сервер, то есть контрактные тесты и проверка фида используют одну схему — это сильно уменьшает вероятность расхождений.
Дальше можно добавить проверки бизнес‑правил и метрики типа:
- доля товаров без описания;
- доля товаров с подозрительно низкой/высокой ценой;
- количество дубликатов id или повторяющихся name + price.
Эти цифры уже можно отправлять в систему метрик (Prometheus, Datadog и т.п.) и держать под ними отдельные SLO на качество данных — так же, как вы задаёте SLO для кода.
9. Как нагрузка и фид связаны между собой
Иногда кажется, что «производительность» и «качество данных» — две не очень связанные темы. На практике они довольно плотно переплетены.
Примеры связок:
- Под нагрузкой часть запросов начинает идти по «редким» веткам логики, которые раньше почти не встречались. Например, товары с особыми типами скидок или нестандартным shipping. Если фид в этих местах грязный, вы можете получить и ошибки, и серьёзную деградацию производительности (куча валидаций, исключений, fallback‑логики).
- Если фид очень шумный (огромные описания с HTML, бессмысленные теги), MCP‑серверу приходится тянуть и сериализовать больше данных, это напрямую влияет на время обработки tool-call’а и размер ответа.
- В commerce‑части плохой фид может привести к большому числу «пустых» checkout‑попыток, когда пользователь выбирает товар, который внезапно out of stock. Это бьёт и по UX, и по метрикам ACP (рост неуспешных intent’ов).
Удобно смотреть на это как на матрицу:
| Проблема фида | Симптом под нагрузкой | Где смотреть |
|---|---|---|
| Неконсистентные цены/валюты | Ошибки в ACP, отклонённые платежи | Логи ACP + SLO checkout |
| Дубликаты товаров | Странные результаты рекомендаций, лишние вызовы | Логи MCP, UX метрики |
| Отсутствуют картинки/описания | Модель даёт «плоские» рекомендации | Логи App + UX отзывы |
| HTML/мусор в описаниях | Медленные сериализации, большие payload'ы | Латентность MCP |
Нагрузочный прогон здесь играет роль фонаря: он помогает подсветить те участки фида, которые в обычной жизни редко трогались, но при активном трафике начинают стрелять.
10. Встраиваем это в release‑процесс GiftGenius
С точки зрения процесса, всё описанное выше не должно быть «однажды перед первым продом». В учебном плане модулей 16 («Production, сеть и масштабирование») и 17 («Наблюдаемость и качество») этот подход зашит именно как часть регулярного release‑checklist’а: перед релизом вы не только гоняете unit/contract/E2E, но и короткий smoke‑load плюс проверку фида.
Разумный минимальный pipeline перед выкладкой новой версии:
- Unit + contract + интеграционные тесты зелёные.
- Короткий smoke‑load против MCP/ACP на staging, если изменялся критичный код (логика поиска, работа с БД, checkout).
- Валидатор фида отрабатывает без ошибок, базовые метрики фида (количество битых записей, доля без картинок и т.п.) в допустимых пределах.
- Дашборды и алерты обновлены с учётом новых endpoint'ов и SLO.
- На случай фейла подготовлен rollback‑план: либо отключение фичи флагом, либо откат билда.
Так ваш GiftGenius перестаёт быть «демкой для DevDay» и превращается в сервис, который готов к жизни в Store и к всплескам трафика.
11. Типичные ошибки при нагрузочных тестах и проверке фида
Ошибка №1: нагрузочный тест «по ChatGPT», а не по своему backend.
Иногда пытаются «протестировать всё как в реальности» и запускают скрипты, которые ходят именно через ChatGPT UI. В итоге упираются в лимиты OpenAI, жгут токены и получают крайне шумные результаты. При этом проблемы MCP/ACP можно было поймать в сто раз дешевле, стреляя прямо в /mcp и /api/checkout.
Ошибка №2: фокус только на среднем времени ответа.
«У нас средняя латентность 500 мс, всё отлично» — а то, что p95 при этом 5 секунд, почему‑то забывают. Мы уже обсуждали в теме SLO, что именно хвост распределения (p95/p99) определяет реальный UX. Под нагрузкой среднее часто остаётся приличным, а хвост растёт в два‑три раза.
Ошибка №3: попытка устроить «enterprise‑нагрузку» вместо практичного smoke‑load.
Месяцами разрабатывать сложный стенд, имитирующий десятки тысяч пользователей, для ChatGPT App уровня GiftGenius — почти всегда лишнее. Гораздо полезнее иметь простой, но регулярно запускаемый smoke‑load на 50–100 VU с понятными метриками.
Ошибка №4: нереалистичный сценарий нагрузки.
Скрипт шлёт один и тот же запрос, без вариаций пользователя, языка, типа товара, и при этом не трогает ACP и webhooks. В результате вы тестируете один горячий happy‑path, а реальные «углы» системы так и остаются в тени. Лучше моделировать хотя бы упрощённый, но правдоподобный флоу: разные бюджеты, разные интересы, часть пользователей доходит до checkout, часть — нет.
Ошибка №5: проверка фида только «на глаз» или в prod.
Фид собрали, выгрузили в прод, увидели странные рекомендации от модели и начали чухать голову. При этом простой скрипт на Zod/JSON Schema мог бы за минуту показать, что 10% товаров без картинок, у 5% цена 0, а у 3% валюта XXX. Отсутствие автоматической валидации фида — один из самых частых источников стыда в commerce‑приложениях.
Ошибка №6: надежда, что LLM «сам всё поймёт» при плохом фиде.
Да, модель умеет много, но она не будет придумывать корректную цену или наличие товара. Если один и тот же товар в фиде встречается с разными ценами, или «в наличии»/«нет в наличии» одновременно, агент может выдать и галлюцинации, и неконсистентный опыт для пользователя. Ответственность за чистоту данных — на вас, а не на модели.
Ошибка №7: отсутствие связи между фид‑метриками и общими SLO.
Можно иметь идеально быстрый MCP и ACP, но если 30% товаров в фиде «битые», пользовательский опыт всё равно будет ужасным. Часто команды отслеживают только технические SLO (латентность, error rate) и игнорируют SLO по качеству данных (минимальный процент валидных SKU, максимум дубликатов и т.п.). В результате «по цифрам всё хорошо», а по ощущениям — нет.
Ошибка №8: запуск нагрузочных тестов прямо в боевом проде без подготовки.
Иногда кто‑то в пятницу вечером решает «быстренько прогнать k6 на боевой MCP», не предупредив никого. В лучшем случае вы собьёте реальные метрики и озадачите on‑call инженера всплеском трафика, в худшем — нарвётесь на rate‑limit’ы внешнего API или платежной системы. Всегда прогоняйте первые сценарии на staging, а если нужен прод‑тест — делайте это осознанно, с окнами и уведомлениями.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ