구조화된 출력으로 LLM 제약: Ollama, Qwen3 및 Python 또는 Go

Ollama에서 구조화된 출력을 얻는 몇 가지 방법

Page content

대형 언어 모델(LLM) 은 강력한 기능을 가지고 있지만, 실제 프로덕션 환경에서는 자유로운 형식의 단락(free-form paragraphs)을 원하는 경우가 드뭅니다. 대신 우리는 예측 가능한 데이터를 원합니다. 즉, 애플리케이션에 입력할 수 있는 속성, 사실 또는 구조화된 객체를 말입니다. 이것이 바로 LLM 구조화된 출력(Structured Output)입니다.

스키마 강제 적용은 잘못된 로짓(logits)이 유효하지 않은 JSON으로 변환되는 빈도를 줄이지만, 재시도 폭풍(retry storms)을 유발하는 원인이 되는 temperature와 penalty 설정은 여전히 중요합니다. 에이전트와 format 제약 조건을 결합할 때는 Qwen 및 Gemma의 에이전틱 추론 파라미터를 참조하십시오.

얼마 전 Ollama는 구조화된 출력 지원을 도입했습니다 (공지사항), 이를 통해 모델의 응답을 JSON 스키마와 일치하도록 제한할 수 있게 되었습니다. 이 기능은 LLM 기능 목록화, 모델 벤치마킹, 시스템 통합 자동화 등의 작업에서 일관된 데이터 추출 파이프라인을 구축할 수 있게 해줍니다.

ducks in a row

이 게시물에서는 다음 내용을 다룹니다:

  • 구조화된 출력의 정의와 그 중요성
  • LLM에서 구조화된 출력을 얻는 간단한 방법
  • Ollama의 새로운 기능이 작동하는 방식
  • LLM 기능 추출 예제:

구조화된 출력(Structured Output)이란 무엇인가요?

일반적으로 LLM은 자유 형식의 텍스트를 생성합니다:

“Model X는 chain-of-thought 추론을 지원하며, 컨텍스트 윈도우가 200K 토큰이고 영어, 중국어, 스페인어를 지원합니다.”

이것은 사람이 읽기에는 적합하지만, 파싱(parsing)하기 어렵습니다.

반면, 구조화된 출력을 사용하면 엄격한 스키마를 요청합니다:

{
  "name": "Model X",
  "supports_thinking": true,
  "max_context_tokens": 200000,
  "languages": ["English", "Chinese", "Spanish"]
}

이 JSON은 검증하기 쉽고, 데이터베이스에 저장하거나 UI에 입력하기 용이합니다.


LLM에서 구조화된 출력을 얻는 간단한 방법

LLM은 때때로 스키마의 의미를 이해하며, 우리는 LLM에게 특정 스키마에 따라 JSON 형식으로 출력을 반환하도록 요청할 수 있습니다. 알리바바의 Qwen3 모델은 추론 및 구조화된 응답을 위해 최적화되어 있습니다. 명시적으로 JSON 형식으로 응답하도록 지시할 수 있습니다.

예제 1: Python에서 ollama와 Qwen3 사용, 스키마 포함 JSON 요청

import json
import ollama

prompt = """
당신은 구조화된 데이터 추출기입니다.
JSON만 반환하세요.
텍스트: "Elon Musk is 53 and lives in Austin."
스키마: { "name": string, "age": int, "city": string }
"""

response = ollama.chat(model="qwen3", messages=[{"role": "user", "content": prompt}])
output = response['message']['content']

# JSON 파싱
try:
    data = json.loads(output)
    print(data)
except Exception as e:
    print("JSON 파싱 오류:", e)

출력:

{"name": "Elon Musk", "age": 53, "city": "Austin"}

Pydantic을 사용한 스키마 검증 강제

잘못된 형식의 출력을 방지하기 위해 Python에서 Pydantic 스키마에 대해 검증할 수 있습니다.

from pydantic import BaseModel

class Person(BaseModel):
    name: str
    age: int
    city: str

# 'output'이 Qwen3에서 온 JSON 문자열이라고 가정
data = Person.model_validate_json(output)
print(data.name, data.age, data.city)

이렇게 하면 출력이 예상된 구조를 따르는지 보장할 수 있습니다.


Ollama의 구조화된 출력

