DevInsight

개발하며 겪은 판단을 적어요

AI
조회 0약 4분 읽기

json_object가 끝이 아니라 시작인 이유

response_format으로 JSON 출력을 강제해도 런타임에는 키 누락·타입 오류·트렁케이션 같은 실패가 살아남는다. 이 글은 OpenAI Chat Completions에서 json_object 모드를 쓰면서도 JSON Schema 검증을 통과할 때까지 파싱·복구·재시도하는 이중 안전망을 코드 예시와 함께 구성하는 법을 다룬다.

DevInsight에서 발행해요

#OpenAI#Chat Completions#response_format#JSON Schema#스키마 검증#런타임 검증#재시도 패턴#LLM 안정화#파싱 복구

배포 첫 주, 정산 내역을 파싱하는 함수가 죽었다. 로그에는 JSONDecodeError가 찍혀 있었고, 그 옆에 finish_reasonlength였다. response_formatjson_object를 걸어뒀는데도 말이다. JSON은 도중에 잘려 나왔고, 잘린 JSON은 아무리 멀쩡해 보여도 문자열일 뿐이다. 호출하는 쪽은 '올바른 JSON'을 약속받았지, '완결된 JSON'을 약속받은 게 아니었다.

json_object가 지키는 약속은 구문뿐이다

response_format: { "type": "json_object" }이 보장하는 건 딱 하나다. 반환 문자열이 JSON 문법으로 해석된다는 것. 스키마는 보지 않는다. 키가 빠져도, 배열 자리에 null이 와도, 날짜가 문자열 대신 숫자로 와도 그건 여전히 유효한 JSON이다. 구조는 그때그때 확률적으로 정해진다. 필드 이름 하나를 바꿔 부르기도 하고, 요구하지 않은 키를 덧붙이기도 한다. 반대로 이 모드를 아예 걸지 않으면 코드펜스에 JSON을 감싸거나, JSON 뒤에 설명을 덧붙이는 답변이 나오기 십상이다.

여기에는 덤으로 붙는 제약도 있다. 이 모드를 쓰면 대화 어딘가에 "json"이라는 단어가 반드시 들어가 있어야 한다. 없으면 호출 자체가 400으로 거절된다. 정확히는 messages with role 'system' must contain the word 'json' 같은 오류를 돌려받는다. 형식 지정만 덧붙이려다 기존 프롬프트가 이 벽에 부딪히는 경우가 흔하다. 모델 지원 범위도 갈린다. json_object는 비교적 오래된 모델까지 커버하지만, 스키마를 직접 강제하는 json_schema 모드는 지원 목록이 따로 있고 파인튜닝된 모델에서는 못 쓰는 구간이 있다. 시작점부터 '이 모델이 뭘 지원하는가'를 분기 조건으로 세워야 한다.

무너지는 곳은 세 군데다

런타임 실패는 크게 세 겹으로 나뉜다.

파싱 실패가 첫 번째다. 문법 자체가 깨진 경우다. max_tokens에 걸려 출력이 잘리면 거의 예외 없이 이 지점에서 죽는다. 객체 하나가 수천 자로 내려오는 응답을 받는 서비스라면 트렁케이션은 언제든 터질 수 있는 사고다.

스키마 실패가 두 번째다. JSON은 정상인데 필수 키가 빠지거나 타입이 어긋나는 경우다. 필드가 통째로 없어서 KeyError가 나는지, null이 들어와서 AttributeError가 나는지는 그때 달라진다.

세 번째는 의미 실패다. 스키마를 다 통과했는데 값이 말이 안 되는 경우다. 가격이 음수로 온다거나, 상품 코드가 허용 목록에 없는 값으로 온다거나. 여기서 걸리는 건 JSON Schema가 아니다. 스키마는 형태만 심사하지, 값이 도메인에서 허용되는 범위인지는 따지지 않는다. 결국 검증은 스키마 층과 의미 층을 따로 통과해야 하고, 마지막 층은 도메인 로직에 남는다. 검증 계층 하나로 두 층을 함께 처리하면 의미 검증이 스키마 라이브러리의 표현력 안에 갇히기 쉽다.

