Single-step агенти (антипатерн) + фікси + код

  • Побач пастку до того, як вона потрапить у прод.
  • Дізнайся, що ламається при впевненій помилці моделі.
  • Скопіюй безпечні defaults: permissions, budgets, idempotency.
  • Знай, коли агент взагалі не потрібен.
Сигнали виявлення
  • Tool calls на run зростають (або повторюються з args hash).
  • Витрати/токени ростуть без кращих результатів.
  • Retries стають постійними (429/5xx).
“Single-step агент” зазвичай — це completion, приклеєна до side effects. Чому це ламається в продакшені і як виглядає мінімальний production loop.
На цій сторінці
  1. Проблема (з реального продакшену)
  2. Чому це ламається в продакшені
  3. 1) Нема feedback loop = нема recovery
  4. 2) Budgets і stop reasons прикручують занадто пізно
  5. 3) Tool output ігнорують або використовують неправильно
  6. 4) Writes стають coin flip
  7. Коли single-step достатньо (так, інколи)
  8. Жорстке routing правило (яке рятує)
  9. Migration path (single-step → loop)
  10. Приклад реалізації (реальний код)
  11. Failure evidence (як це виглядає, коли ламається)
  12. Приклад інциденту (composite)
  13. 🚨 Інцидент: передчасне закриття тікетів
  14. Компроміси
  15. Коли НЕ варто
  16. Чекліст (можна копіювати)
  17. Безпечний дефолтний конфіг
  18. FAQ
  19. Пов’язані сторінки
  20. Production takeaway
  21. Що ламається без цього
  22. Що працює з цим
  23. Мінімум, щоб шипнути
Коротко

Коротко: Single-step “агенти” (один model call → execute → done) не мають місця для валідації, не мають recovery loop і не мають stop reasons. Вони ламаються, бо прод — шумний. Якщо в тебе є tools або side effects, потрібен bounded loop + governance.

Ти дізнаєшся: Коли single-step реально ок • Мінімальне безпечне routing правило • Інтерфейс bounded loop • Stop reasons • Smell test реального інциденту

Concrete metric

Single-step: валідації ніде жити • recovery ховається у “clever prompts” • writes стаються занадто рано
Looped runner: budgets • tool gateway • stop reasons • safe-mode
Ефект: менше інцидентів + дебажні фейли замість “execute & pray”


Проблема (з реального продакшену)

Хтось каже: “ми зробили агента”.

Код такий:

  1. Один раз викликати модель
  2. Розпарсити tool call
  3. Виконати
  4. Повернути, що вийшло
Truth

Це не агент. Це function call з непередбачуваними аргументами.

У демці це швидко. У проді це ламається з тієї ж причини, чому ти взагалі будував агентів: реальні системи шумні, і потрібні feedback + control.


Чому це ламається в продакшені

Failure analysis

1) Нема feedback loop = нема recovery

Прод повний timeouts, partial responses, 429s, stale data і schema drift. Single-step дизайн не має куди покласти recovery logic, тому команди пхають “recovery” в промпти й потім виконують це всліпу.

2) Budgets і stop reasons прикручують занадто пізно

Команди кажуть: “воно не лупиться, нам не потрібні budgets”.

Потім додають retries у tools, retries у model call, і другий tool call “про всяк випадок”.

Truth

Вітаю: ти перевинайшов loops без governance.

3) Tool output ігнорують або використовують неправильно

Якщо ти викликаєш tool один раз, що робити з output? Зазвичай — просто повернути. Це означає: без валідації, без invariants і без перевірки “ми реально розв’язали задачу?”.

4) Writes стають coin flip

У single-step дизайні модель може одразу запропонувати write. Нема policy “read first, write later”. Blast radius приходить рано.


Коли single-step достатньо (так, інколи)

Single-step ок, коли все це true:

  • Нема tools (або tools строго read-only)
  • Нема side effects (без змін стану)
  • Output використовується як текст, а не як команда
  • Ти можеш валідовувати output строгим schema (або це не потрібно)

Decision framework: single-step OK лише якщо все true:

  • ✅ Read-only (без side effects)
  • ✅ Strongly typed output (або без tools)
  • ✅ Failure дешевий (низький blast radius)
  • ✅ Не потрібен retry/recovery loop

Якщо будь-що false — route в looped runner.


Жорстке routing правило (яке рятує)

Якщо наступний крок може мати side effects — single-step шлях заборонений.

