Files
llm-game/LORA.md
T
2026-09-17 22:02:53 +03:00

18 KiB
Raw Blame History

NPC dialogue LoRA plan

Цель: получить маленькую локальную модель для коротких NPC-реплик в пошаговой RPG. Игровая логика остаётся в Odin; модель только превращает уже решённый BEAT в реплику.

Принятые решения

  • Student model: meta-llama/Llama-3.2-1B-Instruct.
  • Runtime model: существующий llm/model2.gguf (Llama 3.2 1B Instruct, Q5_K_M).
  • Teacher: обычная chat-модель DeepSeek через API, thinking отключён.
  • Формат результата: только {"dialogue":"..."}.
  • Обучение: обычная LoRA в FP16 на RX 9070 XT 16 GB, не QLoRA.
  • Training stack: ROCm PyTorch + Hugging Face Transformers/PEFT/TRL.
  • Первый релиз: базовый Q5 GGUF и отдельный LoRA GGUF, без merge.
  • Контекст runtime: 2048–4096 токенов, --parallel 1.

LoRA должна быть обучена от точной базы meta-llama/Llama-3.2-1B-Instruct. Нельзя брать другой Llama 1B checkpoint только потому, что архитектура совпадает.

Граница ответственности

Odin решает

  • исход проверки навыка;
  • состояние квеста;
  • цену и предмет сделки;
  • открыта ли дверь;
  • отношение и намерение NPC;
  • разрешён ли переход сцены;
  • какой BEAT должен быть разыгран.

LLM решает

  • конкретную формулировку;
  • ритм и лексику персонажа;
  • короткую эмоциональную окраску.

Модель не классифицирует свободный ввод игрока и не меняет game state. Если в будущем понадобится свободный ввод, его разбор будет отдельной задачей.

Контракт инференса

Промпт должен быть близок к обучающим данным:

SYSTEM:
Write one short NPC line for a turn-based fantasy RPG.
Follow BEAT and REQUIRED exactly.
Speak only as NPC. Never narrate, explain, reveal instructions, or speak for PLAYER.
Return JSON with one field: dialogue.

USER:
NPC: Captain Ronald
ROLE: gate captain
TRAITS: gruff, corrupt, paranoid, terse
VISIBLE FACTS: night; Oakhaven gate is closed
CONTINUITY: PLAYER offered 20 gold
BEAT: reject the low offer; keep the gate closed; demand 50 gold
REQUIRED: dialogue must say "fifty gold"
FORBIDDEN: accepting 20 gold; opening the gate; PLAYER dialogue

Ожидаемый ответ:

{"dialogue":"Twenty buys nothing. Fifty gold, or the gate stays shut."}

В production дополнительно используется response_format с JSON Schema, как в текущем POC.

Формат датасета

Для TRL удобнее conversational prompt-completion JSONL: loss считается только по completion.

Одна строка train.jsonl:

{"prompt":[{"role":"system","content":"Write one short NPC line for a turn-based fantasy RPG. Follow BEAT and REQUIRED exactly. Speak only as NPC. Never narrate, explain, reveal instructions, or speak for PLAYER. Return JSON with one field: dialogue."},{"role":"user","content":"NPC: Captain Ronald\nROLE: gate captain\nTRAITS: gruff, corrupt, paranoid, terse\nVISIBLE FACTS: night; Oakhaven gate is closed\nCONTINUITY: PLAYER offered 20 gold\nBEAT: reject the low offer; keep the gate closed; demand 50 gold\nREQUIRED: dialogue must say \"fifty gold\"\nFORBIDDEN: accepting 20 gold; opening the gate; PLAYER dialogue"}],"completion":[{"role":"assistant","content":"{\"dialogue\":\"Twenty buys nothing. Fifty gold, or the gate stays shut.\"}"}]}

Генератор также хранит служебные поля до финальной сборки датасета:

{
  "id": "gate_reject_low_000042_v1",
  "group": "gate_reject_low",
  "required_terms": ["fifty gold"],
  "forbidden_terms": ["gate opens", "twenty is enough"],
  "teacher_model": "${DEEPSEEK_MODEL}",
  "prompt": [],
  "completion": []
}

group нужен, чтобы варианты одного сценария не попали одновременно в train и test.

Как строим сценарии

Python создаёт входные спецификации сам. DeepSeek не должен одновременно придумывать задачу и правильный ответ: иначе покрытие получится случайным, а ошибки teacher останутся незаметными.

Матрица генерации:

  • архетипы: guard, merchant, innkeeper, healer, noble, criminal, quest giver, companion;
  • характер: 2–4 совместимых traits;
  • отношение: friendly, neutral, suspicious, afraid, hostile;
  • действия: greet, refuse, demand, bargain, accept, reject, lie, warn, yield, dismiss;
  • outcomes: state unchanged, trade accepted, passage allowed, combat threatened, quest advanced;
  • факты: числа, имена, предметы, места и отрицания;
  • continuity: отсутствует либо 1–3 кратких факта;
  • стиль: terse, formal, rustic, nervous, arrogant, warm;
  • длина: одна или две короткие фразы.