실패를 피드백으로 되돌리는 루프

검증 실패를 에러로 끝내면 거기서 끝이다. 실패 메시지를 다음 호출에 먹여 다시 시키는 게 이 안전망의 핵심이다. 검증이 1차 방어선이라면 재시도가 2차 방어선이다. 파싱이나 스키마 단계에서 걸린 응답은 재시도로 살릴 확률이 꽤 높다. 특히 트렁케이션이라면, max_tokens를 늘려 다시 물으면 같은 자리에서 완주하는 경우가 잦다.

def request_json(messages, schema_validator, max_retries=2, **kwargs): for attempt in range(max_retries + 1): resp = client.chat.completions.create( model=MODEL, messages=messages, response_format={"type": "json_object"}, **kwargs, ) finish = resp.choices[0].finish_reason try: data = json.loads(resp.choices[0].message.content) schema_validator(data) return data except (json.JSONDecodeError, ValidationError) as e: messages = [ *messages, {"role": "assistant", "content": resp.choices[0].message.content}, {"role": "user", "content": f"이전 응답이 유효한 JSON이 아니거나 스키마에 어긋났다: {e}. 스키마를 지켜 JSON만 다시 출력하세요."}, ] raise MaxRetryExceeded(finish_reason=finish)

재시도 피드백은 구체적이어야 루프가 빨리 닫힌다. "JSON이 잘못됐다"는 안내는 소용이 없다. "price 필드가 문자열로 왔다. 숫자여야 한다"처럼 어느 키가 무엇으로 왔는지를 넘겨야 다음 시도가 달라진다. 반대로 에러 메시지를 통째로 넣고 "다시 해봐"라고만 하면 같은 실수가 반복된다.

한 가지 예외가 있다. finish_reason이 length로 죽은 경우는 피드백을 넣기 전에 max_tokens를 먼저 점검해야 한다. 잘리는 원인을 고치지 않고 재시도만 하면 예산만 소진한다.

의미 단계에서 반복적으로 걸린다면 재시도로 풀려 하지 말아야 한다. 그 실패는 호출 루프의 문제가 아니라 프롬프트나 스키마 정의의 모호함에서 온다. 같은 내용을 두 번 세 번 시켜봤자 값은 바뀌지 않는다. 그때는 스키마에 enum을 넣거나 프롬프트에 예시를 넣는 쪽으로 고쳐야 한다.

json_schema로 올라갈 때

모델이 지원한다면 json_object 대신 response_format의 type을 json_schema로 바꾸고 strict: true를 거는 쪽이 결과가 단단하다. 샘플링 자체가 스키마에 묶이도록 강제하므로 스키마 위반 비율이 크게 내려간다. 다만 면제는 없다. 공식 문서도 이 모드에서조차 출력 검증을 하라고 명시한다. strict 모드의 스키마 작성에는 제약도 붙는다. additionalProperties: false를 요구하고, 모든 속성이 required에 들어가 있어야 하며, JSON Schema 키워드 중 일부만 허용된다. 기존 스키마를 그대로 들이밀면 되레 오류만 난다. strict 스키마는 규칙에 맞게 다시 쓰는 작업이지 복붙이 아니다.

지원 모델이 아니라면 다시 json_object로 내려간다. 그러니까 이 안전망은 둘 중 어느 쪽에도 항상 붙어 있어야 한다. 스키마 강제가 강해질수록 실패 확률은 낮아지지만 0이 되는 순간은 없다. json_schema를 쓴다는 이유로 파싱·검증·재시도 루프를 떼는 건 성립하지 않는다.

재시도 예산과 마지막 방어선

