17. 종료를 위상에서 추론하지 않고 노드가 선언한다¶
15단계에서 마무리 노드에 되돌아가는 길을 달려다가 막혔다. 엣지를 하나라도 달면 종료가 깨졌기 때문에 global_transitions 로 우회했다. 그 제약의 뿌리를 걷어냈다.
제약이 어디서 나왔나¶
end_nodes 는 그래프 위상에서 도출됐다.
그래서 마무리 노드에 next_nodes 를 하나 달면:
end_nodes 가 비면 기본 종료 전이의 대상도, 세션을 닫는 장치도 함께 사라진다. 15단계는 이 제약을 우회했다 — global_transitions 는 엣지를 만들지 않으니까. 그 대가가 두 가지였다.
- 전역 전이는 모든 노드에서 평가되므로, 마무리 노드에서만 걸리도록
system.current_node가드를 손으로 달아야 했다. - 노드에 속한 규칙이 노드 밖에 있었다.
추론에는 함정이 하나 더 있었다. 결과가 노드 선언 순서를 따르므로, JSON 에서 노드를 옮기면 end_nodes[0] 을 쓰는 곳이 조용히 바뀐다.
노드가 선언한다¶
| 노드 필드 | 타입 | 기본값 | 의미 |
|---|---|---|---|
id |
string |
필수 | 노드 고유 ID |
terminal |
boolean |
false |
조건부 outgoing edge가 있어도 종료 노드로 선언 |
next_nodes |
array[object] |
[] |
terminal 판정보다 먼저 평가할 조건부 복귀 경로 |
def _resolve_end_nodes(nodes_info, derived) -> List[str]:
"""세션을 닫는 노드. **선언한 것과 추론한 것을 모두 모은다.**"""
declared = [
node_id for node_id, node in (nodes_info or {}).items()
if isinstance(node, dict) and node.get("terminal") is True
]
return declared + [node_id for node_id in derived if node_id not in declared]
둘을 합친다. 처음에는 "선언이 있으면 선언이 이긴다"로 만들었는데, 그러면 일부만 선언한 시나리오에서 나머지 노드가 갈 곳도 마무리 자격도 없어진다. 반대로 추론만 보면 복귀 엣지 하나에 마무리 노드가 사라진다. 어느 쪽이든 구멍이 나므로 합집합이 맞다.
선언이 앞에 온다 — end_nodes[0] 을 쓰는 곳(기본 종료 전이의 대상)이 추론 순서에
흔들리면 안 되기 때문이다. 나머지 세 시나리오는 손대지 않아도 동작이 같다.
senimate end_nodes=['conversation_closure'] (선언)
card_issuance_chatbot end_nodes=['completion_approved', 'completion_rejected'] (추론)
dental_caries_case1 end_nodes=['ensuring_closure'] (추론)
call_for_fire end_nodes=['call_for_fire_completion', ...] (추론)
종료 판정을 옮겼다¶
핵심은 여기다. 종료는 "후속이 없다"가 아니라 "마무리 노드다" 로 판정한다.
# 명시적 전이가 모두 빗나간 뒤, 그래프 후속을 보기 전에
if self._is_terminal(manager, current_node):
dialogue_state.set_complete(True)
return TransitionDecision(next_node=None, reason="terminal_node")
순서가 중요하다. 조건부 next_nodes 와 default_next 를 먼저 보므로, 복귀 조건이 맞으면 그리로 가고 아니면 닫힌다. 마무리 노드가 엣지를 가져도 매 턴 그 엣지로 새어 나가지 않는다.
그리고 종료를 판정하는 곳은 여기 하나뿐이다. 예전에는 _route_graph_successor 안에도 set_complete(True) 가 있었다. end_nodes 가 합집합이 되면서 후속 없는 노드도 이 검사에 걸리므로, 그 코드는 도달할 수 없게 되어 제거했다.
test_termination_is_decided_in_one_place
# _route_graph_successor 소스에 set_complete 가 없는지 — 갈라지면 실패한다
결과 — 우회가 사라졌다¶
closure 후속: ['daily_life_check', 'meal_check', 'health_check', 'general_conversation']
end_nodes: ['conversation_closure'] ← 엣지 4개가 있는데도 유지
복귀 전이가 전역에서 노드로 돌아왔다.
| 15·16단계 | 지금 | |
|---|---|---|
| 복귀 전이 위치 | global_transitions × 4 |
conversation_closure.next_nodes × 4 |
| 노드 범위 가드 | system.current_node 를 손으로 |
필요 없음 (노드에 달렸다) |
| 전역 전이 개수 | 6 | 2 (user_wants_to_end, silent_close) |
전역 전이가 매 턴 6번 평가되던 것도 2번으로 줄었다.
max_turns 경로도 terminal 을 본다¶
def _node_has_successor_config(node_config) -> bool:
if _node_field(node_config, "terminal") is True:
return False
return bool(
_node_field(node_config, "next_nodes", []) or _node_field(node_config, "default_next")
)
두 가지를 함께 고쳤다.
terminal이 먼저다. 복귀 엣지를 "갈 곳이 있다"로 읽으면 턴 상한에서 세션이 닫히지 않고 작별 대신 전환 문장이 나온다.default_next도 본다. 예전에는next_nodes만 봐서, 선형 경로만 선언한 노드가 갈 곳이 있는데도 턴 상한에서 세션을 끝냈다. senimate 에서는 해당 노드들이TaskOriented라 우연히 안 터졌을 뿐이다.
terminal next default_next 갈 곳 있음
greeting_introduction - 1 daily_life_check True
daily_life_check - 0 meal_check True ← 이전 False (버그)
meal_check - 0 health_check True ← 이전 False (버그)
health_check - 1 general_conversation True
general_conversation - 3 - True
conversation_closure True 4 - False ← 엣지가 있어도 마무리
실측 — 동작 동일¶
미안 일이 있어서 다시 이야기 해 → daily_life_check (복귀, 작별 표현 없음)
그런데 오늘 날씨 어때? → daily_life_check
아 잠깐만요 → daily_life_check
응 → 종료
(무응답) → 종료
정상 완주 9턴 → conversation_closure 에서 종료
validator 도 같은 기준을 본다¶
기준이 갈리면 복귀 엣지를 단 마무리 노드가 있는 그래프에 헛경고가 나간다.
declared_ends = _find_declared_terminals(g)
end_nodes = declared_ends + [n for n in topos.end_nodes if n not in declared_ends]
고립 판정에서도 선언된 마무리 노드를 빼야 한다 — 나가는 길이 없는 것이 정상이다. 그리고 종료 노드가 없을 때의 경고가 이제 선언 방법을 알려준다.
테스트¶
test_closing_node_declares_terminal_instead_of_relying_on_topology
# 복귀 엣지가 있는데도 end_nodes 에 남는지 — 이 구조의 존재 이유
test_end_nodes_combine_declaration_and_topology
# 합집합인지, 선언이 앞에 오는지, 중복이 없는지
test_termination_is_decided_in_one_place
test_resume_transitions_use_the_runtime_predicate
# 노드 next_nodes 에서 읽도록 갱신
test_routing_ends_the_session_at_a_successorless_node
# dm/tests/graph/test_validator_terminal.py (신규)
test_declared_terminal_counts_even_with_outgoing_edges
test_topology_still_used_without_declaration
test_declaration_comes_first_in_the_list
test_missing_end_node_warning_mentions_the_declaration
test_declared_terminal_without_edges_is_not_isolated
원칙¶
성질은 선언하게 하고, 추론은 폴백으로만 둔다. 위상에서 우연히 나오는 값에 동작을 걸면, 그 위상을 바꿔야 하는 순간 우회로가 생긴다. 15단계의
global_transitions우회와system.current_node가드가 그 우회로였다.
남은 것¶
- 다른 시나리오는 여전히 추론에 의존한다 — 마무리 노드가 무언가를 해야 할 때
terminal을 선언하면 된다. 지금은 선언하지 않아도 동작이 같다 - 다른 시나리오의 기본 종료 전이 대상 — 선언이 없으면
end_nodes[0]을 고르는데,card_issuance는completion_approved,call_for_fire는call_for_fire_completion이다. 중도 포기에는 맞지 않는다(call_for_fire_cancelled가 맞다).terminal선언 순서를 바꾸거나global_transitions를 직접 선언하면 된다 — 시나리오 설계 판단이라 손대지 않았다