Skip to content
This page has been auto-translated and may contain errors.View in English

구조화된 출력

docs.scrimba.com

먼저 문제의 형태를 짚어보겠습니다. **구조화된 출력(Structured output)**이란 모델이 여러분의 코드가 직접 읽을 수 있는 데이터를 반환하게 만드는 작업입니다. 사람이 손으로 파싱해야 하는 문단이 아니라, 조건문에 바로 쓸 수 있는 필드를 얻는 것이죠. 이번 장에서는 이것을 신뢰할 수 있게 만드는 방법을 다룹니다.

지금까지 모델은 텍스트를 돌려줬습니다. 사람이 읽을 때는 괜찮지만, 소프트웨어는 문단만으로는 할 수 있는 일이 별로 없습니다. 모델의 답변을 코드에서 활용하려면 데이터 형태여야 합니다. 읽을 수 있는 필드, 조건문에 쓸 수 있는 값, 저장할 수 있는 레코드로 말이죠.

이 전환은 작아 보이지만 그 이후의 모든 것을 바꿔놓습니다. 모델이 "고객이 불만스러워 보인다"는 식의 문장 대신 { "sentiment": "negative" }를 돌려주는 순간, 여러분의 코드는 티켓을 라우팅하거나, 대시보드를 업데이트하거나, 알림을 보낼 수 있습니다. 흥미로운 부분은 텍스트를 예측하는 것밖에 하지 못하는 모델에게서 깨끗한 데이터를 뽑아내는 방법이며, 그 답은 여러분이 미처 몰랐던 지렛대를 하나 드러내 줍니다.

모델은 텍스트를 돌려줍니다. 하지만 코드가 필요한 것은 데이터입니다. 읽을 필드, 분기할 값, 저장할 레코드죠. 이번 장은 그 둘 사이의 다리이며, 핵심은 모델이 알아서 예쁘게 포맷해주길 바라는 태도를 버리고 출력 자체를 제약하는 것입니다.

여기에는 프롬프트 요령이 아니라 실제 지렛대가 있습니다. LLM의 작동 방식에서 본 그 샘플링 루프를 그대로 조정해서, 출력이 원하는 형태를 벗어나지 못하게 강제할 수 있습니다. 이렇게 하면 흐릿한 텍스트 생성기가 시스템의 나머지 부분이 믿고 쓸 수 있는 컴포넌트로 바뀝니다.

산문을 돌려주는 모델은 조합할 수 없는 컴포넌트입니다. 그 출력이 다른 시스템으로 흘러 들어가는 순간, 정해진 형태를 가진 데이터가 필요해지고, 그 데이터를 얻는 과정에서 생기는 실패 양상도 눈에 보이고 다룰 수 있는 것이어야 합니다.

이번 장은 생성을 스키마 안에 제약하는 것, 그것이 지연 시간 측면에서 어떤 비용을 치르게 하는지, 그것이 사주지 않는 것(값의 정확성)이 무엇인지, 그리고 경계에서 어떻게 실패하는지를 다룹니다. 그 밑을 받치는 메커니즘은 도구 사용을 움직이는 것과 같습니다. 도구 호출은 함수 시그니처가 스키마 역할을 대신하는 구조화된 출력일 뿐입니다.

JSON으로 요청하기

첫 번째로 떠오르는 방법은 프롬프트에서 그냥 요청하는 것입니다. "JSON 형식으로 답해줘"라고 말이죠. JSON은 소프트웨어가 데이터를 주고받을 때 쓰는 평문 형식으로, 중괄호 안에 필드와 값을 나열해서 작성합니다. 실제로 해보면 대체로 잘 됩니다. 바로 그게 함정입니다. 모델은 여전히 그럴듯한 텍스트를 예측하는 일밖에 하지 않기 때문에, 가끔은 그 그럴듯한 텍스트가 코드 펜스이거나, 데이터 앞에 붙는 다정한 "네, 여기 있습니다!" 같은 말이거나, 데이터 뒤에 붙는 주석일 수 있습니다. 이런 것들은 JSON을 읽는 코드를 전부 망가뜨립니다. 정중하게 요청하는 것은 예측을 JSON 쪽으로 기울일 뿐, 강제하지는 않습니다.

그래서 제공업체들은 진짜 지렛대를 제공합니다. response_format으로 JSON 모드를 켜는 것은 단순히 요청하는 것 이상입니다. 모델을 제약해서 문법적으로 유효한 JSON이 나오게 만듭니다.

python
import json

response = client.chat.completions.create(
    model=MODEL,
    messages=[
        {"role": "system", "content": 'Extract the city and the temperature. Reply as JSON: { "city": string, "tempC": number }.'},
        {"role": "user", "content": "It's about 18 degrees in Oslo right now."},
    ],
    response_format={"type": "json_object"},  # 출력을 유효한 JSON 문법으로 제약함
)

data = json.loads(response.choices[0].message.content)
print(data["city"], data["tempC"])  # "Oslo" 18

response_format={"type": "json_object"}는 출력을 유효한 JSON 문법으로 만들어주므로, json.loads가 엉뚱한 산문 때문에 막히지 않습니다. 그래도 원하는 필드는 프롬프트에서 직접 설명해야 합니다. JSON 모드가 보장하는 것은 유효한 JSON이라는 점뿐이지, 여러분이 생각한 특정 필드가 아니기 때문입니다. "JSON이 아예 아닌 것"은 배제하지만, "형태가 잘못된 JSON"은 배제하지 못합니다. 그리고 이것도 보장하지 못하는 게 하나 있습니다. 응답이 중간에 끊기면 절반짜리 JSON 객체를 받을 수도 있으므로, 이것은 신뢰할 수 있는 문법이지 모든 경우에 대한 절대적인 보장은 아닙니다.