TEXT
if action.has_side_effects:
  run_looped_runner()
else:
  run_single_step()

Це звучить очевидно. Це не очевидно, поки демка працює і нікого ще не пейджили.


Migration path (single-step → loop)

Ось що команди зазвичай шиплять — і чому це ламається:

PYTHON
# v1: single-step (fast, unsafe)
result = tool(llm_decide(task))  # damage can happen before validation

# v2: add validation (still unsafe if the tool already ran)
result = tool(llm_decide(task))
if not valid(result):
    raise RuntimeError("too late: side effect already happened")

# v3: bounded loop (safe enough to operate)
for step in range(max_steps):
    action = llm_decide(state)
    if action.kind == "tool":
        obs = tool_gateway.call(action.name, action.args)  # policy + budgets
        state = update(state, obs)
    else:
        return action.final_answer

Приклад реалізації (реальний код)

Цей патерн залишає single-step там, де йому місце (safe, read-only), а все інше routing’ить у bounded loop runner.

PYTHON
from __future__ import annotations

from dataclasses import dataclass
from typing import Any, Literal


@dataclass(frozen=True)
class Budgets:
  max_steps: int = 25
  max_tool_calls: int = 12
  max_seconds: int = 60


class Stopped(RuntimeError):
  def __init__(self, stop_reason: str):
      super().__init__(stop_reason)
      self.stop_reason = stop_reason


def is_side_effecting(action: dict[str, Any]) -> bool:
  # Production: decide side-effect class in code, not by prompt vibes.
  return action.get("kind") in {"write", "payment", "email", "ticket_close"}


def run_single_step(task: str, *, llm) -> dict[str, Any]:
  """
  Safe single-step: no tools, no writes.
  This is a completion, not an agent.
  """
  text = llm.text({"task": task, "style": "direct"})  # (pseudo)
  return {"status": "ok", "stop_reason": "single_step", "answer": text}


def run_looped(task: str, *, budgets: Budgets, runner) -> dict[str, Any]:
  """
  Delegate to a bounded runner that has:
  - tool gateway
  - output validation
  - stop reasons
  """
  return runner.run(task, budgets=budgets)  # (pseudo)


def route(task: str, *, llm, budgets: Budgets, runner) -> dict[str, Any]:
  # First decision is read-only: are we about to do anything with side effects?
  action = llm.json(
      {
          "task": task,
          "rule": "Return JSON {kind: 'read_only'|'side_effects'} and nothing else.",
          "examples": [{"task": "Summarize this text", "kind": "read_only"}, {"task": "Close ticket #123", "kind": "side_effects"}],
      }
  )  # (pseudo)

  if action.get("kind") == "side_effects":
      return run_looped(task, budgets=budgets, runner=runner)

  return run_single_step(task, llm=llm)
JAVASCRIPT
export class Stopped extends Error {
constructor(stopReason) {
  super(stopReason);
  this.stop_reason = stopReason;
}
}

export function runSingleStep(task, { llm }) {
// Safe single-step: no tools, no writes.
return llm.text({ task, style: "direct" }).then((text) => ({ status: "ok", stop_reason: "single_step", answer: text })); // (pseudo)
}

export function runLooped(task, { budgets, runner }) {
// Delegate to a bounded runner with tool gateway + stop reasons.
return runner.run(task, { budgets }); // (pseudo)
}

export async function route(task, { llm, budgets, runner }) {
const action = await llm.json({
  task,
  rule: "Return JSON {kind: 'read_only'|'side_effects'} and nothing else.",
  examples: [
    { task: "Summarize this text", kind: "read_only" },
    { task: "Close ticket #123", kind: "side_effects" },
  ],
}); // (pseudo)

if (action.kind === "side_effects") return await runLooped(task, { budgets, runner });
return await runSingleStep(task, { llm });
}
Note

Це не виглядає “agentic”. Це виглядає operable. У цьому і сенс.


Failure evidence (як це виглядає, коли ламається)

Single-step фейли виглядають як “одне погане рішення з миттєвим blast radius”.

Trace, який пояснює інцидент у 5 рядків:

JSON
{"run_id":"run_44a1","step":0,"event":"tool_call","tool":"ticket.close","args_hash":"b5d0aa","decision":"allow"}
{"run_id":"run_44a1","step":0,"event":"tool_result","tool":"ticket.close","ok":true}
{"run_id":"run_44a1","step":0,"event":"stop","reason":"success","note":"single-step"}

