본문으로 건너뛰기
페이지 목차
이 페이지에서 검색

로컬 LLM

이 페이지에 묻기

로컬 LLM · 현재 차시와 목차 본문에서만 찾아 답합니다. 답을 지어내지 않고 해당하는 대목을 그대로 보여 주며, 여기에 없으면 없다고 알려 드립니다.

생성형 AI가 아니라 이 페이지 본문을 찾아 그대로 보여 주는 방식입니다. 외부로 나가는 요청이 없습니다.

LOCAL · LLM

로컬 LLM

생성 수정

로컬 모델 실행과 GPU·메모리 이해부터 서빙·RAG·도구 호출·업무형 앱 구현까지 차례로 살펴봅니다.

42개 문서 · 4일차 · 30 / 42

29차시 · Structured Output 활용

지도 위치 : 4일차 · ⑤애플리케이션 층 — 출력의 모양 을 강제한다 | 이전: 28차시 Prompt 기반 AI 기능 구현 | 다음: 30차시 Tool Calling 이해 및 활용 키워드 : JSON 응답 생성, 출력 형식 제어, 구조화 데이터 처리

이 차시를 다 읽으면

  1. "LLM은 다음 토큰 예측뿐"에서 출발해 JSON 형식이 왜 깨질 수 있는지 말할 수 있다.
  2. 형식을 얻는 세 단계 (프롬프트로 부탁 · JSON 모드 · JSON 스키마 강제)를 구별하고, constrained decoding이 무엇을 제한하는지 말할 수 있다.
  3. Pydantic 모델 하나 로 스키마를 만들어 엔진에 넘기고, 받은 응답을 검증하는 흐름을 그릴 수 있다.
  4. enum으로 분류값을 고정하고, 검증 실패 시 재시도 규칙 을 정할 수 있다.
  5. ⭐ " 형식이 맞아도 내용이 틀릴 수 있다 "를 축 ⑧로 설명하고, 스키마 검증과 평가셋이 각각 무엇을 잡는지 말할 수 있다.

0. 이 차시의 판별 축

#축왼쪽 (0)오른쪽 (1)이 축으로 갈리는 것큰 축 (README 5장)
1형식인가 뜻인가스키마 검증 (구문)평가셋 채점 (의미)JSON 모양이 맞는가 ↔ 값이 사실인가⭐⭐ ⑧ 구문론 ↔ 의미론
2누가 형식을 지키게 하는가모델에게 부탁 (프롬프트)엔진이 강제 (constrained decoding)깨질 수 있음 ↔ 모양은 깨질 수 없음결정론 ↔ 확률론 (⑧의 파생, 정책 ↔ 규칙 — 28차시 5절)
3값의 범위가 닫혀 있는가자유 문자열enum (정해진 목록)"배송 문제" ↔ 다섯 개 중 하나—
4정보가 없을 때 무엇을 쓰는가아무 값이나 채움null (부재)지어냄 ↔ "없음"이라고 말함— (빈 값 ↔ null, 3.1)

앞에서 이어집니다 — 1차시 1.2에서 예고했습니다. "분류해서 정해진 값만 내라"는 요구도 결국 다음 토큰 예측이라서 형식이 깨질 수 있다. 그래서 형식을 강제하는 장치가 따로 필요하다. 이 차시가 그 장치입니다. 그리고 이 차시의 결론은 한 줄로 미리 말할 수 있습니다 — {"age": -5}는 JSON 형식은 정상인데, 나이가 음수 입니다. 형식이 맞는 것과 내용이 맞는 것은 다른 일입니다. 축 ⑧이 이 과정에서 가장 높이 올라오는 자리 입니다.


1. 왜 형식이 깨지는가

모델은 "JSON을 출력하는 기능"을 갖고 있지 않습니다. JSON처럼 보이는 다음 토큰 을 하나씩 고를 뿐입니다(1차시 1.2). 그래서 이렇게 깨집니다.