Ollama는 이제 format 파라미터에 스키마를 전달할 수 있게 되었습니다. 모델은 이 스키마를 준수하는 JSON 형식으로만 응답하도록 제한됩니다 (문서).

Python에서는 일반적으로 Pydantic으로 스키마를 정의하고, Ollama가 이를 JSON 스키마로 사용하도록 합니다.


예제 2: LLM 기능 메타데이터 추출

LLM의 기능을 설명하는 텍스트 조각이 있다고 가정해 봅시다:

“Qwen3은 강력한 다국어 지원(영어, 중국어, 프랑스어, 스페인어, 아랍어)을 제공합니다. 추론 단계(chain-of-thought)를 허용합니다. 컨텍스트 윈도우는 128K 토큰입니다.”

구조화된 데이터를 원한다고 가정합니다:

from pydantic import BaseModel
from typing import List
from ollama import chat

class LLMFeatures(BaseModel):
    name: str
    supports_thinking: bool
    max_context_tokens: int
    languages: List[str]

prompt = """
다음 설명을 분석하고 모델의 기능을 JSON 형식으로만 반환하세요.
모델 설명:
'Qwen3 has strong multilingual support (English, Chinese, French, Spanish, Arabic).
It allows reasoning steps (chain-of-thought).
The context window is 128K tokens.'
"""

resp = chat(
    model="qwen3",
    messages=[{"role": "user", "content": prompt}],
    format=LLMFeatures.model_json_schema(),
    options={"temperature": 0},
)

print(resp.message.content)

예상 출력:

{
  "name": "Qwen3",
  "supports_thinking": true,
  "max_context_tokens": 128000,
  "languages": ["English", "Chinese", "French", "Spanish", "Arabic"]
}

예제 3: 여러 모델 비교

여러 모델의 설명을 입력하여 구조화된 형태로 추출합니다:

from typing import List

class ModelComparison(BaseModel):
    models: List[LLMFeatures]

prompt = """
각 모델의 기능을 JSON 형식으로 추출하세요.

1. Llama 3.1은 추론을 지원합니다. 컨텍스트 윈도우는 128K입니다. 언어: 영어만 지원.
2. GPT-4 Turbo는 추론을 지원합니다. 컨텍스트 윈도우는 128K입니다. 언어: 영어, 일본어.
3. Qwen3은 추론을 지원합니다. 컨텍스트 윈도우는 128K입니다. 언어: 영어, 중국어, 프랑스어, 스페인어, 아랍어.
"""

resp = chat(
    model="qwen3",
    messages=[{"role": "user", "content": prompt}],
    format=ModelComparison.model_json_schema(),
    options={"temperature": 0},
)

print(resp.message.content)

출력:

{
  "models": [
    {
      "name": "Llama 3.1",
      "supports_thinking": true,
      "max_context_tokens": 128000,
      "languages": ["English"]
    },
    {
      "name": "GPT-4 Turbo",
      "supports_thinking": true,
      "max_context_tokens": 128000,
      "languages": ["English", "Japanese"]
    },
    {
      "name": "Qwen3",
      "supports_thinking": true,
      "max_context_tokens": 128000,
      "languages": ["English", "Chinese", "French", "Spanish", "Arabic"]
    }
  ]
}

이를 통해 모델의 기능별로 벤치마킹, 시각화 또는 필터링을 수행하는 것이 매우 간단해집니다.


예제 4: 누락된 값 자동 감지

필드가 누락된 경우 null 값을 허용할 수도 있습니다:

from typing import Optional

class FlexibleLLMFeatures(BaseModel):
    name: str
    supports_thinking: Optional[bool]
    max_context_tokens: Optional[int]
    languages: Optional[List[str]]

이렇게 하면 일부 정보가 알려지지 않았더라도 스키마가 유효하게 유지됩니다.


이점, 주의사항 및 모범 사례

Ollama(또는 이를 지원하는 다른 시스템)를 통해 구조화된 출력을 사용하면 많은 이점이 있지만, 몇 가지 주의사항도 있습니다.

