f33cdc88f4
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>
134 lines
5.0 KiB
Python
134 lines
5.0 KiB
Python
"""Okna kontekstu modeli i planowanie budżetu tokenów (LOG-30/31).
|
||
|
||
Po co to istnieje: horoskop MA powstać niezależnie od objętości promptu. Żeby to
|
||
zagwarantować, trzeba wiedzieć dwie rzeczy o każdym modelu — ile zmieści na
|
||
wejściu (okno kontekstu) i ile maksymalnie wypisze na wyjściu. Bez tego łatwo
|
||
wysłać prompt, który wypełnia całe okno i **nie zostawia miejsca na odpowiedź** —
|
||
model kończy wtedy na `max_tokens` z pustą albo uciętą treścią.
|
||
|
||
Zasada naczelna: **zawsze rezerwuj miejsce na odpowiedź.** Budżet promptu liczy
|
||
się jako `okno_kontekstu − zarezerwowane_wyjście − margines`, nigdy odwrotnie.
|
||
|
||
Wartości są zaszyte jako rozsądne domyślne i nadpisywalne środowiskiem
|
||
(`<DOSTAWCA>_CONTEXT_WINDOW`, `<DOSTAWCA>_MAX_OUTPUT`) — modele wychodzą szybciej,
|
||
niż aktualizuje się ten plik.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
|
||
# model -> (okno kontekstu, maksymalne wyjście) w tokenach
|
||
_MODEL_LIMITS: dict[str, tuple[int, int]] = {
|
||
# Anthropic
|
||
"claude-opus-4-8": (1_000_000, 128_000),
|
||
"claude-opus-4-7": (1_000_000, 128_000),
|
||
"claude-opus-4-6": (1_000_000, 128_000),
|
||
"claude-sonnet-5": (1_000_000, 128_000),
|
||
"claude-sonnet-4-6": (1_000_000, 128_000),
|
||
"claude-fable-5": (1_000_000, 128_000),
|
||
"claude-haiku-4-5": (200_000, 64_000),
|
||
# OpenAI
|
||
"gpt-4o": (128_000, 16_384),
|
||
"gpt-4o-mini": (128_000, 16_384),
|
||
"gpt-4.1": (1_000_000, 32_768),
|
||
"gpt-4.1-mini": (1_000_000, 32_768),
|
||
# lokalne (Ollama/vLLM) — zwykle małe okno, dlatego ostrożna domyślna
|
||
"llama3.1": (8_192, 4_096),
|
||
"llama3.2": (8_192, 4_096),
|
||
"qwen2.5": (32_768, 8_192),
|
||
"mistral": (32_768, 8_192),
|
||
}
|
||
|
||
# gdy modelu nie ma w tabeli — zachowawczo, żeby nie obiecywać nieistniejącego okna
|
||
_FALLBACK: dict[str, tuple[int, int]] = {
|
||
"anthropic": (200_000, 32_000),
|
||
"openai": (128_000, 16_384),
|
||
"local": (8_192, 4_096),
|
||
}
|
||
|
||
# ile tokenów zostawiamy jako bufor na narzut protokołu i niedokładność liczenia
|
||
SAFETY_MARGIN = 2_000
|
||
# poniżej tylu tokenów wyjścia nie ma sensu wołać modelu — nie zmieści horoskopu
|
||
MIN_OUTPUT = 1_500
|
||
# powyżej tylu tokenów promptu ostrzegamy użytkownika (nadal pozwalając wysłać)
|
||
WARN_PROMPT_TOKENS = 90_000
|
||
|
||
|
||
def _env_int(provider: str, suffix: str) -> int | None:
|
||
raw = os.getenv(f"{provider.upper()}_{suffix}")
|
||
if not raw:
|
||
return None
|
||
try:
|
||
value = int(raw)
|
||
except ValueError:
|
||
return None
|
||
return value if value > 0 else None
|
||
|
||
|
||
def limits_for(provider: str, model: str) -> tuple[int, int]:
|
||
"""(okno kontekstu, maksymalne wyjście) dla modelu — z nadpisaniem z ENV.
|
||
|
||
Dopasowanie po prefiksie, bo nazwy modeli lokalnych niosą tag (`llama3.1:8b`).
|
||
"""
|
||
env_ctx = _env_int(provider, "CONTEXT_WINDOW")
|
||
env_out = _env_int(provider, "MAX_OUTPUT")
|
||
|
||
key = (model or "").strip().lower()
|
||
known: tuple[int, int] | None = _MODEL_LIMITS.get(key)
|
||
if known is None:
|
||
for name, pair in _MODEL_LIMITS.items():
|
||
if key.startswith(name):
|
||
known = pair
|
||
break
|
||
if known is None:
|
||
known = _FALLBACK.get(provider, _FALLBACK["local"])
|
||
|
||
return (env_ctx or known[0], env_out or known[1])
|
||
|
||
|
||
def plan(provider: str, model: str, prompt_tokens: int,
|
||
want_output: int | None = None) -> dict:
|
||
"""Ile tokenów wyjścia zamówić dla promptu tej wielkości.
|
||
|
||
Zwraca plan z jawną diagnostyką — UI ma z czego zbudować ostrzeżenie, a błąd
|
||
ma czym wytłumaczyć, dlaczego się nie udało.
|
||
"""
|
||
context_window, model_max_output = limits_for(provider, model)
|
||
room = context_window - prompt_tokens - SAFETY_MARGIN
|
||
target = want_output or model_max_output
|
||
max_output = max(0, min(model_max_output, target, room))
|
||
|
||
warnings: list[str] = []
|
||
if prompt_tokens > WARN_PROMPT_TOKENS:
|
||
warnings.append(
|
||
f"Prompt ma ~{prompt_tokens} tokenów — to dużo. Zapytanie zostanie wysłane, "
|
||
f"ale potrwa dłużej i będzie odpowiednio kosztowne."
|
||
)
|
||
if max_output < MIN_OUTPUT:
|
||
warnings.append(
|
||
f"Po zmieszczeniu promptu zostaje tylko {max_output} tokenów na odpowiedź "
|
||
f"(minimum {MIN_OUTPUT}). Zmniejsz budżet promptu albo wybierz model "
|
||
f"z większym oknem kontekstu."
|
||
)
|
||
|
||
return {
|
||
"provider": provider,
|
||
"model": model,
|
||
"context_window": context_window,
|
||
"model_max_output": model_max_output,
|
||
"prompt_tokens": prompt_tokens,
|
||
"max_output": max_output,
|
||
"fits": max_output >= MIN_OUTPUT,
|
||
"warnings": warnings,
|
||
}
|
||
|
||
|
||
def prompt_token_budget(provider: str, model: str, reserve_output: int | None = None) -> int:
|
||
"""Ile tokenów promptu wolno wysłać, ZAWSZE zostawiając miejsce na odpowiedź.
|
||
|
||
To jest podstawa opcji „maksymalny kontekst modelu" w UI.
|
||
"""
|
||
context_window, model_max_output = limits_for(provider, model)
|
||
reserve = reserve_output or model_max_output
|
||
return max(0, context_window - reserve - SAFETY_MARGIN)
|