Продакшен‑стек AI‑агента (те, що між агентом і катастрофою)

Агент — це не один промпт. Це стек: бюджети, інструменти, state, логи, контролі. Оце і зупиняє інциденти.
На цій сторінці
  1. Проблема
  2. Чому це стається в реальних системах
  3. Що ламається, якщо це ігнорувати
  4. Стек (що ми реально запускаємо)
  5. Діаграма (де сидить control layer)
  6. Пошарово: що ми вивчили боляче
  7. Вхідна точка (entry point)
  8. Оркестратор (orchestrator)
  9. Шар моделі (model layer)
  10. Шар інструментів (tool layer)
  11. Шар стану (state)
  12. Спостережуваність (observability)
  13. Шар контролю (control layer)
  14. Код: skeleton оркестрації (TypeScript)
  15. Що ми міряємо (бо “наче ок” — це не метрика)
  16. Таксономія stop reasons (щоб дебажити без вайбів)
  17. Мульти‑тенант реальність (де ховаються інциденти)
  18. Ліміти та запобіжники (бо агенти підсилюють падіння сервісів)
  19. Rollout, який ми використовуємо (бо агенти не заслуговують довіри в day one)
  20. Де має жити “memory” (спойлер: не в промптах)
  21. Реагування на інциденти (що варто мати до першого пейджера)
  22. Тести та replay (бо “works on my prompt” — це не тест)
  23. Порядок побудови “boring first”
  24. Реальний фейл
  25. Компроміси
  26. Коли НЕ варто будувати агентний стек
  27. Лінки

Проблема

В dev твій агент “працює”.

В prod:

  • він лупить луп на flaky API
  • робить 200 tool calls, бо “ще один раз”
  • ти не можеш пояснити, що сталося, бо єдиний лог — це final answer

Це не проблема LLM. Це проблема стеку.

Чому це стається в реальних системах

Агент — це, по суті:

  • planner (LLM)
  • runtime (твій код)
  • side effects (tools)
  • state (memory/artifacts)
  • constraints (budgets/policy)
  • observability (logs/audit)

Якщо ти побудуєш тільки planner — тебе попейджать.

Що ламається, якщо це ігнорувати

  • Немає audit = немає postmortem (або postmortem — “це модель”)
  • Немає budgets = unbounded cost
  • Немає policy boundary = випадкові writes з prod creds
  • Немає state = повторна робота, дублікати tool calls, prompt bloat

Стек (що ми реально запускаємо)

  1. Entry point: UI/API, auth, request id
  2. Orchestrator: routing, retries, budgets, tracing
  3. Model layer: LLM calls (зі spend tracking)
  4. Tool layer: APIs, browser, DB (з allowlists)
  5. State: memory, artifacts, caches, idempotency keys
  6. Observability: structured logs, traces, audit events
  7. Control layer: policy engine, kill switch, incident stop

Діаграма (де сидить control layer)

Ось mental model, який ми використовуємо:

Якщо ти засунеш “control” у промпт — у тебе немає control layer. У тебе є рекомендація.

Пошарово: що ми вивчили боляче

Вхідна точка (entry point)

У вхідній точці ти вирішуєш blast radius.

Нормальні дефолти:

  • автентифікувати до запуску агента
  • згенерувати request id
  • прив’язати tenant/environment до цього request id
  • задати budget upfront (не давай моделі “торгуватись” за budgets)

Якщо ти дозволиш моделі обирати tenant або environment, рано чи пізно вона запише не туди.

Оркестратор (orchestrator)

Це runtime, яка тримає агента “чесним”:

  • step loop
  • timeouts
  • retry policy
  • tool allowlists
  • збір trace
  • stop reasons

Якщо цього немає — кожен агент стає окремою snowflake, яка ламається по‑своєму. Snowflakes милі, поки ти не починаєш їх оперувати.

Шар моделі (model layer)

Твій шар моделі — це в основному:

  • provider fallbacks (якщо вони в тебе є)
  • spend tracking
  • прогнозовані формати виходу (tool actions)