JunoJSON으로 요청하기 프롬프트에서 JSON을 요청하는 것만으로는 불안정합니다. 모델은 그럴듯한 텍스트를 예측할 뿐이라 코드 펜스나 잡담으로 감싸서 파싱을 깨뜨릴 수 있기 때문입니다. response_format을 JSON 모드로 설정하면 문법이 제약되어 유효한 JSON으로 돌아옵니다. 다만 요청한 필드와 일치한다는 보장은 아니고, 중간에 끊긴 응답은 반쪼가리 객체로 올 수도 있으니, 원하는 형태는 프롬프트에서 설명하고 실제로 도착한 결과를 확인하세요.

반사적으로 떠오르는 방법은 프롬프트에 "JSON으로 답해줘"라고 쓰는 것입니다. 여기서 **JSON**은 소프트웨어가 주고받는 필드와 값의 데이터 형식을 말합니다. 대체로 잘 되는데, 바로 그게 문제입니다. "대체로"는 계약이 아니거든요. 모델은 그럴듯한 텍스트를 예측하고, 그 그럴듯한 텍스트에는 종종 코드 펜스나 "물론이죠!" 같은 서두, 혹은 뒤에 붙는 메모가 포함됩니다. 이런 것들은 모두 json.loads를 깨뜨립니다.

JSON 모드가 첫 번째 진짜 지렛대입니다. response_format을 JSON 객체 타입으로 설정하면 생성 과정 자체가 제약되어, 겉만 JSON처럼 생긴 산문이 아니라 실제로 유효한 JSON 문법이 나옵니다.

python
import json

response = client.chat.completions.create(
    model=MODEL,
    messages=[
        {"role": "system", "content": 'Extract the city and the temperature. Reply as JSON: { "city": string, "tempC": number }.'},
        {"role": "user", "content": "It's about 18 degrees in Oslo right now."},
    ],
    response_format={"type": "json_object"},  # 출력을 유효한 JSON 문법으로 제약함
)

data = json.loads(response.choices[0].message.content)
print(data["city"], data["tempC"])  # "Oslo" 18

여기서 response_format의 형태는 OpenAI 방식입니다. 다른 제공업체들도 같은 개념을 각자의 필드 이름으로 제공하므로, 문자 그대로의 키가 아니라 개념을 옮겨서 적용해야 합니다. JSON 모드가 제약하는 것은 항상 문법일 뿐, 필드 구조는 절대 아닙니다. 필드는 프롬프트에서 설명해야 하고, 모델은 여전히 이름을 바꾸거나, 빠뜨리거나, 다르게 중첩시키면서도 유효한 JSON을 낼 수 있습니다.

여기에 균열이 하나 더 있습니다. JSON 모드는 응답이 끝까지 완료되었을 때만 잘 짜인 JSON을 보장합니다. max_tokens 한도에 걸려 객체 중간에서 잘리면 파싱할 수 없는 잘린 출력을 받게 되므로, 파싱 단계에는 여전히 방어 장치가 필요합니다.

JunoJSON으로 요청하기 JSON 모드는 문법을 제약해서 모델이 데이터를 펜스나 잡담으로 감싸지 못하게 하지만, 필드 자체를 고정하는 것은 아니라서 모델이 여전히 이름을 바꾸거나 빠뜨릴 수 있습니다. response_format 키는 OpenAI 방식이고, 다른 제공업체는 다르게 표기합니다. 그리고 그 보장은 응답이 끝까지 완료됐을 때만 유효하므로, max_tokens에 의해 잘린 응답은 파싱할 수 없는 상태로 도착합니다. 그러니 어느 경우든 파싱을 방어적으로 처리하세요.

프롬프트에서 "JSON으로 답해줘"라고 하는 것은 하나의 제안일 뿐이고, 제안은 부하가 걸리면 실패합니다. 여기서는 코드 펜스, 저기서는 서두, 모델이 수다스러울 때는 뒤에 붙는 주석까지, 이 하나하나가 프로덕션에서 json.loads 예외로 이어집니다. JSON 모드(여기서는 response_format={"type": "json_object"})는 그 제안을 생성되는 문법 자체에 대한 제약으로 대체합니다. 그래서 돌아오는 바이트가 JSON으로 파싱됩니다.

python
import json

response = client.chat.completions.create(
    model=MODEL,
    messages=[
        {"role": "system", "content": 'Extract the city and the temperature. Reply as JSON: { "city": string, "tempC": number }.'},
        {"role": "user", "content": "It's about 18 degrees in Oslo right now."},
    ],
    response_format={"type": "json_object"},  # 출력을 유효한 JSON 문법으로 제약함
)

data = json.loads(response.choices[0].message.content)
print(data["city"], data["tempC"])  # "Oslo" 18

이것이 정확히 무엇을 제약하는지 짚어봅시다. JSON 모드는 스키마가 아니라 문법을 고정하며, 그것도 응답이 완료됐을 때만 유효합니다. 모델은 여전히 필드 이름을 스스로 정하고, 필수 필드를 빠뜨릴 수도 있고, 원하는 대로 중첩시킬 수 있습니다.

이 보장은 생성이 정상적으로 끝난다는 조건에 걸려 있습니다. 모델이 객체 중간에서 max_tokens에 걸리면 유효하지 않은 잘린 문자열을 받게 됩니다. 즉, JSON 모드가 없애주지 못하는 단 하나의 실패 양상이 바로 재시도 경로가 가장 처리해야 할 그 실패 양상이라는 뜻입니다. 여기서 보여드린 response_format 키는 OpenAI 방식이고, 다른 제공업체에서는 다른 이름으로 존재하므로, 개념은 옮길 수 있지만 필드 자체는 그대로 옮길 수 없다고 생각하세요. 형태를 보장받고 싶다면 스키마가 필요하고, 그것이 다음 절의 내용입니다.

