# AI 에이전트

LLM 구조화 출력, JSON 스키마를 강제하면 값도 맞을까?

@Hong2026-10-04

이 글에 담긴 코드와 결과는 직접 실행해 측정한 값을 바탕으로 작성했습니다. 실측은 Hong과 팀원들이 진행했고, 그 결과를 바탕으로 AI가 글을 쓰고 Hong이 검토합니다.

실습 코드 내려받기

질문
Ollama의 format에 JSON 스키마를 주면 추출한 값까지 정확해질까?
답
형식만 보장된다. 스키마를 준 두 방식은 50건 모두 파싱과 스키마 검사를 통과했지만, 통과한 응답 중 4~6건(8~12%)은 값이 틀렸다.
조건
Ollama 0.34.4, qwen3:1.7b(Q4_K_M) CPU 추론, Docker Desktop, 과제 50건 × 방식 4개, temperature 0, seed 42

환경

Ollama
0.34.4 컨테이너, GPU 없이 CPU로만 추론
모델
qwen3:1.7b(Q4_K_M, 1.4GB)
실행
macOS(Apple Silicon) Docker Desktop, 이 실습 컨테이너 합계 메모리 3GB·CPU 2.5개 상한
클라이언트
python:3.12-slim, ollama 0.6.3, jsonschema 4.26.0
데이터
추출 과제 50건 × 방식 4개
생성 설정
temperature: 0, seed: 42, num_predict: 256, num_ctx: 2048
목차
  1. 실습 준비
  2. 실습 1: 같은 과제를 네 가지 방식으로 보내기
  3. 실습 2: 스키마를 통과했지만 틀린 값 보기
  4. 정리

이번 시간에는 Ollama의 format 인자에 JSON 스키마를 주면 응답의 형식과 값이 각각 얼마나 맞는지, 추출 과제를 돌려 채점하며 배워가는 시간입니다.

에이전트는 여러 단계를 이어서 일해요. 예를 들어서 앞 단계의 LLM이 본문에서 값을 뽑으면, 다음 단계의 코드가 그 값을 읽어 도구를 부르거나 DB에 쓴다고 해볼게요.

이때 두 단계를 잇는 것은 대개 JSON이에요. 그런데 모델이 JSON 앞에 "다음은 결과입니다" 같은 문장을 붙이거나 코드 블록으로 감싸 보내면 어떻게 될까요? json.loads에서 바로 실패하고, 다음 단계는 멈춰 버립니다.

Ollama는 이 문제를 줄이려고 /api/chat에 format 인자를 두고 있어요. format: "json"을 주면 JSON으로 답하게 하고, JSON 스키마를 주면 그 스키마에 맞는 구조로 답하게 합니다.

다만 파싱이 되고 스키마를 통과했다는 것은 형식이 맞다는 뜻일 뿐이에요. 그 안의 값이 본문과 맞는지는 별개의 문제죠.

그래서 이 사이트에 발행된 글에서 slug, 숫자, 정해진 후보 중 하나, 중첩 배열을 뽑는 과제 50건을 만들고, 같은 과제를 네 가지 방식으로 돌려 볼게요.

응답마다 파싱 성공, 스키마 통과, 값 정답을 따로 채점해서 "유효한 JSON인데 값이 틀린" 응답이 얼마나 나오는지 세어 봅니다.

실습 준비

이 글의 명령은 모두 실습 코드의 llm-structured-output/ 디렉터리에서 실행합니다.

  • compose.yaml, Dockerfile, requirements.txt: Ollama 컨테이너와 Python 클라이언트 컨테이너 정의, 클라이언트 이미지와 패키지
  • tasks.jsonl: 추출 과제 50건(본문, 할 일, 스키마, 정답, 채점 규칙)
  • grade.py: 파싱, 스키마, 값 순서로 응답 하나를 채점하는 모듈
  • extract.py: 과제를 네 가지 방식(뒤에서 설명하는 P, J, SN, SP)별로 Ollama에 보내 결과 파일에 한 줄씩 붙이는 스크립트
  • report.py: 결과 파일을 다시 채점해 요약하는 스크립트
  • results/: 이 글에서 기록한 결과 파일. extract.py는 기록된 과제를 건너뛰므로 직접 돌리려면 먼저 results/를 비웁니다.

