Проблема (чому ти тут)
У dev агент “працює”.
У проді раз на ~200 run’ів стається щось дивне:
- користувач пише “він відправив не те”
- витрати стрибають на 15 хвилин
- він лупиться на flaky API й падає по timeout
А ти маєш… майже нічого:
- одну “фінальну відповідь”
- пару випадкових логів
- інколи помилку tool’а без контексту
І далі починається найгірше: дебаг навмання, але з підключеною кредиткою.
Ця сторінка — як зробити логи так, щоб інциденти знову стали нудними.
Чому це ламається в проді
Агенти ламаються як distributed systems, бо вони ними і є:
- модель — ненадійний planner
- tools — це side effects (HTTP/DB/тікети/email)
- retries + timeouts створюють “магію” з поганим фіналом
Якщо ти не логуваєш луп, ти не відповіси на базові питання інциденту:
- які tool calls були і в якому порядку?
- які args (або хоча б args_hash)?
- що повернув tool (або що ми відредагували)?
- чому run зупинився (
stop_reason)? - яка заявка/юзер це тригернув?
Якщо ти не логуваєш stop_reason, ти не “спостерігаєш”. Ти збираєш вайби.
Діаграма: мінімальний пайплайн подій
Реальний код: інструментуй tool gateway (Python + JS)
Почни з межі. Tools — це місце, де живуть витрати й шкода.
Логуємо:
run_id,trace_id,tool_nameargs_hash(raw args за замовчуванням не зберігаємо)- latency + статус
error_class(нормалізована)
І все ганяємо через gateway, щоб не було “ой, я забув залогувати”.
import hashlib
import json
import time
from dataclasses import dataclass
from typing import Any, Callable, Dict, Optional
def stable_hash(obj: Any) -> str:
raw = json.dumps(obj, sort_keys=True, ensure_ascii=False).encode("utf-8")
return hashlib.sha256(raw).hexdigest()
@dataclass(frozen=True)
class RunCtx:
run_id: str
trace_id: str
user_id: Optional[str] = None
request_id: Optional[str] = None
class Logger:
def event(self, name: str, fields: Dict[str, Any]) -> None: ...
class ToolGateway:
def __init__(self, *, impls: dict[str, Callable[..., Any]], logger: Logger):
self.impls = impls
self.logger = logger
def call(self, ctx: RunCtx, name: str, args: Dict[str, Any]) -> Any:
fn = self.impls.get(name)
if not fn:
self.logger.event("tool_call", {
"run_id": ctx.run_id,
"trace_id": ctx.trace_id,
"tool": name,
"args_hash": stable_hash(args),
"ok": False,
"error_class": "unknown_tool",
})
raise RuntimeError(f"unknown tool: {name}")
t0 = time.time()
self.logger.event("tool_call", {
"run_id": ctx.run_id,
"trace_id": ctx.trace_id,
"tool": name,
"args_hash": stable_hash(args),
})
try:
out = fn(**args)
self.logger.event("tool_result", {
"run_id": ctx.run_id,
"trace_id": ctx.trace_id,
"tool": name,
"latency_ms": int((time.time() - t0) * 1000),
"ok": True,
})
return out
except TimeoutError:
self.logger.event("tool_result", {
"run_id": ctx.run_id,
"trace_id": ctx.trace_id,
"tool": name,
"latency_ms": int((time.time() - t0) * 1000),
"ok": False,
"error_class": "timeout",
})
raise
except Exception as e:
self.logger.event("tool_result", {
"run_id": ctx.run_id,
"trace_id": ctx.trace_id,
"tool": name,
"latency_ms": int((time.time() - t0) * 1000),
"ok": False,
"error_class": type(e).__name__,
})
raiseimport crypto from "node:crypto";
export function stableHash(obj) {
const raw = JSON.stringify(obj);
return crypto.createHash("sha256").update(raw).digest("hex");
}
export class ToolGateway {
constructor({ impls = {}, logger }) {
this.impls = impls;
this.logger = logger;
}
call(ctx, name, args) {
const fn = this.impls[name];
const argsHash = stableHash(args);
if (!fn) {
this.logger.event("tool_call", {
run_id: ctx.run_id,
trace_id: ctx.trace_id,
tool: name,
args_hash: argsHash,
ok: false,
error_class: "unknown_tool",
});
throw new Error("unknown tool: " + name);
}
const t0 = Date.now();
this.logger.event("tool_call", {
run_id: ctx.run_id,
trace_id: ctx.trace_id,
tool: name,
args_hash: argsHash,
});
try {
const out = fn(args);
this.logger.event("tool_result", {
run_id: ctx.run_id,
trace_id: ctx.trace_id,
tool: name,
latency_ms: Date.now() - t0,
ok: true,
});
return out;
} catch (e) {
this.logger.event("tool_result", {
run_id: ctx.run_id,
trace_id: ctx.trace_id,
tool: name,
latency_ms: Date.now() - t0,
ok: false,
error_class: e?.name || "Error",
});
throw e;
}
}Комбінуй це з:
- бюджетами (
/uk/governance/budget-controls) - dedupe проти tool spam (
/uk/failures/tool-spam) - юніт‑тестами, які фіксують stop reasons (
/uk/testing-evaluation/unit-testing-agents)
Реальний фейл (з цифрами)
Ми шипнули “read-only” research агента, який робив http.get.
Одного дня партнерський API почав повертати 200 з error‑payload’ом (так). Наш wrapper трактував “200 == ok” і логував тільки “success”.
Імпакт:
- ~18% run’ів давали впевнено неправильні підсумки ~2 години
- ~30 тікетів
- онкол: ~4 години, щоб довести, що це не “модель галюцинує”
Фікс:
- логувати нормалізовану
error_class+ validation failures - зберігати
args_hash+ latency, щоб знайти hot spots - алерт: validation_fail_rate > 2% протягом 5 хвилин
Тобі не потрібні ідеальні логи. Тобі потрібні логи, які відповідають “що сталося?” за <10 хвилин.
Компроміси
- Логувати raw args корисно і водночас це шлях до PII‑ліка. Default:
args_hash. - Зберігати повні tool results зручно для дебагу і боляче для комплаєнсу. Sampling + редакція.
- Забагато логів може стати окремим інцидентом. Почни з того, на що ти алертиш.
Коли НЕ робити так
- Якщо агент бігає тільки локально в довіреному середовищі — можеш бути лінивішим (на якийсь час).
- Якщо ти щодня ламаєш форму лупа — роби лог легким, але стабільним (IDs + stop reasons).
- Не пиши свій tracing, якщо ти не готовий його оперувати. Бери щось нудне.
Copy‑paste чекліст
- [ ]
run_id,trace_id,request_id,user_idна кожній події - [ ]
tool_call+tool_result(name, args_hash, latency, ok, error_class) - [ ]
stop_reason+ бюджети в кінці run’а - [ ] policy редакції (PII/secrets) + hash за замовчуванням
- [ ] алерти: tool calls/run, timeouts, validation fails
- [ ] одна “incident query” на кожен топ‑фейл (dashboard / saved search)
Safe default config (YAML)
logging:
ids:
run_id: required
trace_id: required
request_id: required
tool_calls:
enabled: true
store_args: false
store_args_hash: true
store_results: "sampled"
result_sample_rate: 0.01
pii:
redact_fields: ["email", "phone", "token", "authorization", "cookie"]
stop_reasons:
enabled: true
alerts:
tool_calls_per_run_p95: { warn: 10, critical: 20 }
timeout_rate: { warn: 0.02, critical: 0.05 }
validation_fail_rate: { warn: 0.02, critical: 0.05 }
Implement in OnceOnly (опційно)
# onceonly-python: governed audit logs + metrics
import os
from onceonly import OnceOnly
client = OnceOnly(api_key=os.environ["ONCEONLY_API_KEY"])
agent_id = "support-bot"
# Pull last 50 actions (includes args_hash + decisions)
for e in client.gov.agent_logs(agent_id, limit=50):
print(e.ts, e.tool, e.decision, e.args_hash, e.spend_usd, e.reason)
# Rollups for dashboards/alerts
m = client.gov.agent_metrics(agent_id, period="day")
print("spend_usd=", m.total_spend_usd, "blocked=", m.blocked_actions)
FAQ (3–5)
Використовується в патернах
Пов’язані відмови
Q: Чи треба логувати raw args інструментів?
A: Default — ні. args_hash + safe поля. Raw args — тільки тимчасово під інцидент, з редакцією.
Q: Яке поле найкорисніше?
A: Стабільний run_id/trace_id на кожній події.
Q: Як швидко ловити лупи?
A: Алерт на tool_calls/run + повтори (tool, args_hash). Далі — /failures/tool-spam.
Q: Чи потрібен distributed tracing?
A: Якщо перетинаєш сервіси — так. Почни з Trace IDs + spans на tool calls.
Пов’язані сторінки (3–6 лінків)
- Основи: Виклик інструментів AI‑агентом (з кодом) · Що робить агента production-ready (guardrails + код)
- Фейли: Tool spam loops (failure mode + фікси + код) · Budget explosion (коли агент спалює гроші) + фікси + код
- Governance: Budget Controls для AI агентів (кроки, час, $) + Код · Kill switch design
- Тести: Юніт‑тести для AI‑агентів (детерміновано, дешево, реально корисно)
- Продакшен‑стек: Продакшен‑стек AI‑агента