16. 반복 침묵 — 같은 말을 되풀이하던 문제¶
무응답이 이어지면 봇이 직전 발화를 부풀려 되풀이했다. 원인은 세 개였고, 겹쳐 있었다.
증상¶
turn 15 · general_conversation
조용히 계신 걸 보니, 지금은 특별히 나누고 싶은 이야기가 없으신 것 같아요. 언제든지 …
turn 16 · general_conversation
조용히 계신 걸 보니, 지금은 특별히 나누고 싶은 이야기가 없으신 것 같아요. 언제든지 … ← 글자까지 동일
turn 17 · general_conversation
조용히 계신 걸 보니, … 다음에 또 다른 주제로 이야기 나눌 수 있으면 좋겠어요. 언제든지 …
재현해 보니 누적되는 형태가 더 분명했다.
1) 조용히 계신 것 같아서 잠시 기다렸어요. 언제든지 편하게 말씀해 주세요.
2) 조용히 계신 것 같아서 잠시 기다렸어요. 혹시 지금은 말씀하시기 어려우신가요? …
3) **다시 정리해서 말씀드리면,** 조용히 계신 것 같아서 잠시 기다렸어요. 혹시 …
봇이 자기 직전 발화를 입력으로 삼아 늘려 가고 있었다.
원인 1 — 재시도 예산이 세션 전체에 N회였다¶
count_key = f"fallback_{response_key}_count"
count = int(dialogue_state.context.get(count_key, 0) or 0)
이 카운터를 되돌리는 코드가 없었다. max_fallback_retries: 2 는 "막힐 때마다 2회"가 아니라 "대화 전체에 2회" 로 동작했다. 그래서 15턴쯤 되면 첫 무응답부터 이미 소진 상태다.
사용자가 정상적으로 말하면 예산을 되돌린다.
kind = self._classify(nlu_result, user_message)
if kind is None:
# 사용자가 정상적으로 말했다. 재시도 예산을 되돌린다.
return PolicyResult(context_updates=_cleared_fallback_counts(dialogue_state))
실측:
(무응답) count 0 → fallback_no_input
(무응답) count 1 → fallback_no_input
아 미안해요 잠깐 딴 생각했어요 count 2 → 0 ← 되돌아간다
(무응답) count 0 → fallback_no_input ← 처음처럼 되묻는다
이 값은 이제 연속 무응답 횟수라는 뜻을 갖는다. 원인 3에서 쓴다.
원인 2 — 소진 뒤에는 지침이 하나도 없었다¶
예산이 끝나면 FallbackPolicy 는 붙잡지 않고 통과시킨다. 옳은 동작이다. 그런데 통과시키면서 아무 것도 알리지 않았다.
BY_FALLBACK_KIND규칙은 정책이 발동할 때만 붙는다BY_DIALOG_ACT에silence항목이 없다
즉 노드는 사용자가 침묵했다는 것조차 모른 채, 빈 입력과 대화 히스토리만 보고 응답을 만들었다. 히스토리에는 자기 직전 발화가 있다. 그래서 그것을 이어 붙였다.
FALLBACK_EXHAUSTED = (
"\n사용자가 여러 번 응답하지 않았습니다. **이미 충분히 말을 건넸으므로 또 권하지 마세요.** "
"직전에 한 말을 다시 만들거나 거기에 문장을 덧붙이지 마세요. "
"**이 노드의 목적에 맞게 매듭지으세요.** 아직 들어야 할 것이 있는 노드라면 기다리겠다는 뜻만 "
"한 문장으로 전하고, 물어볼 것이 없는 노드라면 편안하게 인사하며 마무리하세요. "
"한 문장을 넘기지 마세요."
)
11단계에서 정한 원칙과 같은 모양이다 — 규칙은 태도만 정하고, 무엇을 할지는 노드 목적이 정한다. 수집 노드는 기다리고, 자유 대화 노드는 마무리한다.
safety_watch 와 마찬가지로 턴 단위로 초기화한다. 남으면 사용자가 말한 턴에도 "여러 번 응답하지 않았다"가 붙는다.
원인 3 — 반복 침묵이 마무리로 이어지지 않았다¶
물어볼 것이 없는 노드에서 계속 붙잡을 이유가 없다. 연속 무응답 횟수를 조건에 노출하고, 시나리오가 마무리를 선언한다.
| field | 타입 | 생산자 | 초기화 | 의미 |
|---|---|---|---|---|
context.fallback_no_input_count |
integer |
FallbackPolicy |
정상 발화 시 0 |
연속 무응답 횟수 원본 |
system.no_input_count |
integer |
dst_conditions |
원본 값을 매 평가 시 반영 | condition용 연속 무응답 view |
context.fallback_exhausted |
Optional[str] |
FallbackPolicy |
다음 턴 준비 시 제거 | 현재 턴 재시도 소진 종류 |
{"name": "silent_close", "target": "conversation_closure",
"condition": {"and": [
{"field": "nlu.dialogue_act", "operator": "equals", "value": "silence"},
{"field": "system.no_input_count", "operator": "greater_than", "value": 2}
]}}
원인 1을 고쳤기 때문에 이 값이 "지금 계속 조용하다" 를 뜻한다. 고치기 전이었다면 대화 초반에 세 번 조용했던 사람이 한참 뒤 한 번 침묵했을 때 종료됐을 것이다.
소진 카운트도 계속 세도록 했다. 통과 경로에서 멈추면 이 조건이 영원히 3에 닿지 못한다.
결과¶
U: 네 [health_check ] 식사를 잘 챙겨 드셨군요. 몸은 좀 어떠세요?
U: (무응답) [health_check ] 몸 상태가 괜찮으신지 궁금했어요. 혹시 불편한 곳이 있으시면 말씀해 주세요.
U: (무응답) [health_check ] 혹시 잘 안 들리셨을까요? 몸 상태가 괜찮으신지 다시 여쭤봐도 될까요?
U: (무응답) [closure ] 혹시 지금은 대화가 부담스러우실까요? 괜찮으시다면 나중에 다시
이야기 나눠도 좋습니다. 편안하게 쉬세요. ← 소진 지침
U: (무응답) [closure ] 오늘 이야기 나눠주셔서 감사해요. … [종료]
수집 노드에서는 두 번 되묻고, 세 번째에 매듭짓고, 마무리 노드로 가서 끝난다. 같은 문장의 반복도, 누적도 없다.
정상 완주 흐름과 이중 질문 지표(2+ 턴: 0)는 회귀 없음.
테스트¶
# dm/tests/test_fallback_policy.py
test_speaking_normally_clears_the_retry_budget
test_normal_turn_writes_nothing_when_the_budget_is_already_clean
test_budget_is_restored_for_the_next_silent_stretch
test_fallback_stops_holding_the_node_after_max_retries # 소진 시에도 카운트가 계속 오르는지
# dm/tests/test_session_end_authority.py
test_exhausted_fallback_reaches_the_prompt
test_no_exhaustion_rule_on_ordinary_turns
test_exhaustion_flag_is_cleared_each_turn
test_repeated_silence_routes_to_the_closing_node
test_silence_counter_is_exposed_to_conditions
원칙¶
통과시키는 것과 아무 말도 안 하는 것은 다르다. 정책이 처리를 포기했다면 그 사실을 노드에 알려야 한다. 알리지 않으면 노드는 상황을 모른 채 히스토리만 보고 답을 만들고, 히스토리에는 자기 직전 발화가 있다.
누적 카운터를 조건에 쓰기 전에 언제 0 으로 돌아가는지 확인할 것. 되돌아가지 않는 값은 "지금 상태"가 아니라 "지금까지 합계"다.
12·13·14단계와 같은 계열이다 — 플래그만 남기는 것은 절반이고, 규칙까지 이어야 아무 일이라도 일어난다.
남은 것¶
max_fallback_retries의 뜻이 바뀌었다 — 이제 "막힌 구간마다 N회"다. 노드별 값(greeting 2, closure 0)은 그대로 두었지만, 의미가 달라졌으므로 다시 볼 여지가 있다not_understood도 같은 구조 — 소진 규칙을 공유한다. 반복해서 못 알아듣는 경우의 마무리 조건은 따로 두지 않았다