재시도는 공짜가 아니다. 호출마다 지연이 몇 초씩 쌓이고 토큰 비용도 붙는다. 상한을 걸지 않으면 희귀한 실패 하나가 지연 예산을 통째로 집어삼킨다. 재시도 횟수는 2회가 실용적이다. 3회 이상 허용하면 응답 시간 분포의 꼬리가 통제 밖으로 나간다. 예산 기준은 상황에 따라 다르다. 실시간 응답 경로라면 총 소요 시간을 초 단위로 제한하고, 배치 파이프라인이라면 횟수 제한이면 충분하다. 후자는 지연보다 처리량이 우선이니까.

예산을 다 쓰고 실패했다면 기본값 객체를 돌려주거나 수동 검토 큐로 넘긴다. 어느 쪽이든 '실패를 삼킨 것처럼 보이는 코드'만은 만들지 말아야 한다. 빈 객체를 반환하고 조용히 지나가면, 뒤에서는 잘린 JSON이 쌓여간다. 로그에는 finish_reason과 실패 지점(파싱/스키마/의미), 시도 횟수를 남겨야 나중에 원인을 찾는다.

통과 여부가 아니라 수치 변화를 봐야 한다

이 안전망이 실제로 작동하는지 배포 전에 아는 방법은 딱 하나다. 대표 프롬프트 수십 개를 뽑아 실패율을 재는 것. 볼 값은 첫 시도 실패율, 재시도 회생율, 예산 소진 비율 셋이다. 세 값을 기록해 두고 모델을 바꾸거나 프롬프트를 수정할 때마다 비교한다. 검증 세트에는 긴 상품 설명이나 여러 항목을 내려보내는 트렁케이션 유도 케이스를 반드시 넣어야 한다. 단위 테스트로는 트렁케이션을 재현할 수 없다. 같은 프롬프트를 여러 번 돌리는 스트레스 실행이 필요하다. 예컨대 예산 소진 비율이 이전보다 1퍼센트포인트라도 올랐다면, 재시도 설정을 조정하기 전에 프롬프트와 스키마를 들여다보는 쪽이 옳다.

json_object는 시작점이지 종착지가 아니다. 검증 없이 JSON을 통과시키는 일은 그 모드를 걸어놓고도 파싱 에러가 터지는 밤을 스스로 예약하는 것과 같다.

댓글

댓글을 읽어오는 중입니다.

같이 읽으면 좋은 글

방금 읽은 주제와 이어지는 글을 골랐습니다.

AI 전체 보기
AI

잘게 썰수록 놓치는 문맥, 크게 썰수록 잡히는 노이즈

RAG 품질은 모델이나 임베딩보다 청크 분할에서 갈리는 경우가 많다. 문장 하나를 통째로 잘라내도 답이 될 단락까지 쪼개는 실수, 고정 토큰 수 분할이 의미 단위를 무너뜨리는 문제, 임베딩 모델의 문맥 창을 무시한 크기 설정까지. 이 글은 흔한 분할 실수들을 문맥 유실과 노이즈 유입이라는 두 축으로 비교하고, 평가 시 실제 쿼리 적중 여부와 판단 척도를 따져 청크 설계를 바로잡는 방법을 정리한다.

#RAG#청크 분할#Chunking#임베딩
AI

AI 에이전트는 서버에서 태어났지만 브라우저에서 산다

대부분의 AI 에이전트가 여전히 서버에서 오케스트레이션을 돌리고 있지만, 브라우저가 가진 런타임 맥락과 WebGPU·WebLLM의 발전이 이 판도를 바꾸고 있다. 서버 중심과 브라우저 네이티브 아키텍처의 지연 시간, 개인정보 보호, 비용, 확장성을 비교하며 왜 지금 이 전환이 중요한지 분석한다.

#AI 에이전트#브라우저 AI#WebLLM#AI 오케스트레이션

이전 글

이미지를 얇게 만들수록 빌드가 비대해지는 역설

DevInsight Digest

새 글이 쌓이면, 피드에서 바로 이어 읽으세요.

과장된 알림 대신 발행한 글 전체를 RSS로 제공합니다.

RSS 구독