이점

  • 강화된 보장: 모델이 자유 형식 텍스트 대신 JSON 스키마를 따르도록 요청합니다.
  • 쉬운 파싱: 정규식(regex)이나 휴리스틱 대신 json.loads를 직접 사용하거나 Pydantic/Zod로 검증할 수 있습니다.
  • 스키마 기반 진화: 스키마의 버전을 관리하고 필드(기본값 포함)를 추가하여 후방 호환성을 유지할 수 있습니다.
  • 상호 운용성: 다운스트림 시스템은 구조화된 데이터를 기대합니다.
  • 결정론적 특성(낮은 temperature 사용 시 향상): temperature가 낮을 때(예: 0), 모델은 스키마에 더 엄격하게 따를 가능성이 높습니다. Ollama 문서에서도 이를 권장합니다.

주의사항 및 함정

  • 스키마 불일치: 모델이 여전히 벗어날 수 있습니다. 예를 들어 필수 속성을 누락하거나 키 순서를 변경하거나 추가 필드를 포함할 수 있습니다. 검증이 필요합니다.
  • 복잡한 스키마: 매우 깊거나 재귀적인 JSON 스키마는 모델을 혼란스럽게 하거나 실패로 이어질 수 있습니다.
  • 프롬프트의 모호성: 프롬프트가 모호하면 모델이 필드나 단위를 잘못 추측할 수 있습니다.
  • 모델 간 불일치: 일부 모델은 구조화된 제약을 준수하는 능력이 더 우수하거나 열악할 수 있습니다.
  • 토큰 제한: 스키마 자체는 프롬프트나 API 호출에 토큰 비용을 추가합니다.

모범 사례 및 팁(Ollama 블로그 및 경험 기반)

  • 스키마를 정의하고 JSON 스키마를 자동 생성하려면 Pydantic(Python) 또는 **Zod(JavaScript)**를 사용하세요. 이를 통해 수동 오류를 방지할 수 있습니다.
  • 프롬프트에 항상 “JSON 형식으로만 응답” 또는 **“설명이나 추가 텍스트를 포함하지 마세요”**와 같은 지침을 포함하세요.
  • 무작위성을 최소화하고 스키마 준수를 극대화하려면 temperature = 0(또는 매우 낮게)을 사용하세요. Ollama는 결정론적 접근을 권장합니다.
  • JSON 파싱 또는 스키마 검증이 실패할 경우 검증 및 잠재적 fallback(예: 재시도 또는 정리)을 수행하세요.
  • 간단한 스키마로 시작한 후 점진적으로 확장하세요. 초기에 지나치게 복잡하게 만들지 마세요.
  • 유용하지만 제한적인 오류 지침을 포함하세요: 예를 들어 모델이 필수 필드를 채울 수 없는 경우, 스키마가 허용한다면 생략하는 대신 null로 응답하도록 하세요.

Go 예제 1: LLM 기능 추출

다음은 Qwen3에 LLM 기능에 대한 구조화된 출력을 요청하는 간단한 Go 프로그램입니다.

package main

import (
	"context"
	"encoding/json"
	"fmt"
	"log"

	"github.com/ollama/ollama/api"
)

type LLMFeatures struct {
	Name             string   `json:"name"`
	SupportsThinking bool     `json:"supports_thinking"`
	MaxContextTokens int      `json:"max_context_tokens"`
	Languages        []string `json:"languages"`
}

func main() {
	client, err := api.ClientFromEnvironment()
	if err != nil {
		log.Fatal(err)
	}

	prompt := `
  Analyze the following description and return the model’s features in JSON only.
  Description:
  "Qwen3 has strong multilingual support (English, Chinese, French, Spanish, Arabic).
  It allows reasoning steps (chain-of-thought).
  The context window is 128K tokens."
  `

	// Define the JSON schema for structured output
	formatSchema := map[string]any{
		"type": "object",
		"properties": map[string]any{
			"name": map[string]string{
				"type": "string",
			},
			"supports_thinking": map[string]string{
				"type": "boolean",
			},
			"max_context_tokens": map[string]string{
				"type": "integer",
			},
			"languages": map[string]any{
				"type": "array",
				"items": map[string]string{
					"type": "string",
				},
			},
		},
		"required": []string{"name", "supports_thinking", "max_context_tokens", "languages"},
	}

	// Convert schema to JSON
	formatJSON, err := json.Marshal(formatSchema)
	if err != nil {
		log.Fatal("Failed to marshal format schema:", err)
	}

	req := &api.GenerateRequest{
		Model:   "qwen3:8b",
		Prompt:  prompt,
		Format:  formatJSON,
		Options: map[string]any{"temperature": 0},
	}

	var features LLMFeatures
	var rawResponse string
	err = client.Generate(context.Background(), req, func(response api.GenerateResponse) error {
		// Accumulate content as it streams
		rawResponse += response.Response

		// Only parse when the response is complete
		if response.Done {
			if err := json.Unmarshal([]byte(rawResponse), &features); err != nil {
				return fmt.Errorf("JSON parse error: %v", err)
			}
		}
		return nil
	})
	if err != nil {
		log.Fatal(err)
	}

	fmt.Printf("Parsed struct: %+v\n", features)
}

