12. 전환 턴의 이중 질문 — 프롬프트가 아니라 구조로 고쳤다¶
노드가 바뀌는 턴에서 봇이 질문을 두세 개씩 던지던 문제. 프롬프트 강화는 실패했고, 전달하는 정보를 바꾸니 해결됐다.
먼저 실패한 시도¶
10단계 에서 규칙 문구를 두 번 강화했다.
LISTENING "**물음표가 두 개면 이미 많습니다.** 같은 질문을 표현만 바꿔 덧붙이지 말고…"
HANDOFF_ONE_QUESTION "**질문은 다음 주제에 대해 한 개만**… 새 주제 질문을 던진 뒤에
그 질문을 부연하는 두 번째 질문을 덧붙이지 마세요."
4회 실측했고 전혀 움직이지 않았다.
[1,2,1,1,2,2,0,1,1] → [1,2,1,2,2,2,2,1,1] → [1,2,1,2,2,2,0,1,1] → [1,2,1,1,2,2,1,1,1]
↑ 2·5·6번 턴이 매번 같은 자리에서 두 개
같은 자리에서 반복된다는 건 모델의 변덕이 아니라 입력의 문제라는 뜻이다.
진단 — 전환 턴이 무엇을 받고 있었나¶
한 턴은 "직전 질문의 답을 받고 다음 노드의 질문을 던지는" 구조다. 그래서 전환 턴에는 다음 노드의 description 이 실린다. 실제로 실린 내용을 찍어봤다.
meal_check → 다음 노드: 건강챙기기
전환 턴에 실리는 지침 (앞 400자):
건강 상태를 확인하는 노드. 채우는 슬롯: health_status (adequate | complaint),
불편하다고 하실 때만 symptom · health_issue · pain_level. …
**수집 순서**: 먼저 몸이 어떤지 한 문장으로만 묻습니다. … 불편하다고 하시면
어디가 어떻게 불편한지 한 가지만 듣고, 그 증상 이름을 그대로 넣어 정도를 물으세요. …
그 노드의 responses.default: 몸은 좀 어떠세요?
전환 턴이 그 노드의 수집 계획 전체를 받고 있었다. 슬롯 4개와 3단계 수집 순서. 계획을 읽은 모델이 한 턴에 여러 단계를 커버하려는 것은 자연스러운 반응이다. "질문은 하나만"이라는 문장 하나로 이길 수 없다.
그리고 그 노드가 실제로 던져야 할 한 문장(responses.default)은 전환 턴에 실리지 않고 있었다.
고친 것 — 열 질문 하나를 지목한다¶
전환 턴은 주제를 열기만 하면 된다. 수집 계획은 다음 턴부터 그 노드가 직접 받는다.
1. 다음 노드 요약에 열 질문을 담는다¶
dm/core/dialog/dst/dst_execution.py
responses = next_info.get("responses")
opening = (responses or {}).get("default") if isinstance(responses, dict) else None
return {
...
# 그 노드가 처음 던지는 질문. 전환 턴은 주제를 **열기만** 하면 되므로
# 수집 계획 전체가 아니라 이 한 문장만 목표로 삼게 한다.
"opening": opening if isinstance(opening, str) else "",
}
2. 규칙으로 범위를 못박는다¶
dm/core/llm/prompts/domain_rules_text.py
HANDOFF_OPENING_QUESTION = (
"\n**이 턴에서 물을 것은 다음 하나뿐입니다:** \"{opening}\" "
"위 지침의 나머지(수집 순서·추가 슬롯)는 **다음 턴부터** 해당됩니다. 지금 미리 묻지 마세요. "
"이 문장을 그대로 쓰지 말고 앞 대화에 이어지도록 자연스럽게 바꿔서 한 번만 물으세요."
)
_add_handoff 에서 지침 바로 뒤에 붙인다. 계획을 읽은 직후에 범위를 좁혀야 효과가 있다.
if turn.next_node_guide:
rules.add(text.HANDOFF_NEXT_NODE_GUIDE.format(guide=turn.next_node_guide))
if turn.next_node_opening:
rules.add(text.HANDOFF_OPENING_QUESTION.format(opening=turn.next_node_opening))
실측 — 같은 스크립트로 A/B¶
고정된 사용자 발화 9턴, 봇 응답의 물음표 개수를 센다. 규칙만 껐다 켜서 3회씩 돌렸다.
| 턴별 물음표 수 | 물음표 2개 이상인 턴 | |
|---|---|---|
| 끄고 (baseline) | [1,1,1,1,1,1,1,1,1,0] |
0 |
[1,2,1,1,2,3,1,1,1,0] |
3 | |
[1,1,1,1,2,2,1,1,1,0] |
2 | |
| 켜고 | [1,1,2,1,1,1,1,1,1,0] |
1 |
[1,1,1,1,1,1,1,1,1,0] |
0 | |
[1,1,1,1,1,1,1,1,1,0] |
0 | |
[1,1,1,1,1,1,1,1,1,0] |
0 | |
[1,1,1,1,1,1,1,1,1,0] |
0 |
meal_check → health_check 전환(6번 턴)이 baseline 에서 매번 2~3개였고, 적용 후 5회 모두 1개다. 남은 1건은 전환 턴이 아니라 daily_life_check 내부 턴이다.
실제 응답:
baseline U: 김치찌개 먹었어요
B: 김치찌개 드셨군요. 그럼 몸은 좀 어떠세요? 혹시 어디 불편하신 데는 없으세요?
무릎이나 허리는 괜찮으신가요? ← 3개
적용 후 U: 김치찌개 먹었어요
B: 김치찌개 드셨군요. 맛있게 드셨길 바라요. 산책도 하시고 기분이
좋으셨겠어요. 몸은 좀 어떠세요? ← 1개
원칙¶
프롬프트 규칙이 세 번 실패하면 모델이 아니라 입력을 의심할 것.
"하지 마세요"를 강하게 쓰는 것보다 하지 말아야 할 것을 애초에 보여주지 않는 쪽이 낫다.
11단계의 fallback 정리와 같은 형태의 교훈이다. 거기서는 규칙의 잘못된 전제가, 여기서는 전달하는 정보의 범위가 원인이었다. 둘 다 문구를 덧붙여 덮으려던 시도가 먼저 실패했다.
테스트¶
test_next_node_brief_carries_the_next_nodes_opening_question
# responses.default 만 opening 이 되고 follow_up 은 안 실린다
test_next_node_brief_opening_is_empty_without_responses
test_handoff_turn_names_the_single_question_to_ask
# 수집 계획이 들어간 지침 + 열 질문이 함께 올 때 범위 제한이 붙는지
test_handoff_without_an_opening_question_is_unchanged
부수 효과¶
responses.default 가 두 곳에서 쓰이게 됐다.
| 위치 | 역할 |
|---|---|
| 그 노드의 턴 | 기준 문장 (원래) |
| 직전 노드의 전환 턴 | 열 질문 지목 (추가) |
즉 default 를 고치면 그 노드의 첫 질문이 한 턴 앞에서부터 바뀐다. 11단계 B절 의 표에 이 경로를 함께 봐야 한다.
이 지목이 전환 턴이 아닌 곳에도 실리고 있던 것이 뒤이어 드러났다.
min_turns가 안 찬 턴이 다음 노드의 열 질문을 그대로 던졌다 → 13