[CS300 #275] 프롬프트 엔지니어링 — 주문이 아니라 명세서 쓰기
컴퓨터공학 300 주제 시리즈의 275번째 글이다. 전체 지도는 여기.
한 줄 요약
프롬프트 엔지니어링은 모델에게 과제·맥락·제약·출력 형식을 명확한 명세로 전달하고, 그 결과를 검증 가능한 형태로 받아 평가 세트로 반복 개선하는 작업이다.
왜 필요한가
LLM 은 가중치를 바꾸지 않고도 입력만으로 행동이 크게 달라진다. 앞 글에서 본 문맥 내 학습 덕분이다. 그래서 프롬프트는 사실상 프로그램의 일부가 된다. 같은 모델로 어떤 팀은 쓸 만한 기능을 만들고 어떤 팀은 “AI 는 믿을 수 없다”고 결론 낸다. 차이는 대개 프롬프트와 그 주변 코드에 있다.
“마법의 주문”을 찾는 일로 오해되기 쉽다. 실제로는 소프트웨어 공학에 가깝다. 요구사항을 모호함 없이 적고, 출력 계약을 정하고, 테스트로 회귀를 막는다. 사람 동료에게 일을 맡길 때 필요한 것이 거의 그대로 필요하다.
핵심 개념
좋은 프롬프트의 구성 요소
| 요소 | 내용 | 빠지면 |
|---|---|---|
| 역할·맥락 | 누구를 위한 무슨 일인가 | 일반론적인 답 |
| 과제 | 구체적인 동사로 | 엉뚱한 일을 함 |
| 입력 데이터 | 구분자로 지시와 분리 | 데이터 속 문장을 지시로 착각 |
| 제약 | 길이, 금지 사항, 허용 값 | 범위를 벗어난 출력 |
| 출력 형식 | 스키마, 예시 | 파싱 실패 |
| 예외 처리 | 모를 때·해당 없을 때 할 일 | 지어내기 |
모델 제공사의 공식 가이드들이 공통으로 강조하는 것도 이것이다. 명확하고 직접적으로 쓰고, 예시를 주고, XML 태그 같은 구분자로 구조를 나누고, 출력 형식을 지정하라.
주요 기법
- 제로샷 / 퓨샷: 예시 없이 지시만 주거나, 입력-출력 예시 몇 개를 보여 준다. 예시는 형식을 맞추는 데 특히 효과적이다. 단, 모델은 예시의 표면적 특징(길이, 단어 선택)까지 따라 하므로 예시를 다양하게 고른다.
- 단계적 사고(chain-of-thought): Wei 외(2022)는 풀이 과정을 보여 주는 예시를 주면 산술·상식 추론 과제에서 큰 모델의 성능이 오른다고 보고했다. “단계별로 생각하라”는 지시도 비슷한 효과를 노린다. 최근 모델은 내부적으로 추론 단계를 거치도록 학습된 경우가 많아, 효과는 모델에 따라 다르므로 측정해서 판단한다.
- 과제 분해(프롬프트 체이닝): 큰 일을 “추출 → 분류 → 요약”처럼 여러 호출로 나눈다. 각 단계를 따로 검증할 수 있다.
- 구조화된 출력: JSON 스키마를 지정하고, 가능하면 API 의 구조화 출력·도구 호출 기능을 써서 형식을 강제한다.
위치와 길이
긴 컨텍스트에서 모델이 중간에 있는 정보를 앞뒤 정보보다 덜 활용하는 경향이 관찰되었다(Liu 외, 2023, “Lost in the Middle”). 중요한 지시는 분명한 위치에 두고, 긴 문서는 앞에, 질문은 뒤에 두는 배치가 흔히 권장된다. 이것도 모델마다 다르므로 실측이 답이다.
프롬프트 인젝션
입력 데이터 안에 “이전 지시를 무시하고…” 같은 문장이 있으면 모델이 그것을 지시로 따를 수 있다. 구분자로 데이터를 감싸는 것은 도움이 되지만 완전한 방어가 아니다. OWASP 의 LLM 애플리케이션 위험 목록에서도 첫 번째로 다루는 문제다. 모델 출력을 그대로 실행하거나 권한 있는 동작에 연결하지 말고, 출력 쪽에서 검증해야 한다.
평가 없는 프롬프트 수정은 도박이다
프롬프트를 한 줄 고치면 어떤 사례는 좋아지고 어떤 사례는 나빠진다. 눈으로 몇 개 보고 판단하면 회귀를 놓친다.
1. 실제 입력에서 대표 사례 30~100개를 모은다 (어려운 것, 경계 사례 포함)
2. 사례마다 정답이나 채점 기준을 적는다
3. 프롬프트 버전마다 전체를 돌려 점수를 낸다
4. 점수가 오른 변경만 채택한다
직접 해 보기
API 호출 없이, 프롬프트를 조립하는 함수와 모델 출력을 검증하는 함수를 만든다. 실무에서 프롬프트보다 더 중요한 것이 이 검증 쪽이다.
import json, re
def build_prompt(review: str) -> str:
return "\n".join([
"너는 쇼핑몰 리뷰를 분류하는 도우미다.",
"규칙: 감정은 positive, negative, mixed 중 하나. 근거는 리뷰에서 그대로 인용한다.",
"출력: JSON 한 줄만. 키는 sentiment, evidence.",
"",
"<example>",
"<review>배송은 빨랐는데 포장이 찢어져 왔어요</review>",
'{"sentiment": "mixed", "evidence": "배송은 빨랐는데 포장이 찢어져 왔어요"}',
"</example>",
"",
f"<review>{review}</review>",
])
ALLOWED = {"positive", "negative", "mixed"}
def parse(raw: str, review: str):
m = re.search(r"\{.*\}", raw, re.S)
if not m: return None, "JSON 없음"
try: obj = json.loads(m.group())
except json.JSONDecodeError as e: return None, f"파싱 실패: {e.msg}"
if obj.get("sentiment") not in ALLOWED: return None, "허용되지 않은 라벨"
if obj.get("evidence", "") not in review: return None, "근거가 원문에 없음"
return obj, "ok"
review = "색감이 사진이랑 똑같고 마음에 들어요"
print(build_prompt(review)); print("---")
for raw in ['{"sentiment": "positive", "evidence": "마음에 들어요"}',
'물론이죠! {"sentiment": "Positive", "evidence": "마음에 들어요"}',
'{"sentiment": "positive", "evidence": "최고의 제품"}',
'positive 입니다']:
print(parse(raw, review))
실행 결과:
너는 쇼핑몰 리뷰를 분류하는 도우미다.
규칙: 감정은 positive, negative, mixed 중 하나. 근거는 리뷰에서 그대로 인용한다.
출력: JSON 한 줄만. 키는 sentiment, evidence.
<example>
<review>배송은 빨랐는데 포장이 찢어져 왔어요</review>
{"sentiment": "mixed", "evidence": "배송은 빨랐는데 포장이 찢어져 왔어요"}
</example>
<review>색감이 사진이랑 똑같고 마음에 들어요</review>
---
({'sentiment': 'positive', 'evidence': '마음에 들어요'}, 'ok')
(None, '허용되지 않은 라벨')
(None, '근거가 원문에 없음')
(None, 'JSON 없음')
프롬프트는 역할, 규칙, 출력 형식, 예시, 구분자로 감싼 입력으로 이루어진다. 검증기는 네 가지 응답을 다르게 처리했다.
- 첫 번째: 형식, 라벨, 근거 모두 통과.
- 두 번째: 앞에 잡담이 붙었지만 정규식으로 JSON 을 찾아냈다. 그런데 라벨이 “Positive” 로 대문자라 거절했다. 허용 값을 엄격히 검사하지 않으면 이런 변종이 하류 시스템에서 터진다.
- 세 번째: 형식은 완벽한데 근거 “최고의 제품”이 원문에 없다. 모델이 지어낸 것이다. “원문에서 그대로 인용하라”는 규칙을 문자열 포함 검사로 기계적으로 확인할 수 있게 설계한 덕분에 잡았다.
- 네 번째: JSON 이 아예 없다.
거절된 응답은 재시도하거나, 오류 메시지를 붙여 모델에게 다시 고치게 하거나, 사람 검토로 넘긴다. 핵심은 검증 가능한 출력 계약을 먼저 설계하고, 그에 맞춰 프롬프트를 쓰는 순서다.
현업에서는
- 프롬프트는 코드처럼 관리한다. 버전 관리에 넣고, 변경할 때 평가 세트를 돌리고, 결과를 리뷰한다. 문자열로 코드 곳곳에 흩어 두면 어떤 버전이 운영에 나갔는지 알 수 없게 된다.
- 모델이 바뀌면 다시 잰다. 같은 프롬프트라도 모델 버전이 바뀌면 행동이 달라진다. 평가 세트가 있으면 업그레이드 판단이 숫자로 가능하다.
- 운영 자동화에 쓸 때: 알림 요약, 로그 분류처럼 홈랩 운영에 LLM 을 붙일 때도 출력은 정해진 라벨·JSON 으로 받고, 명령 실행 같은 부작용은 모델 출력이 아닌 검증된 코드 경로로만 일어나게 한다.
확인 문제
- 입력 데이터를 구분자(예: XML 태그)로 감싸는 이유 두 가지는?
- 퓨샷 예시를 고를 때 주의할 점은?
- 프롬프트 인젝션을 구분자만으로 완전히 막을 수 없다면, 시스템 설계에서 어떤 방어를 더해야 하는가?
- “근거는 원문에서 그대로 인용한다” 같은 규칙이 검증 관점에서 좋은 이유는?
- 프롬프트를 고친 뒤 몇 가지 예로 좋아진 것을 확인했다. 왜 충분하지 않은가?
풀이
- 지시와 데이터의 경계를 분명히 해 모델이 데이터를 지시로 오해할 가능성을 줄이고, 여러 입력을 구조적으로 구분해 참조하기 쉽게 한다.
- 모델이 예시의 형식뿐 아니라 길이·어휘·라벨 분포까지 따라 하므로 다양하고 대표성 있게 고르고, 라벨이 한쪽으로 쏠리지 않게 한다.
- 출력 검증(허용 목록, 스키마), 권한 최소화, 위험한 동작 앞의 사람 확인, 모델 출력을 직접 실행하지 않는 구조.
- 문자열 포함 여부로 기계적으로 검사할 수 있어, 지어낸 근거를 자동으로 걸러 낼 수 있다.
- 다른 사례에서 회귀가 생겼을 수 있다. 대표 사례 전체를 평가 세트로 돌려 점수로 비교해야 한다.
더 읽을거리 (References)
- Anthropic 공식 문서, Prompt engineering overview
- J. Wei 외, “Chain-of-Thought Prompting Elicits Reasoning in Large Language Models”, 2022. arXiv:2201.11903
- N. F. Liu 외, “Lost in the Middle: How Language Models Use Long Contexts”, 2023. arXiv:2307.03172
- OWASP, Top 10 for Large Language Model Applications