
모델이 마크다운 펜스(Markdown Fence)로 JSON을 감쌀 때 Check.isValidJson이 올바른 JSON임에도 실패하는 문제
요약
Dart 패키지 `llm_eval`의 `Check.isValidJson` 기능이 모델의 마크다운 코드 펜스(Markdown Fence)를 인식하지 못해 유효한 JSON임에도 실패하던 문제를 해결했습니다. 0.3.1 업데이트를 통해 정규식을 사용하여 펜스 내부의 JSON을 추출하도록 개선되었습니다.
핵심 포인트
- LLM 응답이 마크다운 코드 블록으로 감싸질 경우 기존 `jsonDecode`가 실패하는 문제 발생
- llm_eval 0.3.1 버전에서 마크다운 펜스를 인식하는 정규식 로직 추가
- 구조화된 출력(Structured Output) 회귀 테스트의 신뢰성 향상
llm_eval은 LLM 출력을 회귀 테스트 (regression-testing)하기 위한 작은 Dart 패키지입니다. 모델의 응답에 대한 체크를 작성하고, 이를 CI에서 실행하며, passed와 isError라는 두 개의 독립적인 필드를 통해 결과를 확인합니다. Check.isValidJson은 README의 Quick Start에 소개된 두 가지 체크 중 하나이며, 구조화된 출력 (structured-output) 회귀 테스트를 위해 존재합니다.
오늘의 0.3.1 릴리스 전까지, 해당 체크는 모델의 가공되지 않은 출력 (raw model output)에 대해 jsonDecode를 직접 호출했습니다. 모든 주류 채팅 튜닝 모델 (chat-tuned model)이 기본적으로 수행하는 것처럼, 모델이 JSON 답변을 마크다운 코드 펜스 (markdown code fence)로 감쌀 경우, 답변이 정확하고 펜스 내부의 JSON이 유효함에도 불구하고 체크는 실패를 보고했습니다. 테스트 하네스 (harness)가 펜스를 인식하지 못했기 때문입니다.
재현 (The repro)
스텁 (stub) 대신 실제 모델을 사용하여 실행했습니다: 이 패키지의 test/ollama_e2e_test.dart에서 엔드 투 엔드 테스트 (end-to-end tests)에 이미 사용 중인 llama3.2:3b 모델을 로컬에서 ollama serve로 실행했습니다. 프롬프트 (Prompt):
프랑스의 수도는 어디인가요? city 필드를 포함하는 JSON 객체로 답변하되, 코드 블록 형식으로 작성하세요.
응답 (Response):
```json
{
"city": "Paris"
}
```
이것은 프랑스의 수도입니다.
이것은 정답이며, 펜스 내부의 JSON은 유효합니다. 모킹 (mocking) 없이 0.3.0 버전의 패키지를 경로 의존성 (path dependency)으로 사용하여 정확히 동일한 문자열을 입력했습니다:
passed: false
isError: false
detail: not valid JSON: Unexpected character
README의 자체 퀵스타트 예제를 그대로 사용했을 때도 동일한 결과가 나왔습니다:
Check.isValidJson(where: (v) => v is Map && v.containsKey('city'))
대상:
```json
{"city": "Paris"}
```
passed: false.
체크를 시연하기로 되어 있는 README의 예제가 실제 모델의 기본 출력 형식에 대해 작동하지 않았습니다.
왜 isError: false 부분이 중요한가
llm_eval은 passed와 isError를 의도적으로 별개의 필드로 유지합니다. CheckResult에 대한 문서 주석과 README 모두, 이 분리가 존재하는 이유는 고장 난 하네스 (harness)를 모델의 실패로 오해하지 않기 위해서라고 명시하고 있습니다. 예를 들어, 예외를 던지는 (throws) where 콜백은 lib/src/check.dart의 describeError를 거쳐 실패 (fail)가 아닌 에러 결과 (error result)로 반환됩니다.
마크다운 펜스 (fenced-JSON) 케이스는 바로 그 분리된 구조가 잡아내려 했던 시나리오였으나, 이를 놓치고 말았습니다. 하네스 (harness)의 기능이 부족했고, 그 결과 보고된 passed: false, isError: false는 모델이 답을 틀렸음을 의미했습니다. 하지만 실제로는 모델이 정답을 맞혔고, 체크 (check) 과정에서 문제가 발생한 것이었습니다.
실제 엔드포인트(endpoint)를 대상으로 CI에서 이를 실행하는 사용자에게 이것은 일회성 문제가 아닙니다. JSON 전용 응답 모드 (JSON-only response mode)를 강제하지 않는 한, 대부분의 채팅 모델 (chat models)의 기본 동작인 '모델이 답변을 펜스 (fence)로 감싸는 경우'마다 거짓 실패 (false failure)가 발생합니다. 이는 실제 회귀 (regressions)와 하네스 노이즈 (harness noise)를 구분하기 위해 존재하는 패키지에서 일종의 노이즈가 됩니다.
유지 관리자도 이미 비공식적으로 알고 있었다
test/ollama_e2e_test.dart의 79행을 보면, 모델에게 다음과 같이 프롬프트 (prompt)를 전달합니다:
...No prose, no code fences.
이 지침이 테스트 스위트 (test suite)에 포함된 이유는, 저 또한 이미 이 문제에 부딪혔고 모델에게 기본적으로 수행하는 동작을 하지 말라고 요청함으로써 우회 방법을 찾아냈기 때문입니다. 하지만 이 내용은 라이브러리 (library)에 반영되지 않았습니다. 동일한 프롬프트 문구를 독립적으로 발견하여 복사하지 않고 실제 모델에 대해 Check.isValidJson을 사용하는 모든 사용자는, 출력 결과 어디에서도 프롬프트를 수정해야 한다는 점을 가리키지 않는 만성적인 가짜 실패 (spurious failures)를 겪게 됩니다.
수정 사항
lib/src/check.dart, 수정 전:
CheckResult evaluate(String output) {
Object? decoded;
try {
...
수정 후:
static final RegExp _fencedBlock = RegExp(
r'```(?:json)?\s*\n?(.*?)\n?```',
dotAll: true,
);
...
펜스(fence)가 없는 패스트 패스(fast path)는 수정되지 않았습니다. 즉, 가공되지 않은 jsonDecode가 먼저 실행되며, 올바른 비펜스(unfenced) JSON은 정규 표현식(regex) 단계에 도달하지 않습니다. 오직 FormatException이 발생했을 때만, json 태그가 선택적으로 붙어 있고 dotAll 옵션을 통해 펜스가 여러 줄에 걸쳐 있을 수 있는 단일 펜스 블록을 찾아 캡처된 내용을 디코딩하려고 시도합니다.
실제로 새로운 실패 분기(failure branch)가 하나 존재합니다. 펜스와 일치하지만 그 내용이 여전히 파싱되지 않는 경우이며, 이는 e2로 포착됩니다. 이는 기존 경로와 동일한 not valid JSON: 메시지 형태를 반환하므로, 해당 지점에 도달하는 제어 흐름(control flow)은 이전에는 존재하지 않았음에도 불구하고 사용자가 보게 되는 실패 텍스트는 변경되지 않습니다.
폴백(fallback)을 통해 도달한 경로의 detail 필드에는 'parsed after stripping a markdown code fence'라고 기록됩니다. 이번 수정은 펜스로 감싸진 JSON을 조용히 수락하여 입력을 깨끗한 상태로 제시하는 것을 방지합니다. 이 경로의 보고서는 어떤 경로를 통해 값이 디코딩되었는지 기록하므로, 체크가 통과된 이유를 파악할 때 펜스 제거(fence-stripping) 분기를 거쳤음을 확인할 수 있습니다.
테스트 (Tests)
test/check_test.dart는 이제 다음 사항들을 포함합니다:
json태그가 붙은 펜스 블록 통과- 언어 태그가 없는 일반 펜스 블록 통과
- 정확한 재현 형태(repro shape) 통과: 펜스로 감싸진 JSON 뒤에 닫는 펜스 이후의 산문(prose)이 이어지는 경우
where콜백이 원문 문자열이 아닌 펜스 내부에서 디코딩된 값을 받는 것을 확인- 원문 문자열과 펜스 내용 모두 JSON으로 파싱되지 않을 때 실패
- 회귀 방지(regression guard): 펜스가 없는 일반적인 비-JSON 산문은 이전과 동일하게 실패
이번 수정은 폴백 경로를 추가할 뿐, 유효한 JSON의 정의는 건드리지 않습니다. 펜스가 없고 JSON이 포함되지 않은 산문은 여전히 이전과 동일하게 실패합니다.
또한 지루한 방식으로도 확인했습니다: 수정 파일에만 git stash를 적용하고 재현 코드를 다시 실행했을 때 passed: false가 뜨는 기존 버그를 확인했습니다. 스태시(stash)를 복구하고 다시 실행하니 passed: true가 되었습니다. 전체 라운드 트립(round trip)을 완료했으며, 디프(diff)의 편차도 없었습니다.
유사한 체크를 작성할 때의 의미
Check.isValidJson은 JSON 명세(spec)를 기준으로 작성되었습니다. 즉, 이 문자열이 JSON으로 파싱되는지를 확인합니다. 하지만 모델이 JSON을 출력할 때, 제약(constrain)을 걸지 않았을 경우 실제로 어떻게 응답하는지—즉, 마크다운 형식의 산문(prose) 내부에 펜스 블록(fenced block)이 포함된 형태—를 기준으로 작성되지는 않았습니다.
"모델의 출력(the model's output)\
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기