JunoJSON으로 요청하기 JSON 모드는 완료된 응답에 대해서만 문법을 제약할 뿐, 그 이상은 아닙니다. 모델은 여전히 필드 이름을 마음대로 짓고, 빠뜨리고, 중첩시키며, max_tokens에 걸려 죽은 생성은 잘려서 파싱할 수 없는 출력을 넘겨줍니다. 그 잘림 케이스는 여러분의 에러 처리 경로가 책임져야 합니다. 모드 자체는 그것을 책임지지 않으니까요. response_format의 형태는 OpenAI 방식이니 키가 아니라 개념을 옮기세요. 형태를 원한다면 스키마가 필요합니다.

스키마로 형태를 고정하기

JSON 모드는 문법을 유효하게 유지해주지만, 모델이 여전히 필드 이름을 바꾸거나, 빠뜨리거나, 다르게 중첩시킬 수 있습니다. 이 여지를 없애려면 **스키마**를 줘야 합니다. 출력이 반드시 가져야 할 필드와 타입을 정확히 기술한 것, 즉 모델이 채워 넣어야 하는 양식입니다. strict: true를 쓰면 출력이 그 양식과 일치한다는 것이 보장됩니다.

python
import json

response = client.chat.completions.create(
    model=MODEL,
    messages=[
        {"role": "system", "content": "Extract the contact details from the message."},
        {"role": "user", "content": "Hi, I'm Mara Lin, reach me at [email protected]."},
    ],
    response_format={
        "type": "json_schema",
        "json_schema": {
            "name": "contact",
            "strict": True,  # 스키마를 정확히 강제함
            "schema": {
                "type": "object",
                "properties": {
                    "name": {"type": "string"},
                    "email": {"type": "string"},
                },
                "required": ["name", "email"],
                "additionalProperties": False,
            },
        },
    },
)

contact = json.loads(response.choices[0].message.content)
# {"name": "Mara Lin", "email": "[email protected]"}

모델이 토큰 예측만 하는데 스키마가 어떻게 절대적인 보장이 될 수 있을까요? 그 이유는 제공업체가 모델이 예측할 수 있는 토큰 자체를 제한하기 때문입니다. 매 단계마다 모델은 가능한 다음 토큰 전체에 여전히 순위를 매기지만, 시스템이 스키마를 깨뜨릴 토큰들을 미리 제거하고, 모델은 남은 것들 중에서만 고릅니다.

스키마상 다음 필드가 email이어야 한다면, 다른 필드를 시작하는 토큰들은 모델이 선택하기 전에 이미 판에서 사라집니다. 형태를 벗어나는 토큰들이 제거되기 때문에, 모델은 형태를 벗어날 수가 없습니다.

알아두면 좋은 부수 효과가 하나 있습니다. 여러분이 고른 필드 이름은 모델이 값을 예측할 때 함께 읽는 정보가 되므로, 명확한 이름이 더 좋은 답으로 이어집니다. tempC를 채우라고 하면 모델은 섭씨 숫자 쪽으로 기울지만, value를 채우라고 하면 참고할 정보가 훨씬 줄어듭니다. 필드 이름을 명확하게 짓는 것은 스키마 작업이면서 동시에 지시문 작업이기도 합니다.

Juno스키마로 형태를 고정하기 스키마는 모델이 채워야 할 양식이고, strict: true를 쓰면 정확한 필드와 타입을 얻게 됩니다. 시스템이 형태를 깨뜨릴 토큰을 모델이 고르기 전에 미리 제거하기 때문이죠. 이건 강제이지 정중한 요청이 아닙니다. 필드 이름도 명확하게 지으세요. 필드 이름은 모델이 값을 예측할 때 읽는 맥락이니까요. tempC 같은 이름이 실제로 일을 한다는 걸 믿기까지 저도 시간이 좀 걸렸습니다.

JSON 모드는 문법을 고정하지만 모델이 필드를 바꾸거나 빠뜨리거나 다시 중첩시킬 여지는 남겨둡니다. 스키마는 그 틈을 막습니다. 필드와 타입을 정확히 기술하고, strict: true를 쓰면 출력이 반드시 그것과 일치하도록 보장됩니다.

python
import json

response = client.chat.completions.create(
    model=MODEL,
    messages=[
        {"role": "system", "content": "Extract the contact details from the message."},
        {"role": "user", "content": "Hi, I'm Mara Lin, reach me at [email protected]."},
    ],
    response_format={
        "type": "json_schema",
        "json_schema": {
            "name": "contact",
            "strict": True,  # 스키마를 정확히 강제함
            "schema": {
                "type": "object",
                "properties": {
                    "name": {"type": "string"},
                    "email": {"type": "string"},
                },
                "required": ["name", "email"],
                "additionalProperties": False,
            },
        },
    },
)

contact = json.loads(response.choices[0].message.content)
# {"name": "Mara Lin", "email": "[email protected]"}

이 메커니즘의 이름은 **제약된 디코딩(constrained decoding)**입니다. 샘플링 단계에 적용되는 토큰 가지치기라고 할 수 있습니다. 매 위치마다 모델은 평소처럼 전체 토큰에 순위를 매기고, 그다음 시스템이 출력을 스키마와 어긋나게 만들 토큰들을 마스킹하고, 모델은 남은 것들 중에서 샘플링합니다. 문법상 다음 토큰이 email 필드를 열어야 한다면, 다른 것을 시작하는 토큰들은 뽑히기 전에 이미 확률이 0이 됩니다. 모델이 형태를 벗어날 수 없는 이유는, 프롬프트에서 그런 시도를 만류하는 게 아니라 형태를 벗어나는 선택 자체가 판에서 제거되기 때문입니다.

