AI 프롬프트 5요소: qwen2.5 0.5B 리뷰 한 건의 JSONDecodeError 해결
쇼핑몰 리뷰를 저장하는 작업에 필요한 것은 사람이 읽기 좋은 설명이 아니라 프로그램이 읽을 수 있는 감정과 이유입니다. 그런데 이번 한 줄 요청은 설명 문장을 돌려줬고, 이를 json.loads에 넣자 첫 글자에서 JSONDecodeError가 났습니다. 요청이 성공한 것과 그 뒤의 처리가 성공한 것은 다른 일입니다. 로컬 리뷰 분류를 서버 업무에 붙일 수 있는지는 Debian 13에서 작은 모델의 응답을 JSON으로 읽을 수 있는지, 프롬프트·출력 옵션이 결과를 어떻게 바꾸는지로 판단했고, 재현·해결·점검 스크립트 세 개로 돌려 기록했습니다.
이 글의 AI 프롬프트 공식은 다섯 줄입니다. 이번 실행에서는 역할, 제약, 출력 형식, 예시, 리뷰를 순서대로 적어 같은 리뷰에서 sentiment와 reason을 가진 JSON 한 줄을 받았습니다. 비교 모델은 로컬 태그 qwen2.5-0.5b-local이고, 호출은 이 서버의 Ollama /api/generate입니다. 옵션은 temperature 0입니다.
아래 코드는 각 스크립트의 발췌이며, 명령은 run 디렉터리에서 실행합니다. 재현 스크립트는 한 줄 요청의 오류를 재현하고, 해결 스크립트는 5요소 프롬프트로 json.loads를 통과시키며, 점검 스크립트는 format과 num_predict를 바꿉니다. 각 스크립트는 명령을 화면에 보인 뒤 실행합니다. 출력 앞의 + 줄이 그 명령입니다.
한 줄 요청이 JSONDecodeError로 끝나는 이유
재현 스크립트는 work/a_gen.py를 만든 뒤 같은 리뷰로 한 줄 요청을 보냅니다. 요청 본문에는 format 필드가 없고, temperature는 0이며 num_predict는 60입니다. 여기서는 감정과 이유를 묻는 말만으로 저장용 JSON까지 얻을 수 있는지를 확인합니다. 호출 주소는 http://127.0.0.1:11434/api/generate입니다.
prompt = "이 리뷰의 감정과 이유를 알려줘: 배송은 빨랐는데 포장이 찢어져 왔어요."
body = {
"model": "qwen2.5-0.5b-local",
"prompt": prompt,
"stream": False,
"options": {"temperature": 0, "num_predict": 60},
}
이어서 work/a_loads.py가 저장된 응답 문자열을 json.loads에 넣습니다. 생성 요청과 파싱을 두 파일로 나눠 두면 서버에서 응답을 받았는지와 그 응답을 쓸 수 있는지가 따로 보입니다. 예외를 삼키지 않으니 설명형 문자열이 파서에 들어갔을 때의 트레이스백이 그대로 찍힙니다.
import json
json.loads(open("work/a_response.txt", encoding="utf-8").read())
실행 순서는 응답 생성, 그다음 저장 문자열 파싱입니다. 앞 단계가 응답을 남겼더라도 뒤 단계는 따로 실패할 수 있습니다. 아래 명령은 그 경계를 드러내려고 두 스크립트를 차례로 실행합니다.
python3 work/a_gen.py
python3 work/a_loads.py
명령을 실행한 결과입니다. 요청에는 format이 없고, 응답은 한국어 설명으로 시작합니다. http_status 200 뒤에 파서 오류가 이어지니, 통신이 성공했다는 것만으로 저장 작업을 완료 처리하면 안 됩니다.
+ cat > work/a_gen.py << 'PY'
+ python3 work/a_gen.py
model qwen2.5-0.5b-local
temperature 0
num_predict 60
format_field absent
prompt_begin
이 리뷰의 감정과 이유를 알려줘: 배송은 빨랐는데 포장이 찢어져 왔어요.
prompt_end
http_status 200
done_reason length
eval_count 60
response_repr '이 리뷰의 감정과 이유는 다음과 같습니다:\n\n1. 감정: 리뷰의 주요 내용은 "배송은 빨랐는데 포장이 찢어져 왔어요"입니다. 이는 리뷰의 주요 내용을 설명하고 있습니다.\n\n2'
response_begin
이 리뷰의 감정과 이유는 다음과 같습니다:
1. 감정: 리뷰의 주요 내용은 "배송은 빨랐는데 포장이 찢어져 왔어요"입니다. 이는 리뷰의 주요 내용을 설명하고 있습니다.
2
response_end
+ cat > work/a_loads.py << 'PY'
+ python3 work/a_loads.py
Traceback (most recent call last):
File "/home/user/ai/run/work/a_loads.py", line 3, in <module>
json.loads(open("work/a_response.txt", encoding="utf-8").read())
~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/lib/python3.13/json/__init__.py", line 346, in loads
return _default_decoder.decode(s)
~~~~~~~~~~~~~~~~~~~~~~~^^^
File "/usr/lib/python3.13/json/decoder.py", line 345, in decode
obj, end = self.raw_decode(s, idx=_w(s, 0).end())
~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/lib/python3.13/json/decoder.py", line 363, in raw_decode
raise JSONDecodeError("Expecting value", s, err.value) from None
json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)
bare_loads_exit:1
설명형 응답과 파싱 오류가 잇달아 나온 터미널 화면입니다. done_reason은 length이고 eval_count는 60이라 생성 상한에 닿았습니다. 그런데 파서가 멈춘 곳은 첫 글자이므로, 상한만 늘려서는 출력 형식 문제가 풀리지 않습니다.

