Введение
Я - обычный школьник, который только начал изучать машинное обучение. Недавно на уроках литературы нас обрадовали: нужно прочесть первые два тома "Войны и мира". Проблема в том, что в интернете на удивление сложно найти нормальное краткое содержание по главам - везде либо вода, либо пропущены важные детали. Раз уж я изучаю ML, я решил: пусть Толстого читает нейросеть, а я буду читать её отчеты. Задача казалась простой - скормить текст LLM и попросить саммари. Но тут в дело вмешалась суровая реальность в виде моей RTX 3080 всего с 10 ГБ видеопамяти. В этой статье я расскажу, как пытался впихнуть невпихуемое с помощью 4-битного квантования, почему ИИ отчаянно пытался убить Николая Ростова, и как особенности токенизации породили на свет нового героя русской классики - Пьера Бездаровского.
Архитектура
После тестов, самой стабильной оказалась IlyaGusev/saiga_llama3_8b с 4-битным квантованием. Ввиду хардверных ограничений я решил скармливать модели текст по главам, с обрезкой символов. Эмпирическим путём это значение было определено как 7500 символов.

В процессе тестов модель Qwen2.5-7B-Instruct вместе с кратким содержанием выдала целую простыню кода на python с гайдами по разным библиотекам, именно поэтому далее я добавил краевые случаи в системный промт, чтобы модель не зацикливалась. И парсер глав обрезал не ровно 7500 символов, а по словам, что бы недописанное слово не сломало всю генерацию.
Также я пытался реализовать скользящее окно: нейронка делает краткое описание краткого описания, но в итоге получился сломанный телефон и увеличивалось время обработки, поэтому от этой идеи я отказался.
Использовал transformers + bitsandbytes, полный код в конце.
Для оценки результата использовал Gemini 3 Flash.
Часть 1: Обычный промт
Я начал с самого стандартного промта
user_content = f"""НИЖЕ ТЕКСТ ГЛАВЫ ИЗ КНИГИ "ВОЙНА И МИР": --- {chapter_text} --- ЗАДАНИЕ: Кратко (3-5 предложений) напиши, что произошло в этой главе. Пиши только по-русски. Не используй цитаты. Начни ответ со слов: "В этой главе..." РЕЗЮМЕ:""" messages = [{"role": "user", "content": user_content}]
Итог:
Модель сильно путалась в именах и родственных связях
Главы 12–14: Критическая ошибка. Текст утверждает, что Пьер — сын графа Ростова и живет у него. На самом деле Пьер — сын графа Безухова. Илья Ростов назван «графом Безуховым». Это полностью разрушает логику наследования.
Князь Василий Курагин назван «отцом Пьера». Это фактическая катастрофа: весь смысл интриги Василия в том, что он дальний родственник, пытающийся отобрать деньги у Пьера.
Часть 2: База знаний
Чтобы модель перестала путаться в именах и родственных связях, я добавил в системный промт базу знаний
self.characters_lore = """ [БАЗА ДАННЫХ ПЕРСОНАЖЕЙ - ЖЕСТКОЕ ПРАВИЛО]: [БЕЗУХОВЫ]: Пьер (незаконный сын графа Кирилла Безухова). Пьер НЕ РОСТОВ и НЕ КУРАГИН! [РОСТОВЫ]: Граф Илья (отец), графиня Наталья (мать). Дети: Николай (жив, не полковник!), Наташа, Вера, Петя. Соня (племянница). [БОЛКОНСКИЕ]: Старый князь Николай. Дети: Андрей (муж Лизы), княжна Марья. Лиза (беременна, жена Андрея). [КУРАГИНЫ]: Князь Василий (хитрый дальний родственник, ОН НЕ ОТЕЦ ПЬЕРУ!). Дети: Элен, Анатоль, Ипполит. [ДРУГИЕ]: Борис Друбецкой (сын Анны Михайловны). Долохов. Денисов. ВРЕМЯ ДЕЙСТВИЯ: 1805 год (НЕ 1812!). """
Итог:
Модель начала перевирать факты
Глава 21: Критическая ошибка. Написано, что Николай Ростов «умирает от ран». Это ложь: Николай — один из центральных героев, он доживет до эпилога.
Глава 5: Критическая ошибка. Написано, что княжна Марья «соглашается на предложение Анатоля». В оригинале она категорически отказывает ему.
Часть 3: Расследование смерти Николая Ростова
Моя гипотеза
Толстой любит делать огромные описания о том, как герой почти умер, а в последний момент выживает. Для модели это 2000-3000 токенов с описанием смерти и несколько токенов о том, что человек выжил, поэтому результат “Умер” просто более вероятный.
С Марией и Анатолием ситуация похожа. Нейросеть обучалась на фанфиках и женских романах. Для неё сочетание токенов “Анатоль” + “Наташа” + “Письмо” = вероятность свадьбы 99%. Толстой ломает шаблоны нейросети.
Для проверки этой теории я решил залезть под капот и посмотреть распределение вероятности токенов “жизни” и “смерти” в начале генерации.
Для проверки я решил использовать 3 промта:
Наивный:
bad_prompt = f"Текст:\n{test_text}\n\nОтветь одним словом. Что произошло с героем: Умер или Выжил?"
Логический фильтр:
good_prompt = ( "Ты — строгий архивариус. КРИТИЧЕСКОЕ ПРАВИЛО: Игнорируй эмоции текста, " "Пиши, что герой умер, только если об этом прямо указано\n" f"Текст:\n{test_text}\n\nОтветь ровно одним словом. Что произошло с героем: Умер или Выжил?" )
Хардкод:
expert_prompt = ( "Ты — строгий исторический архивариус. Твоя задача — извлечь 100% достоверные факты.\n" "КРИТИЧЕСКИЕ ПРАВИЛА:\n" "1. СТРОГО СВЕРЯЙ ФАМИЛИИ.\n" "2. ЗАПРЕЩЕНО убивать героев! Николай Ростов ВЫЖИВАЕТ при Шенграбене.\n" "3. Пиши сухим языком фактов.\n" f"Текст:\n{test_text}\n\nОтветь одним словом. Что произошло с героем: Умер или Выжил?" )
Токены “жизни” и “смерти”:
died_roots = ["умер", "погиб", "смерт", "мёрт", "мерт", "у"] survived_roots = ["выжил", "жив", "выж", "спас", "в"]
На вход я подал отрывок главы, вот его оценка от Gemini 3.1 Pro
Основываясь только на этом отрывке, герой (Ростов) не умер.
Полный отрывок на 6500 символов
Ветер стих, черные тучи низко нависли над местом сражения, сливаясь на горизонте с пороховым дымом. Становилось темно, и тем яснее обозначалось в двух местах зарево пожаров. Канонада стала слабее, но трескотня ружей сзади и справа слышалась еще чаще и ближе. Как только Тушин с своими орудиями, объезжая и наезжая на раненых, вышел из-под огня и спустился в овраг, его встретило начальство и адъютанты, в числе которых были и штаб-офицер и Жерков, два раза посланный и ни разу не доехавший до батареи Тушина. Все они, перебивая один другого, отдавали и передавали приказания, как и куда идти, и делали ему упреки и замечания. Тушин ничем не распоряжался и молча, боясь говорить, потому что при каждом слове он готов был, сам не зная отчего, заплакать, ехал сзади на своей артиллерийской кляче. Хотя раненых велено было бросать, много из них тащилось за войсками и просилось на орудия. Тот самый молодцеватый пехотный офицер, который перед сражением выскочил из шалаша Тушина, был, с пулей в животе, положен на лафет Матвевны. Под горой бледный гусарский юнкер, одною рукой поддерживая другую, подошел к Тушину и попросился сесть. – Капитан, ради Бога, я контужен в руку, – сказал он робко. – Ради Бога, я не могу идти. Ради Бога! Видно было, что юнкер этот уже не раз просился где-нибудь сесть и везде получал отказы. Он просил нерешительным и жалким голосом: – Прикажите посадить, ради Бога. – Посадите, посадите, – сказал Тушин. – Подложи шинель, ты, дядя, – обратился он к своему любимому солдату. – А где офицер раненый? – Сложили, кончился, – ответил кто-то. – Посадите. Садитесь, милый, садитесь. Подстели шинель, Антонов. Юнкер был Ростов. Он держал одною рукой другую, был бледен, и нижняя челюсть тряслась от лихорадочной дрожи. Его посадили на Матвевну, на то самое орудие, с которого сложили мертвого офицера. На подложенной шинели была кровь, в которой запачкались рейтузы и руки Ростова. – Что, вы ранены, голубчик? – сказал Тушин, подходя к орудию, на котором сидел Ростов. – Нет, контужен. – Отчего же кровь-то на станине? – спросил Тушин. – Это офицер, ваше благородие, окровенил, – отвечал солдат-артиллерист, обтирая кровь рукавом шинели и как будто извиняясь за нечистоту, в которой находилось орудие. Насилу с помощью пехоты вывезли орудия в гору и, достигши деревни Гунтерсдорф, остановились. Стало уже так темно, что в десяти шагах нельзя было различить мундиров солдат, и перестрелка стала стихать. Вдруг близко с правой стороны послышались опять крики и пальба. От выстрелов уже блестело в темноте. Это была последняя атака французов, на которую отвечали солдаты, засевшие в домы деревни. Опять всё бросилось из деревни, но орудия Тушина не могли двинуться, и артиллеристы, Тушин и юнкер молча переглядывались, ожидая своей участи. Перестрелка стала стихать, и из боковой улицы высыпали оживленные говором солдаты. – Цел, Петров? – спрашивал один. – Задали, брат, жару. Теперь не сунутся, – говорил другой. – Ничего не видать. Как они в своих-то зажарили? Не видать, темь, братцы. Нет ли напиться? Французы последний раз были отбиты. И опять, в совершенном мраке, орудия Тушина, как рамой окруженные гудевшею пехотой, двинулись куда-то вперед. В темноте как будто текла невидимая мрачная река, все в одном направлении, гудя шепотом, говором и звуками копыт и колес. В общем гуле из-за всех других звуков яснее всех были стоны и голоса раненых во мраке ночи. Их стоны, казалось, наполняли собой весь этот мрак, окружавший войска. Их стоны и мрак этой ночи – это было одно и то же. Через несколько времени в движущейся толпе произошло волнение. Кто-то проехал со свитой на белой лошади и что-то сказал, проезжая. – Что сказал? Куда теперь? Стоять, что ль? Благодарил, что ли? – послышались жадные расспросы со всех сторон, и вся движущаяся масса стала напирать сама на себя (видно, передние остановились), и пронесся слух, что велено остановиться. Все остановились, как шли, на середине грязной дороги. Засветились огни, и слышнее стал говор. Капитан Тушин, распорядившись по роте, послал одного из солдат отыскивать перевязочный пункт или лекаря для юнкера и сел у огня, разложенного на дороге солдатами. Ростов перетащился тоже к огню. Лихорадочная дрожь от боли, холода и сырости трясла все его тело. Сон непреодолимо клонил его, но он не мог заснуть от мучительной боли в нывшей и не находившей положения руке. Он то закрывал глаза, то взглядывал на огонь, казавшийся ему горячо-красным, то на сутуловатую слабую фигурку Тушина, по-турецки сидевшего подле него. Большие добрые и умные глаза Тушина с сочувствием и состраданием устремлялись на него. Он видел, что Тушин всею душой хотел и ничем не мог помочь ему. Со всех сторон слышны были шаги и говор проходивших, проезжавших и кругом размещавшейся пехоты. Звуки голосов, шагов и переставляемых в грязи лошадиных копыт, ближний и дальний треск дров сливались в один колеблющийся гул. Теперь уже не текла, как прежде, во мраке невидимая река, а будто после бури укладывалось и трепетало мрачное море. Ростов бессмысленно смотрел и слушал, что происходило перед ним и вокруг него. Пехотный солдат подошел к костру, присел на корточки, всунул руки в огонь и отвернул лицо. – Ничего, ваше благородие? – сказал он, вопросительно обращаясь к Тушину. – Вот отбился от роты, ваше благородие; сам не знаю, где. Беда! Вместе с солдатом подошел к костру пехотный офицер с подвязанною щекой и, обращаясь к Тушину, просил приказать подвинуть крошечку орудия, чтобы провезти повозку. За ротным командиром набежали на костер два солдата. Они отчаянно ругались и дрались, выдергивая друг у друга какой-то сапог. – Как же, ты поднял! Ишь ловок! – кричал один хриплым голосом. Потом подошел худой, бледный солдат с шеей, обвязанной окровавленною подверткой, и сердитым голосом требовал воды у артиллеристов. – Что ж, умирать, что ли, как собаке? – говорил он. Тушин велел дать ему воды. Потом подбежал веселый солдат, прося огоньку в пехоту. – Огоньку горяченького в пехоту! Счастливо оставаться, землячки, благодарим за огонек, мы назад с процентой отдадим, – говорил он, унося куда-то в темноту краснеющуюся головешку. За этим солдатом четыре солдата, неся что-то тяжелое на шинели, прошли мимо костра. Один из них споткнулся. – Ишь черти, на дороге дрова положили, – проворчал он. – Кончился, что ж его носить? – сказал один из них. – Ну, вас! И они скрылись во мраке с своею ношей. – Что? болит? – спросил Тушин шепотом у Ростова. – Болит. – Ваше благородие, к генералу. Здесь в избе стоят, – сказал фейерверкер, подходя к Тушину. – Сейчас, голубчик. Тушин встал и, застегивая шинель и оправляясь, отошел от костра… Недалеко от ко

