내 라이브러리가 모든 힌디어 사용자에게 모델이 고장 났다고 알려준 이유
요약
LLM 출력 정제 라이브러리 llmclean의 개발자가 라틴 문구 전용 종결자 리스트를 사용하여 힌디어와 중국어 문장을 모두 잘린 것으로 오판한 사례를 다룹니다. 개발자의 편향이 테스트 코드에 어떻게 반영되어 사각지대를 만드는지 경고합니다.
핵심 포인트
- 라틴 문구 중심의 종결자 리스트는 비영어권 언어에서 오류를 유발함
- 테스트 코드는 개발자의 인지적 편향과 가정을 그대로 물려받음
- 유닛 테스트만으로는 개발자가 생각하지 못한 사각지대를 발견하기 어려움
- 다국어 지원 시 각 언어 특유의 문장 부호를 반드시 고려해야 함
저는 가공되지 않은 LLM (Large Language Model) 출력을 정리하기 위한 의존성 없는 작은 라이브러리인 llmclean을 관리하고 있습니다. 0.4.0 버전에서 모델의 출력이 생각 도중에 잘렸는지 여부를 알려주는 함수를 추가했습니다.
def looks_truncated(text):
return text.rstrip()[-1] not in ".!?…\"')]}"
만약 마지막 문자가 문장 종결자 (sentence terminator)가 아니라면, 해당 출력은 토큰 제한 (token limit)에 의해 잘렸을 가능성이 높습니다. 단순하고 명확하며, 제 테스트에서는 아주 잘 작동했습니다.
그 후, 저는 5개의 로컬 모델로부터 300개의 출력을 생성하고 모든 출력에 대해 탐지기를 실행했습니다. 결과의 두 줄은 다음과 같았습니다.
multilingual_hi 11/11 flagged truncated (100%)
multilingual_zh 12/12 flagged truncated (100%)
모든 힌디어와 중국어 생성 결과가 그랬습니다. 실제로 잘린 것은 단 하나도 없었습니다.
종결자 리스트를 다시 확인해 보세요
".!?…\"')]}" 이 리스트는 모두 라틴 문장 부호 (Latin punctuation)입니다. 전부 다요.
힌디어 문장은 마침표 (full stop)로 끝나지 않습니다. 그들은 danda — ।, U+0964 — 로 끝납니다. 중국어는 표의적 마침표 (ideographic full stop)인 。, U+3002로 끝납니다. 이 중 어느 것도 해당 문자열에 포함되어 있지 않습니다. 제가 영어를 생각하며 작성했기 때문에, 제가 작성했을 법한 그 어떤 문자열에도 포함되어 있지 않았습니다.
그래서 제 라이브러리는 वर्षा तब होती है। — 완벽하고 문장 부호가 올바르게 찍힌 힌디어 문장 — 을 보고 출력이 잘렸다고 보고했습니다. 그리고 모든 중국어 문장에 대해서도 똑같이 행동했습니다. 만약 여러분이 인도 계열 언어 (Indic-language) 출력에 대해 llmclean을 실행하고 있었다면, 이 라이브러리는 여러분의 모델이 고장 났다고 끊임없이 잘못된 정보를 알려주고 있었던 것입니다.
저는 인도인입니다. 저는 힌디어를 처리하는 것들을 만듭니다. 그런데도 저는 라틴 문구 전용 종결자 세트를 인지하지 못한 채 배포했습니다.
실제로 저를 괴롭히는 부분
저에게는 194개의 테스트가 있습니다. 그중 어느 것도 이것을 잡아내지 못했으며, 같은 종류의 테스트를 더 추가했더라도 잡아내지 못했을 것입니다.
제가 그 테스트를 어떻게 작성했을지 생각해 보세요:
def test_truncation():
assert looks_truncated("The encoder maps input") is True
assert looks_truncated("The encoder maps input.") is False
저는 함수를 영어로 작성했기 때문에, 테스트도 영어로 작성했을 것입니다. 테스트는 코드의 사각지대를 그대로 물려받습니다. 왜냐하면 동일한 사람이, 동일한 자리에서, 동일한 가정을 품고 두 가지를 모두 작성했기 때문입니다. 테스트는 당신이 생각한 케이스만을 확인할 수 있습니다. 이것은 제가 생각하지 못한 케이스였으며, 이는 정확히 유닛 테스트 (unit tests)가 가장 취약한 범주입니다.
해결하는 데는 2분이 걸렸습니다:
_TERMINAL_PUNCT = (
".!?…\"')]}" # latin
"।॥" # devanagari danda, double danda
...```
하지만 이를 찾아내는 데는 완전히 다른 무언가를 구축해야 했습니다.
## 그것을 찾아낸 것
유닛 테스트 (Unit tests)는 당신이 상상한 케이스를 확인합니다. 상상하지 못한 케이스를 찾으려면 당신이 직접 작성하지 않은 입력값이 필요합니다. 그래서 저는 실제 운영 환경과 유사한 형태의 코퍼스 (corpus)를 생성하는 하네스 (harness)를 구축했습니다.
**5개의 모델** — Llama 3.1, Gemma 4, Qwen 2.5, DeepSeek-R1, Mistral.
**15개의 작업 유형 (task types)** — 평면 JSON (flat JSON), 중첩 JSON (nested JSON), 긴 컨텍스트에서의 JSON, 채팅 답변, 요약, 마크다운 문서, 코드, 분류, 힌디어, 중국어, 힌글리시 (Hinglish) 코드 스위칭, 표.
다국어 작업들이 핵심이었으며, llmclean의 사용자가 인도인에 편중되어 있었기에 이들을 포함했습니다.
**4가지 디코딩 조건 (decode conditions)** — 실제 절단 (truncation)이 발생하여 진양성 (true positives)과 위양성 (false positives)을 구분할 수 있도록 의도적인 24-토큰 제한을 포함했습니다.
그다음 — 그리고 이 부분이 사람들이 생략하는 부분이라고 생각합니다 — 저는 모든 생성물을 8가지 **전송 변이 (transport mutations)**를 통해 다시 재생했습니다. 왜냐하면 실제 운영 트래픽은 모델의 출력 그 자체가 아니기 때문입니다. 그것은 모델의 출력이 네트워크, 게이트웨이, 그리고 누군가의 인코딩 버그를 거친 *이후*의 결과물입니다:
- Windows 클라이언트에서 발생하는 CRLF 줄 바꿈 (llmclean 0.2.0 버전에서 이로 인한 실제 버그가 있었습니다)
- 게이트웨이에 의해 앞에 붙은 BOM
- 연결이 끊겨 단어 중간에서 잘린 스트림
- HTML 이스케이프 처리된 `&` 및 `<`
- 어딘가에서 latin-1로 디코딩된 UTF-8 바이트가 생성한 모지바케 (mojibake, 글자 깨짐)
- 스트리밍 재조립 과정에서 중복된 청크 경계 (chunk boundaries)
300번의 생성(generations) × 8번의 변이(mutations) × 14개의 함수 = **30,128번의 호출**. 충돌(crash)은 0건이었습니다. 그리고 제가 영어로 아무리 테스트 코드를 작성해도 결코 드러나지 않았을, 아주 명확한 버그 하나가 발견되었습니다.
수정 후: 잘못된 절단(false truncation) 플래그가 일반 생성(generations)의 66%에서 27%로 감소한 반면, 의도적으로 제한된(deliberately-capped) 생성은 85%를 유지했습니다. 남은 27%를 수동으로 확인해 보았는데, 이는 단어 중간이나 코드 펜스(code-fence) 중간에서 실제로 끝나는 진짜 출력물이었습니다. 중국어의 경우 12/12에서 0/12로 줄었습니다.
## 내 코드에서 발견한 또 다른 문제
이 작업을 하는 동안 누군가 타당한 질문을 던졌습니다. 라이브러리가 실패했을 때 로그(log)를 남기느냐는 것이었습니다.
llmclean에는 엄격한 규칙이 있습니다. 어떤 공개 함수(public function)도 예외를 발생시키지(raise) 않는다는 것입니다. 무언가 잘못되면, 입력값을 변경 없이 그대로 돌려받게 됩니다. 이를 위해 13개의 `except Exception` 블록이 존재했습니다. 그리고 그 블록들은 모두 다음과 같은 형태였습니다:
except Exception:
return original
로그도 없고, 경고(warning)도 없고, 아무것도 없었습니다. 저는 `strip_markdown`에 버그를 주입하고 `logging.basicConfig(level=DEBUG)`를 설정하여 실행해 보았습니다:
returned: '# Title'
그게 전부였습니다. 함수는 조용히 아무것도 하지 않았고, 아무에게도 알리지 않았습니다.
이 상황은 나쁜 의미로 웃깁니다. 왜냐하면 제가 같은 주에 작성한 `degeneracy.py`의 독스트링(docstring)에는 다음과 같이 적혀 있었기 때문입니다: _"오버플로를 조용히 정리하는 클리너는 모델의 손상을 숨긴다."_ 저는 모델 출력에 대해 그런 경고를 적어놓고는, 정작 제 자신의 오류에 대해서는 정확히 똑같은 죄를 저질렀습니다.
이제 모든 폴백(fallback)은 `NullHandler`와 함께 `logging.getLogger("llmclean")`로 로그를 남기므로, 사용자가 요청하지 않는 한 조용히 유지됩니다. 주입했던 버그를 다시 실행하면 다음과 같이 나타납니다:
WARNING llmclean: llmclean.strip_markdown returned its input unchanged
after AttributeError: 'NoneType' object has no attribute 'sub'
Traceback (most recent call last): ...
여전히 `'# Title'`을 반환합니다. 계약(contract)은 유지됩니다. 하지만 이제는 확인할 수 있습니다.
이를 위한 테스트를 작성하면서 한 가지 구멍을 더 발견했습니다. 만약 *로그 핸들러(log handler)*가 예외를 발생시키면, 그 예외가 탈출하여 '절대 예외를 발생시키지 않는다'는 보장을 깨뜨린다는 점이었습니다. 즉, 로그 도우미들이 스스로의 오류를 삼켜버리는(swallow) 구조였습니다. 방어적인 코드를 위한 방어적인 코드였던 셈입니다.
## 0.4.0의 나머지 부분
**퇴화 탐지 (Degeneration detection).** 반복은 세 가지 수준에서 발생하며, 제 반복 제거기 (repetition trimmer)는 그중 하나만 처리했습니다:
| 수준 (level) | 예시 (example) | 이전 (before) |
| --- | --- | --- |
| 구 (phrase) | `"So this is 8 infinity. So this is 8 infinity." ` | 처리됨 (handled) |
| ... | |
마지막 사례는 아주 고약합니다. 그것은 고유 단어 비율 (distinct-word ratio)에서는 완벽한 1.0을 기록하고, 인접 중복 (adjacent duplicates)에서는 0.0을 기록하는데, 왜냐하면 그것이 _하나의 고유한 단어_이기 때문입니다. 모든 단어 수준 지표 (word-level metric)는 문제가 없다고 말합니다. 오직 단어 내부의 검사만이 이를 찾아낼 수 있습니다.
`degeneracy_score()`는 이 모든 것을 보고하며, 의도적으로 아무것도 수정하지 않습니다. 철학은 보고(report) → 재시도(retry) → 복구(repair) 순서입니다. 즉, 모델이 퇴화 (degenerating)하고 있다면, 재시도를 하는 것이 복구된 텍스트의 품질 저하보다 대개 비용이 적게 들며, 조용히 텍스트를 정리하는 것은 고장 난 모델에 반창고를 붙여서 출시하는 것과 같다는 의미입니다.
**세 개의 정문 (Three front doors).** 누군가가 llmclean에는 15개의 함수가 있지만 시작할 수 있는 명확한 지점이 없다고 지적했습니다. `pd.read_csv()`는 호출 한 번이면 되는데, 왜 이것은 그렇지 않느냐는 것이었죠. 타당한 지적입니다. 가장 흔한 작업은 예전에는 네 줄의 코드와 try/except 구문이 필요했습니다:
result = enforce_json(strip_fences(raw))
try:
data = json.loads(result)
...
이제는 다음과 같습니다:
data = llmclean.load_json(raw)
여기에 산문 (prose)을 위한 `clean_text(raw)`와 퇴화 보고 (degeneration report)를 위한 `check(raw)`가 추가되었습니다. 나머지 12개의 함수는 파이프라인 (pipeline)을 직접 제어하고 싶을 때 여전히 사용할 수 있습니다.
## 제가 여러분에게 전하고 싶은 교훈
텍스트를 처리하는 무언가를 만든다면, 자신도 모르게 인코딩해 버린 가정을 찾아내십시오. 저의 가정은 문장이 마침표로 끝난다는 것이었습니다. 여러분의 가정은 다른 무엇일 것입니다.
일반적인 버전으로 말하자면: **여러분의 테스트는 버그를 작성한 것과 동일한 뇌에 의해 작성되므로**, 테스트 또한 그 버그의 사각지대를 물려받습니다. 유일하게 신뢰할 수 있는 탈출구는 여러분이 직접 작성하지 않은 입력값입니다. 데이터가 있다면 실제 트래픽 (real traffic)을, 없다면 생성된 트래픽 (generated traffic)을 사용하십시오. 생성기 (generator)를 만드는 데 보낸 한 오후의 시간이, 194개의 수동 작성 테스트가 구조적으로 찾아낼 수 없었던 무언가를 찾아냈습니다.
llmclean 0.4.0 버전은 [PyPI](https://pypi.org/project/llmclean/)와 [GitHub](https://github.com/Tushar-9802/llmclean)에서 확인할 수 있습니다. 의존성(dependencies)이 전혀 없으며, 여전히 순수 표준 라이브러리 (standard library)만 사용합니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기