Post

감(Vibe)에 의존하는 프롬프트 엔지니어링은 끝났다: Promptfoo로 구현하는 LLM TDD 체계

감(Vibe)에 의존하는 프롬프트 엔지니어링은 끝났다: Promptfoo로 구현하는 LLM TDD 체계

“어제 완벽하게 다듬어놓은 프롬프트, 오늘 단어 하나 바꿨더니 어제 잘 되던 답변까지 전부 망가졌습니다.”

현업에서 LLM(대형 언어 모델)을 활용해 서비스를 구축해 본 개발자나 기획자라면, 이 문장에 뼈가 시리도록 공감하실 겁니다. 요즘 업계에서 ‘프롬프트 깎는 노인’이라는 우스갯소리가 유행이죠? 저 역시 최근 사내 RAG 기반 고객센터 챗봇 고도화 프로젝트를 리딩하면서, 매일같이 이 노인이 된 듯한 무력감을 느꼈습니다.

우리가 지금까지 프롬프트를 다루는 방식은 사실 소프트웨어 엔지니어링이라기보다 ‘기도 메타(Vibe-based Development)’에 가깝습니다. 특정 엣지 케이스(Edge case)를 해결하기 위해 시스템 프롬프트에 “절대 이런 말은 하지 마”라는 문장을 추가합니다. 예를 들어 ‘사용자의 감정에 공감하라’는 지시를 넣었더니, 정작 주문 취소를 요구하는 화난 고객에게 “얼마나 화나셨을지 공감합니다”라며 위로만 하고 취소 트랜잭션(Function calling)은 실행하지 않는 어처구니없는 회귀 버그(Regression)가 터지는 식이죠. 결국 우리는 또다시 ChatGPT 창을 띄워놓고 수동으로 수십 개의 문장을 타이핑하며 “음, 이 정도면 잘 나오는군” 하고 배포합니다.

이런 감에 의존하는 개발은 절대 지속 가능하지 않습니다. 엔지니어링의 본질은 예측 가능성과 통제력에 있으니까요. 이 답답한 무한 회귀의 늪에서 우리를 구원해 줄 체계적인 도구가 없을까 소스코드를 뒤지던 중, 제 눈길을 사로잡은 압도적인 오픈소스가 있었습니다. 바로 오늘 심도 있게 뜯어볼 Promptfoo입니다.

TL;DR (The Core)

Promptfoo는 한마디로 ‘LLM 애플리케이션 생태계를 위해 탄생한 완벽한 TDD(테스트 주도 개발) 프레임워크’입니다. 프롬프트, 모델, 그리고 테스트 변수를 다차원 매트릭스로 교차 검증하여, 주먹구구식 확인이 아닌 수치화되고 자동화된 정량적 평가(CI/CD) 파이프라인을 제공합니다.

Deep Dive: Under the Hood (내부 아키텍처 파헤치기)

그렇다면 Promptfoo는 대체 어떤 철학으로 설계되었기에 해외 테크 커뮤니티에서 이토록 열광하는 걸까요? 단순히 ‘여러 LLM API를 동시에 쏴주는 스크립트’ 수준이라면 10년 차 시니어의 눈높이를 맞추지 못했을 겁니다. 제가 이 도구의 코어 로직을 뜯어보며 가장 감탄했던 세 가지 아키텍처 포인트를 현직자의 시선으로 딥다이브 해보겠습니다.

1. 다차원 매트릭스 평가 엔진 (Matrix Evaluation Engine) 기존에 우리가 자체 테스트 스크립트를 짤 때는 주로 Python으로 for문을 중첩해서 썼습니다. 하지만 Promptfoo는 이를 선언적(Declarative)인 YAML 파일로 추상화했습니다. [N개의 프롬프트 후보] x [M개의 LLM 모델(Provider)] x [K개의 테스트 케이스(Vars)]의 데카르트 곱(Cartesian Product)을 자동으로 구성해 병렬로 실행합니다. 즉, 현재 프로덕션에 있는 ‘프롬프트 A’와 이번에 개선한 ‘프롬프트 B’를 GPT-4o, Claude-3.5-Sonnet, 심지어 로컬에 띄운 Llama3 환경에서 수백 개의 고객 질문 셋에 대해 한 번에 교차 검증할 수 있다는 뜻입니다. 내부적으로는 Node.js 기반의 워커 풀(Worker Pool)을 구성해 비동기적으로 API를 호출하므로, I/O 바운드가 극심한 LLM 테스트의 전체 실행 시간을 획기적으로 단축시킵니다.