이 예제 Go 프로그램을 컴파일하고 실행하려면 - 이 main.go 파일이 ollama-struct 폴더 안에 있다고 가정합니다. 이 폴더 내에서 다음을 실행해야 합니다:

# initialise module
go mod init ollama-struct
# pull all the dependencise
go mod tidy
# build & execute
go build -o ollama-struct main.go
./ollama-struct

예상 출력

Parsed struct: {Name:Qwen3 SupportsThinking:true MaxContextTokens:128000 Languages:[English Chinese French Spanish Arabic]}

Go 예제 2: 여러 모델 비교

이 기능을 확장하여 비교할 모델 목록을 추출할 수 있습니다.

  type ModelComparison struct {
		Models []LLMFeatures `json:"models"`
	}

	prompt = `
Extract features from the following model descriptions and return as JSON:

	1. PaLM 2: This model has limited reasoning capabilities and focuses on basic language understanding. It supports a context window of 8,000 tokens. It primarily supports English language only.
	2. LLaMA 2: This model has moderate reasoning abilities and can handle some logical tasks. It can process up to 4,000 tokens in its context. It supports English, Spanish, and Italian languages.
	3. Codex: This model has strong reasoning capabilities specifically for programming and code analysis. It has a context window of 16,000 tokens. It supports English, Python, JavaScript, and Java languages.

	Return a JSON object with a "models" array containing all models.
	`

	// Define the JSON schema for model comparison
	comparisonSchema := map[string]any{
		"type": "object",
		"properties": map[string]any{
			"models": map[string]any{
				"type": "array",
				"items": map[string]any{
					"type": "object",
					"properties": map[string]any{
						"name": map[string]string{
							"type": "string",
						},
						"supports_thinking": map[string]string{
							"type": "boolean",
						},
						"max_context_tokens": map[string]string{
							"type": "integer",
						},
						"languages": map[string]any{
							"type": "array",
							"items": map[string]string{
								"type": "string",
							},
						},
					},
					"required": []string{"name", "supports_thinking", "max_context_tokens", "languages"},
				},
			},
		},
		"required": []string{"models"},
	}

	// Convert schema to JSON
	comparisonFormatJSON, err := json.Marshal(comparisonSchema)
	if err != nil {
		log.Fatal("Failed to marshal comparison schema:", err)
	}

	req = &api.GenerateRequest{
		Model:   "qwen3:8b",
		Prompt:  prompt,
		Format:  comparisonFormatJSON,
		Options: map[string]any{"temperature": 0},
	}

	var comp ModelComparison
	var comparisonResponse string
	err = client.Generate(context.Background(), req, func(response api.GenerateResponse) error {
		// Accumulate content as it streams
		comparisonResponse += response.Response

		// Only parse when the response is complete
		if response.Done {
			if err := json.Unmarshal([]byte(comparisonResponse), &comp); err != nil {
				return fmt.Errorf("JSON parse error: %v", err)
			}
		}
		return nil
	})
	if err != nil {
		log.Fatal(err)
	}

	for _, m := range comp.Models {
		fmt.Printf("%s: Context=%d, Languages=%v\n", m.Name, m.MaxContextTokens, m.Languages)
	}

예상 출력

PaLM 2: Context=8000, Languages=[English]
LLaMA 2: Context=4000, Languages=[English Spanish Italian]
Codex: Context=16000, Languages=[English Python JavaScript Java]

참고로, 이 예제에서는 qwen3:4b도 qwen3:8b와 동일하게 잘 작동합니다.

