Action is proposed as structured data (tool + args).
Проблема (з реального продакшену)
Агенти — це loops. Loops хочуть продовжувати.
Без step cap “завершив” означає: агент випадково здався. У проді він не здається.
Чому це ламається в продакшені
1) “Він зупиниться, коли буде готово” — не стратегія
У проді є:
- flaky tools
- rate limits
- partial outages
- неоднозначні задачі
Неоднозначно + tools + без cap = лог з сотнями викликів.
2) Без stop reason це не видно
Якщо ти бачиш тільки “timeout”, ти не бачиш, що steps “вибухнули”. Stop reasons — це дебаг.
3) Enforce в run loop
Не в UI. Не “майже завжди”. У loop, на кожному step.
Приклад реалізації (реальний код)
Мінімальний step guard:
from dataclasses import dataclass
@dataclass(frozen=True)
class StepPolicy:
max_steps: int = 25
class StepExceeded(RuntimeError):
def __init__(self, stop_reason: str):
super().__init__(stop_reason)
self.stop_reason = stop_reason
def run(task: str, *, policy: StepPolicy) -> dict:
steps = 0
try:
while True:
steps += 1
if steps > policy.max_steps:
raise StepExceeded("max_steps")
action = llm_decide(task) # (pseudo)
if action.kind != "tool":
return {"status": "ok", "answer": action.final_answer, "steps": steps}
obs = call_tool(action.name, action.args) # (pseudo)
task = update(task, action, obs) # (pseudo)
except StepExceeded as e:
return {"status": "stopped", "stop_reason": e.stop_reason, "steps": steps}export class StepExceeded extends Error {
constructor(stopReason) {
super(stopReason);
this.stopReason = stopReason;
}
}
export function run(task, { maxSteps = 25 } = {}) {
let steps = 0;
try {
while (true) {
steps += 1;
if (steps > maxSteps) throw new StepExceeded("max_steps");
const action = llmDecide(task); // (pseudo)
if (action.kind !== "tool") return { status: "ok", answer: action.finalAnswer, steps };
const obs = callTool(action.name, action.args); // (pseudo)
task = update(task, action, obs); // (pseudo)
}
} catch (e) {
if (e instanceof StepExceeded) return { status: "stopped", stopReason: e.stopReason, steps };
throw e;
}
}Реальний інцидент (з цифрами)
Агент мав “просто” зібрати список результатів.
У нього не було:
- step cap
- loop detection
- і tool повертав трохи різні результати
Результат:
- ~700 tool calls за один run
- ~18 хвилин runtime
- cost був не максимальний (пощастило), але rate limits і queue delay — реальні
Fix:
- step cap + stop reason
- loop detection (args hash)
- dedupe/caching для повторних queries
Компроміси
- Step limits інколи зупиняють раніше. Це краще, ніж unbounded.
- Якщо cap занадто низький, буде більше “stopped” → потрібна UX.
- Step limits без time/cost budgets — неповно (але вже корисно).
Коли НЕ варто
- Чесно: завжди краще мати cap. Якщо ти не хочеш max_steps, тобі потрібні інші жорсткі budgets.
Чекліст (можна копіювати)
- [ ]
max_stepsна run - [ ] Stop reason
max_stepsлогувати + показувати - [ ] Step count у traces
- [ ] loop detection / no-progress stop
- [ ] комбінувати з time + cost budgets
Безпечний дефолтний конфіг (JSON/YAML)
step_limits:
max_steps: 25
stop_reasons:
surface_to_user: true
log: true
FAQ (3–5)
Використовується в патернах
Пов’язані відмови
Q: Який max_steps ставити?
A: Стартуй з 25. Міряй stops. Якщо часто stop — проблема в scope/tools/prompt, а не в цифрі.
Q: Max_steps сам по собі достатній?
A: Ні. Додай max_seconds, max_tool_calls і часто max_usd.
Q: Як називати stop reason?
A: Коротко і машинно: max_steps. Тобі потрібні алерти і дашборди.
Пов’язані сторінки (3–6 лінків)
- Foundations: The ReAct loop explained · Planning vs reactive agents
- Failure: Infinite loop failure · Tool spam loops
- Governance: Budget controls · Kill switch design
- Production stack: Production agent stack