Design: Thinking-Modelle → leerer Content (Harness-Blocker)¶
Datum: 2026-06-22
Status: Spec — zur Review
Branch: feat/thinking-models-blocker
1. Problem & Beleg¶
Erster echter Qual-Lauf via GUI (google/gemma-4-12b-qat, LM Studio :1234):
50/52 Antworten content_empty. Das Modell schrieb alles ins reasoning-Feld
(delta.reasoning_content); das ~400-Token-max_tokens-Budget war vom Reasoning
aufgebraucht, bevor je sichtbarer content kam. Der Judge bewertete jede leere
Antwort hart mit 1/5 → eine Scorecard aus lauter 1en.
Das ist ein Harness-Bug, nicht das Modell. Drei Lücken im Code:
- Keine Frühwarnung.
run_eval(qualrun.py:122) startet die volle Matrix ohne jeden Vorabtest. Ein Modell, das nur Reasoning ausgibt, fällt erst nach ~50 Cells auf (~30 Min).cli.py:540ruftrun_evalohne Pre-Flight;gui/control.py:184spawnt den Subprocess ohne Pre-Flight. - Stille Fehlbewertung.
judge.py:192-200verdrahtetcontent_empty → score=1, unscored=Falseohne Judge-Call. reasoning-only (Token-Starvation, unser Setup-Fehler) und echt-leer (Modell gibt wirklich nichts aus) werden zusammengeworfen.reasoning_charswird zwar gemessen (qualrun.py:170), aber nirgends im Scoring genutzt. - Kein Hebel.
client.py:60reicht keinextra_bodydurch;ModelSpec(config.py:34) hat keinen Thinking-/Budget-Knopf. Es gibt keine Möglichkeit, einem Reasoning-Modell mehr Denk-Budget zu geben oder Thinking abzuschalten.
Nicht-Bug, geprüft: Die TTFT-Messung ist korrekt für Reasoning-Modelle —
runner.py:123-125 setzt ttft nur auf den ersten sichtbaren delta_text; Reasoning
blockt sie nie, bei reiner Reasoning-Ausgabe bleibt ttft=nan (nan-sicher in
derive_rates). Kein vierter Bug; nicht im Scope.
2. Ziele / Nicht-Ziele¶
Ziele - Reasoning-Modelle fair vermessbar machen (faire Chance auf sichtbaren Content) und - den Trade-off transparent machen, statt ihn still als 1/5 zu verbuchen. - Verschwendete Voll-Läufe per Frühwarnung verhindern. - Judge-Modell wie das Eval-Modell aus dem GUI wählbar machen.
Nicht-Ziele / bewusste Auslassungen (YAGNI)
- Kein automatisches Aufblähen des Budgets. Ein kleines max_tokens ist ein legitimes
Test-Setup (kleine Maschine) — der Harness ändert die Mess-Bedingung nie ohne explizite
Opt-in-Angabe.
- Kein engine-spezifischer Code außerhalb client.py. Der Thinking-Schalter ist generisches
extra_body, kein if engine == "lm-studio".
- Keine i18n der Rationale-Texte (Deutsch bleibt, wie der Rest der Judge-Texte).
- Kein eigener nicht-streaming Smoke-Pfad — der Pre-Flight nutzt denselben stream_once
wie die Messung (eine Code-Bahn, gleiche Bedingung).
3. Übergreifende Prinzipien¶
- Mess-Bedingung nie heimlich verfälschen. Alle „faire-Chance"-Hebel sind opt-in, Default-neutral. Wer nichts setzt, sieht exakt das heutige Verhalten — nur transparenter.
- Sichtbares Antwort-Budget vom Denk-Budget trennen.
pack.prompt.max_tokensbleibt das gemeinte sichtbare Budget (gleich für alle Modelle → fairer Vergleich). Reasoning-Headroom ist ein separater, pro-Modell Aufschlag aufs Gesamt-Budget. - DRY über CLI und GUI. Die GUI spawnt
python -m touchstone evalals Subprocess; jede Logik inrun_evaldeckt damit beide Pfade ab. Surfacing läuft über die bestehendeevents.jsonl-Maschinerie, die die GUI ohnehin tailt. - Engine-agnostisch.
extra_bodywird roh an die OpenAI-API durchgereicht; der Harness weiß nicht, wie eine Engine Thinking abschaltet — der User gibt es an (Doku liefert Beispiele).
4. Komponente ① — Pre-Flight-Smoke¶
Zweck¶
Vor dem Voll-Lauf einmal pro Modell prüfen, ob das gewählte Setup (Modell × Endpoint × effektives Budget × extra_body) sichtbaren Content liefert. Frühwarn-Gate, keine Messung.
Schnittstelle (pure, DI-testbar)¶
@dataclass
class PreflightResult:
model: str
status: Literal["ok", "reasoning_only", "empty", "not_loaded", "error"]
text_chars: int
reasoning_chars: int
detail: str # menschenlesbar (z.B. "nur Reasoning: 1423 chars, kein content")
def preflight_models(
client: StreamClient,
models: list[ModelSpec],
*,
prompt: str = SMOKE_PROMPT,
budget_for: Callable[[ModelSpec], int], # effektives max_tokens je Modell (s.u.)
) -> list[PreflightResult]
stream_once (gleiche Bahn wie die Messung), ein kleiner aber realistischer Prompt
(SMOKE_PROMPT, z.B. „Antworte in genau einem Satz: Was ist 2 + 2?").
- Smoke-Budget je Modell = max(prompt.max_tokens über den Pack) + model.reasoning_headroom_tokens
— also das großzügigste realistische Gesamt-Budget, nicht ein winziger Fixwert (sonst zeigte
jeder Thinking-Smoke fälschlich „kaputt"). Bewusste Wahl: Der Smoke ist eine gnädige Frühwarnung
— liefert das Modell selbst beim größten Budget nur Reasoning, ist die Warnung sicher korrekt; enge
kleinere Budgets, die nur einzelne Cells reasoning-only machen, fängt die per-Cell-unscored-Logik
(Komponente ②). So minimiert der Smoke fälschliche „kaputt"-Warnungen.
- Klassifikation: not ok → error/not_loaded (Exception-Text unterscheidet „model not
found" von Transport); ok & text.strip() → ok; ok & leer & reasoning_chars>0 →
reasoning_only; ok & leer & kein reasoning → empty.
- Wirft nie selbst (defensiv wie discover_endpoint_models); ein toter Endpoint ergibt
error-Results, kein Crash.
Integration¶
- In
run_eval, nachcells = iter_eval_cells(...)(qualrun.py:111), vorsampler.start()(damit der Smoke nicht im Ressourcen-Fenster der Messung liegt). - Neuer optionaler Callback
on_preflight(results: list[PreflightResult])(analog zu den vorhandenenon_run_start/on_cell_*). Der Event-Writer (--emit-events) schreibt einepreflight-Event-Zeile nachevents.jsonl; die GUI zeigt sie in der Live-Karte. - Resume überspringt den Pre-Flight (
resume=True→ Cells laufen weiter, kein Smoke). - Verhalten bei
reasoning_only/empty/error: - Default: warnen — CLI druckt eine prominente Warnung, Event wird geschrieben, Lauf startet. (Begründung: ein bewusst kleines Budget ist legitim; ein Hard-Abort würde genau dieses Szenario unmessbar machen. Ein falsch-negativer Smoke darf keinen validen Lauf verhindern.)
--strict-preflight(CLI-Flag, GUI-Checkbox später): bei nicht-ok-ModellenRuntimeErrorvor der Matrix → kein Lauf, kein run-dir-Müll.
CLI / GUI¶
cli.py eval_cmd: neues Flag--strict-preflight/--no-strict-preflight(Default off), durchgereicht anrun_eval.gui/control.py start_eval: Pre-Flight läuft automatisch im gespawnten eval-Subprocess; die GUI rendert diepreflight-Events in der bestehenden Live-Progress-Karte.
5. Komponente ② — Thinking-Handling¶
5a. Faire Chance (opt-in, an ModelSpec)¶
class ModelSpec(BaseModel):
id: str
quant: str = ""
max_tokens_default: int = 400
reasoning_headroom_tokens: int = 0 # NEU: extra Gesamt-Budget NUR für dies Modell
extra_body: dict[str, object] = Field(default_factory=dict) # NEU: generisch durchgereicht
qualrun.py:131):
max_tokens = cell.prompt.max_tokens + cell.model.reasoning_headroom_tokens.
Default 0 → identisch zu heute. Für gemma setzt der User z.B. reasoning_headroom_tokens:
2000 → Gesamt-Budget 2400, sichtbares Antwort-Budget bleibt konzeptionell 400.
(Ehrlich dokumentiert: Headroom ist Platz zum Denken, keine harte Garantie auf sichtbaren
Content — die OpenAI-API hat nur ein max_tokens.)
- extra_body wird qualrun → stream_once → client.stream → chat.completions.create(
extra_body=…) durchgereicht (nur wenn nicht leer). Damit kann der User Thinking abschalten,
wo der Endpoint es kann (z.B. {"chat_template_kwargs": {"enable_thinking": false}} bei
qwen3-Stil) — ohne engine-Zweig im Harness.
- Schema-Erweiterungen sind additiv; models_from_json (GUI-Override) akzeptiert die neuen
Felder mit Defaults (kein Picker-UI dafür in dieser Iteration — YAML/Config-Sache).
5b. Bewertung (judge.py score_response)¶
Den content_empty-Zweig (judge.py:192-200) aufteilen über resp.reasoning_chars:
- reasoning-only (content_empty & reasoning_chars > 0): unscored=True,
red_flag=False, klare Rationale „reasoning-only: kein sichtbarer Content (N reasoning-chars)
— Budget zu klein oder Thinking aktiv; nicht als Assistenz-Antwort bewertbar". Kein Score
von 1. Fließt nicht ins Mittel (scorecard.mean_score schließt unscored aus).
- echt-leer (content_empty & reasoning_chars == 0): unverändert score=1,
unscored=False — ein Modell, das wirklich nichts ausgibt, ist als Assistent unbrauchbar.
- not ok (Generierungsfehler): unverändert score=0, unscored=True.
EMPTY_CONTENT_RATIONALE (judge.py:30) wird in zwei Konstanten gesplittet
(REASONING_ONLY_RATIONALE, EMPTY_RATIONALE).
5c. Reasoning sichtbar machen¶
EvalResponsebekommtreasoning_text: str = ""(neben dem schon vorhandenenreasoning_chars). Befüllt nur beicontent_empty(qualrun.py), sonst leer — das hältresponses.jsonlschlank (normale Antworten brauchen ihr Reasoning nicht).- So ist das Denken in der GUI-Antwortansicht /
responses.jsonleinsehbar und der reasoning-only Zustand ist nachvollziehbar (deckt deinen „Reasoning ggf. kommentieren"-Wunsch ab, ohne pro Cell einen teuren Judge-Call zu erzwingen).
5d. Transparenz in der Scorecard¶
scorecard.pyzeigt je (Modell, Variant) die unscored-Quote mit Grund (z.B. „38/52 reasoning-only · 2 Fehler") — damit ein Mittel, das auf wenigen bewerteten Antworten beruht, nicht fälschlich „vollständig" aussieht.- Master-Dimensionen (
_build_dimension_prompt) sehen ohnehin nurnot unscored-Verdicts (judge.py:154) — reasoning-only zieht das holistische Urteil also nicht mehr nach unten.
6. Komponente ③ — Judge-Modell-Picker¶
Discovery generisch machen¶
gui/configs.py: Kern-Helperdiscover_models(base_url, api_key) -> {"models": [...], "error": str|None}(3 s Timeout,max_retries=0— wie heute).discover_endpoint_models( config_path)und neudiscover_judge_endpoint_models(judge_config_path)sind dünne Wrapper, die ihre jeweilige Config laden und die Endpoint-Koordinaten extrahieren (JudgeConfig.endpoint).
Route¶
gui/app.py: neue RouteGET /judge-endpoint-models?judge_config=<path>, never-500, gleicher Path-Traversal-Guard wie/endpoint-models(nurjudge*.yamlaus dem cwd erlaubt).
CLI-Override¶
cli.py judge: neues optionales--judge-model <id>. Gesetzt →jc.model_copy(update={ "model": judge_model})überschreibt das YAML-Modell für diesen Lauf (Muster wie evalapply_models_override). Bundle/YAML werden nicht zurückgeschrieben.
GUI¶
gui/control.py start_judge: optionaler Paramjudge_model; gesetzt →["--judge-model", judge_model]ans argv (Muster wiestart_evalmit--models-json).gui/app.py POST /runs/judge: optionales Form-Feldjudge_model→ anstart_judge.templates/config.htmlJudge-Form: ein Modell-Dropdown, das bei Wechsel desjudge_config_path/judge-endpoint-modelsabfragt (analog zum Eval-@change).static/model_picker.js: schlanke Alpine-KomponentejudgeModelPicker()— füllt ein Dropdown und schreibt in ein Hidden-Field. Kein quant/Multi-Select (Judge braucht nurid). Verworfen: die volle eval-modelPicker-Komponente wiederverwenden (Multi-Select/quant/adhoc sind für den Judge unnötiger Ballast).
Wichtig¶
- Der Judge selbst soll denken dürfen — der Thinking-Aus-Schalter (Komponente ②) gilt nur
für Eval-Modelle, nie für den
OpenAIJudgeBackend.
7. Schema-Änderungen (Zusammenfassung)¶
ModelSpec:+ reasoning_headroom_tokens: int = 0,+ extra_body: dict = {}.EvalResponse:+ reasoning_text: str = ""(nur beicontent_emptybefüllt).runner.StreamClient-Protocol +stream_once:+ extra_body: dict | None = Nonedurchreichen.client.OpenAIStreamClient.stream:+ extra_body→create(extra_body=…)(nur wenn gesetzt).judge.py:EMPTY_CONTENT_RATIONALE→REASONING_ONLY_RATIONALE+EMPTY_RATIONALE.- Keine Änderung an
RAW_CSV_COLUMNS(die Perf-CSV ist unberührt; alles Additive liegt im Qual-Kontraktresults.pybzw. ist transient).
8. Datenfluss (neu)¶
config.yaml (ModelSpec.reasoning_headroom_tokens, extra_body)
│
├─► run_eval ──► preflight_models() ──► on_preflight ──► events.jsonl ──► GUI-Karte / CLI-Warnung
│ (vor sampler.start; resume skippt)
│
└─► Matrix-Loop ──► stream_once(max_tokens = prompt + headroom, extra_body) ──► client.stream
│
└─► EvalResponse(content_empty, reasoning_chars, reasoning_text*)
│ (*nur bei content_empty)
└─► judge.score_response
├─ reasoning-only → unscored + Rationale
├─ echt-leer → score 1
└─ sonst → Judge-Call
└─► scorecard (unscored-Quote sichtbar)
9. Fehlerbehandlung¶
- Pre-Flight defensiv (wirft nie); tote Endpoints →
error-Result + Warnung, kein Crash. --strict-preflightist die einzige Stelle, die wegen Pre-Flight bewusst abbricht (RuntimeErrorvor der Matrix, vor jeglichem Schreiben).extra_body-Durchreichung ist optional; ein Endpoint, der ein Feld ignoriert, ist unkritisch. Ein Endpoint, der bei unbekanntemextra_body4xx wirft, schlägt schon im Pre-Flight auf → Warnung, bevor die Matrix läuft.judge --judge-modelmit nicht existierendem Modell → Judge-Call schlägt fehl → bestehenderunscored-Pfad (score=0, unscored=True), kein Crash.
10. Test-Strategie (TDD, pytest)¶
Pure Logik unit-getestet, I/O dependency-injected (bestehendes Muster).
- config/pack: ModelSpec Defaults + reasoning_headroom_tokens/extra_body-Roundtrip
durch models_from_json; effektives Budget = prompt.max_tokens + headroom.
- client: extra_body landet (nur wenn gesetzt) in create(...) (Fake-SDK-Spy); leer →
Argument gar nicht gesetzt (kein None-Override des SDK-Defaults).
- runner: stream_once reicht extra_body durch.
- preflight: Fake-Client liefert content / reasoning-only / leer / Exception → korrekte
status-Klassifikation; on_preflight-Callback aufgerufen; --strict-preflight wirft;
Default wirft nicht; resume ruft Pre-Flight nicht.
- qualrun: max_tokens inkl. Headroom; extra_body durchgereicht; reasoning_text nur bei
content_empty persistiert.
- judge: reasoning-only → unscored=True, kein score=1, richtige Rationale; echt-leer →
score=1; not ok → unverändert.
- scorecard: reasoning-only-Verdicts aus dem Mittel ausgeschlossen; unscored-Quote im Render.
- judge-picker: discover_models generisch; discover_judge_endpoint_models lädt JudgeConfig;
/judge-endpoint-models never-500 + Path-Guard; --judge-model override; start_judge argv.
- GUI-Smoke: headless Chrome — Judge-Dropdown füllt sich bei Config-Wechsel; Pre-Flight-Event
rendert in der Live-Karte. (GUI nach Änderung neu starten; JS headless prüfen.)
11. Doku-Updates¶
AGENTS.mdGotchas: reasoning-only → unscored (nicht mehr 1/5); Pre-Flight +--strict-preflight;reasoning_headroom_tokens/extra_body; Judge-Picker.docs/explanation/design-decisions.md: warum unscored statt 1/5, warum Budget opt-in.templates/_method_explainer.html: die reasoning-only/unscored-Behandlung im UI erklären (Bewertungs-Methode bleibt transparent + UI-abrufbar).
12. Reihenfolge der Umsetzung¶
- Schema-Additive (
ModelSpec,EvalResponse, Protocol/Signaturen) + Durchreichungextra_body/Headroom (Komponente ② 5a/5c) — Fundament, viele andere Tests hängen daran. - Judge-Bewertung (② 5b/5d) — kleinster, klar abgegrenzter Verhaltens-Change.
- Pre-Flight (①) — baut auf dem effektiven Budget aus 1 auf.
- Judge-Picker (③) — orthogonal, GUI-lastig, zuletzt (separater headless-Smoke).
- Doku (§11) parallel zu jeder Komponente.