콘텐츠로 이동

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_ACTsilence 항목이 없다

즉 노드는 사용자가 침묵했다는 것조차 모른 채, 빈 입력과 대화 히스토리만 보고 응답을 만들었다. 히스토리에는 자기 직전 발화가 있다. 그래서 그것을 이어 붙였다.

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 다음 턴 준비 시 제거 현재 턴 재시도 소진 종류
# dst_conditions
"no_input_count": raw_context.get("fallback_no_input_count", 0),
{"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에 닿지 못한다.

return PolicyResult(context_updates={
    "fallback_exhausted": response_key,
    count_key: count + 1,
})

결과

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
python -m pytest    # 213 passed

원칙

통과시키는 것과 아무 말도 안 하는 것은 다르다. 정책이 처리를 포기했다면 그 사실을 노드에 알려야 한다. 알리지 않으면 노드는 상황을 모른 채 히스토리만 보고 답을 만들고, 히스토리에는 자기 직전 발화가 있다.

누적 카운터를 조건에 쓰기 전에 언제 0 으로 돌아가는지 확인할 것. 되돌아가지 않는 값은 "지금 상태"가 아니라 "지금까지 합계"다.

12·13·14단계와 같은 계열이다 — 플래그만 남기는 것은 절반이고, 규칙까지 이어야 아무 일이라도 일어난다.

남은 것

  • max_fallback_retries 의 뜻이 바뀌었다 — 이제 "막힌 구간마다 N회"다. 노드별 값(greeting 2, closure 0)은 그대로 두었지만, 의미가 달라졌으므로 다시 볼 여지가 있다
  • not_understood 도 같은 구조 — 소진 규칙을 공유한다. 반복해서 못 알아듣는 경우의 마무리 조건은 따로 두지 않았다