Files
ss-tools/docs/translation-performance-analysis.md
busya ec6421de35 rename ss-tools to superset-tools across the entire project
- Replace all occurrences of 'ss-tools' with 'superset-tools' in 104 files
- Rename git bundle file ss-tools.bundle → superset-tools.bundle
- Update .gitignore pattern accordingly
- Preserve variable names (hasSsTools etc.) and code identifiers
2026-06-16 11:15:19 +03:00

509 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Анализ производительности перевода: причины медлительности и план доработок
**Дата:** 2026-06-03 (v2 — после code review)
**Автор:** fullstack-coder (superset-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 | |