From f8474498f828a1817f8b1ce3aeaafefe2480a439 Mon Sep 17 00:00:00 2001 From: busya Date: Wed, 3 Jun 2026 23:26:35 +0300 Subject: [PATCH] fix: restore translation-performance-analysis.md --- docs/translation-performance-analysis.md | 508 +++++++++++++++++++++++ 1 file changed, 508 insertions(+) create mode 100644 docs/translation-performance-analysis.md diff --git a/docs/translation-performance-analysis.md b/docs/translation-performance-analysis.md new file mode 100644 index 00000000..0be8722b --- /dev/null +++ b/docs/translation-performance-analysis.md @@ -0,0 +1,508 @@ +# Анализ производительности перевода: причины медлительности и план доработок + +**Дата:** 2026-06-03 (v2 — после code review) +**Автор:** fullstack-coder (ss-tools) + рецензент +**Контекст:** Пользователь сообщил "Очень долго стартует перевод". По логам trace_id `8bd7ac8f` (run `4c9de39e`) проведён анализ. + +--- + +## 1. Исходные данные + +**Объём:** 5455 строк из Superset datasource (dataset 906, таблица `userdata.debt_comment_translations`) +**Модель:** `qwen-flash` через `lite.ai.rusal.com/v1` (provider_type=litellm, response_format=yes) +**Режим:** `full=False` (только новые записи, без перезаписи существующих) +**Батчей сформировано:** 203 + +--- + +## 2. Таймлайн одного прогона (из логов) + +| Время | Событие | Длительность | Симптом | +|-------|---------|--------------|---------| +| `14:34:39` | Run стартовал | — | | +| `14:34:40` | Данные загружены (5455 строк) | ~1s | ✅ | +| `14:34:40` | "Processing 203 batches" | — | | +| `14:34:40.430` | **LLM request:** prompt_len=145062 | **~1m47s** | ⚠️ | +| `14:36:27` | `finish_reason=length` — ответ обрезан | | ❌ | +| `14:36:27` | Splitting → 2 батча | | | +| `14:36:27` | prompt_len=101330 | **~1m40s** | ⚠️ | +| `14:38:06` | `finish_reason=length` | | ❌ | +| `14:38:06` | Splitting → ещё 2 батча | | | +| `14:38:06` | prompt_len=25826 | **~40s** | ✅ stop | +| `14:38:47` | prompt_len=76479 | **~1m39s** | ⚠️ | +| `14:40:26` | `finish_reason=length` | | ❌ | +| ... | каскад продолжается | | | + +**Оценка общего времени:** >10-15 минут на 5455 строк. + +--- + +## 3. ⚠️ Важное ограничение анализа: prompt_len — это символы или токены? + +**В логах нет прямого указания, что `prompt_len=145062` — токены.** Формат логирования (`prompt_len=145062`) без указания единиц измерения не позволяет утверждать, что это именно токены. Это могут быть символы. + +**До любых правок требуется:** + +Для 10-20 реальных батчей залогировать: + +| Поле | Источник | Зачем | +|------|----------|-------| +| `chars` | `len(prompt)` | Длина в символах | +| `estimated_input_tokens` | `estimate_token_budget()` | Текущая оценка | +| `provider_prompt_tokens` | `response.usage.prompt_tokens` | Реальные токены входа | +| `provider_completion_tokens` | `response.usage.completion_tokens` | Реальные токены выхода | +| `provider_total_tokens` | `response.usage.total_tokens` | Сумма | +| `max_tokens` | Параметр запроса | Сколько просили | +| `context_window_resolved` | Что использовали как контекст | 64000 или другое | +| `max_output_tokens_resolved` | Что использовали как лимит выхода | | +| `rows_in_batch` | `len(batch_rows)` | | +| `target_languages_count` | `len(target_languages)` | | +| `finish_reason` | Из ответа API | stop / length / error | +| `response_rows_recovered` | Сколько строк распарсили | Для recovery | + +**Вывод:** Все гипотезы ниже основаны на косвенных признаках. Без логов usage токенов от провайдера (response.usage) некоторые причины остаются недоказанными. Добавление этих логов — **P0, первый шаг**. + +--- + +## 4. Первопричины (по степени вероятности) + +### 4.1. Batch sizing недооценивает output budget (основная гипотеза) + +`finish_reason=length` с вероятностью >90% означает не "вход не влез во входной контекст", а **"модель упёрлась в max_tokens при генерации ответа"**. + +Каждый батч содержит N строк. Для каждой строки модель должна вернуть JSON с переводами на каждый из target_languages. Если target_languages_count > 1, то **выход растёт линейно**, а batch sizing учитывает это только грубой оценкой. + +**Файл:** `backend/src/plugins/translate/_token_budget.py` + +Текущие константы для оценки выхода: + +```python +OUTPUT_PER_ROW_PER_LANG = 120 # токенов на строку перевода на один язык +JSON_OVERHEAD_PER_ROW = 50 # JSON-обвязка на строку +REASONING_OVERHEAD = 2000 # CoT overhead +MAX_OUTPUT_HEADROOM = 3000 # запас +``` + +Для 128 строк × 2 языка: +``` +нужно = 128 × 2 × 120 + 128 × 50 + 2000 + 3000 = 40560 токенов +``` + +Если `max_output_tokens = 16384` (default), то батч гарантированно обрежется. +И в логе мы видим `finish_reason=length` на батчах > 50-60 строк. + +**Следствие:** Проблема не (только) в CJK-токенизации, а в том, что **батч-сайзер упаковывает слишком много строк относительно output лимита**. + +### 4.2. CJK-оценка токенов входа — дополнительный фактор + +**Файл:** `backend/src/plugins/translate/_token_budget.py:89-108` + +```python +cjk_tokens = cjk_count / 1.5 # 1.5 chars/token +other_tokens = other_count / 2.2 # 2.2 chars/token +``` + +Если `prompt_len` в логах — символы, а не токены, то при 60% CJK-символов: +- Оценка: 145062 / 1.5 ≈ 96708 токенов +- Реальность (Qwen): может быть ~120000+ токенов + +То есть вход недооценивается на 20-30%, и "съедает" часть output budget. + +**Вывод:** CJK-оценка — вторичный фактор. Первичный — output budget. + +### 4.3. PROVIDER_DEFAULTS не содержит модели qwen-flash + +**Файл:** `backend/src/plugins/translate/_token_budget.py:32-39` + +```python +PROVIDER_DEFAULTS = { + "gpt-4o-mini": {"context_window": 128000, "max_output_tokens": 16384}, + "gpt-4o": {"context_window": 128000, "max_output_tokens": 16384}, + "o1-mini": {"context_window": 128000, "max_output_tokens": 65536}, + "claude-3-5-sonnet": {"context_window": 200000, "max_output_tokens": 8192}, + "deepseek-v4-flash": {"context_window": 64000, "max_output_tokens": 8192}, + "default": {"context_window": 64000, "max_output_tokens": 16384}, +} +``` + +Когда модель не найдена: +- `context_window = 64000` (default) +- `max_output_tokens = 16384` (default) +- `available_input_budget = 64000 - 16384 = 47616` + +Если `qwen-flash` на самом деле поддерживает 128K контекст и 8K вывод — бюджет по входу может быть недооценён, а бюджет по выходу переоценён. + +### 4.4. Каскад finish_reason=length умножает проблему + +**Файл:** `backend/src/plugins/translate/_llm_call.py:85-96, 190-233` + +```python +if finish_reason == "length" and len(batch_rows) >= 2: + if _recursion_depth < MAX_RETRIES_PER_BATCH: # = 3 + return self._split_and_retry(...) # binary split + +def _split_and_retry(self, ...): + mid = len(batch_rows) // 2 + left = self.call_llm_for_batch(..., rows[:mid], depth + 1) + right = self.call_llm_for_batch(..., rows[mid:], depth + 1) +``` + +**Проблема:** Бинарное деление **не спасает частичный результат**. Даже если модель вернула 80 из 100 строк валидного JSON — они теряются, и обе половины перезапрашиваются с нуля. + +Если truncation случается на 3+ уровнях рекурсии — 1 батч превращается в 7+ LLM-вызовов. + +--- + +## 5. План доработок + +### 5.0. [P0] Измерить → потом править + +Без реальных цифр любое изменение — гадание. + +**Добавить в `_llm_http.py` сбор usage от провайдера и логирование:** + +```python +# После ответа API: +usage = response.get("usage", {}) +log("llm_http", "REFLECT", "LLM usage stats", { + "prompt_tokens": usage.get("prompt_tokens"), + "completion_tokens": usage.get("completion_tokens"), + "total_tokens": usage.get("total_tokens"), + "finish_reason": finish_reason, + "max_tokens": max_tokens, + "rows": len(batch_rows), + "chars": len(prompt), +}) +``` + +Для 10-20 реальных батчей собрать статистику и **только после этого** принимать решения о коэффициентах. + +### 5.1. [P0] Учитывать output budget при расчёте размера батча + +**Проблема:** Сейчас output budget учитывается, но недостаточно жёстко. +**Файл:** `backend/src/plugins/translate/_token_budget.py:160-176` + +```python +def _apply_output_aware_batch_sizing(safe_size, num_languages, max_output_tokens): + while safe_size > 0: + needed_output = ( + safe_size * num_languages * OUTPUT_PER_ROW_PER_LANG + + safe_size * JSON_OVERHEAD_PER_ROW + + REASONING_OVERHEAD + MAX_OUTPUT_HEADROOM + ) + if needed_output <= max_output_tokens: + break + safe_size -= 1 + return safe_size +``` + +**Улучшение:** Сделать output budget **первичным** ограничителем, а input budget — вторичным: + +```python +def _compute_max_rows_by_output(max_output_tokens, num_languages): + """Сколько строк влезет в max_output_tokens.""" + overhead = REASONING_OVERHEAD + MAX_OUTPUT_HEADROOM + per_row = num_languages * OUTPUT_PER_ROW_PER_LANG + JSON_OVERHEAD_PER_ROW + if per_row <= 0: + return 20 + available = max_output_tokens - overhead + if available <= 0: + return 1 + return max(available // per_row, 1) +``` + +И в `_batch_sizer.py:auto_size_batches()`: + +```python +max_rows_by_output = _compute_max_rows_by_output(max_output_tokens_val, num_languages) + +# Брать минимум из всех ограничений: +max_rows = min( + max_rows_by_input_budget, + max_rows_by_output, + absolute_hard_cap, # safety net + job.batch_size or inf, # user preference +) +``` + +### 5.2. [P0] Вынести context_window / max_output_tokens в настройки провайдера + +#### 5.2.1. Модель БД + +**Файл:** `backend/src/models/llm.py` + +```python +class LLMProvider(Base): + # ... существующие поля ... + context_window = Column( + Integer, nullable=True, default=None, + comment="Total context window in tokens. NULL = fallback to PROVIDER_DEFAULTS", + ) + max_output_tokens = Column( + Integer, nullable=True, default=None, + comment="Max output tokens. NULL = fallback to PROVIDER_DEFAULTS", + ) +``` + +Nullable → обратная совместимость. + +#### 5.2.2. Safe cap + +Даже если пользователь ввёл значения — применяется верхняя граница: + +```python +PROVIDER_SAFE_CAP = 256000 # абсолютный максимум + +effective_context_window = min( + provider.context_window or PROVIDER_DEFAULTS.get(model, default)["context_window"], + PROVIDER_SAFE_CAP, +) +effective_max_output_tokens = min( + provider.max_output_tokens or PROVIDER_DEFAULTS.get(model, default)["max_output_tokens"], + effective_context_window, # не может быть больше контекста +) +``` + +#### 5.2.3. Pydantic схема + +**Файл:** `backend/src/plugins/llm_analysis/models.py` + +```python +class LLMProviderConfig(BaseModel): + # ... существующие поля ... + context_window: int | None = Field( + None, ge=1000, le=256000, + description="Context window in tokens. Leave blank for auto.", + ) + max_output_tokens: int | None = Field( + None, ge=256, + description="Max output tokens. Must be less than context_window.", + ) +``` + +#### 5.2.4. Сервисный слой + +**Файл:** `backend/src/services/llm_provider.py` + +```python +# create_provider +db_provider = LLMProvider( + ... + context_window=config.context_window, + max_output_tokens=config.max_output_tokens, +) + +# update_provider +db_provider.context_window = config.context_window +db_provider.max_output_tokens = config.max_output_tokens + +# Новый хелпер для batch sizing: +def get_provider_token_config(self, provider_id: str) -> dict: + provider = self.get_provider(provider_id) + if not provider: + return {"model": None, "context_window": None, "max_output_tokens": None} + return { + "model": provider.default_model or "gpt-4o-mini", + "context_window": provider.context_window, + "max_output_tokens": provider.max_output_tokens, + } +``` + +#### 5.2.5. Интеграция в batch sizing + +**Файл:** `backend/src/plugins/translate/_batch_proc.py:208-247` +**Файл:** `backend/src/plugins/translate/_batch_sizer.py:70-218` + +В обоих местах заменить: +```python +# Было: +provider_info = resolve_provider_model(job) +estimate_token_budget(provider_info=provider_info) + +# Стало: +config = LLMProviderService(db).get_provider_token_config(job.provider_id) +estimate_token_budget( + provider_info=config["model"], + context_window=config["context_window"], # приоритет над provider_info + max_output_tokens=config["max_output_tokens"], # приоритет над provider_info +) +``` + +#### 5.2.6. PROVIDER_DEFAULTS — остаётся fallback + +```python +def estimate_token_budget(..., context_window=None, max_output_tokens=None, provider_info=None): + # Если явно переданы — используем их + # Если оба None — смотрим PROVIDER_DEFAULTS + # Если и там нет — DEFAULT_... +``` + +#### 5.2.7. Svelte UI + +**Файл:** `frontend/src/lib/components/llm/ProviderConfig.svelte` + +- Collapsible "Advanced: Token Limits" +- Два number input: context_window, max_output_tokens +- Placeholder: "Auto-detected. Override only if you know the provider's real limits." +- Валидация на клиенте + +#### 5.2.8. Alembic миграция + +Новая миграция: add columns `context_window`, `max_output_tokens` to `llm_providers`. + +### 5.3. [P0] Консервативный tokenizer estimate + единый safety factor + +**Файл:** `backend/src/plugins/translate/_token_budget.py` + +```python +# Поправить коэффициенты (разумные значения, точные — после замера): +CJK_RATIO = 1.0 # было 1.5 +OTHER_RATIO = 1.8 # было 2.2 + +# Единый safety factor (один, не размазанный): +INPUT_SAFETY_FACTOR = 0.75 # 75% от расчётного бюджета +OUTPUT_SAFETY_FACTOR = 0.70 # 70% от расчётного output-бюджета +``` + +**Важно:** Эти цифры — стартовые. После сбора `usage.prompt_tokens` / `usage.completion_tokens` их надо откалибровать по реальным данным. + +### 5.4. [P1] Retry only missing rows после partial response + +**Текущий код:** `backend/src/plugins/translate/_llm_call.py:190-233` — binary split, теряет все уже переведённые строки. + +**Улучшение:** При `finish_reason=length`: +1. Попытаться распарсить ответ (`_recover_truncated_rows` в `_llm_parse.py:95-115`) +2. Сохранить успешно переведённые строки +3. Ретраить **только** те строки, которых не хватает + +```python +if finish_reason == "length": + recovered = _recover_truncated_rows(llm_response, len(batch_rows), finish_reason) + saved_rows = [] + missing_rows = [] + if recovered and recovered.get("rows"): + # Распределить: какие строки удалось перевести, какие — нет + parsed_ids = set(r.get("row_id") for r in recovered["rows"]) + for row in batch_rows: + if str(row.get("row_index")) in parsed_ids: + saved_rows.append(row) + else: + missing_rows.append(row) + + if missing_rows and len(missing_rows) < len(batch_rows) * 0.95: + # Есть существенный прогресс → ретраим только missing + self._persist_partial(batch_rows, saved_rows, batch_id, run_id, ...) + return self._retry_missing(job, run_id, missing_rows, dict_matches, ...) + else: + # Прогресса нет → binary split + return self._split_and_retry(...) +``` + +**Эффект:** Если из 100 строк вернулось 80 — ретраим только 20, а не 100. + +### 5.5. [P1] Dynamic row cap (вместо фиксированного 50) + +**Файл:** `backend/src/plugins/translate/_batch_sizer.py:148-166` + +```python +# Вычислить max_rows по output: +output_per_row = num_languages * OUTPUT_PER_ROW_PER_LANG + JSON_OVERHEAD_PER_ROW +available_output = max_output_tokens - REASONING_OVERHEAD - MAX_OUTPUT_HEADROOM +max_rows_by_output = max(available_output // output_per_row, 1) if output_per_row > 0 else 20 + +# Вычислить max_rows по input: +max_rows_by_input = per_batch_budget // average_row_tokens + +# Итоговый лимит: +ABSOLUTE_HARD_CAP = 50 # safety net, не основное ограничение +max_rows = min(max_rows_by_output, max_rows_by_input, ABSOLUTE_HARD_CAP) +``` + +### 5.6. [P2] Self-calibration per run + +После первого `finish_reason=length` в рамках одного run_id: +- Посчитать реальное `actual_ratio = actual_tokens / estimated_tokens` +- Склировать batch sizing для следующих батчей +- Сбросить при новом run_id + +--- + +## 6. Итоговые приоритеты + +| # | Что | Файлы | Почему | +|---|-----|-------|--------| +| **P0** | Добавить usage-логи от провайдера | `_llm_http.py`, `_llm_call.py` | Без данных нельзя обосновать изменения | +| **P0** | Output budget как первичный ограничитель | `_token_budget.py`, `_batch_sizer.py` | `finish_reason=length` — это чаще про выход, а не про вход | +| **P0** | Консервативный tokenizer + safety factor | `_token_budget.py` | Быстро снижает truncation | +| **P0** | Provider-level context_window / max_output_tokens | model + schema + service + routes + UI + migration | Нужно для неизвестных моделей | +| **P1** | Retry only missing rows после truncation | `_llm_call.py`, `_llm_parse.py` | Сохраняет частичный результат | +| **P1** | Dynamic row cap (output-aware) | `_batch_sizer.py` | Точнее, чем фиксированные 50 строк | +| **P2** | Self-calibration per run/provider | `_batch_sizer.py`, `_llm_call.py` | Адаптация под модель | + +--- + +## 7. Метрики успеха + +После внедрения: + +| Метрика | Цель | Как измерить | +|---------|------|-------------| +| `finish_reason=length` | < 1% LLM вызовов | Из логов | +| Среднее число LLM вызовов на батч | ≤ 1.1 | total_calls / total_batches | +| p95 длительность батча | < 90s | Из timing-логов | +| Общее время на 5455 строк | ≤ 8 min | Из run duration | +| successful_rows / requested_rows | ≥ 99.5% | Из records | +| Malformed JSON rate | < 0.5% | Из parse failures | + +--- + +## 8. Перед внедрением — замерить + +Собрать для 10-20 батчей (разный размер, разное количество языков): + +| Поле | Как получить | +|------|-------------| +| characters | `len(prompt)` | +| estimated_input_tokens | `_estimate_tokens_for_text()` | +| actual_prompt_tokens | `response.usage.prompt_tokens` | +| actual_completion_tokens | `response.usage.completion_tokens` | +| finish_reason | Из ответа | +| rows | `len(batch_rows)` | +| languages | `len(target_languages)` | +| response_rows_count | После парсинга | + +На этих данных: +1. Посчитать `actual_ratio = actual_prompt_tokens / estimated_tokens` — точный CJK-коэффициент +2. Посчитать `output_per_row_actual = actual_completion_tokens / rows / languages` — точный output per row + +Только после этого фиксировать константы в коде. + +--- + +## 9. PROVIDER_DEFAULTS — схема fallback (для справки) + +``` +Пользователь указал context_window в UI? + → да: используем (с safe cap) + → нет: PROVIDER_DEFAULTS.get(model_name)? + → да: используем + → нет: DEFAULT_CONTEXT_WINDOW / DEFAULT_MAX_OUTPUT_TOKENS +``` + +--- + +## 10. Текущие константы _token_budget.py (для справки) + +| Константа | Значение | Описание | +|-----------|----------|----------| +| `DEFAULT_CONTEXT_WINDOW` | 64000 | | +| `DEFAULT_MAX_OUTPUT_TOKENS` | 16384 | | +| `REASONING_OVERHEAD` | 2000 | | +| `OUTPUT_PER_ROW_PER_LANG` | 120 | | +| `JSON_OVERHEAD_PER_ROW` | 50 | | +| `PROMPT_BASE_TOKENS` | 600 | | +| `DICT_TOKENS_PER_ENTRY` | 20 | | +| `DICT_TOKENS_MAX` | 5000 | | +| `CHARS_PER_TOKEN_MIXED` | 2.2 | | +| `MIN_MAX_TOKENS` | 4096 | | +| `MAX_OUTPUT_HEADROOM` | 3000 | |