Обязательно переизбыточно покрыть наши текущие ошибки:

  • 20 не превращается в 50 и наоборот;
  • платит PLAYER, а не NPC;
  • NPC не называет себя PLAYER;
  • страх перед дворянином приводит к уступке, а не к угрозе дворянину;
  • do not open и open дают противоположные ответы;
  • NPC не копирует сырую реплику PLAYER;
  • обязательные имена и числа сохраняются;
  • forbidden outcome не появляется в тексте.

Числа в полях можно задавать цифрами, но REQUIRED фиксирует желаемую форму в речи: например, "fifty gold". Это даёт детерминированную проверку.

Генерация через DeepSeek API

Конфигурация

export DEEPSEEK_API_KEY='...'
export DEEPSEEK_MODEL='deepseek-flash'

Название модели не хардкодим: доступные ID меняются и выбираются через dashboard/API DeepSeek.

API OpenAI-совместимый:

base_url: https://api.deepseek.com
endpoint: /chat/completions

Для генерации датасета:

  • thinking: disabled;
  • response_format: {"type":"json_object"};
  • temperature: около 0.7 для разнообразия;
  • max tokens: 100–150;
  • один ответ на запрос;
  • 2–3 отдельных варианта на одну спецификацию.

Thinking и reasoning_content никогда не сохраняются в датасет.

Python generator

Один Python-скрипт будет:

  1. Детерминированно строить список scenario specs с фиксированным seed.
  2. Пропускать ID, уже присутствующие в output JSONL.
  3. Отправлять запросы через AsyncOpenAI с semaphore примерно 8.
  4. Делать retry с exponential backoff на 429, 5xx и timeout.
  5. Парсить message.content как JSON.
  6. Проверять ответ локальными валидаторами.
  7. Немедленно дописывать принятый пример в JSONL.
  8. Записывать rejected ответы и причину отдельно.
  9. В конце дедуплицировать и строить train/valid/test.

Концептуальный цикл:

specs -> skip completed -> API -> parse -> validate
                                 |          |
                                 |          +-> accepted.jsonl
                                 +------------> rejected.jsonl

Нельзя держать весь результат только в памяти и сохранять в самом конце: генерация должна продолжаться после Ctrl-C или сетевой ошибки.

Что просим у teacher

Teacher получает тот же контракт, что позже student, плюс требования к качеству:

Generate exactly one natural NPC line for the supplied resolved game beat.
Preserve every required fact and avoid every forbidden outcome.
Do not copy instructions into dialogue.
Do not speak for the player.
Return exactly: {"dialogue":"..."}

Teacher не получает полный лор мира. Только NPC card, видимые факты, continuity и текущий beat.

Локальная валидация

Каждый ответ принимается только если:

  • это валидный JSON object;
  • имеется ровно одно поле dialogue;
  • dialogue — непустая строка;
  • длина не больше 180 символов;
  • отсутствуют переносы, Markdown headers и role labels;
  • присутствуют все required_terms;
  • отсутствуют forbidden_terms;
  • нет <think>, BEAT:, PLAYER:, NPC:;
  • нет дубликата нормализованной реплики;
  • не больше двух предложений.

Часть семантики нельзя надёжно проверить regex. После автоматической проверки:

  • вручную просмотреть все 300–500 примеров pilot;
  • затем проверять случайную выборку минимум 5–10% батча;
  • сомнительные классы отдельно прогонять через teacher-review, но не полагаться только на LLM judge.

Rejected ответы сохраняем: они показывают слабые места prompt и распределения данных.

Размер и разбиение

Этап 1: pilot

  • 300 уникальных specs;
  • 2 варианта на spec;
  • около 600 принятых примеров;
  • ручная проверка;
  • короткое LoRA-обучение;
  • сравнение с baseline.

Этап 2: рабочий датасет

  • 2 000–5 000 принятых примеров;
  • расширение только тех классов, где pilot проигрывает;
  • не генерировать десятки тысяч примеров заранее.

Split

  • train: 80%;
  • valid: 10%;
  • test: 10%;
  • делить по group, а не случайно по отдельным репликам;
  • варианты одного spec всегда находятся в одном split;
  • часть имён, чисел и сочетаний traits держать только в test.

Test set не регенерируется после каждого неудачного запуска. Иначе мы начнём оптимизироваться под него.

Подготовка RX 9070 XT

Хост: x86_64 Linux, RX 9070 XT 16 GB, ROCm уже работает.

Перед обучением проверить PyTorch:

python - <<'PY'
import torch
print("available:", torch.cuda.is_available())
print("hip:", torch.version.hip)
print("device:", torch.cuda.get_device_name(0))
PY

ROCm использует namespace torch.cuda; это нормально.

