Проблема
Твій перший агент спробує зробити все й одразу.
Він полізе в веб, почне “підкручувати” конфіги, захоче писати в бази — і загалом поводитиметься як інтерн із root-доступом.
Не стартуй так.
Почни з одного інструмента, тільки read-only, і одразу з бюджетами та логами.
Чому це важливо в реальних системах
Перший раз, коли ти зашипиш tool calling, ти гарантовано знайдеш:
- відсутні таймаути
- відсутню ідемпотентність
- логи, які “є”, але нічого не пояснюють
Краще знайти це на одному інструменті, ніж на дванадцяти.
Плейбук (20 хвилин, без героїзму)
- Обери один read-only tool (
web.searchабоkb.search). - Додай бюджети: steps + time (+ $ якщо можеш).
- Логуй кожен tool call (назва, args hash, тривалість, статус).
- Додай loop detection на повтори з тими самими args.
- Поки що не додавай “send” чи “write” tools.
Що насправді означає «один інструмент»
«Один інструмент» — це не «одна функція + прихований аварійний вихід».
Це означає:
- ти можеш показати allowlist і порахувати її на одній руці
- у тебе немає універсального
http.requestіз повним інтернетом - у тебе немає
run_shell“для дебагу”
Якщо твій перший агент може фетчити будь-які URL — ти вже пропустив безпечну фазу.
Крок 0: обери задачу, яка не збанкрутить тебе
Хороші перші задачі:
- “знайди 5 релевантних доків у нашій KB”
- “підсумуй останні інциденти з внутрішньої папки з постмортемами”
- “задрафти відповідь за відомими шаблонами”
Погані перші задачі:
- “гугли, доки не станеш впевненим”
- “пофікси прод-конфіг”
- “запускай команди на серверах”
Перший агент має бути нудним і дешевим.
Крок 1: бюджети (те, що перетворює луп на систему)
Бюджети — це не “nice to have”. Це різниця між:
- запитом, який зупиняється
- запитом, який продовжує списувати гроші
Мінімум, який ми ставимо:
- max steps
- max seconds
Якщо можеш оцінювати витрати у $ — додай це одразу. Це окупається.
Крок 2: логуй trace дій
У перший день не потрібна “ідеальна observability”. Потрібно хоча б те, що відповідає на питання:
- які tools він викликав?
- з якими аргументами?
- скільки тривав кожен виклик?
- чому він зупинився?
Якщо ти не можеш це відповісти — ти не можеш це шипнути.
Крок 2.5: зроби інструменти детермінованими (інакше не віддебажиш)
Агент — це луп. Лупи й так складні. А тепер додай tools, які повертають різні результати в кожному запуску (таймаути, флейкові API, сторінки що змінюються). Вітаю: ти збудував недетерміновану систему й скоро дебажитимеш це о 03:00.
Трюк, який ми використовуємо з самого початку: record/replay. Для конкретного run’а записуй:
- назву tool
- args hash
- відповідь (або помилку)
Тоді ти можеш “прокрутити” той самий світ і протестувати:
- зміни промпта
- зміну моделі
- налаштування loop guard
Мінімальний концептуальний код:
class TapeTools(SafeTools):
def __init__(self, allow: set[str], impl: dict, tape: list[dict] | None = None):
super().__init__(allow=allow, impl=impl)
self.tape = tape if tape is not None else []
def call(self, name: str, *, args: dict[str, Any], request_id: str) -> Any:
try:
out = super().call(name, args=args, request_id=request_id)
self.tape.append({"tool": name, "args": args, "ok": True, "out": out})
return out
except Exception as e:
self.tape.append({"tool": name, "args": args, "ok": False, "err": str(e)})
raise
def replay(tape: list[dict]):
# Replace real tools with deterministic playback.
i = 0
def impl(name: str, **args):
nonlocal i
item = tape[i]
i += 1
assert item["tool"] == name
if not item["ok"]:
raise RuntimeError(item["err"])
return item["out"]
return implexport class TapeTools extends SafeTools {
constructor({ allow, impl, tape }) {
super({ allow, impl });
this.tape = tape || [];
}
async call(name, { args, requestId }) {
try {
const out = await super.call(name, { args, requestId });
this.tape.push({ tool: name, args, ok: true, out });
return out;
} catch (e) {
this.tape.push({
tool: name,
args,
ok: false,
err: String(e && e.message ? e.message : e),
});
throw e;
}
}
}
export function replay(tape) {
let i = 0;
return async function call(name, args) {
const item = tape[i];
i += 1;
if (!item || item.tool !== name) throw new Error("tape mismatch");
if (!item.ok) throw new Error(item.err);
return item.out;
};
}Це гламурно? Ні. Дає можливість проганяти “golden suite” із 20 задач перед релізом? Так. Саме так ти дізнаєшся про регресії до того, як їх знайде клієнт.
Код (мінімально, але по-продакшену)
from dataclasses import dataclass
import time
from typing import Any
@dataclass(frozen=True)
class Budget:
max_steps: int = 12
max_seconds: int = 20
class Tools:
def __init__(self):
self.allow = {"web.search"}
def call(self, name: str, *, args: dict[str, Any]) -> Any:
if name not in self.allow:
raise RuntimeError(f"tool not allowed: {name}")
return tool_impl(name, args=args) # (pseudo)
def first_agent(question: str, *, tools: Tools, budget: Budget) -> str:
started = time.time()
last = None
for step in range(budget.max_steps):
if time.time() - started > budget.max_seconds:
return "stopped: time budget exceeded"
action = llm_pick_action(question) # (pseudo): either {"tool":..., "args":...} or {"finish":...}
if action.get("finish"):
return action["finish"]
key = f"{action['tool']}:{action['args']}"
if key == last:
return "stopped: loop guard (same call twice)"
last = key
obs = tools.call(action["tool"], args=action["args"])
question = update_state(question, action, obs) # (pseudo)
return "stopped: step budget exceeded"export class Tools {
constructor() {
this.allow = new Set(["web.search"]);
}
async call(name, { args }) {
if (!this.allow.has(name)) throw new Error("tool not allowed: " + name);
return toolImpl(name, { args }); // (pseudo)
}
}
export async function firstAgent(question, { tools, budget }) {
const started = Date.now();
let last = null;
for (let step = 0; step < budget.max_steps; step++) {
if ((Date.now() - started) / 1000 > budget.max_seconds) {
return "stopped: time budget exceeded";
}
const action = await llmPickAction(question); // (pseudo): { tool, args } or { finish }
if (action.finish) return action.finish;
const key = String(action.tool) + ":" + JSON.stringify(action.args);
if (key === last) return "stopped: loop guard (same call twice)";
last = key;
const obs = await tools.call(action.tool, { args: action.args });
question = updateState(question, action, obs); // (pseudo)
}
return "stopped: step budget exceeded";
}Зроби це реальним (маленький runnable skeleton)
Мінімальний приклад вище навмисно короткий. Ось трохи повніший “single file” skeleton, який реально не соромно поставити за API:
from dataclasses import dataclass
import hashlib
import json
import time
import uuid
from typing import Any, Callable
@dataclass(frozen=True)
class Budget:
max_steps: int = 12
max_seconds: int = 20
def args_hash(args: dict[str, Any]) -> str:
raw = json.dumps(args, sort_keys=True, default=str).encode("utf-8")
return hashlib.sha256(raw).hexdigest()[:12]
class LoopGuard(RuntimeError):
pass
class Monitor:
def __init__(self, *, max_repeat: int = 2):
self.max_repeat = max_repeat
self.counts: dict[str, int] = {}
def mark(self, tool: str, args: dict[str, Any]) -> None:
key = f"{tool}:{args_hash(args)}"
self.counts[key] = self.counts.get(key, 0) + 1
if self.counts[key] >= self.max_repeat:
raise LoopGuard(f"loop guard: {key} repeated {self.counts[key]}x")
class SafeTools:
def __init__(self, allow: set[str], impl: dict[str, Callable[..., Any]]):
self.allow = allow
self.impl = impl
def call(self, name: str, *, args: dict[str, Any], request_id: str) -> Any:
if name not in self.allow:
raise RuntimeError(f"[{request_id}] tool not allowed: {name}")
started = time.time()
try:
return self.impl[name](**args)
finally:
ms = int((time.time() - started) * 1000)
print(f"[{request_id}] tool={name} ms={ms} args_hash={args_hash(args)}")
def run_first_agent(question: str, *, tools: SafeTools, budget: Budget) -> str:
request_id = uuid.uuid4().hex
started = time.time()
monitor = Monitor(max_repeat=2)
state = {"question": question, "notes": []}
for step in range(budget.max_steps):
if time.time() - started > budget.max_seconds:
return f"[{request_id}] stopped: time budget"
action = llm_pick_action(state) # (pseudo)
if action.get("finish"):
return action["finish"]
tool = action["tool"]
args = action["args"]
monitor.mark(tool, args)
obs = tools.call(tool, args=args, request_id=request_id)
state = update_state(state, action, obs) # (pseudo)
return f"[{request_id}] stopped: step budget"import crypto from "node:crypto";
function stableStringify(obj) {
if (obj === null || typeof obj !== "object") return JSON.stringify(obj);
if (Array.isArray(obj)) return "[" + obj.map(stableStringify).join(",") + "]";
const keys = Object.keys(obj).sort();
return "{" + keys.map((k) => JSON.stringify(k) + ":" + stableStringify(obj[k])).join(",") + "}";
}
export function argsHash(args) {
return crypto.createHash("sha256").update(stableStringify(args)).digest("hex").slice(0, 12);
}
export class LoopGuard extends Error {}
export class Monitor {
constructor({ maxRepeat = 2 } = {}) {
this.maxRepeat = maxRepeat;
this.counts = new Map();
}
mark(tool, args) {
const key = tool + ":" + argsHash(args);
const next = (this.counts.get(key) || 0) + 1;
this.counts.set(key, next);
if (next >= this.maxRepeat) throw new LoopGuard("loop guard: " + key + " repeated " + next + "x");
}
}
export class SafeTools {
constructor({ allow, impl }) {
this.allow = allow;
this.impl = impl;
}
async call(name, { args, requestId }) {
if (!this.allow.has(name)) throw new Error("[" + requestId + "] tool not allowed: " + name);
const started = Date.now();
try {
return await this.impl[name](args);
} finally {
const ms = Date.now() - started;
console.log("[" + requestId + "] tool=" + name + " ms=" + ms + " args_hash=" + argsHash(args));
}
}
}
export async function runFirstAgent(question, { tools, budget }) {
const requestId = crypto.randomUUID().replace(/-/g, "");
const started = Date.now();
const monitor = new Monitor({ maxRepeat: 2 });
let state = { question, notes: [] };
for (let step = 0; step < budget.max_steps; step++) {
if ((Date.now() - started) / 1000 > budget.max_seconds) return "[" + requestId + "] stopped: time budget";
const action = await llmPickAction(state); // (pseudo)
if (action.finish) return action.finish;
const tool = action.tool;
const args = action.args;
monitor.mark(tool, args);
const obs = await tools.call(tool, { args, requestId });
state = updateState(state, action, obs); // (pseudo)
}
return "[" + requestId + "] stopped: step budget";
}Це не “ідеальний фреймворк”. Це маленький, обмежений луп із:
- allowlisted tools
- budgets
- loop guard
- basic logs
Цього достатньо, щоб почати вчитись і не підпалювати прод.
Крок 3: маленька «tool policy» (навіть для read-only)
Навіть read-only tools мають бути явно в allowlist. Це змушує зробити поверхню доступу видимою.
Ти можеш розширити іграшковий Tools клас вище до того, що можна оперувати:
- додай args hashing
- додай таймінги
- додай класифікацію помилок (retryable vs fatal)
Не перегинай із інженерією. Просто зроби так, щоб це дебажилось.
Контракти інструментів (щоб модель грала “в межах ліній”)
Модель із задоволенням вигадає args:
- wrong field names
- huge strings
- weird nested objects
Якщо твій wrapper приймає “any dict”, runtime стає validation layer. А це означає, що ти дебажитимеш validation errors у продакшені.
Почни просто:
- опиши shape аргументів для кожної tool
- валідуй типи й межі
- відхиляй невідомі поля
Концептуальний приклад:
def validate_web_search(args: dict[str, Any]) -> dict[str, Any]:
q = str(args.get("q", "")).strip()
if not q or len(q) > 200:
raise ValueError("invalid q")
k = int(args.get("k", 5))
if k < 1 or k > 8:
raise ValueError("invalid k")
return {"q": q, "k": k}export function validateWebSearch(args) {
const q = String((args && args.q) || "").trim();
if (!q || q.length > 200) throw new Error("invalid q");
const k = Number((args && args.k) ?? 5);
if (!Number.isFinite(k) || k < 1 || k > 8) throw new Error("invalid k");
return { q, k };
}Виглядає тупо. Але саме це зупиняє агента від виклику web.search із 10 000 символів prompt dump.
І це дає чисту помилку, яку можна трактувати як fatal (без “ретраїмо вічно”).
Крок 4: rollout без болю
Так ми зазвичай шипаємо першу версію:
- Тільки всередині команди (ти + один колега)
- Read-only (без write tools)
- Canary (малий зріз трафіку)
- Kill switch (операторська зупинка)
Якщо пропустиш canary, перший луп станеться на твоєму найважливішому клієнті.
Крок 4.5: скасування (припини витрати, коли користувач пішов)
Якщо у тебе є UI, користувачі будуть кидати запити. Якщо ти не прокинеш cancellation, агент все одно продовжить бігти. Він продовжить викликати tools. Продовжить палити токени. Продовжить генерувати вихід, який ніхто не читає.
Зроби cancellation stop reason’ом першого класу:
- клієнт від’єднався → аборти поточний run
- abort має проходити в model calls і tool calls
- логуй
stop_reason = client_cancel
Навіть груба реалізація краща за “палим гроші, доки не помре бюджет”.
Крок 5: метрики успіху (щоб знати, що стало краще)
Обери 3 числа й тримай їх у графіках:
- completion rate (чи завершився?)
- середня вартість на run ($ або токени/кредити)
- p95 runtime (95-й перцентиль часу)
Якщо completion rate високий, але cost вибухає — бюджети зроблені криво. Якщо cost низький, але completion rate жахливий — найімовірніше, поганий tool contract.
Де запускати (serverless vs workers)
Твій перший агент, швидше за все, житиме як API route. Це нормально… доки не перестане бути нормальним.
Два типові підводні камені:
- cold starts: довгі холодні старти + луп = поганий UX
- time limits: у serverless є ліміти виконання; твій “бюджет 90 секунд” може просто не влізти
Часто ми розділяємо так:
- приходить запит (швидко)
- run агента відбувається у воркері (з бюджетами)
- UI опитує / стрімить прогрес
Якщо це звучить як “забагато архітектури”, тримай просто: просто постав бюджети настільки низько, щоб платформа реально могла зупинити run. Найшвидший спосіб отримати 500-ки — запускати довгий луп у request handler без guardrails.
Загартування v2 (ще маленько, ще безпечно)
Коли “один інструмент” стабільний, ось що ми додаємо далі:
Додай args hashing + dedupe
Якщо агент повторно викликає той самий tool з тими самими args — зупиняй. Це ловить найпростіші нескінченні лупи.
Додай бюджети на кожну tool
Глобальні бюджети зупиняють run. А бюджети на рівні tool зупиняють флейкову залежність від “з’їдання” всього run’а.
Example:
web.searchmax 3 calls
Додай kill switch
Навіть для крихітного агента. Якщо це біжить у продакшені — ти хочеш мати кнопку “стоп” без деплою.
Додай “stop reason”
Зроби stop reason видимим у логах і UI:
- time budget
- step budget
- loop detected
- tool denied
Це зупиняє користувачів від “жму refresh, бо не зрозумів що сталося”.
Перший write tool (як не накосячити)
Коли ти нарешті додаєш write tool:
- make it a separate tool (don’t reuse read tool name)
- add idempotency keys
- require approval by default
- log an audit event for the intended write
Якщо пропустиш ідемпотентність — зашипиш дублікати записів. Це не “може бути”. Це “коли”.
Типові помилки першого агента (ми теж так робили)
- Синдром “ще один інструмент”. Починаєш із
web.search, закінчуєшhttp.requestі адмін-токеном. - Ретраї без лімітів. Ти “обробляєш помилки” і випадково будуєш нескінченний луп.
- Нема stop reason в UI. Користувач жме refresh, бо не зрозумів що сталося, і ти платиш двічі.
- Логувати тільки фінальну відповідь. Потім ти не можеш пояснити, чому tool викликався 17 разів.
Якщо уникнеш цих чотирьох — ти вже попереду більшості “agent demo”. І ще: проганяй агента з фейковою tool, яка один раз падає. Якщо луп переживає це без спаму ретраїв — ок. Якщо розсипається — фікси зараз. Серйозно, зараз.
Реальний фейл
Ми бачили, як “перший агент” зашипили з нульовими бюджетами. Він отримав дивний промпт, почав блукати по вебу й застряг.
Результат:
- користувач чекав ~2 хвилини
- агент спалив гроші
- всі звинуватили модель
Це була не модель. Це була відсутність бюджетів.
Коли НЕ варто будувати агента (поки що)
- Якщо це робиться скриптом — пиши скрипт.
- Якщо це робиться workflow — будуй workflow.
- Якщо ти не можеш додати tool permissions + audit logs — не шипай tool calling.
Далі
- Починаємо тут: Що таке AI-агент?
- Патерн: Research agent
- Фейл: Нескінченний луп