LLM 라이브러리에 em-dash 제거 기능을 추가할 뻔했습니다. 그 후 로컬 모델이 실제로 em-dash를 생성하는지 테스트해 보았습니다.
요약
LLM 출력 노이즈 제거 라이브러리인 llmclean의 업데이트 과정과 로컬 모델의 출력 특성을 분석한 글입니다. 실험 결과, 로컬 모델은 클라우드 모델과 달리 em-dash나 스마트 따옴표 같은 타이포그래피 난잡함을 거의 생성하지 않음을 확인했습니다.
핵심 포인트
- llmclean v0.3.0은 추론 블록 및 특수 문자 제거 기능을 포함함
- 로컬 7-8B 모델은 클라우드 모델과 달리 특수 문장 부호 생성 빈도가 매우 낮음
- 타이포그래피 정제 기능은 주로 클라우드 모델의 출력을 처리하는 데 유용함
- 실험을 통해 라이브러리의 기능 범위를 클라우드 출력값 정제로 한정함
llmclean은 제가 관리하는, 가공되지 않은 LLM 출력의 노이즈를 제거하기 위한 의존성 없는(zero-dependency) 아주 작은 라이브러리입니다. v0.2.0은 "실제 운영 트래픽이 저에게 가르쳐준 것들"을 담은 릴리스였습니다. 모든 수정 사항은 제가 운영 중인 파이프라인 중 하나에서 실제로 발생한 오류로부터 비롯되었습니다.
0.3.0은 성격이 조금 다른 릴리스입니다. 이번에는 사람들이 계속 불평하며 수동으로 재구현하고 있는 기능들을 바탕으로, 꼭 필요하다고 확신하는 기능 목록을 작성했습니다. <think> 추론 블록 제거, em-dash(em-dash) 및 스마트 따옴표(smart quotes) 제거, 제로 너비 문자(zero-width characters) 제거, 텍스트 음성 변환(text-to-speech)을 위한 마크다운(markdown) 평탄화 등이 포함되었습니다.
코드를 작성하기 전에, 저는 처음부터 했어야 했던 일을 했습니다. 바로 제가 관심을 두고 있는 모델들이 실제로 그런 난잡한 결과물을 생성하는지 확인하는 것이었습니다. 저는 Llama 3.1, Gemma 4, Qwen 2.5, DeepSeek-R1, Mistral 등 모두 7–8B instruct 모델인 5개의 로컬 모델을 대상으로 8개의 생성 프롬프트를 실행하여 결과물을 측정했습니다. 총 40번의 생성과 각각 한 번의 진단 과정을 거쳤습니다.
제 가설 중 세 가지가 틀렸습니다.
1. 로컬 모델은 타이포그래피 난잡함을 거의 생성하지 않습니다
em-dash 문제는 실제로 존재하는 현상입니다. 이 문제가 꽤 심각해져서 OpenAI는 ChatGPT에서 em-dash를 억제하는 설정을 출시하기도 했습니다. 화려한 문장 부호를 ASCII로 교체하는 일만 전문으로 하는 독립적인 라이브러리들도 존재합니다. 그래서 저는 이것이 많이 나타날 것이라고 가정했습니다.
로컬 모델로부터 얻은 40번의 생성 결과 전체에서, 저는 스마트 따옴표(smart quotes), 말줄임표(ellipsis) 문자, 줄 바꿈 없는 공백(non-breaking spaces), 합자(ligatures), 제로 너비 문자(zero-width characters)를 단 하나도 발견하지 못했습니다. 심지어 모델에게 누군가가 "hello"라고 말하는 것을 인용하고, 강조를 위해 대시(dash)를 사용하며, 말줄임표로 말을 흐리도록 명시적으로 요청하는 프롬프트도 작성해 보았습니다. 모델은 일반 따옴표와 세 개의 실제 점 ...을 반환했을 뿐, …를 사용하지 않았습니다.
모든 사람이 정제 코드를 작성하는 그 타이포그래피 난잡함은, 제가 측정할 수 있는 한, 프런티어 클라우드 모델(frontier cloud-model)의 특징입니다. ChatGPT, Claude, Gemini는 이를 생성합니다. 하지만 여러분의 노트북에서 실행되는 7B instruct 모델은 대부분 그렇지 않습니다.
그 기능이 쓸모없다는 뜻은 아닙니다. 사람들은 클라우드 출력값을 파이프라인에 끊임없이 붙여넣으며, 바로 그 지점에서 이런 문제들이 발생하기 때문입니다. 하지만 이 발견은 제가 기능을 구축하고 테스트하는 방식을 바꾸어 놓았습니다. normalize_typography와 strip_invisibles는 독스트링(docstrings)과 테스트 코드 내에서 '붙여넣은 클라우드 출력값(pasted cloud output)'을 위한 도구로 범위를 한정하였고, ChatGPT 출력값과 유사하게 구성된 합성 피스처(synthetic fixtures)를 대상으로 테스트되었습니다. 제 로컬 모델들을 대상으로 테스트하지 않은 이유는, 제 로컬 모델들은 그러한 입력값을 생성할 수 없기 때문입니다.
2. 전각 문장 부호(fullwidth-punctuation) 아이디어는 거꾸로 되어 있었습니다
저는 스스로 메모해 두기를, 중국어 데이터 비중이 높은 Qwen은 전각 문장 부호(fullwidth punctuation) — ,:;() — 를 출력할 것이며, JSON 내부에서 이를 정규화(normalize)해야 할 것이라고 생각했습니다. 라이브러리가 다루지 못하는 완전히 새로운 유형의 정리 작업이었죠.
실제로 Qwen(및 다른 모델들)에게 중국어 텍스트로 프롬프트를 입력했을 때 다음과 같은 일이 일어났습니다. 중국어 콘텐츠(content) 자체는 문제없이 전달되었으며, 완전히 정상적인 ASCII : 및 " 구조를 가진 JSON 문자열 값 안에 위치했습니다. 전각 문장 부호는 제가 중국어 산문(prose) 을 요청했을 때 — 北京是中国的首都,拥有丰富的历史文化遗产 — 에만 나타났는데, 여기서 ,와 。는 노이즈가 아니라 올바른 문장 부호였습니다.
따라서 전각 문장 부호 정규화는 JSON 복구(JSON-repair) 문제가 전혀 아닙니다. 그것은 산문 정규화(prose-normalization) 문제이며, 매우 니치(niche)한 문제입니다. 결과적으로 이는 JSON 전략이 아니라, normalize_typography 내에서 기본적으로 꺼져 있고 선택적으로 사용할 수 있는(opt-in) 카테고리로 분류되었습니다. 전각 문장 부호가 JSON 파싱을 깨뜨리는 합성 사례(synthetic case)가 있느냐고요? enforce_json에 그러한 공백이 있긴 하지만, 제가 가진 어떤 모델도 실제로 그것을 출력하지 않으므로 저는 그에 맞춰 구축하지 않았습니다.
3. Ollama에서는 <think> 태그가 절대 유출되지 않습니다
이 부분이 저를 잘못된 입력값을 대상으로 기능을 구축하게 만들 뻔한 결정적인 요인이었습니다. DeepSeek-R1은 추론 모델(reasoning model)입니다. 이 모델은 답변하기 전에 <think>...</think> 블록 안에서 생각을 합니다. 당연한 정리 작업은 해당 블록을 제거(strip)하는 것입니다.
하지만 Ollama를 통해 DeepSeek-R1을 실행하고 응답을 확인했을 때는 태그가 없었습니다. 텍스트에서 추론(reasoning) 과정이 그냥 사라져 있었습니다. Ollama(현재 버전들)는 서버 측에서 <think> 블록을 파싱하여 네이티브 API와 OpenAI 호환 API 모두에서 별도의 thinking 필드로 전달합니다. Ollama를 사용하는 소비자는 인라인(inline)으로 태그를 절대 볼 수 없으므로, 이들에게 strip_reasoning_trace는 아무런 동작도 하지 않는(no-op) 함수가 됩니다.
그렇다고 태그가 가짜인 것은 아닙니다. llama.cpp를 직접 사용하거나, --reasoning-parser를 전달하지 않은 vLLM, 가공되지 않은 transformers, LM Studio, 그리고 대부분의 호스팅 애그리게이터(hosted aggregators)에서는 태그가 노출됩니다. 그래서 저는 다른 방식으로 제거(stripper) 기능을 검증했습니다. Ollama의 thinking 필드에서 실제 DeepSeek-R1 추론 흔적(reasoning trace)을 캡처한 다음, 다른 백엔드들이 방출하는 인라인 <think>...</think> 형식으로 다시 감싸서, 해당 함수가 답변을 정확하게 복구하는지 확인했습니다. 여기에는 시작 태그가 채팅 템플릿(chat template)에 존재하고 마지막 </think>만 돌아오는 DeepSeek 특유의 기이한 점(quirk)도 포함되었습니다.
출시된 기능
실험 결과에 따라 범위를 한정한, 모두 순수 표준 라이브러리(pure standard library)로 구성된 5개의 새로운 함수입니다:
from llmclean import strip_reasoning_trace, strip_preamble
from llmclean import strip_invisibles, normalize_typography, strip_markdown
...
strip_markdown과 코드 펜스(fence) 처리 기능은 실제 로컬 캡처 데이터를 통해 검증되었습니다. 마크다운(markdown)은 모든 모델이 끊임없이 방출하는 유일한 것이기 때문입니다. "헤더와 불렛 포인트를 사용하여 설명해줘"라는 모든 요청, 모든 코드 답변, 모든 테이블에서 마크다운이 나타났습니다.
이번 릴리스에는 sweep(전수 조사)과는 무관한 정확성 수정 사항도 포함되어 있습니다. enforce_json에 있던 기존의 Python 리터럴(Python-literal) 복구 방식은 True/False/None을 맹목적으로 찾아 바꾸는 방식이었습니다. 이는 {"note": "set the flag to True"}가 {"note": "set the flag to true"}로 출력되는 결과를 초래했습니다. 즉, 문자열 값(string values) 내부의 단어뿐만 아니라 문자열 키(string keys) 내부의 단어까지 손상시켰습니다. 정규 표현식(Regex)으로는 단독으로 쓰인 True 토큰과 따옴표 안의 True라는 글자를 구분할 수 없습니다. 수정된 방식은 문자열 내부에 있는지 여부를 추적하며, 문자열 외부의 리터럴만 다시 쓰는 단일 패스(single pass) 방식입니다.
실제 교훈
저는 거의 직업적으로 정리(cleanup) 코드를 작성하는 사람임에도 불구하고, 실제 모델 출력 대신 상상 속의 모델 출력 버전에 맞춰 세 가지 기능을 거의 구축할 뻔했습니다. sweep에는 오후 한나절이 걸렸습니다. 이 과정은 한 기능의 전제를 무너뜨렸고, 다른 기능은 선택 사항(opt-in)으로 격하시켰으며, 세 번째 기능은 범위를 재조정했습니다. 그리고 결과적으로 저는 각 함수가 모든 곳에서 도움이 된다고 암시하는 대신, 각 함수가 실제로 언제 도움이 되는지를 문서화할 수 있게 되었습니다.
만약 LLM 출력을 후처리(post-processing)하고 있다면, 저렴한 실험을 한 번 해볼 가치가 있습니다. 실제로 배포 중인 모델들을 대상으로 몇 가지 프롬프트를 실행해 보고, 문자 그대로 무엇이 출력되는지 살펴보는 것입니다. 여러분이 정리하고 있는 혼란은 여러분이 생각하는 것과는 다른 혼란일 수도 있습니다.
llmclean 0.3.0은 PyPI와 GitHub에서 확인할 수 있습니다. 8개의 함수, 의존성(dependencies) 제로, 여전히 머릿속에 다 들어올 만큼 가볍습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기