그 밖의 파일은 데이터·보조 스크립트입니다.

compose.yaml은 Ollama 0.34.4 컨테이너 ollama(메모리 2,750MB, CPU 2.25, 호스트 포트 7520)와 클라이언트 컨테이너 client(메모리 250MB, CPU 0.25)를 띄웁니다. 각각의 역할은 이렇습니다.

  • ollama: 모델 서버입니다. 구조화 출력의 동작은 Ollama 버전에 따라 달라질 수 있으니, 이 글의 결과는 모두 0.34.4에서 나온 것으로 보시면 됩니다. Docker Desktop(macOS) 안에서는 GPU를 쓸 수 없어 CPU로만 추론해요.
  • LLAMA_ARG_CACHE_RAM: 프롬프트 캐시가 쓸 메모리 상한(MiB)입니다. 처음에는 이 줄 없이 돌렸다가 중간에 모델 프로세스가 메모리 부족으로 죽었어요.
  • client: 과제를 보내고 채점하는 Python 컨테이너입니다. python:3.12-slim에 ollama 0.6.3과 jsonschema 4.26.0만 설치했어요. 실습 디렉터리를 /app에 연결해서 결과 파일이 호스트에 남습니다.

compose 파일에 name: ajson이 있어서 프로젝트 이름은 따로 주지 않으셔도 돼요.

클라이언트 컨테이너로 Python 스크립트를 실행하는 함수를 하나 정의해 둘게요. 아래 블록은 파일로 만들지 말고 실습 디렉터리에서 연 터미널에 그대로 붙여 넣어 주세요. 이후 명령은 모두 이 터미널에서 실행하고, 터미널을 새로 열면 다시 붙여 넣으시면 됩니다.

mkdir -p results
docker compose up -d ollama
docker compose build client
docker compose exec ollama ollama pull qwen3:1.7b
lab() { docker compose run --rm -T client python "$@"; }

qwen3:1.7b는 thinking 겸용 모델이라 think를 주지 않으면 추론 텍스트부터 씁니다. 그래서 모든 호출에 think: false를 명시했어요.

모델이 작으니 이 글의 값 정답률은 큰 모델보다 낮게 나올 수 있다는 점을 염두에 두고 봐 주세요.

과제는 이 사이트에 발행된 글의 본문에서 1000자 이하로 잘라 만들었어요. 검색 없이, 주어진 본문에서 값을 뽑는 일만 시켜요. 유형은 네 가지입니다.

  • slug(12건): 본문이 링크한 다른 글의 slug를 뽑습니다. 정답은 링크 주소에서 /posts/ 뒤 부분이고 문자열로 비교합니다.
  • 숫자(13건): 본문에 적힌 기본값, 오류 번호, 실습에서 바꾼 값을 뽑습니다. 한 본문에 숫자가 여러 개 있어서 다른 숫자를 가져오면 틀려요. 수치로 비교합니다.
  • enum(12건): 본문이 다루는 기술(12개 후보) 또는 MySQL 로그 종류(7개 후보)를 고릅니다. 스키마의 enum으로 후보를 제한해요.
  • 중첩 배열(13건): 본문의 compose 파일에 있는 서비스마다 이름과 이미지를 뽑거나, 설정 블록의 이름과 값을 모두 뽑습니다. 순서는 따지지 않고 항목 집합이 같아야 정답이에요.

과제마다 본문(input_text), 할 일(instruction), 스키마(schema), 정답(gold), 채점 규칙(fields)을 tasks.jsonl 한 줄에 담았어요.

과제 id는 유형 순서대로 slug가 t01~t12, 숫자가 t13~t25, enum이 t26~t37, 중첩 배열이 t38~t50입니다. 뒤의 사례 출력에서 id와 유형 다음에 붙은 이름은 본문을 가져온 글의 slug라고 보시면 됩니다.

