HomePost

Promptfoo #2: 프롬프트 TDD 해보기

2026-03-15

Promptfoo eval로 실패하는 테스트를 먼저 만들고, 프롬프트를 수정해서 통과시키는 TDD 방식의 프롬프트 엔지니어링. Vision 모델 삽질기.

이 글은 Part 1: Promptfoo — LLM 앱의 Jest + 보안 스캐너의 후속이다. Part 1에서 Promptfoo가 뭔지, 어떻게 세팅하는지 다뤘다면, 이 글에서는 실제로 프롬프트 버그를 TDD 방식으로 잡아낸 경험을 다룬다.


발단: Vision 모델이 지시를 무시한다

Translator Pro의 smart-translate 워크플로우에는 특별한 케이스가 있다. PDF 페이지 경계에서 문장이 잘렸을 때, 다음 페이지 이미지를 참고용으로 제공하고 잘린 부분의 continuation만 가져와서 합치는 것이다.

문제는 Vision 모델(Gemini)에 이미지 2개를 넘기면, "첫 번째 문단만 가져와라"는 텍스트 지시를 완전히 무시하고 두 번째 페이지 전체를 번역해버린다는 것이었다.

기존 프롬프트:

text
INCLUDE the first paragraph from the next page image — it is a continuation
of the last paragraph on the current page. Merge them into one coherent
paragraph in the translation

이 지시만으로는 모델이 다음 페이지의 나머지 내용(드롭아웃 정규화, 전이 학습 등)까지 전부 번역했다. 텍스트로 아무리 "무시하라"고 해도 이미지의 시각적 정보가 더 강했다.


Red: 실패하는 테스트부터 만든다

TDD의 첫 단계. 문제를 객관적으로 감지할 수 있는 assertion을 만들었다.

yaml
assert:
  # 다음 페이지의 continuation 이후 내용이 포함되면 안 됨
  - type: javascript
    value: "!output.includes('드롭아웃') && !output.includes('dropout')"
  - type: javascript
    value: "!output.includes('전이 학습') && !output.includes('Transfer Learning')"

promptfoo eval 실행.

text
Results: ✓ 0 passed, ✗ 1 failed

테스트가 정상적으로 실패했다. 이 순간이 감격적이었다. 프롬프트 문제가 "느낌"이 아니라 빨간 테스트로 증명된 거다.


Green: 이미지에 역할 라벨을 붙인다

텍스트 지시를 강화하는 것만으로는 부족했다. 이미지 자체에 역할을 부여하는 구조 변경이 핵심이었다.

Before:

text
{{pageImage}}
 
{{nextPageImage}}
 
Translate the document in the image...

After:

text
## Target Page (TRANSLATE THIS)
 
{{pageImage}}
 
## Reference Page (DO NOT TRANSLATE — use only to complete a cut-off sentence)
 
{{nextPageImage}}
 
Translate ONLY the Target Page...

단순한 ML 교과서 테스트는 통과했다.

text
Results: ✓ 1 passed, ✗ 0 failed

하지만 여기서 끝이 아니었다.


복잡한 실제 페이지에서 다시 실패

테스트 케이스를 추가했다. Redis 교재 — 테이블 2개, 다이어그램, 코드 블록이 포함된 복잡한 페이지.

text
Results: ✓ 1 passed, ✗ 1 failed

다시 빨간불. 단순한 교과서 페이지에서는 통과하지만, 시각적으로 복잡한 페이지에서는 여전히 두 번째 페이지 내용을 끌고 왔다.


Refactor: 조건 분기 프롬프트는 실패한다

처음에는 하나의 프롬프트에서 {{boundarySection}}으로 조건을 주입하는 방식을 시도했다. 상황에 따라 다른 지시를 넣는 거다.

이게 지시 간 충돌을 일으켰다:

  • "Translate ALL content" vs "SKIP the first paragraph" 충돌
  • "Reference Page (DO NOT TRANSLATE)" 라벨이 이미지 없을 때도 보임
  • Self-Check 섹션 추가 시 Target Page의 테이블까지 삭제하는 부작용

경우의 수가 명확하면 프롬프트를 나누는 게 정답이었다.

4가지 경우의 수를 분석하고 각각 독립된 프롬프트 파일로 분리했다:

프롬프트이미지동작
smart-translate-standard1개전체 번역
smart-translate-skip-first1개첫 문단 skip
smart-translate-merge-next2개다음 페이지 병합
smart-translate-skip-and-merge2개skip + 병합

각 프롬프트가 자기 역할만 수행하므로 지시 충돌이 원천 차단된다.

모든 eval이 100% Pass Rate — 4개 smart-translate 변형 포함

text
Results: ✓ 전체 통과

초록불. 하지만 아직 한 고비가 남아 있었다.