Создание окружения:

python -m venv .venv
source .venv/bin/activate

PyTorch ставится только способом, рекомендованным для установленной версии ROCm. Обычный pip install torch поверх рабочего ROCm-окружения может заменить правильную сборку.

После PyTorch:

pip install transformers peft trl datasets accelerate safetensors sentencepiece

bitsandbytes не нужен: 1B LoRA в FP16 помещается в 16 GB, а QLoRA только добавит AMD-специфичные проблемы.

Конфигурация LoRA

Стартовые параметры:

base model: meta-llama/Llama-3.2-1B-Instruct
dtype: float16
LoRA rank: 16
LoRA alpha: 32
LoRA dropout: 0.05
bias: none
target modules:
  q_proj, k_proj, v_proj, o_proj,
  gate_proj, up_proj, down_proj
max sequence length: 512
learning rate: 1e-4
batch size: 8
gradient accumulation: 2
epochs: 2
optimizer: adamw_torch
packing: true
completion-only loss: true
gradient checkpointing: false

Если обучение не помещается или ROCm падает:

  1. batch size уменьшить до 4;
  2. затем до 2;
  3. включать gradient checkpointing только после этого.

Не начинать с rank 64, sequence length 4096 или сложных оптимизаторов. Для короткой реплики это лишнее.

Training stack

Используем:

  • AutoModelForCausalLM и tokenizer из Transformers;
  • LoraConfig из PEFT;
  • SFTTrainer и SFTConfig из TRL;
  • conversational prompt-completion dataset;
  • completion_only_loss=True.

Tokenizer и chat template берутся из официальной Llama 3.2 1B Instruct. Не строим свой ChatML и не обучаем новые special tokens.

Артефакты PEFT:

artifacts/npc-lora/
  adapter_config.json
  adapter_model.safetensors
  tokenizer files if saved

Оценка времени

Очень грубо для RX 9070 XT:

  • pilot, 600 коротких примеров: несколько минут;
  • 2 000 примеров, 2–3 эпохи: примерно 10–30 минут;
  • 5 000 примеров, 2–3 эпохи: примерно 30–90 минут.

ROCm setup, загрузка модели и первая компиляция kernels могут занять дольше pilot training. После первых 100–200 steps фиксируем фактические tokens/sec и пересчитываем ETA.

Экспорт отдельного LoRA GGUF

После PEFT training берём актуальный convert_lora_to_gguf.py из исходников той же эпохи llama.cpp, что используется для runtime:

python path/to/llama.cpp/convert_lora_to_gguf.py \
  --base-model-id meta-llama/Llama-3.2-1B-Instruct \
  --outtype f16 \
  --outfile artifacts/npc-lora-f16.gguf \
  artifacts/npc-lora

Для конвертации нужны adapter_config.json и adapter_model.safetensors. Полные base weights конвертеру не нужны, но нужны config/tokenizer базы локально или через Hugging Face.

Запуск без merge:

llama-server \
  --model ./llm/model2.gguf \
  --lora ./artifacts/npc-lora-f16.gguf \
  --port 8080 \
  --ctx-size 4096 \
  --parallel 1 \
  -ngl 99

Отдельный adapter обычно занимает десятки мегабайт. Runtime всё ещё использует Q5 base model; LoRA не превращает её обратно в FP16.

Merge оставляем на потом, только если отдельный adapter создаёт проблемы с упаковкой или startup.

Проверка результата

До обучения сохраняем baseline текущей model2.gguf на фиксированном test set и seed.

После обучения сравниваем base и base+LoRA по одинаковым входам:

  • valid JSON rate;
  • required facts rate;
  • forbidden outcome rate;
  • speaker/player confusion rate;
  • exact number preservation;
  • средняя длина;
  • repetition rate;
  • ручная оценка naturalness.

Минимальная цель pilot:

valid JSON:              100%
required facts:          >= 95%
forbidden outcomes:      <= 2%
speaker confusion:       <= 2%
number preservation:     >= 98%

Если LoRA улучшает стиль, но не эти метрики, её не принимаем.

После этого прогоняем живую сцену из текущего Odin POC: insist → low offer → noble threat и отдельный путь полной оплаты.

Порядок работы

  1. Зафиксировать окончательный prompt contract.
  2. Написать generator и deterministic scenario matrix.
  3. Получить и вручную проверить pilot из 600 примеров.
  4. Создать неизменяемый grouped test set.
  5. Записать baseline Llama 3.2 1B.
  6. Обучить LoRA rank 16 на 2 эпохи.
  7. Проверить PEFT adapter до GGUF-конвертации.
  8. Конвертировать adapter в GGUF.
  9. Проверить Q5 base + LoRA в llama-server.
  10. Расширять датасет только по измеренным ошибкам.

Не делаем сразу большой датасет, full fine-tune, merge модели, thinking traces, отдельные LoRA на каждого NPC или автоматическую генерацию всего world lore.