결과에 따라 스스로 점수를 매기고, 자체 재작성본으로 A/B 테스트를 수행하며, 거의 배포 없이 승자를 교체하는 프롬프트 레이어
요약
프롬프트를 고정된 상수가 아닌 데이터베이스의 버전 관리되는 행(row)으로 취급하여, 배포 과정 없이 실시간으로 업데이트하고 관리하는 프롬프트 레이어 아키텍처를 제안합니다.
핵심 포인트
- 프롬프트를 코드 내 상수가 아닌 DB 레지스트리로 관리하여 배포 없이 실시간 교체 가능
- 실제 비즈니스 결과(암시적 점수)를 기반으로 프롬프트 품질을 객관적으로 평가
- A/B 테스트와 자동 재작성 사이클을 통해 프롬프트 성능을 지속적으로 최적화
거의 모든 "프롬프트 관리 (prompt management)"는 .txt 파일 폴더와 코드 리뷰에 불과합니다. 이것은 그 반대입니다. 프롬프트는 버전 관리되는 행(row)으로서 데이터베이스에 존재하며, 각 에이전트(agent)는 요청 시점에 최신 프롬프트를 찾습니다. 품질은 실제로 일어난 일(게시물이 수락됨, 초안이 게시됨 등, LLM이 스스로를 평가하는 것이 아님)을 바탕으로 점수가 매겨지며, 일일 사이클을 통해 패배한 프롬프트를 재작성하고, 재작성본에 대해 A/B 테스트를 수행하며, 승자를 자동으로 승격시킵니다.
요약 (TL;DR)
프롬프트가 레지스트리에 등록된 후의 사이클:
| 단계 | 발생하는 일 | 위치 |
|---|---|---|
| 해결 (Resolver) | 에이전트가 요청 시점에 활성화된 프롬프트를 찾음 (실시간 교체 가능) | resolve_prompt |
| ... |
이를 작동하게 만드는 유일한 아이디어: combined_score = 명시적(explicit)·0.4 + 암시적(implicit)·0.6이며, 여기서 암시적 점수는 실제 비즈니스 결과입니다. 모델은 결코 자신의 작업을 스스로 평가하지 않습니다.
출발점: 상수로 취급되는 프롬프트
거의 모든 사람이 게시하는 기준선(baseline)은 다음과 같습니다:
SUMMARY_PROMPT = """You are an expert reviewer. Summarise: {text}"""
async def summarise(text: str) -> str:
...
두 가지 문제점이 있으며, 이 문제들은 직접 겪기 전까지는 보이지 않습니다:
- 프롬프트를 변경하는 것은 배포입니다. 시스템 프롬프트(system prompt)의 쉼표 하나를 조정하는 것이 PR(Pull Request), 빌드(build), 롤아웃(rollout)을 의미합니다. 따라서 아무도 반복(iterate)하지 않으며, 프롬프트는 유지보수되지 않은 채 방치됩니다.
- "새로운 것이 더 나은가?"는 직감에 의존합니다. 문구를 변경하고, 세 개의 출력물을 훑어본 뒤, 배포합니다. 측정할 수 없으므로 개선은 없고 변화만 있을 뿐입니다.
아래의 모든 내용은 "상수 + 직감"을 "행(row) + 점수"로 대체합니다.
레이어 1: 버전 관리되는 행으로서의 프롬프트
프롬프트는 데이터가 됩니다. (agent, prompt_type, version)당 하나의 행이 생성되며, 그중 하나가 활성(active) 상태로 표시됩니다.
class PromptVersion(Base):
__tablename__ = "prompt_versions"
agent_name: Mapped[str]
...
새 버전을 등록하면 동일한 트랜잭션 내에서 이전 활성 버전을 비활성화하므로, "새 프롬프트 게시"는 배포가 아니라 데이터베이스 쓰기(write) 작업입니다:
async def register_prompt(self, *, agent_name, prompt_type, prompt_text, activate):
current_max = await self._max_version(agent_name, prompt_type)
if activate:
...
이미 존재하던 고정 프롬프트는 멱등성(idempotent)을 유지하며 레지스트리에 버전 1로 한 번 기록됩니다. 이 시드(seed)는 또한 폴백 (fallback) 역할을 하며, 다음 레이어에서 자세히 설명합니다.
레이어 2: 에이전트가 요청 시점에 프롬프트를 해결 (resolve)
이것이 반복(iteration)이 가져다주는 공짜 이점입니다. SUMMARY_PROMPT.format(...) 대신, 에이전트는 현재 행을 읽어오는 리졸버(resolver)를 호출합니다:
async def resolve_prompt(db, *, agent_name, prompt_type, template_vars, fallback_text):
"""`FOO_PROMPT.format(**vars)`의 직접적인 대체제이지만, 텍스트는 레지스트리에서 가져옵니다.
레지스트리에 행이 없는 경우 고정된 상수로 폴백합니다 (배포
...
명확하지 않은 두 가지 세부 사항이 있습니다:
- 폴백은 고정 프롬프트입니다. 레지스트리는 오버라이드 (override)일 뿐, 결코 하드 의존성 (hard dependency)이 아닙니다. 학습 테이블이 비어 있는 개발 환경은 상수에 기반한 기존 코드와 정확히 동일하게 동작합니다. 에이전트별로 하나씩 도입할 수 있습니다.
get_prompt는usage_count를 증가시키고version_id를 반환합니다. 이 ID는 요청 트레이스 (trace)에 찍히므로, 나중에 결과가 도착했을 때 (레이어 3) 해당 결과를 생성한 프롬프트의 정확한 버전으로 귀속될 수 있습니다. 이 연결 고리가 없다면 점수 매기기는 아무런 의미가 없습니다.
이 레이어를 거치고 나면, 운영 환경(prod)에서 프롬프트를 변경하는 것은 배포 없이 다음 요청에서 즉시 적용되는 UPDATE (또는 새로운 활성 행 추가) 작업이 됩니다. 이는 진정한 질문을 던지게 합니다: 새로운 행이 더 낫다는 것을 어떻게 알 수 있을까요?
레이어 3: 의견이 아닌 결과로부터의 품질
이것이 핵심이며, 대부분의 "프롬프트 평가 (prompt evaluation)"가 실수하는 지점입니다. 의도적으로 가중치를 둔 두 가지 유형의 신호(signal)가 있습니다:
명시적 (Explicit): 사용자가 당신에게 말해줍니다. 출력물에 대한 👍/👎 또는 1~5점 사이의 별점입니다. 정직하지만 희소하고 편향되어 있습니다 (사람들은 보통 화가 났을 때 평가하기 때문입니다). 수집할 가치는 있지만, 가중치는 낮게 두는 것이 좋습니다.
암묵적 (Implicit): 세상이 당신에게 말해줍니다. 에이전트가 생성한 결과물이 실제로 _다운스트림 (downstream)_에서 작동했나요? 이것들은 Layer 2 트레이스(trace)를 통해 프롬프트 버전과 연결되는 실제 비즈니스 이벤트입니다:
# 암묵적 신호는 모델의 자기 평가가 아니라 다운스트림 결과입니다:
# cfp_accepted — 에이전트가 제안하는 데 도움을 준 CFP가 수락됨
# post_published — 에이전트가 작성한 초안이 게시됨
...
작업자가 최근 결과들을 검토하며, 각 결과를 해당 결과를 생성한 버전 대비 성공/실패로 기록합니다. 그런 다음 스코어러(scorer)가 이 모든 것을 합산합니다:
# combined_score = 명시적·0.4 + 암묵적·0.6
explicit = (avg_rating / 5 * 100) * 0.75 + approval_rate * 0.25
implicit = implicit_success_rate # 0–100, 다운스트림의 진실
...
암묵적 신호가 명시적 신호보다 더 높은 가중치를 갖도록 (0.6 대 0.4) 의도적으로 설정했습니다. 사람들이 좋다고 말하지만 정작 그 프롬프트로 제안한 CFP가 전혀 수락되지 않는다면, 이는 투박하더라도 실제로 수락을 이끌어내는 프롬프트보다 더 나쁜 프롬프트입니다. 결과가 근본적인 진실이며, 평가는 하나의 힌트일 뿐입니다.
동일한 프로세스를 통해 오늘의 combined_score를 일주일 전 동일한 프롬프트의 점수와 비교하여 **추세 (trend)**를 계산합니다 (improving | stable | declining). 절대적인 점수뿐만 아니라 이 추세야말로, 주변 환경이 변하는 동안 조용히 성능이 저하되고 있는 프롬프트를 식별해 줍니다.
벽에 새겨둘 만한 원칙은 다음과 같습니다: 절대로 모델이 자신의 출력물을 스스로 평가하게 두지 마십시오. "GPT에게 이 답변을 1점에서 10점 사이로 평가해달라고 요청하세요"라는 방식은 유창성 (fluency)을 측정할 뿐, 유용성 (utility)을 측정하지 못합니다. 점수를 답변이 시스템을 나간 _이후_에 발생하는 사건과 연결하십시오.
Layer 4: 버전에 따른 A/B 라우팅
새로운 후보 프롬프트는 즉시 활성화되지 않습니다. 대신 **변형 (variant)**으로 등록되며, A/B 테스트가 트래픽의 일부를 해당 프롬프트로 라우팅합니다:
# start_test: 변형을 등록하고 (활성화하지 않음), 90/10 테스트를 시작함
variant = await registry.register_prompt(..., activate=False)
db.add(ABTest(agent_name=..., prompt_type=...,
...
해결 단계 (Layer 2)에서는 먼저 실행 중인(RUNNING) 테스트가 있는지 확인하고 주사위를 던집니다:
test = await running_test_for(agent_name, prompt_type)
if test:
chosen = test.variant_version_id if randint(1, 100) <= test.variant_traffic_pct \
...
50/50이 아닌 90/10 방식입니다. 성능이 낮은 변형은 스스로를 테스트하거나 폐기될 때까지 트래픽의 10%만 차지합니다. 기존 제어군(control)은 나머지 90%의 트래픽을 계속 처리합니다.
Layer 5: 승자 선언 (또는 미선언)
평가는 정해진 일정에 따라 실행됩니다. 규칙은 의도적으로 지루하게 설계되었습니다. 지루함이야말로 노이즈를 승격시키는 것을 방지해 주기 때문입니다:
if variant.usage_count < test.min_samples: # < 50회 사용
return "not_enough_samples" # 계속 대기
...
세 가지 임계값이 있으며, 각 임계값은 나름의 근거를 가집니다:
min_samples = 50: 이 수치 미만에서의 "승리"는 순전히 운입니다. 아직은 지켜보지 마세요.|diff| ≥ 10 points: 2점 차이의 우위는 노이즈입니다. 실질적인 격차를 요구하십시오.hard_stop = 500: 500회의 사용 후에도 차이가 나지 않는다면 두 버전은 동등한 것입니다. 기존의 주력(titular) 버전을 유지하고 트래픽 낭비를 멈추십시오. 엄격한 상한선(hard stop)이 없다면 결론이 나지 않은 테스트가 영원히 실행될 것입니다.
승격(promotion)은 단순히 activate(winner)를 호출하는 것뿐입니다. 이는 Layer 1의 쓰기 작업과 동일한 핫 스왑(hot swap) 방식입니다. 새로운 프롬프트는 다음 요청부터 즉시 라이브 상태가 됩니다.
Layer 6: 사이클 종료, 패배자 재작성
지금까지는 여전히 인간이 변형(variant)을 직접 _작성_해야 했습니다. 마지막 단계는 이 과정을 자동화합니다. 일일 최적화 도구(optimizer)가 성능이 낮은 프롬프트를 찾아냅니다:
다음 중 하나라도 해당되어 성능이 낮은 경우:
usage_count >= 30 AND combined_score < 40 (데이터는 충분하지만 점수가 낮음)
performance_trend == "declining" AND combined_score < 60 (성능 저하 중)
…취약한 프롬프트와 그 점수를 LLM에 전달합니다 ("여기 프롬프트가 있고 현재 성능이 이렇습니다. 결과를 개선하도록 다시 작성해 주세요"). 그리고 재작성된 내용을 PENDING(대기 중) 제안으로 저장합니다. A/B 관리자(Layer 4)는 이 PENDING 제안들을 가져와 테스트를 시작합니다. 이제 사이클은 처음부터 끝까지 실행됩니다:
배포(seed) → 해결(resolve) → 측정(결과) → 최적화(재작성) → A/B 테스트 → 승격(promote) → 해결 …
사람이 여전히 테스트에 진입하는 항목을 승인합니다. 즉, 최적화 도구(optimizer)가 제안할 뿐, 프로덕션(prod)에 자동으로 배포하지는 않습니다. 하지만 작성, 점수 매기기, 그리고 승격 과정은 자동입니다.
함정: 버그 하나가 모든 쿼리를 무너뜨리다
하나의 셀프 서비스 기능이 다음과 같은 오류와 함께 실패하기 시작했습니다:
Multiple rows were found when one or none was required
해결사(resolver)의 버전 선택기(version selector)가 scalar_one_or_none()을 사용하고 있었습니다:
test = (await db.execute(
select(ABTest).where(ABTest.status == "RUNNING",
ABTest.agent_name == name,
...
스키마 상에서 프롬프트당 단 하나의 RUNNING 상태인 테스트를 강제하는 장치가 없었고, 13초 간격으로 두 개의 테스트가 열려 있었습니다. 그 시점부터 해당 프롬프트에 대한 모든 요청은 scalar_one_or_none()에 걸려 두 개의 행을 발견하게 되었고, 예외(exception)를 발생시켰습니다. 더 심각한 것은, 해결사가 LookupError만 포착하도록 되어 있어, 예외가 폴백(fallback)을 지나쳐 사용자에게까지 그대로 전달되었다는 점입니다.
두 가지 해결책, 둘 다 필요합니다:
# 1) 오류를 내는 대신 중복을 허용: 가장 최신 것을 선택하고 이상 징후를 기록합니다.
tests = (await db.execute(
select(ABTest).where(...).order_by(ABTest.created_at.desc())
...
-- 2) 데이터가 중복된 경우: 가장 오래된 RUNNING 테스트를 취소합니다.
UPDATE prompt_ab_tests SET status = 'CANCELLED' WHERE id = '<older>';
이 교훈은 일반화될 수 있습니다: scalar_one_or_none()은 "단 하나만 있어야 한다"가 데이터베이스 제약 조건(constraint)이 아닌 모든 곳에서 잠재적인 크래시(crash) 요인입니다. 만약 유일 인덱스(unique index)가 불변성(invariant)을 강제하지 않는다면, 읽기 경로(read path)는 위반 사항을 무시하는 것이 아니라 이를 허용할 수 있어야 합니다.
도움이 되지 않았던 것들
- 자기 평가 품질. 모델에게 자신의 출력을 점수 매기라고 요청하는 것은 유창성(fluency)은 측정했을지 몰라도, 유용성(utility)은 결코 측정하지 못했습니다. 이는 다운스트림(downstream) 결과로 완전히 대체되었습니다.
- 50/50 A/B 분할. 테스트되지 않은 변형(variant)에 트래픽의 절반을 할당하는 것. 90/10 분할은 10분의 1의 영향 범위(impact radius)만으로도 동일하게 변형을 테스트할 수 있습니다.
- 절대 점수만 사용하기. 70점에서 시작해 점차 하락하는 프롬프트는 60점에서 안정적인 프롬프트보다 더 큰 문제입니다. 추세(trend)를 통해 점수가 숨기고 있던 부패(pudding)를 포착할 수 있었습니다.
- 단 하나의 테스트가 RUNNING 중이라고 가정하기. 유일 제약 조건(unique constraint)이 없다면 →
scalar_one_or_none은 시한폭탄과 같았습니다.
교훈
- 의견이 아닌 결과에 대해 점수를 매기세요. 모든 시스템은 프롬프트 버전과 결합된 실제 이벤트와 같은 암묵적 신호(implicit signals) 위에 세워집니다. 그 외의 모든 것은 장식에 불과합니다.
- 프롬프트 변경을 비용 없이 만드세요. 로그 기록과 요청 시점의 즉시 해결(resolution)을 결합하면, "프롬프트 변경"은 배포(deployment) 작업에서 단순한 행(row) 추가 작업으로 변합니다. 비용 없는 반복(iteration)은 개선을 위한 전제 조건입니다.
- 오버라이드(override)는 상수로 퇴화해야 합니다. 고정된 폴백(fallback)은 점진적으로 채택하면서도 사이클에 대한 의존성을 결코 강화하지 않음을 의미합니다.
- 지루한 임계값(thresholds)이 영리한 방법보다 승리합니다. 최소 샘플 수, 실제 격차 요구 사항, 그리고 엄격한 상한선(hard cap)이 노이즈를 승격시키는 것을 방지해 줍니다.
- 테스트되지 않은 변경에 대해서는 영향 범위를 작게 유지하세요. 50/50이 아니라 90/10입니다.
- 데이터베이스 제약 조건이 아니라면 불변성(invariant)이 아닙니다. 읽기 경로(read path)는 "불가능한" 두 번째 행을 허용할 수 있어야 합니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기