JavaRush /Курсы /ChatGPT Apps /Лёгкие нагрузочные тесты и качество данных фида

Лёгкие нагрузочные тесты и качество данных фида

ChatGPT Apps
17 уровень , 4 лекция
Открыта

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), каждый из которых делает последовательность:

  1. Вызов инструмента giftgenius.search_gifts (поиск подарков по профилю и бюджету).
  2. Вызов giftgenius.get_gift_details для пары товаров из результата.
  3. (Иногда) вызов 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 перед выкладкой новой версии:

  1. Unit + contract + интеграционные тесты зелёные.
  2. Короткий smoke‑load против MCP/ACP на staging, если изменялся критичный код (логика поиска, работа с БД, checkout).
  3. Валидатор фида отрабатывает без ошибок, базовые метрики фида (количество битых записей, доля без картинок и т.п.) в допустимых пределах.
  4. Дашборды и алерты обновлены с учётом новых endpoint'ов и SLO.
  5. На случай фейла подготовлен 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, а если нужен прод‑тест — делайте это осознанно, с окнами и уведомлениями.

1
Задача
ChatGPT Apps, 17 уровень, 4 лекция
Недоступна
Мини-библиотека метрик (p50/p95/p99 + error rate) и демо-эндпоинт
Мини-библиотека метрик (p50/p95/p99 + error rate) и демо-эндпоинт
1
Задача
ChatGPT Apps, 17 уровень, 4 лекция
Недоступна
Smoke-load скрипт для MCP endpoint (конкурентность + SLO-gate по p95)
Smoke-load скрипт для MCP endpoint (конкурентность + SLO-gate по p95)
1
Опрос
Наблюдаемость и качество, 17 уровень, 4 лекция
Недоступен
Наблюдаемость и качество
Наблюдаемость и качество
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