실무에서 쓸 수 있는 손잡이가 두 가지 있습니다. 필드 이름은 지시문 역할을 하므로, 원하는 값에 맞게 이름을 지으세요. tempC는 모델을 섭씨 쪽으로 이끌지만 value는 모델을 갈피 잃게 만들고, 필드에 설명을 달면 그 효과가 더 강해집니다. 그리고 같은 제약이 enum(허용된 값의 고정된 목록)이나 숫자 범위도 강제할 수 있는데, 이것이 분류를 신뢰할 수 있게 만드는 이유입니다. 레이블 위치에서는 여러분이 나열한 레이블만 살아남으므로, 모델은 목록에 없는 범주를 만들어낼 수 없습니다.

Juno스키마로 형태를 고정하기strict: true를 쓰면 제약된 디코딩이 모델이 샘플링하기 전에 스키마를 깨뜨릴 모든 토큰을 마스킹하므로, 형태가 요청이 아니라 강제로 지켜집니다. 이걸 적극 활용하세요. 필드 이름과 설명은 지시문이니 value 대신 tempC라고 이름 지으면 모델이 더 잘 채웁니다. 같은 마스킹이 enum도 강제하기 때문에 분류가 안정적으로 유지되는 것이고, 모델은 목록에 없는 레이블을 지어낼 수 없습니다.

JSON 모드는 파싱 가능한 바이트를 얻게 해주고, 스키마는 정해진 형태를 얻게 해줍니다. strict: true를 쓰면 출력이 선언한 필드 집합과 타입에 반드시 일치한다는 것이 보장되는데, 이는 무작정 인덱싱해도 되는 데이터와 방어적으로 하나하나 확인해야 하는 데이터 사이의 차이입니다.

python
import json

response = client.chat.completions.create(
    model=MODEL,
    messages=[
        {"role": "system", "content": "Extract the contact details from the message."},
        {"role": "user", "content": "Hi, I'm Mara Lin, reach me at [email protected]."},
    ],
    response_format={
        "type": "json_schema",
        "json_schema": {
            "name": "contact",
            "strict": True,
            "schema": {
                "type": "object",
                "properties": {
                    "name": {"type": "string"},
                    "email": {"type": "string"},
                },
                "required": ["name", "email"],
                "additionalProperties": False,
            },
        },
    },
)

contact = json.loads(response.choices[0].message.content)

이 메커니즘의 이름은 **제약된 디코딩(constrained decoding)**이며, 그 비용이 공짜가 아니라는 점에서 근본부터 이해할 가치가 있습니다. 모델은 매 단계마다 어휘 사전의 모든 토큰에 대해 확률을 산출합니다. 제약된 디코딩은 여러분의 스키마를 문법(어떤 토큰 시퀀스가 합법적인지에 대한 규칙 집합)으로 컴파일한 다음, 매 단계마다 마스크를 만들어 불법적인 모든 토큰의 확률을 0으로 설정합니다. 그래서 샘플링은 지금까지 출력을 유효하게 유지하는 토큰에만 도달할 수 있습니다. 형태는 요청이 아니라 구조적으로 강제되는 것입니다.

이 컴파일은 공짜가 아니고, 지연 시간으로 드러납니다. 새로운 스키마에 대한 첫 호출은 문법을 만드는 데 일회성 비용을 지불하므로, 새로운 스키마의 콜드 스타트 지연 시간은 안정 상태보다 높습니다. 스키마를 요청마다 새로 만들지 말고 안정적으로 유지하며 재사용하세요. 그렇지 않으면 그 컴파일 비용을 계속 반복해서 지불하게 됩니다.

additionalProperties: false와 완전한 required 목록은 장식이 아닙니다. 이것들이 있어야 시스템이 여분의 필드나 빠진 필드를 확실히 거부할 수 있고, 스키마 기반 거부를 지원하는 제공업체에서는 모델이 형태가 이상한 객체 대신 구조화된 거부를 깔끔하게 돌려줄 수 있는 통로도 됩니다. 이것들을 빼버리면 유효하다고 인정되는 범위가 넓어지는데, 이는 여러분이 strict 모드를 쓴 이유와 정반대입니다.

몇 가지 실무적인 현실을 짚어보겠습니다. strict 지원은 제공업체마다 다릅니다. 어떤 곳은 실제 문법을 강제하고, 어떤 곳은 근사치만 지원하거나 JSON Schema의 일부(예: pattern 미지원, 제한된 중첩)만 지원합니다. 그러니 스펙만 믿지 말고 여러분의 제공업체가 실제로 무엇을 지키는지 테스트하세요. 부분 출력과 스트리밍 출력은 다루기 까다로운 경우입니다. 토큰이 스트리밍되는 동안에는 문법적으로 미완성인 객체를 손에 들고 있게 되므로, 완료될 때까지 버퍼링한 뒤 파싱하거나, 절반만 만들어진 구조를 견딜 수 있는 증분 파서를 써야 합니다. 그리고 이것은 도구 사용과 같은 메커니즘입니다. 함수의 인자 스키마도 똑같은 방식으로 제약되므로, 여기서 배운 모든 것이 도구 호출을 신뢰할 수 있게 만드는 데 그대로 옮겨갑니다.

Juno스키마로 형태를 고정하기 제약된 디코딩은 여러분의 스키마를 문법으로 컴파일하고 샘플링 전에 불법적인 토큰을 모두 0으로 만들기 때문에, 형태가 구조적으로 강제됩니다. 그 컴파일은 새로운 스키마에 대한 첫 호출에서 지연 시간 비용을 치르므로, 요청마다 스키마를 새로 만들지 말고 재사용하세요. additionalProperties: false와 완전한 required 목록을 유지하세요. 이것들이 거부를 강제하고, 일부 제공업체에서는 깔끔한 구조화된 거부까지 가능하게 해줍니다. strict 지원은 제공업체마다 다르고, 스트리밍은 반쪼가리 객체를 넘겨주며, 같은 마스킹이 도구 호출 인자도 다루므로, 여기서 배운 것이 두 배로 도움이 됩니다.

