Збери свого першого AI агента (безпечно, з кодом)

Почни з малого: один tool, жорсткі бюджети й kill switch. Найшвидший шлях до агента, який не зганьбить тебе в продакшені.
На цій сторінці
  1. Проблема
  2. Чому це важливо в реальних системах
  3. Плейбук (20 хвилин, без героїзму)
  4. Що насправді означає «один інструмент»
  5. Крок 0: обери задачу, яка не збанкрутить тебе
  6. Крок 1: бюджети (те, що перетворює луп на систему)
  7. Крок 2: логуй trace дій
  8. Крок 2.5: зроби інструменти детермінованими (інакше не віддебажиш)
  9. Код (мінімально, але по-продакшену)
  10. Зроби це реальним (маленький runnable skeleton)
  11. Крок 3: маленька «tool policy» (навіть для read-only)
  12. Контракти інструментів (щоб модель грала “в межах ліній”)
  13. Крок 4: rollout без болю
  14. Крок 4.5: скасування (припини витрати, коли користувач пішов)
  15. Крок 5: метрики успіху (щоб знати, що стало краще)
  16. Де запускати (serverless vs workers)
  17. Загартування v2 (ще маленько, ще безпечно)
  18. Додай args hashing + dedupe
  19. Додай бюджети на кожну tool
  20. Додай kill switch
  21. Додай “stop reason”
  22. Перший write tool (як не накосячити)
  23. Типові помилки першого агента (ми теж так робили)
  24. Реальний фейл
  25. Коли НЕ варто будувати агента (поки що)
  26. Далі

Проблема

Твій перший агент спробує зробити все й одразу.

Він полізе в веб, почне “підкручувати” конфіги, захоче писати в бази — і загалом поводитиметься як інтерн із root-доступом.

Не стартуй так.

Почни з одного інструмента, тільки read-only, і одразу з бюджетами та логами.

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

Перший раз, коли ти зашипиш tool calling, ти гарантовано знайдеш:

  • відсутні таймаути
  • відсутню ідемпотентність
  • логи, які “є”, але нічого не пояснюють

Краще знайти це на одному інструменті, ніж на дванадцяти.

Плейбук (20 хвилин, без героїзму)

  1. Обери один read-only tool (web.search або kb.search).
  2. Додай бюджети: steps + time (+ $ якщо можеш).
  3. Логуй кожен tool call (назва, args hash, тривалість, статус).
  4. Додай loop detection на повтори з тими самими args.
  5. Поки що не додавай “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

Мінімальний концептуальний код:

PYTHON
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 impl
JAVASCRIPT
export 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 задач перед релізом? Так. Саме так ти дізнаєшся про регресії до того, як їх знайде клієнт.

Код (мінімально, але по-продакшену)

PYTHON
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"
JAVASCRIPT
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:

PYTHON
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"
JAVASCRIPT
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
  • валідуй типи й межі
  • відхиляй невідомі поля

Концептуальний приклад:

PYTHON
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}
JAVASCRIPT
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 без болю

Так ми зазвичай шипаємо першу версію:

  1. Тільки всередині команди (ти + один колега)
  2. Read-only (без write tools)
  3. Canary (малий зріз трафіку)
  4. 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.search max 3 calls

Додай kill switch

Навіть для крихітного агента. Якщо це біжить у продакшені — ти хочеш мати кнопку “стоп” без деплою.

Додай “stop reason”

Зроби stop reason видимим у логах і UI:

  • time budget
  • step budget
  • loop detected
  • tool denied

Це зупиняє користувачів від “жму refresh, бо не зрозумів що сталося”.

Перший write tool (як не накосячити)

Коли ти нарешті додаєш write tool:

  1. make it a separate tool (don’t reuse read tool name)
  2. add idempotency keys
  3. require approval by default
  4. 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.

Далі

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

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

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

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

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