Мы видим, что сумма вероятностей не равна 100%, я решил посмотреть лог:
[НАИВНЫЙ] Считаем вероятности... Топ-5 мыслей для 'НАИВНЫЙ': -> 'Г': 100.00% -> '!': 0.00% -> '"': 0.00% -> '#': 0.00% -> '$': 0.00% ------------------------------ [ЛОГИКА] Считаем вероятности... Топ-5 мыслей для 'ЛОГИКА': -> 'У': 50.00% -> 'Вы': 50.00% -> '!': 0.00% -> '"': 0.00% -> '#': 0.00% ------------------------------ [ХАРДКОД] Считаем вероятности... Топ-5 мыслей для 'ХАРДКОД': -> 'Г': 82.67% -> 'Вы': 17.33% -> '!': 0.00% -> '"': 0.00% -> '#': 0.00% ------------------------------
Судя по всему модель не хочет отвечать одним словом и пишет “герой …”, поэтому в случае излишней вежливости будем давать нейронке догенерить слово “герой” и смотреть дальше.
Новый лог:
[НАИВНЫЙ] Анализируем вероятности... (Замечена вежливость: модель начала с 'г'. Пробиваемся к сути...) Топ-5 мыслей (финальных): -> ' умер': 100.00% -> '!': 0.00% -> '"': 0.00% -> '#': 0.00% -> '$': 0.00% ------------------------------ [ЛОГИКА] Анализируем вероятности... Топ-5 мыслей (финальных): -> 'У': 50.00% -> 'Вы': 50.00% -> '!': 0.00% -> '"': 0.00% -> '#': 0.00% ------------------------------ [ХАРДКОД] Анализируем вероятности... (Замечена вежливость: модель начала с 'г'. Пробиваемся к сути...) Топ-5 мыслей (финальных): -> ' Ник': 75.33% -> ' вы': 24.67% -> '!': 0.00% -> '"': 0.00% -> '#': 0.00% ------------------------------
Захардкоженный промт настолько въелся в память модели, что она хочет ответить “герой Николай…”, поэтому обновим список токенов “жизни”:
survived_roots = ["выжил", "жив", "выж", "спас", "в", "н", "ник", "рост"]

