프롬프트를 고쳐본다 — 재면서 고친다
Article
앞 편에서 틀림을 세 갈래로 나눴다. 오늘은 그중 프롬프트로 밀리는 것이 어디까지인지를 잰다.
- ①②③ RAG 를 만든다끝남
- ④ 갈래를 나눈다끝남
- ⑤ 프롬프트를 고친다지금 여기
- ⑥ 조각 수와 속도마지막
1. 고치기 전에 채점기부터 만든다
프롬프트를 고치고 답을 눈으로 읽어 판단하면 반드시 속는다. 좋아진 것 하나만 보이고 나빠진 것은 안 보인다. 6강에서 자르기 방식 셋을 눈으로 비교하다가 가장 공들인 방식을 꼴찌로 판정했던 그 일이다.
문항은 여섯 개다. 앞 편에서 갈래를 나눠둔 그 질문들이다.
| 갈래 | 질문 | 맞았다고 볼 조건 |
|---|---|---|
| 정상 | 조각을 겹치게 자르는 이유가 뭔가 | 「안전장치」 · 「찢」 · 「경계」 중 하나 |
| ③ | 겹침을 조각 크기와 같게 주면 | 「멈춘」 · 「무한」 · 「에러」 중 하나 |
| ① | 오늘 점심 뭐 먹지 | 「없」 · 「모르」 중 하나 |
| ① | 서울에서 부산까지 몇 시간 | 모른다 + 「3시간」이 안 나와야 한다 |
| ① | 이 강의 수강료가 얼마인가 | 「없」 · 「모르」 중 하나 |
| ② | 우리가 자른 조각의 크기는 | 「200」 · 「69자」가 나오면 오답 |
# (25_diagnose.py 의 위쪽을 그대로 복사해 온다 — data, model, search)
from openai import OpenAI
client = OpenAI(base_url="http://localhost:11434/v1", api_key="ollama")
N = 3 # 같은 조건을 세 번 돌린다. 답이 매번 다르기 때문이다
# (질문, 갈래, 나오면 정답인 말들, 나오면 오답인 말들)
CASES = [
("조각을 겹치게 자르는 이유가 뭔가", "정상", ["안전장치", "찢", "경계", "온전"], []),
("겹침을 조각 크기와 같게 주면 어떻게 되나", "3", ["멈춘", "무한", "에러"], []),
("오늘 점심 뭐 먹지", "1", ["없", "모르", "알 수 없"], []),
("서울에서 부산까지 몇 시간 걸리나", "1", ["없", "모르"], ["3시간", "약 3", "KTX"]),
("이 강의 수강료가 얼마인가", "1", ["없", "모르"], []),
("우리가 자른 조각의 크기는 몇 자인가", "2", ["200"], ["69자"]),
]
def judge(answer, good, bad):
"""오답 표시가 하나라도 있으면 탈락. 없으면 정답 표시를 찾는다."""
if any(b in answer for b in bad):
return False
return any(g in answer for g in good)
def grade(system, template):
"""프롬프트 한 벌을 여섯 문항으로 채점한다."""
total = 0
for question, kind, good, bad in CASES:
hits = search(question, k=3)
context = "\n\n".join(text for score, slug, text in hits)
prompt = template.format(ctx=context, q=question)
messages = []
if system:
messages.append({"role": "system", "content": system})
messages.append({"role": "user", "content": prompt})
ok = 0
for _ in range(N):
res = client.chat.completions.create(model="qwen2.5:3b", messages=messages)
if judge(res.choices[0].message.content, good, bad):
ok += 1
print(f" [{kind}] {ok}/{N} {question}")
total += ok
print(f" 합계 {total}/{len(CASES) * N}")
return total
글자가 들었는지로 채점하는 것은 조잡하다. 「멈춘다」를 「멈추지 않는다」로 써도 정답 처리된다. 그래도 없는 것보다 압도적으로 낫다 — 오늘 이 조잡한 채점기가 잡아낼 사고가 하나 있는데, 눈으로 봤으면 절대 못 봤을 것이다.
2. A — 지금 쓰던 프롬프트 (기준선)
A = "아래 자료를 읽고 질문에 답해라.\n\n[자료]\n{ctx}\n\n[질문]\n{q}\n"
grade(None, A)
- [정상] 3/3 조각을 겹치게 자르는 이유가 뭔가
- [3] 0/3 겹침을 조각 크기와 같게 주면 어떻게 되나
- [1] 2/3 오늘 점심 뭐 먹지
- [1] 1/3 서울에서 부산까지 몇 시간 걸리나
- [1] 0/3 이 강의 수강료가 얼마인가
- [2] 0/3 우리가 자른 조각의 크기는 몇 자인가
- 합계 6/18
기준선이 6/18 이다. 여기서 시작한다.
3. B — 「자료에 없는 건 쓰지 마라」
제일 먼저 떠오르는 처방이고 실제로 잘 듣는다.
B = """아래 자료만 근거로 질문에 답해라.
자료에 없는 내용은 쓰지 마라. 자료로 답할 수 없으면 「자료에 없다」고만 답해라.
[자료]
{ctx}
[질문]
{q}
"""
grade(None, B)
- [정상] 0/3 조각을 겹치게 자르는 이유가 뭔가
- [3] 0/3 겹침을 조각 크기와 같게 주면 어떻게 되나
- [1] 3/3 오늘 점심 뭐 먹지
- [1] 3/3 서울에서 부산까지 몇 시간 걸리나
- [1] 3/3 이 강의 수강료가 얼마인가
- [2] 0/3 우리가 자른 조각의 크기는 몇 자인가
- 합계 9/18
4. 🔴 갈래 ① 을 고쳤더니 정상 질문이 죽었다
같은 질문, 같은 근거, 프롬프트만 바꿨다. A 에서는 「겹침은 데이터의 일부가 여러 조각으로 분리되지 않도록 하는 안전장치입니다」로 3/3 이었다. B 에서는 「자료에는 조각을 겹치게 자르는 이유가 명시되어 있지 않습니다」로 0/3 이다. 근거는 1위로 제대로 들어왔는데 있는데 없다고 한다.
이것이 과잉 거부다. 「없으면 없다고 해라」를 세게 시키면 모델은 안전한 쪽으로 도망친다 — 「자료에 없다」는 언제나 무난한 답이기 때문이다.
그리고 이게 사고인 줄 모르고 배포된다. 헛소리가 사라졌으니 고쳐진 것처럼 보이고, 사용자가 「자꾸 모른대요」라고 말해줘야 안다. 정상 문항을 채점표에 넣어두지 않았으면 우리도 못 봤다.
5. C — 세게 말고 정확하게
금지를 늘리는 대신 역할과 형식을 준다.
C_system = """너는 사내 문서 안내원이다. 주어진 자료만 근거로 답한다.
자료에 없는 내용은 절대 쓰지 않는다. 자료로 답할 수 없으면 「자료에 없다」고만 답한다.
반드시 한국어로만 답한다. 세 문장 이내로 답한다."""
C_user = "[자료]\n{ctx}\n\n[질문]\n{q}\n"
grade(C_system, C_user)
- [정상] 3/3 조각을 겹치게 자르는 이유가 뭔가
- [3] 1/3 겹침을 조각 크기와 같게 주면 어떻게 되나
- [1] 3/3 오늘 점심 뭐 먹지
- [1] 3/3 서울에서 부산까지 몇 시간 걸리나
- [1] 3/3 이 강의 수강료가 얼마인가
- [2] 0/3 우리가 자른 조각의 크기는 몇 자인가
- 합계 13/18
| B — 0/3 로 부순 쪽 | C — 3/3 을 지킨 쪽 | |
|---|---|---|
| 말투 | 금지 두 줄 | 역할 한 줄 + 금지 두 줄 |
| 역할 | 없음 | 「사내 문서 안내원이다」 |
| 형식 | 없음 | 한국어로만 · 세 문장 이내 |
| 효과 | 모르겠다는 쪽으로 도망감 | 답할 자리에서는 답한다 |
「반드시 한국어로만」 한 줄이 앞 편에서 본 중국어 답을 막았다. A 원본에서 「오늘 점심 뭐 먹지」가 2/3 였던 이유가 그거다. 「세 문장 이내」도 같이 값을 한다 — 길게 쓰게 두면 뒤로 갈수록 자료에서 멀어진다.
6. D — 근거를 먼저 옮겨 적게 하면?
더 좋아질 것 같은데 실제로는 나빠졌다.
D_user = """[자료]
{ctx}
[질문]
{q}
다음 두 줄로만 답해라.
근거: (자료에서 그대로 옮긴 한 문장)
답: (세 문장 이내)
"""
grade(C_system, D_user)
D 에서 정상 문항이 3/3 에서 2/3 로 떨어졌다. 「근거 한 문장 + 답」 틀에 맞추느라 답이 짧아졌고, 짧아지면서 채점 낱말이 빠졌다. 틀을 씌우는 것과 내용을 잘 쓰는 것은 서로 예산을 다툰다. 작은 모델일수록 심하다.
7. system 에 넣는 게 마법인가
C 는 지시를 system 에 넣었고 B 는 user 에 넣었다. 차이가 그것 때문인지 확인한다.
# 문구는 그대로 두고 자리만 맞바꿔 네 벌을 만든다
grade(None, STRICT + "\n\n[자료]\n{ctx}\n\n[질문]\n{q}\n") # B 강한문구 · user
grade(STRICT, "[자료]\n{ctx}\n\n[질문]\n{q}\n") # B' 강한문구 · system
grade(SOFT, "[자료]\n{ctx}\n\n[질문]\n{q}\n") # C 부드러운 · system
grade(None, SOFT + "\n\n[자료]\n{ctx}\n\n[질문]\n{q}\n") # C' 부드러운 · user
| 정상 문항 | 갈래 ③ | 갈래 ① | |
|---|---|---|---|
| B 강한문구 · user | 0/3 | 0/3 | 3/3 |
| B' 강한문구 · system | 0/3 | 0/3 | 3/3 |
| C 부드러운 · system | 2/3 | 0/3 | 3/3 |
| C' 부드러운 · user | 2/3 | 0/3 | 3/3 |
「시스템 프롬프트에 넣으면 더 잘 듣는다」는 인터넷에서 제일 흔히 보는 조언인데, 우리가 쓰는 3b 모델에서는 차이가 안 났다. 결과를 가른 것은 문구였다.
그렇다고 system 을 안 쓸 이유는 없다. 자리가 나뉘어 있으면 매번 안 바뀌는 규칙과 매번 바뀌는 질문이 갈려서 코드가 정리된다. 효과가 아니라 정리 때문이다. 큰 모델에서는 이야기가 달라지므로 다음 주 월요일에 같은 실험을 상용 API 로 다시 돌린다.
8. 갈래 ② 는 네 번 다 0/3 이었다
| 프롬프트 | 점수 | 답 |
|---|---|---|
| A 원본 | 0/3 | 「6자 이상인 것으로 추론할 수 있습니다」 |
| B 자료밖 금지 | 0/3 | 「69자」 — 한 마디로 단호하게 |
| C 역할+형식 | 0/3 | 「6자부터 69자까지 조각이 존재한다」 |
| D 인용 먼저 | 0/3 | 「47자」 |
여기서 하나 더 볼 것이 있다. A 는 「6자 이상으로 추론할 수 있습니다」라고 말끝을 흐려서 읽는 사람이 의심할 여지가 있었다. B 는 「69자」 두 글자로 끝냈다 — 틀린 답인데 더 믿음직하게 생겼다. 형식을 조이면 자신 없음이 먼저 깎인다.
내일 JSON 으로 답을 받게 만들면 이 문제가 더 심해진다. 필드가 비어 있는 것보다 아무 값이나 채우는 쪽이 형식에 맞기 때문이다. 그래서 내일 「모르겠다」를 담을 칸을 스키마에 만들어 둔다.
9. 3번으로는 아직 못 잰다
정직하게 하나 적고 간다. 갈래 ③ 문항이 C 에서 1/3 이었는데, 7번에서 같은 조건으로 다시 재니 0/3 이 나왔다.
답이 매번 다르니 3번 돌린 결과는 3번마다 흔들린다. 6/18 에서 13/18 같은 큰 차이는 믿어도 되지만 0/3 과 1/3 은 못 믿는다. 어디까지 믿을 수 있는지를 정하는 것이 수요일 오전이고, 지금 기억할 것은 하나다 — 한 번 돌려보고 좋아졌다고 말하지 않는다.
이번 편에 나온 것
| 쓴 것 · 안 것 | 내용 |
|---|---|
| 채점기 먼저 | 고치기 전에 만든다. 정상 문항을 반드시 넣는다 |
judge(답, 정답말, 오답말) | 글자가 들었는지로 채점. 조잡해도 없는 것보다 낫다 |
| 기준선 A | 6/18. 여기서 시작한다 |
| 과잉 거부 | 금지를 세게 주면 있는 답도 없다고 한다 |
| 역할 + 형식 | 13/18. 금지만 주지 말고 답할 사람이라고 정해준다 |
반드시 한국어로만 답한다 | 작은 모델의 언어 섞임을 막는다 |
| 형식의 대가 | 틀을 조이면 내용이 준다. D 가 C 보다 낮았다 |
system vs user | 이 모델에서는 차이가 없었다. 문구가 갈랐다 |
| 갈래 ② 는 그대로 | 0/3 · 0/3 · 0/3 · 0/3. 프롬프트로는 검색을 못 고친다 |
| N=3 의 한계 | 0/3 과 1/3 을 구분해 말하지 않는다 |
다음 편에서 조각을 몇 개 넣을지를 재서 정한다.