콘텐츠로 이동

15. 마무리 노드 — 되돌아갈 길과 즉시 종료

이 문서는 당시 global transition 우회 설계의 기록입니다. 이후 terminal: true가 도입되어 closure 재개 규칙은 node의 next_nodes로 이동했습니다. 현재 구조는 17. 종료 노드 선언을 참고하세요.

마무리 노드가 들어가면 나올 수 없는 방이었고, 정작 끝내야 할 때는 같은 인사를 세 번 반복한 뒤 끝났다. 둘은 같은 원인에서 나왔다.


증상

You > 대화 종료
Mago > 알겠습니다. 오늘 이야기 나눠주셔서 감사해요. …          turn 2 · 진행 중
You > 응
Mago > 네, 알겠습니다.                                    turn 3 · 진행 중
You > (무응답)
Mago > 네, 알겠습니다.                                    turn 4 · 진행 중
You > ㅇㅋ
Mago > 네, 알겠습니다.                                    turn 5 · 완료

그리고 되돌아갈 수 없었다.

You > 대화 종료
You > 아니 조금만 더 얘기해요
Mago > 네, 알겠습니다. 다음에 또 이야기 나눌 수 있으면 좋겠어요.   ← 세션 종료

원인 — 전역 전이가 자기 자신을 가리키고 있었다

봇이 작별 인사를 하고 나면 NLU 가 뒤따르는 "응", "ㅇㅋ", 무응답을 계속 user_wants_to_end 로 읽는다. senimate 의 종료 전이는 conversation_closure 를 가리키는데, 이미 그 노드에 있다.

next_node = self._resolve_target(...)   # → "conversation_closure"
if next_node:
    return next_node                    # 현재 노드를 그대로 돌려준다 = 노드 고정

노드가 고정되면 _route_graph_successor 까지 내려가지 않는다. 세션을 닫는 장치가 바로 거기 있다.

if not succ and next_node is None:
    dialogue_state.set_complete(True)

결국 max_turns=2 가 채워질 때까지 같은 인사를 반복했다.

고친 것 — 자기 자신을 가리키는 전이는 무시한다

if next_node == current_node:
    # 이미 그 노드에 있다. 전이가 아무 것도 지시하지 않으므로 이 노드의
    # 평소 라우팅에 맡긴다.
    continue

이미 있는 곳으로 가라는 지시는 정의상 아무 것도 지시하지 않는다. 넘겨주면 후속 없는 노드에서 라우팅이 세션을 닫는다 — 종료해야 할 상황에서 바로 종료된다.


되돌아갈 길 — 노드가 아니라 전역 전이에

되돌아가는 전이를 conversation_closure.next_nodes 에 두는 것이 자연스러워 보이지만, 넣으면 종료가 깨진다.

노드 레벨 전이를 넣으면:
  closure 후속: ['general_conversation']
  end_nodes: []                          ← 마무리 노드가 사라진다

end_nodes 는 그래프 위상(출력 차수 0)에서 도출된다. 엣지가 하나 생기면 마무리 노드가 목록에서 빠지고, 종료 전이의 대상도 세션을 닫는 장치도 함께 사라진다.

global_transitions엣지를 만들지 않는다.

closure 후속: []
end_nodes: ['conversation_closure']      ← 복귀 전이 4개를 넣은 뒤에도 유지

전역 전이를 노드 범위로 좁혀야 했다

global_transitions 는 모든 노드에서 평가된다. 마무리 노드에서만 걸려야 하므로 범위를 좁힐 필드가 필요했다.

condition field 타입 생산자 의미
system.current_node Optional[str] dst_conditions 전역 전이를 현재 노드 범위로 제한
# dst_conditions.evaluate_conditions
"system": {
    "current_node": dialogue_state.current_node,
    ...
}

복귀 대상 — 안 채운 슬롯이 있는 노드 먼저

전역 전이는 선언 순서대로 평가되고 첫 매칭이 이긴다. 그래서 우선순위를 데이터로 표현할 수 있다.

순서 조건 대상
1 slots.daily_life 비었음 daily_life_check
2 slots.meal_status 비었음 meal_check
3 slots.health_status 비었음 health_check
4 (조건 없음) general_conversation

각 전이의 공통 조건:

{"and": [
  {"field": "system.current_node", "operator": "equals",     "value": "conversation_closure"},
  {"field": "nlu.dialogue_act",    "operator": "not_equals", "value": "affirm"},
  {"field": "nlu.dialogue_act",    "operator": "not_equals", "value": "goodbye"},
  {"field": "nlu.dialogue_act",    "operator": "not_equals", "value": "thank"},
  {"field": "nlu.dialogue_act",    "operator": "not_equals", "value": "silence"},
  {"field": "slots.daily_life",    "operator": "is_empty"}
]}