과제 파일 tasks.jsonl은 이 사이트에 발행된 글의 본문을 잘라 만든 50줄(약 64KB)이고 zip에 들어 있어요. 아래는 그중 세 줄입니다.

{"id": "t22", "kind": "number", "source_slug": "mysql-deadlock", "instruction": "deadlock으로 롤백된 쪽이 받는 오류 번호를 뽑는다.", "schema": {"type": "object", "properties": {"error_code": {"type": ["integer", "null"]}}, "required": ["error_code"], "additionalProperties": false}, "gold": {"error_code": 1213}, "fields": {"error_code": "num"}}
{"id": "t32", "kind": "enum", "source_slug": "mysql-binlog", "instruction": "본문이 주로 다루는 MySQL 로그를 다음 후보에서 하나 고른다: binlog, relay log, redo log, undo log, error log, slow query log, general query log", "schema": {"type": "object", "properties": {"log_type": {"type": "string", "enum": ["binlog", "relay log", "redo log", "undo log", "error log", "slow query log", "general query log"]}}, "required": ["log_type"], "additionalProperties": false}, "gold": {"log_type": "binlog"}, "fields": {"log_type": "str"}}
{"id": "t40", "kind": "nested", "source_slug": "mysql-binlog", "instruction": "본문의 compose 파일에 정의된 서비스마다 서비스 이름과 이미지를 뽑는다.", "schema": {"type": "object", "properties": {"services": {"type": "array", "items": {"type": "object", "properties": {"name": {"type": "string"}, "image": {"type": "string"}}, "required": ["name", "image"], "additionalProperties": false}}}, "required": ["services"], "additionalProperties": false}, "gold": {"services": [{"name": "source", "image": "mysql:8.4"}, {"name": "replica", "image": "mysql:8.4"}]}, "fields": {"services": "set:name,image"}}

본문에 값이 없을 때를 대비해 숫자 필드는 null도 허용했어요. 시스템 프롬프트에서 "모르면 null"이라고 지시하기 때문이에요.

채점은 LLM 판정 없이 규칙으로만 해요. 순서는 이렇습니다. 응답을 그대로 json.loads에 넣고, 읽히면 jsonschema로 과제 스키마를 검사하고, 통과하면 필드마다 정답과 비교해요.

문자열은 NFKC 정규화, 대소문자 무시, 앞뒤 공백과 따옴표 제거 뒤 같은지 보고, 숫자는 수치로 비교합니다.

채점 함수는 grade.py의 grade(task, content)이고, 응답 하나마다 parsed, schema_ok, value_ok와 틀린 필드 목록을 돌려줘요.

방식에 따라 format을 비우거나 "json"이나 과제 스키마로 채워 Ollama chat을 부르는 일은 extract.py의 call이 맡아요.

이 글에서 말하는 "유효하지만 값이 틀림"은 schema_ok가 참이고 value_ok가 거짓인 응답이에요. 틀린 값은 세 가지로 나눠 볼게요.

  • (a) 본문의 다른 값: 틀린 값이 본문 안에 있는 다른 값입니다.
  • (b) 본문에 없는 값: 본문 어디에도 없는 값을 썼습니다.
  • (c) 빈 값/모름: null, 빈 문자열, "unknown", 빈 배열을 썼습니다.

실습 1: 같은 과제를 네 가지 방식으로 보내기

이제 같은 과제를 네 방식으로 보내 볼게요. 시스템 프롬프트는 모든 방식에 같아요.