그래도 검증은 해야 합니다

스키마가 형태를 제약해주더라도, 출력은 여전히 외부에서 온 데이터로 취급해야 합니다. 형태는 유효한데 내용은 틀릴 수 있습니다. 추출된 이메일이 사실은 오타이거나, 모델이 그냥 추측한 숫자이거나, 입력에 없어서 비워둔 필드일 수 있습니다. 그리고 호출 자체가 평범한 방식으로 실패할 수도 있습니다. max_tokens에 걸려 잘려서 파싱할 수 없는 JSON으로 도착하는 경우처럼요. 방어적으로 파싱하세요. 유효한 형태가 곧 올바른 값을 뜻하지는 않습니다.

python
import json

def parse_contact(raw):
    try:
        data = json.loads(raw)
        if not data.get("name") or not data.get("email"):
            return None  # 존재하지만 비어 있음
        return data
    except json.JSONDecodeError:
        return None  # 유효한 JSON이 아님 (예: 잘린 응답)

contact = parse_contact(response.choices[0].message.content)
if not contact:
    pass  # 실패 처리: 재시도, 다시 요청, 또는 친절한 오류 표시

이것은 LLM의 작동 방식에서 본 환각(hallucination) 이야기가 새로운 옷을 입은 것입니다. 형태가 맞다고 값도 맞는 것은 아닙니다. 스키마는 name 필드가 존재한다는 것을 보장하지만, 그 이름이 맞다는 것을 보장하지는 않습니다. 파싱 주변에 try/except를 두고 값을 확인하는 것은, 형태는 맞지만 여전히 틀린 응답에 대한 값싼 보험입니다.

Juno그래도 검증은 해야 합니다 스키마는 형태를 보장할 뿐 값이 진실이라는 것은 결코 보장하지 않습니다. 유효한 레코드도 틀리거나 비어 있는 값을 담을 수 있고, 호출 자체가 잘려서 도착할 수도 있습니다. 파싱을 try/except로 감싸고 신뢰하기 전에 값을 확인하세요. 검증이 실패했을 때 무엇을 할지(재시도인지 친절한 오류인지) 미리 정해두면 나중에 허둥댈 일이 없습니다.

strict 모드는 형태를 보장하지, 내용을 보장하지 않습니다. 출력이 스키마와 일치하면서도 여전히 틀릴 수 있습니다. 그럴듯해 보이는 이메일이 오타이거나, 필수 필드를 채우기 위해 만들어낸 숫자이거나, 입력에 아무것도 없어서 빈 문자열로 채워진 경우처럼요. 그리고 응답이 max_tokens에서 잘리면 파싱이 완전히 실패할 수도 있습니다. 그러니 매번 검증해야 합니다.

python
import json

def parse_contact(raw):
    try:
        data = json.loads(raw)
    except json.JSONDecodeError:
        return None, "unparseable"  # 잘렸거나 형식이 잘못됨

    if not data.get("name") or not data.get("email"):
        return None, "empty_field"  # 존재하지만 비어 있음
    if "@" not in data["email"]:
        return None, "bad_email"  # 형태는 맞지만 값이 틀림
    return data, None

contact, error = parse_contact(response.choices[0].message.content)
if error == "unparseable":
    pass  # 한 번 재시도, 대개는 잘림 문제: max_tokens를 올리거나 입력을 줄임
elif error:
    pass  # 값 수준 실패를 기록하고 폴백 처리

이 패턴은 실패 양상을 구분하는 **방어적 파싱(defensive parse)**입니다. 이 실패들은 서로 다른 처리를 원하기 때문입니다. JSONDecodeError는 대개 잘림을 의미하므로, 해결책은 max_tokens를 올리거나 입력을 줄여서 재시도하는 것입니다. 비어 있거나 범위를 벗어난 값은 형식 실패가 아니라 내용 실패이므로, 같은 호출을 재시도해봐야 별 소용이 없습니다. 기록하고 폴백 처리를 하세요.

"파싱이 안 됨"과 "파싱은 됐지만 틀림"을 구분하세요. 해결책이 다르니까요. 이것은 환각과 다시 이어집니다. 제약된 디코딩은 형태가 잘못되는 실패 양상을 없앴을 뿐, 값이 틀리는 실패 양상은 그대로 남겨두었습니다.

Juno그래도 검증은 해야 합니다 strict 모드는 형태를 고정할 뿐 내용은 고정하지 않으므로 매번 검증하세요. 레코드는 형식적으로 완벽하면서도 오타나 추측값, 빈 값을 담을 수 있습니다. 실패를 나눠서 처리하세요. JSONDecodeError는 대개 잘림을 뜻하니(max_tokens를 올리거나 입력을 줄여서 재시도) 잘못된 값은 내용 실패라서 같은 호출을 재시도해도 고쳐지지 않습니다. 제약된 디코딩은 형태가 망가지는 문제를 없앴지만 잘못된 값 문제는 그대로 남겨두었습니다.

제약된 디코딩은 형태가 망가지는 실패 양상만 없애고 그 외에는 아무것도 해결하지 않습니다. 형태는 보장되지만 내용은 그렇지 않습니다. 입력에 애초에 없던 필수 필드는 자신 있어 보이는 추측값으로 채워지고, 숫자는 말이 안 되는 범위를 벗어나 나오고, 이메일은 문자열로는 파싱되지만 오타일 수 있습니다.

형식 보장 자체도 잘림이 생기면 무너집니다. 그러니 검증은 선택적인 위생 관리가 아니라, 스키마가 구조적으로 잡아낼 수 없는 것을 잡아내는 계층입니다.

python
import json

def parse_contact(raw, retry):
    try:
        data = json.loads(raw)
    except json.JSONDecodeError:
        return None, "truncated"  # 생성이 미완료됨, 내용 문제가 아님

    if not data.get("name") or "@" not in data.get("email", ""):
        return None, "invalid_value"  # 형태는 맞지만 값이 틀림
    return data, None

