Коротко: 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 реального інциденту
Single-step: валідації ніде жити • recovery ховається у “clever prompts” • writes стаються занадто рано
Looped runner: budgets • tool gateway • stop reasons • safe-mode
Ефект: менше інцидентів + дебажні фейли замість “execute & pray”
Проблема (з реального продакшену)
Хтось каже: “ми зробили агента”.
Код такий:
- Один раз викликати модель
- Розпарсити tool call
- Виконати
- Повернути, що вийшло
Це не агент. Це function call з непередбачуваними аргументами.
У демці це швидко. У проді це ламається з тієї ж причини, чому ти взагалі будував агентів: реальні системи шумні, і потрібні feedback + control.
Чому це ламається в продакшені
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 “про всяк випадок”.
Вітаю: ти перевинайшов 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 шлях заборонений.
if action.has_side_effects:
run_looped_runner()
else:
run_single_step()
Це звучить очевидно. Це не очевидно, поки демка працює і нікого ще не пейджили.
Migration path (single-step → loop)
Ось що команди зазвичай шиплять — і чому це ламається:
# 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.
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)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 });
}Це не виглядає “agentic”. Це виглядає operable. У цьому і сенс.
Failure evidence (як це виглядає, коли ламається)
Single-step фейли виглядають як “одне погане рішення з миттєвим blast radius”.
Trace, який пояснює інцидент у 5 рядків:
{"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)
🚨 Інцидент: передчасне закриття тікетів
System: single-step агент “закривати resolved тікети”
Duration: менше 1 години
Impact: 18 тікетів закрито помилково
Що сталося
Агент одразу викликав ticket.close на базі шматка тексту. Він прочитав сарказм як “resolved”.
Найгірше: ніхто не міг пояснити чому. Не було loop state, не було stop reasons, які щось означають, і не було місця для валідації.
Fix
- Route side-effecting actions у looped runner
- Tool gateway policy + audit logs
- Approvals для
ticket.close
Компроміси
- Loop — це більше коду, ніж один model call.
- Більше steps = потенційно більше latency (budgets допомагають).
- Потрібна observability (але вона потрібна все одно).
Коли НЕ варто
- Якщо це справді одна детермінована трансформація — не називай це агентом.
- Якщо задачі потрібен tool feedback і recovery — single-step буде крихким.
- Якщо ти не можеш логувати traces і stop reasons — спочатку виправ observability.
Чекліст (можна копіювати)
- [ ] Якщо є 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
Безпечний дефолтний конфіг
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
Пов’язані сторінки
Production takeaway
Що ламається без цього
- ❌ Writes стаються до валідації
- ❌ “Recovery” живе у промптах і tool retries
- ❌ Нема stop reasons, які пояснюють поведінку
Що працює з цим
- ✅ Side effects routing’яться у bounded runner
- ✅ Budgets + tool gateway тримають run під контролем
- ✅ Фейли explainable (stop reasons + traces)
Мінімум, щоб шипнути
- Routing правило (read-only може бути single-step; side effects — ні)
- Bounded runner (budgets + stop reasons)
- Tool gateway (deny by default)
- Validation layer (до writes)