JSON 모드로는 부족한 이유: Structured Outputs strict 스키마로 LLM 형식 실패 없애기
- JSON 모드는 문법적으로 올바른 JSON만 보장한다. 필드 이름·타입·허용 값은 여전히 모델에 달려 있어, 복잡한 문서는 6개 중 2개만 검증을 통과했다.
- 거부된 4건을 열어 보니 3건은 모델이 아니라 검증기의 결함이었다. JSON에는 정수와 실수의 구분이 없는데, 검증기가
14.0을 "정수가 아니다"로 거부하고 있었다. - 검증 코드(Pydantic)에서 JSON 스키마를 생성해 strict로 강제하자 같은 모델에서 6개 중 5개, 더 강한 모델로 바꾸자 6개 모두 통과했다. 재시도가 사라져 모델 호출도 10회에서 6회로 줄었다(모델 교체 포함).
- 스키마가 없애는 것은 구조 오류다. 필드 사이의 관계 같은 의미 규칙 위반은 가끔 남으므로 받은 뒤의 검증은 그대로 두고, 응답이 잘렸거나 거부된 경우는 형식 오류와 따로 알린다.
자동매매 시스템이 LLM에게 매매 전략을 JSON 문서로 받는다. 문서에는 진입·청산 조건, 손절, 포지션 크기, 최적화할 파라미터가 들어가고, 스키마 정의가 40개가 넘는다. LLM은 OpenAI의 gpt-5-mini와 gpt-5.6-terra, 받은 문서의 검증은 Python의 Pydantic 클래스가 한다. 이 글에서 "모델"은 LLM을, "검증 코드"는 Pydantic 쪽을 가리킨다.
- JSON 모드:
response_format: {"type": "json_object"}. 출력이 JSON으로 파싱되는 것만 보장한다. - 최적화 축: 문서가 "이 파라미터는 이 범위에서 바꿔 보며 최적화하라"고 선언한 값. 정수 축과 실수 축이 있다.
- Structured Outputs(strict):
{"type": "json_schema", "strict": true}로 스키마를 넘기면 모델이 생성 단계에서 그 스키마를 벗어나지 못한다.
1.JSON 모드는 무엇을 보장하나
LLM에게 정해진 모양의 JSON을 받아야 한다. 흔히 프롬프트에 스키마를 붙이고 "이 형식의 JSON으로만 답하라"고 쓴 뒤 JSON 모드를 켠다. 그러면 파싱 오류는 사라진다. 그런데 JSON 모드가 보장하는 것은 거기까지다.
필드 이름을 조금 바꾸거나, 없는 값을 지어내거나, 필수 필드를 빠뜨려도 올바른 JSON이면 통과한다. 스키마가 작으면 모델이 대개 맞춘다. 정의가 수십 개인 문서에서는 매번 어딘가가 어긋난다. 그러면 검증에서 거부되고, 재시도하느라 호출과 시간이 늘어난다.
gpt-5-mini로 전략 문서를 6개 받았을 때 검증을 통과한 것은 2개였다. 거부 사유는 정수 자리의 실수 3건, 스키마에 없는 필드 1건이었다.
2.거부를 모델 탓으로 읽기 전에 검증기부터 본다
형식 거부가 많으면 모델이 약하다고 결론 내리기 쉽다. 모델을 바꾸기 전에 거부 사유를 하나씩 열어 본다. 여기서는 거부 4건 중 3건이 같은 사유였고, 원인은 검증기에 있었다.
문서의 손절 파라미터는 실수(float) 필드였다. 모델이 "n": 14를 쓰면 검증 코드가 이
값을 14.0으로 바꿔 저장한다. 그런데 이 값을 정수 최적화 축으로 선언하면, 축 검사는 정수 타입만 받으므로 "정수가 아니다"로
거부했다. 모델은 올바르게 썼는데 매번 거부되는 조합이었다.
# 전: 타입으로 판단해 14.0을 거부한다 if axis.kind == "int" and not isinstance(value, int): raise SpecError("정수여야 한다") # 후: 정수 축은 정수값 실수(14.0)도, 실수 축은 정수 표기(30)도 받는다 if axis.kind == "int" and not (isinstance(value, int) or float(value).is_integer()): raise SpecError("정수여야 한다") # 14.5는 여전히 거부 if axis.kind == "float" and isinstance(value, bool): raise SpecError("숫자여야 한다")
반대 방향도 나왔다. 조건식의 기준값(예: "RSI가 30보다 작으면")을 실수 축으로 선언하자 "실수여야 한다"로
거부됐다. 둘 다 JSON에 없는 구분을 검증기가 요구한 것이다. 규칙은 하나다. JSON에서 온 수는 타입이 아니라 값으로 판단한다.
저장하는 값의 타입은 그대로 두고 검사만 고치면, 이미 저장된 문서에는 영향이 없다.
3.스키마는 검증 코드에서 생성한다
검증기를 고쳐도 남은 1건처럼 모델이 없는 필드를 쓰는 일은 계속 생긴다. 없는 필드를 생성 단계에서 막는 기능이 Structured Outputs다. 스키마를
strict: true로 넘기면 모델은 스키마를 벗어난 출력을 만들 수 없다.
스키마를 손으로 다시 쓰면 검증 코드와 어긋난다. 한쪽에 필드를 더하고 다른 쪽을 잊으면, 모델이 스키마대로 써도 검증에서 거부된다. 검증에 쓰는 모델에서 스키마를 생성하고, strict가 받는 형태로만 변환한다.
schema = StrategyDocument.model_json_schema() # ① 검증과 같은 정의에서 시작 trial = {… "spec": to_strict(schema) …} # ② 새 전략 제안 — strict 규칙으로 변환 (아래 표) stop = {… "reason": {"type": "string"} …} # 또는 "더 시도할 가설이 없다" response_format = { "type": "json_schema", "json_schema": {"name": "reply", "strict": True, "schema": {"type": "object", "properties": {"reply": {"anyOf": [trial, stop]}}, "required": ["reply"], "additionalProperties": False}}, } reply = Reply.model_validate_json(text) # ③ 받은 뒤에도 같은 검증 코드로 다시 검증
| strict가 요구하는 것 | 변환 방법 |
|---|---|
모든 속성이 required | 선택 필드는 필수로 두고 타입에 null을 합친다 |
모든 객체에 additionalProperties: false | 재귀적으로 붙인다 |
루트는 anyOf가 아닌 객체 | "새 전략 / 그만" 같은 갈래는 {"reply": anyOf[…]} 봉투에 넣는다 |
| 지원하지 않는 키워드는 뺀다 | title·default·minLength 등을 지운다(검증은 ③이 한다) |
| 크기 한도(OpenAI) | 속성 5,000개, 중첩 10단계, 문자열 합계 120,000자 — 정의 40여 개짜리 이 문서도 한도의 5분의 1 정도였다 |
4.선택 필드의 null은 어디까지 걷나
strict에서 선택 필드는 "값 또는 null"이 되므로, 모델은 쓰지 않을 필드에 null을 넣어 보낸다. 검증 모델이 null을
받지 않는 필드라면 받기 전에 걷어 내야 한다.
여기서 쉽게 틀린다. 모든 null을 일괄로 지우면, 원래 null을 허용하지 않는 필드의 null이 조용히 기본값으로 바뀐다. 롱·숏을 정하는 방향 필드에 모델이 null을 넣었는데 걷어 내자 기본값인 롱이 되어 버렸다. 오류로 거부되어야 할 응답이 멀쩡한 문서가 된 것이다.
그래서 선택 필드를 둘로 나눈다. "없음"이 뜻을 갖는 필드(기본값이 비어 있음 — 예: 목표가가 없으면 목표가 청산을 안 한다)만 strict 스키마에서 null을 허용하고, 기본값이 있는 필드(방향처럼 비우면 기본값이 대신 들어가는 필드)는 null을 허용하지 않아 모델이 값을 반드시 쓰게 한다. 받은 뒤에는 스키마가 null을 허용한 자리에서만 null을 걷는다.
5.잘린 응답과 거부는 형식 오류와 다르게 알린다
스키마를 강제해도 파싱에 실패하는 응답이 남는다. 대부분 출력 한도에서 잘린 경우다. 추론 모델은 답 앞에 생각하는 토큰을 먼저 쓰고, 그 토큰도 출력 한도에 들어간다. 긴 문서를 쓰다 한도에 닿으면 JSON이 중간에서 끊긴다.
잘린 응답을 "형식 오류"로만 모델에게 되돌리면 모델은 같은 길이의 문서를 다시 쓰고 또 잘린다. 응답의 종료 사유를 함께 본다. Chat Completions는
finish_reason: "length", Responses API는 status: "incomplete"와
max_output_tokens 사유가 온다. 잘렸으면 한도를 올리는 문제로 다룬다. strict에서 모델이 요청을 거부하면
본문 대신 refusal이 오므로, 빈 본문으로 읽지 않고 거부로 알린다.
출력 한도 8,192토큰에서 문서 1건이 중간에 끊겼다. 문서 하나에 완성 토큰을 6,000개 넘게 쓴 경우가 있어 한도를 16,384로 올렸다. 과금은 실제로 만든 토큰만이라 한도를 올려도 비용은 그대로다.
6.결과
| 방식 (전략 문서 6개) | 검증 통과 | 모델 호출 |
|---|---|---|
| gpt-5-mini · JSON 모드 | 2 / 6 | 10회 (재시도 포함) |
| gpt-5-mini · strict 스키마 (두 번 잼) | 5 / 6 · 5 / 6 | 9·10회 (거부된 1건의 재시도) |
| gpt-5.6-terra · strict 스키마 | 6 / 6 | 6회 (재시도 없음) |
strict로 바꾼 뒤 첫 측정에서 남은 1건은 5절의 잘림이었고, 한도를 올린 뒤 남은 1건은 구조가 아니라 의미 규칙이었다. "여러 시간 단위를 섞는 지표의 주기는 기준 주기보다 길어야 한다" 같은 필드 사이의 관계는 JSON 스키마로 표현되지 않는다. gpt-5.6-terra로 문서를 36번 받아 보면 이런 의미 규칙 위반이 프롬프트·추론 설정에 따라 0~7건 나왔다(구조 오류는 0). 검증기 수정과 strict를 함께 적용해서 둘의 효과를 따로 재지는 않았다. 스키마는 형식 실패를 생성 단계에서 없애는 층이고, 의미 검증을 대신하지 않는다. 받은 뒤의 검증(3절의 ③)은 그대로 둔다.
7.이 결론이 안 통하는 조건
- strict를 지원하지 않는 제공자. OpenAI 호환 API라도 스키마 강제를 문서화하지 않은 곳이 있다. NVIDIA NIM에서 같은 스키마를 보내 보니 한 모델은 스키마를 무시했고, 다른 모델은 큰 스키마에서 깨진 JSON을 냈다. 지원을 확인한 연결에만 켠다.
- 스키마가 한도를 넘는 문서. 속성 수·중첩 깊이·문자열 합계 한도를 넘으면 요청이 거부된다. 문서를 나누거나 자주 안 쓰는 갈래를 뺀다.
- 자유 형식이 목적인 출력. 요약·설명처럼 구조가 없는 답에는 스키마가 필요 없다.
8.정리
- 형식 거부가 많으면 사유를 하나씩 열어 본다. 모델을 바꾸기 전에 검증기 결함부터 찾는다.
- JSON에서 온 수는 값으로 판단한다. 정수·실수 타입 검사는 거짓 거부를 만든다.
- 스키마는 검증 모델에서 생성해 strict로 강제한다. 손으로 다시 쓰지 않는다.
- null은 스키마가 허용한 자리에서만 걷는다. 일괄로 지우면 오류가 기본값이 된다.
- 잘림과 거부를 형식 오류와 따로 알린다. 잘렸으면 한도를 올린다.
- 받은 뒤의 검증은 그대로 둔다. 스키마는 의미 규칙을 모른다.
형식 실패의 절반은 모델이 아니라 받는 쪽에 있었다. 나머지 절반은 부탁이 아니라 강제로 없앴다.
'프로그래밍 > AI' 카테고리의 다른 글
| LLM 에이전트 입력 토큰 70% 줄이기: OpenAI 툴 지연 로딩과 한국어 설명의 함정 (0) | 2026.10.05 |
|---|---|
| 로컬 LLM 서버가 살아 있는데 틀리게 돌 때: 스트림 오류·빠진 의존성·설정 차이 (0) | 2026.09.26 |
| 추론 모델 에이전트가 느린 이유는 생각 토큰: thinking을 끄기 전에 확인할 것 (0) | 2026.09.26 |
| 로컬 LLM이 매번 입력을 처음부터 다시 읽을 때: prefix 캐시 확인하는 법 (0) | 2026.09.25 |
| 32GB 맥에서 큰 로컬 LLM 돌릴 때 메모리 나누는 법: GPU 한도와 서버 캐시 (0) | 2026.09.25 |
댓글