본문으로 건너뛰기

© 2026 Molayo

Dev.to헤드라인2026. 06. 17. 15:48

Ollama Structured Outputs 실무 적용 — Pydantic을 사용하여 로컬 LLM으로부터 타입 안전한 JSON 가져오기

요약

Ollama의 format 파라미터를 활용하여 로컬 LLM으로부터 타입 안전한 JSON 출력을 얻는 방법을 설명합니다. Pydantic과 결합하여 JSON 스키마를 적용함으로써 파싱 오류를 방지하고 생성 속도를 획기적으로 개선할 수 있습니다.

핵심 포인트

  • Ollama의 format 파라미터로 제약된 디코딩(Constrained decoding) 적용 가능
  • 마크다운 코드 펜스 등 불필요한 텍스트 생성 방지 및 파싱 안정성 확보
  • 불필요한 토큰 생성을 줄여 추론 속도를 약 6.4배 향상
  • Pydantic을 사용하여 JSON 스키마를 자동으로 생성하고 관리

json.loads(response)가 특정 시점에서 실패합니다. 모델에게 "JSON만 반환해"라고 말했지만, 모델은 모든 내용에

 마크다운 코드 펜스 (markdown code fence)를 추가했습니다. 간단한 정규 표현식(regex)으로 이를 제거할 수 있지만, 그 정규 표현식이 예외 상황(edge case)을 만나면 운영 환경(production)에서 문제가 발생합니다.

Ollama 0.3.0부터는 `format` 파라미터에 JSON 스키마 (JSON schema)를 전달함으로써 이 문제를 근본적으로 해결할 수 있습니다. 모델의 추론 (inference) 자체가 스키마에 의해 제약되므로, 코드 펜스도, 설명 텍스트도, 중간 사고 과정의 부산물(artifacts)도 발생하지 않습니다. 오직 파싱 가능한 JSON만 생성됩니다.

저는 Gemma4와 Ollama 0.30.7을 사용하여 실무에서 얼마나 잘 작동하는지 로컬에서 테스트를 진행했습니다.

## LLM 응답 파싱이 까다로운 이유

클라우드 LLM API 없이 Ollama를 로컬에서 실행할 때 발생하는 가장 흔한 문제는 JSON 파싱입니다. 여기에는 두 가지 이유가 있습니다.

첫째, 텍스트 생성 모델들은 "자연스러운 텍스트 (natural text)"를 생성하도록 훈련되었습니다. JSON만 요청하더라도, 종종 `json ... ` 블록으로 감싸거나 "물론입니다! 요청하신 JSON은 다음과 같습니다:"와 같은 스타일의 텍스트를 앞에 붙이곤 합니다. 제가 직접 재현한 결과는 다음과 같습니다:

json
입력: 'tips (array), difficulty (1-5) 키를 가진 JSON 형식으로 3가지 Python 팁을 알려줘'
...

{
  "tips": [
    "먼저 기초를 마스터하세요...",
    ...
  ]
}

JSON 파싱: 실패 (FAILED)


Python의 `json.loads()`는 마크다운 래퍼 (markdown wrapper)를 처리할 수 없습니다. "JSON만"이라는 지시어는 운영 환경에서 신뢰할 수 없습니다.

둘째, 속도입니다. 저는 동일한 쿼리를 두 가지 방식으로 측정했습니다: 구조화된 출력 (structured output)이 없을 때는 32초, 있을 때는 5초가 걸렸습니다. 왜 그런지에 대해서는 아래에서 더 자세히 다루겠습니다.

## Ollama format 파라미터의 작동 방식

Ollama의 `/api/generate` 엔드포인트에는 `format` 필드가 있습니다. JSON 스키마 객체를 전달하면 Ollama는 추론 중에 **제약된 디코딩 (constrained decoding)**을 적용합니다.

python
...


