feat(llm): horoskop powstaje zawsze — kontynuacja, okna kontekstu, budzet max
PRZYCZYNA PUSTYCH ODPOWIEDZI NA ANTHROPICU (potwierdzona w dokumentacji API): domyslnym modelem byl `claude-sonnet-5`, ktory przy POMINIETYM parametrze `thinking` wlacza myslenie adaptacyjne, a `thinking.display` domyslnie jest "omitted". Tokeny myslenia licza sie do max_tokens, wiec przy LLM_MAX_TOKENS=2000 cala tura wychodzila jako bloki `thinking` z pustym tekstem — parser filtrowal type=="text" i zwracal pusty string. Opus 4.8 bez `thinking` nie mysli, wiec tam objaw by nie wystapil. Gwarancja niepustej odpowiedzi (wszyscy trzej dostawcy): - generate() to teraz PETLA, nie pojedynczy strzal: tura -> jesli urwana na limicie, dopisz ture „kontynuuj" w tej samej rozmowie i sklej tekst, - tura zlozona z samego myslenia traktowana jak urwana (nie jak pustka), - pusta i NIE urwana -> jedna proba z podpowiedzia, dopiero potem blad, - kontynuacja konczy sie tura UZYTKOWNIKA — Claude odrzuca prefill asystenta (400), - `thinking` konfigurowany JAWNIE (adaptive + effort=high; ANTHROPIC_THINKING=off). Okna kontekstu i rezerwa na odpowiedz (app/llm/limits.py): - tabela okien/limitow wyjscia per model + nadpisanie z ENV, - plan() liczy okno odpowiedzi jako okno - prompt - margines i NIGDY nie oddaje calego kontekstu promptowi, - Anthropic liczy tokeny DOKLADNIE (/v1/messages/count_tokens), reszta szacuje, - >90 tys. tokenow promptu -> ostrzezenie, ale wyslanie NADAL mozliwe i z pelnym oknem odpowiedzi. UI: suwak budzetu rozszerzony o „bardzo obszerny" i „maksymalny kontekst modelu" (liczony z okna wybranego modelu po odjeciu rezerwy); przy wyniku widac plan tokenow, liczbe tur i ostrzezenia. Domyslny model Anthropic: claude-opus-4-8. Testy: 170 passed / 1 skipped (logika) + 15 (prezentacja). Nowe testy pokrywaja sklejanie kontynuacji, brak prefillu asystenta, ture z samego myslenia, rezerwe na odpowiedz i prog ostrzezenia. Zweryfikowane e2e na atrapie Anthropica odtwarzajacej zgloszony objaw: 3 tury, obie czesci tekstu obecne. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -5,9 +5,24 @@ vLLM, llama.cpp) i OpenAI mówią **tym samym** protokołem `/chat/completions`,
|
||||
więc jedna implementacja obsługuje oba — różni je tylko adres i klucz. Anthropic
|
||||
ma własny kształt `/v1/messages`, stąd druga klasa. Mniej zależności, mniej
|
||||
powierzchni ataku, pełna kontrola nad tym, co wychodzi z sieci.
|
||||
|
||||
**Gwarancja niepustej odpowiedzi.** Horoskop ma powstać niezależnie od objętości
|
||||
promptu, więc `generate()` nie jest pojedynczym strzałem, tylko pętlą:
|
||||
1. wyślij turę z policzonym limitem wyjścia,
|
||||
2. jeśli model urwał na limicie — dopisz turę „kontynuuj" i sklej tekst,
|
||||
3. jeśli tura nie dała ani znaku tekstu — ponów z podpowiedzią,
|
||||
4. dopiero brak tekstu po wszystkich próbach jest błędem (z diagnostyką).
|
||||
Kontynuacja jest pewniejsza niż jedno wielkie żądanie: każda tura mieści się
|
||||
w timeoucie HTTP, a długość odpowiedzi przestaje być ograniczona jedną turą.
|
||||
|
||||
**Anthropic i myślenie.** Modele Claude potrafią mieć włączone myślenie, którego
|
||||
tokeny liczą się do `max_tokens`. Przy ciasnym limicie cała tura potrafi wyjść
|
||||
jako same bloki `thinking` z pustym tekstem — dokładnie ten objaw, który
|
||||
zgłoszono. Traktujemy taką turę jak ucięcie i kontynuujemy, zamiast zwracać pustkę.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
|
||||
import httpx
|
||||
@@ -17,6 +32,23 @@ from app.llm.base import Completion, LLMError, LLMProvider
|
||||
RETRY_STATUSES = {429, 500, 502, 503, 504}
|
||||
MAX_ATTEMPTS = 3
|
||||
|
||||
# ile razy wolno poprosić model o dokończenie urwanej odpowiedzi
|
||||
MAX_CONTINUATIONS = 12
|
||||
# ile tokenów zamawiać na jedną turę — mieści się w timeoucie, a pętla i tak
|
||||
# dociągnie resztę; zbyt duża wartość ryzykuje zerwanie połączenia w trakcie
|
||||
TURN_TOKENS_CAP = 16_000
|
||||
|
||||
_CONTINUE = (
|
||||
"Kontynuuj dokładnie od miejsca, w którym przerwałeś — nie powtarzaj tego, "
|
||||
"co już napisałeś, i nie zaczynaj od nowa. Jeśli skończyłeś całą odpowiedź, "
|
||||
"napisz wyłącznie: KONIEC"
|
||||
)
|
||||
_NUDGE = (
|
||||
"Nie otrzymałem żadnej treści. Napisz odpowiedź zgodnie z powyższym poleceniem, "
|
||||
"zaczynając od razu od treści horoskopu."
|
||||
)
|
||||
_DONE_MARKER = "KONIEC"
|
||||
|
||||
|
||||
def _post_with_retry(url: str, headers: dict, payload: dict, timeout: float) -> dict:
|
||||
"""POST z ponawianiem i backoffem — chroni przed chwilowym 429/5xx."""
|
||||
@@ -37,8 +69,8 @@ def _post_with_retry(url: str, headers: dict, payload: dict, timeout: float) ->
|
||||
time.sleep(2 ** attempt)
|
||||
continue
|
||||
raise LLMError(
|
||||
f"Model nie odpowiedział w czasie {timeout:.0f}s. Dłuższe horoskopy "
|
||||
f"wymagają większego LLM_TIMEOUT albo mniejszego budżetu promptu."
|
||||
f"Model nie odpowiedział w czasie {timeout:.0f}s. Zwiększ LLM_TIMEOUT "
|
||||
f"albo zmniejsz budżet promptu."
|
||||
) from e
|
||||
except httpx.HTTPError as e:
|
||||
last = e
|
||||
@@ -49,40 +81,108 @@ def _post_with_retry(url: str, headers: dict, payload: dict, timeout: float) ->
|
||||
raise LLMError(f"Nie udało się wywołać modelu: {last}")
|
||||
|
||||
|
||||
def _require_text(text: str, usage: dict, finish_reason: str | None, max_tokens: int) -> str:
|
||||
"""Pusta odpowiedź modelu MUSI być błędem, nie pustym tekstem.
|
||||
def _merge_usage(total: dict, turn: dict) -> dict:
|
||||
"""Sumuje zużycie tokenów przez wszystkie tury jednej odpowiedzi."""
|
||||
for key, value in (turn or {}).items():
|
||||
if isinstance(value, int):
|
||||
total[key] = total.get(key, 0) + value
|
||||
return total
|
||||
|
||||
Inaczej mamy cichą awarię: warstwa wyżej dostaje `horoscope=""`, widok nic nie
|
||||
renderuje i NIC nie tłumaczy użytkownikowi, dlaczego strona wróciła pusta.
|
||||
Najczęstsza przyczyna: prompt wypełnił okno kontekstu modelu, więc na odpowiedź
|
||||
nie zostało miejsca (`finish_reason=length`, `completion_tokens=0`) — dotyczy to
|
||||
zwłaszcza obszernych promptów natalnych na modelach lokalnych o małym kontekście.
|
||||
"""
|
||||
if text and text.strip():
|
||||
return text
|
||||
|
||||
powod = []
|
||||
if finish_reason:
|
||||
powod.append(f"finish_reason={finish_reason}")
|
||||
if usage:
|
||||
pt, ct = usage.get("prompt_tokens"), usage.get("completion_tokens")
|
||||
if pt is not None:
|
||||
powod.append(f"tokeny promptu={pt}")
|
||||
if ct is not None:
|
||||
powod.append(f"tokeny odpowiedzi={ct}")
|
||||
szczegoly = f" ({', '.join(powod)})" if powod else ""
|
||||
def _join(parts: list[str]) -> str:
|
||||
return "".join(parts).strip()
|
||||
|
||||
rada = (
|
||||
"Najczęstsza przyczyna: prompt nie zmieścił się w oknie kontekstu modelu i na "
|
||||
"odpowiedź nie zostało miejsca. Zmniejsz budżet promptu (zwięzły), zwiększ okno "
|
||||
"kontekstu modelu (w Ollamie num_ctx) albo podnieś LLM_MAX_TOKENS."
|
||||
if finish_reason == "length" or (usage or {}).get("completion_tokens") == 0
|
||||
else "Model przyjął żądanie, ale nie wygenerował treści."
|
||||
|
||||
def _explain_empty(turns: int, usage: dict, stop: str | None) -> str:
|
||||
detail = []
|
||||
if stop:
|
||||
detail.append(f"powód zakończenia: {stop}")
|
||||
for key in ("completion_tokens", "output_tokens"):
|
||||
if usage.get(key) is not None:
|
||||
detail.append(f"tokeny odpowiedzi: {usage[key]}")
|
||||
break
|
||||
suffix = f" ({', '.join(detail)})" if detail else ""
|
||||
return (
|
||||
f"Model nie zwrócił żadnej treści po {turns} próbach{suffix}. "
|
||||
f"Najczęstsza przyczyna: prompt wypełnił okno kontekstu i nie zostało miejsca "
|
||||
f"na odpowiedź. Zmniejsz budżet promptu albo wybierz model z większym oknem."
|
||||
)
|
||||
raise LLMError(f"Model zwrócił pustą odpowiedź{szczegoly}. {rada}")
|
||||
|
||||
|
||||
class ChatCompletionsProvider(LLMProvider):
|
||||
class _Driver:
|
||||
"""Wspólna pętla: tura → ewentualna kontynuacja → sklejony tekst.
|
||||
|
||||
Podklasy dostarczają tylko `_turn()` — reszta (kontynuacje, ponawianie pustej
|
||||
tury, sumowanie zużycia) jest identyczna dla obu protokołów.
|
||||
"""
|
||||
|
||||
name: str
|
||||
model: str
|
||||
leaves_lan: bool
|
||||
|
||||
def _turn(self, messages: list[dict], max_tokens: int):
|
||||
"""(tekst, czy_ucięta, zużycie, nazwa_modelu, powód_zakończenia)."""
|
||||
raise NotImplementedError
|
||||
|
||||
def generate(self, prompt: str, max_tokens: int) -> Completion:
|
||||
messages: list[dict] = [{"role": "user", "content": prompt}]
|
||||
parts: list[str] = []
|
||||
usage: dict = {}
|
||||
model_name = self.model
|
||||
remaining = max(max_tokens, 256)
|
||||
stop: str | None = None
|
||||
turns = 0
|
||||
nudged = False
|
||||
|
||||
while turns <= MAX_CONTINUATIONS:
|
||||
turns += 1
|
||||
budget = max(256, min(remaining, TURN_TOKENS_CAP))
|
||||
text, truncated, turn_usage, model_name, stop = self._turn(messages, budget)
|
||||
_merge_usage(usage, turn_usage)
|
||||
remaining -= budget
|
||||
|
||||
chunk = text.strip()
|
||||
if chunk:
|
||||
if chunk.endswith(_DONE_MARKER): # model zgłasza koniec
|
||||
parts.append(("\n" if parts else "") + chunk[: -len(_DONE_MARKER)].rstrip())
|
||||
break
|
||||
parts.append(("\n" if parts else "") + chunk)
|
||||
if not truncated:
|
||||
break
|
||||
elif not truncated:
|
||||
# pusta i NIE ucięta: jedna próba z podpowiedzią, potem koniec
|
||||
if nudged or parts:
|
||||
break
|
||||
nudged = True
|
||||
messages = messages + [
|
||||
{"role": "assistant", "content": "…"},
|
||||
{"role": "user", "content": _NUDGE},
|
||||
]
|
||||
continue
|
||||
# ucięta (także tura złożona z samego myślenia) — poproś o dokończenie
|
||||
|
||||
if remaining < 256:
|
||||
break
|
||||
messages = [
|
||||
{"role": "user", "content": prompt},
|
||||
{"role": "assistant", "content": _join(parts) or "…"},
|
||||
{"role": "user", "content": _CONTINUE},
|
||||
]
|
||||
|
||||
final = _join(parts)
|
||||
if not final:
|
||||
raise LLMError(_explain_empty(turns, usage, stop))
|
||||
|
||||
usage["turns"] = turns
|
||||
return Completion(text=final, model=model_name, provider=self.name,
|
||||
leaves_lan=self.leaves_lan, usage=usage)
|
||||
|
||||
def count_tokens(self, prompt: str) -> int:
|
||||
"""Szacunek tokenów promptu. Dostawcy z własnym licznikiem nadpisują."""
|
||||
return int(len(prompt) / 3.6)
|
||||
|
||||
|
||||
class ChatCompletionsProvider(_Driver, LLMProvider):
|
||||
"""Protokół OpenAI `/chat/completions` — lokalny serwer modelu ORAZ OpenAI."""
|
||||
|
||||
def __init__(self, name: str, base_url: str, model: str, api_key: str = "",
|
||||
@@ -100,27 +200,20 @@ class ChatCompletionsProvider(LLMProvider):
|
||||
h["Authorization"] = f"Bearer {self.api_key}"
|
||||
return h
|
||||
|
||||
def generate(self, prompt: str, max_tokens: int) -> Completion:
|
||||
def _turn(self, messages: list[dict], max_tokens: int):
|
||||
data = _post_with_retry(
|
||||
f"{self.base_url}/chat/completions", self._headers(),
|
||||
{
|
||||
"model": self.model,
|
||||
"max_tokens": max_tokens,
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
},
|
||||
{"model": self.model, "max_tokens": max_tokens, "messages": messages},
|
||||
self.timeout,
|
||||
)
|
||||
try:
|
||||
choice = data["choices"][0]
|
||||
text = choice["message"]["content"]
|
||||
text = choice["message"].get("content") or ""
|
||||
except (KeyError, IndexError, TypeError) as e:
|
||||
raise LLMError(f"Nieoczekiwany kształt odpowiedzi modelu: {str(data)[:300]}") from e
|
||||
usage = data.get("usage") or {}
|
||||
text = _require_text(text, usage, choice.get("finish_reason"), max_tokens)
|
||||
return Completion(
|
||||
text=text, model=data.get("model", self.model), provider=self.name,
|
||||
leaves_lan=self.leaves_lan, usage=usage,
|
||||
)
|
||||
stop = choice.get("finish_reason")
|
||||
return (text, stop == "length", data.get("usage") or {},
|
||||
data.get("model", self.model), stop)
|
||||
|
||||
def health(self) -> dict:
|
||||
info = {"provider": self.name, "model": self.model, "leaves_lan": self.leaves_lan}
|
||||
@@ -133,7 +226,7 @@ class ChatCompletionsProvider(LLMProvider):
|
||||
return info
|
||||
|
||||
|
||||
class AnthropicProvider(LLMProvider):
|
||||
class AnthropicProvider(_Driver, LLMProvider):
|
||||
"""Protokół Anthropic `/v1/messages`."""
|
||||
|
||||
leaves_lan = True
|
||||
@@ -146,40 +239,68 @@ class AnthropicProvider(LLMProvider):
|
||||
self.api_key = api_key
|
||||
self.timeout = timeout
|
||||
|
||||
def generate(self, prompt: str, max_tokens: int) -> Completion:
|
||||
def _headers(self) -> dict:
|
||||
return {
|
||||
"Content-Type": "application/json",
|
||||
"x-api-key": self.api_key,
|
||||
"anthropic-version": "2023-06-01",
|
||||
}
|
||||
|
||||
def _thinking(self) -> dict:
|
||||
"""Konfiguracja myślenia. Domyślnie adaptacyjne — podnosi jakość tekstu.
|
||||
|
||||
UWAGA: tokeny myślenia liczą się do `max_tokens`, więc przy ciasnym limicie
|
||||
cała tura potrafi wyjść jako samo myślenie z pustym tekstem. Pętla
|
||||
kontynuacji to obsługuje, ale ANTHROPIC_THINKING=off wyłącza myślenie,
|
||||
gdy zależy nam na przewidywalnym zużyciu tokenów.
|
||||
"""
|
||||
mode = os.getenv("ANTHROPIC_THINKING", "adaptive").lower()
|
||||
if mode in ("off", "disabled", "0", "false"):
|
||||
return {"thinking": {"type": "disabled"}}
|
||||
return {
|
||||
"thinking": {"type": "adaptive"},
|
||||
"output_config": {"effort": os.getenv("ANTHROPIC_EFFORT", "high")},
|
||||
}
|
||||
|
||||
def _turn(self, messages: list[dict], max_tokens: int):
|
||||
if not self.api_key:
|
||||
raise LLMError("Brak LLM_API_KEY — dostawca anthropic wymaga klucza.")
|
||||
data = _post_with_retry(
|
||||
f"{self.base_url}/v1/messages",
|
||||
{
|
||||
"Content-Type": "application/json",
|
||||
"x-api-key": self.api_key,
|
||||
"anthropic-version": "2023-06-01",
|
||||
},
|
||||
{
|
||||
"model": self.model,
|
||||
"max_tokens": max_tokens,
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
},
|
||||
self.timeout,
|
||||
)
|
||||
raise LLMError("Brak ANTHROPIC_API_KEY — dostawca anthropic wymaga klucza.")
|
||||
payload = {"model": self.model, "max_tokens": max_tokens, "messages": messages}
|
||||
payload.update(self._thinking())
|
||||
data = _post_with_retry(f"{self.base_url}/v1/messages", self._headers(),
|
||||
payload, self.timeout)
|
||||
try:
|
||||
text = "".join(b.get("text", "") for b in data["content"] if b.get("type") == "text")
|
||||
blocks = data["content"]
|
||||
text = "".join(b.get("text", "") for b in blocks if b.get("type") == "text")
|
||||
except (KeyError, TypeError) as e:
|
||||
raise LLMError(f"Nieoczekiwany kształt odpowiedzi modelu: {str(data)[:300]}") from e
|
||||
usage = data.get("usage") or {}
|
||||
# Anthropic nazywa to inaczej — sprowadzamy do wspólnego kształtu dla diagnostyki
|
||||
norm = {"prompt_tokens": usage.get("input_tokens"),
|
||||
"completion_tokens": usage.get("output_tokens")}
|
||||
text = _require_text(text, {k: v for k, v in norm.items() if v is not None},
|
||||
data.get("stop_reason"), max_tokens)
|
||||
return Completion(
|
||||
text=text, model=data.get("model", self.model), provider=self.name,
|
||||
leaves_lan=True, usage=usage,
|
||||
|
||||
stop = data.get("stop_reason")
|
||||
# tura złożona z samego myślenia = budżet poszedł na rozumowanie; traktujemy
|
||||
# jak ucięcie, żeby pętla poprosiła o treść zamiast zwrócić pustkę
|
||||
thinking_only = not text.strip() and any(
|
||||
b.get("type") in ("thinking", "redacted_thinking") for b in blocks
|
||||
)
|
||||
return (text, stop == "max_tokens" or thinking_only, data.get("usage") or {},
|
||||
data.get("model", self.model), stop)
|
||||
|
||||
def count_tokens(self, prompt: str) -> int:
|
||||
"""Dokładny licznik Anthropic — nie szacunek. Od tego zależy, czy po
|
||||
zmieszczeniu promptu zostanie miejsce na odpowiedź."""
|
||||
if not self.api_key:
|
||||
return super().count_tokens(prompt)
|
||||
try:
|
||||
data = _post_with_retry(
|
||||
f"{self.base_url}/v1/messages/count_tokens", self._headers(),
|
||||
{"model": self.model, "messages": [{"role": "user", "content": prompt}]},
|
||||
min(self.timeout, 30.0),
|
||||
)
|
||||
return int(data.get("input_tokens") or super().count_tokens(prompt))
|
||||
except LLMError:
|
||||
return super().count_tokens(prompt)
|
||||
|
||||
def health(self) -> dict:
|
||||
return {
|
||||
"provider": self.name, "model": self.model, "leaves_lan": True,
|
||||
"status": "ok (klucz ustawiony)" if self.api_key else "brak LLM_API_KEY",
|
||||
"status": "ok (klucz ustawiony)" if self.api_key else "brak ANTHROPIC_API_KEY",
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user