이 글은 WelfareAI RAG 에이전트의 프롬프트를 검증 가능한 계약으로 만들고, 감지→자가교정→측정으로 닫히는 품질 루프를 붙인 과정을 정리한 글이다.
핵심 주장은 하나다. 프롬프트 최적화는 문장을 다듬는 일이 아니라 계약을 정의하는 일이고, 계약은 잠그고 측정할 수 있어야 한다. "프롬프트를 더 좋게 고쳤다"는 주장은 (1) 무엇이 계약인지 명시하고 (2) 그 계약이 깨지면 CI가 알려주고 (3) 실제 답변이 계약을 지키는 비율을 숫자로 볼 수 있을 때만 성립한다.
세 그래프의 시스템 프롬프트를 나란히 놓고 보니 문제가 명확했다. Search 프롬프트는 도구 규칙·정확도 규칙·답변 형식까지 갖췄는데, Eligibility는 5줄짜리 원칙 목록이 전부였다.
3. 답변 첫 줄에 반드시 다음 셋 중 하나를 씁니다: [가능], [불확실], [어려움]
태그를 쓰라고만 했지 언제 어떤 태그를 골라야 하는지가 없다. 판정 기준이 미정의면 판정 일관성은 프롬프트가 아니라 모델의 그날 기분에 달려 있다. 자격 판정은 이 서비스에서 가장 책임이 무거운 답변인데도 그랬다.
프롬프트를 prompts.ts 한 곳으로 모으고(정적 문자열 유지 — prompt caching 계약), 각각을 강화했다. 원칙은 "모델이 판단을 내리는 지점마다 판단 기준을 명문화한다"였다.
Eligibility — 판정 태그의 의미를 정의:
- [가능]: 검색된 정책의 명시 조건을 확인된 사용자 조건이 모두 충족하고, 충돌이 없음
- [불확실]: 핵심 조건 중 사용자 정보가 미입력이거나, 문서에 조건이 명시되어 있지 않음
- [어려움]: 확인된 사용자 조건 중 하나 이상이 정책의 명시 조건과 충돌
- 조건 일부만 확인되면 [가능]이 아니라 [불확실]입니다. 낙관하지 않습니다.
Search — 도구 선택의 경계를 정의: 가장 헷갈리는 쌍은 search_welfare(탐색)와 check_policy_eligibility(특정 정책 자격 확인)였다. 시스템 프롬프트와 툴 description 양쪽에 "자격 확인이 목적이면 이쪽, 정책을 찾는 단계면 저쪽"이라는 상호 배제 안내와 예시 질문을 넣었다. 그리고 툴 루프 예산과 맞물리는 규율도: 같은 도구를 같은 인자로 재호출 금지, 두 라운드 안에 근거가 안 모이면 수집된 근거로 답변.
프롬프트를 고치는 것만으로는 부족하다. 다음 사람이(혹은 다음 달의 내가) 프롬프트를 수정하다 판정 태그 정의를 지우면? 그래서 두 겹으로 잠갔다.
1겹 — 프롬프트 계약 스펙 (CI, 결정적): 문구 전체가 아니라 계약만 잠근다. 판정 태그 셋, 답변 형식 마커(📋, 핵심 요약), 7개 도구 목록, 그리고 라우터 폴백의 안전핀인 "애매하면 SEARCH"까지.
it('ELIGIBILITY 프롬프트는 세 판정 태그를 전부 정의한다', () => {
expect(ELIGIBILITY_SYSTEM_PROMPT).toContain('[가능]');
// ...
});
2겹 — 답변 형식 관찰 (런타임, 차단 없음): 프롬프트가 계약을 말해도 모델이 지킨다는 보장은 없다. 그래서 verify_answer에 순수 함수 체커를 심었다 — 첫 줄이 판정 태그로 시작하는가, 정책 블록을 썼으면 핵심 요약 줄이 있는가. 위반해도 답변을 막지 않고 trace에 답변 형식 경고 이벤트만 남긴다. 프롬프트 변경 전후로 형식 준수율이 어떻게 움직였는지를 실트래픽으로 측정하는 지표다.
프롬프트를 바꾸고 단위 테스트·빌드·eval을 다 통과시킨 뒤, 실제 인스턴스를 별도 포트에 띄워 스모크를 돌렸다. 여기서 테스트 3종이 전부 못 잡은 버그가 두 개 나왔다.
첫째, API가 아예 안 떴다. 직전 커밋에서 TypeORM 엔티티의 nullable 컬럼에 | null 타입을 붙였는데, 유니온 타입은 데코레이터 메타데이터가 Object로 방출된다. 명시적 type이 없는 @Column({ nullable: true })은 이 메타데이터로 컬럼 타입을 추론하므로 부팅이 DataTypeNotSupportedError로 죽었다. jest도 nest build도 TypeORM 메타데이터를 빌드하지 않아서 통과했었다. 타입체크·단위 테스트·빌드가 전부 초록이어도 "부팅"은 별개의 검증 축이다.
둘째, 판정이 이미 가진 정보를 모른 척했다. 이전 세션에서 확인해 둔 사용자 사실(나이대 20대, 소득 중위 80%)이 있는데도 판정 근거에 "연령 미입력"이 나왔다. 자격 판정 컨텍스트는 retriever가 만든 프로필 요약을 우선 쓰는데, 거기에 대화 유래 사실이 빠져 있었다. 한 줄을 덧붙여 고치자 같은 질문의 판정이 이렇게 바뀌었다.
수정 전: [불확실] 연령(연령 미입력)과 소득이 없어 판단할 수 없습니다.
수정 후: [어려움] 사용자의 소득(중위소득 80% 이하)이 요구되는
청년가구 소득기준(중위소득 60% 이하)을 초과합니다.
"모르겠다"가 "안 된다, 이유는 이것"으로 바뀌었다. 프롬프트에 판정 기준을 정의해 둔 것이 그대로 작동한 순간이기도 하다 — 확인된 조건과 명시 조건의 충돌은 [어려움], 정확히 그 규칙대로.
여기까지가 "계약"이라면, 나머지 절반은 "루프"다. 기존 구조는 감지까지는 했다 — 검색 신뢰도 게이트가 엔티티 부재를 잡고, 그라운딩 체커가 근거 없는 주장을 잡는다. 문제는 감지 다음이 전부 사용자 호출(HITL)이거나 경고 딱지였다는 것. 기계가 스스로 고쳐볼 기회가 없었다.
검색 재시도 (retry_search): 지목된 정책이 검색 결과에 없으면, 사용자에게 재질문하기 전에 정책명 정확 매치(렉시컬 arm)를 가진 search_welfare로 한 번 더 찾아본다. 흥미로운 지점은 LLM이 필요 없었다는 것 — 어떤 정책이 안 나왔는지 이미 알고 있으므로 쿼리 재작성조차 결정적으로 된다. 이미 그 도구를 불렀거나 재시도를 했으면 기존대로 HITL. 턴당 1회, trace에 기록.
정정 부록 (grounded-repair): 근거 없는 정책명이 답변에 들어갔을 때 "답변을 다시 쓰면 되지 않나?"가 첫 발상이었지만, 답변은 이미 토큰 단위로 사용자 화면에 스트리밍된 뒤다. 스트리밍 아키텍처에서 사후 검증의 교정 수단은 재작성이 아니라 덧붙이기다. LLM이 2~3문장 정정 안내를 생성해 블록쿼트 부록으로 붙이고, 실패/타임아웃이면 결정적 문구로 폴백한다 — 어느 쪽이든 부록은 반드시 나간다.
측정 (rag:quality): 이 모든 관찰 이벤트가 rag_traces에 쌓이므로, 집계 리포트 하나로 루프가 닫힌다.
=== RAG 품질 운영 리포트 ===
[라우팅 tier] regex 19.2% | hitl_resume 3.8% | ...
[HITL] 프로필 재질문 3.8% | 복구형 HITL 30.8% | 재개 후 성공 100%
[안전장치] 예산 소진 0 | 검색 재시도 0 | 정정 부록 0
첫 리포트에서 이미 다음 개선 대상이 보인다. 복구형 HITL 30.8% — 검색이 약해서 사용자를 부른 비율이다. 방금 넣은 검색 재시도가 정확히 이 숫자를 깎는 장치이고, 몇 주 뒤 같은 리포트가 도입 효과를 말해줄 것이다.