학습 자료

답의 모양을 고정한다 — 추천을 JSON 으로


Article

이번 편이 준비의 마지막이다. 앞에서 고객 맥락을 안전하게 꺼내는 것까지 했다. 이제 모델에게 물어보고 답을 받는데, 받는 모양을 정한다.

같은 추천, 다른 모양

문장으로 받으면

지금까지 해온 것

  • 「이 고객님께는 히알루론산 시카
  • 클렌징오일을 추천드립니다. 왜냐하면…」
  • 사람은 읽기 좋다
  • 화면에 카드로 뿌릴 수 없다
  • 맞았는지 셀 수 없다

정해진 모양으로 받으면

오늘 만들 것

  • {"items": [{"product_id": "P140", ...}]}
  • 화면에 그대로 꽂는다
  • 내일 채점기가 이걸 먹는다
  • 사람이 읽기는 나쁘다 — 상관없다
내일 정확도를 재려면 상품 번호가 정해진 자리에 있어야 한다. 문장에서 상품명을 찾아내는 코드를 짜는 순간 그 코드가 또 틀린다.

1. 먼저 그냥 부탁해본다

제일 간단한 방법부터 해본다. 부탁하면 된다.

10_ask_json.py
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 에 못 박는다

부탁을 사용자 말에 섞어 넣으면 묻힌다. 따로 자리를 만들어 준다.

11_system.py
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. 진짜 처방 — 형식을 강제한다

말로 부탁하는 대신 틀을 코드로 적어 넘긴다.

12_schema.py
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 | 세라마이드가 장벽을 채워줍니다.

처방 — 우리가 준 목록 안에 있는지 확인한다

모델이 지어낼 수 없게 만드는 방법은 없다. 대신 받은 뒤에 확인하는 것은 우리가 할 수 있다.

19_check.py
# 중괄호로 감싸면 집합(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초
형식을 강제하는 것은 작은 모델도 된다. 그런데 무엇을 고르느냐는 완전히 다른 문제다.
검사는 두 층이다
  1. 01

    ① 모양 검사 — 남이 해준다

    response_format 에 클래스를 넘기면 칸 이름과 종류는 반드시 맞는다. 우리가 코드를 쓸 일이 없다.

  2. 02

    ② 내용 검사 — 우리만 할 수 있다

    P207 이 실재하는지는 우리 DB 만 안다. 모델도 모르고 pydantic 도 모른다. 이건 반드시 우리가 짠다.

5. 오늘 만든 것을 한 줄로 잇는다

여기까지 만든 파이프라인
  1. 고객 맥락SQL · 2편
  2. 개인정보 가리기8·9편
  3. 후보 좁히기지금은 카테고리로 대충
  4. 정해진 모양의 추천이번 편
  5. 후보 안에 있는지 검사우리가 짠다
가운데 「후보 좁히기」가 지금 제일 허술하다. 다음 편부터 통째로 여기에 쓴다.

이번 편에 나온 것

정리
쓴 것 · 안 것내용
말로 부탁코드블록으로 감싸거나 인사를 붙이거나 칸 이름을 바꾼다
system 메시지역할과 형식을 따로 못 박는다. 확률이 오를 뿐이다
pydantic 클래스받고 싶은 모양을 코드로 적는다
parse(response_format=...)칸 이름과 종류가 반드시 맞는다. 객체로 바로 온다
message.parsedjson.loads 를 안 쓴다
출력 상한 800추천 개수와 이유 길이를 줄인다
🔴 없는 상품 번호형식은 맞는데 내용이 틀린다. 조용히 난다
내용 검사후보 목록에 있는지 대조한다. 우리만 할 수 있다
형식을 고정한 진짜 이유내일 채점을 한 줄로 하기 위해서

미션

미션
  1. 01

    [필수] 말로 부탁한 것을 열 번 돌린다

    mission_json_1.py. parse 없이 「JSON 으로만 답해라」로 열 번 돌리고 json.loads 가 몇 번 실패하는지 센다. 실패한 답을 하나 저장해둔다.

  2. 02

    [응용] 이유 칸에 조건을 건다

    mission_json_2.py. reason 을 40자 이내로 제한해본다. Field 에 길이 조건을 걸었을 때와 프롬프트로 부탁했을 때 무엇이 다른가.

  3. 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 이 답이 된다.

Share
  • 파이썬
  • AI
  • JSON
  • pydantic
  • 추천
답의 모양을 고정한다 — 추천을 JSON 으로 — 디코드랩(DCODELAB)