intent 가 아니라 dialog act 로 판정한다

처음에는 user_wants_to_continue intent 를 쓸 생각이었다. 실측해 보니 나오지 않았다.

"아니 조금만 더 얘기해요"  → intent=deny            act=deny
"요즘 무릎이 아파서요"     → intent=health_status_inquiry  act=inform

intent 는 LLM 이 자유롭게 만드는 문자열이라 조건에 걸기 어렵다. dialog act 는 닫힌 집합이므로 "마무리를 수긍하는 행위"를 열거하고 나머지를 복귀로 보는 것이 안전하다.

처음에는 null(알아듣지 못함)도 수긍에 넣었다가 뺐다. 뒤의 복귀 턴의 문장 절 참고 — 말을 한 이상 사용자는 아직 자리에 있다.


무응답이면 종료한다

마무리 노드에서 되묻는 것은 어색하다. FallbackPolicy 는 재시도 예산이 소진되면 붙잡지 않고 통과시키므로, 예산을 0 으로 두면 첫 무응답부터 노드가 자기 문장을 만들고 라우팅이 닫는다.

"params": {"max_turns": 2, "max_fallback_retries": 0}
param 타입 당시 값 의미
max_turns integer 2 마무리 노드 최대 체류 턴
max_fallback_retries integer 0 첫 무응답부터 fallback이 노드를 붙잡지 않음

노드 역할을 두 갈래로 나눴다 (뒤에 되돌림)

이 절의 조치는 뒤에서 되돌렸다. 두 갈래를 description 한 문단에 담는 것 자체가 프롬프트 안의 모순이었다. 지금은 런타임이 재개 턴에 설명을 통째로 갈아끼운다 → 복귀 턴의 문장

여기까지 하고도 "요즘 무릎이 아파서요" 에 작별 인사가 붙었다.

U: 요즘 무릎이 아파서요
B: 무릎이 아프시군요. … 오늘 이야기 나눠주셔서 감사합니다. 다음에 또 뵐게요.

전환은 라우팅이 이 턴 뒤에 한다. 즉 이 턴의 프롬프트는 아직 마무리 노드의 것이고, description 이 무조건 마무리를 지시하고 있었다.

대화를 마무리하는 노드. **새 질문을 하지 않습니다.** … 마무리 인사를 **한 번만** 하세요.

노드의 역할이 실제로 두 갈래이므로 description 을 조건화했다.

(1) 사용자가 마무리를 받아들이면(네·응·잘 있어요·감사 인사·무응답):
    짧은 마무리 인사만 한 번, 1~2문장 이내.
(2) **아직 이야기를 이어가려 하면**(거절하거나, 새 이야기를 꺼내거나, 질문하면):
    **마무리 인사를 하지 마세요.** 대화는 여기서 다시 이어집니다.
    '다음에 또 뵐게요' 를 붙이면 사용자의 말을 무시한 것이 됩니다.

deny / negate 규칙이 아예 없었다

BY_DIALOG_ACTdeny 항목이 없어서, 거부를 받았을 때 노드 목적이 그대로 이겼다. 마무리 노드에 국한된 문제가 아니라 일반 규칙의 공백이었다.

"deny": (
    "\\n[부정] 사용자가 방금 제안이나 진행을 거부했습니다. **그 뜻을 그대로 받아들이세요.** "
    "하려던 것을 접고 사용자가 원하는 쪽으로 이어가세요. "
    "특히 **마무리하려던 상황이면 작별 인사를 하지 마세요.** "
    "'알겠습니다' 뒤에 '다음에 또 뵐게요' 같은 말을 붙이면 거부를 무시한 것이 됩니다. "
    "대화를 더 하고 싶다는 뜻이면 반갑게 받아주고 이야기를 이어가세요."
),
BY_DIALOG_ACT["negate"] = BY_DIALOG_ACT["deny"]

실측 — 5개 경로 전부

복귀 (내용 있는 말)  U: 요즘 무릎이 아파서요
                    B: 무릎이 아프시군요. 많이 불편하시겠어요. 통증이 심하면 병원에
                       가보시는 것도 좋을 것 같아요. 요즘은 어떻게 지내고 계세요?
                    → daily_life_check

복귀 (deny)         U: 아니 조금만 더 얘기해요
                    B: 네, 알겠습니다. 그럼 어떤 이야기를 더 나누고 싶으신가요?
                    → daily_life_check

복귀 (질문)         U: 그런데 너는 누구야?
                    B: 저는 SeniMate이에요. … 혹시 요즘 특별히 신경 쓰이는 일은 없으셨나요?
                    → daily_life_check