Модель — не єдина “ненадійна” частина. Але це єдина частина, яку всі звинувачують — бо так легше, ніж визнати, що runtime не існує.

Шар інструментів (tool layer)

Tools — це місце, де живуть side effects. Тут ти enforce’иш:

  • allowlists (що можна викликати)
  • permissions (куди/що можна писати)
  • idempotency keys (що можна безпечно повторювати)
  • timeouts (що не має зависати)
  • rate limits (щоб не DDoS’нути залежності)

Tool layer не повинна приймати “do the thing” як input. Вона має приймати структуровані args з валідацією.

Шар стану (state)

State — це не один bucket.

Ми ділимо на:

  • scratch: короткі нотатки на один run (малі, структуровані)
  • artifacts: outputs, які потрібні потім (чернетки, витяги, плани)
  • memory: що ти хочеш переносити між runs (обережно)
  • cache: dedupe дорогих reads (URLs, KB lookups)

Якщо звалити все в “memory”, ти отримаєш prompt bloat і гірші відповіді. Якщо не нести нічого — отримаєш повторну роботу і дублікати tool calls.

Спостережуваність (observability)

Якщо ти не можеш відповісти “що він зробив?”, ти не можеш запускати це в проді.

Мінімум:

  • action trace (steps, tool calls, stop reason)
  • структуровані логи tool’ів (args hash, duration, status)
  • оцінка spend/cost
  • per‑tenant usage metrics

Якщо хочеш серйозно — додай tracing (spans на model calls і tools). Але навіть прості структуровані логи краще, ніж “модель так сказала”.

Шар контролю (control layer)

Control layer — це те, що має існувати коли ти спиш:

  • budgets (hard limits)
  • tool permissions (least privilege)
  • approvals (для writes)
  • kill switch (operator stop)
  • incident stop (circuit breakers)

Це не “security theater”. Це те, що перетворює LLM‑демо на систему, яку можна залишити працювати.

Код: skeleton оркестрації (TypeScript)

Тобі не потрібен величезний framework. Тобі потрібні явні точки контролю.

TS
type Budget = { maxSteps: number; maxSeconds: number; maxUsd: number };
type ToolName = "web.search" | "http.get" | "ticket.create";

type Policy = {
  allowTools: ToolName[];
  budget: Budget;
  requireApprovalFor: ToolName[];
};

type AuditEvent =
  | { type: "tool.call"; tool: ToolName; args: unknown; ms: number }
  | { type: "budget.stop"; reason: string }
  | { type: "kill"; reason: string };

export async function runAgent(input: string, policy: Policy) {
  const started = Date.now();
  const events: AuditEvent[] = [];

  for (let step = 0; step < policy.budget.maxSteps; step++) {
    if (Date.now() - started > policy.budget.maxSeconds * 1000) {
      events.push({ type: "budget.stop", reason: "time" });
      break;
    }
    if (await killSwitchIsOn()) {
      events.push({ type: "kill", reason: "operator" });
      break;
    }

    const action = await llmDecideNext(input); // returns {tool, args} or {finish}
    if (action.type === "finish") return { output: action.text, events };

    if (!policy.allowTools.includes(action.tool)) {
      throw new Error(`tool not allowed: ${action.tool}`);
    }
    if (policy.requireApprovalFor.includes(action.tool)) {
      await waitForHumanApproval(action); // (pseudo)
    }

    const t0 = Date.now();
    const obs = await callTool(action.tool, action.args); // must enforce timeouts + idempotency
    events.push({ type: "tool.call", tool: action.tool, args: action.args, ms: Date.now() - t0 });

    input = updateState(input, action, obs); // keep state small, structured
  }

  return { output: "stopped", events };
}

Що ми міряємо (бо “наче ок” — це не метрика)

Якщо ти хочеш запускати агентів у проді, міряй нудне:

  • completion rate (закінчив чи вперся в budget?)
  • p50/p95 runtime
  • p50/p95 tool calls per run
  • cost per run (tokens + tool credits)
  • loop rate (runs, які зупинилися loop guard’ом)
  • policy deny rate (як часто allowlist блокує дію)

