Root cause: batch sizing underestimated CJK token density (1.5→1.0 chars/token) and ignored output budget as primary constraint, causing cascading finish_reason=length. Changes: - _token_budget.py: CJK_RATIO 1.5→1.0, OTHER_RATIO 2.2→1.8, safety factors 0.75/0.70 - _token_budget.py: new _compute_max_rows_by_output() — output budget is PRIMARY constraint - _batch_sizer.py: resolve_provider_config() with DB-level context_window/max_output_tokens - _batch_sizer.py: INPUT_SAFETY_FACTOR applied, max_rows_by_output used as row cap - _llm_http.py: log actual usage.prompt_tokens/.completion_tokens from provider - _llm_call.py: retry only missing rows after finish_reason=length (save partial result) - models/llm.py + schema: provider-level context_window / max_output_tokens (nullable) - services/llm_provider.py: get_provider_token_config() helper - Alembic migration: add columns to llm_providers - Svelte ProviderConfig: collapsible Advanced: Token Limits section - 12 new tests (token budget, batch sizer, provider config) - All 492 tests pass
22 KiB
Анализ производительности перевода: причины медлительности и план доработок
Дата: 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
Текущие константы для оценки выхода:
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:
- Попытаться распарсить ответ (
_recover_truncated_rowsв_llm_parse.py:95-115) - Сохранить успешно переведённые строки
- Ретраить только те строки, которых не хватает
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 | После парсинга |
На этих данных:
- Посчитать
actual_ratio = actual_prompt_tokens / estimated_tokens— точный CJK-коэффициент - Посчитать
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 |