fix: Seed-X 프롬프트 형식과 티어별 용어집 전략 수정
4티어(Seed-X-PPO-7B) 번역 경로가 모델 카드 요구사항을 어기고 있었다. - 프롬프트 끝의 `<ko>` 등 대상 언어 태그가 빠져 있었다. PPO 학습에 쓰인 신호라 없으면 번역 품질이 흔들린다. - Seed-X 는 chat template 없는 번역 전용 completion 모델인데 "구어체로 자연스럽게" 같은 지시문 래퍼를 씌우고 있었다. 학습 분포를 벗어난다. - 그 결과 MTSpec.supports_prompt_glossary=True 가 사실과 달랐다. Seed-X 는 용어집 지시문을 이해하지 못하므로 플레이스홀더 치환을 써야 한다. 수정 - PromptStyle(NONE/SEEDX/INSTRUCT) 도입, supports_prompt_glossary 를 prompt_style 에서 파생시켜 둘이 어긋날 수 없게 함 - build_seedx_prompt() 로 모델 카드 형식을 분리 (지시문 주입 불가) - INSTRUCT 경로는 chat template 사용, Qwen3 thinking 모드는 끔 - LANGUAGES 에 seedx 태그 명시 - finetune_mt.py 가 티어의 prompt_style 을 따라가게 해 학습/추론 프롬프트 일치 - AWQ Int4 로드 실패 시 autoawq 설치 안내를 담은 오류 메시지 검증: pytest 52개 통과 (프롬프트 회귀 테스트 13개 추가), ruff clean 근거: https://huggingface.co/ByteDance-Seed/Seed-X-PPO-7B Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -18,12 +18,17 @@
|
|||||||
|
|
||||||
`용어집` 화면에서 "원문 → 각 언어 역어"를 등록하면 끝입니다. 학습 없이 바로 적용됩니다.
|
`용어집` 화면에서 "원문 → 각 언어 역어"를 등록하면 끝입니다. 학습 없이 바로 적용됩니다.
|
||||||
|
|
||||||
동작 방식은 번역 백엔드에 따라 다릅니다.
|
동작 방식은 번역 모델에 따라 다릅니다.
|
||||||
|
|
||||||
- **NLLB 계열(1~3티어)** — 등록 단어를 `⟦0⟧` 같은 토큰으로 바꿔치기해 모델이 아예
|
- **1~4티어 (NLLB, Seed-X)** — 등록 단어를 `⟦0⟧` 같은 토큰으로 바꿔치기해 모델이 아예
|
||||||
건드리지 못하게 한 뒤, 번역이 끝나면 지정한 역어로 되돌립니다. 100% 보장됩니다.
|
건드리지 못하게 한 뒤, 번역이 끝나면 지정한 역어로 되돌립니다.
|
||||||
- **LLM 계열(4~5티어)** — 그 문장에 실제로 나온 용어만 골라 프롬프트에
|
- **5티어 (Qwen3)** — 그 문장에 실제로 나온 용어만 골라 프롬프트에
|
||||||
"이 용어는 이렇게 옮겨라"로 넣어줍니다. 조사·어미까지 문맥에 맞게 붙습니다.
|
"이 용어는 이렇게 옮겨라"로 넣어줍니다. 조사·어미까지 문맥에 맞게 붙어 더 자연스럽습니다.
|
||||||
|
|
||||||
|
4티어 Seed-X가 LLM인데도 프롬프트 방식을 못 쓰는 이유는, 이 모델이 chat template 없는
|
||||||
|
**번역 전용 completion 모델**이기 때문입니다. 지시문을 이해하지 못할뿐더러 모델 카드가
|
||||||
|
정한 고정 프롬프트 형식(끝의 `<ko>` 같은 언어 태그 포함)을 벗어나면 품질이 무너집니다.
|
||||||
|
용어집을 문맥까지 자연스럽게 반영하고 싶다면 5티어를 쓰세요.
|
||||||
|
|
||||||
CSV로 한 번에 가져올 수 있습니다.
|
CSV로 한 번에 가져올 수 있습니다.
|
||||||
|
|
||||||
|
|||||||
@@ -64,6 +64,30 @@ Seed-X는 번역만 하도록 만들어진 모델이라 7B치고 품질이 비
|
|||||||
**VRAM이 8GB 이하라면 → 3티어 "균형".** 그리고 이 경우의 추가학습은
|
**VRAM이 8GB 이하라면 → 3티어 "균형".** 그리고 이 경우의 추가학습은
|
||||||
NLLB-1.3B를 **풀 파인튜닝**하는 쪽이 오히려 유리하다 (docs/FINETUNING.md 참고).
|
NLLB-1.3B를 **풀 파인튜닝**하는 쪽이 오히려 유리하다 (docs/FINETUNING.md 참고).
|
||||||
|
|
||||||
|
## Seed-X를 쓸 때 반드시 지켜야 하는 것
|
||||||
|
|
||||||
|
Seed-X는 일반 챗 모델이 아니라 **번역만 하도록 학습된 completion 모델**입니다.
|
||||||
|
모델 카드가 명시하는 제약이 세 가지 있고, 구현에 그대로 반영했습니다.
|
||||||
|
|
||||||
|
1. **프롬프트 끝의 언어 태그는 필수입니다.** PPO 학습에 쓰인 신호라 빠지면 품질이
|
||||||
|
크게 흔들립니다. 형식은 정확히 이렇습니다.
|
||||||
|
```
|
||||||
|
Translate the following English sentence into Korean:
|
||||||
|
May the force be with you <ko>
|
||||||
|
```
|
||||||
|
2. **chat template이 없습니다.** `apply_chat_template`을 쓰거나 멀티턴 대화 형식으로
|
||||||
|
넣으면 안 됩니다.
|
||||||
|
3. **지시문을 끼워 넣으면 안 됩니다.** "구어체로 자연스럽게 옮겨라" 같은 문장을 붙이면
|
||||||
|
학습 분포를 벗어납니다. 그래서 4티어는 용어집도 프롬프트가 아니라
|
||||||
|
플레이스홀더 치환으로 넣습니다.
|
||||||
|
|
||||||
|
이 제약 때문에 프롬프트 빌더를 `build_seedx_prompt()`로 분리하고, 형식이 바뀌면
|
||||||
|
바로 깨지도록 `tests/test_prompts.py`에 회귀 테스트를 걸어놨습니다.
|
||||||
|
|
||||||
|
참고로 `AWQ-Int4`/`GPTQ-Int8`은 ByteDance가 직접 배포하는 **공식** 양자화본입니다
|
||||||
|
(모델 카드가 경고하는 것은 제3자 양자화본입니다). 다만 AWQ 가중치를 읽으려면
|
||||||
|
`pip install autoawq`가 필요합니다.
|
||||||
|
|
||||||
## 주의 — 벤치마크를 곧이곧대로 믿지 말 것
|
## 주의 — 벤치마크를 곧이곧대로 믿지 말 것
|
||||||
|
|
||||||
위 비교는 공개 벤치마크와 모델 카드에 근거한 것이고, **실제 게임/방송 음성에서의
|
위 비교는 공개 벤치마크와 모델 카드에 근거한 것이고, **실제 게임/방송 음성에서의
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ gpu = [
|
|||||||
"huggingface-hub>=0.24",
|
"huggingface-hub>=0.24",
|
||||||
"sentencepiece>=0.2",
|
"sentencepiece>=0.2",
|
||||||
]
|
]
|
||||||
|
# 4티어(Seed-X AWQ Int4) 전용 커널. 다른 티어에는 필요 없다.
|
||||||
|
awq = ["autoawq>=0.2.6"]
|
||||||
# Windows 오디오 캡처
|
# Windows 오디오 캡처
|
||||||
windows = [
|
windows = [
|
||||||
"PyAudioWPatch>=0.2.12.7",
|
"PyAudioWPatch>=0.2.12.7",
|
||||||
|
|||||||
@@ -20,3 +20,6 @@ psutil>=5.9; sys_platform == "win32"
|
|||||||
|
|
||||||
# --- 선택 ------------------------------------------------------------
|
# --- 선택 ------------------------------------------------------------
|
||||||
webrtcvad-wheels>=2.0.14
|
webrtcvad-wheels>=2.0.14
|
||||||
|
|
||||||
|
# 4티어(Seed-X AWQ Int4)를 쓸 때만 필요합니다:
|
||||||
|
# pip install autoawq
|
||||||
|
|||||||
@@ -21,7 +21,13 @@ from pathlib import Path
|
|||||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
||||||
|
|
||||||
from hearo.constants import LANGUAGES, models_dir # noqa: E402
|
from hearo.constants import LANGUAGES, models_dir # noqa: E402
|
||||||
from hearo.models.tiers import MTBackend, get_tier, ordered_tiers # noqa: E402
|
from hearo.models.tiers import ( # noqa: E402
|
||||||
|
MTBackend,
|
||||||
|
PromptStyle,
|
||||||
|
get_tier,
|
||||||
|
ordered_tiers,
|
||||||
|
)
|
||||||
|
from hearo.models.translator import build_seedx_prompt # noqa: E402
|
||||||
|
|
||||||
#: NLLB(seq2seq)와 Qwen(causal LM)에서 공통으로 존재하는 attention 투영 이름
|
#: NLLB(seq2seq)와 Qwen(causal LM)에서 공통으로 존재하는 attention 투영 이름
|
||||||
LORA_TARGETS = ["q_proj", "v_proj"]
|
LORA_TARGETS = ["q_proj", "v_proj"]
|
||||||
@@ -52,8 +58,19 @@ def load_pairs(path: Path) -> list[dict]:
|
|||||||
return pairs
|
return pairs
|
||||||
|
|
||||||
|
|
||||||
def build_prompt(item: dict) -> tuple[str, str]:
|
def build_prompt(item: dict, style: PromptStyle = PromptStyle.INSTRUCT) -> tuple[str, str]:
|
||||||
"""LLM 학습용 (프롬프트, 정답) 쌍. translator.py 의 추론 프롬프트와 맞춰야 한다."""
|
"""LLM 학습용 (프롬프트, 정답) 쌍.
|
||||||
|
|
||||||
|
**추론 때 쓰는 프롬프트와 반드시 같아야 한다.** 형식이 어긋나면 학습은
|
||||||
|
정상적으로 끝나지만 실제 번역에서는 효과가 거의 나오지 않는다. 그래서
|
||||||
|
Seed-X 형식은 translator.py 의 함수를 그대로 재사용한다.
|
||||||
|
"""
|
||||||
|
if style is PromptStyle.SEEDX:
|
||||||
|
prompt = build_seedx_prompt(
|
||||||
|
item["source"], item["source_lang"], item["target_lang"]
|
||||||
|
)
|
||||||
|
return prompt, "\n" + item["target"]
|
||||||
|
|
||||||
src = LANGUAGES[item["source_lang"]]["english"]
|
src = LANGUAGES[item["source_lang"]]["english"]
|
||||||
tgt = LANGUAGES[item["target_lang"]]["english"]
|
tgt = LANGUAGES[item["target_lang"]]["english"]
|
||||||
prompt = (
|
prompt = (
|
||||||
@@ -65,7 +82,8 @@ def build_prompt(item: dict) -> tuple[str, str]:
|
|||||||
return prompt, " " + item["target"]
|
return prompt, " " + item["target"]
|
||||||
|
|
||||||
|
|
||||||
def train_llm(pairs, repo, output: Path, epochs: int, lr: float, rank: int, batch: int):
|
def train_llm(pairs, repo, output: Path, epochs: int, lr: float, rank: int, batch: int,
|
||||||
|
style: PromptStyle = PromptStyle.INSTRUCT):
|
||||||
import torch
|
import torch
|
||||||
from datasets import Dataset
|
from datasets import Dataset
|
||||||
from peft import LoraConfig, get_peft_model
|
from peft import LoraConfig, get_peft_model
|
||||||
@@ -82,7 +100,7 @@ def train_llm(pairs, repo, output: Path, epochs: int, lr: float, rank: int, batc
|
|||||||
tokenizer.pad_token = tokenizer.eos_token
|
tokenizer.pad_token = tokenizer.eos_token
|
||||||
|
|
||||||
def encode(item):
|
def encode(item):
|
||||||
prompt, answer = build_prompt(item)
|
prompt, answer = build_prompt(item, style)
|
||||||
prompt_ids = tokenizer(prompt, add_special_tokens=False)["input_ids"]
|
prompt_ids = tokenizer(prompt, add_special_tokens=False)["input_ids"]
|
||||||
answer_ids = tokenizer(answer, add_special_tokens=False)["input_ids"]
|
answer_ids = tokenizer(answer, add_special_tokens=False)["input_ids"]
|
||||||
answer_ids.append(tokenizer.eos_token_id)
|
answer_ids.append(tokenizer.eos_token_id)
|
||||||
@@ -227,7 +245,10 @@ def main() -> int:
|
|||||||
if tier.mt.backend is MTBackend.TRANSFORMERS:
|
if tier.mt.backend is MTBackend.TRANSFORMERS:
|
||||||
repo = args.base_model or tier.mt.repo
|
repo = args.base_model or tier.mt.repo
|
||||||
print(f"LLM LoRA 학습: {repo} · {len(pairs)}쌍 · {args.epochs} epoch")
|
print(f"LLM LoRA 학습: {repo} · {len(pairs)}쌍 · {args.epochs} epoch")
|
||||||
train_llm(pairs, repo, args.output, args.epochs, args.lr, args.rank, args.batch)
|
train_llm(
|
||||||
|
pairs, repo, args.output, args.epochs, args.lr, args.rank, args.batch,
|
||||||
|
tier.mt.prompt_style,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
# CT2 변환본에는 학습에 필요한 가중치가 없으므로 원본 리포로 바꾼다.
|
# CT2 변환본에는 학습에 필요한 가중치가 없으므로 원본 리포로 바꾼다.
|
||||||
repo = args.base_model or _hf_original(tier.mt.repo)
|
repo = args.base_model or _hf_original(tier.mt.repo)
|
||||||
|
|||||||
@@ -11,11 +11,19 @@ APP_VERSION = "0.1.0"
|
|||||||
ORG_NAME = "tkrmagid"
|
ORG_NAME = "tkrmagid"
|
||||||
|
|
||||||
# 지원 언어 (1차: 4개)
|
# 지원 언어 (1차: 4개)
|
||||||
|
#
|
||||||
|
# 각 키의 의미:
|
||||||
|
# label — UI 표시 이름
|
||||||
|
# english — 프롬프트에 쓰는 영어 언어명
|
||||||
|
# nllb — NLLB-200 언어 코드 (FLORES-200 형식)
|
||||||
|
# seedx — Seed-X 프롬프트 끝에 붙이는 태그. 모델 카드가 "언어 태그는 필수"라고
|
||||||
|
# 명시하며, 빠지면 번역 품질이 크게 흔들린다. 값이 우리 언어코드와
|
||||||
|
# 같아 보여도 우연이 아니라 Seed-X 표에 맞춘 것이므로 따로 적어둔다.
|
||||||
LANGUAGES: dict[str, dict[str, str]] = {
|
LANGUAGES: dict[str, dict[str, str]] = {
|
||||||
"ko": {"label": "한국어", "english": "Korean", "nllb": "kor_Hang", "flag": "KO"},
|
"ko": {"label": "한국어", "english": "Korean", "nllb": "kor_Hang", "seedx": "ko"},
|
||||||
"en": {"label": "English", "english": "English", "nllb": "eng_Latn", "flag": "EN"},
|
"en": {"label": "English", "english": "English", "nllb": "eng_Latn", "seedx": "en"},
|
||||||
"ja": {"label": "日本語", "english": "Japanese", "nllb": "jpn_Jpan", "flag": "JA"},
|
"ja": {"label": "日本語", "english": "Japanese", "nllb": "jpn_Jpan", "seedx": "ja"},
|
||||||
"zh": {"label": "中文", "english": "Chinese", "nllb": "zho_Hans", "flag": "ZH"},
|
"zh": {"label": "中文", "english": "Chinese", "nllb": "zho_Hans", "seedx": "zh"},
|
||||||
}
|
}
|
||||||
LANGUAGE_CODES = tuple(LANGUAGES.keys())
|
LANGUAGE_CODES = tuple(LANGUAGES.keys())
|
||||||
|
|
||||||
|
|||||||
82
tests/test_prompts.py
Normal file
82
tests/test_prompts.py
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
"""번역 프롬프트 형식 회귀 테스트.
|
||||||
|
|
||||||
|
Seed-X 는 chat template 없는 번역 전용 completion 모델이라 모델 카드가 정한
|
||||||
|
형식을 한 글자도 벗어나면 안 된다. 특히 끝의 `<언어코드>` 태그는 PPO 학습에
|
||||||
|
쓰인 것이라 빠지면 품질이 무너진다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from hearo.constants import LANGUAGE_CODES, LANGUAGES
|
||||||
|
from hearo.models.glossary import Glossary, GlossaryEntry
|
||||||
|
from hearo.models.tiers import TIERS, MTBackend, PromptStyle, get_tier
|
||||||
|
from hearo.models.translator import build_seedx_prompt
|
||||||
|
|
||||||
|
|
||||||
|
def test_seedx_prompt_matches_model_card_exactly():
|
||||||
|
"""모델 카드 예시: "Translate the following English sentence into Chinese:\\nMay the force be with you <zh>" """
|
||||||
|
assert build_seedx_prompt("May the force be with you", "en", "zh") == (
|
||||||
|
"Translate the following English sentence into Chinese:\n"
|
||||||
|
"May the force be with you <zh>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_seedx_prompt_always_ends_with_target_language_tag():
|
||||||
|
for target in LANGUAGE_CODES:
|
||||||
|
prompt = build_seedx_prompt("hello", "en", target)
|
||||||
|
assert prompt.endswith(f" <{target}>"), f"{target} 태그 누락"
|
||||||
|
|
||||||
|
|
||||||
|
def test_seedx_prompt_has_no_extra_instructions():
|
||||||
|
"""지시문을 끼워 넣으면 Seed-X 의 학습 분포를 벗어난다."""
|
||||||
|
prompt = build_seedx_prompt("fall back now", "en", "ko")
|
||||||
|
lowered = prompt.lower()
|
||||||
|
for forbidden in ("casual", "output only", "glossary", "game or broadcast", "용어"):
|
||||||
|
assert forbidden not in lowered
|
||||||
|
assert prompt.count("\n") == 1 # 지시문 한 줄 + 본문 한 줄
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("code", LANGUAGE_CODES)
|
||||||
|
def test_every_language_has_a_seedx_tag(code):
|
||||||
|
assert LANGUAGES[code]["seedx"], f"{code} 에 seedx 태그가 없습니다"
|
||||||
|
|
||||||
|
|
||||||
|
def test_seedx_tier_does_not_use_prompt_glossary():
|
||||||
|
"""Seed-X 는 지시문을 못 알아들으므로 프롬프트 용어집을 쓰면 안 된다."""
|
||||||
|
precision = get_tier("precision")
|
||||||
|
assert precision.mt.prompt_style is PromptStyle.SEEDX
|
||||||
|
assert precision.mt.supports_prompt_glossary is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_instruct_tier_uses_prompt_glossary():
|
||||||
|
ultimate = get_tier("ultimate")
|
||||||
|
assert ultimate.mt.prompt_style is PromptStyle.INSTRUCT
|
||||||
|
assert ultimate.mt.supports_prompt_glossary is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_seq2seq_tiers_never_use_prompt_glossary():
|
||||||
|
for tier in TIERS.values():
|
||||||
|
if tier.mt.backend is MTBackend.CTRANSLATE2:
|
||||||
|
assert tier.mt.prompt_style is PromptStyle.NONE
|
||||||
|
assert tier.mt.supports_prompt_glossary is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_llm_tier_declares_a_prompt_style():
|
||||||
|
for tier in TIERS.values():
|
||||||
|
if tier.mt.backend is MTBackend.TRANSFORMERS:
|
||||||
|
assert tier.mt.prompt_style is not PromptStyle.NONE, tier.key
|
||||||
|
|
||||||
|
|
||||||
|
def test_glossary_survives_seedx_placeholder_path():
|
||||||
|
"""Seed-X 경로에서 용어집이 프롬프트가 아니라 치환으로 동작하는지."""
|
||||||
|
g = Glossary([GlossaryEntry("Nexus", {"ko": "넥서스"})])
|
||||||
|
protected, repl = g.protect("push to the Nexus", "ko")
|
||||||
|
|
||||||
|
prompt = build_seedx_prompt(protected, "en", "ko")
|
||||||
|
assert "Nexus" not in prompt # 모델이 건드릴 수 없게 가려짐
|
||||||
|
assert prompt.endswith(" <ko>")
|
||||||
|
|
||||||
|
# 모델이 플레이스홀더를 그대로 통과시켰다고 가정
|
||||||
|
assert Glossary.restore("⟦0⟧로 밀어", repl) == "넥서스로 밀어"
|
||||||
@@ -1,4 +1,11 @@
|
|||||||
from hearo.models.tiers import DEFAULT_TIER, TIERS, MTBackend, get_tier, ordered_tiers
|
from hearo.models.tiers import (
|
||||||
|
DEFAULT_TIER,
|
||||||
|
TIERS,
|
||||||
|
MTBackend,
|
||||||
|
PromptStyle,
|
||||||
|
get_tier,
|
||||||
|
ordered_tiers,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_exactly_five_tiers_ordered_by_quality():
|
def test_exactly_five_tiers_ordered_by_quality():
|
||||||
@@ -20,12 +27,20 @@ def test_exactly_one_recommended_and_one_finetune_pick():
|
|||||||
assert sum(t.best_after_finetune for t in TIERS.values()) == 1
|
assert sum(t.best_after_finetune for t in TIERS.values()) == 1
|
||||||
|
|
||||||
|
|
||||||
def test_llm_backends_support_prompt_glossary():
|
def test_only_instruct_models_support_prompt_glossary():
|
||||||
|
"""LLM이라고 다 되는 게 아니다 — 지시문을 이해하는 instruct 모델만 가능하다.
|
||||||
|
|
||||||
|
Seed-X 는 번역 전용 completion 모델이라 지시문을 넣으면 오히려 망가진다.
|
||||||
|
"""
|
||||||
for tier in TIERS.values():
|
for tier in TIERS.values():
|
||||||
if tier.mt.backend is MTBackend.TRANSFORMERS:
|
expected = tier.mt.prompt_style is PromptStyle.INSTRUCT
|
||||||
assert tier.mt.supports_prompt_glossary
|
assert tier.mt.supports_prompt_glossary is expected, tier.key
|
||||||
else:
|
|
||||||
assert not tier.mt.supports_prompt_glossary
|
|
||||||
|
def test_seq2seq_backends_have_no_prompt_style():
|
||||||
|
for tier in TIERS.values():
|
||||||
|
if tier.mt.backend is MTBackend.CTRANSLATE2:
|
||||||
|
assert tier.mt.prompt_style is PromptStyle.NONE, tier.key
|
||||||
|
|
||||||
|
|
||||||
def test_unknown_key_falls_back_to_default():
|
def test_unknown_key_falls_back_to_default():
|
||||||
|
|||||||
Reference in New Issue
Block a user