Якщо ти не міряєш policy denies, ти “пофіксиш” агента розширенням прав — замість того, щоб фіксити задачу.

Таксономія stop reasons (щоб дебажити без вайбів)

Якщо ти ship’иш агентів без явних stop reasons, твої дашборди будуть 100% вайби: “не спрацювало” → “таймаут” → “може модель погана”.

Ми логуємо один stop_reason на run і сприймаємо це як контракт. Це різниця між:

  • “agent flaky”
  • “60% runs стопаються на tool_timeout:http.get, бо upstream помирає”

Поширені stop reasons, які ми реально бачимо:

  • finish
  • max_steps, max_seconds, max_usd
  • policy_deny:<tool>
  • approval_timeout
  • tool_timeout:<tool>
  • tool_error_exhausted:<tool>
  • loop_detected
  • operator_kill

Приклад event’а (така нудна строка потім рятує цілий день):

JSON
{
  "request_id": "req_9f2c",
  "tenant": "acme-prod",
  "steps": 25,
  "tool_calls": 17,
  "usd_estimate": 1.03,
  "stop_reason": "max_usd"
}

Так, можна зробити fancy “partial success” і “degraded mode”. Але почни з одного stop reason. Зроби його консистентним. Твоє on‑call‑я подякує.

Мульти‑тенант реальність (де ховаються інциденти)

Multi‑tenant агентні системи ламаються дуже передбачувано:

  • неправильний tenant context
  • cross‑tenant caches
  • спільні credentials
  • “глобальні” tools, які тихо мають доступ до всього

Запобіжники:

  • tenant id задає entry point, а не модель
  • caches key’яться tenant + environment
  • credentials scope’яться tenant + environment
  • audit logs завжди включають tenant id

Якщо чогось із цього немає — ти рано чи пізно зіллєш дані.

Ліміти та запобіжники (бо агенти підсилюють падіння сервісів)

Якщо dependency flaky, агент — це failure amplifier: він ретраїть, шукає альтернативи, пробує ще раз, “перевіряє”, пробує ще раз.

Ось як ти перетворюєш:

  • “upstream API повертає 500 протягом 2 хвилин” на
  • “ми надіслали 80k requests і отримали rate limit на годину”

Ми робимо три нудні речі:

  1. Per‑tool concurrency caps (на tenant). Наприклад: browser tool max 2 паралельні runs. Більше — це self‑DDoS.
  2. Rate limiting на межі tool’а. Не всередині моделі.
  3. Circuit breakers, які fail fast, коли error rate спайкає.

Псевдокод:

TS
const httpGet = rateLimit({ perTenantRps: 5 }, async (url: string) => {
  return fetch(url, { signal: AbortSignal.timeout(8000) });
});

const breaker = new CircuitBreaker({
  windowMs: 30_000,
  failureRate: 0.5,
  cooldownMs: 60_000,
});

const res = await breaker.exec(() => httpGet("https://api.example.com/health"));

Коли breaker open, ми стопаємо run з чіткою причиною (tool_unhealthy:http.get), і не робимо вигляд, що модель “reason’ом” пройде через outage. Не пройде. Вона просто спалить budget.

Rollout, який ми використовуємо (бо агенти не заслуговують довіри в day one)

Shipping у prod — це не бінарний перемикач.

Ми ship’имо так:

  1. тільки internal users
  2. тільки read‑only tools
  3. маленький canary‑відсоток
  4. поступово розширюємо permissions (з approvals для writes)
  5. і тільки потім думаємо про “autonomous” поведінку

І так: kill switch весь час має бути під рукою.

Де має жити “memory” (спойлер: не в промптах)

Якщо зберігати все в промпті, ти отримаєш:

  • ballooning context windows
  • гірші відповіді (модель тоне в шумі)
  • більшу вартість

Ми віддаємо перевагу:

  • маленькому структурованому scratchpad на run
  • artifacts поза промптом (drafts, notes, citations)
  • опційній long‑term memory зі строгим scoping + TTL