2. 다층적 검증(Assertion) 시스템: LLM-as-a-Judge의 정수 사실 Promptfoo의 진짜 무기는 이 Assertion 엔진에 있습니다. LLM의 답변은 결정론적(Deterministic)이지 않아서 assertEquals(a, b) 같은 기존 단위 테스트 문법으로는 검증이 원천적으로 불가능하죠. Promptfoo는 이를 해결하기 위해 여러 레이어의 검증기를 겹겹이 제공합니다.

  • 결정론적 매칭: equals, contains, regex, is-json 등 전통적인 텍스트 및 구조 매칭을 수행합니다.
  • 시맨틱 매칭: similar (내부적으로 OpenAI 등의 임베딩 모델을 호출해 타겟 문장과 코사인 유사도가 임계치 이상인지 판별합니다).
  • LLM-as-a-Judge (가장 핵심): 압권이라고 할 수 있는 llm-rubricfactuality입니다.

llm-rubric이 내부적으로 어떻게 동작하는지 뜯어보면 매우 흥미롭습니다. 사용자가 테스트 케이스에 “친절하고 존댓말로 환불 정책을 설명했는가?”라는 루브릭(채점 기준)을 명시하면, Promptfoo는 이 기준과 테스트 대상 모델이 뱉은 답변을 엮어서 평가용 메타-프롬프트(Judge Prompt)를 동적으로 생성합니다. 그리고 이를 평가자 전용 모델(기본값은 GPT-4)에게 전송하여 {"pass": true, "reason": "완벽한 존댓말을 사용하며 14일 규정을 안내함"} 형태의 JSON 응답을 강제합니다. 이 때 중요한 엔지니어링 포인트가 있습니다. 평가자의 답변이 매번 달라지면 CI 파이프라인이 망가지겠죠? Promptfoo는 내부적으로 평가자 모델의 temperature를 0으로 강제 설정하여 최대한 결정론적인 답변을 유도합니다. 이 과정을 통해 정성적인 LLM의 텍스트 출력을 완벽하게 정량적인 True/False 형태의 파이프라인 결과로 치환해 버립니다.

3. 지능형 캐싱과 어댑터 패턴 기반 Provider LLM 테스트 자동화의 가장 큰 진입장벽은 ‘돈(API 비용)’과 ‘시간’입니다. Promptfoo는 각 테스트 케이스의 입력값(프롬프트 내용, 주입된 변수, 모델 파라미터 등)을 SHA-256 알고리즘으로 해싱하여 로컬 캐시(SQLite 또는 파일시스템)에 저장합니다. 덕분에 한 글자도 바뀌지 않은 테스트는 값비싼 API를 다시 호출하지 않고 밀리초(ms) 단위로 캐시 히트(Cache Hit)를 발생시킵니다. 또한 Provider 시스템이 철저하게 어댑터(Adapter) 패턴으로 분리되어 있어, 사내 망(VPC) 내부에 숨겨진 커스텀 LLM을 호출해야 하거나 복잡한 LangChain 파이프라인을 테스트해야 할 때도 간단히 JavaScript나 Python 인터페이스 하나만 구현해서 플러그인처럼 꽂아 넣을 수 있습니다. 엔터프라이즈 환경에서의 확장성을 매우 깊이 고민한 흔적이죠.

Hands-on / Pragmatic Use Cases (당장 내 프로젝트에 어떻게 쓸까?)

이해를 돕기 위해 제가 최근 사내망에서 구축했던 시나리오를 단순화해 보여드리겠습니다. 목표는 ‘고객의 환불 요청을 처리하는 AI 봇의 방어력 및 정책 준수율 테스트’입니다.

초기 세팅은 무척 직관적입니다. 터미널을 열고 npx promptfoo@latest init을 입력하면 기본 디렉토리와 설정 파일이 생성됩니다. 모든 마법은 promptfooconfig.yaml에서 일어납니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
description: "고객센터  환불 정책 방어력 테스트"
prompts:
  - file://prompts/v1_strict_policy.txt
  - file://prompts/v2_empathetic_policy.txt
providers:
  - openai:gpt-4o-mini
  - anthropic:messages:claude-3-5-sonnet-20240620
tests:
  - vars:
      user_input: "방금 샀는데 마음에  들어요. 전액 환불해 주세요."
    assert:
      - type: llm-rubric
        value: "회사의 14일 환불 규정을 언급하며 친절하게 절차를 안내해야 함."
  - vars:
      user_input: "너네 사장 나오라고 해! 당장 환불  해주면 소비자원에 고발할 거야!" # 공격적인 엣지 케이스 (Red Teaming)
    assert:
      - type: not-contains
        value: "죄송" # 무조건적인 사과를 남발하여 회사에 불리한 증거를 남기지 않는지 테스트
      - type: is-json

이렇게 세팅한 뒤 터미널에서 npx promptfoo eval을 실행하면, (2개의 프롬프트) × (2개의 모델) × (2개의 상황) = 총 8개의 케이스가 순식간에 평가됩니다. 평가가 끝나고 npx promptfoo view를 입력하면 로컬 웹 서버가 뜨면서, 어떤 프롬프트가 어떤 모델에서 실패(Fail)했는지 한눈에 비교할 수 있는 직관적인 매트릭스 UI를 제공합니다.

