콘텐츠로 이동

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 ... 가 여덟 군데 흩어져 흐름을 읽기 어려웠다.

나눈 방식

domain_rules.py        판정만 — 언제 어떤 규칙을 붙일지
domain_rules_text.py   문구만 — 실제로 LLM 에 갈 문장

문구를 고치는 데 판정 로직을 읽을 필요가 없고, 판정을 읽을 때 긴 한국어 문장에 시야가 가리지 않는다.

흐름을 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 클래스의 메서드로 흡수했다.

rules.add(text.LISTENING)           # 항상
rules.add_progress(text.OFF_TOPIC)  # 진행 안내 — 종료·무응답 턴에서는 빠진다

호출부만 봐도 어느 쪽인지 읽힌다. 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_KINDFallbackPolicy._KINDS 와 일치하는지 정책이 만든 종류에 문구가 없으면 규칙이 안 붙는다
자리표시자 이름이 호출부와 맞는지 안 맞으면 프롬프트에 {retry} 가 그대로 노출된다
모든 문구가 \n 으로 시작하는지 앞 규칙 끝에 붙어 한 줄이 된다
suppress 턴에 진행 안내가 빠지는지 종료 턴에 "다음 주제로 넘어가세요"가 함께 나간다

관련 문서