Go 개발자를 위한 모범 사례

  • temperature를 0으로 설정하여 스키마 준수를 최대화하세요.
  • 파싱이 실패할 경우 fallback 처리와 함께 json.Unmarshal로 검증하세요.
  • 스키마를 단순하게 유지하세요. 깊게 중첩되거나 재귀적인 JSON 구조는 문제를 일으킬 수 있습니다.
  • 누락된 데이터를 예상한다면 선택적 필드를 허용하세요(Go 구조체 태그에서 omitempty 사용).
  • 모델이 간헐적으로 유효하지 않은 JSON을 생성하는 경우 재시도 로직을 추가하세요.

전체 예제 - LLM 스펙으로 차트 그리기 (단계별: 구조화된 JSON에서 비교 테이블로)

llm-chart

  1. 원하는 데이터에 대한 스키마 정의

Pydantic을 사용하여 (a) Ollama용 JSON 스키마를 생성하고 (b) 모델의 응답을 검증할 수 있습니다.

from pydantic import BaseModel
from typing import List, Optional

class LLMFeatures(BaseModel):
    name: str
    supports_thinking: bool
    max_context_tokens: int
    languages: List[str]
  1. Ollama에게 해당 형식의 JSON만 반환하도록 요청

format=에 스키마를 전달하고 결정론적 처리를 위해 temperature를 낮춥니다.

from ollama import chat

prompt = """
Extract features for each model. Return JSON only matching the schema.
1) Qwen3 supports chain-of-thought; 128K context; English, Chinese, French, Spanish, Arabic.
2) Llama 3.1 supports chain-of-thought; 128K context; English.
3) GPT-4 Turbo supports chain-of-thought; 128K context; English, Japanese.
"""

resp = chat(
    model="qwen3",
    messages=[{"role": "user", "content": prompt}],
    format={"type": "array", "items": LLMFeatures.model_json_schema()},
    options={"temperature": 0}
)

raw_json = resp.message.content  # JSON list of LLMFeatures
  1. 검증 및 정규화

프로덕션에서 사용하기 전에 항상 검증하세요.

from pydantic import TypeAdapter

adapter = TypeAdapter(list[LLMFeatures])
models = adapter.validate_json(raw_json)  # -> list[LLMFeatures]
  1. 비교 테이블 생성(pandas)

검증된 객체를 정렬/필터 및 내보낼 수 있는 DataFrame으로 변환합니다.

import pandas as pd

df = pd.DataFrame([m.model_dump() for m in models])
df["languages_count"] = df["languages"].apply(len)
df["languages"] = df["languages"].apply(lambda xs: ", ".join(xs))

# Reorder columns for readability
df = df[["name", "supports_thinking", "max_context_tokens", "languages_count", "languages"]]

# Save as CSV for further use
df.to_csv("llm_feature_comparison.csv", index=False)
  1. (선택사항) 빠른 시각화

간단한 차트는 모델 간의 차이를 빠르게 파악하는 데 도움이 됩니다.

import matplotlib.pyplot as plt

plt.figure()
plt.bar(df["name"], df["max_context_tokens"])
plt.title("Max Context Window by Model (tokens)")
plt.xlabel("Model")
plt.ylabel("Max Context Tokens")
plt.xticks(rotation=20, ha="right")
plt.tight_layout()
plt.savefig("max_context_window.png")

TL;DR

Ollama의 새로운 구조화된 출력 지원을 통해 LLM을 단순한 챗봇이 아닌 데이터 추출 엔진으로 다룰 수 있습니다.

위의 예제는 추론 지원, 컨텍스트 윈도우 크기, 지원 언어와 같은 LLM 기능에 대한 구조화된 메타데이터를 자동으로 추출하는 방법을 보여주었습니다. 이는 그렇지 않았다면 취약한 파싱이 필요한 작업입니다.

LLM 모델 카탈로그, 평가 대시보드, 또는 AI 기반 연구 보조 도구를 구축하고 있든 상관없이, 구조화된 출력을 통해 통합이 원활하고 신뢰할 수 있으며 프로덕션 준비가 된 상태가 됩니다.

유용한 링크

구독하기

시스템, 인프라, AI 엔지니어링에 관한 새 글을 받아보세요.