Spaces:
Paused
Paused
File size: 8,660 Bytes
0f9caed | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 | """
Token accounting a inteligentní správa kontextu.
Dvě odpovědnosti:
1. TokenStats — thread-safe počítadlo tokenů. Přesná čísla bere z `usage`
pole odpovědí (vLLM i HF router je vracejí), takže globální statistiky
(/health, UI) i per-turn statistiky (patička v chatu) jsou skutečné,
ne odhady. Cache hity se nepočítají (nic nestály).
2. compact_messages — deterministická kompakce kontextu, aby se dlouhé
agentní smyčky vešly do context window a šetřily KV cache:
- Fáze 1 (stárnutí): staré výsledky nástrojů (mimo posledních
`keep_last_steps` bloků) se zkrátí na `aged_chars` znaků — přesné
výpisy souborů/logů ztrácejí hodnotu, jakmile na ně agent zareagoval.
- Fáze 2 (vypouštění): pokud se stále nevejde, vypouští NEJSTARŠÍ celé
bloky (assistant + jeho tool odpovědi) hned za kotvou a nahradí je
jednou souhrnnou poznámkou, aby model věděl, že historie byla zkrácena.
- Vždy zůstává: systémové zprávy, první user zpráva (zadání úlohy)
a posledních `keep_last_steps` bloků v plném znění.
Kompakce nikdy nerozbije strukturu tool-callingu (tool zpráva bez svého
assistant/tool_calls předchůdce) — vypouští se výhradně celé bloky.
Odhad tokenů je heuristika ~4 znaky/token (±15 %) — pro rozhodnutí „kdy
kompaktovat" bohatě stačí; přesná čísla dodává `usage` po každém volání.
"""
from __future__ import annotations
import json
import time
from threading import Lock
CHARS_PER_TOKEN = 4
MESSAGE_OVERHEAD_TOKENS = 4 # role, oddělovače, formát šablony
# ---------------------------------------------------------------- odhad tokenů
def _message_text(msg: dict) -> str:
content = msg.get("content") or ""
if isinstance(content, list):
content = json.dumps(content, ensure_ascii=False)
text = str(content)
if msg.get("tool_calls"):
text += json.dumps(msg["tool_calls"], ensure_ascii=False)
return text
def estimate_tokens(messages: list[dict]) -> int:
"""Hrubý odhad počtu tokenů zpráv (~4 znaky/token + režie zprávy)."""
total = 0
for msg in messages:
total += MESSAGE_OVERHEAD_TOKENS + len(_message_text(msg)) // CHARS_PER_TOKEN
return total
# ---------------------------------------------------------------- statistiky
class TokenStats:
"""Thread-safe počítadlo skutečné spotřeby tokenů (z `usage` odpovědí).
Používá se dvojmo: globální instance (celkové statistiky od startu app)
a per-turn instance (patička jednoho chatu vč. sub-agentů).
"""
def __init__(self):
self._lock = Lock()
self.calls = 0
self.prompt_tokens = 0
self.completion_tokens = 0
self.by_source: dict[str, dict] = {}
self.last_context_tokens = 0 # prompt_tokens posledního volání
self.saved_tokens = 0 # ušetřeno kompakcí kontextu
self.started_at = time.time()
def add(self, source: str, usage) -> None:
"""Zaznamená jedno LLM volání. `usage` je objekt/dict s
prompt_tokens/completion_tokens, nebo None (pak se nepočítá nic)."""
if usage is None:
return
prompt = getattr(usage, "prompt_tokens", None)
completion = getattr(usage, "completion_tokens", None)
if prompt is None and isinstance(usage, dict):
prompt = usage.get("prompt_tokens")
completion = usage.get("completion_tokens")
prompt = int(prompt or 0)
completion = int(completion or 0)
with self._lock:
self.calls += 1
self.prompt_tokens += prompt
self.completion_tokens += completion
self.last_context_tokens = prompt
src = self.by_source.setdefault(
source, {"calls": 0, "prompt_tokens": 0, "completion_tokens": 0})
src["calls"] += 1
src["prompt_tokens"] += prompt
src["completion_tokens"] += completion
def add_saved(self, tokens: int) -> None:
with self._lock:
self.saved_tokens += max(0, tokens)
def snapshot(self) -> dict:
with self._lock:
return {
"calls": self.calls,
"prompt_tokens": self.prompt_tokens,
"completion_tokens": self.completion_tokens,
"total_tokens": self.prompt_tokens + self.completion_tokens,
"last_context_tokens": self.last_context_tokens,
"saved_by_compaction_tokens": self.saved_tokens,
"by_source": {k: dict(v) for k, v in self.by_source.items()},
"since": int(self.started_at),
}
# ---------------------------------------------------------------- kompakce
TRUNCATION_MARK = "\n…[zkráceno kompakcí kontextu]"
def _split_blocks(messages: list[dict]) -> tuple[list[dict], list[list[dict]]]:
"""Rozdělí zprávy na (kotva, bloky).
Kotva = úvodní system zprávy + první user zpráva. Blok = souvislá skupina,
kterou lze bezpečně vypustit celou: assistant s tool_calls + jeho tool
odpovědi, nebo samostatná user/assistant zpráva.
"""
anchor: list[dict] = []
i = 0
while i < len(messages) and messages[i].get("role") == "system":
anchor.append(messages[i])
i += 1
if i < len(messages) and messages[i].get("role") == "user":
anchor.append(messages[i])
i += 1
blocks: list[list[dict]] = []
current: list[dict] = []
for msg in messages[i:]:
if msg.get("role") == "tool":
# tool patří k předchozímu assistant tool_calls bloku
current.append(msg)
continue
if current:
blocks.append(current)
current = [msg]
if current:
blocks.append(current)
return anchor, blocks
def _age_tool_results(blocks: list[list[dict]], keep_last: int,
aged_chars: int) -> int:
"""Zkrátí tool výsledky ve starých blocích. Vrací počet ušetřených znaků."""
saved_chars = 0
for block in blocks[:-keep_last] if keep_last > 0 else blocks:
for msg in block:
if msg.get("role") != "tool":
continue
content = msg.get("content") or ""
if isinstance(content, str) and len(content) > aged_chars:
saved_chars += len(content) - aged_chars
msg["content"] = content[:aged_chars] + TRUNCATION_MARK
return saved_chars
def _drop_summary(dropped: list[list[dict]]) -> dict:
tools = []
for block in dropped:
for msg in block:
for tc in msg.get("tool_calls") or []:
name = (tc.get("function") or {}).get("name")
if name:
tools.append(name)
tool_note = f" (nástroje: {', '.join(tools[:15])})" if tools else ""
return {"role": "system",
"content": f"🧹 [Kompakce kontextu: vypuštěno {len(dropped)} "
f"starších kroků konverzace{tool_note}. Shrnutí stavu "
f"si drž ve vlastních odpovědích.]"}
def compact_messages(messages: list[dict], budget_tokens: int,
keep_last_steps: int = 6,
aged_chars: int = 2000) -> tuple[list[dict], int]:
"""Zhutní kontext pod budget_tokens. Vrací (nové zprávy, ušetřené tokeny).
Deterministická, bez LLM volání. Vstup nemodifikuje (pracuje s kopií).
Pokud se ani po vypuštění všeho kromě kotvy a posledních bloků nevejde,
vrací nejlepší možný výsledek (best effort).
"""
before = estimate_tokens(messages)
if before <= budget_tokens:
return messages, 0
work = [dict(m) for m in messages]
anchor, blocks = _split_blocks(work)
# Fáze 1: stárnutí starých tool výsledků
_age_tool_results(blocks, keep_last_steps, aged_chars)
flat = anchor + [m for b in blocks for m in b]
if estimate_tokens(flat) <= budget_tokens:
return flat, before - estimate_tokens(flat)
# Fáze 2: vypouštění nejstarších bloků (poslední keep_last_steps nikdy)
dropped: list[list[dict]] = []
keep = max(1, keep_last_steps)
while len(blocks) > keep:
dropped.append(blocks.pop(0))
flat = anchor + [_drop_summary(dropped)] + [m for b in blocks for m in b]
if estimate_tokens(flat) <= budget_tokens:
break
if dropped:
flat = anchor + [_drop_summary(dropped)] + [m for b in blocks for m in b]
return flat, max(0, before - estimate_tokens(flat))
|