Memory — це продуктова фіча. Стався до неї як до фічі. Тестуй. Аудитуй. Scope’ай.

Реагування на інциденти (що варто мати до першого пейджера)

Агенти будуть фейлити. Питання в тому, чи можеш ти швидко зупинити шкоду.

Перед тим як ship’ити, переконайся, що ти можеш:

  • вимкнути tool (browser, email, payments) без деплою коду
  • вимкнути tenant, не валячи всіх інших
  • знайти конкретний run по request id
  • зробити replay run’а в безпечному середовищі
  • відповісти “які tool calls були?” за хвилину

Якщо ти цього не можеш, перший інцидент буде повільний і болючий.

Тести та replay (бо “works on my prompt” — це не тест)

Нервова правда: поведінка агента змінюється, якщо ти змінюєш будь‑що. Версія моделі. Prompt. Tool schema. Відповіді upstream API. Навіть timeouts.

Тому ми тестуємо стек, а не тільки промпт:

  • record/replay відповіді tools у sandbox (ті самі inputs, стабільні outputs)
  • ганяємо невеликий набір “golden” задач на кожному деплої
  • робимо assert’и по traces, а не тільки по фінальному тексту (steps, tools, stop_reason)

Це ловило реальні регресії:

  • rename tool schema змусив агента лупитися на validation errors
  • tweak retries подвоїв tool calls (вартість ~2× за ніч)

Якщо ти не можеш детерміновано replay’нути run — дебаг перетворюється на археологію.

Порядок побудови “boring first”

Якщо стартуєш з нуля — будуй у такому порядку:

  1. wrapper для tools (allowlist + timeouts + idempotency)
  2. budgets (steps/time) + stop reasons
  3. audit events (tool calls з args hash)
  4. kill switch
  5. і тільки потім: fancy planning, memory, multi‑agent routing

Більшість команд робить навпаки, бо демки нагороджують “smart”. Продакшен нагороджує “зупиняється, коли стає дивно”.

Реальний фейл

Ми якось зашипили “працюючого” агента без структурованих audit events. Потім він зробив щось дивне в проді.

Таймлайн постмортему:

  • “він дуже багато разів викликав tool”
  • “ми думаємо, що він ретраїв”
  • “ми не можемо сказати, які саме аргументи він використовував”

Це з’їло ~пів дня інженерного часу — здебільшого на суперечки “що взагалі сталося”.

Фікс:

  • кожен tool call емітить структуровану подію (tool, args hash, duration, status)
  • request id протягується через усе
  • kill switch — це один клік, а не деплой коду

Компроміси

  • Більше інструментації = більше коду.
  • Більше policy = більше кейсів “agent refused”.
  • Все одно дешевше, ніж дебажити в темряві.

Коли НЕ варто будувати агентний стек

Якщо це разовий внутрішній скрипт, який запускається раз на тиждень — не over‑engineer. Але якщо воно торкається prod систем або реальних грошей, тобі потрібен стек. Крапка.

Лінки

Не впевнені, що це ваш кейс?

Спроєктувати агента →
⏱️ 10 хв читанняОновлено Бер, 2026Складність: ★★★
Інтегровано: продакшен-контрольOnceOnly
Додай guardrails до агентів з tool-calling
Зашип цей патерн з governance:
  • Бюджетами (кроки / ліміти витрат)
  • Дозволами на інструменти (allowlist / blocklist)
  • Kill switch та аварійна зупинка
  • Ідемпотентність і dedupe
  • Audit logs та трасування
Інтегрована згадка: OnceOnly — контрольний шар для продакшен агент-систем.
Автор

Цю документацію курують і підтримують інженери, які запускають AI-агентів у продакшені.

Контент створено з допомогою AI, із людською редакторською відповідальністю за точність, ясність і продакшн-релевантність.

Патерни та рекомендації базуються на постмортемах, режимах відмов і операційних інцидентах у розгорнутих системах, зокрема під час розробки та експлуатації governance-інфраструктури для агентів у OnceOnly.