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:
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
Reference in New Issue
Block a user