컴퓨터공학 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 으로 받고, 명령 실행 같은 부작용은 모델 출력이 아닌 검증된 코드 경로로만 일어나게 한다.

확인 문제

  1. 입력 데이터를 구분자(예: XML 태그)로 감싸는 이유 두 가지는?
  2. 퓨샷 예시를 고를 때 주의할 점은?
  3. 프롬프트 인젝션을 구분자만으로 완전히 막을 수 없다면, 시스템 설계에서 어떤 방어를 더해야 하는가?
  4. “근거는 원문에서 그대로 인용한다” 같은 규칙이 검증 관점에서 좋은 이유는?
  5. 프롬프트를 고친 뒤 몇 가지 예로 좋아진 것을 확인했다. 왜 충분하지 않은가?

풀이

  1. 지시와 데이터의 경계를 분명히 해 모델이 데이터를 지시로 오해할 가능성을 줄이고, 여러 입력을 구조적으로 구분해 참조하기 쉽게 한다.
  2. 모델이 예시의 형식뿐 아니라 길이·어휘·라벨 분포까지 따라 하므로 다양하고 대표성 있게 고르고, 라벨이 한쪽으로 쏠리지 않게 한다.
  3. 출력 검증(허용 목록, 스키마), 권한 최소화, 위험한 동작 앞의 사람 확인, 모델 출력을 직접 실행하지 않는 구조.
  4. 문자열 포함 여부로 기계적으로 검사할 수 있어, 지어낸 근거를 자동으로 걸러 낼 수 있다.
  5. 다른 사례에서 회귀가 생겼을 수 있다. 대표 사례 전체를 평가 세트로 돌려 점수로 비교해야 한다.

더 읽을거리 (References)