feat(llm): Härtung gegen tool-unfähiges Modell (no-tool-endpoints-404)
Waehlt jemand (Admin/Preset) ein Modell ohne Tool-faehigen Endpoint, lieferte OpenRouter ein 404 "No endpoints found that support tool use" -> bisher 4x Retry mit Backoff und danach stille, konfident-veraltete Fallback-Antwort. - ToolCallingLLM erkennt das strukturelle 404 (_looks_tool_unsupported), merkt es fuer den Turn (_tools_unsupported), deaktiviert Tools und antwortet SOFORT plain (kein Haenger). Log-Warnung + Metrik tool_unsupported_total. - Backstop-Inject jetzt als SYSTEM-Kontext statt synthetischem tool_call: das funktioniert mit tool-faehigen UND tool-unfaehigen Modellen (letztere lehnen tool-Messages mit 405 ab). Folge: heikle Kategorien bleiben auch auf einem tool-unfaehigen Modell korrekt; nur nicht-heikle Volatil-Fragen fallen plain. Live verifiziert gegen mistral-2501 (tool-unfaehig): 3.7s statt Haenger, Backstop liefert weiter "Merz"; gegen mistral-3.2 kein Regress (1 Suche). Tests: 312 gruen. Doc §5.7/§5.8/§8. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
e753788a23
commit
48a79da12f
2 changed files with 70 additions and 17 deletions
|
|
@ -140,9 +140,21 @@ getestet). Bei Treffer erzwingt `ToolCallingLLM` die Suche **deterministisch**.
|
|||
**Wichtige Designentscheidung:** *Nicht* per `tool_choice`-Forcen — das honoriert
|
||||
das Modell nicht zuverlässig (beobachtet: forced choice ignoriert → veraltete
|
||||
Antwort). Stattdessen führt der Wrapper die Suche **selbst direkt** aus und speist
|
||||
das Ergebnis als synthetischen Tool-Turn ein (`_inject_forced_search`); das Modell
|
||||
formuliert daraus. Metrik: `search_forced_total`. Recall-optimiert (lieber eine
|
||||
überflüssige Suche als eine konfident falsche Antwort).
|
||||
das Ergebnis als **System-Kontext** ein (`_inject_forced_search`); das Modell
|
||||
formuliert daraus. System-Kontext (statt synthetischem tool_call) funktioniert mit
|
||||
tool-fähigen **und** tool-unfähigen Modellen (letztere lehnen tool-Messages mit 405
|
||||
ab). Metrik: `search_forced_total`. Recall-optimiert (lieber eine überflüssige
|
||||
Suche als eine konfident falsche Antwort).
|
||||
|
||||
### 5.8 Härtung: tool-unfähiges Modell
|
||||
Wählt jemand (Admin/Preset) ein Modell ohne Tool-fähigen Endpoint, liefert
|
||||
OpenRouter ein 404 „No endpoints found that support tool use". `ToolCallingLLM`
|
||||
erkennt das (`_looks_tool_unsupported`), **merkt es für den Turn** (`_tools_unsupported`),
|
||||
deaktiviert Tools und antwortet **sofort plain** (kein Retry-Hänger, keine stille
|
||||
Stale-Antwort durch die Fallback-Kette). Log-Warnung + Metrik `tool_unsupported_total`.
|
||||
Wichtig: Der **Backstop bleibt wirksam** (er speist Fakten als System-Kontext ein,
|
||||
nicht als tool_call) → heikle Kategorien bleiben auch auf einem tool-unfähigen Modell
|
||||
korrekt; nur nicht-heikle Volatil-Fragen (Wetter/Preise) fallen auf plain zurück.
|
||||
|
||||
## 6. Die zwei harten Stellen (bewusst benannt)
|
||||
|
||||
|
|
@ -174,8 +186,8 @@ wird zur Tool-Rangordnung).
|
|||
- **Erledigt (nach v1):** Backstop für heikle Kategorien (§5.7), Filler für
|
||||
**Gerät-TTS** (§5.4), **Citations in die UI-Bubble** (`PipelineTrace.citations`
|
||||
→ `on_citations` → WS `semantic`-Event → Quellen-Links unter der Antwort).
|
||||
- **Fast-follow (offen):** „no-tool-endpoints"-404-Härtung (tool-unfähiges Modell
|
||||
bewusst plain statt still stale), ggf. deterministischer Logistik-Nudge.
|
||||
- **Erledigt:** „no-tool-endpoints"-404-Härtung (§5.8).
|
||||
- **Fast-follow (offen):** ggf. deterministischer Logistik-Nudge (`schedule_hours`).
|
||||
- **Später (Multitool):** weitere Tools in dieselbe `ToolCallingLLM`-Schleife
|
||||
(Kalender, Erinnerungen, Medizin-Safety) mit Tool-Rangordnung.
|
||||
|
||||
|
|
|
|||
|
|
@ -26,6 +26,16 @@ logger = logging.getLogger(__name__)
|
|||
|
||||
ENDPOINT = "https://openrouter.ai/api/v1/chat/completions"
|
||||
_RETRY_STATUS = {404, 429, 500, 502, 503}
|
||||
# OpenRouter-404, wenn das Modell keinen Tool-fähigen Endpoint hat.
|
||||
_TOOL_UNSUPPORTED_HINT = "support tool use"
|
||||
|
||||
|
||||
class _ToolsUnsupported(Exception):
|
||||
"""Strukturelles 404: das gewählte Modell kann (über OpenRouter) kein Tool-Calling."""
|
||||
|
||||
|
||||
def _looks_tool_unsupported(status: int, body: str, has_tools: bool) -> bool:
|
||||
return has_tools and status == 404 and _TOOL_UNSUPPORTED_HINT in (body or "").lower()
|
||||
|
||||
# Tool-Schema inkl. Trigger-Kategorien — wortgleich zur im Eval validierten Fassung.
|
||||
WEB_SEARCH_TOOL = {
|
||||
|
|
@ -138,6 +148,17 @@ class ToolCallingLLM(LLMProvider):
|
|||
self.max_retries = max(1, max_retries)
|
||||
self.temperature = temperature
|
||||
self.decontextualizer = decontextualizer
|
||||
# Wird gesetzt, sobald das Modell ein "no tool endpoints"-404 liefert →
|
||||
# für den Rest des Turns ohne Tools (kein Retry-Hänger, keine stille Stale).
|
||||
self._tools_unsupported = False
|
||||
|
||||
def _mark_tools_unsupported(self, body: str) -> None:
|
||||
if not self._tools_unsupported:
|
||||
logger.warning("Modell %r kann kein Tool-Calling → web_search für diesen Turn "
|
||||
"deaktiviert (plain). Backstop nutzt weiter direkte Suche. %s",
|
||||
self.model, (body or "")[:160])
|
||||
metrics.inc("tool_unsupported_total")
|
||||
self._tools_unsupported = True
|
||||
|
||||
async def _resolve_text(self, text: str, history, language) -> str:
|
||||
"""Koreferenz-Vorstufe (gegated) — löst Pronomen aus der History auf."""
|
||||
|
|
@ -161,7 +182,7 @@ class ToolCallingLLM(LLMProvider):
|
|||
async def _chat(self, messages: list[dict], allow_tools: bool = True) -> dict:
|
||||
"""Ein Modell-Aufruf; liefert die Assistant-Message (mit/ohne tool_calls)."""
|
||||
payload = {"model": self.model, "messages": messages, "temperature": self.temperature}
|
||||
if allow_tools:
|
||||
if allow_tools and not self._tools_unsupported:
|
||||
payload["tools"] = [WEB_SEARCH_TOOL]
|
||||
payload["tool_choice"] = "auto"
|
||||
timeout = httpx.Timeout(connect=10.0, read=120.0, write=30.0, pool=10.0)
|
||||
|
|
@ -171,6 +192,13 @@ class ToolCallingLLM(LLMProvider):
|
|||
for attempt in range(1, self.max_retries + 1):
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
resp = await client.post(ENDPOINT, json=payload, headers=headers)
|
||||
# Modell ohne Tool-Endpoint: einmalig merken, Tools entfernen, sofort
|
||||
# ohne Tools erneut (kein Retry-Hänger, keine stille Stale).
|
||||
if _looks_tool_unsupported(resp.status_code, resp.text, "tools" in payload):
|
||||
self._mark_tools_unsupported(resp.text)
|
||||
payload.pop("tools", None)
|
||||
payload.pop("tool_choice", None)
|
||||
continue
|
||||
if resp.status_code in _RETRY_STATUS and attempt < self.max_retries:
|
||||
last_error = RuntimeError(f"OpenRouter {resp.status_code}: {resp.text[:200]}")
|
||||
await asyncio.sleep(min(2.0 * attempt, 30.0))
|
||||
|
|
@ -212,7 +240,9 @@ class ToolCallingLLM(LLMProvider):
|
|||
async def _inject_forced_search(self, messages: list[dict], query: str,
|
||||
language: str | None) -> list:
|
||||
"""Backstop: heikle Kategorie → Suche DETERMINISTISCH ausführen und das
|
||||
Ergebnis als synthetischen Tool-Turn einspeisen. Verlässlicher als
|
||||
Ergebnis als **System-Kontext** einspeisen. Bewusst KEIN synthetischer
|
||||
tool_call: System-Kontext funktioniert mit tool-fähigen UND tool-unfähigen
|
||||
Modellen (letztere lehnen tool-Messages mit 405 ab). Verlässlicher als
|
||||
`tool_choice`-Forcen (das honoriert das Modell nicht zuverlässig).
|
||||
Liefert die Citations zurück."""
|
||||
tool = self.tools.get("web_search")
|
||||
|
|
@ -220,10 +250,12 @@ class ToolCallingLLM(LLMProvider):
|
|||
return []
|
||||
metrics.inc("tool_calls_total", {"tool": "web_search"})
|
||||
result = await tool.run(query, language=language)
|
||||
messages.append({"role": "assistant", "content": None, "tool_calls": [
|
||||
{"id": "forced_0", "type": "function",
|
||||
"function": {"name": "web_search", "arguments": json.dumps({"query": query})}}]})
|
||||
messages.append({"role": "tool", "tool_call_id": "forced_0", "content": result.text})
|
||||
messages.append({
|
||||
"role": "system",
|
||||
"content": ("Aktuelle Web-Suchergebnisse zur Nutzerfrage (maßgeblich, neuer als "
|
||||
"dein Trainingswissen):\n" + result.text +
|
||||
"\n\nBeantworte die Frage des Nutzers auf Basis dieser Ergebnisse."),
|
||||
})
|
||||
return list(getattr(result, "citations", []) or [])
|
||||
|
||||
async def complete(self, text: str, history: list[dict] | None = None,
|
||||
|
|
@ -280,8 +312,14 @@ class ToolCallingLLM(LLMProvider):
|
|||
|
||||
for _round in range(self.max_rounds):
|
||||
acc = _StreamAcc()
|
||||
async for piece in self._stream_chat(messages, allow_tools=True, acc=acc):
|
||||
yield piece
|
||||
try:
|
||||
async for piece in self._stream_chat(messages, allow_tools=True, acc=acc):
|
||||
yield piece
|
||||
except _ToolsUnsupported:
|
||||
# Modell ohne Tool-Endpoint -> Runde plain (ohne Tools) wiederholen.
|
||||
acc = _StreamAcc()
|
||||
async for piece in self._stream_chat(messages, allow_tools=False, acc=acc):
|
||||
yield piece
|
||||
if not acc.tool_calls:
|
||||
return # war Text -> fertig (durchgereicht)
|
||||
if on_tool_start is not None:
|
||||
|
|
@ -303,7 +341,7 @@ class ToolCallingLLM(LLMProvider):
|
|||
"""Ein gestreamter Modell-Call. Yieldet Text-Deltas; füllt acc (Text + tool_calls)."""
|
||||
payload = {"model": self.model, "messages": messages,
|
||||
"temperature": self.temperature, "stream": True}
|
||||
if allow_tools:
|
||||
if allow_tools and not self._tools_unsupported:
|
||||
payload["tools"] = [WEB_SEARCH_TOOL]
|
||||
payload["tool_choice"] = "auto"
|
||||
timeout = httpx.Timeout(connect=10.0, read=120.0, write=30.0, pool=10.0)
|
||||
|
|
@ -312,9 +350,12 @@ class ToolCallingLLM(LLMProvider):
|
|||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
async with client.stream("POST", ENDPOINT, json=payload, headers=headers) as resp:
|
||||
if resp.status_code >= 400:
|
||||
body = await resp.aread()
|
||||
raise RuntimeError(
|
||||
f"OpenRouter {resp.status_code}: {body.decode(errors='replace')[:200]}")
|
||||
body = (await resp.aread()).decode(errors="replace")
|
||||
# Modell ohne Tool-Endpoint: merken + signalisieren (Caller wiederholt ohne Tools).
|
||||
if _looks_tool_unsupported(resp.status_code, body, "tools" in payload):
|
||||
self._mark_tools_unsupported(body)
|
||||
raise _ToolsUnsupported()
|
||||
raise RuntimeError(f"OpenRouter {resp.status_code}: {body[:200]}")
|
||||
async for line in resp.aiter_lines():
|
||||
if not line.startswith("data:"):
|
||||
continue
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue