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

22 KiB
Raw Permalink Blame History

Анализ производительности перевода: причины медлительности и план доработок

Дата: 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

Текущие константы для оценки выхода:

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

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

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

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 от провайдера и логирование:

# После ответа 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

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 — вторичным:

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():

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

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

Даже если пользователь ввёл значения — применяется верхняя граница:

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

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

# 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

В обоих местах заменить:

# Было:
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

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

# Поправить коэффициенты (разумные значения, точные — после замера):
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. Ретраить только те строки, которых не хватает
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

# Вычислить 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