페이지 목차
이 페이지에서 검색
로컬 LLM
이 페이지에 묻기
로컬 LLM · 현재 차시와 목차 본문에서만 찾아 답합니다. 답을 지어내지 않고 해당하는 대목을 그대로 보여 주며, 여기에 없으면 없다고 알려 드립니다.
생성형 AI가 아니라 이 페이지 본문을 찾아 그대로 보여 주는 방식입니다. 외부로 나가는 요청이 없습니다.
LOCAL · LLM
로컬 LLM
생성 수정
로컬 모델 실행과 GPU·메모리 이해부터 서빙·RAG·도구 호출·업무형 앱 구현까지 차례로 살펴봅니다.
42개 문서 · 4일차 · 30 / 42
29차시 · Structured Output 활용
지도 위치 : 4일차 · ⑤애플리케이션 층 — 출력의 모양 을 강제한다 | 이전: 28차시 Prompt 기반 AI 기능 구현 | 다음: 30차시 Tool Calling 이해 및 활용 키워드 : JSON 응답 생성, 출력 형식 제어, 구조화 데이터 처리
이 차시를 다 읽으면
- "LLM은 다음 토큰 예측뿐"에서 출발해 JSON 형식이 왜 깨질 수 있는지 말할 수 있다.
- 형식을 얻는 세 단계 (프롬프트로 부탁 · JSON 모드 · JSON 스키마 강제)를 구별하고, constrained decoding이 무엇을 제한하는지 말할 수 있다.
- Pydantic 모델 하나 로 스키마를 만들어 엔진에 넘기고, 받은 응답을 검증하는 흐름을 그릴 수 있다.
- enum으로 분류값을 고정하고, 검증 실패 시 재시도 규칙 을 정할 수 있다.
- ⭐ " 형식이 맞아도 내용이 틀릴 수 있다 "를 축 ⑧로 설명하고, 스키마 검증과 평가셋이 각각 무엇을 잡는지 말할 수 있다.
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 |
| 3 | JSON 모드와 JSON 스키마 강제가 각각 보장하는 것은? | 2 |
| 4 | constrained 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을 허용하지 않으면 지어낸다", "형식이 맞아도 내용이 틀릴 수 있다"는 반드시 포함해줘.
답은 내가 답한 다음에 채점하고, 틀린 이유를 노트의 절 번호를 근거로 설명해줘.
LangTailor