Kurzfazit: Ohne Observability wird jeder Agent-Failure zu âdas Modell war weirdâ â untestbar und unfixbar. Du brauchst: Tool-Aufruf-Traces, stop reasons, Cost Tracking und Replay. Das ist nicht optional.
Du lernst: Minimum Monitoring ⢠ein einheitliches Event-Schema ⢠stop reason taxonomy ⢠Replay-Basics ⢠ein konkreter Incident, den du wiedererkennst
Ohne Monitoring: Nutzer melden Probleme zuerst ⢠Debugging nach Gefßhl ⢠kein Replay
Mit minimalem Monitoring: Drift frßh sehen ⢠debuggen via Traces + stop reasons ⢠letzte Runs replayen
Impact: schnellere Incident Response + weniger Wiederholungsfehler (weil du Root Cause fixen kannst)
Problem (zuerst)
Ein Agent-Run geht schief.
Ein User meldet: âer hat die falsche Email geschickt.â
Du Ăśffnest Logs und hast:
- final answer text (vielleicht)
- stack trace (vielleicht)
- vibes (safe)
Wenn du diese fĂźnf Fragen nicht beantworten kannst, ist das System nicht operierbar:
- Welche Tools wurden aufgerufen (und in welcher Reihenfolge)?
- Mit welchen Args (oder wenigstens args hashes)?
- Was kam zurĂźck (oder wenigstens snapshot hashes)?
- Welche Version von model/prompt/tools lief?
- Warum hat es gestoppt?
Das ist nicht âfehlende Dashboardsâ. Das ist âdieses System ist nicht operierbarâ.
Der 03:00-Moment
So fĂźhlt sich âkein Monitoringâ an:
03:12 â Support: "Agent emailed the wrong customer. Please stop it."
Du greppst Logs und findest⌠nichts, was du joinen kannst.
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
Kein run_id. Kein Step Trace. Kein stop reason. Kein args hash. Keine Tool/Model-Version.
Warum das in Production bricht
1) Agenten sind Distributed Systems mit extra Schritten
Sobald ein Agent Tools aufruft, hast du mehrere Dependencies, mehrere Failure Modes und mehrere Retries. Ohne Step-Level Logs wird Debugging zu Storytelling.
2) âSuccess rateâ versteckt die interessanten Fehler
Drift zeigt sich als:
- mehr tool calls per run
- mehr tokens per request
- hĂśhere Latenz
- andere stop reasons
3) Du kannst nicht fixen, was du nicht replayen kannst
Wenn du einen Run nicht replayen oder zumindest aus Logs rekonstruieren kannst, kannst du einem âFixâ nicht trauen. Du rätst nur.
4) Monitoring ist Teil von Governance
Budgets, Allowlists und Kill-Switches sind wertlos, wenn du nicht siehst, wann sie triggern.
Hard invariants (nicht verhandelbar)
- Jeder Run hat
run_id. - Jeder Step hat
step_id. - Jeder Tool Call loggt: tool name, args hash, duration, status, error class.
- Jeder Run endet mit einem stop event:
stop_reason. - Ohne (teilweises) Replay kannst du einem Fix nicht trauen.
Implementierung (echter Code)
Der häufigste Fehler: zwei Log-Formate:
- Tool-Events sind strukturiert
- Stop-Events sind âspecialâ
Das killt Joinability.
Dieses Sample nutzt ein einheitliches Event-Schema fĂźr Tool-Results und 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 };
}Example failure case (konkret)
đ¨ Incident: âAlles ist langsamâ (und wir wussten nicht warum)
Date: 2024-10-08
Duration: 3 Tage unbemerkt, ~2 Stunden Debug, sobald wir Visibility hatten
System: Customer-Support-Agent
What actually happened
Das http.get-Tool hat intermittierend 429s/503s zurĂźckgegeben.
Unser Tool-Layer hat bis zu 8Ă pro Call retried (vorher 2Ă) ohne Jitter. Der Agent hat diese Failures als âversuch eine andere Queryâ interpretiert und mehr tool calls pro run gemacht.
Ăber 3 Tage (illustrative Zahlen, aber das Muster ist typisch):
- avg tool calls/run: 4.3 â 11.7
- p95 latency: 2.1s â 8.4s
- spend/run: ~2Ă
Nichts ist âgecrashtâ. Success rate blieb ~91%, also sah der Drift aus wie âUser sind ungeduldigâ, bis Support eskaliert hat.
Root cause (die langweilige Version)
- retries ohne jitter â thundering herd
- keine stop reasons in Logs â âsuccessâ maskiert Drift
- kein tool-call trace â wir konnten nicht beweisen, wo Zeit/Spend hinging
Fix
- Structured event logs (run_id, step_id, tool, args hash, duration, status)
- stop reasons an Caller/UI zurĂźckgeben
- Dashboards + Alerts auf Drift-Signale (tool calls/run, latency P95, stop reasons)
Dashboards + Alerts (Beispiele zum Klauen)
Du brauchst nicht perfekte Observability. Du brauchst nĂźtzliche Observability.
PromQL-Beispiele (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-Beispiel (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-Regeln (Plain English)
- Wenn
tool_calls_per_run_p952Ă baseline fĂźr 10 Minuten â investigate (und ggf. Writes killen). - Wenn
stop_reason=loop_detectedĂźber baseline â investigate (tool spam / outage / bad prompt). - Wenn
stop_reason=tool_timeoutspiked â Upstream ist kaputt, nicht âmodel weirdnessâ.
Abwägungen
- Logging kostet Geld (Storage, Indexing). Trotzdem billiger als blind incidents.
- Logge keine rohen PII/Secrets. Hash args und redact aggressiv.
- Replay braucht Retention Policy + Access Controls.
Wann NICHT nutzen
- Bau keine schwere Tracing-Plattform, bevor du structured logs hast. Start small.
- Logge keine rohen tool args, wenn sie PII/Secrets enthalten. Nie.
- Shippe keine Agenten ohne stop reasons. Du baust Retry Loops.
Checklist (Copy-Paste)
- [ ]
run_id/step_idfĂźr jeden run - [ ] einheitliches Event-Schema (tool results + stop events)
- [ ] tool-call logs: tool, args_hash, duration, status, error class
- [ ] stop reason an User zurĂźckgeben + loggen
- [ ] tokens/tool calls/spend pro run als Metrics
- [ ] Dashboards: latency P95, tool_calls/run, stop_reason distribution
- [ ] Replay-Daten: snapshot hashes (mit Retention + Access Control)
Safe default config
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
Related pages
Production takeaway
What breaks without this
- â Incidents sind nicht erklärbar
- â Drift ist âmodel weirdnessâ
- â Cost overruns fallen zu spät auf
What works with this
- â Runs sind joinable, replayable, debuggable
- â Drift ist ein Graph, kein Streit
- â Kill-Switches triggern auf echte Signale
Minimum to ship
- Unified structured logs
- Stop reasons
- Basic metrics + dashboards
- Alerts auf Drift