너는 기술 문서에서 정보를 뽑는 추출기다. 본문에 적힌 내용만 쓰고, 본문에 없는 값은 지어내지 않는다. 본문에서 찾을 수 없는 값은 숫자면 null, 문자열이면 "unknown"으로 쓴다. 설명 없이 JSON 객체 하나로만 답한다.
  • P(프롬프트만): format을 주지 않습니다. 시스템 프롬프트의 "JSON 객체 하나로만 답한다"가 유일한 형식 지시예요.
  • J(format: "json"): JSON으로 답하게 강제하지만 구조는 정하지 않습니다.
  • SN(스키마, 프롬프트에는 없음): format에 과제 스키마를 줍니다. 프롬프트에는 스키마를 쓰지 않아요.
  • SP(스키마, 프롬프트에도 있음): SN과 같은 format에 더해, 사용자 메시지 끝에 스키마를 문자열로 붙입니다.

P, J, SN의 메시지는 글자 하나까지 같고 format만 달라요. 할 일(instruction)은 "오류 번호를 뽑는다"처럼 자연어로만 적고, 필드 이름은 알려 주지 않았어요. 필드 이름을 알려 주는 것은 SP의 스키마 문자열뿐입니다.

모든 호출은 temperature: 0, seed: 42, num_predict: 256, num_ctx: 2048, num_thread: 2로 했어요.

extract.py는 과제마다 네 방식으로 call을 부르고 응답을 결과 파일에 한 줄씩 남겨요. report.py는 결과 파일을 grade.py의 grade로 다시 채점해 두 번째 인자로 고른 보기를 출력합니다.

"유효하지만 틀림"의 분모는 스키마를 통과한 응답 수예요.

lab extract.py P,J,SN,SP results/main.jsonl
lab report.py results/main.jsonl summary
[P] 파싱 49/50 (98%)  스키마 통과 9/50 (18%)  값 정답 9/50 (18%)  유효하지만 틀림 0/9 (0%)
     참고: 코드 블록을 벗기면 파싱 50/50 (100%), 필드 이름을 무시하면 값이 다 들어 있음 37/50 (74%)
     생성 토큰 평균 28, 호출당 1.9~31.2초(평균 8.9초)
[J] 파싱 50/50 (100%)  스키마 통과 10/50 (20%)  값 정답 9/50 (18%)  유효하지만 틀림 1/10 (10%)
     참고: 코드 블록을 벗기면 파싱 50/50 (100%), 필드 이름을 무시하면 값이 다 들어 있음 41/50 (82%)
     생성 토큰 평균 29, 호출당 0.6~11.3초(평균 2.8초)
[SN] 파싱 50/50 (100%)  스키마 통과 50/50 (100%)  값 정답 44/50 (88%)  유효하지만 틀림 6/50 (12%)
     생성 토큰 평균 30, 호출당 0.6~14.6초(평균 3.1초)
[SP] 파싱 50/50 (100%)  스키마 통과 50/50 (100%)  값 정답 46/50 (92%)  유효하지만 틀림 4/50 (8%)
     생성 토큰 평균 27, 호출당 2.4~13.3초(평균 4.9초)

P는 50건 중 49건, J는 50건 모두 파싱됐지만, 과제 스키마를 통과한 응답은 각각 9건, 10건이었습니다.

왜 그럴까요? 필드 이름을 알려 주지 않았으니 모델이 이름을 스스로 지었기 때문이에요.

  • 필드 이름이 제각각이에요. error_code 대신 error_number, linked_slug 대신 slug를 썼어요.
  • 숫자를 문자열("864000")로 썼어요. 스키마는 정수를 요구하니 이름이 맞았어도 통과하지 못합니다.

J에서 필드 이름과 구조를 무시하고, 정답 값이 응답 어딘가에 모두 들어 있는지만 보면 50건 중 41건이었어요.

값은 대부분 뽑았지만, 다음 단계의 코드가 정해진 필드 이름으로는 찾을 수 없는 모양이었던 거죠. format: "json"은 JSON으로 읽히는 것까지만 보장한다고 보시면 됩니다.

스키마를 format에 준 SN과 SP는 50건 모두 파싱되고 50건 모두 스키마를 통과했어요. 필드 이름, 정수 타입, enum 후보, 중첩 배열 안의 name과 image까지 0.34.4에서 실제로 강제됐습니다.