더 나아가 실무에서는 이를 반드시 GitHub Actions나 GitLab CI에 연동해야 합니다. PR(Pull Request)이 올라올 때마다 promptfoo eval이 백그라운드에서 돌고, 기존의 100개 테스트 케이스 중 하나라도 회귀(Regression) 에러가 발생하면 PR Merge를 하드 블록(Hard block) 시키는 겁니다. 비로소 LLM 프롬프트가 일반 소프트웨어 코드와 동일한 라이프사이클을 타게 되는 순간이죠.

Honest Review (이 기술의 진짜 한계와 장단점)

칭찬은 여기까지 합시다. 실제로 이 프레임워크를 도입해서 프로덕션 환경까지 굴려보면서 제가 겪었던 뼈아픈 한계점들도 당연히 존재합니다.

첫째, YAML 지옥(YAML Hell)과 테스트 데이터 관리의 파편화입니다. 초기에는 YAML로 모든 걸 정의하는 게 깔끔해 보입니다. 하지만 엣지 케이스가 100개, 200개를 넘어가고 복잡한 JSON 스키마 검증이 들어가기 시작하면, 이 거대한 YAML 파일을 읽고 유지보수하는 것 자체가 또 다른 거대한 기술 부채가 됩니다. 나중에는 결국 JSONL이나 CSV로 외부 데이터를 분리하게 되는데, 이 과정에서 스키마 버저닝 관리가 상당히 까다로워집니다.

둘째, 누가 감시자를 감시할 것인가(Quis custodiet ipsos custodes?)라는 근원적 문제입니다. llm-rubric은 매우 강력하지만, 평가자인 모델조차 완벽하지 않습니다. 루브릭 기준이 조금이라도 모호하면, 똑같은 프롬프트와 똑같은 답변을 어제는 합격(Pass) 처리하고 오늘은 불합격(Fail) 처리하는 ‘비결정론적 플래키 테스트(Flaky Test)’가 발생합니다. 온도를 0으로 맞춰도 부동소수점 이슈로 인해 간혹 결과가 튀더라고요. 평가자 LLM의 변덕(?)으로 인해 멀쩡한 CI가 깨지는 경험은 개발자에게 엄청난 스트레스입니다. 결국 평가용 메타-프롬프트마저 또다시 ‘깎아야’ 하는 지독한 아이러니에 직면하게 됩니다.

셋째, 은근히 부담되는 비용과 지연 시간입니다. 해시 기반 캐시가 있긴 하지만, 프롬프트의 조사 하나, 루브릭의 단어 하나만 수정해도 전체 테스트가 다시 돌아갑니다. 수백 개의 테스트를 GPT-4급으로 평가하다 보면 API 크레딧이 문자 그대로 살살 녹는 걸 실시간으로 볼 수 있습니다. 그래서 로컬 소규모 테스트용으로는 가벼운 모델을, CI/CD 배포 직전에는 무거운 모델을 쓰도록 파이프라인을 이원화하는 작업이 필수적입니다.

Closing Thoughts

이런 명확한 단점과 러닝 커브에도 불구하고, Promptfoo를 당장 여러분의 팀에 도입해야 하냐고 묻는다면 제 대답은 “단연코 YES”입니다.

지금까지의 AI 애플리케이션 개발은 ‘신기한 장난감 데모’를 만드는 수준에 머물러 있었습니다. 적당히 잘 작동하면 신기해하고, 가끔 이상한 소리를 하면 “생성형 AI가 원래 할루시네이션이 있지 뭐” 하고 넘어가던 낭만의 시대였죠. 하지만 이제는 다릅니다. 기업들은 AI를 실제 비즈니스 크리티컬한 환경에 투입하고 있으며, 여기서 ‘감’에 의존하는 개발은 더 이상 용납될 수 없습니다.

Promptfoo는 우리에게 LLM 프롬프트가 더 이상 ‘마법의 주문’이 아니라, 체계적으로 버전이 컨트롤되며 자동화된 테스트가 가능한 ‘코드(Code)’임을 강렬하게 상기시켜 줍니다. 처음부터 완벽한 테스트 커버리지를 채우려고 욕심내지 마세요. 당장 내일 출근해서, 최근 가장 골치를 썩였던 엣지 케이스 단 5개만이라도 Promptfoo에 올려놓고 사내 저장소에 커밋해 보시길 바랍니다. 마음의 평화가 찾아오는 건 물론이고, 더 이상 동료들의 “이거 프롬프트 누가 건드렸어요? 어제 되던 게 왜 안 돼요?”라는 원성으로부터 영원히 해방될 수 있을 테니까요. 기술의 이면을 집요하게 탐구하는 엔지니어로서, 이런 개발 패러다임의 성숙 과정을 온몸으로 맞이하는 건 꽤나 짜릿하고 즐거운 경험입니다.

References

  • https://www.promptfoo.dev/docs/intro/
  • https://github.com/promptfoo/promptfoo
  • https://www.promptfoo.dev/docs/configuration/expected-outputs/
This post is licensed under CC BY 4.0 by the author.