1. Ошибки и идемпотентность в ChatGPT App
В классическом вебе многие до сих пор живут в парадигме «пользователь нажал кнопку → один HTTP-запрос → один ответ». В мире LLM это уже давно не так. Модель может решить вызвать ваш инструмент несколько раз, может перегенерировать ответ после нажатия пользователем Regenerate, может переспросить, может столкнуться с сетевой ошибкой по пути. В итоге один и тот же инструмент вполне может быть вызван два или три раза с очень похожими аргументами.
При этом у любой ошибки внезапно появляется два потребителя. С одной стороны — модель, которой нужно понятное машинно-читаемое объяснение, что пошло не так, чтобы она могла исправить аргументы и повторить попытку. С другой — пользовательский UI (виджет и сам чат), где надо показать человеческое сообщение и предложить дальнейшие действия, а не «Error: 500 (see logs)».
Ещё один важный момент: классическая архитектура редко предполагает, что кто-то будет массово нажимать «повтори ответ» и тем самым увеличивать число повторных запросов (ретраев). В ChatGPT этот сценарий — норма. Плюс платформа сама может сделать повторный вызов при временных сетевых проблемах. Поэтому концепция идемпотентности в этой экосистеме — не дополнительная опция, а базовое требование, особенно для инструментов, которые делают что-то «настоящим образом» — создают заказы, списывают деньги, отправляют письма и т.д.
Эта лекция как раз про то, как не позволить одному неудачному вызову инструмента (tool call) сломать пользователю настроение, а вам — продакшен.
Insight
ChatGPT не передает аргументы в ваши функции, а скорее угадывает набор аргументов под вашу схему. Она смотрит на JSON Schema, контекст диалога и статистически подбирает значения — и довольно часто промахивается. Ошибки вроде «не тот тип», «забыл обязательное поле», «противоречивые параметры» — нормальная часть жизни tool-calls, а не форс-мажор. По открытым данным и телеметрии подобные промахи легко занимают до ~30% вызовов для сложных схем.
Для модели это не проблема: она воспринимает ваш ответ как сигнал «аргументы были плохими» и просто пробует ещё раз, возможно два-три раза подряд, слегка меняя вход. Для вас это значит другое: каждый инструмент нужно проектировать так, как будто его почти гарантированно вызовут несколько раз с очень похожими параметрами.
Именно поэтому так важна идемпотентность. ChatGPT будет снова и снова пытаться угадать с какими параметрами нужно вызвать ваши функции. 2-3 попытки на вызов - это норма.
2. Безопасная настройка виджета: text/html+skybridge и _meta
Прежде чем уйти в чисто серверные вопросы (ошибки, ретраи, идемпотентность), закроем один специфический для Apps SDK момент про безопасность UI: как сделать так, чтобы ваш виджет рендерился в чате безопасно, а не как «ужасная страница из интернета».
registerResource и MIME-тип text/html+skybridge
Ваш виджет с точки зрения ChatGPT — это специальный HTML-ресурс, который попадает в песочницу клиента ChatGPT, а не напрямую в браузер пользователя. Чтобы платформа поняла, что это именно виджет, а не просто HTML, используется MIME-тип text/html+skybridge.
На уровне MCP/сервера вы регистрируете ресурс с помощью чего-то вроде (псевдо-TS):
// где-то в конфигурации MCP-сервера
registerResource({
name: "giftgenius-widget",
path: "/widget",
mimeType: "text/html+skybridge", // важно!
});
Этот mimeType — сигнал клиенту ChatGPT: «это не просто HTML, а компонентный шаблон для встроенного виджета, который надо запустить в изолированной среде». Если указать обычный text/html, платформа может показать сырой HTML или вообще отказать в рендере.
_meta и управление безопасностью: CSP, домен и рамка
Дальше в ход идут метаданные, передаваемые вместе с ответом инструмента или ресурса — _meta. Через них вы управляете, какие внешние ресурсы виджет может грузить, как он ведёт себя визуально и даже как модель его будет описывать.
Типичный пример структуры:
const toolResult = {
content: "<!-- HTML виджета -->",
_meta: {
"openai/widgetCSP": "default-src 'self'; img-src https://cdn.example.com",
"openai/widgetDomain": "https://chatgpt.com",
"openai/widgetPrefersBorder": true,
"openai/widgetDescription": "GiftGenius показывает рекомендации подарков в виде карточек."
}
};
Разберём ключевые поля.
- openai/widgetCSP задаёт Content Security Policy для виджета. Это ваш небольшой файрвол для браузера внутри ChatGPT: вы явно перечисляете, откуда можно грузить скрипты, стили, картинки, делать XHR и т.д. Платформа ожидает строгую политику без wildcard *, вам нужно явно указать используемые домены (чат, свой API, CDN).
- openai/widgetDomain задаёт origin, в контексте которого будет работать ваш виджет. Обычно это домен ChatGPT; вы не подменяете его на свой сайт, а просто сообщаете, как это должно выглядеть в изолированной среде.
- openai/widgetPrefersBorder — чисто визуальный флаг: рисовать ли рамку вокруг виджета. Для GiftGenius вполне логично оставить рамку, чтобы визуально отделять блок рекомендаций от обычных сообщений в чате.
- openai/widgetDescription — текстовое описание для модели. Вместо того чтобы пытаться самой «придумать» объяснение, модель может использовать эту строку, когда рассказывает пользователю, что за интерфейс сейчас открылся. Это снижает риск странных или избыточных комментариев модели.
Практический вывод: один раз аккуратно настроив mimeType и _meta, вы получаете безопасный, изолированный UI, который не лезет, куда не надо, и ведёт себя предсказуемо и с точки зрения пользователя, и с точки зрения платформы. С фронтенд-частью безопасности разобрались: виджет живёт в песочнице и ходит только туда, куда вы ему разрешили. Дальше сфокусируемся на серверной стороне — типах ошибок и том, как их описывать и делать инструменты идемпотентными.
Insight: кеширование виджета
ChatGPT кеширует HTML виджета в момент регистрации приложения. HTML-виджет ChatGPT — это не «живой фронтенд», а зафиксированный артефакт сборки. При публикации приложения (Store или Dev Mode) платформа считывает HTML-ресурс (text/html+skybridge) и дальше всегда использует именно эту версию. Любое изменение — даже одна строка текста или отступ в карточке — фактически означает новый релиз.
Отсюда вывод: правки HTML-структуры, слотов, data-* атрибутов и контракта structuredContent → DOM — это не «быстрый фикс», а полноценная фронтенд-миграция. Если сегодня вы рендерите список из items[], а завтра переходите на results[], старый виджет об этом не узнает: он продолжит получать прежний JSON и будет работать некорректно.
3. Типы ошибок в работе инструментов
Теперь перейдём к мясу: какие вообще бывают ошибки у инструмента и чем они отличаются с точки зрения UX и бэкенда. Удобно мыслить четырьмя слоями ошибок.
Ошибки валидации входа
Самый базовый уровень — когда входные аргументы вообще не соответствуют контракту.
Примеры для нашего учебного приложения GiftGenius и его инструмента suggest_gifts (подбор подарков по интересам и бюджету):
- возраст меньше нуля или больше 120;
- бюджет отрицательный;
- обязательное поле relationship_type отсутствует;
- budget_min > budget_max.
Сюда же попадает банальный JSON, не соответствующий схеме. В идеале Apps SDK и JSON Schema отфильтруют «совсем плохие» вызовы ещё до вашего кода, но бизнес-валидацию (типа соотношения budget_min/budget_max) всё равно нужно делать самим.
Ошибки бизнес-логики
Здесь вход вроде бы корректный, но по доменным правилам вы не можете дать нормальный результат.
Типичные сюжетные повороты:
- по заданным интересам и бюджету вы не нашли ни одного подарка;
- пользователь превысил дневной лимит подборок;
- товар, который модель просит купить, больше не продаётся.
Это не «сломался сервер», а нормальные, ожидаемые ситуации, которые надо представить пользователю и модели в удобоваримой форме, а не как 500 Internal Server Error.
Ошибки внешней инфраструктуры
Этот слой уже про «технический ад»: недоступна база, внешний API по таймауту, внутри вашего кода вылетело необработанное исключение.
Например:
- запрос к каталогу подарков возвращает 503 или не отвечает;
- MongoDB внезапно решила встать на паузу;
- в коде фильтрации подарков вы делите на ноль.
С точки зрения UX это часто повод сказать: «Сервис временно недоступен, попробуйте позже», иногда — попробовать скрытый retry. Но важно не сливаться молча и не показывать пользователю сырой stack trace.
Ошибки платформы/сети
И наконец, есть слой, который может случиться вообще вне вашего кода: tool-call не доходил, соединение порвалось в середине ответа, streaming-сценарий прервался. Такое бывает чаще чем вы думаете. Например, если использовать бесплатный туннель, то во время пиковых часов его скорость падает настолько, что ChatGPT tool calls отваливаются по timeout.
Полностью контролировать это вы не можете, но вы точно можете проектировать инструменты и виджет так, чтобы повторные вызовы и прерывания не превращали систему в хаос. Именно поэтому мы говорим об идемпотентности и аккуратной обработке ошибок, а не просто «try/catch и забыли».
4. Как описывать и возвращать ошибки: и для модели, и для UI
Важный сдвиг мышления: ваша ошибка — это не только то, что вы залогировали в console.error. Это часть контракта инструмента, с которой будут работать и модель, и интерфейс.
Структура ошибки
Обычно удобно придерживаться простой структуры:
type ToolError = {
code: string; // "VALIDATION_ERROR", "NO_RESULTS", "UPSTREAM_TIMEOUT"
message: string; // человекочитаемое или компактное для модели
retryable: boolean; // есть ли смысл пробовать ещё раз
};
И результат инструмента можно заворачивать в дискриминирующий юнион:
type SuggestGiftsResult =
| { ok: true; gifts: GiftCard[] }
| { ok: false; error: ToolError };
При этом в MCP-протоколе есть ещё отдельный флаг «это ошибка», но внутри полезно придерживаться собственного формата, чтобы UI и модель могли одинаково интерпретировать, что произошло.
Стратегия «fail gracefully»
Не каждую неприятную ситуацию надо оформлять как «жёсткую» ошибку. Иногда гораздо более полезно вернуть пустой результат, но без ошибки, просто с пояснением.
Например, если подарков не найдено, разумно вернуть ok: true, пустой массив gifts: [] и какое-то поле noResultsReason для UI и модели, вместо "NO_RESULTS" как ошибки. Тогда модель может продолжить диалог: «Я ничего не нашёл в этом бюджете, хотите поднять бюджет или уточнить интересы?».
А вот если внешний API лег полностью, то это скорее ok: false с code: "UPSTREAM_UNAVAILABLE" и retryable: true, чтобы модель имела шанс попробовать ещё раз позже или с другими параметрами.
Напомним, из раздела 3 у нас есть четыре слоя ошибок. Валидационные ошибки обычно идут как ok: false и retryable: false — модели не стоит повторять тот же самый вызов с теми же аргументами. Бизнес-ситуации вроде «ничего не найдено» чаще оформляются как ok: true с пустым результатом и пояснением. Инфраструктурные сбои внешних сервисов — как ok: false с retryable: true, чтобы модель могла безопасно пробовать ещё раз. А ошибки платформы/сети вообще могут происходить до или после вашего кода и в практике часто выглядят как повторный вызов инструмента — именно поэтому нам так важна аккуратная идемпотентность, о которой поговорим дальше.
Не сливаем внутренние детали наружу
В серверном коде легко соблазниться и просто пробросить error.toString() в ответ. Для LLM-инструментов это не лучшая идея: вы получите мусор в диалоге и потенциально раскроете чувствительные детали (URLs внутренних сервисов, stack trace-и, имена таблиц). Рекомендация — перехватывать исключения и преобразовывать их в компактные коды ошибок и аккуратные сообщения.
Пример минимальной обёртки:
try {
const gifts = await loadGiftsFromCatalog(input);
return { ok: true, gifts };
} catch (err) {
console.error("suggest_gifts failed", err);
return {
ok: false,
error: {
code: "UPSTREAM_ERROR",
message: "Catalog service is unavailable",
retryable: true
}
};
}
Модель видит аккуратный сигнал, UI — понятный текст, а подробности остаются в логах.
Отображение ошибки в виджете
С точки зрения React-виджета задача банальна: проверить ok, и если оно false, показать дружелюбное сообщение и, по возможности, способ продолжения.
function GiftResults({ result }: { result: SuggestGiftsResult }) {
if (!result.ok) {
return (
<div>
<p>Не получилось подобрать подарки: {result.error.message}</p>
{result.error.retryable && <p>Попробуйте изменить параметры или повторить запрос.</p>}
</div>
);
}
if (result.gifts.length === 0) {
return <p>Подарков по таким условиям не нашлось. Попробуйте изменить бюджет или интересы.</p>;
}
return <GiftCardsList gifts={result.gifts} />;
}
Это как раз тот случай, когда простое и честное сообщение делает UX существенно приятнее, чем «что-то пошло не так».
Мы уже договорились, что часть ошибок можно честно помечать как retryable: true и предлагать пользователю «попробовать ещё раз». Как только в системе появляются такие ретраи (явные в UI или скрытые на стороне платформы), возникает следующий вопрос: что будет, если один и тот же инструмент вызовут дважды с теми же данными? Это уже история про идемпотентность.
5. Идемпотентность: защита от «ещё один такой же вызов»
Теперь к самой весёлой части. Формально идемпотентность — это свойство операции, при котором повторный вызов с теми же входными данными не меняет состояние системы и результат. В строгом смысле это и про отсутствие повторных побочных эффектов, и про одинаковый ответ. В практике ChatGPT Apps нас в первую очередь интересует первое: чтобы повторные вызовы не портили данные и не создавали новые сущности, даже если сам ответ может чуть отличаться.
В контексте ChatGPT Apps идемпотентность — это защита от всего того добра, что происходит при ретраях, Regenerate и непредсказуемой логике LLM.
Где идемпотентность особенно важна
Инструменты только для чтения по умолчанию обычно безопасны: сколько ни зови suggest_gifts с одними и теми же параметрами, вы просто получите ещё один список подарков. Даже если он чуть отличается, это не меняет состояние системы и не создаёт побочных эффектов.
Критичны инструменты, которые модифицируют состояние внешних систем:
- создание заказа (create_order);
- проведение платежа (charge_card, submit_payment);
- отправка писем и уведомлений (send_email, send_sms);
- создание сущностей с побочными эффектами (например, бронирований).
Если такой инструмент вызывается два раза подряд с практически одинаковыми аргументами, у вас могут появиться дубли заказов, двойные списания и прочие радости бухгалтерии.
Паттерн idempotency_key
Классический подход: добавить к инструменту дополнительный параметр idempotency_key — строковый идентификатор операции. Если запрос с таким ключом уже успешно обработан, сервер не выполняет действие ещё раз, а возвращает сохранённый результат.
Пример расширенной схемы для гипотетического инструмента create_checkout_session в GiftGenius:
const CreateCheckoutSchema = {
type: "object",
properties: {
giftId: {
type: "string",
description: "ID выбранного подарка"
},
idempotency_key: {
type: "string",
description: "Уникальный ключ операции для защиты от дублей"
}
},
required: ["giftId", "idempotency_key"]
} as const;
На сервере обработчик делает примерно следующее:
async function createCheckoutSession(input: CreateCheckoutInput) {
const existing = await db.checkoutSessions.findOne({ idempotencyKey: input.idempotency_key });
if (existing) {
return existing; // возвращаем старый результат
}
const session = await paymentProvider.createSession({ giftId: input.giftId });
await db.checkoutSessions.insert({ idempotencyKey: input.idempotency_key, session });
return session;
}
Если модель по какой-то причине вызовет инструмент второй раз с тем же idempotency_key, пользователь не получит второй платёж, а просто увидит тот же checkout.
Разделение prepare и commit
Для особенно чувствительных действий (платежи, необратимые изменения) часто используют двухфазный подход: отдельный инструмент для подготовки (prepare_*), отдельный — для коммита (commit_*).
Например:
- prepare_order — проверяет наличие товара, считает стоимость, возвращает «черновик заказа»;
- commit_order — по ID черновика создаёт настоящий заказ и инициирует платёж.
Такой дизайн даёт несколько бонусов. Во‑первых, можно сделать первый шаг полностью идемпотентным: повторный prepare_order с теми же параметрами вернёт тот же черновик. Во‑вторых, commit_order можно разрешать вызывать только после явного подтверждения пользователя, что удобно и с точки зрения UX, и с точки зрения безопасности.
6. Безопасный дизайн инструментов
Идемпотентность — это необходимый, но не единственный ингредиент безопасности. Очень многое решает сам дизайн набора инструментов, который вы отдаёте модели.
Принцип наименьших привилегий
Идея простая: каждый инструмент должен уметь ровно то, что нужно для сценария, и ни строчкой больше. Не надо одной функции do_anything_with_user_account, которая:
- может читать, обновлять и удалять всё подряд;
- принимает строку operation и JSON payload на добрую удачу.
Лучше иметь отдельные, чётко описанные tools:
- get_user_profile;
- update_user_preferences;
- create_order;
- cancel_order.
Та же логика и для GiftGenius: suggest_gifts только подбирает варианты; create_checkout_session не шарит о том, как отменять заказы или менять e‑mail пользователя.
Разделение «read» и «write»-инструментов
Хороший паттерн — чётко разделять инструменты, которые только читают данные, и те, что что-то меняют. Запрос каталога подарков (search_products, suggest_gifts) безопасен сам по себе, даже если модель злоупотребляет им. А вот create_order или charge_payment уже требуют более осторожного обращения.
В описаниях таких инструментов стоит явно писать, что они делают и в каком контексте их можно вызывать. Например:
{
"name": "create_checkout_session",
"description": "Создаёт новую сессию оплаты для одного подарка. Вызывай ТОЛЬКО после того, как пользователь явно подтвердил свой выбор.",
"parameters": { /* ... */ }
}
Это не стопроцентная защита (LLM всё равно может ошибиться), но вы как минимум даёте ей чёткий сигнал о рисках.
Human-in-the-loop и подтверждения
Для по‑настоящему «опасных» действий удобно строить сценарий с подтверждением. Например, модель:
- Сначала вызывает инструмент, который подготавливает данные для покупки и возвращает их в удобном для UI виде (название подарка, цена, адрес доставки).
- Платформа показывает пользователю виджет с кнопкой «Подтвердить покупку».
- Лишь после клика по кнопке вызывается инструмент коммита, который и делает реальный платёж.
Так вы не даёте модели возможности «по-тихому» оформить заказ без участия пользователя, даже если она вдруг решит, что это очень умное решение.
Семантика риска в описаниях и аннотациях
В некоторых версиях платформы появляются специальные аннотации вроде destructiveHint, которые сигнализируют, что инструмент может делать необратимые действия. Даже если таких полей нет или они ещё нестабильны, вы можете закладывать эту семантику прямо в description и в названия параметров.
Например, вместо:
{
"name": "delete_user_data",
"description": "Удаляет данные пользователя."
}
сделать:
{
"name": "request_user_data_deletion",
"description": "Отмечает аккаунт пользователя для удаления его личных данных в соответствии с политикой сервиса. Используй ТОЛЬКО после того, как пользователь явно запросил удаление."
}
И заодно построить вокруг этого человеческий подтверждающий UX.
7. Маленькая практическая доработка GiftGenius
Давайте свяжем всё это с нашим учебным приложением GiftGenius — App для подбора подарков. Предположим, что мы добавляем к GiftGenius ещё один инструмент — create_checkout_session, чтобы пользователь мог не только подобрать подарок, но и перейти к оформлению.
С точки зрения JSON Schema и безопасности мы делаем следующее.
Во‑первых, добавляем idempotency_key и аккуратное описание:
const CreateCheckoutTool = {
name: "create_checkout_session",
description:
"Создаёт сессию оплаты для одного выбранного подарка. " +
"Вызывай только после того, как пользователь подтвердил, что хочет купить этот подарок.",
parameters: {
type: "object",
properties: {
gift_id: {
type: "string",
description: "Идентификатор подарка из результата suggest_gifts."
},
idempotency_key: {
type: "string",
description: "Уникальный ключ операции. Используй один и тот же ключ при повторном вызове."
}
},
required: ["gift_id", "idempotency_key"]
}
} as const;
Во‑вторых, на сервере реализуем идемпотентный обработчик:
async function handleCreateCheckout(input: CreateCheckoutInput) {
const existing = await db.checkout.findOne({ idempotencyKey: input.idempotency_key });
if (existing) {
return { ok: true, checkout: existing };
}
const checkout = await payments.createSession({ giftId: input.gift_id });
await db.checkout.insert({ idempotencyKey: input.idempotency_key, ...checkout });
return { ok: true, checkout };
}
В‑третьих, учитываем ошибки:
try {
return await handleCreateCheckout(input);
} catch (err) {
console.error("create_checkout_session failed", err);
return {
ok: false,
error: {
code: "PAYMENT_PROVIDER_ERROR",
message: "Не удалось создать сессию оплаты. Попробуйте позже.",
retryable: true
}
};
}
А в виджете показываем понятное состояние ошибки и, возможно, кнопку «Повторить» на UI-уровне, которая инициирует новый диалог с моделью.
Так, шаг за шагом, наш милый учебный проект перестаёт быть «игрушкой для демо» и медленно превращается в нечто, что теоретически можно выпускать в продакшен.
8. Типичные ошибки при работе с ошибками и идемпотентностью инструментов
Ошибка №1: Ошибка = просто throw и 500.
Если при любом сбое ваш tool просто выбрасывает исключение, которое превращается в «что-то пошло не так», модель и UI остаются без информации. Модель не понимает, стоит ли повторять вызов с другими аргументами, а пользователь не понимает, что делать дальше. Намного лучше возвращать структурированную ошибку с кодом, кратким сообщением и признаком retryable, а внутри сервера уже логировать детали.
Ошибка №2: Отсутствие различий между типами ошибок.
Смешивать валидаторные, бизнесовые и инфраструктурные ошибки в один котёл — плохая идея. В итоге ситуация «ничего не найдено» выглядит для модели и пользователя так же, как «упала база данных». Это ломает UX и мешает модели адекватно реагировать: вместо предложения изменить запрос она будет впадать в режим «сорян, сервис сломан». Это особенно больно, когда вы смешиваете, например, бизнес-ошибки и инфраструктурные ошибки из раздела 3.
Ошибка №3: Неидемпотентные операции в мире ретраев.
Проектировать инструмент create_order так, будто его всегда вызовут ровно один раз — прямой путь к дубликатам заказов, особенно когда пользователь активно жмёт Regenerate или соединение на полдороге рвётся. Если в инструменте есть побочные эффекты, почти всегда стоит добавить idempotency_key и хранить результаты, чтобы повторный вызов не создавал новые сущности.
Ошибка №4: Один монструозный «универсальный» инструмент.
Иногда разработчики пытаются сделать один суперинструмент с параметром action, который умеет всё: искать, создавать, изменять и удалять. Для LLM это почти гарантированный способ сделать поведение непредсказуемым: модель сложнее учится, что когда вызывать, и последствия ошибок становятся намного тяжелее. Правильнее разбивать на маленькие, чётко описанные, по возможности read-only инструменты и отдельно — аккуратно оформленные mutating-инструменты с подтверждениями.
Ошибка №5: Протекание внутренних деталей в ответы.
Выбрасывать в модель и UI raw stack trace или полные тексты исключений — типичная инженерная лень. Это неудобно пользователю, может раскрывать внутреннюю структуру системы и не помогает модели исправиться. Стоит перехватывать исключения, маппить их в компактные коды и простые сообщения, а все детали оставлять в логах и системе мониторинга.
Ошибка №6: Отсутствие связки ошибок с UX виджета.
Часто серверная часть аккуратно возвращает коды ошибок, а виджет с UI просто падает в вечный spinner или пустой блок. Пользователь видит «ничего не произошло», модель видит, что tool-call завершился, и продолжает диалог как ни в чём не бывало. Гораздо лучше продумать отдельные состояния error и empty, показывать человеку понятные сообщения и, по возможности, подсказывать варианты действий (изменить параметры, попробовать позже).
Ошибка №7: Игнорирование принципа наименьших привилегий.
Даже если вы сделали идемпотентность и хорошую обработку ошибок, но при этом описали инструмент вроде execute_sql_anywhere, который может делать всё, риск остаётся огромным. LLM может вызвать его в неправильном контексте или с ошибочными параметрами. Каждый инструмент должен быть максимально узким и делать ровно одно понятное действие — особенно, когда речь идёт о деньгах или личных данных пользователя.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