프롬프트에 스키마를 쓰지 않은 SN에서도 필드 이름이 스키마대로 나왔어요.

그런데 값 정답은 SN 44건, SP 46건이에요. 스키마를 통과한 응답 중 SN은 6건(12%), SP는 4건(8%)이 값이 틀렸습니다.

실습 2: 스키마를 통과했지만 틀린 값 보기

Ollama 구조화 출력 문서는 스키마를 format에 주는 것과 별개로, 응답을 잘 유도하려면 스키마를 프롬프트에도 문자열로 넣으라고 권해요. SN과 SP의 차이가 바로 이것이죠.

SN과 SP에서 스키마는 통과했지만 값이 틀린 응답을 과제별로 볼게요. 괄호 안 글자는 앞에서 나눈 틀린 값의 종류예요.

lab report.py results/main.jsonl cases SN
lab report.py results/main.jsonl cases SP
t13 number (c) cassandra-partition-tombstone
  정답: {"gc_grace_seconds_default": 864000}
  응답: {"gc_grace_seconds_default": null}
t14 number (c) docker-oom
  정답: {"max_ram_percentage_default": 25}
  응답: {"max_ram_percentage_default": null}
t15 number (c) grpc-load-balancing
  정답: {"dns_min_interval_default_seconds": 30}
  응답: {"dns_min_interval_default_seconds": null}
t22 number (c) mysql-deadlock
  정답: {"error_code": 1213}
  응답: {"error_code": null}
t27 enum (b) flink-watermark
  정답: {"category": "Flink"}
  응답: {"category": "MySQL"}
t39 nested (a) k8s-graceful-rollout
  정답: {"services": [{"name": "server", "image": "rancher/k3s:v1.35.9-k3s1"}, {"name": "agent", "image": "rancher/k3s:v1.35.9-k3s1"}]}
  응답: {"services": [{"name": "k8sroll-server", "image": "rancher/k3s:v1.35.9-k3s1"}, {"name": "k8sroll-agent", "image": "rancher/k3s:v1.35.9-k3s1"}]}
t07 slug (a) kafka-rebalance
  정답: {"linked_slug": "kafka-consumer-group"}
  응답: {"linked_slug": "/posts/kafka-consumer-group"}
t20 number (c) kafka-retention-offset
  정답: {"default_minutes": 10080, "default_days": 7}
  응답: {"default_minutes": 10080, "default_days": null}
t27 enum (b) flink-watermark
  정답: {"category": "Flink"}
  응답: {"category": "Redis"}
t39 nested (a) k8s-graceful-rollout
  정답: {"services": [{"name": "server", "image": "rancher/k3s:v1.35.9-k3s1"}, {"name": "agent", "image": "rancher/k3s:v1.35.9-k3s1"}]}
  응답: {"services": [{"name": "k8sroll-server", "image": "rancher/k3s:v1.35.9-k3s1"}, {"name": "k8sroll-agent", "image": "rancher/k3s:v1.35.9-k3s1"}]}

SN이 틀린 숫자 과제 4건은 모두 null이었어요. 예를 들어 본문에 "기본값은 864000초"가 분명히 있는데도 gc_grace_seconds_default에 null을 넣었죠.

같은 4건이 SP에서는 모두 맞았어요. 스키마를 프롬프트에 넣어 필드 이름을 미리 보여 주면 이런 "모름"이 줄어든다는 문서의 권고가, 이 과제들에서는 그대로 나타났습니다.

반대로 SP에서만 틀린 과제도 있었어요. t07은 slug에 /posts/kafka-consumer-group처럼 경로를 붙였고, t20은 "10080분(7일)"에서 분은 뽑고 일을 null로 뒀어요.

두 방식의 차이가 2건이라 "스키마를 프롬프트에 넣으면 값 정답률이 오른다"고 일반화하기는 어려워요. 대신 틀리는 모양은 달랐습니다. SN의 오답은 대부분 빈 값이었고, SP의 오답은 값을 채우되 다른 값을 썼어요.

