답의 모양을 고정한다 — 추천을 JSON 으로
Article
이번 편이 준비의 마지막이다. 앞에서 고객 맥락을 안전하게 꺼내는 것까지 했다. 이제 모델에게 물어보고 답을 받는데, 받는 모양을 정한다.
문장으로 받으면
지금까지 해온 것
- 「이 고객님께는 히알루론산 시카
- 클렌징오일을 추천드립니다. 왜냐하면…」
- 사람은 읽기 좋다
- 화면에 카드로 뿌릴 수 없다
- 맞았는지 셀 수 없다
정해진 모양으로 받으면
오늘 만들 것
{"items": [{"product_id": "P140", ...}]}- 화면에 그대로 꽂는다
- 내일 채점기가 이걸 먹는다
- 사람이 읽기는 나쁘다 — 상관없다
1. 먼저 그냥 부탁해본다
제일 간단한 방법부터 해본다. 부탁하면 된다.
from config import client, MODEL # 내 노트북이든 상용이든 여기만 보면 된다
import sqlite3, json
con = sqlite3.connect("shop.db")
cur = con.cursor()
# 후보 상품 — 지금은 카테고리로 대충 좁혔다. 내일 임베딩으로 제대로 고른다
candidates = cur.execute("""
SELECT product_id, name, brand, price FROM products
WHERE category = '클렌징오일' AND price <= 30000
LIMIT 8
""").fetchall()
catalog = "\n".join(f"{i} | {n} | {b} | {p}원" for i, n, b, p in candidates)
# join 은 사이사이에 앞의 글자를 넣어 이어붙인다. 여기서는 줄바꿈을 넣어 한 줄씩 늘어놓았다.
# for 뒤의 i, n, b, p 는 SELECT 에 적은 순서대로 묶음을 풀어 담은 것이다
prompt = f"""아래 고객에게 어울릴 상품 세 개를 후보에서 골라라.
[고객] 41세 여성 · 건성
[구매 이력] 병풀 링클 클렌징오일 28100원(별점3) / 히알루론산 탄력 클렌징오일 25000원(별점4) / 쌀겨 탄력 선크림 19500원(별점5)
[후보]
{catalog}
JSON 으로만 답해라.
"""
res = client.chat.completions.create(
model=MODEL,
messages=[{"role": "user", "content": prompt}],
)
# 모델이 준 답은 그냥 글자다. JSON 처럼 생겼어도 아직 글자일 뿐이다
raw = res.choices[0].message.content
print(raw)
# json.loads 가 그 글자를 파이썬이 다룰 수 있는 값으로 바꾼다.
# 모양이 조금이라도 어긋나면 여기서 JSONDecodeError 가 난다
data = json.loads(raw)
# 이제 딕셔너리라 열쇠로 꺼낼 수 있다. items 안의 첫 번째를 본다
print(data["items"][0])
대체로 잘 된다. 대체로가 문제다. 같은 코드를 여러 번 돌리면 이런 것들이 섞여 나온다.
- ① ```json 으로 감싸서 온다
- ```json
- {"items": [...]}
- ```
- -> json.loads 에서 JSONDecodeError
- ② 앞에 인사를 붙인다
- 물론입니다! 아래와 같이 추천드립니다.
- {"items": [...]}
- -> 역시 JSONDecodeError
- ③ 칸 이름을 제멋대로 정한다
- {"추천": [{"상품번호": "P140", "이유": "..."}]}
- -> data["items"] 에서 KeyError
2. 첫 처방 — 역할과 형식을 system 에 못 박는다
부탁을 사용자 말에 섞어 넣으면 묻힌다. 따로 자리를 만들어 준다.
SYSTEM = """너는 화장품 쇼핑몰의 추천 담당자다.
반드시 JSON 객체 하나만 출력한다. 설명·인사·코드블록 표시를 붙이지 않는다.
칸 이름은 items · product_id · reason 을 그대로 쓴다.
reason 은 한국어 한 문장으로 쓴다."""
res = client.chat.completions.create(
model=MODEL,
messages=[
{"role": "system", "content": SYSTEM}, # 역할과 규칙
{"role": "user", "content": prompt}, # 이번 요청
],
)
- system
- 너는 무엇이고 어떻게 답하나
- 매번 같다
- user
- 이번에 물어보는 것
- 매번 다르다
- assistant
- 모델이 한 말
- 이어서 물을 때 넣는다
훨씬 나아진다. 그런데 여전히 가끔 어긴다. 부탁은 부탁이다.
3. 진짜 처방 — 형식을 강제한다
말로 부탁하는 대신 틀을 코드로 적어 넘긴다.
from pydantic import BaseModel, Field
# openai 를 깔 때 pydantic 이 같이 깔린다. 따로 설치할 것 없다
# class 는 "이런 모양의 값"을 정의하는 문법이다.
# BaseModel 을 물려받으면 pydantic 이 "이건 검사할 틀"로 알아본다
class Item(BaseModel):
# 칸 이름: 종류 형태로 적는다. str = 글자, int = 정수
# Field 의 description 은 모델에게 함께 전해진다. 사람용 주석이 아니라
# "이 칸에 뭘 넣어야 하는지" 를 모델이 실제로 읽는다
product_id: str = Field(description="후보 목록에 있는 상품 번호")
reason: str = Field(description="추천 이유 한 문장")
class Recommend(BaseModel):
# list[Item] = "위에서 정한 Item 이 여러 개 든 목록"
items: list[Item]
res = client.chat.completions.parse( # create 가 아니라 parse 다
model=MODEL,
messages=[
{"role": "system", "content": SYSTEM},
{"role": "user", "content": prompt},
],
response_format=Recommend, # 클래스를 그대로 넘긴다
)
# content(글자) 가 아니라 parsed 를 꺼낸다. 검사까지 끝난 파이썬 값이 바로 온다
rec = res.choices[0].message.parsed
# 딕셔너리가 아니라 객체라서 대괄호가 아니라 점으로 꺼낸다.
# 딕셔너리: it["product_id"]
# 객체: it.product_id
for it in rec.items:
print(it.product_id, "|", it.reason)
- P140 | 기존에 만족한 히알루론산 성분의 클렌징오일이라 이어서 쓰기 좋습니다.
- P003 | 건성 피부에 맞는 순한 제형이고 가격대도 비슷합니다.
- P062 | 세라마이드가 들어 있어 건조한 피부의 장벽을 채워줍니다.
| 방법 | 무엇을 보장하나 | 안 되는 것 |
|---|---|---|
| 말로 부탁 | 아무것도 | 가끔 어긴다 |
system 에 규칙 | 확률이 올라간다 | 여전히 부탁이다 |
response_format 에 클래스 | 칸 이름과 종류가 반드시 맞는다 | 내용이 맞는지는 모른다 |
4. 🔴 형식은 맞는데 없는 상품을 추천한다
여기가 오늘의 함정이다. 틀은 완벽하게 지키면서 내용이 틀린 경우가 있다.
- P140 | 히알루론산 성분이라 이어서 쓰기 좋습니다.
- P207 | 건성 피부에 맞는 저자극 제형입니다.
- P062 | 세라마이드가 장벽을 채워줍니다.
처방 — 우리가 준 목록 안에 있는지 확인한다
모델이 지어낼 수 없게 만드는 방법은 없다. 대신 받은 뒤에 확인하는 것은 우리가 할 수 있다.
# 중괄호로 감싸면 집합(set)이다. "이 안에 있나" 를 빠르게 보려고 집합으로 만든다.
# n, b, p 는 안 쓰지만 묶음을 풀려면 개수를 맞춰 적어야 한다
candidate_ids = {i for i, n, b, p in candidates}
keep = [] # 후보 안에 있는 진짜 추천
drop = [] # 모델이 지어낸 번호
for it in rec.items:
if it.product_id in candidate_ids:
keep.append(it)
else:
drop.append(it.product_id)
if drop:
print("후보에 없는 번호를 지어냈다:", drop)
print(f"쓸 수 있는 추천 {len(keep)}개")
- 후보에 없는 번호를 지어냈다: ['P207']
- 쓸 수 있는 추천 2개
qwen-cpu
6편 · 3B · 내 노트북
- 형식은 완벽하게 지킨다
- 고른 것: P014 · P115 · P096
- 정답 P140 을 못 골랐다
- 「레티놀 클렌징오일은 히알루론산 시카보다 이마에 더…」
- 후보 밖 번호도 더 자주 낸다
gpt-4o-mini
7편 · 상용 API
- 형식은 똑같이 지킨다
- 고른 것에 P140 이 들어온다
- 이유 문장이 말이 된다
- 한 건에 2~3초
- 01
① 모양 검사 — 남이 해준다
response_format에 클래스를 넘기면 칸 이름과 종류는 반드시 맞는다. 우리가 코드를 쓸 일이 없다. - 02
② 내용 검사 — 우리만 할 수 있다
P207 이 실재하는지는 우리 DB 만 안다. 모델도 모르고 pydantic 도 모른다. 이건 반드시 우리가 짠다.
5. 오늘 만든 것을 한 줄로 잇는다
- 고객 맥락SQL · 2편
- 개인정보 가리기8·9편
- 후보 좁히기지금은 카테고리로 대충
- 정해진 모양의 추천이번 편
- 후보 안에 있는지 검사우리가 짠다
이번 편에 나온 것
| 쓴 것 · 안 것 | 내용 |
|---|---|
| 말로 부탁 | 코드블록으로 감싸거나 인사를 붙이거나 칸 이름을 바꾼다 |
system 메시지 | 역할과 형식을 따로 못 박는다. 확률이 오를 뿐이다 |
pydantic 클래스 | 받고 싶은 모양을 코드로 적는다 |
parse(response_format=...) | 칸 이름과 종류가 반드시 맞는다. 객체로 바로 온다 |
message.parsed | json.loads 를 안 쓴다 |
| 출력 상한 800 | 추천 개수와 이유 길이를 줄인다 |
| 🔴 없는 상품 번호 | 형식은 맞는데 내용이 틀린다. 조용히 난다 |
| 내용 검사 | 후보 목록에 있는지 대조한다. 우리만 할 수 있다 |
| 형식을 고정한 진짜 이유 | 내일 채점을 한 줄로 하기 위해서 |
미션
- 01
[필수] 말로 부탁한 것을 열 번 돌린다
mission_json_1.py.parse없이 「JSON 으로만 답해라」로 열 번 돌리고json.loads가 몇 번 실패하는지 센다. 실패한 답을 하나 저장해둔다. - 02
[응용] 이유 칸에 조건을 건다
mission_json_2.py.reason을 40자 이내로 제한해본다.Field에 길이 조건을 걸었을 때와 프롬프트로 부탁했을 때 무엇이 다른가. - 03
[도전] 지어낸 번호가 얼마나 나오는지 잰다
mission_json_3.py. 고객 20명에게 추천을 시키고 후보 밖 번호가 몇 번 나오는지 센다. 후보를 8개 줄 때와 40개 줄 때 차이가 있는지 본다.
[도전] 후보를 늘리면 지어내기가 늘어난다충분히 고민해본 뒤 꼭 필요한 경우에만 열어보세요
후보가 길어질수록 지어내기가 늘어난다. 목록이 길면 모델이 번호를 정확히 옮겨 적기 어려워지기 때문이다. 사람이 40줄짜리 표에서 번호를 베껴 적을 때와 같다.
여기서 하나 배운다 — 후보를 많이 주는 것이 좋은 게 아니다. 내일 배울 검색이 하는 일이 바로 이것이다. 200개 중 잘 고른 열 개를 주는 것이 마흔 개를 주는 것보다 낫다.
그리고 후보가 길면 입력 토큰도 는다. 정확도와 비용이 같은 방향으로 움직이는 드문 경우다.
customer_ids = [r[0] for r in cur.execute("SELECT customer_id FROM customers LIMIT 20").fetchall()]
for n_candidates in (8, 40):
made_up = 0
total = 0
for cid in customer_ids:
candidates = pick_candidates(cid, n_candidates) # 앞에서 만든 함수를 개수만 바꿔 쓴다
rec = recommend(cid, candidates)
ids = {i for i, n, b, p in candidates}
for it in rec.items:
total += 1
if it.product_id not in ids:
made_up += 1
print(f"후보 {n_candidates}개 -> 지어낸 것 {made_up}/{total}")숫자가 반대로 나올 수도 있다. 회차마다 흔들리기 때문이다. 20명은 표본이 작다.
그래서 지난주부터 한 이야기를 다시 한다 — 작은 차이는 못 믿는다. 8개에서 0건, 40개에서 1건이면 아무 결론도 못 낸다. 5건과 0건쯤 차이가 나야 이야기를 시작한다.
정리하면
「JSON 으로 줘」는 부탁이고, 부탁은 가끔 안 지켜진다. 코드블록으로 감싸거나 인사를 붙이거나 칸 이름을 자기 마음대로 정한다. system 에 규칙을 못 박으면 확률이 오르지만 여전히 부탁이다.
틀을 코드로 적어 넘기면 그때부터는 문법적으로 어길 수 없다. pydantic 으로 받고 싶은 모양을 적고 parse 로 넘기면 파이썬 객체가 바로 온다. 오늘 배운 것 중 실무에서 제일 자주 쓸 한 가지다.
그런데 형식이 맞는 것과 내용이 맞는 것은 다르다. P207 처럼 존재하지 않는 상품 번호가 완벽한 모양으로 온다. 이건 서비스를 멈추지 않아서 더 나쁘다 — 화면에 카드가 그려지고 클릭해야 빈 페이지가 뜬다. 우리가 준 목록 안에 있는지 대조하는 코드는 반드시 우리가 짠다.
그리고 형식을 고정한 진짜 이유는 화면이 아니라 자다. 다음에 만들 채점기가 이 JSON 을 그대로 먹는다. 자가 흔들리면 그다음에 하는 일이 전부 헛일이 된다.
다음 편에서 후보 좁히기를 만든다. 지금은 「클렌징오일 중 3만원 이하」로 대충 잘랐는데, 이 고객이 클렌징오일을 좋아한다는 걸 우리가 눈으로 보고 정해준 것이다. 200개 중에서 기계가 고르게 만드는 것이 다음 차례다. 그리고 「3만원 이하 선물용」이 검색으로 안 잡히는 장면을 만난다 — 그때 2편에서 배운 SQL 이 답이 된다.