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:
EJClaw
2026-09-21 10:56:49 +09:00
parent 1a87ec6677
commit 5596f905d1
8 changed files with 181 additions and 21 deletions

View File

@@ -18,12 +18,17 @@
`용어집` 화면에서 "원문 → 각 언어 역어"를 등록하면 끝입니다. 학습 없이 바로 적용됩니다.
동작 방식은 번역 백엔드에 따라 다릅니다.
동작 방식은 번역 모델에 따라 다릅니다.
- **NLLB 계열(1~3티어)** — 등록 단어를 `⟦0⟧` 같은 토큰으로 바꿔치기해 모델이 아예
건드리지 못하게 한 뒤, 번역이 끝나면 지정한 역어로 되돌립니다. 100% 보장됩니다.
- **LLM 계열(4~5티어)** — 그 문장에 실제로 나온 용어만 골라 프롬프트에
"이 용어는 이렇게 옮겨라"로 넣어줍니다. 조사·어미까지 문맥에 맞게 붙습니다.
- **1~4티어 (NLLB, Seed-X)** — 등록 단어를 `⟦0⟧` 같은 토큰으로 바꿔치기해 모델이 아예
건드리지 못하게 한 뒤, 번역이 끝나면 지정한 역어로 되돌립니다.
- **5티어 (Qwen3)** — 그 문장에 실제로 나온 용어만 골라 프롬프트에
"이 용어는 이렇게 옮겨라"로 넣어줍니다. 조사·어미까지 문맥에 맞게 붙어 더 자연스럽습니다.
4티어 Seed-X가 LLM인데도 프롬프트 방식을 못 쓰는 이유는, 이 모델이 chat template 없는
**번역 전용 completion 모델**이기 때문입니다. 지시문을 이해하지 못할뿐더러 모델 카드가
정한 고정 프롬프트 형식(끝의 `<ko>` 같은 언어 태그 포함)을 벗어나면 품질이 무너집니다.
용어집을 문맥까지 자연스럽게 반영하고 싶다면 5티어를 쓰세요.
CSV로 한 번에 가져올 수 있습니다.

View File

@@ -64,6 +64,30 @@ Seed-X는 번역만 하도록 만들어진 모델이라 7B치고 품질이 비
**VRAM이 8GB 이하라면 → 3티어 "균형".** 그리고 이 경우의 추가학습은
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`가 필요합니다.
## 주의 — 벤치마크를 곧이곧대로 믿지 말 것
위 비교는 공개 벤치마크와 모델 카드에 근거한 것이고, **실제 게임/방송 음성에서의

View File

@@ -25,6 +25,8 @@ gpu = [
"huggingface-hub>=0.24",
"sentencepiece>=0.2",
]
# 4티어(Seed-X AWQ Int4) 전용 커널. 다른 티어에는 필요 없다.
awq = ["autoawq>=0.2.6"]
# Windows 오디오 캡처
windows = [
"PyAudioWPatch>=0.2.12.7",

View File

@@ -20,3 +20,6 @@ psutil>=5.9; sys_platform == "win32"
# --- 선택 ------------------------------------------------------------
webrtcvad-wheels>=2.0.14
# 4티어(Seed-X AWQ Int4)를 쓸 때만 필요합니다:
# pip install autoawq

View File

@@ -21,7 +21,13 @@ from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
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 투영 이름
LORA_TARGETS = ["q_proj", "v_proj"]
@@ -52,8 +58,19 @@ def load_pairs(path: Path) -> list[dict]:
return pairs
def build_prompt(item: dict) -> tuple[str, str]:
"""LLM 학습용 (프롬프트, 정답) 쌍. translator.py 의 추론 프롬프트와 맞춰야 한다."""
def build_prompt(item: dict, style: PromptStyle = PromptStyle.INSTRUCT) -> tuple[str, str]:
"""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"]
tgt = LANGUAGES[item["target_lang"]]["english"]
prompt = (
@@ -65,7 +82,8 @@ def build_prompt(item: dict) -> tuple[str, str]:
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
from datasets import Dataset
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
def encode(item):
prompt, answer = build_prompt(item)
prompt, answer = build_prompt(item, style)
prompt_ids = tokenizer(prompt, add_special_tokens=False)["input_ids"]
answer_ids = tokenizer(answer, add_special_tokens=False)["input_ids"]
answer_ids.append(tokenizer.eos_token_id)
@@ -227,7 +245,10 @@ def main() -> int:
if tier.mt.backend is MTBackend.TRANSFORMERS:
repo = args.base_model or tier.mt.repo
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:
# CT2 변환본에는 학습에 필요한 가중치가 없으므로 원본 리포로 바꾼다.
repo = args.base_model or _hf_original(tier.mt.repo)

View File

@@ -11,11 +11,19 @@ APP_VERSION = "0.1.0"
ORG_NAME = "tkrmagid"
# 지원 언어 (1차: 4개)
#
# 각 키의 의미:
# label — UI 표시 이름
# english — 프롬프트에 쓰는 영어 언어명
# nllb — NLLB-200 언어 코드 (FLORES-200 형식)
# seedx — Seed-X 프롬프트 끝에 붙이는 태그. 모델 카드가 "언어 태그는 필수"라고
# 명시하며, 빠지면 번역 품질이 크게 흔들린다. 값이 우리 언어코드와
# 같아 보여도 우연이 아니라 Seed-X 표에 맞춘 것이므로 따로 적어둔다.
LANGUAGES: dict[str, dict[str, str]] = {
"ko": {"label": "한국어", "english": "Korean", "nllb": "kor_Hang", "flag": "KO"},
"en": {"label": "English", "english": "English", "nllb": "eng_Latn", "flag": "EN"},
"ja": {"label": "日本語", "english": "Japanese", "nllb": "jpn_Jpan", "flag": "JA"},
"zh": {"label": "中文", "english": "Chinese", "nllb": "zho_Hans", "flag": "ZH"},
"ko": {"label": "한국어", "english": "Korean", "nllb": "kor_Hang", "seedx": "ko"},
"en": {"label": "English", "english": "English", "nllb": "eng_Latn", "seedx": "en"},
"ja": {"label": "日本語", "english": "Japanese", "nllb": "jpn_Jpan", "seedx": "ja"},
"zh": {"label": "中文", "english": "Chinese", "nllb": "zho_Hans", "seedx": "zh"},
}
LANGUAGE_CODES = tuple(LANGUAGES.keys())

82
tests/test_prompts.py Normal file
View 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) == "넥서스로 밀어"

View File

@@ -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():
@@ -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
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():
if tier.mt.backend is MTBackend.TRANSFORMERS:
assert tier.mt.supports_prompt_glossary
else:
assert not tier.mt.supports_prompt_glossary
expected = tier.mt.prompt_style is PromptStyle.INSTRUCT
assert tier.mt.supports_prompt_glossary is expected, tier.key
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():