From 0848e9581f98b61abcaa403209a893cc6a6fd82d Mon Sep 17 00:00:00 2001 From: Brummel Date: Sun, 3 May 2026 16:43:17 +0200 Subject: [PATCH] Fix gpt-oss-120b with reasoning effort This commit adjusts the configuration for the `gpt-oss-120b` model to run in plain-text mode. This is necessary because when `reasoning_effort` is set to "medium", the reasoning tokens combined with schema-constrained decoding can exhaust the `max_completion_tokens` budget, leading to an empty content response. The change restores the previous behavior where `gpt-oss-120b` was configured without `response_format` or `top_p`, ensuring the request body sent to Ionos remains byte-identical to the pre-multi-backend state. This prevents regressions and maintains stability for existing production workflows. --- docs/ionos-llm-api.md | 108 ++++++++++++++++++++++++++-------- server/src/analyze/backend.rs | 31 ++++++++-- 2 files changed, 107 insertions(+), 32 deletions(-) diff --git a/docs/ionos-llm-api.md b/docs/ionos-llm-api.md index 1a5f7e4..b32c70b 100644 --- a/docs/ionos-llm-api.md +++ b/docs/ionos-llm-api.md @@ -28,9 +28,17 @@ abgleichen, bevor man auf diese Notizen baut. Alle drei sind notwendig; jede einzelne weggelassen kippt in einen anderen Failure-Mode (Timeout, Endlos-Schleife, oder silent `{"document": ""}`). -- **Wichtig**: gpt-oss-120b braucht weder Sampling noch Format-Hinweis — - es ist schema-aware trainiert und laeuft mit dem Default-Body sauber. - Die Drei-Komponenten-Loesung ist Llama-spezifisch, nicht universell. +- **Wichtig**: gpt-oss-120b laeuft mit `reasoning_effort: medium` + am besten im **plain-text mode** (kein `response_format`, kein + `top_p`, keine zweite system-Message). Reasoning-Tokens zaehlen gegen + `max_completion_tokens`, und schema-constrained decoding zusaetzlich + zum Reasoning kann das Budget so leersaugen, dass `content: null` + zurueckkommt (Test T7 / Production-Failure auf Case c414cf52, + 6 recordings, ~3.8k chars). Doctate setzt deshalb fuer gpt-oss + `use_json_schema: false` — Body ist byte-identisch zum + pre-multi-backend-Stand (commit bf6464d). +- Die Drei-Komponenten-Loesung ist also **Llama-spezifisch**, nicht + universell. Backends mit Reasoning brauchen das Gegenteil: schema OFF. ## So kommt man an die richtige API-Info @@ -238,6 +246,32 @@ verbatim (2819 chars). Schema + Format-Hinweis. T6 (separate `system`-Message) ist architektonisch sauberer und gewaehlt fuer die Doctate-Implementation. +#### Session 3 (gpt-oss-Regression vom Multi-Backend-Default, 30 s Timeout) + +Modell `openai/gpt-oss-120b` mit `reasoning_effort: "medium"`, +`max_completion_tokens: 4096`. Input: realer Doctate-Body Case +`c414cf52` (6 Aufnahmen, 3851 char user_content, ~1900 prompt_tokens +nach DEFAULT_SYSTEM_PROMPT). + +| Test | response_format | Wallclock | finish | content | completion_tokens | +|---|---|---|---|---|---| +| Production 14:23 | `json_schema` (DEFAULT_USE_JSON_SCHEMA) | 58 s | – | **null** (`LlmError::Parse`) | – | +| T7 (nach Fix) | weggelassen | 15.68 s | stop | sauberer Arztbrief, 3685 chars | 1692 | + +**Ursache**: Mit `reasoning_effort: medium` zaehlen Reasoning-Tokens +gegen `max_completion_tokens`. Schema-constrained decoding (vLLM +grammar) belastet zusaetzlich das CPU-Budget. Bei Bodies dieser +Groesse fressen Reasoning + Schema-Resampling die 4096 Tokens auf, +bevor das Modell zur Inhalts-Generierung kommt — Resultat: +`choices[0].message.content == null`. + +**Pre-Multi-Backend** (commit bf6464d) lief gpt-oss ohne +`response_format`, also reasoning + plain text → content kam immer +zurueck. Der Multi-Backend-Layer hat `DEFAULT_USE_JSON_SCHEMA = true` +universell aktiviert, was fuer Llama noetig, fuer gpt-oss aber +schaedlich war. Fix: `gpt_oss_120b` setzt jetzt explizit +`with_use_json_schema(false)` — Body byte-identisch zu pre-bf6464d. + ## Loesungswege im Vergleich | Loesung | Speed | Sauber | Modell-agnostisch | Kommentar | @@ -249,20 +283,33 @@ verbatim (2819 chars). | Modellwechsel auf `gpt-oss-120b` / `Llama-3.3-70B` | sehr schnell | ja | – | Schema-aware, brauchen die Drei-Komponenten-Kombi nicht | **Empfehlung pro Modell-Familie:** -- Schema-aware Modelle (gpt-oss-120b, vermutlich Llama 3.3 70B): - `temperature: 0.5 + response_format: json_schema`. Body bleibt klein - und stabil. -- Llama 3.1 405B FP8: nur als **Drei-Komponenten-Kombi** brauchbar — - `temperature: 0.6 + top_p: 0.9 + response_format: json_schema + +- **Reasoning-Modelle mit `reasoning_effort != null`** (gpt-oss-120b + und Verwandte): **plain-text mode** — kein `response_format`, kein + `top_p`. Reasoning + Schema kollidieren am Token-Budget, Resultat + ist `content: null`. Doctate-Default fuer dieses Profil: + `temperature: 0.5 + reasoning_effort: medium + max_completion_tokens: + 4096`. +- **Llama 3.1 405B FP8**: nur als **Drei-Komponenten-Kombi** brauchbar + — `temperature: 0.6 + top_p: 0.9 + response_format: json_schema + expliziter Format-Hinweis als zweite system-Message`. Wer eine Komponente weglaesst, faengt sich Timeout (T1, T3) oder silent deletion (T4) ein. +- **Schema-aware Nicht-Reasoning-Modelle** (vermutlich Llama 3.3 70B, + Mistral Nemo): Default plain-text + `response_format: json_schema` + ist der Erwartungswert, aber **noch nicht empirisch verifiziert** + — vor Aktivierung gegen den Doctate-Body testen. ## Doctate-Implementation (`server/src/analyze/backend.rs`) -Die Drei-Komponenten-Kombi lebt als Per-Backend-Konfiguration: +Pro-Backend-Konfiguration mit zwei verschiedenen Profilen: ```rust +// gpt-oss: schema OFF, weil Reasoning + Schema das Token-Budget aufessen. +ionos_backend("gpt_oss_120b", "GPT OSS 120b", "openai/gpt-oss-120b") + .with_reasoning_effort("medium") + .with_use_json_schema(false), + +// Llama 3.1 405B FP8: Drei-Komponenten-Kombi. const LLAMA_TEMPERATURE: f32 = 0.6; const LLAMA_TOP_P: f32 = 0.9; const LLAMA_FORMAT_INSTRUCTION: &str = "AUSGABEFORMAT: ..."; @@ -274,30 +321,32 @@ ionos_backend("llama_3_1_405b", "Llama 3.1 405B", "...405B-Instruct-FP8") .with_format_instruction(LLAMA_FORMAT_INSTRUCTION), ``` -`chat_once` injiziert `format_instruction` als zweite `system`-Message -zwischen `system_prompt` und `user`-Content, falls `Some`. Backends -ohne `format_instruction` (gpt-oss) erzeugen den exakten Body wie vor -dem Llama-Fix — byte-stabil, gepinnt durch -`gpt_oss_has_no_format_instruction_and_no_top_p`-Test. +`chat_once` baut den Body dynamisch: +- `response_format: json_schema` wird gesendet **gdw** + `backend.use_json_schema == true` (Llama: ja, gpt-oss: nein). +- `top_p` wird per `Option::is_none`-Skip nur gesendet wenn `Some` + (Llama: ja, gpt-oss: nein). +- Eine zweite `system`-Message wird ans `messages`-Array gehaengt, + falls `format_instruction.is_some()` (Llama: ja, gpt-oss: nein). + +Damit ist der gpt-oss-Body **byte-identisch** zum pre-Multi-Backend- +Stand (commit bf6464d). Gepinnt durch +`gpt_oss_runs_in_plain_text_mode_no_schema_no_top_p_no_format_hint`-Test. `worker.rs` hat einen **Empty-Guard** nach `vocab.replace`: leere -`document`-Strings (Failure-Mode T4) loesen einen Failure-Marker statt -einer leeren `document.md` aus. +`document`-Strings (Failure-Mode T4 fuer Llama) loesen einen +Failure-Marker statt einer leeren `document.md` aus. ## Entscheidungsbaum ```mermaid graph TD - A[Neuer Backend-Profil-Eintrag in backend.rs] --> B{Modell?} - B -->|gpt-oss-120b
schema-aware| C[Default: temp 0.5,
use_json_schema=true,
kein top_p, keine format_instruction] - B -->|Llama 3.1 405B FP8| D[Drei-Komponenten-Kombi:
temp 0.6 + top_p 0.9 + schema
+ format_instruction] - B -->|Llama 3.3 70B
noch nicht getestet| E[Erst gegen Doctate-Body testen,
vermutlich wie gpt-oss] - B -->|Anderes neues Modell| F{Schema-aware trainiert?} - F -->|Ja| C - F -->|Nein| D - C --> G[Body byte-stabil zu pre-Llama-Fix] - D --> H[Worker-Empty-Guard schuetzt vor silent deletion] - E --> G + A[Neuer Backend-Profil-Eintrag in backend.rs] --> B{Reasoning-Modell?
reasoning_effort != null?} + B -->|Ja, gpt-oss-120b| C[plain-text mode:
with_use_json_schema false,
kein top_p, keine format_instruction
Body byte-stabil zu pre-bf6464d] + B -->|Nein| D{Schema-aware trainiert?} + D -->|Ja, vermutlich Llama 3.3 70B,
Mistral Nemo| E[Default: temp 0.5,
use_json_schema=true
VOR Aktivierung gegen
Doctate-Body testen] + D -->|Nein, Llama 3.1 405B FP8| F[Drei-Komponenten-Kombi:
temp 0.6 + top_p 0.9 + schema
+ format_instruction] + F --> G[Worker-Empty-Guard schuetzt
vor silent deletion] ``` ## Quellen und Verweise @@ -328,6 +377,13 @@ einem neuen Bug auf diese Workarounds setzt, immer zuerst pruefen, ob: `None` zu setzen und einen Doctate-Case neu zu triggern. Wenn der Output sauber kommt, kann der Format-Hinweis weg. Wenn er `{"document":""}` ist, gilt diese Doku noch. +5. Der gpt-oss-Token-Budget-Konflikt mit `response_format: json_schema` + weiterhin existiert: testweise `with_use_json_schema(true)` fuer + gpt-oss setzen, einen 6-Aufnahmen-Case (~3.8k chars) triggern. Wenn + `LlmError::Parse: missing choices[0].message.content` im Log + erscheint, gilt diese Doku noch. Wenn der Output sauber durchkommt, + hat Ionos das Reasoning/Schema-Budget-Verhalten geaendert und + `use_json_schema=true` koennte fuer gpt-oss reaktiviert werden. Datum oben aktualisieren, wenn man verifiziert hat, dass diese Dokumentation noch dem realen Backend-Verhalten entspricht. diff --git a/server/src/analyze/backend.rs b/server/src/analyze/backend.rs index 71296ab..7e7b03d 100644 --- a/server/src/analyze/backend.rs +++ b/server/src/analyze/backend.rs @@ -95,6 +95,11 @@ impl LlmBackend { self } + fn with_use_json_schema(mut self, use_schema: bool) -> Self { + self.use_json_schema = use_schema; + self + } + fn with_system_prompt(mut self, prompt: &str) -> Self { self.system_prompt = prompt.into(); self @@ -144,8 +149,16 @@ pub fn backends() -> &'static [LlmBackend] { static B: OnceLock> = OnceLock::new(); B.get_or_init(|| { vec![ + // gpt-oss-120b: schema OFF on purpose. With `reasoning_effort: + // medium`, reasoning tokens count against `max_completion_tokens` + // (4096); on large bodies (e.g. 6 recordings, ~3.8k chars) the + // reasoning eats the budget and the model returns + // `choices[0].message.content == null` — the LlmError::Parse path. + // Pre-multi-backend (commit bf6464d) gpt-oss ran without schema + // and worked fine; this restores that body byte-for-byte. ionos_backend("gpt_oss_120b", "GPT OSS 120b", "openai/gpt-oss-120b") - .with_reasoning_effort("medium"), + .with_reasoning_effort("medium") + .with_use_json_schema(false), ionos_backend( "llama_3_1_405b", "Llama 3.1 405B", @@ -290,15 +303,21 @@ mod tests { assert!(b.use_json_schema, "Llama needs schema-enforced stop"); } - /// gpt-oss-120b is schema-aware out of the box. Asserting that it has - /// neither a top_p nor a format instruction guarantees the body sent to - /// Ionos stays byte-identical to pre-Llama-fix builds — the production - /// path that has been working for weeks must not silently change. + /// gpt-oss-120b runs in plain-text mode (no `response_format`, no + /// `top_p`, no second system message). Reason: with + /// `reasoning_effort: medium`, schema-constrained decoding plus reasoning + /// can exhaust `max_completion_tokens` on large bodies and return + /// `content: null`. This pin guarantees the body sent to Ionos stays + /// byte-identical to the pre-multi-backend path (commit bf6464d). #[test] - fn gpt_oss_has_no_format_instruction_and_no_top_p() { + fn gpt_oss_runs_in_plain_text_mode_no_schema_no_top_p_no_format_hint() { let b = find_backend("gpt_oss_120b").unwrap(); assert!((b.temperature - 0.5).abs() < f32::EPSILON); assert!(b.top_p.is_none(), "gpt-oss must not send top_p"); + assert!( + !b.use_json_schema, + "gpt-oss must NOT use json_schema — collides with reasoning_effort budget" + ); assert!( b.format_instruction.is_none(), "gpt-oss must not get a second system message — body byte-stable"