Представьте: у вас есть транскрипт выступления на 40-60 минут – полотно из нескольких тысяч слов с таймкодами. И для продвижения материала через Reels, Shorts или, упаси господь, ВК Клипы, нужно достать из него +-6 самодостаточных фрагментов: законченная мысль, не оборванная на полуслове, которую можно показать вне контекста. Изначальная мысль закинуть в LLM промпт и забыть развалилась. Расскажу, какие грабли я собрал и какая конструкция в итоге заработала стабильно.

Привет, Хабр! Меня зовут Андрей, и я потихоньку развиваю своего телеграм-бота для нарезки вертикальных видео по имени Шорти.

Постановка задачи

Формально на входе:

{
  "duration": 2412.3,
  "words": [{"word": "сегодня", "start": 0.12, "end": 0.43}, ...],
  "segments": [{"start": 0.0, "end": 5.2, "text": "..."}, ...]
}

На выходе нужно N диапазонов (start, end) в секундах, каждый из которых:

  • начинается с начала предложения и заканчивается на конце предложения (никаких «…и поэтому» в начале);

  • содержит одну законченную мысль — историю, тезис, вывод, шутку;

  • укладывается в 15-75 секунд (формат вертикального ролика).

Ключевая трудность: модель «видит» текст, но режет по символам/смыслу так, как удобно ей, а нам нужны границы, выровненные по реальной речи и таймингам.

Наивный подход и четыре способа, которыми он ломается

Первое, что приходит в голову:

«Вот транскрипт с таймкодами. Найди 6 самых интересных моментов и верни их время начала и конца.»

Это не работает. А именно происходит:

  1. Старт с середины фразы. Модель возвращает start, попадающий внутрь предложения: «…а вот это уже меняет всё». Зритель не понимает, о чём речь.

  2. Старт со связки. Грамматически это «начало предложения», но смыслово — мусор: «Но если посмотреть глубже…», «Поэтому я и говорю…». Формально корректно, на деле — оборванный контекст.

  3. Таймкоды «из головы». Если просить модель назвать секунды, она их галлюцинирует. Возвращает start: 734.0, а реального слова на 734-й секунде нет — там середина паузы или чужая фраза. Модель не считает время, она его придумывает.

  4. Нестабильный формат. На длинном входе модель то возвращает 6 фрагментов, то 1; то валидный JSON, то JSON с комментарием сверху, то с оборванной скобкой. Один и тот же промпт на одном и том же входе ведёт себя по-разному от запроса к запросу.

Каждую из четырёх проблем пришлось закрывать отдельно.

Идея, которая всё развернула: единица — предложение, а не слово и не секунда

Главная ошибка наивного подхода — давать модели свободу резать где угодно. Решение: сузить пространство выбора до предложений. Модель не называет секунды и не режет по словам — она выбирает диапазон номеров предложений.

Сначала склеиваем слова/сегменты Whisper обратно в предложения и нумеруем их:

def build_sentences(words: list[dict]) -> list[dict]:
    """Склеивает слова в предложения, сохраняя тайминги границ."""
    sentences, cur = [], []
    for w in words:
        cur.append(w)
        if w["word"].endswith((".", "!", "?", "…")):
            sentences.append({
                "id": len(sentences),
                "text": " ".join(x["word"] for x in cur),
                "start": cur[0]["start"],
                "end": cur[-1]["end"],
            })
            cur = []
    if cur:  # хвост без финальной пунктуации
        sentences.append({"id": len(sentences),
                          "text": " ".join(x["word"] for x in cur),
                          "start": cur[0]["start"], "end": cur[-1]["end"]})
    return sentences

Теперь модель видит пронумерованный список:

[0] Сегодня я хочу поговорить про найм.
[1] Когда мы выросли с пяти до пятидесяти человек, всё сломалось.
[2] Оказалось, что процесс, который работал на маленькой команде, не масштабируется.
...

И возвращает не секунды, а индексы:

{"highlights": [{"from": 1, "to": 4, "score": 0.9}, {"from": 12, "to": 15, "score": 0.8}]}

Что это сразу чинит:

  • галлюцинации таймкодов исчезают как класс — время мы берём не у модели, а из своих же предложений: start = sentences[from].startend = sentences[to].end;

  • границы всегда по предложениям — невозможно начать с середины фразы, потому что выбор — это целые предложения.

def ranges_to_items(sentences, ranges, min_len=15, max_len=125):
    items = []
    for r in ranges:
        s, e = sentences[r["from"]], sentences[r["to"]]
        dur = e["end"] - s["start"]
        if min_len <= dur <= max_len:
            items.append({"start": s["start"], "end": e["end"],
                          "score": r.get("score", 0)})
    return items

Промпт, который заработал

Перевод единицы в «предложения» убрал проблемы 1 и 3. Проблему 2 (старт со связок) и качество выбора закрыл промпт — жёсткий, с явными критериями и явными запретами:

Ты выбираешь самодостаточные фрагменты из расшифровки выступления.
Вход — пронумерованный список предложений.

Верни 6 фрагментов как диапазоны предложений [from, to]. Каждый фрагмент:
— ЗАКОНЧЕННАЯ мысль: история, тезис с объяснением, вывод, яркий пример или шутка;
— НАЧИНАЕТСЯ с предложения, которое можно понять без предыдущего контекста;
— НЕ начинается со связок: «но», «поэтому», «и», «а», «то есть», «таким образом»;
— длиной примерно 15–120 секунд связной речи.

Для каждого фрагмента дай score 0..1 — насколько он сильный вне контекста.
Ответ — строго JSON: {"highlights": [{"from": int, "to": int, "score": float}]}