즉시 종료 (수긍)     U: 응        → B: 네, 알겠습니다. 편안한 하루 보내세요.   [완료]
즉시 종료 (무응답)   U: (무응답)  → B: 네, 알겠습니다. 편안한 시간 보내세요.   [완료]

종료가 5턴에서 3턴으로 줄고, 되돌아갈 수 있게 됐다. 정상 완주 흐름과 이중 질문 지표는 회귀 없음.


복귀 턴의 문장 — 라우팅은 맞는데 작별 인사를 했다

15단계 이후에도 이 트레이스가 나왔다.

U: 미안 이제 이야기 할 수 있어
B: 네, 알겠습니다. 언제든지 말씀 나누고 싶으실 때 편하게 찾아와 주세요. 편안한 시간 보내세요.
   → daily_life_check    (라우팅은 복귀했다. 문장만 작별이다)

프롬프트를 찍어 보니 안에 지시가 이었고 서로 반대였다.

- 진행 중인 단계: … (2) 아직 이야기를 이어가려 하면 … 마무리 인사를 하지 마세요.
- 이 턴에서 전달할 내용(기준 문장):
  - 오늘 이야기 나눠주셔서 감사해요. 다음에 또 뵐게요.        ← 정면 충돌
[부정] … 마무리하려던 상황이면 작별 인사를 하지 마세요.

responses.default무조건 작별이고, 그것이 "이 턴에서 전달할 내용"으로 실린다. 긍정 지시("전달하라")가 부정 지시("하지 말라")를 이긴다.

1. 노드가 자기 역할을 알게 했다

두 갈래 판단을 하려면 먼저 자기가 마무리 노드임을 알아야 한다.

runtime node field 타입 공개 여부 의미
_is_end_node bool 내부 전용 현재 노드가 runtime.end_nodes에 포함되는지
node_config["_is_end_node"] = bool(
    dialogue_state.current_node in (manager.runtime.end_nodes or [])
)

그리고 판단을 LLM 에게 맡기지 않고 못박는다.

_CLOSING_ACKS = frozenset({"affirm", "goodbye", "thank", "silence"})

@property
def resumes_conversation(self) -> bool:
    if not self.is_end_node or not self.has_nlu:
        return False
    return self.dialogue_act not in _CLOSING_ACKS

null(알아듣지 못함)은 넣지 않았다. 말을 한 이상 사용자는 아직 자리에 있다. 못 알아들었다고 대화를 닫으면 책임이 뒤바뀐다.

2. 모순 자체를 없앴다 — 두 번에 걸쳐

규칙을 하나 더 얹는 것으로는 부족했다. 프롬프트를 찍어 확인하며 모순되는 것을 하나씩 치웠다.

첫 번째 — 기준 문장.

if resuming:
    examples_text = ""          # `responses.default` 는 무조건 작별이다

그래도 어떤 발화에서는 작별했다. 다시 찍어 보니 규칙도 있고 기준 문장도 없는데 여전했다.

U: 미안 일이 있어서 다시 이야기 해
   intent='user_wants_to_continue' act='deny'  [대화 재개]=True  기준문장=False
   → 네, 알겠습니다. 다음에 편하실 때 이야기 나눠요. 좋은 하루 보내세요.

두 번째 — 노드 description. 남은 압력은 이것이었다. - 진행 중인 단계: 로 실리는 설명이 통째로 "마무리하라"고 말한다. 이 턴이 끝나면 라우팅이 다른 노드로 옮기므로, 마무리 노드의 지침은 애초에 해당하지 않는다.

_RESUME_NODE_PURPOSE = (
    "사용자가 대화를 이어가려고 합니다. 마무리하지 말고 대화를 계속하세요. "
    "이 노드의 마무리 지침은 이번 턴에 해당하지 않습니다."
)

if resuming:
    node_purpose = _RESUME_NODE_PURPOSE
    node_config = {**node_config, "description": _RESUME_NODE_PURPOSE}

이걸로 해결됐다. 그리고 두 갈래 description 이 더 이상 필요 없어져 마무리 역할만 적은 원래 형태로 되돌렸다 — 조건 분기는 런타임이 한다.

13단계와 같은 처방이다 — 반대되는 지시가 함께 실릴 수 있으면 규칙을 덧붙이지 말고 하나를 치운다. 이번에는 치울 것이 둘이었다.

3. 말했는데 silence 로 온 판정을 고쳤다

"아 잠깐만요" 가 act=silence 로 왔다. silence 는 "아직 아무 말도 하지 않았다"는 뜻이고 마무리 노드에서는 닫는 근거가 된다. 말한 것이 분명하므로 null 로 고친다.

