413 lines
18 KiB
Markdown
413 lines
18 KiB
Markdown
# 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. Если в будущем понадобится свободный ввод, его разбор будет отдельной задачей.
|
||
|
||
## Контракт инференса
|
||
|
||
Промпт должен быть близок к обучающим данным:
|
||
|
||
```text
|
||
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
|
||
```
|
||
|
||
Ожидаемый ответ:
|
||
|
||
```json
|
||
{"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`:
|
||
|
||
```json
|
||
{"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.\"}"}]}
|
||
```
|
||
|
||
Генератор также хранит служебные поля до финальной сборки датасета:
|
||
|
||
```json
|
||
{
|
||
"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
|
||
|
||
### Конфигурация
|
||
|
||
```sh
|
||
export DEEPSEEK_API_KEY='...'
|
||
export DEEPSEEK_MODEL='deepseek-flash'
|
||
```
|
||
|
||
Название модели не хардкодим: доступные ID меняются и выбираются через dashboard/API DeepSeek.
|
||
|
||
API OpenAI-совместимый:
|
||
|
||
```text
|
||
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.
|
||
|
||
Концептуальный цикл:
|
||
|
||
```text
|
||
specs -> skip completed -> API -> parse -> validate
|
||
| |
|
||
| +-> accepted.jsonl
|
||
+------------> rejected.jsonl
|
||
```
|
||
|
||
Нельзя держать весь результат только в памяти и сохранять в самом конце: генерация должна продолжаться после Ctrl-C или сетевой ошибки.
|
||
|
||
### Что просим у teacher
|
||
|
||
Teacher получает тот же контракт, что позже student, плюс требования к качеству:
|
||
|
||
```text
|
||
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:
|
||
|
||
```sh
|
||
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`; это нормально.
|
||
|
||
Создание окружения:
|
||
|
||
```sh
|
||
python -m venv .venv
|
||
source .venv/bin/activate
|
||
```
|
||
|
||
PyTorch ставится только способом, рекомендованным для установленной версии ROCm. Обычный `pip install torch` поверх рабочего ROCm-окружения может заменить правильную сборку.
|
||
|
||
После PyTorch:
|
||
|
||
```sh
|
||
pip install transformers peft trl datasets accelerate safetensors sentencepiece
|
||
```
|
||
|
||
`bitsandbytes` не нужен: 1B LoRA в FP16 помещается в 16 GB, а QLoRA только добавит AMD-специфичные проблемы.
|
||
|
||
## Конфигурация LoRA
|
||
|
||
Стартовые параметры:
|
||
|
||
```text
|
||
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:
|
||
|
||
```text
|
||
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:
|
||
|
||
```sh
|
||
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:
|
||
|
||
```sh
|
||
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:
|
||
|
||
```text
|
||
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.
|