Coach-Benchmark: hunger × Effort auf Fable (SILV-448)
04.09.2026, 18:50–19:25 Berlin · Strato. Modell claude-fable-5-1 übers Max-Abo
(Agent-SDK 0.2.152, gebündelte CLI 2.1.259), Prompt = Daemon-Bausatz (Runbook:
coach-daemon-runbook), Kontextblöcke aus docs/coach-skills/
kontext-hunger.md (Tag 15:10, Plan-Woche Do–Sa, Eingabe „ziemlich, nur Tanke“). Ein Client je
Call (wie im Betrieb), seriell. Rohdaten app/data/coach-bench.jsonl, Skript app/coach_bench.py.
Umfang: low n = 12 warm (+2 kalt), medium 8 (+1), high 6 (+1) — statt 24 je Stufe: das 5-h-Abo-Fenster teilt sich der Coach mit den Claude-Code-Sessions, und Dennis' Gerätetest heute Abend braucht davon noch etwas. Die Streuung ist bei allen drei Stufen klein genug, dass die Aussage steht (s. min–max); mehr Calls hätten die Mediane nicht bewegt, nur das Fenster geleert.
Zahlen (warm = Client vorab verbunden, wie im Pool)
| Effort | n | TTFT med | TTFT p90 | TTFT min–max | Gesamt med | Gesamt max | Out-Tokens med | davon Thinking med | JSON gültig | plan_changes | Zeilen | Kalt: TTFT |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| low | 12 | 17,5 s | 23,3 s | 12,1–25,1 | 29,9 s | 37,6 s | 2 172 | ~0,9k (Einzelmessungen 863–1 087) | 12/12 | 3–5 (med 4) | 4/4 | 16,0 · 19,8 s |
| medium | 8 | 21,6 s | 25,7 s | 17,2–30,2 | 33,5 s | 41,0 s | 2 564 | 1 322 | 8/8 (kalt 1× kein JSON) | 3–5 (med 4) | 4/4 | 24,4 s |
| high | 6 | 40,3 s | 42,9 s | 25,7–49,1 | 53,5 s | 62,8 s | 4 302 | 2 864 | 6/6 | 3–5 (med 5) | 4/4 | 41,8 s |
Kontext je Call 32 071 Tokens, davon 32 069 aus dem Cache (Cache-Prefix trägt: Kern + Mahlzeiten + Skill + Stand + Blöcke sind über die Calls identisch). Textausgabe ~1 100–1 300 Tokens (JSON mit 3–5 Änderungen, 6–13 facts); Ausgabetempo ~100 Tok/s.
Was die Zeit frisst — zerlegt
| Anteil | low | Beleg |
|---|---|---|
CLI-Erstturn + API bis message_start |
3,4–4,2 s | auch bei einem 10-Token-Prompt („Sag OK“) 3,2 s TTFT — Grundlast der CLI, nicht des Prompts |
| Thinking (unsichtbar, vor dem ersten Textzeichen) | ~13 s (4,3 → 18,2 s) | thinking-Block mit 863–1 087 Tokens, signature_delta bei 18,2 s |
| Textausgabe | ~11 s | 1 150 Tokens JSON |
| Kaltstart (Prozess) | +1,5–3 s | kalt 16,0/19,8 vs. warm 17,5 med — im Rauschen |
Thinking lässt sich auf Fable über die CLI nicht abschalten. Probiert, alle wirkungslos
(Thinking-Tokens 860–960, TTFT 15–17 s): thinking={"type":"disabled"}, max_thinking_tokens=0,
Env CLAUDE_CODE_DISABLE_THINKING=1, MAX_THINKING_TOKENS=0. CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING=1
+ disabled macht es schlimmer (Effort wird verworfen → 2 732 Thinking-Tokens, TTFT 39 s). Ein
„Denk nur kurz nach“-Hinweis im Prompt: 13,3 / 18,6 s — kein belastbarer Effekt. Effort ist der
einzige Hebel und skaliert das Thinking ~1 : 1,5 : 3.
Fixe Grundlast im Kontext: die CLI hängt ~8,3k Tokens eigenes Material an (gemessen mit
Mini-System-Prompt: cache_creation 8 278), trotz tools=[]/setting_sources=None. Unser
Anteil: System-Prompt ~16k (deutsche Tabellen ≈ 2,3 B/Token, nicht 3,6), User-Prompt ~3k, dazu ~5k
die ich nicht zuordnen kann (Katalog-JSON/Blöcke). Alles gecacht — für die Latenz unerheblich,
weil Thinking dominiert.
Qualität (Stichprobe, alle 34 Antworten gelesen)
Inhaltlich durchweg das, was der Skill verlangt: Baustein aus „Mahlzeiten“ mit Dennis' Portionen
(Pudding + Milchreis/Banane), Ersatz im Plan als group (verpasste 12:30-Kombi + Banane raus),
Stand gegen Plan-Budget mit Deadband, Protein-Lücke, Alternative in Zeile 4; facts mit beiden
Budgets; Vorabend-Regel (Chili-Bowl bleibt) respektiert. low und high unterscheiden sich in der
Sache nicht — high formuliert vollständiger (Live-Budget-Hinweis, zweite Rechnung), nicht besser.
Ein Ausfall (medium, kalt): gerades " nach „Banane + HP Pudding im String → kein JSON.
Daemon repariert das jetzt („…" → „…“) und die Ausgaberegel verbietet gerade Anführungszeichen.
Bewertung gegen die Zielwerte (Spec §7)
| Knopfklasse | Ziel | Fable gemessen | Urteil |
|---|---|---|---|
Ad-hoc (hunger, snack, pre_run, post_run, two_day, alcohol, shopping_hint) |
< 3 s erste Zeile | ~17 s (low) | verfehlt, Faktor 6 — durch Thinking, nicht durch den Harness |
Planung (plan_today, plan_tomorrow, session_moved, eating_out, hf_week, traffic_light, weight_context, ate_something) |
< 6 s | ~22 s (medium) | verfehlt |
Bewertung (rate_day, change_one_thing, week_review) |
< 12 s | ~40 s (high) | verfehlt |
Die 3 s waren auf Basis der Messung vom 30.08. (TTFT 0,7–1,4 s bei kleinem Prompt ohne Thinking-Anteil) geschätzt; mit einem echten Coach-Prompt denkt Fable erst — und das dauert. Das ist der Preis des Dennis-Entscheids „immer Fable, kein kleineres Modell“ und wird hier nicht umgangen.
Empfehlung
- Effort je Knopfklasse: Ad-hoc low, Planung low (nicht medium — die Antwortqualität
war bei low bereits vollständig, medium kostet +4 s ohne sichtbaren Mehrwert), Bewertung
medium (high kostet +20 s; die Tiefe von high war Formulierung, nicht Substanz). Das ist
eine Empfehlung an den Coach (Frontmatter
effortin den Skills) — der Daemon nimmt, was dort steht. - UI ehrlich: das
meta-Event trägt jetztexpected_ttft_s(17/30/40 je Effort); die App soll „Coach denkt (~17 s)“ mit Fortschritt zeigen, nicht einen 3-s-Spinner. Streaming deranswer_mdab der ersten Zeile bleibt (danach ~10 s bis fertig). - Pool:
high=1nicht nötig — der Kaltstart (+1,5–3 s) ist gegen 17–40 s Thinking Rauschen. Pool low=1, medium=1 bleibt (spart nichts Messbares, kostet nichts). - Nicht tun: Prompt-Trim für Latenz (Kontext ist gecacht, Thinking dominiert); Thinking-Flags (wirkungslos oder kontraproduktiv); Modellwechsel (Dennis: nein).
- Offen für Dennis: wenn 17 s für „Hunger, was jetzt?“ im Alltag zu lang sind, ist die einzige echte Stellschraube ein Modell ohne Zwangs-Thinking — das ist eine Produktentscheidung, keine technische; Zahlen dafür liefere ich auf Ansage (Memory: Prompt-/Algorithmus-Änderung erst benchmarken, dann Freigabe).
Nebenbefund Betrieb: api/oauth/usage ist selbst rate-limitiert — 30-s-Polling hat ab 13:29
für Stunden 429 geliefert. Daemon pollt jetzt alle 5 min, bei 429 15 min Pause, letzter Wert
bleibt; das Rate-Limit-Ereignis der CLI (RateLimitEvent rejected) fängt Überläufe zusätzlich.