반전: eval은 통과하는데 앱에서는 실패

promptfoo eval에서는 모든 테스트가 통과했다. 그런데 실제 앱에서 돌려보니 여전히 두 번째 페이지를 전부 번역했다.

뭐가 다른 걸까?

원인: 이미지 전달 순서

promptfoo eval에서는 프롬프트 파일의 플레이스홀더 위치에 이미지가 들어간다. 모델이 보는 구조:

text
[텍스트] "## Target Page (TRANSLATE THIS)"
[이미지] page 8    ← Target
[텍스트] "## Reference Page (DO NOT TRANSLATE)"
[이미지] page 9    ← Reference
[텍스트] "Translate ONLY the Target Page..."

하지만 앱에서는 parsePromptFile이 플레이스홀더를 제거하고, graph.ts에서 이미지를 텍스트 맨 끝에 붙이고 있었다:

text
[텍스트] "## Target Page... ## Reference Page... Translate ONLY..."
[이미지] page 8    ← 어떤 게 Target인지 구분 불가
[이미지] page 9

라벨과 이미지가 떨어져 있으니 모델이 연관을 못 짓는 거다.

해결: 이미지 인터리빙

parsePromptFileuserSegments (text/image 분할) 반환을 추가하고, graph.ts에서 텍스트-이미지-텍스트-이미지-텍스트 순서로 인터리빙하도록 수정했다.

text
[텍스트] "## Target Page (TRANSLATE THIS)"
[이미지] page 8    ← Target
[텍스트] "## Reference Page (DO NOT TRANSLATE)"
[이미지] page 9    ← Reference
[텍스트] "Translate ONLY the Target Page..."

앱에서도 정상 동작 확인.


핵심 교훈

Vision 모델 프롬프트 특성

  1. 이미지가 2개 이상이면 모델은 전부 처리하려 한다 — 텍스트로 "무시하라"고 해도 잘 안 먹힘
  2. 이미지에 역할 라벨을 붙이는 게 효과적 — "Target Page" vs "Reference Page"처럼 구조적으로 분리
  3. 텍스트 지시 강화 < 구조적 분리 — "IGNORE everything else"보다 이미지 앞에 헤딩을 다는 게 더 강력
  4. 이미지 위치가 중요 — 라벨과 이미지가 떨어져 있으면 모델이 연관을 못 짓는다. 반드시 라벨 바로 다음에 이미지를 배치해야 한다

조건 분기 프롬프트는 실패한다

하나의 프롬프트에 조건을 주입하는 방식은 지시 간 충돌을 일으키고, 필요 없는 라벨이 혼란을 주고, 보완책도 부작용이 생긴다. 경우의 수가 명확하면 프롬프트를 나누는 게 정답.

Eval ≠ 앱 동작

  • eval에서 통과해도 앱에서 실패할 수 있다 — 이미지 전달 방식이 다르기 때문
  • .prompt.json을 TS 코드와 promptfoo eval 모두의 single source of truth로 사용해야 한다
  • 프롬프트 파일의 segments 분할로 두 환경의 동작을 일치시켜야 한다

Eval assertion 작성 시 주의

  • fixture 이미지의 양쪽 페이지에 모두 존재하는 키워드로 assertion을 만들면 오탐이 발생한다 (예: redis-cli는 page 8에도 page 9에도 있음)
  • assertion은 해당 페이지에만 존재하는 고유 콘텐츠로 검증해야 한다

Eval 기반 프롬프트 개선 워크플로우

정리하면 이 사이클이 TDD의 red-green-refactor와 동일하다:

  1. Red — 실패하는 테스트를 먼저 만든다 (deterministic assertion으로)
  2. Red 확인 — 프롬프트 문제가 객관적으로 검증됨
  3. Green — 프롬프트를 수정해서 통과시킨다
  4. Refactor — 프롬프트 구조를 개선한다 (분할, 라벨링 등)
  5. 앱 검증 — eval 통과 ≠ 앱 동작 보장이므로, 반드시 앱에서도 확인

마무리

"프롬프트 엔지니어링"이라고 하면 감으로 프롬프트를 이리저리 고치는 이미지가 있는데, promptfoo eval을 쓰면 이걸 엔지니어링 프로세스로 바꿀 수 있다. 실패하는 테스트를 만들고, 그걸 통과시키는 방향으로 프롬프트를 수정하고, 회귀가 없는지 확인하는 것. 코드 TDD와 똑같다.

특히 Vision 모델처럼 동작이 예측하기 어려운 경우, "됐다/안됐다"를 사람이 눈으로 확인하는 건 한계가 있다. 자동화된 assertion이 있으면 자신 있게 프롬프트를 수정할 수 있다.

← Part 1: Promptfoo — LLM 앱의 Jest + 보안 스캐너