length-Retry: abgeschnittene Antworten sind die q-024-Ursache, kein Thinking (D14)

- Diagnose: q-024-Flakiness war KEIN Think-Ghost (thinking-Feld leer,
  keine Tags), sondern num_predict=1024 — lange belegte Antworten
  brachen bei done_reason=length ab (3/3 Sondenlaeufe), Zitationen
  wurden unvollstaendig, CITE_RE matchte partielle IDs -> Verletzung ->
  Regenerierungs-Eskalation -> UNCERTAIN.
- Fix: num_predict 1024 -> 2048; OllamaClient.chat_full() liefert
  (content, done_reason); chat_with_length_retry() wiederholt bei
  length einmal mit 2x Budget (technischer Retry, kein Regel-
  Regenerierungszaehler) - im Antwort-, Map- und Regenerierungspfad.
- Voll-Eval (46 Fragen): Zitier-Praezision 100 %, Verweigerung korrekt
  100 % (46/46) - erstmals alle M3-Gates erfuellt; erwartete Quelle
  92,7 %; Latenz mean 39,6 s / p95 78 s (vollstaendige statt
  abgeschnittener Antworten). q-024: 4/4 stabil.
- Tests 59 -> 60. Report lokal data/eval-qwen38-lengthfix.json.
This commit is contained in:
2026-09-15 14:14:27 +02:00
parent 4a9e06f66e
commit 1594aa9cbd
7 changed files with 115 additions and 13 deletions
+15
View File
@@ -241,6 +241,21 @@ zu planen).
erwartete Quelle 90,2 %, Latenz mean 34,2 s. Tests 57 -> 59. erwartete Quelle 90,2 %, Latenz mean 34,2 s. Tests 57 -> 59.
Report `data/eval-qwen38-stage2.json`. Verbleibend: q-024 transiente Report `data/eval-qwen38-stage2.json`. Verbleibend: q-024 transiente
Flakiness (Think-Ghost-Verdacht, 2/3 Laeufe sauber). Flakiness (Think-Ghost-Verdacht, 2/3 Laeufe sauber).
- **D14 (Output-Budget + length-Retry, 2026-09-15):** q-024-Flakiness
Ursache gefunden — NICHT Thinking (Hypothese widerlegt: thinking-Feld
leer, keine Tags), sondern `num_predict=1024`: lange belegte Antworten
wurden bei `done_reason=length` abgschnitten (q-024: 3/3 Sondenlaeufe)
→ unvollstaendige Zitationen → CITE_RE matcht partielle IDs →
Verletzung → Regen-Eskalation → UNCERTAIN. Fix: (a) num_predict
1024 → 2048; (b) `OllamaClient.chat_full()` liefert (content,
done_reason); (c) `chat_with_length_retry`: ein technischer Retry
mit 2× num_predict bei length (zaehlt nicht als Regel-Regenerierung,
laeuft auch im Map- und im Regenerierungspfad).
**Voll-Eval: Zitier-Praezision 100 %, Verweigerung korrekt 100 %**
(46/46 — alle M3-Gates erfuellt, erstmals ueber Basislinie),
erwartete Quelle 92,7 %, Latenz mean 39,6 s / p95 78 s (+5 s:
vollstaendige statt abgeschnittener Antworten). q-024: 4/4 stabil.
Tests 59 → 60. Report `data/eval-qwen38-lengthfix.json`.
- **Bake-off-Protokoll** (Skill): Modellwechsel nur über dokumentierten - **Bake-off-Protokoll** (Skill): Modellwechsel nur über dokumentierten
Goldset-Vergleich; Kriterium: Zitier-Präzision > Verweigerungs- Goldset-Vergleich; Kriterium: Zitier-Präzision > Verweigerungs-
korrektheit > Latenz. korrektheit > Latenz.
+12
View File
@@ -168,6 +168,18 @@ q-015 antwortet mit KV-Abhängigkeit + Rückfrage. Eval (46 Fragen):
Zitier-Präzision 97,8 % · Verweigerung 97,8 % (Gate ✓) · erwartete Zitier-Präzision 97,8 % · Verweigerung 97,8 % (Gate ✓) · erwartete
Quelle 90,2 % · Latenz mean 34,2 s. Report: `data/eval-qwen38-stage2.json`. Quelle 90,2 % · Latenz mean 34,2 s. Report: `data/eval-qwen38-stage2.json`.
**Output-Budget + length-Retry (2026-09-15, D14):** Die q-024-Flakiness
war kein Thinking, sondern `num_predict=1024`: lange belegte Antworten
wurden bei `done_reason=length` abgeschnitten → unvollständige Zitationen
→ Verletzungs-/Eskalationsspirale. Fix: `num_predict=2048`, `chat_full()`
liefert `done_reason`, bei `length` ein technischer Retry mit 2× Budget
(kein Regel-Regenerierungs-Zähler). **Voll-Eval: Zitier-Präzision
100 % · Verweigerung korrekt 100 %** (46/46, alle M3-Gates erstmals
voll erfüllt) · erwartete Quelle 92,7 % · Latenz mean 39,6 s / p95 78 s.
Report: `data/eval-qwen38-lengthfix.json`. Offene Restfälle: keine
Fehlverweigerungen/Verletzungen mehr; 3-4 Fragen zitiert gültige,
aber nicht die erwartete Quelle (q-015-Klasse).
## Dateien ## Dateien
``` ```
+3 -1
View File
@@ -51,7 +51,9 @@ class Config:
# (KV-Cache @32k ~6-8 GB -> gesamt ~21 GB, Budget ok) # (KV-Cache @32k ~6-8 GB -> gesamt ~21 GB, Budget ok)
max_context_chars: int = 90_000 # Budget fuer User-Content; niedrig gerankte max_context_chars: int = 90_000 # Budget fuer User-Content; niedrig gerankte
# Bloecke werden weggelassen statt trunciert # Bloecke werden weggelassen statt trunciert
num_predict: int = 1024 num_predict: int = 2048 # Output-Budget; 1024 schnitt lange belegte
# Antworten ab (done_reason=length, q-024-Fall)
# -> unvollstaendige Zitationen
think: bool = False # Thinking per Request abschalten (Latenz) think: bool = False # Thinking per Request abschalten (Latenz)
chat_timeout_s: float = 300.0 chat_timeout_s: float = 300.0
embed_timeout_s: float = 240.0 embed_timeout_s: float = 240.0
+29 -8
View File
@@ -227,20 +227,41 @@ def answer_question(
{"role": "user", "content": build_user_content(question, results)}, {"role": "user", "content": build_user_content(question, results)},
] ]
def chat(msgs): def chat(msgs, num_predict: int | None = None):
return strip_think( """Ein Chat-Zug; liefert (gestrippter Content, done_reason).
client.chat( Bei done_reason='length' (Antwort bei num_predict abgeschnitten)
sind Zitationen ggf. unvollstaendig — der Aufrufer ruft einmal mit
doppeltem Budget neu (technischer Retry, kein Regel-Regeneration)."""
budget = num_predict or cfg.num_predict
if hasattr(client, "chat_full"):
raw, done_reason = client.chat_full(
cfg.answer_model, cfg.answer_model,
msgs, msgs,
temperature=cfg.temperature, temperature=cfg.temperature,
num_ctx=cfg.num_ctx, num_ctx=cfg.num_ctx,
num_predict=cfg.num_predict, num_predict=budget,
think=cfg.think, think=cfg.think,
) )
) else:
raw = client.chat(
cfg.answer_model,
msgs,
temperature=cfg.temperature,
num_ctx=cfg.num_ctx,
num_predict=budget,
think=cfg.think,
)
done_reason = "stop"
return strip_think(raw), done_reason
def chat_with_length_retry(msgs):
final, done_reason = chat(msgs)
if done_reason == "length":
final, done_reason = chat(msgs, num_predict=cfg.num_predict * 2)
return final
if map_messages is not None: if map_messages is not None:
summary = chat(map_messages) summary = chat_with_length_retry(map_messages)
if summary: if summary:
messages = [ messages = [
{"role": "system", "content": SYSTEM_PROMPT}, {"role": "system", "content": SYSTEM_PROMPT},
@@ -255,7 +276,7 @@ def answer_question(
] ]
# leerer Map-Output -> Fallback: messages bleibt die Einzelantwort # leerer Map-Output -> Fallback: messages bleibt die Einzelantwort
final = chat(messages) final = chat_with_length_retry(messages)
violations = validate_answer(final, allowed) violations = validate_answer(final, allowed)
regenerations = 0 regenerations = 0
if violations: if violations:
@@ -267,7 +288,7 @@ def answer_question(
"Beantworte die Frage erneut und zitiere nur diese IDs — oder verweigere " "Beantworte die Frage erneut und zitiere nur diese IDs — oder verweigere "
f"mit dem vorgesehenen Satz („{REFUSAL_MESSAGE}“)." f"mit dem vorgesehenen Satz („{REFUSAL_MESSAGE}“)."
) )
retry = chat( retry = chat_with_length_retry(
messages messages
+ [{"role": "assistant", "content": final}, + [{"role": "assistant", "content": final},
{"role": "user", "content": warn}] {"role": "user", "content": warn}]
+18 -4
View File
@@ -74,10 +74,14 @@ class OllamaClient:
# -- Chat --------------------------------------------------------------- # -- Chat ---------------------------------------------------------------
def chat(self, model: str, messages: list[dict], def chat_full(self, model: str, messages: list[dict],
temperature: float = 0.1, num_ctx: int = 16384, temperature: float = 0.1, num_ctx: int = 16384,
num_predict: int = 1024, think: bool = False) -> str: num_predict: int = 1024,
"""POST /api/chat, stream=False; `think`-Flag mit 404/400-Fallback.""" think: bool = False) -> tuple[str, str]:
"""POST /api/chat; liefert (content, done_reason). done_reason ==
'length' bedeutet: Antwort wurde bei num_predict abgeschnitten —
Zitationen koennen dann unvollstaendig sein (Think-Ghost-Ursache
q-024, D13-Follow-up)."""
body: dict[str, Any] = { body: dict[str, Any] = {
"model": model, "model": model,
"messages": messages, "messages": messages,
@@ -105,4 +109,14 @@ class OllamaClient:
content = msg.get("content") or "" content = msg.get("content") or ""
if not content.strip(): if not content.strip():
raise OllamaError(f"empty response from {model} (keys: {list(data.keys())})") raise OllamaError(f"empty response from {model} (keys: {list(data.keys())})")
return content, str(data.get("done_reason") or "stop")
def chat(self, model: str, messages: list[dict],
temperature: float = 0.1, num_ctx: int = 16384,
num_predict: int = 1024, think: bool = False) -> str:
"""POST /api/chat, stream=False; `think`-Flag mit 404/400-Fallback."""
content, _ = self.chat_full(
model, messages, temperature=temperature, num_ctx=num_ctx,
num_predict=num_predict, think=think,
)
return content return content
+8
View File
@@ -315,4 +315,12 @@ tests/ # pytest: Ingest-, Retrieval-, Grounding-Unit-Tests
Eval: 97,8 % / 97,8 % / 90,2 %, Latenz mean 34,2 s (Map-Reduce nur Eval: 97,8 % / 97,8 % / 90,2 %, Latenz mean 34,2 s (Map-Reduce nur
bei Survey-Fragen, ~95 s). Nächster Schritt: Rechtsprechung-Intake bei Survey-Fragen, ~95 s). Nächster Schritt: Rechtsprechung-Intake
(`rj-*`), dann M4 (Odoo; Privacy-Neubewertung für Lohndaten-Zugriff). (`rj-*`), dann M4 (Odoo; Privacy-Neubewertung für Lohndaten-Zugriff).
- **Output-Budget + length-Retry (2026-09-15, M6/D14):** q-024-Flakiness
war num_predict=1024 (done_reason=length, abgeschnittene Zitationen),
nicht Thinking. Fix: num_predict=2048, chat_full() mit done_reason,
technischer 2×-Budget-Retry bei length. **Voll-Eval erstmals mit allen
Gates erfüllt: Zitier-Präzision 100 %, Verweigerung 100 %, erwartete
Quelle 92,7 %** (46/46), Latenz mean 39,6 s. M6 damit abgeschlossen;
nächster Schritt: Rechtsprechung-Intake (`rj-*`), dann M4 (Odoo;
Privacy-Neubewertung für Lohndaten-Zugriff).
- Betrieb: `agent/README.md`. - Betrieb: `agent/README.md`.
+30
View File
@@ -167,3 +167,33 @@ def test_trim_results_drops_tail_under_budget():
assert sum(len(b.text) for b in trimmed) + len(trimmed) * 64 <= 90_000 + 8_000 assert sum(len(b.text) for b in trimmed) + len(trimmed) * 64 <= 90_000 + 8_000
# Unbegrenzt: Original unveraendert # Unbegrenzt: Original unveraendert
assert trim_results(blocks, None) is blocks assert trim_results(blocks, None) is blocks
def test_length_retry_doubles_budget(mini_index):
"""done_reason='length' (abgeschnittene Antwort) -> ein technischer
Retry mit doppeltem num_predict; zaehlt nicht als Regel-Regenerierung."""
class LengthyOllama:
def __init__(self):
self.calls = []
self.budgets = []
def chat_full(self, model, messages, temperature=0.1, num_ctx=16384,
num_predict=1024, think=False):
self.calls.append(messages[-1])
self.budgets.append(num_predict)
if len(self.budgets) == 1:
return "Halbe Zitation [lb-min-0", "length"
return "Antwort mit Beleg [lb-min-01].", "stop"
def chat(self, *a, **k):
raise AssertionError("chat() sollte via chat_full laufen")
def close(self):
pass
client = LengthyOllama()
result = answer_question("Altersteilzeit?", mini_index, client=client)
assert result["verified"] is True
assert result["citations"] == ["lb-min-01"]
assert result["regenerations"] == 0 # technischer Retry, keine Regel-Regen
assert client.budgets == [mini_index.num_predict, mini_index.num_predict * 2]