터미널 캡처의 파일 경로가 본문의 /home/user/ai와 다른 것은 앞쪽 작업 폴더 이름만 짧게 바꿨기 때문이며, 명령과 오류 출력은 같습니다.
http_status는 200입니다. format_field는 absent이고, done_reason은 length이며, eval_count는 60입니다. 상한과 생성 토큰 수가 같습니다. response_repr은 한국어 설명으로 시작합니다. 첫 글자는 JSON 객체의 중괄호가 아닙니다.
트레이스백의 마지막 줄은 json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)입니다. bare_loads_exit 값은 1입니다. 설명 안에 배송과 포장이 있어도, 문자열이 JSON 값으로 시작하지 않으면 loads는 필드를 돌려주지 않습니다.
파서가 첫 글자에서 멈춘 까닭
Python json 문서는 JSONDecodeError를, 역직렬화하는 데이터가 유효한 JSON 문서가 아닐 때 발생한다고 적습니다. 이번 트레이스백은 Expecting value, line 1 column 1, char 0이라고 적습니다. 저장된 텍스트의 맨 앞은 한글 문장입니다.
한 줄 요청에는 역할, 제약, 출력 형식, 예시가 없습니다. 리뷰 문장과 감정과 이유를 알려 달라는 말만 있습니다. 모델은 그 말에 맞춰 설명 문장을 썼고, num_predict 60에서 done_reason이 length가 되어 생성이 멈췄습니다. 상한 때문에 뒤가 잘렸지만, 오류 위치는 이미 설명으로 시작한 첫 글자입니다.
5요소 프롬프트로 sentiment와 reason을 읽는 방법
해결 스크립트는 같은 모델과 주소를 쓰고, temperature 0과 num_predict 60도 그대로 둡니다. format 필드도 여전히 없으니 앞선 실행과 달라진 것은 프롬프트뿐입니다. 역할, 설명 금지 제약, JSON 출력 형식, 예시, 실제 리뷰를 나눠 적어 모델이 할 일을 구체적으로 정합니다. 예시는 형식을 보여 주는 재료일 뿐이라, 실제 응답이 그 내용을 베꼈는지는 뒤의 출력으로 따로 봅니다.
prompt = """역할: 너는 쇼핑몰 리뷰 분류기다.
제약: 설명 문장을 쓰지 마라.
출력 형식: {"sentiment":"positive|negative|mixed","reason":"한 문장"} JSON 한 줄만.
예시: {"sentiment":"positive","reason":"배송이 빨랐다"}
리뷰: 배송은 빨랐는데 포장이 찢어져 왔어요."""
body = {
"model": "qwen2.5-0.5b-local",
"prompt": prompt,
"stream": False,
"options": {"temperature": 0, "num_predict": 60},
}
스크립트는 응답을 json.loads로 읽고 sentiment와 reason을 출력합니다. 예시의 positive와 배송이 빨랐다와 같은지도 정수로 찍습니다. 이유 문장에 포장과 배송이 있는지도 함께 찍습니다.
parsed = json.loads(response)
example_reason = "배송이 빨랐다"
example_sentiment = "positive"
print("parse_ok", 1)
print("sentiment", parsed["sentiment"])
print("reason", parsed["reason"])
print("sentiment_equals_example", int(parsed["sentiment"] == example_sentiment))
print("reason_equals_example", int(parsed["reason"] == example_reason))
print("reason_mentions_packaging", int("포장" in parsed["reason"]))
print("reason_mentions_shipping", int("배송" in parsed["reason"]))
print("SUCCESS: five-element json.loads returned sentiment and reason")
이 명령은 프롬프트를 바꾼 요청과 파싱 검사를 한 번에 실행합니다. 감정과 이유를 읽은 다음 예시와 같은지, 실제 리뷰의 배송과 포장이 이유에 들어갔는지를 찍습니다. 종료 값만이 아니라 이 검사 결과까지 봐야 저장할 내용이 만들어졌는지 알 수 있습니다.
python3 work/b_gen.py
명령 실행 결과입니다. 앞의 한 줄 요청과 달리 done_reason은 stop이고 eval_count는 28입니다. parse_ok와 예시 비교 플래그를 같이 보면 JSON 구문이 통과한 것과 예시를 베꼈는지가 갈라집니다.
+ cat > work/b_gen.py << 'PY'
+ python3 work/b_gen.py
model qwen2.5-0.5b-local
temperature 0
num_predict 60
format_field absent
prompt_begin
역할: 너는 쇼핑몰 리뷰 분류기다.
제약: 설명 문장을 쓰지 마라.
출력 형식: {"sentiment":"positive|negative|mixed","reason":"한 문장"} JSON 한 줄만.
예시: {"sentiment":"positive","reason":"배송이 빨랐다"}
리뷰: 배송은 빨랐는데 포장이 찢어져 왔어요.
prompt_end
http_status 200
done_reason stop
eval_count 28
response_repr '{"sentiment":"negative","reason":"배송이 빨랐지만 포장이 찢어져 왔다"}'
parse_ok 1
sentiment negative
reason 배송이 빨랐지만 포장이 찢어져 왔다
sentiment_equals_example 0
reason_equals_example 0
reason_mentions_packaging 1
reason_mentions_shipping 1
SUCCESS: five-element json.loads returned sentiment and reason
five_exit:0
캡처에서 프롬프트를 바꾼 뒤에는 parse_ok 1과 sentiment negative가 나옵니다. 이유 문장은 빠른 배송과 찢어진 포장을 둘 다 담았고, 예시와 같은지를 나타내는 두 플래그는 0입니다. 이 리뷰에서는 원하는 필드를 읽으면서도 예시 문장을 그대로 베끼지 않은 응답을 얻었습니다.