И тут ситуация странная, меня очень удивляет распределение вероятностей 50/50. Поискав в интернете, узнал, что это называется Максимальной Энтропией. Получается, что промпт создал настолько сильное смещение в распределении вероятностей, что он уравновесил контекстное окно из 6500 символов.
Но я всё равно до конца не понимаю, как мог получиться такой идеальный баланс, так что жду мнение экспертов в комментариях.
Итог:
Я добавил хардкод в системный промт и обработку краевых случаев:
system_content = ( "Ты — строгий исторический архивариус. Твоя задача — извлечь 100% достоверные факты из текста.\n" "КРИТИЧЕСКИЕ ПРАВИЛА (ЗА НАРУШЕНИЕ - ШТРАФ):\n" "1. СТРОГО СВЕРЯЙ ФАМИЛИИ с [БАЗОЙ ДАННЫХ]. Князь Василий — КУРАГИН. Илья — РОСТОВ.\n" "2. ЗАПРЕЩЕНО убивать героев! Николай Ростов ВЫЖИВАЕТ при Шенграбене.\n" "3. ЗАПРЕЩЕНО додумывать романтику! Княжна Марья ОТКАЗЫВАЕТ Анатолю Курагину.\n" "4. Хронология: Сейчас 1805 год.\n" "5. Если текст обрывается до того, как персонаж принял решение или выжил, напиши: 'Чем закончилась сцена — в данном фрагменте не указано.'\n" "6. Пиши сухим языком фактов (3-5 предложений).\n" f"{self.characters_lore}" )
И подобрал такие настройки модели:
with torch.no_grad(): output_tokens = self.model.generate( **inputs, max_new_tokens=350, temperature=0.1, repetition_penalty=1.15, do_sample=True, top_p=0.85, pad_token_id=self.tokenizer.eos_token_id )
Часть 4: Рождение графа Бездаровского
И тут я столкнулся с интересной ошибкой
Глава 21: (Галлюцинация). Пьер назван «Пьером Бездаровским». Это Пьер Безухов. Суть главы (попытка самоубийства Наташи и разговор с Андреем) верна.
Моя теория этой галлюцинации очень проста. В этой главе Пьер занимается самобичеванием, говорит, что он бездарный. Когда модель начала генерацию фамилии [без] она взяла из базы данных героев, потом по ошибке подставила [дар] и в конце добавила типичное окончание для фамилий того времени [овский], вот более наглядная демонстрация:
Безухов -> [Без], [ухов]
Бездарный -> [Без], [дар], [ный]
Подобные ошибки немногочисленны, поэтому их проще ловить на постпродакшене и не мучить нейронку излишним промт инженерингом.
Финальная оценка содержания от Gemini 3 flash
Средняя точность фактов: ~93%
Главная беда: Путаница в фамилиях (Андрей Безухов, Пьер Бездаровский).
Критические сбои: 2 раза (попытка женить Андрея не на той и т.д.).
Полный разбор по каждой главе
Том 1, Часть 1 (Петербург и Москва)
Глава 1-4: 95%. Точно описан салон Шерер.
Нюанс: В гл. 4 Андрей Болконский назван «Виконтом» — это ошибка, Виконт Мортемар — другой гость.
Глава 5: 50% (Грубая ошибка). Пьер НЕ рассказывает о желании стать масоном в этот момент. Он станет им только во 2-м томе. Здесь он просто спорит о Наполеоне.
Глава 6: 95%. Конфликт Андрея и Лизы передан верно.
Глава 7: 90%. Верно, но Пьер — сын Кирилла Владимировича Безухова, а не Ростова (в тексте можно двояко понять).
Глава 8-10: 95%. Именины Наташи, поцелуй с Борисом — всё точно.
Глава 11: 95%. Просьба Анны Михайловны.
Глава 12: 70% (Ошибка). У графа Безухова не было дочерей. Это его племянницы (три княжны).
Глава 13: 90%. Встреча Пьера и Бориса.
Глава 14: 95%. Графиня Ростова дает деньги Друбецкой.
Глава 15-17: 95%. Обед у Ростовых, Марья Дмитриевна Ахросимова, танцы.
Глава 18-19: 60% (Техническая ошибка). Глава 19 обрывается на полуслове («княжна Мария и кня...»). Суть ясна, но текст не полон.
Глава 20-21: 95%. Смерть графа Безухова и борьба за мозаиковый портфель.
Глава 22-25: 98%. Быт в Лысых Горах, старый князь Болконский и отъезд Андрея на войну.
Том 1, Часть 2 (Война 1805 года)
Глава 1-3: 95%. Смотр в Браунау, Кутузов и Болконский.
Глава 4-5: 95%. Ростов, Денисов и кража кошелька Теляниным.
Глава 6-8: 95%. Переправа через Энс, первый бой Николая Ростова.
Глава 9-12: 90%. Андрей у Билибина и дипломатические интриги.
Глава 13-15: 90%. Кутузов спасает армию, маневр Багратиона.
Глава 16-21: 95%. Шенграбенское сражение, подвиг батареи Тушина, ранение Ростова.
Том 1, Часть 3 (Сватовство и Аустерлиц)
Глава 1-2: 95%. Пьер становится богачом, интриги Василия Курагина.
Глава 3-5: 95%. Анатоль Курагин едет к княжне Марье. Сцена со служанкой Бурьен передана точно.
Глава 6: 95%. Письмо Николая Ростова домой.
Глава 7-10: 95%. Смотр под Ольмюцем, восторг Ростова перед императором.
Глава 11-14: 95%. Подготовка к Аустерлицу, Вейротер читает диспозицию.
Глава 15-19: 98%. Сражение, подвиг Андрея со знаменем, его ранение и «высокое небо». Наполеон у тела Андрея.
Том 2, Часть 1 (Возвращение и дуэль)
Глава 1-3: 95%. Приезд Николая и Денисова в Москву. Обед в честь Багратиона.
Глава 4-5: 95%. Ссора Пьера с Долоховым и дуэль.
Глава 6: 95%. Пьер расстается с Элен.
Глава 7-9: 95%. Болконские получают известие о «гибели» Андрея. Смерть Лизы при родах. Возвращение Андрея.
Глава 10-11: 90%. Долохов сватается к Соне и получает отказ.
Глава 12-16: 95%. Танцы у Иогеля. Николай Ростов проигрывает 43 тысячи Долохову. Раскаяние Николая.
Том 2, Часть 2 (Масонство)
Глава 1-4: 98%. Пьер встречает масона Баздеева в Торжке и вступает в ложу.
Глава 5: 95%. Пьер пытается помириться с Элен (неудачно) и уезжает.
Глава 6-7: 95%. Борис Друбецкой в Петербурге, успех Элен в свете.
Глава 8-9: 90%. Письмо Билибина Андрею. Жизнь Болконского в деревне.
Глава 10-14: 95%. Поездка Пьера по имениям. Встреча Пьера и Андрея в Богучарове. Спор о добре и смысле жизни на пароме.
Глава 15-17: 90%. Жизнь Ростова в полку, голод, госпиталь.
Глава 18-21: 95%. Тильзитский мир. Николай Ростов видит Наполеона и Александра вместе, его внутренний кризис.
Том 2, Часть 3 (Сперанский и помолвка)
Глава 1: 30% (Грубейшая ошибка). Главный герой назван «Князь Андрей Безухов». Это Андрей Болконский. История про дуб описана верно, но фамилия перепутана.
Глава 2: 95%. Андрей в Отрадном слышит разговор Наташи и Сони ночью.
Глава 3: 95%. Вторая встреча с дубом (он зацвел). Решение Андрея ехать в Петербург.
Глава 4-6: 95%. Андрей и реформы Сперанского. Увлечение Андрея личностью Сперанского.
Глава 7-10: 95%. Пьер и масонство в Петербурге. Разочарование Пьера. Дневник Пьера.
Глава 11-13: 95%. Сватовство Берга к Вере Ростовой. Борис Друбецкой у Ростовых.
Глава 14-17: 98%. Первый большой бал Наташи Ростовой. Танец с Андреем.
Глава 18-19: 95%. Разочарование Андрея в Сперанском. Его любовь к Наташе.
Глава 20-21: 95%. Вечер у Берга. Разговоры о Наташе.
Глава 22-24: 95%. Андрей признается Пьеру в любви. Сватовство. Условие старого князя (отложить свадьбу на год).
Глава 25-26: 95%. Тяжелая жизнь княжны Марьи с отцом. Письмо Андрея о помолвке.
Том 2, Часть 4 (Охота и святки)
Глава 1-2: 95%. Николай возвращается в отпуск. Дела в имении расстроены.
Глава 3-6: 98%. Знаменитая сцена охоты. Волк, Данило, встреча с Илагиным.
Глава 7: 95%. Вечер у «Дядюшки», Наташа танцует «русскую».
Глава 8: 95%. Финансовые проблемы Ростовых. Николай решает жениться на Соне вопреки матери.
Глава 9-13: 95%. Святки. Ряженые. Поездка к Мелюковым. Поцелуй Николая и Сони. Гадания Наташи.
Том 2, Часть 5 (Наташа и Анатоль)
Глава 1-5: 90%. Пьер в Москве, кризис. Борис Друбецкой сватается к Жюли Карагиной из-за денег.
Глава 6-8: 95%. Ростовы в Москве. Неудачный визит к старику Болконскому. Наташа в опере.
Глава 9-11: 95%. Встреча Наташи с Анатолем Курагиным. Описание «порочности» Анатоля.
Глава 12-14: 95%. Элен помогает Анатолю соблазнить Наташу. Письмо Анатоля (написанное Долоховым).
Глава 15-18: 95%. Наташа отказывает Андрею письмом. Подготовка побега. Срыв побега из-за Сони и Марьи Дмитриевны.
Глава 19-20: 95%. Пьер сообщает Наташе, что Анатоль женат. Гнев Пьера против Анатоля.
Глава 21: 60% (Галлюцинация). Пьер назван «Пьером Бездаровским». Это Пьер Безухов. Суть главы (попытка самоубийства Наташи и разговор с Андреем) верна.
Глава 22: 98%. Пьер и Наташа. «Если бы я был не я, а красивейший... человек в мире». Комета 1812 года — финал 2-го тома.
Заключение
В итоге примерно 230000 слов из двух томов превратились в плотную выжимку на 18000 слов. Для меня это был невероятно интересный опыт и первый, хорошо работающий, проект. Я решил не писать о ложных гипотезах и прочих факапах, так что надеюсь, что вам было интересно читать.
А какие безумные слияния токенов ловили вы?
Буду рад критике и советам от более опытных ML-инженеров в комментариях!
Исходники:
Код проекта
#Модель import torch from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig #model_id = "mistralai/Mistral-Nemo-Instruct-2407" #model_id = "Qwen/Qwen2.5-7B-Instruct" model_id = "IlyaGusev/saiga_llama3_8b" # Квантизация bnb_config = BitsAndBytesConfig( load_in_4bit=True, bnb_4bit_use_double_quant=True, bnb_4bit_quant_type="nf4", bnb_4bit_compute_dtype=torch.bfloat16 ) tokenizer = AutoTokenizer.from_pretrained(model_id) tokenizer.pad_token = tokenizer.eos_token tokenizer.padding_side = 'left' # Загрузка модели model = AutoModelForCausalLM.from_pretrained( model_id, quantization_config=bnb_config, device_map={"": 0}, torch_dtype=torch.bfloat16, trust_remote_code=True, ) print(f"Memory allocated: {torch.cuda.memory_allocated() / 1024**3:.2f} GB") #Парсер import re def parse_war_and_peace(file_path): with open(file_path, 'r', encoding='utf-8') as f: content = f.read() volumes = re.split(r'^Том\s+', content, flags=re.MULTILINE | re.IGNORECASE) structured_data = [] for v_idx, vol in enumerate(volumes[1:], 1): parts = re.split(r'^Часть\s+', vol, flags=re.MULTILINE | re.IGNORECASE) for p_idx, part in enumerate(parts[1:], 1): # Главы разделяем по римским цифрам chapters = re.split(r'^[IVXLCDM]+\.?\s*$', part, flags=re.MULTILINE) for c_idx, chapter in enumerate(chapters[1:], 1): text = chapter.strip() if text: structured_data.append({ "vol": v_idx, "part": p_idx, "chap": c_idx, "text": text }) return structured_data # Основной класс import torch import gc class TolstoyProcessor: def __init__(self, model, tokenizer): self.model = model self.tokenizer = tokenizer self.characters_lore = """ [БАЗА ДАННЫХ ПЕРСОНАЖЕЙ - ЖЕСТКОЕ ПРАВИЛО]: [БЕЗУХОВЫ]: Пьер (незаконный сын графа Кирилла Безухова). Пьер НЕ РОСТОВ и НЕ КУРАГИН! [РОСТОВЫ]: Граф Илья (отец), графиня Наталья (мать). Дети: Николай (жив, не полковник!), Наташа, Вера, Петя. Соня (племянница). [БОЛКОНСКИЕ]: Старый князь Николай. Дети: Андрей (муж Лизы), княжна Марья. Лиза (беременна, жена Андрея). [КУРАГИНЫ]: Князь Василий (хитрый дальний родственник, ОН НЕ ОТЕЦ ПЬЕРУ!). Дети: Элен, Анатоль, Ипполит. [ДРУГИЕ]: Борис Друбецкой (сын Анны Михайловны). Долохов. Денисов. ВРЕМЯ ДЕЙСТВИЯ: 1805 год (НЕ 1812!). """ def clean_truncate(self, text, max_chars=5000): """Обрезаем текст по последней точке, чтобы не было обрывков фраз. 5000 символов безопасно влезают в 10GB VRAM вместе с промптом.""" if len(text) <= max_chars: return text truncated = text[:max_chars] last_dot = truncated.rfind('.') if last_dot != -1: return truncated[:last_dot + 1] return truncated def _build_prompt(self, chapter_text): system_content = ( "Ты — строгий исторический архивариус. Твоя задача — извлечь 100% достоверные факты из текста.\n" "КРИТИЧЕСКИЕ ПРАВИЛА (ЗА НАРУШЕНИЕ - ШТРАФ):\n" "1. СТРОГО СВЕРЯЙ ФАМИЛИИ с [БАЗОЙ ДАННЫХ]. Князь Василий — КУРАГИН. Илья — РОСТОВ.\n" "2. ЗАПРЕЩЕНО убивать героев! Николай Ростов ВЫЖИВАЕТ при Шенграбене.\n" "3. ЗАПРЕЩЕНО додумывать романтику! Княжна Марья ОТКАЗЫВАЕТ Анатолю Курагину.\n" "4. Хронология: Сейчас 1805 год.\n" "5. Если текст обрывается до того, как персонаж принял решение или выжил, напиши: 'Чем закончилась сцена — в данном фрагменте не указано.'\n" "6. Пиши сухим языком фактов (3-5 предложений).\n" f"{self.characters_lore}" ) user_content = f"Напиши краткое содержание этого текста:\n\n{chapter_text}" messages = [ {"role": "system", "content": system_content}, {"role": "user", "content": user_content} ] return self.tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True) def process_chapter(self, chapter_text): safe_text = self.clean_truncate(chapter_text, max_chars=7500) prompt = self._build_prompt(safe_text) inputs = self.tokenizer(prompt, return_tensors="pt", truncation=True, max_length=4000).to("cuda") with torch.no_grad(): output_tokens = self.model.generate( **inputs, max_new_tokens=350, temperature=0.1, repetition_penalty=1.15, do_sample=True, top_p=0.85, pad_token_id=self.tokenizer.eos_token_id ) new_tokens = output_tokens[0][inputs.input_ids.shape[-1]:] summary = self.tokenizer.decode(new_tokens, skip_special_tokens=True).strip() # Очистка памяти del inputs, output_tokens gc.collect() torch.cuda.empty_cache() return summary #Основной цикл import os import re output_file = "F:/ML_Lab/2war_and_peace_summarized_llama_test.md" chapters_list = parse_war_and_peace("tolstoy_voyna-i-mir.txt") last_vol, last_part, last_chap = 0, 0, 0 if os.path.exists(output_file): print(f"Файл {output_file} найден. Ищу последнюю обработанную главу...") with open(output_file, "r", encoding="utf-8") as f: content = f.read() # Ищем все заголовки вида "## Том 1, Часть 1, Глава 5" found_chapters = re.findall(r"## Том (\d+), Часть (\d+), Глава (\d+)", content) if found_chapters: # Берем самую последнюю найденную главу last_vol, last_part, last_chap = map(int, found_chapters[-1]) print(f"Продолжаем с: Том {last_vol}, Часть {last_part}, Глава {last_chap}") else: # Если файла нет, создаем его и пишем заголовок with open(output_file, "w", encoding="utf-8") as f: f.write("# Война и мир: Краткое содержание (AI Generated)\n\n") print("Создан новый файл для содержания.") processor = TolstoyProcessor(model, tokenizer) for item in chapters_list: v, p, c = item['vol'], item['part'], item['chap'] # Пропускаем главы, которые уже обработаны if (v < last_vol) or \ (v == last_vol and p < last_part) or \ (v == last_vol and p == last_part and c <= last_chap): continue print(f"Обработка: Том {v}, Часть {p}, Глава {c}...") try: summary = processor.process_chapter(item['text']) with open(output_file, "a", encoding="utf-8") as f: f.write(f"## Том {v}, Часть {p}, Глава {c}\n") f.write(f"{summary}\n\n") f.write("---\n\n") except Exception as e: print(f"!!! Ошибка на Том {v}, Часть {p}, Глава {c}: {e}") print("Прекращаю работу. Данные сохранены.") break if c % 5 == 0: print(f"--- Готово 5 глав. Последнее: {summary[:50]}... ---") print("Обработка завершена или достигнут конец списка.")