contact, failure = parse_contact(raw, retry=False)
if failure == "truncated":
    pass  # max_tokens를 올리거나 입력을 줄이고 호출을 재시도
elif failure == "invalid_value":
    pass  # 복구 경로: 잘못된 출력을 다시 넣어 재요청하거나 폴백으로 라우팅

무엇이든 삼켜버리는 try/except와 실제로 버텨내는 검증기를 가르는 요소는 세 가지입니다.

  • 실패를 분류하세요. 잘림은 max_tokens를 늘려서 재시도하면 되는 예산 문제이고, 잘못된 값은 무작정 재시도해도 그대로 재현되는 내용 문제입니다.
  • 폴백만이 아니라 값 수준 문제를 위한 **복구 경로(repair path)**를 만드세요. 잘못된 출력을 고치라는 지시와 함께 다시 넣어주면 추가 호출 한 번으로 복구되는 경우가 많고, 이는 요청 전체를 실패로 처리하는 것보다 저렴합니다. 다만 계속해서 틀리는 입력이 무한히 반복되지 않도록 재시도 횟수를 제한하세요.
  • 비용에 한계를 두세요. 재시도 한 번은 청구되는 호출 한 번, 지연 시간 한 번이 더 늘어나는 것이므로, 재시도 예산은 설계에서 나중에 덧붙이는 것이 아니라 처음부터 포함해야 합니다.

파싱만이 아니라 값도 검증하세요. 이것은 환각 억제 이야기를 구조화된 데이터에 적용한 것입니다. 제약된 디코딩은 깨끗한 봉투를 주지만 그 안에 무엇이 들어있는지에 대해서는 아무것도 말해주지 않습니다. 그래서 바로 이 봉투 앞에서 팀들이 검증을 멈추고 화를 입습니다. 스키마 검증, 범위·의미 검사, 그리고 한계가 있는 복구 루프, 이 세 계층은 각각 다른 것을 잡아냅니다. 스키마는 형태를, 검사는 말이 안 되는 값을, 복구 루프는 살릴 수 있는 값을 되살립니다.

Juno그래도 검증은 해야 합니다 strict 모드는 형태가 망가지는 문제를 없애지만 틀린 값은 온전히 남겨두므로, 파싱뿐 아니라 내용도 검증하세요. 실패를 분류하세요. 잘림은 예산 문제(max_tokens를 늘려서 재시도)이고, 잘못된 값은 무작정 재시도해도 그대로 재현되는 내용 문제이니 잘못된 출력을 다시 넣어주는 복구 경로를 만들고, 잘못된 입력이 청구액을 무한히 늘리지 않도록 재시도 횟수를 제한하세요. 깨끗한 봉투야말로 사람들이 검증을 멈추고 화를 입는 지점입니다.

두 가지 패턴: 분류와 추출

구조화된 출력 작업은 대부분 두 가지 형태 중 하나입니다.

**분류(Classification)**는 입력을 고정된 레이블 집합 중 하나로 정리합니다. 스키마 안의 enum이 같은 토큰 가지치기 기법을 이용해 출력을 정확히 그 레이블들로만 제약합니다. 레이블 위치에서는 허용된 값만 예측될 수 있으므로, 모델은 목록에 없는 범주를 만들어낼 수 없습니다.

python
# 분류용 스키마 조각
{"type": "string", "enum": ["billing", "technical", "general"]}

**추출(Extraction)**은 앞서 나온 연락처 예시처럼 자유 텍스트에서 특정 필드를 뽑아냅니다. 이름, 날짜, 금액, 제품명 목록 같은 것들이죠. 분류와 추출을 합치면 실제 AI 기능의 상당 부분을 다룰 수 있습니다. 고객 지원 티켓 라우팅, 콘텐츠 태깅, 이메일을 레코드로 변환하기, 영수증 읽기 등이 그렇습니다. 두 가지 모두 흐릿한 텍스트 생성기를 여러분의 코드가 믿고 쓸 수 있는 컴포넌트로 바꿔줍니다.

Juno두 가지 패턴: 분류와 추출 분류는 입력을 고정된 레이블 집합 중 하나로 정리하며, enum으로 고정해두면 모델이 목록에 없는 범주를 지어내지 못합니다. 추출은 이름 붙은 필드들을 자유 텍스트에서 뽑아 레코드로 만듭니다. 둘 다 흐릿한 텍스트 생성기를 신뢰할 수 있는 컴포넌트로 바꿔주고, 둘을 합치면 실용적인 AI 기능의 상당 부분을 커버합니다.

구조화된 출력으로 만드는 것은 거의 다 두 가지 형태 중 하나이고, 이름을 붙여두면 적절한 스키마를 고르기가 쉬워집니다.

**분류(Classification)**는 입력을 고정된 집합 안의 레이블 하나로 매핑합니다. 이 작업의 전부를 enum이 담당합니다. 제약된 디코딩이 레이블 위치에서 여러분이 나열한 레이블을 제외한 모든 토큰을 마스킹하므로, 모델은 그중 하나를 돌려주거나 아예 아무것도 돌려주지 못합니다.

python
# 분류: 닫힌 집합에서 레이블 하나
{"type": "string", "enum": ["billing", "technical", "general"]}

**추출(Extraction)**은 앞서 본 연락처와 티켓 예시처럼, 자유 텍스트에서 이름 붙은 필드들을 뽑아 레코드로 만듭니다. 이 둘은 조합할 수 있습니다. 하나의 스키마가 동시에 분류하고 추출할 수 있는데, 이것이 실제 기능의 대부분입니다. 닫힌 집합에는 enum을, 열린 값에는 타입이 있는 필드를 쓰세요.