def _correct_silence_on_spoken_input(nlu_result, user_message) -> NLUResult:
    if nlu_result.dialogue_act is not DialogAct.SILENCE:
        return nlu_result
    if not str(user_message or "").strip():
        return nlu_result
    corrected = dict(nlu_result.raw)
    corrected["dialogue_act"] = DialogAct.NULL.value
    return NLUResult.from_dict(corrected)

결과

미안 일이 있어서 다시 이야기 해  → daily_life_check  괜찮아요, 다시 이야기 나눌 수 있어서
                                                반가워요. 요즘 날씨가 참 좋죠? …
그런데 오늘 날씨 어때?         → daily_life_check  오늘 날씨가 궁금하셨군요. …
아 잠깐만요                  → daily_life_check  네, 말씀하세요. 어떤 이야기를 나누고 싶으신가요?
응                         → 종료              네, 알겠습니다. 다음에 또 뵐게요.
(무응답)                    → 종료              네, 알겠습니다. 언제든지 편하게 …

작별 표현이 재개 턴에서 사라졌다.

세 번 고쳐야 했던 이유

같은 증상을 세 번 고쳤다. 매번 "프롬프트에 규칙을 넣었으니 될 것"이라고 보고 넘어갔기 때문이다.

시도 조치 결과
1 deny 규칙 추가 deny 만 해결. inform 은 그대로
2 description 을 두 갈래로 좋아졌지만 발화에 따라 갈림
3 기준 문장 제거 대부분 해결. 일부 여전
4 description 교체 해결

규칙이 프롬프트에 실렸는지 확인하는 것만으로는 부족하다. 실려 있어도 반대 방향 지시가 남아 있으면 진다. 매번 프롬프트 전문을 찍어 무엇이 반대로 말하고 있는지를 찾아야 했다.

테스트

test_resume_transitions_do_not_make_the_closing_node_a_successor  # end_nodes 보존
test_resume_transitions_are_scoped_to_the_closing_node
test_resume_prefers_a_node_with_an_unfilled_slot
test_closing_acknowledgements_do_not_resume
test_no_input_at_the_closing_node_ends_instead_of_re_asking
test_self_targeting_global_transition_is_ignored
test_global_transition_to_another_node_still_routes                # 무시가 과하지 않은지
test_current_node_is_exposed_to_conditions

test_resume_rule_fires_on_engaging_acts
test_resume_rule_stays_quiet_on_closing_acknowledgements
test_null_is_not_a_closing_acknowledgement
test_resume_rule_only_applies_to_end_nodes
test_reference_sentence_is_dropped_on_a_resume_turn   # 모순이 사라졌는지
test_runtime_marks_end_nodes
test_spoken_input_is_never_silence
test_actual_silence_is_left_alone
python -m pytest    # 221 passed

원칙

이미 있는 곳으로 가라는 지시는 아무 것도 지시하지 않는다. 그것을 전이로 취급하면 노드가 고정되고, 그 아래에 있던 종료 장치까지 함께 막힌다.

종료 구조를 건드릴 때는 end_nodes 가 위상에서 도출된다는 것을 먼저 볼 것. 엣지 하나가 마무리 노드를 목록에서 지운다. 엣지를 만들지 않는 global_transitions 가 그래서 필요하다.

조건에 걸 값은 닫힌 집합이어야 한다. intent 는 LLM 이 만드는 문자열이고, dialog act 는 열거된 목록이다.

반대되는 지시가 프롬프트에 함께 실릴 수 있으면 규칙을 덧붙이지 말고 하나를 치울 것. 긍정 지시("이것을 전달하라")가 부정 지시("이렇게 하지 말라")를 이긴다.

남은 것

  • _CLOSING_ACKS 가 두 곳에 있다 — 코드(문장 판단)와 시나리오 복귀 조건(라우팅). 한쪽만 바꾸면 작별 인사를 해놓고 대화 노드로 돌아가는 어긋남이 생긴다
  • 복귀 기회는 한 턴뿐이다max_turns=2 이므로 마무리 노드의 두 번째 턴에서는 turn limit 이 먼저 세션을 닫는다(executor 가 session_ended 를 내면 라우팅이 실행되지 않는다). 수긍한 뒤 마음을 바꾸는 경로는 지원하지 않는다
  • 복귀 대상이 슬롯 이름을 하드코딩한다 — 노드와 슬롯의 대응을 시나리오에 두 번 적는다(collect_slots 와 복귀 조건). 런타임이 collect_slots 로 유도할 수 있으나 지금은 선언이 명시적이라는 장점이 있다
  • 다른 시나리오에는 복귀 경로가 없다 — senimate 만 선언했다