Якщо це викликає дискомфорт — добре.


Приклад інциденту (composite)

Incident

🚨 Інцидент: передчасне закриття тікетів

System: single-step агент “закривати resolved тікети”
Duration: менше 1 години
Impact: 18 тікетів закрито помилково


Що сталося

Агент одразу викликав ticket.close на базі шматка тексту. Він прочитав сарказм як “resolved”.

Найгірше: ніхто не міг пояснити чому. Не було loop state, не було stop reasons, які щось означають, і не було місця для валідації.


Fix

  1. Route side-effecting actions у looped runner
  2. Tool gateway policy + audit logs
  3. Approvals для ticket.close

Компроміси

Trade-offs
  • Loop — це більше коду, ніж один model call.
  • Більше steps = потенційно більше latency (budgets допомагають).
  • Потрібна observability (але вона потрібна все одно).

Коли НЕ варто

Don’t
  • Якщо це справді одна детермінована трансформація — не називай це агентом.
  • Якщо задачі потрібен tool feedback і recovery — single-step буде крихким.
  • Якщо ти не можеш логувати traces і stop reasons — спочатку виправ observability.

Чекліст (можна копіювати)

Production checklist
  • [ ] Якщо є side effects — потрібен looped runner
  • [ ] Route side-effecting tasks геть від single-step
  • [ ] Додай budgets (steps, tool calls, seconds)
  • [ ] Використай tool gateway (default-deny allowlist)
  • [ ] Валідовуй tool outputs перед дією
  • [ ] Поверни stop reasons (і логуйте їх)
  • [ ] Вимагай approvals для writes

Безпечний дефолтний конфіг

YAML
routing:
  allow_single_step_only_when: "read_only"
budgets:
  max_steps: 25
  max_tool_calls: 12
  max_seconds: 60
tools:
  allow: ["search.read", "kb.read", "http.get"]
writes:
  require_approval: true
stop_reasons:
  return_to_user: true

FAQ

FAQ
Single-step агенти бувають ок?
Так — коли немає tools і немає side effects. Тоді це completion, а не агент.
Хіба loop не повільніший?
Може бути. Приблизно: single-step ≈ 1 LLM call + 1 tool call; 3-step loop ≈ 3 LLM calls + 2 tool calls. Це часто ~3× latency. Budgets обмежують worst case — і швидкість не важлива, якщо воно wrong або unoperable.
Який мінімум governance для loop?
Step limits, tool-call budgets, default-deny tool policy та stop reasons.
Де взяти хороший loop pattern?
Почни з bounded ReAct-style runner і tool gateway. Не вигадуй власний loop без budgets і traces.

Пов’язані сторінки

Related

Production takeaway

Production takeaway

Що ламається без цього

  • ❌ Writes стаються до валідації
  • ❌ “Recovery” живе у промптах і tool retries
  • ❌ Нема stop reasons, які пояснюють поведінку

Що працює з цим

  • ✅ Side effects routing’яться у bounded runner
  • ✅ Budgets + tool gateway тримають run під контролем
  • ✅ Фейли explainable (stop reasons + traces)

Мінімум, щоб шипнути

  1. Routing правило (read-only може бути single-step; side effects — ні)
  2. Bounded runner (budgets + stop reasons)
  3. Tool gateway (deny by default)
  4. Validation layer (до writes)

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

Спроєктувати агента →
⏱️ 8 хв читанняОновлено Бер, 2026Складність: ★★★
Реалізувати в OnceOnly
Safe defaults for tool permissions + write gating.
Використати в OnceOnly
# onceonly guardrails (concept)
version: 1
tools:
  default_mode: read_only
  allowlist:
    - search.read
    - kb.read
    - http.get
writes:
  enabled: false
  require_approval: true
  idempotency: true
controls:
  kill_switch: { enabled: true, mode: disable_writes }
audit:
  enabled: true
Інтегровано: продакшен-контрольOnceOnly
Додай guardrails до агентів з tool-calling
Зашип цей патерн з governance:
  • Бюджетами (кроки / ліміти витрат)
  • Дозволами на інструменти (allowlist / blocklist)
  • Kill switch та аварійна зупинка
  • Ідемпотентність і dedupe
  • Audit logs та трасування
Інтегрована згадка: OnceOnly — контрольний шар для продакшен агент-систем.
Автор

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

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

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