diff --git a/History/2026-08-19-llm-metrics.md b/History/2026-08-19-llm-metrics.md new file mode 100644 index 0000000..2570bdc --- /dev/null +++ b/History/2026-08-19-llm-metrics.md @@ -0,0 +1,45 @@ +# v0.0.34 — вывод токенов и времени LLM — 2026-08-19 + +**Дата:** 2026-08-19 +**Версия:** 0.0.33 → 0.0.34 + +--- + +## Суть + +Пользователь замечает, что обфускация долгая. Добавлен вывод в UI: +- суммарных токенов LLM за прогон; +- суммарного времени работы LLM за прогон. + +Строка статуса после завершения: «✅ Обработано N файлов за Xс · LLM: Yс · Z токенов». + +## Изменения + +### `drhider/llm_client.py` +- В `LLMClient.__init__` добавлены кумулятивные счётчики: + - `tokens_prompt`, `tokens_completion`, `llm_sec` (float); + - свойство `tokens_total = prompt + completion`. +- В `complete()` после ответа: + - `self.llm_sec += r.elapsed.total_seconds()` — реальное время HTTP-вызова; + - `usage` (из `r.json()`) → `self.tokens_prompt` / `self.tokens_completion` + (через `.get(..., 0)` — устойчиво к отсутствию `usage`). +- Возврат `complete()` не изменён (по-прежнему `content`) — `scan_llm_ner` не трогали. + +### `site/routes/api_bp.py` +- В `worker()` после `obfuscate_files` в очередь `result` добавляется + `stats = {tokens: llm.tokens_total, llm_sec: round(llm.llm_sec, 1)}`. +- В `event: complete` данные: `{total, tokens, llm_sec}`. + +### `site/templates/index.html` +- В обработчике `complete`: если `d.llm_sec > 0` — добавляет + «· LLM: {llm_sec}с» и, если `d.tokens > 0` — «· {tokens} токенов». + +### `site/app.py` +- `VERSION = "0.0.34"`. + +## Проверка +- `py_compile` + `node --check` — OK. +- Накопление: 2 вызова → 240 prompt + 90 completion = 330 total, llm_sec = 6.0. +- Устойчивость: ответ без `usage` → токены 0, время 1.0 — без падения. +- SSE end-to-end: `complete` приходит с `{"total":1,"tokens":0,"llm_sec":0.0}` + (локально без ключа LLM — 0; в кластере с ключом — ненулевые). diff --git a/drhider/llm_client.py b/drhider/llm_client.py index 83b2962..d91aa6d 100644 --- a/drhider/llm_client.py +++ b/drhider/llm_client.py @@ -23,8 +23,21 @@ class LLMClient: """ def __init__(self): - """Инициализировать клиент. httpx импортируется лениво.""" + """Инициализировать клиент. httpx импортируется лениво. + + Ведёт кумулятивные счётчики за всё время жизни клиента: + tokens_prompt — суммарные входные токены + tokens_completion — суммарные выходные токены + llm_sec — суммарное время LLM-запросов (сек) + """ self._httpx = httpx + self.tokens_prompt = 0 + self.tokens_completion = 0 + self.llm_sec = 0.0 + + @property + def tokens_total(self) -> int: + return self.tokens_prompt + self.tokens_completion def complete(self, prompt: str) -> str: """Отправить промпт в LLM и вернуть текст ответа. @@ -61,4 +74,14 @@ class LLMClient: timeout=120, ) r.raise_for_status() - return r.json()["choices"][0]["message"]["content"] + + # Время запроса (реальное время HTTP-вызова LLM) + self.llm_sec += r.elapsed.total_seconds() + + data = r.json() + # Счётчики токенов из usage (могут отсутствовать у некоторых прокси) + usage = data.get("usage", {}) or {} + self.tokens_prompt += int(usage.get("prompt_tokens", 0) or 0) + self.tokens_completion += int(usage.get("completion_tokens", 0) or 0) + + return data["choices"][0]["message"]["content"] diff --git a/site/app.py b/site/app.py index 6b2d862..2ef5bb3 100644 --- a/site/app.py +++ b/site/app.py @@ -20,7 +20,7 @@ if _sys_path_root not in sys.path: sys.path.insert(0, _sys_path_root) # Версия приложения (меняется при изменениях) -VERSION = "0.0.33" +VERSION = "0.0.34" def create_app(): diff --git a/site/routes/api_bp.py b/site/routes/api_bp.py index bc993d4..5d0c17f 100644 --- a/site/routes/api_bp.py +++ b/site/routes/api_bp.py @@ -87,7 +87,11 @@ def process_stream(sid): zip_data, csv_str = obfuscate_files( all_files, llm_client=llm, progress_cb=progress ) - q.put(("result", zip_data, csv_str)) + stats = { + "tokens": llm.tokens_total, + "llm_sec": round(llm.llm_sec, 1), + } + q.put(("result", zip_data, csv_str, stats)) except Exception as e: q.put(("error", repr(e))) @@ -115,7 +119,7 @@ def process_stream(sid): return elif kind == "result": - _, zip_data, csv_str = evt + _, zip_data, csv_str, stats = evt store_result(sid, zip_data) if csv_str: store_csv(sid, csv_str) @@ -123,7 +127,10 @@ def process_stream(sid): with zipfile.ZipFile(io.BytesIO(zip_data)) as zf: count = len([n for n in zf.namelist() if n != "mapping.csv"]) try: - yield f"event: complete\ndata: {json.dumps({'total': count})}\n\n" + yield ( + f"event: complete\n" + f"data: {json.dumps({'total': count, **stats})}\n\n" + ) except _disconnect_exceptions(): return return diff --git a/site/templates/index.html b/site/templates/index.html index 27c30e2..6039ddc 100644 --- a/site/templates/index.html +++ b/site/templates/index.html @@ -404,8 +404,14 @@ async function uploadFiles() { activeES = null; clearInterval(ptimer); const d = JSON.parse(e.data); + const totalSec = ((performance.now() - t0) / 1000).toFixed(1); + let msg = '✅ Обработано ' + d.total + ' файлов за ' + totalSec + 'с'; + if (d.llm_sec > 0) { + msg += ' · LLM: ' + d.llm_sec + 'с'; + if (d.tokens > 0) msg += ' · ' + d.tokens + ' токенов'; + } st.className = 'status done'; - st.textContent = '✅ Обработано ' + d.total + ' файлов за ' + ((performance.now() - t0) / 1000).toFixed(1) + 'с'; + st.textContent = msg; db.classList.add('show'); resolve(); });