로컬에서 LLM을 돌려 다른 시스템에 결과를 넘길 때 가장 성가신 건 모델이 JSON을 요청해도 앞뒤에 설명을 붙이거나 중괄호를 흘린다는 점이다. 파싱 코드에 try/except를 둘러도 열 번에 한 번씩 깨지면 운영에서는 결국 사람이 로그를 뒤지게 된다. Ollama는 v0.5부터 이 문제를 format 파라미터로 정면으로 해결했고, v0.34.4에서는 thinking 모델과의 궁합까지 손봤다.
결론부터 말하자면, format에 JSON 스키마 객체를 넣으면 Ollama가 디코딩 단계에서 토큰 생성을 제약해 스키마에 맞는 JSON만 뱉어낸다. 프롬프트로 "JSON으로 답해"라고 부탁하는 것과 달리 모델이 스키마를 벗어날 수 없다. thinking 모델에서도 v0.34.4부터 추론과 구조화 출력이 한 번의 패스로 처리된다.
1. format이 하는 일
구조화 출력(structured outputs)은 모델의 응답을 미리 정한 JSON 스키마에 묶는 기능이다. Ollama의 format 파라미터는 두 가지 값을 받는다. 문자열 "json"을 주면 예전부터 있던 JSON 모드로, 유효한 JSON이라는 보장만 준다. 반면 JSON 스키마 객체를 주면 필드 이름, 타입, 필수 항목까지 강제한다. 공식 문서 표현으로는 "the model will generate a response that matches the schema"이다.
차이가 중요하다. "json" 모드는 {"answer": "..."}든 {"foo": 1}든 JSON이기만 하면 통과시킨다. 스키마 모드는 내가 정의한 키가 반드시 들어오므로 뒤에서 model_validate_json() 같은 검증이 거의 항상 성공한다.
먼저 버전을 확인한다. 아래 명령 결과의 버전 번호는 설치 상태에 따라 다를 수 있다.
$ ollama --version ollama version is 0.34.4
구조화 출력 자체는 0.5 이상이면 동작한다. thinking 모델 단일 패스 처리까지 쓰려면 0.34.4 이상으로 올린다.
2. curl로 스키마를 걸어 보기
/api/chat에 format으로 스키마를 넘긴다. 국가 정보를 뽑는 예제다.
curl http://localhost:11434/api/chat -d '{
"model": "llama3.1",
"messages": [{"role": "user", "content": "캐나다에 대해 알려줘"}],
"stream": false,
"options": {"temperature": 0},
"format": {
"type": "object",
"properties": {
"name": {"type": "string"},
"capital": {"type": "string"},
"languages": {"type": "array", "items": {"type": "string"}}
},
"required": ["name", "capital", "languages"]
}
}'
응답의 message.content에 스키마에 맞는 JSON 문자열이 들어온다.
{
"message": {
"role": "assistant",
"content": "{\"name\": \"Canada\", \"capital\": \"Ottawa\", \"languages\": [\"English\", \"French\"]}"
},
"done": true
}
content는 여전히 문자열이다. Ollama가 보장하는 건 그 문자열이 스키마에 맞는 JSON이라는 점이지, 응답 본문이 객체로 바뀌는 게 아니다. 받는 쪽에서 한 번 더 파싱한다.
options의 temperature를 0으로 두는 게 핵심이다. 구조화 출력에서는 창의성이 필요 없고, 0으로 두면 같은 입력에 같은 출력이 나와 디버깅이 수월하다.
3. Python에서 pydantic으로 검증까지
실무에서는 스키마를 손으로 쓰지 않고 pydantic 모델에서 뽑아낸다. model_json_schema()가 바로 format에 넣을 스키마를 만들어 준다.
from ollama import chat
from pydantic import BaseModel
class Country(BaseModel):
name: str
capital: str
languages: list[str]
resp = chat(
model='llama3.1',
messages=[{'role': 'user', 'content': '캐나다에 대해 알려줘'}],
format=Country.model_json_schema(),
options={'temperature': 0},
)
country = Country.model_validate_json(resp.message.content)
print(country)
name='Canada' capital='Ottawa' languages=['English', 'French']
모델 정의 한 곳이 요청 스키마와 응답 검증 양쪽의 단일 소스가 된다. 필드를 늘리면 프롬프트는 그대로 두고 모델 클래스만 고치면 된다. JavaScript 쪽은 zod 스키마를 zodToJsonSchema()로 변환해 같은 자리에 넣으면 동일하게 동작한다.
막상 돌려보면 스키마는 맞는데 값이 비어 나오는 경우가 있다. 대개 프롬프트가 모델에게 무엇을 채우라고 알려주지 않아서다. 스키마는 모양만 강제하지 내용을 지어내 주지 않으므로, 프롬프트에 어떤 정보를 원하는지 명시하고 필요하면 "JSON으로 답하라"는 지시를 함께 준다.
4. thinking 모델에서 한 번에 끝내기
thinking 모델은 답하기 전에 추론 과정을 먼저 생성하는 모델이다. Qwen 계열의 추론 모드가 대표적이다. 문제는 이 추론 텍스트가 구조화 출력과 부딪쳤다는 점이다. 예전 버전에서는 모델이 생각을 길게 늘어놓은 뒤 그 블록에 스키마를 적용하려다 한 번 더 패스를 돌아야 했고, 그만큼 느리고 결과도 들쭉날쭉했다.
v0.34.4 릴리스 노트의 표현은 간결하다. "Structured outputs on thinking models now apply in a single pass, making them faster and more reliable." 추론은 추론대로 생성하고, 최종 답변만 스키마에 묶는 작업을 한 패스로 처리한다는 뜻이다.
think 파라미터는 불리언 또는 "low", "medium", "high", "max" 레벨을 받는다. format과 같이 넘기면 된다.
resp = chat(
model='qwen3',
messages=[{'role': 'user', 'content': '3과 5의 최소공배수를 구해줘'}],
think=True,
format={
'type': 'object',
'properties': {
'answer': {'type': 'integer'},
'reason': {'type': 'string'},
},
'required': ['answer'],
},
options={'temperature': 0},
)
print(resp.message.thinking) # 추론 과정
print(resp.message.content) # 스키마에 맞는 JSON
추론 텍스트는 message.thinking으로, 스키마에 묶인 최종 답은 message.content로 분리돼 온다. 파서는 content만 보면 되고, 로그로 추론을 남기고 싶으면 thinking을 따로 저장한다.
5. tool calling과 섞을 때
tool calling은 모델이 직접 답하는 대신 외부 함수를 호출하도록 tools 배열에 함수 정의를 넘기는 기능이다. 모델이 도구를 쓰기로 하면 message.content 대신 message.tool_calls에 호출 인자가 담겨 온다.
여기서 한 가지 걸린다. tools와 format을 동시에 넘기면 둘의 역할이 겹친다. 도구 호출 인자는 이미 tool 정의의 parameters 스키마로 구조가 잡히고, format은 모델이 도구를 안 쓰고 직접 답할 때의 최종 content를 제약한다. thinking 모델이 도구를 호출하지 않고 바로 답해 버리는 상황에서 구조화 제약이 어긋나는 사례가 보고된 적이 있어, 두 기능을 섞는다면 0.34.4 이상에서 쓰고 응답을 tool_calls가 있는 경우와 없는 경우로 나눠 처리한다.
현장에서는 역할을 분리하는 쪽이 깔끔하다. 도구를 호출해 데이터를 모으는 요청과, 모은 데이터를 스키마에 맞춰 정형화하는 요청을 두 단계로 나눈다. 첫 요청은 tools만, 두 번째 요청은 format만 건다. 한 요청에 둘을 다 거는 건 꼭 필요할 때만 한다.
resp = chat(
model='llama3.1',
messages=messages,
tools=[{
'type': 'function',
'function': {
'name': 'get_weather',
'description': '도시의 현재 날씨를 반환',
'parameters': {
'type': 'object',
'properties': {'city': {'type': 'string'}},
'required': ['city'],
},
},
}],
)
if resp.message.tool_calls:
for call in resp.message.tool_calls:
print(call.function.name, call.function.arguments)
else:
print(resp.message.content)
6. 운영에서 챙길 것
스키마를 너무 깊게 중첩하면 모델이 제약을 지키느라 생성이 느려지고 품질이 떨어질 수 있다. 중첩 객체와 배열을 남발하지 말고, 꼭 필요한 필드만 평평하게 정의한다.
required에 넣은 필드는 반드시 채워져 나오지만 값의 정확성까지 보장하지는 않는다. 숫자 범위나 enum 같은 제약은 스키마에 명시하고, 값의 타당성은 받는 쪽에서 한 번 더 검증한다.
스키마가 자주 바뀐다면 pydantic이나 zod 모델을 코드에 두고 거기서 스키마를 생성하는 방식을 고정한다. 손으로 쓴 JSON 스키마는 필드가 늘 때마다 요청 쪽과 검증 쪽이 어긋나기 쉽다.