07. 프롬프트 위생과 domain_rules 정리¶
두 가지를 다뤘다. responses 가 프롬프트에 들어가는 방식과, 그 지시를 만드는 domain_rules.py 의 구조다.
A. responses — few-shot 예시에서 기준 문장으로¶
증상¶
노드에 responses 를 선언해 두니 선언한 문장이 거의 그대로 나왔다. 매 턴 같은 말을 한다.
원인: 같은 문구가 프롬프트에 두 번 들어가고 있었다¶
① 노드 설정: { ..., "responses": { "default": "...", "no_input": "...", ... } }
② 응답 예시 (이 스타일과 톤을 참고하되, 상황에 맞게 다양하게 변형하여 생성하세요):
- default: 안녕하세요, 저는 SeniMate이에요...
- no_input: 잘 안 들리셨을까요...
- not_understood: 제가 잘 못 알아들었어요...
측정 결과 default 2회, no_input 2회 등장했다. 게다가 정상 턴인데 무응답·이해 불가 문구까지 예시로 보여주고 있었다. 1315자 프롬프트에서 이 비중으로 같은 문장을 반복하면 LLM 은 베낀다.
무엇을 바꿨나¶
| 항목 | 이전 | 이후 |
|---|---|---|
| 노드 설정 덤프 | responses 포함 |
제외 (_ROUTING_ONLY_KEYS) |
| 예시 개수 | 선언된 전부 | 지금 상황에 해당하는 하나만 |
| 프레이밍 | "응답 예시 · 참고하되 변형하세요" | "이 턴에서 전달할 내용 · 그대로 쓰지 마세요" |
| 반복 방지 | 없음 | 최근 봇 발화 3개를 보여주고 금지 |
fallback 턴에는 FallbackPolicy 가 고른 문구가 자동으로 기준 문장 자리에 들어간다. 정책이 이미 상황을 알고 있으므로 따로 판단하지 않는다.
이 턴에서 전달할 내용(기준 문장):
- 안녕하세요, 저는 SeniMate이에요...
이 문장을 **그대로 쓰지 마세요.** 담긴 뜻과 말투만 참고해서, 사용자가 방금 한 말에
맞춰 새 문장으로 만드세요. 사용자가 이미 답한 내용을 다시 묻는 문장이라면 그 부분은 빼세요.
이미 말한 기준 문장은 아예 뺀다¶
"이 문장을 전달하세요" 와 "이 문장 쓰지 마세요" 가 같은 프롬프트에서 충돌하면 안 된다.
| 턴 | 기준 문장 | anti-repeat |
|---|---|---|
| 첫 턴 | 제시 | 없음 |
| 이미 말한 뒤 | 생략 | 제시 |
responses 의 역할이 정리됐다¶
| 용도 | 동작 |
|---|---|
| LLM 실패 시 안전망 | generate_natural_response 가 예외 시 그대로 사용 (기존 유지) |
| 기준 의미 전달 | 프롬프트에 "이 뜻을 전달하되 새 문장으로" |
| ~~few-shot 예시~~ | 제거 |
responses 는 이제 "이런 말을 해라"가 아니라 "이런 뜻을 전해라"다.
실측¶
--node greeting_introduction 으로 같은 노드에 5턴 (실제 LLM)
'' → 안녕하세요, 저는 SeniMate이에요. 어르신의 안부를 여쭙고 이야기를 나누고 싶어서 왔어요...
'반가워요' → 반갑습니다! 저는 SeniMate이에요. 요즘 어떻게 지내고 계신지 궁금해요...
'' → 혹시 잘 안 들리셨을까요? 천천히 말씀해 주셔도 괜찮아요... [no_input]
'ㅁㄴㅇㄹ' → 제가 잘 이해하지 못한 것 같아요. 혹시 ... 다시 한 번 말씀해... [not_understood]
'네 괜찮아요' → 그렇군요. 그럼 요즘 일상은 어떻게 보내고 계신지 궁금해요...
선언된 문구를 그대로 복사한 턴이 하나도 없다. 상황별 fallback 은 여전히 정확히 발동한다.
스스로 만든 버그 — anti-repeat 이 동작하지 않았다¶
Data flow 문서를 쓰며 히스토리 저장 형식을 확인하다 발견했다.
# 런타임 저장 (dst_state.update_conversation_history)
history.append({"role": "assistant", "message": response_text})
# anti-repeat 이 읽던 것
str(item.get("content", "")) # ← 항상 빈 값
키 이름이 달라 anti-repeat 이 한 번도 동작하지 않았다. _history_text() 를 두어 message / content / text 를 모두 받도록 고쳤다.
그 전 실측에서 응답이 다양해 보였던 것은 anti-repeat 이 아니라 기준 문장 프레이밍 변경의 효과였다. 회귀 테스트 2건을 런타임 형식으로 고정했다.
B. domain_rules.py 리팩토링¶
문제¶
판정 조건과 한국어 문구가 300줄에 섞여 있었다. if not suppress_turn_notes and ... 가 여덟 군데 흩어져 흐름을 읽기 어려웠다.
나눈 방식¶
문구를 고치는 데 판정 로직을 읽을 필요가 없고, 판정을 읽을 때 긴 한국어 문장에 시야가 가리지 않는다.
흐름을 7단계로 드러냈다¶
파일 상단 docstring 과 진입점이 같은 순서다.
rules.add(text.LISTENING)
if turn.has_nlu:
_add_speech_act(rules, turn)
_add_turn_situation(rules, turn)
_add_slot_progress(rules, turn)
_add_handoff(rules, turn)
_add_turn_budget(rules, turn)
| 단계 | 내용 |
|---|---|
| 1 | 역할 (응답자 모드면 여기서 끝) |
| 2 | 경청 — 모든 턴 공통 |
| 3 | 발화 행위 — dialog act |
| 4 | 턴 상황 — 무응답 / 이해 불가 / 종료 / optional 포기 / 턴 상한 / 주제 이탈 |
| 5 | 슬롯 진행 — 수집 완료 / 미수집 / 확인 / 낮은 신뢰도 |
| 6 | 노드 전환 — 전환 턴에만 |
| 7 | 턴 예산 — 남은 턴이 적을 때 |
가장 헷갈리던 것을 이름으로 드러냈다¶
suppress_turn_notes 를 _Rules 클래스의 메서드로 흡수했다.
호출부만 봐도 어느 쪽인지 읽힌다. intent_data / node_config 를 규칙마다 다시 파헤치던 것도 _Turn dataclass 로 한 번만 정리한다.
동작 보존을 두 겹으로 확인했다¶
- 기존 테스트 139건 통과 (커밋된 baseline 기준)
- 입력 매트릭스 대조 — 노드 설정 26종 × intent_data 조합 × context 5종 = 11,570건, 출력 불일치 0건
구조 계약 테스트¶
dm/tests/test_domain_rules_structure.py 10건. 판정과 문구가 어긋나면 런타임에서 조용히 실패하므로 미리 잡는다.
| 검사 | 막는 사고 |
|---|---|
BY_DIALOG_ACT 키가 실제 DialogAct 값인지 |
오타난 키는 영원히 매칭되지 않는다 |
BY_FALLBACK_KIND 가 FallbackPolicy._KINDS 와 일치하는지 |
정책이 만든 종류에 문구가 없으면 규칙이 안 붙는다 |
| 자리표시자 이름이 호출부와 맞는지 | 안 맞으면 프롬프트에 {retry} 가 그대로 노출된다 |
모든 문구가 \n 으로 시작하는지 |
앞 규칙 끝에 붙어 한 줄이 된다 |
| suppress 턴에 진행 안내가 빠지는지 | 종료 턴에 "다음 주제로 넘어가세요"가 함께 나간다 |
관련 문서¶
- Notion 📐 Domain rules — 분석과 흐름도
- Notion 🔀 Data flow — 히스토리가 쓰이는 세 경로