의식적으로 내려야 할 결정은 이것입니다. 값이 실제로 고정된 집합(상태, 카테고리, 우선순위)이라면 enum을 써서 모델이 벗어나지 못하게 하고, 값이 열려 있다면(이름, 요약 등) 평범한 타입 필드를 쓰고 그 값은 검증할 만큼만 신뢰하세요. 이걸 뒤섞으면, 즉 실제 세계에는 경우가 더 많은데 enum을 쓰거나, 닫힌 집합이면 오류를 잡아낼 수 있었을 텐데 자유 필드를 쓰면, 이런 기능들은 조용히 오작동하게 됩니다.

Juno두 가지 패턴: 분류와 추출 대부분은 두 가지 형태로 커버됩니다. 분류는 닫힌 집합에서 레이블 하나로 매핑하고(enum이 강제함), 추출은 이름 붙은 필드들을 뽑아 레코드로 만들며, 하나의 스키마로 둘 다 할 수도 있습니다. 신중하게 고르세요. 고정된 집합에는 enum을 써서 모델이 벗어나지 못하게 하고, 열린 값에는 검증할 평범한 타입 필드를 쓰세요. 실제 세계의 경우를 놓친 enum은 잘못된 레이블의 조용한 원인이 됩니다.

구조화된 출력은 두 가지 패턴으로 압축됩니다. 그리고 이 구분은 단순한 용어가 아니라 설계 지렛대입니다. 분류는 입력을 닫힌 집합의 레이블 하나로 매핑하며, enum으로 강제되어 제약된 디코딩이 이를 여러분의 값들 중 하나를 고르는 엄격한 선택으로 축소시킵니다. 추출은 자유 텍스트에서 타입이 있는 필드를 뽑아냅니다. 실무의 스키마 대부분은 이 둘을 함께 씁니다.

python
# 분류: 제약된 디코딩은 레이블 위치에서 이 토큰들만 허용함
{"type": "string", "enum": ["billing", "technical", "general"]}

지렛대는 어디에 닫힌 집합의 경계선을 긋느냐입니다. enum은 날카로운 경계를 가진 정확성 보장이기 때문입니다. 실제로 값의 범위가 정해져 있는 것(상태, 우선순위)은 enum에 넣어야 모델이 목록 밖의 값을 뱉어내지 못하고, 그렇게 하면 열려 있던 생성이 다운스트림 코드가 신뢰할 수 있는 확인 절차로 바뀝니다.

하지만 enum은 알맞은 값이 없을 때도 선택을 강제합니다. 실제 입력이 어느 레이블에도 맞지 않으면, 모델은 맞지 않는다고 말하는 대신 가장 가까운, 틀린 값을 고르도록 제약됩니다. 탈출구가 없는 enum은 "맞는 게 없음"을 자신 있어 보이는 잘못된 레이블로 세탁해버립니다. 그러니 지저분한 실제 입력을 다뤄야 하는 분류기라면 명시적인 탈출구(escape hatch), 즉 otherunknown 항목을 추가하고, 이를 다른 곳으로 라우팅하라는 신호로 읽으세요.

같은 주의가 확신도에도 적용됩니다. 어떤 결정이 그 레이블에 좌우된다면, 모델이 확신도나 짧은 근거 필드도 함께 내놓게 하세요. 그러면 애매한 판단이 깨끗해 보이는 enum 값으로 세탁되지 않고 눈에 보이게 됩니다. 이것은 도구 사용에서와 같은 직관입니다. 거기서는 모델이 닫힌 집합에서 도구 하나를 고르며, 여러분은 "아무것도 맞지 않으면 어떻게 하지"라는 동일한 문제를 마주하게 됩니다.

Juno두 가지 패턴: 분류와 추출 분류는 enum으로 강제되는 닫힌 집합이고, 추출은 자유 텍스트에서 뽑는 타입 필드입니다. enum은 날카로운 경계를 가진 보장이라, 맞는 게 없을 때도 선택을 강제해서 "맞지 않음"을 자신 있어 보이는 잘못된 레이블로 세탁해버립니다. otherunknown 항목을 추가해서 그쪽으로 라우팅하고, 결정이 레이블에 좌우될 때는 확신도 필드도 함께 내놓게 하세요. 도구 사용에서 닫힌 집합 중 도구 하나를 고르는 것과 같은 형태입니다.

실전에서

지저분한 고객 지원 이메일을 구조화된 티켓으로 바꾸는 예시입니다. 하나의 스키마에서 분류와 추출을 함께 씁니다.

python
import json

response = client.chat.completions.create(
    model=MODEL,
    messages=[
        {"role": "system", "content": "Turn the support email into a ticket."},
        {"role": "user", "content": email_text},
    ],
    response_format={
        "type": "json_schema",
        "json_schema": {
            "name": "ticket",
            "strict": True,
            "schema": {
                "type": "object",
                "properties": {
                    "category": {"type": "string", "enum": ["billing", "technical", "general"]},
                    "urgency": {"type": "string", "enum": ["low", "medium", "high"]},
                    "summary": {"type": "string"},
                },
                "required": ["category", "urgency", "summary"],
                "additionalProperties": False,
            },
        },
    },
)

ticket = json.loads(response.choices[0].message.content)
# {"category": "billing", "urgency": "high", "summary": "Double-charged for last month's subscription."}

한 번의 호출로 이메일을 두 가지 기준으로 분류하고 요약을 추출해서, 여러분의 코드가 라우팅하고 저장할 수 있는 레코드를 돌려줍니다. 형태는 보장되지만 값은 여전히 확인할 만합니다. 지금까지는 텍스트가 들어가고 텍스트가 나오는 흐름뿐이었습니다. 다음 장인 임베딩에서는 모델에게 완전히 다른 감각을 줍니다. 텍스트가 의미로 비교할 수 있는 숫자가 되는 것인데, 이는 검색과 여러분 자신의 문서를 다루는 작업의 기초가 됩니다.