t39는 compose 파일의 서비스 이름을 뽑는 과제예요. 본문의 compose 파일에는 서비스 키 server, agent와 함께 hostname: k8sroll-server, hostname: k8sroll-agent가 있어요.

J, SN, SP 세 방식 모두 서비스 이름에 server 대신 hostname 값인 k8sroll-server를 넣었어요. 이미지는 맞았고 배열 구조도 스키마대로였습니다. 본문에 있는 다른 값을 가져온 (a) 유형이죠.

왜 이렇게 됐을까요? name이라는 필드 이름이 "서비스 키"와 "호스트 이름" 중 무엇을 뜻하는지 스키마만으로는 정해지지 않기 때문이에요. 필드에 description을 달거나 이름을 service_key처럼 더 구체적으로 지으면 줄일 수 있는 오류입니다.

t27은 본문이 다루는 기술을 12개 후보에서 고르는 과제예요. 본문은 Flink 글에서 가져온 문단인데, 워터마크와 지연값 이야기만 있고 "Flink"라는 단어는 없어요. 과제를 만들 때 이 점을 놓쳤어요. 본문만 보고는 답할 수 없는 과제인 거죠.

시스템 프롬프트는 모르면 "unknown"을 쓰라고 했지만, 스키마의 enum에는 "unknown"이 없어요. 형식을 강제하면 모델은 후보 중 하나를 반드시 골라야 하고, 그래서 SN은 MySQL, SP는 Redis를 골랐어요. 본문에 없는 값을 지어낸 (b) 유형입니다.

그러니 "모름"을 허용하려면 스키마의 enum에도 그 값을 넣어 주셔야 해요. 넣지 않으면 답이 없는 본문에서도 후보 중 하나가 정답처럼 나옵니다.

여기서 눈여겨볼 건 이 오류들이 모두 파싱과 스키마 검사를 통과했다는 점이에요. 다음 단계의 코드는 k8sroll-server라는 서비스를 찾다가 실패하거나, Redis 분류로 글을 잘못 묶거나, null을 기본값으로 처리하게 될 거예요.

정리

범위: Ollama 0.34.4, qwen3:1.7b(Q4_K_M), macOS Apple Silicon Docker Desktop에서 CPU 추론, 과제 50건 × 방식 4개, temperature: 0, seed: 42.

  • format에 JSON 스키마를 준 SN과 SP는 50건 모두 파싱되고 스키마를 통과했으며 필드 이름, 정수 타입, enum, 중첩 배열까지 강제됐어요. 다음 단계가 읽을 형식을 맞추려면 format에 스키마를 주세요.
  • 프롬프트만 쓴 P는 49건, format: "json"인 J는 50건이 파싱됐지만 과제 스키마를 통과한 것은 9건과 10건이었고, J는 정답 값이 응답 어딘가에 있는 경우가 41건이었어요. 프롬프트 지시나 format: "json"만으로 필드 이름과 타입이 맞기를 기대하지 마세요.
  • 스키마를 통과한 응답 가운데 SN은 6건(12%), SP는 4건(8%)이 값이 틀렸어요. 파싱과 스키마 검사를 통과한 값도 다음 단계로 넘기기 전에 따로 검사해 주세요.
  • 프롬프트에 스키마가 없는 SN은 본문에 있는 숫자 4건을 null로 비웠고 같은 4건이 SP에서는 모두 맞았지만, 값 정답 차이는 44건과 46건으로 2건이었어요. 스키마는 프롬프트에도 넣되, 2건 차이를 방식의 우열로 읽지는 마세요.
  • enum에 "unknown"이 없는 t27에서 SN과 SP는 본문에 없는 MySQL과 Redis를 골랐고, t39에서는 세 방식이 name에 서비스 키 대신 hostname 값을 넣었어요. "모름"을 허용하려면 enum에 그 값을 넣고, 뜻이 갈릴 필드에는 description을 달거나 이름을 구체적으로 지어 주세요.
내 링크 만들기