Два неочевидных момента, которые сильно подняли качество:

  • явный список запрещённых стартовых слов — «не начинается со связок» абстрактно модель игнорирует, перечисление конкретных слов работает;

  • score как часть ответа — он не только сортирует, он заставляет модель оценивать фрагмент, а не просто резать. Это меняет сам выбор в лучшую сторону.

Подстраховка: доснэппинг границ после ответа

Даже с выбором по предложениям модель иногда возвращает from, указывающий на предложение, которое само начинается со слабой связки (Whisper мог склеить пунктуацию не идеально). Поэтому после ответа я доснэппиваю границы — сдвигаю старт к ближайшему «сильному» началу:

WEAK_STARTS = ("но", "а", "и", "поэтому", "то есть", "таким образом", "значит")

def snap_start(sentences, idx):
    """Если предложение стартует со связки — двигаем к следующему сильному началу."""
    while idx < len(sentences):
        first = sentences[idx]["text"].lstrip().split(" ", 1)[0].lower().strip(",")
        if first not in WEAK_STARTS:
            return idx
        idx += 1
    return idx

Модель предлагает, детерминированный код подчищает. Этот «ремень безопасности» поверх вероятностного выбора окупился больше всего: качество перестало плясать от запроса к запросу.

Самое раздражающее: надёжность

Проблема 4 (нестабильность) оказалась самой живучей. Что помогло:

Over-request + топ по score. Просишь N, а просишь N+2. Модель на длинном входе любит вернуть меньше, чем просили; запас + отбор топа по score гарантирует, что выдашь ровно N приличных, а не «что осталось».

Ретраи на кривой JSON и 5xx. Модель периодически возвращает JSON с префиксом-болтовнёй или обрывает скобку, плюс прилетают 503. Простой ретрай с парсингом «вытащи первый валидный JSON-объект» добивает в 2–3 попытки:

import json, re

def parse_highlights(raw: str) -> list[dict] | None:
    m = re.search(r"\{.*\}", raw, re.S)   # вырезаем JSON из возможной болтовни
    if not m:
        return None
    try:
        return json.loads(m.group(0))["highlights"]
    except (json.JSONDecodeError, KeyError):
        return None

def get_highlights(call_llm, prompt, attempts=3):
    for _ in range(attempts):
        raw = call_llm(prompt)               # бросает на 5xx — ловим выше
        parsed = parse_highlights(raw)
        if parsed:
            return parsed
    return None   # уходим в эвристический фолбэк

Любопытное наблюдение: первый запрос нередко возвращает 1 фрагмент, а ретрай с тем же промптом — нормальные 6. Дешевле сделать второй запрос, чем вылизывать промпт до идеала.

Фолбэк без LLM

Модель может быть недоступна (нет ключа, лимиты, ночной 503). Чтобы пайплайн не падал, есть эвристика без LLM: берём предложения, скорим по простым признакам (длина, наличие цифр/имён/вопросов, плотность речи без длинных пауз), снэппим границы тем же кодом и отдаём топ. Качество ниже, но продукт всегда что-то выдаёт — это важнее, чем «иногда идеально, иногда никак».

Что в итоге

Рабочая конструкция собралась из пяти слоёв, и ни один по отдельности задачу не решает:

  1. Предложение как единица выбора — убивает галлюцинации таймкодов и обрывы фраз.

  2. Промпт с явными критериями и запретами + score — поднимает качество выбора.

  3. Детерминированный доснэппинг границ — снимает дрожание от запроса к запросу.

  4. Over-request + ретраи + вырезание JSON — закрывает нестабильность.

  5. Эвристический фолбэк — гарантирует, что выход есть всегда.

Главный вывод, который я унёс: не давайте LLM резать где угодно — сужайте пространство выбора и подчищайте результат детерминированным кодом. Модель хороша как «оценщик смысла», но границы, тайминги и формат надёжнее держать на своей стороне.

Всё это крутится у меня внутри Telegram-бота, который нарезает записи выступлений на вертикальные ролики со слайдами — если интересно посмотреть на результат этой механики вживую, он тут (первая нарезка бесплатная, на ней и видно, как отрабатывает выбор фрагментов).

Буду рад, если поделитесь в комментариях, как сами решаете похожую задачу извлечения span'ов из длинных текстов — особенно как боретесь с нестабильностью формата.

Комментарии (4)


  1. dd7nis
    28.06.2026 13:11

    Попробуйте использовать библиотеку Instructor для возвращения детерминированного ответа (валидного JSON).


  1. rPman
    28.06.2026 13:11

    Фолбэк без LLM

    зачем вы в итак недетерменированную llm систему вносите этот хаос, вы (ваши клиенты) не будете понимать, что именно сейчас формирует результат и какое это будет качество


    1. ShortyAiBotTg Автор
      28.06.2026 13:11

      А можете чуть подробнее рассказать про вашу мысль? Хочу прислушаться к вам, но мне нужно чуть побольше контекста)


      1. rPman
        28.06.2026 13:11

        вы в алгоритм формирования результата ввели автоматическую систему переключения работы на что то по проще... которая будет включаться абсолютно непрогнозируемо для клиента... т.е. нажимая кнопку в сервисе будет не ясно, какого качества результат будет.

        Хотите такой функционал - давайте выбор, сделать через llm и сделать другими методами, тогда если llm бакэнд отсутствует, будет возвращаться ошибка и будет ясно - что это не работает.

        Ну или хотя бы сообщайте об этом, что 'не шмогла но вот вам вариант по хуже, что бы вы не грустили'.