http_status는 200입니다. done_reason은 stop이고 eval_count는 28입니다. 60 상한에 닿기 전에 생성이 끝났습니다. response_repr은 {"sentiment":"negative","reason":"배송이 빨랐지만 포장이 찢어져 왔다"}입니다. parse_ok는 1이고, sentiment는 negative이며, reason은 배송이 빨랐지만 포장이 찢어져 왔다입니다.
sentiment_equals_example은 0이고 reason_equals_example도 0입니다. 예시의 감정은 positive이고 예시의 이유는 배송이 빨랐다인데, 이번 응답은 그 두 값과 다릅니다. reason_mentions_packaging과 reason_mentions_shipping은 1입니다. 성공 줄은 SUCCESS: five-element json.loads returned sentiment and reason이고 five_exit 값은 0입니다.
표준 라이브러리만으로 필요한 필드를 읽는다는 점에서 이 구성은 작은 리뷰 처리 작업에 붙이기 쉽습니다. 다만 예시를 넣은 프롬프트는 출력 형식을 이끄는 동시에 베낄 문장도 건넵니다. 그래서 저장하기 전에는 파싱 성공 여부와 함께, 이유가 실제 리뷰 내용을 가리키는지도 검사합니다.
format json과 num_predict에서 갈리는 출력
5요소로 파싱이 된 뒤에도 format과 토큰 상한에 따라 결과가 갈립니다. 점검 스크립트는 환경과 문서 주소를 먼저 출력하고, 이어서 네 가지 호출을 합니다. 한 줄 요청에 format을 문자열 json으로 두는 경우, 스키마와 제약 문장 프롬프트를 두는 경우, 그 조합의 num_predict를 16으로 줄이는 경우, 같은 스키마에 5요소 프롬프트를 붙이는 경우입니다. 온도는 모두 0입니다.
grep ^PRETTY_NAME= /etc/os-release
date -I
python3 --version
ollama --version
ollama list
ollama show qwen2.5-0.5b-local
nproc
command -v nvidia-smi || echo "nvidia-smi:not-found"
free -h
환경 확인 다음에는 본문에서 참조하는 문서 주소의 응답 상태를 남깁니다. 아래 반복문은 주소마다 요청을 보내 url_status를 찍습니다. 200은 주소에 닿았다는 결과일 뿐이고, 지금 설정에서 원하는 JSON이 나오는지는 뒤의 생성 결과를 보고 판단합니다.
for u in \
https://docs.python.org/3/library/json.html \
https://docs.ollama.com/capabilities/structured-outputs \
https://docs.ollama.com/api/generate \
https://github.com/ollama/ollama/releases/tag/v0.40.3
do
echo "+ curl -sI -o /dev/null -w %{http_code} --max-time 20 $u"
code=$(curl -sI -o /dev/null -w "%{http_code}" --max-time 20 "$u" || echo ERR)
echo "url_status $code $u"
done
네 호출은 work/c_gen.py에 있습니다. 스키마는 sentiment와 reason이 문자열인 객체이고, 두 키를 required로 둡니다. 제약 문장 프롬프트는 리뷰에 없는 내용을 쓰지 말라고 적고, 같은 리뷰 문장을 붙입니다. 짧은 호출만 num_predict를 16으로 둡니다.
SCHEMA = {
"type": "object",
"properties": {
"sentiment": {"type": "string"},
"reason": {"type": "string"},
},
"required": ["sentiment", "reason"],
}
호출 목록은 프롬프트와 출력 제약의 조합을 나란히 놓도록 짰습니다. bare_format_json은 상한 40, schema_constraint는 64, schema_short는 16, schema_five는 60을 씁니다. 제약 문장과 스키마가 같은 두 호출을 맞대어 보면 생성 상한이 짧을 때 응답이 얼마나 완결되는지가 보입니다.
call("bare_format_json", BARE, "json", 40, "work/c_bare_json.txt")
call("schema_constraint", CONSTRAINT, SCHEMA, 64, "work/c_constraint.txt")
call("schema_short", CONSTRAINT, SCHEMA, 16, "work/c_short.txt")
call("schema_five", FIVE, SCHEMA, 60, "work/c_schema_five.txt")
work/c_gen.py는 응답이 JSON이 아니면 JSONDecodeError를 잡아 parse_error 줄로 찍습니다. 오류를 기록하고도 다음 케이스를 계속 실행하니, 옵션별 차이가 한 출력 안에 나란히 남습니다. sentiment를 읽는 코드와, 상한 16에서 잘린 문자열의 json.loads는 예외를 삼키지 않는 별도 파일로 다시 실행합니다.
try:
parsed = json.loads(response)
except json.JSONDecodeError as exc:
print("parse_ok", 0)
print("parse_error", f"{type(exc).__name__}: {exc}")
다음 코드는 저장된 문자열을 파싱한 뒤 sentiment 키를 읽으려는 단계입니다. 이번 실행에서는 JSON 문자열 자체가 닫히지 않아 키 조회까지 가지 못합니다. 필드가 빠졌다고 분류하기 전에 json.loads가 성공했는지부터 가려야 하는 이유입니다.
print(json.loads(open("work/c_bare_json.txt", encoding="utf-8").read())["sentiment"])
짧은 상한으로 저장된 응답도 별도 파일에서 다시 파싱합니다. 여기서는 키를 조회하지 않으니 객체를 끝맺지 못한 문제가 그대로 드러납니다. 실제 출력의 short_loads_exit는 1이었고, 스키마가 있어도 잘린 문자열은 되살아나지 않습니다.
json.loads(open("work/c_short.txt", encoding="utf-8").read())
아래 순서로 비교 요청과 두 파싱 재현을 실행합니다. 비교 스크립트는 오류를 적고 다음 케이스로 넘어가지만, 별도 파싱 스크립트에서는 예외가 그대로 터집니다. 전체 비교가 끝났다는 것과 개별 응답을 써도 된다는 것은 이렇게 나뉩니다.
python3 work/c_gen.py
python3 work/c_key.py
python3 work/c_short.py
명령 실행 결과입니다. 환경 표에는 이 출력에서 읽은 값만 옮겼습니다. nproc는 8, 메모리 총량은 15Gi이고, nvidia-smi:not-found는 그 명령을 찾지 못했다는 뜻입니다. 출력 옵션을 바꾼 효과는 이 환경 정보와 함께 놓고 읽어야 헷갈리지 않습니다.
+ grep ^PRETTY_NAME= /etc/os-release
PRETTY_NAME="Debian GNU/Linux 13 (trixie)"
+ date -I
2026-10-14
+ python3 --version
Python 3.13.5
+ ollama --version
ollama version is 0.40.3
+ ollama list
NAME ID SIZE MODIFIED
qwen2.5-0.5b-local:latest 9806541f10f4 491 MB 2 days ago
+ ollama show qwen2.5-0.5b-local
Model
architecture qwen2
parameters 630.17M
context length 32768
embedding length 896
quantization Q4_K_M
Capabilities
tools
completion
+ nproc
8
+ command -v nvidia-smi
nvidia-smi:not-found
+ free -h
total used free shared buff/cache available
Mem: 15Gi 11Gi 408Mi 168Mi 4.0Gi 3.9Gi
Swap: 0B 0B 0B
+ curl -sI -o /dev/null -w %{http_code} --max-time 20 https://docs.python.org/3/library/json.html
url_status 200 https://docs.python.org/3/library/json.html
+ curl -sI -o /dev/null -w %{http_code} --max-time 20 https://docs.ollama.com/capabilities/structured-outputs
url_status 200 https://docs.ollama.com/capabilities/structured-outputs
+ curl -sI -o /dev/null -w %{http_code} --max-time 20 https://docs.ollama.com/api/generate
url_status 200 https://docs.ollama.com/api/generate
+ curl -sI -o /dev/null -w %{http_code} --max-time 20 https://github.com/ollama/ollama/releases/tag/v0.40.3
url_status 200 https://github.com/ollama/ollama/releases/tag/v0.40.3
+ cat > work/c_gen.py << 'PY'
+ python3 work/c_gen.py
case bare_format_json
temperature 0
num_predict 40
format_field "json"
http_status 200
done_reason length
eval_count 40
response_repr '{ "감정 ": "불행", "원인 ": "배송이 빨랐지만 포장이 찢어져 왔다", "정신 ": "배송'
parse_ok 0
parse_error JSONDecodeError: Unterminated string starting at: line 1 column 53 (char 52)
case_end bare_format_json
case schema_constraint
temperature 0
num_predict 64
format_field {"type": "object", "properties": {"sentiment": {"type": "string"}, "reason": {"type": "string"}}, "required": ["sentiment", "reason"]}
http_status 200
done_reason stop
eval_count 27
response_repr '{ "sentiment": "negative", "reason": "리뷰에 없는 내용은 쓰지 마라." }'
parse_ok 1
parsed_keys sentiment,reason
sentiment negative
reason 리뷰에 없는 내용은 쓰지 마라.
reason_equals_example 0
reason_mentions_packaging 0
reason_equals_constraint_sentence 1
case_end schema_constraint
case schema_short
temperature 0
num_predict 16
format_field {"type": "object", "properties": {"sentiment": {"type": "string"}, "reason": {"type": "string"}}, "required": ["sentiment", "reason"]}
http_status 200
done_reason length
eval_count 16
response_repr '{ "sentiment": "negative", "reason": "리뷰에 없는'
parse_ok 0
parse_error JSONDecodeError: Unterminated string starting at: line 1 column 38 (char 37)
case_end schema_short
case schema_five
temperature 0
num_predict 60
format_field {"type": "object", "properties": {"sentiment": {"type": "string"}, "reason": {"type": "string"}}, "required": ["sentiment", "reason"]}
http_status 200
done_reason stop
eval_count 28
response_repr '{"sentiment":"negative","reason":"배송이 빨랐지만 포장이 찢어져 왔다"}'
parse_ok 1
parsed_keys sentiment,reason
sentiment negative
reason 배송이 빨랐지만 포장이 찢어져 왔다
reason_equals_example 0
reason_mentions_packaging 1
reason_equals_constraint_sentence 0
case_end schema_five
SUCCESS: schema plus five-element json.loads returned sentiment and reason
c_gen_exit:0
+ cat > work/c_key.py << 'PY'
+ python3 work/c_key.py
Traceback (most recent call last):
File "/home/user/ai/run/work/c_key.py", line 3, in <module>
print(json.loads(open("work/c_bare_json.txt", encoding="utf-8").read())["sentiment"])
~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/lib/python3.13/json/__init__.py", line 346, in loads
return _default_decoder.decode(s)
~~~~~~~~~~~~~~~~~~~~~~~^^^
File "/usr/lib/python3.13/json/decoder.py", line 345, in decode
obj, end = self.raw_decode(s, idx=_w(s, 0).end())
~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/lib/python3.13/json/decoder.py", line 361, in raw_decode
obj, end = self.scan_once(s, idx)
~~~~~~~~~~~~~~^^^^^^^^
json.decoder.JSONDecodeError: Unterminated string starting at: line 1 column 53 (char 52)
bare_json_key_exit:1
+ cat > work/c_short.py << 'PY'
+ python3 work/c_short.py
Traceback (most recent call last):
File "/home/user/ai/run/work/c_short.py", line 3, in <module>
json.loads(open("work/c_short.txt", encoding="utf-8").read())
~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/lib/python3.13/json/__init__.py", line 346, in loads
return _default_decoder.decode(s)
~~~~~~~~~~~~~~~~~~~~~~~^^^
File "/usr/lib/python3.13/json/decoder.py", line 345, in decode
obj, end = self.raw_decode(s, idx=_w(s, 0).end())
~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/lib/python3.13/json/decoder.py", line 361, in raw_decode
obj, end = self.scan_once(s, idx)
~~~~~~~~~~~~~~^^^^^^^^
json.decoder.JSONDecodeError: Unterminated string starting at: line 1 column 38 (char 37)
short_loads_exit:1
환경 정보와 출력 옵션별 실행이 한 화면에 들어 있습니다. 닫히지 않은 문자열을 읽은 두 별도 파싱 시도는 Unterminated string 오류와 종료 값 1을 남겼고, schema_five는 파싱에 성공했습니다. 같은 서버에서도 프롬프트와 생성 상한을 어떻게 짝짓느냐에 따라 뒤 처리 결과가 달라진다는 뜻입니다.

