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 · 완료
그리고 되돌아갈 수 없었다.
원인 — 전역 전이가 자기 자신을 가리키고 있었다¶
봇이 작별 인사를 하고 나면 NLU 가 뒤따르는 "응", "ㅇㅋ", 무응답을 계속 user_wants_to_end 로 읽는다. senimate 의 종료 전이는 conversation_closure 를 가리키는데, 이미 그 노드에 있다.
next_node = self._resolve_target(...) # → "conversation_closure"
if next_node:
return next_node # 현재 노드를 그대로 돌려준다 = 노드 고정
노드가 고정되면 _route_graph_successor 까지 내려가지 않는다. 세션을 닫는 장치가 바로 거기 있다.
결국 max_turns=2 가 채워질 때까지 같은 인사를 반복했다.
고친 것 — 자기 자신을 가리키는 전이는 무시한다¶
이미 있는 곳으로 가라는 지시는 정의상 아무 것도 지시하지 않는다. 넘겨주면 후속 없는 노드에서 라우팅이 세션을 닫는다 — 종료해야 할 상황에서 바로 종료된다.
되돌아갈 길 — 노드가 아니라 전역 전이에¶
되돌아가는 전이를 conversation_closure.next_nodes 에 두는 것이 자연스러워 보이지만, 넣으면 종료가 깨진다.
end_nodes 는 그래프 위상(출력 차수 0)에서 도출된다. 엣지가 하나 생기면 마무리 노드가 목록에서 빠지고, 종료 전이의 대상도 세션을 닫는 장치도 함께 사라진다.
global_transitions 는 엣지를 만들지 않는다.
전역 전이를 노드 범위로 좁혀야 했다¶
global_transitions 는 모든 노드에서 평가된다. 마무리 노드에서만 걸려야 하므로 범위를 좁힐 필드가 필요했다.
| condition field | 타입 | 생산자 | 의미 |
|---|---|---|---|
system.current_node |
Optional[str] |
dst_conditions |
전역 전이를 현재 노드 범위로 제한 |
복귀 대상 — 안 채운 슬롯이 있는 노드 먼저¶
전역 전이는 선언 순서대로 평가되고 첫 매칭이 이긴다. 그래서 우선순위를 데이터로 표현할 수 있다.
| 순서 | 조건 | 대상 |
|---|---|---|
| 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 는 LLM 이 자유롭게 만드는 문자열이라 조건에 걸기 어렵다. dialog act 는 닫힌 집합이므로 "마무리를 수긍하는 행위"를 열거하고 나머지를 복귀로 보는 것이 안전하다.
처음에는
null(알아듣지 못함)도 수긍에 넣었다가 뺐다. 뒤의 복귀 턴의 문장 절 참고 — 말을 한 이상 사용자는 아직 자리에 있다.
무응답이면 종료한다¶
마무리 노드에서 되묻는 것은 어색하다. FallbackPolicy 는 재시도 예산이 소진되면 붙잡지 않고 통과시키므로, 예산을 0 으로 두면 첫 무응답부터 노드가 자기 문장을 만들고 라우팅이 닫는다.
| param | 타입 | 당시 값 | 의미 |
|---|---|---|---|
max_turns |
integer |
2 |
마무리 노드 최대 체류 턴 |
max_fallback_retries |
integer |
0 |
첫 무응답부터 fallback이 노드를 붙잡지 않음 |
노드 역할을 두 갈래로 나눴다 (뒤에 되돌림)¶
이 절의 조치는 뒤에서 되돌렸다. 두 갈래를
description한 문단에 담는 것 자체가 프롬프트 안의 모순이었다. 지금은 런타임이 재개 턴에 설명을 통째로 갈아끼운다 → 복귀 턴의 문장
여기까지 하고도 "요즘 무릎이 아파서요" 에 작별 인사가 붙었다.
전환은 라우팅이 이 턴 뒤에 한다. 즉 이 턴의 프롬프트는 아직 마무리 노드의 것이고, description 이 무조건 마무리를 지시하고 있었다.
노드의 역할이 실제로 두 갈래이므로 description 을 조건화했다.
(1) 사용자가 마무리를 받아들이면(네·응·잘 있어요·감사 인사·무응답):
짧은 마무리 인사만 한 번, 1~2문장 이내.
(2) **아직 이야기를 이어가려 하면**(거절하거나, 새 이야기를 꺼내거나, 질문하면):
**마무리 인사를 하지 마세요.** 대화는 여기서 다시 이어집니다.
'다음에 또 뵐게요' 를 붙이면 사용자의 말을 무시한 것이 됩니다.
deny / negate 규칙이 아예 없었다¶
BY_DIALOG_ACT 에 deny 항목이 없어서, 거부를 받았을 때 노드 목적이 그대로 이겼다. 마무리 노드에 국한된 문제가 아니라 일반 규칙의 공백이었다.
"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. 모순 자체를 없앴다 — 두 번에 걸쳐¶
규칙을 하나 더 얹는 것으로는 부족했다. 프롬프트를 찍어 확인하며 모순되는 것을 하나씩 치웠다.
첫 번째 — 기준 문장.
그래도 어떤 발화에서는 작별했다. 다시 찍어 보니 규칙도 있고 기준 문장도 없는데 여전했다.
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
원칙¶
이미 있는 곳으로 가라는 지시는 아무 것도 지시하지 않는다. 그것을 전이로 취급하면 노드가 고정되고, 그 아래에 있던 종료 장치까지 함께 막힌다.
종료 구조를 건드릴 때는
end_nodes가 위상에서 도출된다는 것을 먼저 볼 것. 엣지 하나가 마무리 노드를 목록에서 지운다. 엣지를 만들지 않는global_transitions가 그래서 필요하다.조건에 걸 값은 닫힌 집합이어야 한다. intent 는 LLM 이 만드는 문자열이고, dialog act 는 열거된 목록이다.
반대되는 지시가 프롬프트에 함께 실릴 수 있으면 규칙을 덧붙이지 말고 하나를 치울 것. 긍정 지시("이것을 전달하라")가 부정 지시("이렇게 하지 말라")를 이긴다.
남은 것¶
_CLOSING_ACKS가 두 곳에 있다 — 코드(문장 판단)와 시나리오 복귀 조건(라우팅). 한쪽만 바꾸면 작별 인사를 해놓고 대화 노드로 돌아가는 어긋남이 생긴다- 복귀 기회는 한 턴뿐이다 —
max_turns=2이므로 마무리 노드의 두 번째 턴에서는 turn limit 이 먼저 세션을 닫는다(executor 가session_ended를 내면 라우팅이 실행되지 않는다). 수긍한 뒤 마음을 바꾸는 경로는 지원하지 않는다 - 복귀 대상이 슬롯 이름을 하드코딩한다 — 노드와 슬롯의 대응을 시나리오에 두 번 적는다(
collect_slots와 복귀 조건). 런타임이collect_slots로 유도할 수 있으나 지금은 선언이 명시적이라는 장점이 있다 - 다른 시나리오에는 복귀 경로가 없다 — senimate 만 선언했다