왜 다국어 출력이 흔들리는가

같은 프롬프트 템플릿에 언어만 바꿔 넣으면 출력이 미묘하게 달라진다. 영어로는 JSON 스키마를 정확히 지키던 모델이 한국어에서는 필드명을 번역해버리거나, 존댓말/반말이 문장마다 뒤섞이고, 날짜·통화·소수점 표기가 로케일마다 제각각이 된다. 원인은 세 가지다. 첫째, 지시문과 데이터가 같은 언어라 모델이 구조와 내용을 구분하지 못한다. 둘째, 언어별 토크나이저 분할 차이로 few-shot 예시의 형식 신호가 약해진다. 셋째, 번역된 지시문은 원문보다 강제력이 떨어져 제약이 쉽게 무너진다.

구조와 내용을 분리하는 원칙

핵심 규칙은 하나다. 지시문·스키마·키는 항상 영어로 고정하고, 자연어 출력 언어만 변수로 주입한다. 필드명을 번역시키면 다운스트림 파싱이 언어별로 깨지므로, 구조는 언어 중립적으로 유지하고 값만 대상 언어로 생성하게 만든다. 출력 포맷을 JSON으로 강제하면 자유 서술보다 로케일 혼입이 크게 줄어든다.

SYSTEM:
You are a data extraction API. Always respond with valid JSON only.
Rules:
- Keys MUST stay in English exactly as in the schema.
- Only string VALUES are written in the target language.
- Do not translate, add, or reorder keys.
- Numbers use dot as decimal separator, no thousands separator.

Schema: {"title": string, "summary": string, "sentiment": "pos"|"neg"|"neu"}
Target language: {{output_lang}}   # e.g. "ko-KR"
Input: {{document}}

로케일 정책을 명시적으로 못박기

"한국어로 답하라"만으로는 부족하다. 경어체 수준, 숫자·날짜 포맷, 단위를 로케일 정책으로 코드에 담아 프롬프트에 주입해야 재현성이 생긴다.

LOCALE_POLICY = {
    "ko-KR": {"tone": "합쇼체(-습니다) 유지", "date": "YYYY년 M월 D일",
              "decimal": ".", "currency": "KRW, 기호는 '원'"},
    "ja-JP": {"tone": "です・ます体", "date": "YYYY年M月D日",
              "decimal": ".", "currency": "JPY, 記号は '円'"},
    "en-US": {"tone": "neutral formal", "date": "MMM D, YYYY",
              "decimal": ".", "currency": "USD, symbol '$'"},
}

def build_prompt(base_tmpl: str, lang: str, doc: str) -> str:
    p = LOCALE_POLICY[lang]
    directives = "\n".join(f"- {k}: {v}" for k, v in p.items())
    return base_tmpl.format(output_lang=lang, policy=directives, document=doc)

정책을 데이터로 분리해두면 언어 추가가 템플릿 수정이 아니라 딕셔너리 한 줄 추가로 끝난다.

언어별 few-shot 고정

형식 준수율은 대상 언어 예시를 한두 개 붙일 때 가장 크게 오른다. 영어 예시만 주면 모델은 형식은 배우되 언어를 영어로 되돌리기 쉽다. 언어별로 같은 구조·다른 언어 예시를 페어링해 고정하라. 예시는 실제 출력과 바이트 단위로 동일해야 하며, 주석이나 말줄임을 넣지 않는다.

출력을 스키마로 검증하고 재시도

프롬프트만으로 100%를 기대하지 말고 후단에서 계약을 강제한다. 스키마 검증에 실패하면 오류를 되먹여 재생성한다.

from pydantic import BaseModel, ValidationError
from typing import Literal

class Result(BaseModel):
    title: str
    summary: str
    sentiment: Literal["pos", "neg", "neu"]

def parse_or_retry(call, prompt, max_retry=2):
    for i in range(max_retry + 1):
        raw = call(prompt)
        try:
            return Result.model_validate_json(raw)
        except ValidationError as e:
            prompt += f"\n\nPrevious output was invalid: {e}. Fix and return JSON only."
    raise RuntimeError("schema validation failed")

검증 계층이 있으면 언어별 실패율을 지표로 뽑아 어떤 로케일에서 제약이 무너지는지 관측할 수 있다.

회귀 테스트로 일관성 지키기

다국어 일관성은 한 번 맞춰도 모델 버전이 바뀌면 다시 흔들린다. 언어별 고정 입력·기대 형식을 테스트로 남겨 CI에서 돌린다. 값의 자연어 내용까지 일치시킬 필요는 없고, 키 집합·타입·언어 판별·포맷 규칙을 단언한다.

import pytest, langid

CASES = ["ko-KR", "ja-JP", "en-US"]

@pytest.mark.parametrize("lang", CASES)
def test_keys_and_language(lang, model_client):
    r = run_pipeline(model_client, lang, SAMPLE_DOC)
    assert set(r.model_dump().keys()) == {"title", "summary", "sentiment"}
    detected, _ = langid.classify(r.summary)
    assert detected == lang.split("-")[0]   # 값 언어가 정책과 일치

비교: 접근별 트레이드오프

접근형식 준수유지보수비용
언어별 템플릿 전체 복제높음나쁨(N배 중복)낮음
영어 지시 + 언어 변수 주입높음좋음낮음
프롬프트만·검증 없음불안정보통낮음
주입 + 스키마 검증 + 재시도매우 높음좋음재시도 시 증가

주의점

몇 가지 함정이 있다. 언어 판별기(langid, fastText)는 짧은 문자열에서 오탐이 잦으므로 최소 길이를 확보하고 임계치를 둔다. RTL 언어(아랍어·히브리어)는 구두점·숫자 방향이 섞이니 정책에 방향 규칙을 추가한다. 재시도는 지연·비용을 늘리므로 상한을 두고 실패는 폴백 언어로 처리한다. 마지막으로 지시문을 영어로 고정하면 입력 문서까지 영어로 오해하는 경우가 있으니, 데이터 블록을 구분자로 명확히 감싸 구조와 내용의 경계를 남겨야 한다.