| 항목 | 값 |
|---|---|
| OS | Debian GNU/Linux 13 (trixie) |
| 실행일 | 2026-10-14 |
| Python | 3.13.5 |
| Ollama | 0.40.3 |
| 모델 태그 | qwen2.5-0.5b-local:latest |
| 모델 ID | 9806541f10f4 |
| 모델 크기 | 491 MB |
| architecture | qwen2 |
| parameters | 630.17M |
| context length | 32768 |
| embedding length | 896 |
| quantization | Q4_K_M |
| nproc | 8 |
| Mem total | 15Gi |
환경 표는 출력에 나온 OS와 실행일, 모델 정보와 메모리 총량을 옮긴 것입니다. ollama --version의 값은 0.40.3이고, ollama show의 capabilities는 tools와 completion입니다. 태그 이름과 parameters 줄은 표기가 다르니, 모델 규모는 이름으로 짐작하지 않고 출력의 630.17M을 그대로 적었습니다. 이 표는 재현 환경을 맞출 때 쓰는 기준이고, 동시 요청을 얼마나 감당하는지는 실제 서비스 부하를 걸어 봐야 정해집니다.
Ollama structured outputs 문서는 format에 json을 넣거나 JSON 스키마를 넣어 응답 모양을 맞추는 방법을 보여 줍니다. 같은 문서는 스키마를 프롬프트에도 적는 방법을 권장합니다. 이번 점검 스크립트는 스키마를 format에만 넣었습니다. 5요소 프롬프트 안에는 출력 형식 줄이 이미 있고, 스키마 JSON 자체를 프롬프트 문자열로 다시 붙이지는 않았습니다. Ollama 릴리스 v0.40.3 페이지의 상태도 200입니다. 버전 문자열은 위 표의 ollama --version과 같습니다.
bare_format_json에서는 done_reason이 length이고 parse_ok는 0입니다. 출력 블록처럼 문자열이 닫히지 않아 파싱이 실패합니다. 이어서 work/c_key.py에서도 키 조회에 앞선 json.loads 단계에서 JSONDecodeError가 발생합니다.
schema_constraint는 num_predict 64에서 done_reason이 stop이고 eval_count는 27입니다. parse_ok는 1이고 sentiment는 negative입니다. reason은 리뷰에 없는 내용은 쓰지 마라입니다. reason_equals_constraint_sentence는 1이고 reason_mentions_packaging은 0입니다. 스키마가 두 키를 만들어도, 이유 칸에 제약 문장이 들어가면 포장 파손은 그 값에 없습니다.
schema_short는 같은 제약 문장과 같은 스키마에서 num_predict만 16입니다. done_reason은 length이고 eval_count는 16입니다. response_repr은 이유 문자열 중간인 리뷰에 없는에서 끊깁니다. work/c_short.py의 트레이스백 마지막 줄은 json.decoder.JSONDecodeError: Unterminated string starting at: line 1 column 38 (char 37)입니다. short_loads_exit 값은 1입니다. 상한이 객체 중간에서 끊기면 스키마를 넣었어도 loads가 실패합니다.
schema_five는 5요소 프롬프트에 같은 스키마를 붙이고 num_predict를 60으로 둡니다. done_reason은 stop이고 eval_count는 28입니다. response_repr은 실행 로그의 5요소 응답과 같은 {"sentiment":"negative","reason":"배송이 빨랐지만 포장이 찢어져 왔다"}입니다. reason_equals_example은 0이고 reason_mentions_packaging은 1입니다. 성공 줄은 SUCCESS: schema plus five-element json.loads returned sentiment and reason이고 c_gen_exit 값은 0입니다.
실무에서 갈라 봐야 할 실패는 두 가지입니다. 문자열이 잘린 경우와, 파싱은 되는데 이유가 엉뚱한 경우입니다. 앞의 것은 종료 사유와 파싱 오류로 드러나고, 뒤의 것은 제약 문장을 그대로 돌려준 schema_constraint에서 보입니다. 구조를 맞추는 스키마와 할 일을 전하는 프롬프트를 함께 관리해야 두 문제를 각각 막습니다.
요청이 성공한 다음 JSON 안을 다시 보는 이유
운영 설정을 갱신할 때는 모델 태그와 호출 경로, 프롬프트와 출력 옵션을 한꺼번에 대조합니다. 문서 주소가 열리거나 생성 요청이 성공했다고 해서 응답 형식까지 보장되지는 않습니다. 이번에도 통신은 성공했는데 파싱 실패와 제약 문장 복사가 나왔으니, 설정을 바꾼 뒤에는 같은 리뷰를 보내 종료 사유와 필드 내용까지 다시 검사하는 절차가 있어야 합니다.
헬스체크는 프로세스가 응답하는지만 보고, 리뷰 분류 품질은 별도의 검증 작업에서 판단하도록 나누기를 권합니다. Kubernetes probe의 경로를 바꿀 때도 생성 API의 성공 응답을 품질 판정 대신 쓰지 말고, 서비스가 실제로 내놓는 상태 확인 경로와 배포 설정을 맞춥니다. 생성과 파싱·저장을 떼어 두면, 설정 변경으로 응답이 잘리거나 형식이 바뀌었을 때 잘못된 분석 결과가 저장 단계로 흘러가는 일을 막기 쉽습니다.
리뷰 분류에 쓸 프롬프트와 옵션 조합
이번 run 출력에서 리뷰의 감정과 이유를 제대로 읽어 낸 선택지는 아래 두 가지입니다. 온도는 모두 0이고, 두 선택지는 같은 이유 문장을 돌려줬습니다. 이 결과로 보면 프롬프트를 먼저 다듬고, 저장 계약이 필요할 때 스키마를 얹는 순서가 맞다고 판단합니다.
| 선택 | 이번 실행 결과 |
|---|---|
| 5요소, format 없음, num_predict 60 | parse_ok 1, sentiment negative, packaging 1, example과 다름, five_exit 0 |
| 스키마와 5요소, num_predict 60 | 5요소와 같은 response_repr, packaging 1, c_gen_exit 0 |
리뷰 한 건을 JSON 한 줄로 저장하는 경우에는 해결 스크립트의 5요소 프롬프트를 쓰는 쪽을 추천합니다. 역할과 제약, 출력 형식, 예시, 리뷰가 한 프롬프트에 있고, 이번 five_exit는 0이며 이유 문장에 포장이 있습니다. 예시와 같은지 보려면 reason_equals_example이 0인지 출력에서 확인하면 됩니다.
응답 모양을 format의 스키마로도 맞추고 싶다면 같은 5요소에 점검 스크립트의 스키마를 붙이면 됩니다. 이번 schema_five의 response_repr은 format 없는 5요소와 같았고, 성공 줄도 출력되었습니다. 한 줄 요청에 format json만 붙이는 조합은 추천하지 않습니다. 이번에는 문자열이 닫히지 않아 JSONDecodeError가 났습니다.
이번 제약 문장과 스키마 조합의 이유 칸은 저장할 리뷰 분석으로 추천하지 않습니다. 이번 reason은 리뷰에 없는 내용은 쓰지 마라이고 포장 플래그는 0입니다. 같은 리뷰와 설정에서는 num_predict를 이번 성공 실행의 60으로 두는 쪽을 권합니다. 16에서는 이유 문자열이 닫히지 않아 short_loads_exit가 1이었습니다.
출력을 다시 놓고 보면, 이 리뷰에서는 모델을 바꾸기 전에 요청의 역할과 출력 형식을 분명히 하는 것이 효과가 컸습니다. 운영에 붙인다면 5요소 프롬프트와 스키마를 함께 관리하고, 파싱 실패 검사와 이유 내용 검사를 저장 앞에 두겠습니다. num_predict는 이번에 성공한 60에서 출발하되, 응답이 length로 끝나면 정상 분석으로 저장하지 않겠습니다. 실시간 서비스라면 생성 지연이 요청 처리로 곧장 번지지 않게 분류 작업을 별도 처리 경로로 빼는 구성을 권합니다. 서버 사양은 표의 환경만 보고 실시간 처리 용량을 정하지 말고 실제 리뷰 요청을 기준으로 잡으며, 작은 모델이 제약 문장을 베끼는 입력은 재처리하거나 다른 모델로 넘기는 경로를 마련해 둡니다.