Juno실전에서 strict 스키마를 쓴 한 번의 호출로 이메일을 두 가지 기준으로 분류하고 요약도 함께 추출할 수 있고, 여러분의 코드가 라우팅하고 저장할 수 있는 레코드를 돌려줍니다. 형태는 보장되고, 값은 여전히 확인할 만합니다. 도구 사용도 나중에 이 같은 요령을 씁니다. 모델이 도구에 보내는 인자 역시 구조화된 출력이기 때문입니다.

실제 기능은 보통 두 패턴을 하나의 스키마에서 함께 씁니다. 여기서는 고객 지원 이메일이 티켓이 됩니다. 두 번의 분류와 한 번의 추출이 하나의 호출 안에 들어 있습니다.

python
import json

response = client.chat.completions.create(
    model=MODEL,
    messages=[
        {"role": "system", "content": "Turn the support email into a ticket."},
        {"role": "user", "content": email_text},
    ],
    response_format={
        "type": "json_schema",
        "json_schema": {
            "name": "ticket",
            "strict": True,
            "schema": {
                "type": "object",
                "properties": {
                    "category": {"type": "string", "enum": ["billing", "technical", "general"]},
                    "urgency": {"type": "string", "enum": ["low", "medium", "high"]},
                    "summary": {"type": "string"},
                },
                "required": ["category", "urgency", "summary"],
                "additionalProperties": False,
            },
        },
    },
)

ticket = json.loads(response.choices[0].message.content)
# {"category": "billing", "urgency": "high", "summary": "Double-charged for last month's subscription."}

두 개의 enum은 엄격하게 제약되므로, categoryurgency는 언제나 그대로 라우팅에 쓸 수 있습니다. summary는 열린 필드이므로, 신뢰하기 전에 검증해야 할 대상은 바로 이것입니다. 강제된 레이블과 검증된 자유 텍스트 필드의 이런 조합이 대부분의 추출 기능이 취하는 형태입니다. 다음 장인 임베딩에서는 모델에게 완전히 다른 감각을 줍니다. 텍스트를 의미로 비교할 수 있는 숫자로 바꾸는 것인데, 이는 검색과 여러분 자신의 문서를 다루는 작업의 기초가 됩니다.

Juno실전에서 strict 스키마 하나로 두 가지 기준으로 분류하고 요약도 한 번의 호출에서 함께 추출해, 라우팅하고 저장할 수 있는 레코드를 돌려줄 수 있습니다. enum들은 강제되어 있으니 그 레이블들은 안심하고 실행에 옮길 수 있고, 열린 summary는 검증해야 할 필드입니다. 고정된 레이블과 검증된 자유 텍스트의 이런 조합이 추출 기능의 일상적인 형태입니다.

실무에서 일상적으로 쓰는 형태는 여러 요소가 합쳐진 것입니다. 몇 가지 축에서 분류하고, 자유 텍스트 필드를 추출하는 것을 하나의 스키마, 하나의 호출로 처리합니다.

python
import json

response = client.chat.completions.create(
    model=MODEL,
    messages=[
        {"role": "system", "content": "Turn the support email into a ticket."},
        {"role": "user", "content": email_text},
    ],
    response_format={
        "type": "json_schema",
        "json_schema": {
            "name": "ticket",
            "strict": True,
            "schema": {
                "type": "object",
                "properties": {
                    "category": {"type": "string", "enum": ["billing", "technical", "general"]},
                    "urgency": {"type": "string", "enum": ["low", "medium", "high"]},
                    "summary": {"type": "string"},
                },
                "required": ["category", "urgency", "summary"],
                "additionalProperties": False,
            },
        },
    },
)

ticket = json.loads(response.choices[0].message.content)
# {"category": "billing", "urgency": "high", "summary": "Double-charged for last month's subscription."}

필드마다 강제되는 정도가 다르고, 이것이 바로 설계의 핵심입니다. enum들은 절대적인 보장이므로 categoryurgency는 별도 확인 없이 그대로 라우팅에 써도 안전합니다. summary는 제약이 없는 문자열이므로, 환각이 섞이거나 빗나간 값이 깨끗한 스키마 뒤에 숨을 수 있는 자리이며, 검증을 받아야 할 필드입니다. 강제된 두 레이블과 열린 summary 필드는 잘림에도 가장 취약한 스키마입니다. 길게 생성된 요약이 max_tokens에 걸려 객체 전체를 파싱 불가능하게 만들 수 있으므로, 예산은 레이블이 아니라 자유 텍스트 필드를 기준으로 잡으세요.

이것은 도구 사용과 같은 메커니즘입니다. 도구 호출은 모델이 함수의 스키마에 대해 구조화된 인자를 내놓는 것이고, 동일한 방식으로 제약되므로, 여기서 익힌 검증, 복구, enum 탈출구에 대한 직관이 에이전트를 신뢰할 수 있게 만드는 데 그대로 옮겨갑니다. 여기서부터 임베딩은 모델을 완전히 다른 모드로 옮겨놓습니다. 텍스트를 의미로 비교할 수 있는 벡터로 바꾸는 것인데, 이것이 검색과 여러분 자신의 문서로 답변을 뒷받침하는 작업의 기반이 됩니다.

Juno실전에서 strict 스키마 하나, 강제된 enum 두 개와 열린 summary: 레이블들은 안심하고 라우팅에 써도 되지만, 자유 텍스트 필드는 환각값이 숨을 수 있는 자리이고 긴 생성이 max_tokens에 걸려 잘림을 일으키는 자리이므로, 이 필드를 기준으로 예산을 잡고 따로 검증하세요. 이것은 도구 사용과 같은 메커니즘이며, 인자가 함수 스키마에 대해 제약되므로, 여기서 익힌 검증, 복구, enum 탈출구 같은 습관이 에이전트에도 그대로 이어집니다.