NVIDIA 모델에서 JSON 스키마가 40초 타임아웃 나는 문제

26년 06월 07일

구조화된 출력이 안 된다

이날은 연속성 원장을 전체 스택에 구현하다가 치명적인 버그를 만난 날이었다.

NVIDIA 모델이 구조화된 출력을 처리하지 못해서 40초마다 타임아웃이 났다.

에피소드 하나에 9분이 걸렸다.

원인을 추적하니 “OpenAI 호환”이라는 말의 함정이었다.

소설 생성 에이전트를 테스트하고 있었다.

근데 모든 LLM 호출이 40초 이상 걸리다가 타임아웃이 났다.

에피소드 하나 만드는 데 9분이 걸렸다.

정상이면 1분이면 되는 작업이다.

원인을 격리하기 위해 세 가지 호출 방식을 비교했다.

첫째, 일반 텍스트로 호출했다.

7.4초. 정상이다.

모델이 느린 게 아니다.

둘째, 구조화된 출력 형식으로 호출했다.

40초 타임아웃. 실패다.

셋째, 도구 호출 방식으로 호출했다.

22초. 느리지만 동작은 한다.

그러니까 모델 자체는 정상이다.

API 키도 정상이다.

엔드포인트도 정상이다.

문제는 구조화된 출력 형식, 특히 JSON 스키마를 보냈을 때만 발생한다.

JSON 스키마의 함정

구조화된 출력은 JSON 스키마라는 형식으로 동작한다.

“응답을 이 스키마에 맞춰서 내놔라”라고 API에 알려주는 거다.

그러면 모델이 스키마에 맞는 JSON을 반환한다.

근데 JSON 스키마의 엄격 모드는 제약 디코딩이라는 기술이 필요하다.

모델이 토큰을 생성할 때 스키마에 맞는 토큰만 생성하도록 강제하는 거다.

이건 추론 엔진 수준의 지원이 필요하다.

OpenAI랑 Gemini는 완벽하게 지원한다.

근데 NVIDIA NIM이나 서드파티 호스팅은 “OpenAI 호환 API”라고 하면서 실제로는 JSON 스키마를 제대로 처리하지 못한다.

무시하거나 멈추거나 한다.

NVIDIA가 정확히 그랬다.

”OpenAI 호환”이라는 말

“OpenAI 호환 API”라는 말은 주의해야 한다.

“요청 형식이 같다”는 뜻이지, “모든 기능을 지원한다”는 뜻이 아니다.

요청 형식은 같은데, 특정 기능은 안 돌아간다.

JSON 스키마가 그렇다.

그러면 어떻게 해야 하나. 가장 이식성 높은 방법은 일반 텍스트로 받고 직접 JSON을 파싱하고 검증하는 거다.

JSON 스키마에 의존하지 않는 거다.

모델이 바뀌어도, 프로바이더가 바뀌어도 동작한다.

코드베이스에 이미 기반이 있었다.

응답에서 JSON 후보를 추출해서 파싱하는 검증 처리기가 있었다.

이걸 쓰면 JSON 스키마 없이도 구조화된 출력을 얻을 수 있다.

이 방식으로 전환하면 프로바이더 독립성도 확보된다.

연속성 원장 구현

이 버그 외에 연속성 원장도 구현했다.

연속성 원장은 소설이 여러 에피소드에 걸쳐 일관되게 유지되도록 하는 시스템이다.

시간 제한 검색

가장 중요한 구성 요소는 시간 제한 검색이다.

검색할 때 현재 에피소드보다 이후의 정보는 제외한다.

“이 캐릭터는 에피소드 20에서 죽는다”는 정보가 에피소드 5에서 검색되면, 스포일러가 된다.

그래서 현재 에피소드 이전의 정보만 검색되도록 필터링한다.

이 검색은 하이브리드다.

벡터 유사도 75%, 텍스트 검색 25%를 합친다.

한국어 텍스트 검색은 약하다.

그래서 벡터가 주력이고 텍스트는 보조다.

검증기

그리고 검증기를 만들었다.

에이전트가 주장하는 증거가 실제 산문에 있는지 확인하는 거다.

정규화랑 슬라이딩 윈도우로 유사도를 계산한다.

60% 이상이면 통과. 의역은 허용하되, 환각은 거부한다.

여기서 중요한 결정이 있다.

강제하지 않는 거다.

검증이 실패하면 변경 사항을 제외하지만 에피소드 커밋을 막지 않는다.

사용자 판단에 맡기는 거다.

기계가 창작을 강제로 막으면 안 된다고 판단했다.

컨텍스트 검색

각 청크에 컨텍스트를 붙여서 임베딩 품질을 올렸다.

컨텍스트 에이전트가 각 청크마다 50~100 토큰짜리 맥락을 생성한다.

이 맥락을 원문 앞에 붙여서 임베딩한다.

그러니까 검색 품질이 올라간다.

그리고 해시로 중복을 감지한다.

이미 인덱싱한 청크는 건너뛴다.

선택적 재인덱싱이다.

데이터베이스 정리

죽은 테이블 7개, 죽은 컬럼 2개, 죽은 함수 2개를 정리했다.

마이그레이션하면서 남은 잔재다.

전부 코드에서 참조가 없고 데이터도 없었다.

안전하게 제거했다.

테이블이 19개에서 12개로 줄었다.

스키마가 깔끔해졌다.

마무리

이날의 핵심 교훈은 “OpenAI 호환이라는 말을 믿지 마라”다.

NVIDIA 모델은 “OpenAI 호환 API”를 제공한다.

근데 JSON 스키마를 안 지원한다.

요청 형식은 같은데, 기능은 다르다.

이걸 모르고 JSON 스키마에 의존하면, 프로바이더를 바꿨을 때 전체가 멈춘다.

이식성이 높은 방식은 일반 텍스트로 받고 직접 파싱하는 거다.

어떤 프로바이더든 동작한다.

“호환”이라는 말에 기대지 말고 내가 검증할 수 있는 방식을 쓰는 게 안전하다.

그리고 연속성 원장의 시간 제한 검색이 중요했다.

소설 생성에서 스포일러를 막는 건 기본이다.

현재 에피소드 이전의 정보만 검색되도록 하는 것만으로, 미래 정보가 새어나가는 걸 막을 수 있다.

단순한 필터 하나가 치명적인 버그를 막는다.