Files
2026-09-17 22:02:53 +03:00

413 lines
18 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.