당신의 LLM은 \(x\)를 쓰지만, Markdown 파서는 $x$를 원합니다
요약
LLM이 생성하는 TeX 스타일의 수학식 구분자(\( \), \[ \])와 일반적인 Markdown 파서의 달러 기호($) 방식 간의 불일치 문제를 해결하는 과정을 다룹니다. 정규 표현식을 이용한 전처리 대신 토크나이저를 직접 확장하여 문제를 해결한 사례와 관련 패키지를 소개합니다.
핵심 포인트
- LLM은 TeX 스타일 구분자를 선호하지만 대부분의 Markdown 파서는 달러 기호를 요구함
- 정규 표현식을 이용한 단순 치환은 코드 블록이나 TeX 명령어를 오인식할 위험이 큼
- 가장 안정적인 해결책은 파싱 전 단계에서 토크나이저에 새로운 구분자를 직접 학습시키는 것
- 관련 오픈소스 패키지 micromark-extension-math-extended 및 remark-math-extended 출시
요약 (TL;DR) — LLM은 \(...\)와 \[...\]를 선호합니다. 대부분의 Markdown 수학 플러그인은 $...$와 $$...$$만 이해합니다. 저는 정규 표현식 (regex)으로 이를 연결하려 시도했으나 흥미로운 방식으로 실패했고, 결국 토크나이저 (tokenizer)를 가르치는 방향으로 결론을 내렸습니다. 그 결과 두 개의 패키지가 탄생했습니다: micromark-extension-math-extended와 remark-math-extended.
설정 (The setup)
때때로 수학식을 렌더링할 때 어려운 점은 방정식 자체가 아닙니다. 방정식이 어디서 시작하고 어디서 끝나는지에 대해 합의하는 것입니다.
저는 일부 OpenAI 모델로부터 생성된 Markdown을 렌더링 파이프라인 (rendering pipeline)으로 전달하고 있었는데, 출력 결과가 계속 다음과 같이 나왔습니다:
양력 계수는 \(C_L\)입니다.
\[
...
여기에는 아무런 문제가 없습니다. 이것은 전형적인 TeX 방식입니다:
\( ... \)→ 인라인 수학 (inline math)\[ ... \]→ 디스플레이 수학 (display math)
하지만 제 툴체인 (toolchain)에 있는 모든 Markdown 수학 패키지는 달러 기호를 원했습니다:
양력 계수는 $C_L$입니다.
$$
...
단 두 글자의 차이입니다. 제 인생의 몇 주를 허비했네요. 시작해 봅시다.
시도 #1: "그냥 구분자만 바꾸면 되잖아" 🙃
가장 뻔한 방법은 모델의 출력이 파서 (parser)에 도달하기 전에 전처리 (preprocess)하는 것입니다:
\(x\) -> $x$
\[x\] -> $$x$$
정규 표현식 (regex)은 정상적인 경로 (happy path)를 처리합니다. 하지만 실제 Markdown은 정상적인 경로가 아닙니다.
이제 변환기 (converter)는 다음과 같은 곳 안에 있는 구분자에는 손을 대지 않아야 합니다:
- 인라인 및 펜스 코드 (inline and fenced code)
- 이스케이프 처리된 (escaped) Markdown 문장 부호
- 이미 달러 기호로 구분된 수학식
- 잘린 모델 출력
- 그 자체로 백슬래시 (backslash)가 가득한 TeX 명령
마지막 항목이 아주 뼈아픕니다:
\begin{cases}
x \[1em]
y
...
\[1em]은 선택적 간격이 포함된 TeX 줄 바꿈입니다. 저 [는 디스플레이 방정식의 시작이 아닙니다. 당신의 정규 표현식은 이를 알지 못합니다. 당신의 정규 표현식은 원래 아무것도 알지 못합니다.
그리고 정말로 위험한 경우가 있습니다:
\[
닫히지 않음
...
만약 모델이 닫는 기호인 \]를 잊어버린다면 — 스트리밍 (streaming) 중에는 끊임없이 발생하는 일입니다 — 단순한 변환기는 문서의 나머지 부분을 하나의 거대한 방정식으로 삼켜버리게 됩니다.
그 모든 과정을 처리했다면, 축하드립니다. 당신은 전처리기 (preprocessor)를 작성한 것이 아닙니다. 두 번째 Markdown 파서 (parser)를 작성한 것이며, 이제 당신은 두 개의 파서를 유지 관리해야 합니다.
시도 #2: 토크나이저 (tokenizer)에게 가르치기
따라서 파싱(parsing)을 하기 전에 입력을 다시 쓰는 대신, TeX 스타일의 구분자 (delimiters)를 micromark 토크나이저 (tokenizer)에 직접 추가했습니다. 한 번 제대로 파싱하면, 위에서 언급한 모든 문제들이 더 이상 당신의 문제가 되지 않습니다.
그 결과 두 개의 패키지가 만들어졌습니다.
micromark-extension-math-extended
micromark을 직접 사용하는 프로젝트를 위한 저수준 (low-level) 패키지입니다.
npm install micromark-extension-math-extended
import {micromark} from 'micromark'
import {math, mathHtml} from 'micromark-extension-math-extended'
...
세 가지 형식 모두 작동하며, 예상하는 의미를 그대로 유지합니다:
| 구문 (Syntax) | 렌더링 결과 |
|---|---|
$C_L$ | 인라인 (inline) |
| ... |
remark-math-extended
remark / unified를 위한 고수준 (higher-level) 패키지입니다. remark-math를 바로 대체하여 사용할 수 있습니다:
npm install remark-math-extended
import rehypeKatex from 'rehype-katex'
import rehypeStringify from 'rehype-stringify'
import remarkMath from 'remark-math-extended'
...
스트리밍 (streaming) 문제, 구체적으로
이 부분이 제가 가장 신경 쓰는 부분이라 별도의 제목을 붙였습니다.
규칙: \[는 반드시 일치하는 \]가 있어야 합니다. 일치하는 닫는 기호가 없으면, 파서는 문서의 나머지 부분을 삼켜버리는 대신 일반적인 Markdown으로 되돌아갑니다 (fallback).
LLM으로부터 토큰 단위 (token-by-token)로 렌더링하는 경우, 기술적으로 모든 프레임 (frame)은 형식이 잘못된 입력입니다. 절반만 도착한 응답 때문에 UI가 순식간에 하나의 거대한 KaTeX 블록으로 폭발했다가 1초 뒤에 다시 원래대로 돌아오는 현상이 발생해서는 안 됩니다.
또한 토크나이저 (tokenizer)는 실제 중첩된 시작 기호와 \[1em] 같은 정당한 TeX를 구분합니다.
이 두 가지 동작이 제가 정규 표현식 (regex) 수준이 아닌 파서 (parser) 수준에서 접근한 결정적인 이유입니다.
당신이 알아야 할 트레이드오프 (tradeoff) ⚠️
의도적인 불일치(incompatibility)가 하나 있습니다: 표준 CommonMark에서는 백슬래시()가 문장 부호를 이스케이프(escape) 처리하므로, \(와 \[는 리터럴(literal) (와 [를 의미합니다. TeX 스타일의 구분자(delimiters)를 활성화하면 이 동작이 변경됩니다.
원래의 동작을 되돌려야 하는 경우:
math({backslashDelimiters: false})
또는 remark를 사용하는 경우:
unified().use(remarkMath, {
backslashDelimiters: false
})
달러($) 구분자 수학식은 어떤 경우에도 계속 작동합니다.
알려진 한 가지 제한 사항
remark-math-extended는 기존의 mdast-util-math 트리와 직렬화기(serializer)를 재사용합니다. 수학 값과 그 인라인(inline)/디스플레이(display) 의미는 라운드 트립(round trip) 과정에서 유지되지만, 직렬화 과정에서 구분자가 다시 달러 기호로 정규화(normalize)됩니다:
\(x\) -> $x$
\[x\] -> $$x$$
대부분의 렌더링 파이프라인(rendering pipelines)에서는 문제없습니다. 하지만 바이트 단위로 구분자를 그대로 보존해야 한다면 문제가 될 수 있습니다. 만약 해당되는 경우 이슈(issue)를 생성해 주세요. 얼마나 흔한 사례인지 알고 싶습니다.
링크
LLM이 생성한 Markdown, 과학적 글쓰기, 또는 Markdown과 TeX이 혼합된 작업을 하신다면: 어떤 엣지 케이스(edge cases)를 겪으셨나요? 사례들을 수집하고 있습니다. \[1em] 사례를 찾는 데 부끄러울 정도로 오랜 시간이 걸렸는데, 이것이 마지막 사례는 아닐 것이라 확신합니다.
이 내용이 또 다른 구분자 변환 파서를 유지 관리해야 하는 수고를 덜어주었다면, 커피 한 잔을 후원해 주세요 ☕
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기