① 설명이 붙음     네, 분석 결과는 다음과 같습니다. {"category": "배송지연", ...}
② 코드 블록       ```json {"category": "배송지연", ...} ```
③ 생각이 섞임     <think>고객은 열흘째...</think> {"category": "배송지연", ...}
④ 잘림           {"category": "배송지연", "summary": "고객은 주문한 상품이
⑤ 목록 밖의 값    {"category": "배송 문제"}
⑥ 키 이름이 다름   {"분류": "배송지연"}
깨짐원인앞 차시
① ② ⑥사람에게 친절하게 답하도록 학습됨28차시 출력 형식 칸
③Qwen3 계열의 생각 모드 출력28차시 2.1
④max_tokens 도달 — finish_reason="length"⚠️ 25차시 4.1
⑤비슷한 뜻의 다른 말을 고름이 차시 3절 enum

⚠️ 28차시처럼 프롬프트를 잘 다듬으면 대부분 지켜집니다. 문제는 "대부분" 입니다. 하루 문의 2,000건에서 1%가 깨지면 20건이 파싱 에러로 멈춥니다.


2. ⭐ 형식을 얻는 세 단계

단계방법보장하는 것보장 못 하는 것
1. 프롬프트로 부탁"JSON으로만 답해" + 형식 예시없음 (대부분 지킴)전부
2. JSON 모드Ollama "format": "json" / OpenAI 호환 response_format={"type": "json_object"}문법적으로 올바른 JSON키 이름 · 타입 · 필수 필드 · enum
3. JSON 스키마 강제Ollama "format": <JSON 스키마> / OpenAI 호환 response_format={"type": "json_schema", ...}스키마에 맞는 JSON (키 · 타입 · enum)⚠️ 값이 사실인가 , 잘림

2.1 Constrained decoding — 메뉴판을 좁힌다

Constrained decoding (제약 디코딩) — [토큰 고르기 방식]인데, [ 매 토큰마다 스키마 · 문법에 어긋나는 후보를 막고 남은 후보 중에서만 고른다 ]는 점에서 생성이 끝난 뒤 검사만 하는 사후 검증과 구별된다.

2차시 흐름도의 ⑤ Sampling 칸에서 일어나는 일입니다.

지금까지 생성:  {"category": "
다음 토큰 후보:  배송 32% | 교환 20% | 안녕 5% | } 3% | ...
스키마의 enum:  [배송지연, 교환·반품, 결제오류, 상품문의, 기타]
            ─▶ "안녕", "}" 같은 후보는 막힘 → enum 값으로 이어질 수 있는 후보만 남음

⭐ 모델이 똑똑해진 것이 아닙니다. 여전히 다음 토큰 예측이고, 다만 고를 수 있는 칸이 좁아진 것 입니다. 그래서 모양은 보장되지만 "배송지연과 교환·반품 중 무엇이 맞는가"는 여전히 모델의 판단 입니다.

⚠️ 3단계에서도 남는 것이유 · 대응
잘림max_tokens에 닿으면 미완성 JSON. finish_reason을 꼭 확인
지원 범위 차이스키마 키워드(문자열 패턴, 길이 제한 등) 지원이 엔진 · 버전마다 다름 . 지원 안 되는 키워드는 무시될 수 있음 → 공식 문서 확인
생각 모드와 충돌생각 출력과 구조화 출력이 엉킬 수 있음 → 추출 작업에서는 생각 모드를 끄는 방법을 모델 카드 · 엔진 문서에서 확인
뜻은 모름스키마는 모양만 막습니다. 필드가 무엇을 뜻하는지 는 프롬프트에도 적어 줌

3. Pydantic으로 스키마 하나, 두 번 쓴다

3.1 문의봇 스키마

from enum import Enum
from pydantic import BaseModel, Field

class Category(str, Enum):
    DELAY = "배송지연"
    RETURN = "교환·반품"
    PAYMENT = "결제오류"
    PRODUCT = "상품문의"
    ETC = "기타"

class InquiryExtraction(BaseModel):
    category: Category
    customer_name: str | None = Field(description="본문에 적힌 고객 이름. 없으면 null")
    order_id: str | None = Field(description="본문에 적힌 주문번호. 없으면 null")
    contact: str | None = Field(description="본문에 적힌 연락처. 없으면 null")
    request: str = Field(description="고객이 원하는 조치 한 문장")
    summary: str = Field(description="상담원용 요약, 2문장 이내")

🔍 읽는 포인트

  • Category(str, Enum) — 분류값을 닫힌 목록 으로 고정합니다(Enum = 정해진 값들의 목록). str을 함께 상속하면 각 값이 그냥 문자열처럼 다뤄져 JSON에 "배송지연" 그대로 실립니다.
  • ⭐ str | None — "없으면 null"을 허용한 것 이 이 스키마의 가장 중요한 줄입니다. 아래 ⚠️를 보세요.
  • description — 스키마에 들어가 엔진과 모델이 함께 봅니다. 그래도 프롬프트에 필드 설명을 한 번 더 적는 편이 안전합니다(2.1 표 마지막 줄).

⚠️ null을 허용하지 않으면 지어내게 만듭니다. 주문번호가 없는 문의에 order_id: str(필수 문자열)을 강제하면, constrained decoding은 무언가 문자열을 반드시 채우게 합니다. 모델은 그럴듯한 주문번호를 만들어 냅니다. 0이나 빈 문자열 ""은 "비어 있다"는 값 이고, null은 "값 자체가 없다(부재)" 는 표시입니다. 이 차이가 여기서 사고를 가릅니다. 정보가 없을 수 있는 필드는 반드시 null을 허용 합니다.

3.2 두 번 쓰기 — 생성 전에 넘기고, 생성 후에 검사한다

schema = InquiryExtraction.model_json_schema()                      # ① 생성 전: 엔진에 넘김

r = await client.chat.completions.create(
    model=MODEL, messages=messages, temperature=0, max_tokens=500,
    response_format={"type": "json_schema",
                     "json_schema": {"name": "inquiry_extraction", "schema": schema}})

if r.choices[0].finish_reason == "length":
    raise Truncated()
data = InquiryExtraction.model_validate_json(r.choices[0].message.content)   # ② 생성 후: 문지기
# Ollama 고유 API로 같은 일을 할 때
res = ollama.chat(model="qwen3:8b", messages=messages,
                  format=InquiryExtraction.model_json_schema(), options={"temperature": 0})
data = InquiryExtraction.model_validate_json(res["message"]["content"])

🔍 읽는 포인트

  • 같은 클래스 가 ① 스키마를 만들고 ② 검증합니다. 계약이 한 곳에만 적혀 있으니 둘이 어긋날 일이 없습니다.
  • model_json_schema() · model_validate_json() — Pydantic 2 버전부터 메서드 이름이 model_로 시작 합니다. AI가 .schema() · .parse_raw()를 쓴 코드를 주면 옛 버전(1.x) 방식입니다.
  • model_validate_json — 앞에서 배운 스키마 = 문지기 (25차시 3절). 안전함이 증명된 데이터만 유스케이스로 들어갑니다.
  • 엔진이 이미 강제했는데 왜 또 검사하나? ① 엔진이 무시한 스키마 키워드가 있을 수 있고, ② 엔진(어댑터)을 바꾸면 강제가 없을 수 있고, ③ 잘림은 강제로 못 막습니다. 엔진의 강제는 성능 장치, 앱의 검증은 계약 입니다.

💡 어느 폴더에? 이 Pydantic 모델은 LLM이라는 바깥세상의 출력 형식 을 아는 물건입니다. 바깥 형식을 아는 것은 바깥쪽(어댑터)에 둔다는 25차시 2.1의 원칙대로 adapter/outbound/llm/ 쪽에 두고, 유스케이스에는 DTO(app/dtos/, 층 사이를 오가는 단순한 데이터 묶음)로 바꿔 넘깁니다.


4. 검증에 실패하면

생성 ─▶ finish_reason == "length"? ── 예 ─▶ max_tokens를 늘려 1회 재시도
          │ 아니오
          ▼
       Pydantic 검증 ── 통과 ─▶ 유스케이스로
          │ 실패
          ▼
       에러 내용을 messages에 붙여 1회 재시도 ── 또 실패 ─▶ "수동 분류 필요"로 표시 → 상담원에게
규칙이유
재시도 1~2회 상한LLM 호출은 멱등이라 안전하지만(25차시 5.1) GPU 일이 늘어남
temperature 0에서 똑같이 다시 보내지 않음같은 입력이면 같은 실수. "이전 출력의 category가 목록 밖 값이었다" 같은 에러를 붙여 보냄
끝내 실패하면 사람에게HITL (아래) — 시스템은 좋은 초안을, 확정은 사람이
실패율을 기록실패율이 오르면 프롬프트 · 모델 · 양자화 변경을 의심 (33, 39차시)

HITL (Human-in-the-Loop, 사람 확인 단계) — [자동화 설계 방식]인데, [ 자동 처리 흐름 중간에 사람이 확인 · 확정하는 단계를 일부러 끼워 넣는다 ]는 점에서 끝까지 기계가 결정하는 완전 자동화와 구별된다. 문의봇에서는 "수동 분류 필요" 표시(이 절), 주문 취소 확정 버튼(30차시), 답변 초안 발송(31차시)이 그 자리입니다.


5. ⭐⭐ 형식이 맞아도 내용이 틀릴 수 있다

README 5.1의 축 ⑧을 한 문장으로 하면 이렇습니다 — 구문 검사를 통과했다는 말은 "틀리지 않았다"가 아니라 "형식은 맞다"입니다. 문의봇에서는 이렇게 생겼습니다.

모델 출력스키마 검증사실
"order_id": "20261128-001" — 본문에 주문번호가 없음⭕❌ 지어냄
"category": "결제오류" — 정답은 교환·반품⭕❌ 오분류
"customer_name": "김민수" — 본문에는 김민준⭕❌ 한 글자 틀림
"summary"가 고객 요청과 반대로 요약⭕❌

⭐ 모두 완벽한 JSON 이고 스키마를 통과 합니다. 그래서 검사가 둘 필요합니다.

스키마 검증 (구문)평가셋 채점 (의미)
묻는 것모양이 맞나값이 맞나
언제매 요청, 운영 중 실시간프롬프트 · 모델 · 양자화를 바꿀 때마다 + 운영 표본
누가코드 (Pydantic)정답지 + 사람
잡는 것깨진 JSON, 목록 밖 값, 빠진 필드오분류, 지어낸 주문번호, 틀린 이름
못 잡는 것그럴듯한 거짓표본 밖의 개별 요청

💡 의미 검사의 일부는 규칙으로 끌어올 수 있습니다. "추출한 주문번호가 원문에 글자 그대로 있는가 "는 코드 한 줄로 매 요청 검사할 수 있습니다. 지어낸 값을 잡는 가장 싼 방법입니다 — 31차시에서 기능별로 정리합니다.

앞에서 배운 순서 정책 → 규칙 (28차시 5절)으로 보면 문의봇의 흐름은 이렇습니다.

[LLM 생성 (확률적, 정책)] ─▶ [스키마 검증 + 원문 대조 (결정론적, 규칙)] ─▶ [상담원 확인 (사람)]

가드레일이 마지막 에 있어야 "최종 출력은 검증을 통과했다"고 말할 수 있습니다. 그리고 그 검증이 형식만 본다는 것을 잊지 않는 것이 이 차시의 전부입니다.

앞으로 다시 만납니다 — 30차시의 도구 호출 인자도 JSON입니다. 모델이 만든 인자를 같은 방식(Pydantic)으로 검증 합니다. 31차시는 기능별로 "의미 쪽 평가"를 어떻게 하는지 정리합니다.


6. 실습 — 맡기고 검수하기

지시

문의봇 정보 추출을 세 가지 방식으로 구현하고 비교해줘.
- 스키마: 노트의 InquiryExtraction (category enum, customer_name/order_id/contact 는 null 허용, request, summary)
- 방식 A: 프롬프트로만 JSON 요청 / 방식 B: JSON 모드 / 방식 C: response_format json_schema
- 공통: temperature 0, max_tokens 500, 결과는 InquiryExtraction.model_validate_json 으로 검증
- 평가셋 eval/extract.jsonl (문의 40건 + 필드별 정답. 그중 10건은 주문번호가 본문에 없음) — 내가 준 파일만 사용
- 지표: 파싱 실패 수, 스키마 검증 실패 수, finish_reason=length 수, 필드별 정확도,
  "주문번호 없는 10건" 중 order_id 가 null 로 나온 수, 추출한 order_id 가 원문에 글자 그대로 있는 비율
맥락: 사용하는 엔진(Ollama 또는 vLLM)의 구조화 출력 공식 문서를 먼저 확인하고 지원 범위를 적어줘. 추측하지 말고 실행 출력 원문을 붙여.
보고 형식: [지표 | A | B | C] 표

받을 증거

  • 세 방식의 지표 표와 평가 스크립트 실행 출력 원문
  • 방식 A에서 깨진 출력 원문 몇 개 (1절의 ①~⑥ 중 무엇인가)
  • 주문번호가 없는 문의에 대한 C 방식의 출력 원문

내가 직접 볼 것

볼 것이걸로 아는 것
A → C로 갈수록 파싱 · 스키마 실패가 0에 가까워지는가2절 — 구문은 엔진이 막아 준다
C에서도 필드별 정확도는 100%가 아닌가⭐ 5절 — 형식이 맞아도 내용이 틀린다
주문번호 없는 10건에서 null 개수3.1 — 지어내지 않고 "없음"이라고 하는가
원문 대조 비율이 100% 미만이면 그 사례를 직접 읽기지어낸 값인가, 형식만 바꾼 값(하이픈 제거 등)인가는 사람이 판단

부록 A. 스스로 확인하기

#질문답이 있는 곳
1프롬프트를 잘 써도 JSON이 깨지는 근본 이유는?1
2형식이 깨지는 여섯 모양 중 finish_reason과 관련된 것은?1
3JSON 모드와 JSON 스키마 강제가 각각 보장하는 것은?2
4constrained decoding은 2차시 흐름도의 어느 칸에서 무엇을 제한하나?2.1
5정보가 없을 수 있는 필드에 null을 허용하지 않으면 생기는 일은?3.1
6엔진이 스키마를 강제하는데도 앱에서 다시 검증하는 이유 세 가지는?3.2
7검증 실패 시 같은 요청을 그대로 다시 보내면 안 되는 이유는?4
8스키마 검증과 평가셋 채점이 각각 잡는 것과 못 잡는 것은?5

부록 B. 자주 하는 실수

실수바로잡기
"프롬프트에 JSON으로 답하라고 했으니 됐다"❌ 대부분 지킬 뿐. 하루 수천 건이면 매일 깨짐
"JSON 모드면 스키마도 맞는다"❌ JSON 모드는 문법만 . 키 · enum은 스키마 강제
"스키마 강제면 잘림도 없다"❌ max_tokens에 닿으면 미완성. finish_reason 확인
없는 정보 필드를 필수 문자열로⚠️ 지어내게 강제 하는 것. null 허용
엔진이 강제하니 Pydantic 검증 생략⚠️ 엔진을 바꾸면 강제가 사라질 수 있음. 앱의 검증은 계약
"스키마 검증 통과 = 정확한 추출"❌ 구문 통과일 뿐. 의미는 평가셋 으로

부록 C. NotebookLM 복습 프롬프트

이 29차시 노트만 근거로 6문제를 내줘. 비전공자 기준으로.
"형식을 얻는 세 단계", "constrained decoding이 제한하는 것", "null을 허용하지 않으면 지어낸다", "형식이 맞아도 내용이 틀릴 수 있다"는 반드시 포함해줘.
답은 내가 답한 다음에 채점하고, 틀린 이유를 노트의 절 번호를 근거로 설명해줘.