제약된 디코딩 (Constrained decoding)은 각 생성 단계에서 스키마 (schema)를 위반하는 모든 토큰의 확률을 0으로 설정합니다. 따라서 모델이 마크다운 펜스 (markdown fence)를 생성하고 "싶어" 하더라도, 스키마는 이를 물리적으로 불가능하게 만듭니다. 이것이 바로 속도 향상이 발생하는 지점이기도 합니다. 모델이 포맷팅 결정을 내리는 데 토큰을 낭비하지 않기 때문입니다.

측정된 수치는 다음과 같습니다:

bash
...


6.4배의 차이입니다. 로컬 LLM은 이미 느린 편인데, 그 위에 신뢰할 수 없는 파싱 (parsing)까지 더해지면 전체 파이프라인이 훨씬 더 나쁘게 느껴집니다.

## Pydantic 모델 연결하기

JSON 스키마 (JSON schema) 객체를 수동으로 작성하는 것은 지루한 일입니다. Pydantic 모델을 사용하면 `model_json_schema()`가 스키마를 자동으로 생성합니다.

```python
from pydantic import BaseModel
from typing import List, Dict, Any, Literal

...

model_validate_json은 JSON 문자열을 파싱 (parse)함과 동시에 Pydantic 검증 (validation)을 수행합니다. 만약 severity에 정수가 들어오거나 line에 문자열이 들어오면 ValidationError를 발생시킵니다. 이를 포착하여 수정된 프롬프트 (prompt)로 재시도하는 것이 실제 에이전트 (agent)에서 흔히 쓰이는 패턴입니다.

코드 리뷰 테스트의 실제 출력 결과:

=== Code Review Output ===
Total issues: 3
Critical: 2
...

total_issues: 3critical_count: 2는 정수 (integer) 형태로 들어옵니다. 따라서 if result.critical_count > 0 분기 처리를 안전하게 수행할 수 있습니다.

실무 패턴: 에이전트 도구 디스패치 (Agent Tool Dispatch)

구조화된 출력 (structured output)의 가장 강력한 사용 사례는 에이전트가 다음에 호출할 도구를 결정하는 것입니다. 도구 목록과 현재 상황을 전달하면, 타입 안전한 (type-safe) 도구 호출 선택 결과를 돌려받습니다.

from typing import Literal, Dict, Any

class ToolCall(BaseModel):
...
=== Agent tool dispatch ===
Tool: web_search
Params: {'query': 'current Bitcoin price'}
...

tool_nameLiteral["web_search", "read_file", ...]로 타입 지정되어 있기 때문에, tool_call.tool_name은 항상 이 네 가지 값 중 하나가 됩니다. 만약 모델이 존재하지 않는 도구 이름을 지어낸다면, Pydantic은 ValidationError를 발생시킵니다. 따라서 if tool_call.tool_name == "web_search" 분기 코드를 안심하고 작성할 수 있습니다.

이것은 구조적으로 클라우드 API의 함수 호출 (Function Calling) 방식과 동일합니다. Claude Agent SDK의 도구 사용 패턴 (Claude Agent SDK's Tool Use patterns)과 비교해 보면 흥미로운 설계 차이점이 나타납니다. 클라우드 LLM은 모델 수준에서 도구 선택을 네이티브하게 처리하는 반면, 로컬 Ollama는 명시적인 JSON 스키마 (JSON Schema)와 Pydantic 검증 레이어 (Validation Layer)가 필요합니다.

Gemma4와 스키마 복잡성: 내가 발견한 한계점

솔직히 말해서, 모든 경우에 완벽하게 작동하는 것은 아닙니다. Gemma4:e4b (4비트 양자화, 4B 파라미터)로 테스트했을 때 몇 가지 실질적인 제약 사항을 발견했습니다.

깊게 중첩된 스키마 (Deeply nested schemas). 3단계 이상 깊게 중첩된 JSON 스키마 (List[Dict[str, List[BaseModel]]])의 경우, 중간 단계에서 빈 배열을 반환하는 경우가 가끔 발생합니다. 12B 모델 (gemma4:12b-it-qat)을 사용하면 이 현상이 줄어들기는 하지만 완전히 사라지지는 않습니다. 이는 모델의 컨텍스트 처리 (Context Handling) 능력에 따른 근본적인 한계입니다.

선택적 필드 처리 (Optional field handling). Optional[str]로 선언된 필드가 null 대신 빈 문자열 ""로 채워지는 경우가 있습니다. Pydantic 검증은 통과하지만, 의미론적(Semantics)으로는 차이가 있습니다. 이 경우 @validator를 통한 후처리 (Post-processing)가 필요합니다.

스키마 크기 (Schema size). 대규모 Pydantic 모델의 JSON 스키마는 수백 개의 토큰에 달할 수 있습니다. 이는 컨텍스트 윈도우 (Context Window) 공간을 차지하여 실제 프롬프트 (Prompt)에 사용할 수 있는 여유 공간을 줄입니다. 복잡한 스키마를 다루려면 더 강력한 모델이 필요합니다.

Ollama를 API 서버로 배포하고 나면 (Ollama FastAPI 프로덕션 가이드 (Ollama FastAPI production guide)에서 다룸), 스키마 복잡도에 따라 런타임 (Runtime) 중에 모델을 전환하는 것이 실행 가능한 최적화 방법이 됩니다.

패턴 참조: 언제 무엇을 사용할 것인가

상황 (Situation)접근 방식 (Approach)이유 (Why)
단순 데이터 추출 (1-2단계)format + json.loads()빠르고 오버헤드가 없음
.........

저는 이것을 JSON 파싱의 신뢰도를 "불안정한" 상태에서 "100%에 가까운" 상태로 옮겨주는 스위치라고 생각합니다. 모든 프롬프트 끝에 "JSON으로만 답변해 주세요"라고 덧붙이며 요행을 바라던 시절이 있었습니다. 실제 차이를 측정해 보니 그 방식이 얼마나 취약했는지 명확히 알 수 있었습니다.

바로 복사해서 사용할 수 있는 시작 코드 (Copy-Paste Starter Code)

import json
import urllib.request
from typing import List, Optional, Dict, Any
...

다음에 시도해 볼 것들 (What to Try Next)

이 내용은 가장 단순한 사례만을 다룹니다. 실제 에이전트(Agent)에는 조금 더 많은 것이 필요합니다.

재시도 로직 (Retry logic). Pydantic의 ValidationError가 발생하면, 프롬프트를 약간 수정하여 재시도하세요. 이때 에러 메시지를 포함하는 것이 이상적입니다. 모델은 자신이 왜 틀렸는지 알 수 있을 때 스스로를 수정하는 경우가 많습니다.

스트리밍 (Streaming). stream: true를 사용하면 JSON이 생성되는 대로 점진적으로 받을 수 있습니다. 대규모 응답을 메모리 효율적으로 처리하려면 ijson과 같은 스트리밍 JSON 파서(Streaming JSON parser)와 함께 사용하세요.

모델 전환 (Model switching). 런타임 중에 단순 추출은 gemma4:e4b (빠름)로, 복잡한 중첩 스키마(Nested schema)는 gemma4:12b-it-qat (정확함)로 라우팅하세요. Pydantic AI로 전체 에이전트 구조화하기에서는 이러한 결정을 프레임워크 수준으로 추상화하는 방법을 보여줍니다.

이미 Gemma4 기반의 에이전트를 로컬에서 실행 중이라면, 오늘 바로 format 파라미터를 추가하는 것만으로도 측정 가능한 신뢰도 향상을 가져오는 단 한 줄의 코드 변경이 가능합니다. 특히 에이전트 루프(Agent loop) 내에서 잘못된 응답이 즉각적으로 다운스트림(Downstream) 에러를 유발하는 구간이라면 더욱 그렇습니다.

AI 자동 생성 콘텐츠

본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0