Коротко: Без observability кожен фейл агента перетворюється на “модель була дивна” — діагноз, який не тестується й не фікситься. Тобі потрібні: трейси tool calls, stop reasons, трекінг вартості й можливість replay. Це не опційна інфраструктура.
Ти дізнаєшся: Мінімальні вимоги до моніторингу • Один unified event schema • Таксономія stop reasons • Основи replay • Конкретний інцидент, який ти впізнаєш
Без моніторингу: юзери репортять першими • дебаг “по вайбах” • без replay
З мінімальним моніторингом: раннє виявлення drift • дебаг через трейси + stop reasons • replay останніх runs
Ефект: швидша реакція на інциденти + менше повторних фейлів (бо можна фіксити root cause)
Проблема (з реального продакшену)
Один run агента йде не так.
Юзер пише: “він відправив не той email”.
Ти відкриваєш логи й у тебе є:
- фінальний текст відповіді (можливо)
- stack trace (можливо)
- вайби (точно)
Якщо ти не можеш відповісти на ці 5 питань — систему неможливо нормально оперувати:
- Які tools були викликані (і в якому порядку)?
- З якими аргументами (або хоча б args hashes)?
- Що повернулося (або хоча б snapshot hashes)?
- Яка версія model/prompt/tools працювала?
- Чому run зупинився?
Це не “немає дашбордів”. Це “ця система не operable”.
Момент 03:00
Ось як відчувається “без моніторингу”:
03:12 — Support: "Agent emailed the wrong customer. Please stop it."
Ти grep’аєш логи й знаходиш… нічого, що можна нормально join’нути.
2026-02-07T03:11:58Z INFO sent email to customer@example.com
2026-02-07T03:11:59Z INFO sent email to customer@example.com
2026-02-07T03:12:01Z WARN http.get 429
2026-02-07T03:12:03Z INFO Agent completed task
Нема run_id. Нема step trace. Нема stop reason. Нема tool args hash. Нема версії model/tool.
І ти робиш найгірший дебаг: grep по email і молишся, що він унікальний.
Чому це ламається в продакшені
1) Агент — це distributed system з додатковими кроками
Щойно агент викликає tools, ти побудував:
- кілька залежностей (HTTP, DB, APIs)
- кілька failure modes (timeouts, 502s, rate limits)
- кілька retries (і retry storms)
Якщо ти не логуєш кожен крок — ти дебажиш сторітелінгом.
2) “Success rate” ховає цікаві фейли
Drift проявляється як:
- більше tool calls/run (зациклення, а не явний фейл)
- більше tokens/request (модель “пояснює” помилки)
- довша latency (retries, повільні tools)
- інші stop reasons (budgets, denials, timeouts)
3) Ти не виправиш те, що не можеш replay
Якщо ти не можеш replay (або хоча б реконструювати) run з логів — ти не можеш довіряти “фіксу”. Ти просто вгадуєш.
4) Моніторинг — частина governance
Budgets, allowlists і kill switches марні, якщо ти не бачиш, коли вони trigger’яться.
Жорсткі інваріанти (не обговорюється)
- Кожен run має
run_id. - Кожен step має
step_id. - Кожен tool call логує: tool name, args hash, duration, status, error class.
- Кожен run завершується stop event:
stop_reason. - Якщо ти не можеш replay (хоч частково) — ти не можеш довіряти фіксу.
Приклад реалізації (реальний код)
Типовий фейл тут — два різні формати логів:
- tool events структуровані
- stop events “особливі”
Це вбиває joinability.
Цей приклад використовує один unified event schema для tool calls і stop events.
from __future__ import annotations
from dataclasses import dataclass, asdict
import hashlib
import json
import time
from typing import Any, Literal
EventKind = Literal["tool_result", "stop"]
def sha(obj: Any) -> str:
raw = json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
return hashlib.sha256(raw).hexdigest()[:24]
@dataclass(frozen=True)
class Event:
run_id: str
kind: EventKind
ts_ms: int
# optional fields
step_id: int | None = None
tool: str | None = None
args_sha: str | None = None
duration_ms: int | None = None
status: Literal["ok", "error"] | None = None
error: str | None = None
stop_reason: str | None = None
usage: dict[str, Any] | None = None
def log_event(ev: Event) -> None:
print(json.dumps(asdict(ev), ensure_ascii=False))
def call_tool(run_id: str, step_id: int, tool: str, args: dict[str, Any]) -> Any:
started = time.time()
try:
out = tool_impl(tool, args=args) # (pseudo)
dur = int((time.time() - started) * 1000)
log_event(
Event(
run_id=run_id,
kind="tool_result",
ts_ms=int(time.time() * 1000),
step_id=step_id,
tool=tool,
args_sha=sha(args),
duration_ms=dur,
status="ok",
error=None,
)
)
return out
except Exception as e:
dur = int((time.time() - started) * 1000)
log_event(
Event(
run_id=run_id,
kind="tool_result",
ts_ms=int(time.time() * 1000),
step_id=step_id,
tool=tool,
args_sha=sha(args),
duration_ms=dur,
status="error",
error=type(e).__name__,
)
)
raise
def stop(run_id: str, *, reason: str, usage: dict[str, Any]) -> dict[str, Any]:
log_event(
Event(
run_id=run_id,
kind="stop",
ts_ms=int(time.time() * 1000),
stop_reason=reason,
usage=usage,
)
)
return {"status": "stopped", "stop_reason": reason, "usage": usage}import crypto from "node:crypto";
export function sha(obj) {
const raw = JSON.stringify(obj, Object.keys(obj || {}).sort());
return crypto.createHash("sha256").update(raw, "utf8").digest("hex").slice(0, 24);
}
export function logEvent(ev) {
console.log(JSON.stringify(ev));
}
export async function callTool(runId, stepId, tool, args) {
const started = Date.now();
try {
const out = await toolImpl(tool, { args }); // (pseudo)
logEvent({
run_id: runId,
kind: "tool_result",
ts_ms: Date.now(),
step_id: stepId,
tool,
args_sha: sha(args),
duration_ms: Date.now() - started,
status: "ok",
error: null,
});
return out;
} catch (e) {
logEvent({
run_id: runId,
kind: "tool_result",
ts_ms: Date.now(),
step_id: stepId,
tool,
args_sha: sha(args),
duration_ms: Date.now() - started,
status: "error",
error: e?.name || "Error",
});
throw e;
}
}
export function stop(runId, { reason, usage }) {
logEvent({
run_id: runId,
kind: "stop",
ts_ms: Date.now(),
stop_reason: reason,
usage,
});
return { status: "stopped", stop_reason: reason, usage };
}Приклад інциденту (конкретний)
🚨 Інцидент: “все повільно” (і ми не знали чому)
Date: 2024-10-08
Duration: 3 дні непомічено, ~2 години дебагу після того, як додали visibility
System: customer support agent
Що реально сталося
Tool http.get почав повертати інтермітентні 429/503.
Наш tool layer ретраїв до 8× на call (раніше 2×) без jitter. Агент інтерпретував ці фейли як “спробуй інший запит” і почав робити більше tool calls на run.
За 3 дні (ілюстративні числа, але патерн типовий):
- avg tool calls/run: 4.3 → 11.7
- p95 latency: 2.1s → 8.4s
- spend/run: ~2×
Нічого “не впало”. Success rate тримався ~91%, тож drift виглядав як “юзери нетерплячі”, доки support не ескалював.
Root cause (нудна версія)
- retries + без jitter → thundering herd
- немає stop reasons у логах → “success” маскував drift
- немає tool-call trace → ми не могли довести, куди пішли час/витрати
Fix
- Structured event logs (run_id, step_id, tool, args hash, duration, status)
- Stop reasons показувати caller/UI
- Dashboards + alerts на drift сигнали (tool calls/run, latency P95, stop reasons)
Dashboards + alerts (приклади, які можна вкрасти)
Тобі не потрібна ідеальна observability. Тобі потрібна корисна.
PromQL (Grafana)
# Tool calls per run (p95)
histogram_quantile(0.95, sum(rate(agent_tool_calls_bucket[5m])) by (le))
# Stop reasons over time
sum(rate(agent_stop_total[10m])) by (stop_reason)
# Latency p95
histogram_quantile(0.95, sum(rate(agent_run_latency_ms_bucket[5m])) by (le))
SQL (Postgres/BigQuery-style)
-- Alert: tool_calls/run spike vs baseline
SELECT
date_trunc('hour', created_at) AS hour,
avg(tool_calls) AS avg_tool_calls
FROM agent_runs
WHERE created_at > now() - interval '7 days'
GROUP BY 1
HAVING avg(tool_calls) > 2 * (
SELECT avg(tool_calls)
FROM agent_runs
WHERE created_at BETWEEN now() - interval '14 days' AND now() - interval '7 days'
);
Alert rules (plain English)
- Якщо
tool_calls_per_run_p95= 2× baseline 10 хвилин → investigate (і подумай про kill writes). - Якщо
stop_reason=loop_detectedз’являється вище baseline → investigate (tool spam / bad prompt / outage). - Якщо
stop_reason=tool_timeoutспайкає → це upstream проблема, не “model weirdness”.
Компроміси
- Логінг коштує грошей (storage, indexing). Все одно дешевше за сліпі інциденти.
- Не логуй raw PII/secrets. Hash args і aggressively redact.
- Replay потребує retention policy + access controls.
Коли НЕ варто
- Не будуй важку tracing платформу до того, як у тебе є structured logs. Start small.
- Не логуй raw tool args, якщо там PII/secrets. Ніколи.
- Не шип агенти без stop reasons. Ти створюєш retry loops.
Чекліст (можна копіювати)
- [ ]
run_id/step_idдля кожного run - [ ] Unified event schema (tool results + stop events)
- [ ] Tool-call logs: tool, args_hash, duration, status, error class
- [ ] Stop reason повертається юзеру + логується
- [ ] Metrics по run: tokens/tool calls/spend
- [ ] Dashboards: latency P95, tool_calls/run, stop_reason distribution
- [ ] Replay data: snapshot hashes (retention + access control)
Безпечний дефолтний конфіг
logging:
events:
enabled: true
schema: "unified"
store_args: false
store_args_hash: true
include: ["run_id", "step_id", "tool", "duration_ms", "status", "error", "stop_reason"]
metrics:
track: ["tokens_per_request", "tool_calls_per_run", "latency_p95", "spend_per_run", "stop_reason"]
retention:
tool_snapshot_days: 14
logs_days: 30
FAQ
Пов’язані сторінки
Production takeaway
Що ламається без цього
- ❌ Ти не можеш пояснити інциденти
- ❌ Drift виглядає як “model weirdness”
- ❌ Перевитрати видно постфактум
Що працює з цим
- ✅ Можна join’ити, replay’ити й дебажити runs
- ✅ Drift стає графіком, а не суперечкою
- ✅ Kill switches trigger’яться від реальних сигналів
Мінімум, щоб шипнути
- Unified structured logs
- Stop reasons
- Базові metrics + dashboards
- Alerts на drift