init
This commit is contained in:
@@ -0,0 +1,412 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user