19장. 조건 분기와 라우팅
갈래마다 필요한 도구만 — 네 개의 전담 노드
한 줄 요약
갈림길 끝의 노드는 자기 일에 필요한 것만 가집니다. 주문 노드에는 주문 도구 둘, 일반 노드에는 상품 도구 둘, 정책 노드에는 도구 대신 문서 검색, 상담원 노드에는 LLM조차 없습니다. 라우터가 문의를 이미 나눠 줬기 때문에 가능한 일입니다.
1. 네 노드가 가진 것
| 노드 | 가진 것 | LLM 호출 | 하는 일 |
|---|---|---|---|
order |
get_my_orders, get_order_status |
있음 | 주문 데이터를 조회해 답한다 |
policy |
정책 문서 검색 (chroma_db/) |
있음 | 검색한 발췌만 근거로 답한다 |
human |
create_ticket |
없음 | 티켓을 만들고 접수번호를 알린다 |
general |
search_products, check_stock |
있음 | 상품 문의와 그 밖의 문의에 답한다 |
8장에서 만든 도구는 모두 여섯 개입니다. 그 여섯을 한 노드에 몰아주지 않고 갈래별로 나눠 줬습니다.
2. 왜 나눠 주는가
7장에서 확인한 두 가지를 그대로 적용한 것입니다.
- 도구 설명은 매 호출마다 입력에 실립니다. 도구가 많을수록 호출 한 번의 입력 토큰이 늘어납니다.
- 선택지가 많을수록 엉뚱한 도구를 고를 여지가 커집니다.
주문 노드에 도착한 문의는 이미 "주문 문의"로 분류된 것입니다. 그 노드가 티켓 도구나 상품 검색 도구까지 들고 있을 이유가 없습니다.
길을 나누면 도구도 나뉩니다. 분기 구조가 흐름만 정리하는 것이 아니라, 각 호출의 비용과 실수할 여지도 함께 줄입니다.
3. 주문 노드 — 도구 둘을 가진 호출
def order_node(state: CSState) -> dict:
"""주문 도구를 쓰는 갈래 (6~8장 도구 재사용)."""
r = client.models.generate_content(
model=MODEL, contents=state["question"],
config=types.GenerateContentConfig(
system_instruction="하루마켓 상담원. 도구로 조회한 데이터로만 답변. "
"주문번호·상품명·옵션·상태는 조회 결과의 값을 "
"그대로 옮긴다. 존댓말.",
tools=[tools["get_my_orders"], tools["get_order_status"]],
automatic_function_calling=types.AutomaticFunctionCallingConfig(
maximum_remote_calls=4),
),
)
return {"answer": r.text, "log": state["log"] + ["주문 노드 처리"]}
tools=에 파이썬 함수를 그대로 넣으면 SDK가 모델의 도구 요청을 받아 함수를 실행하고 결과를 다시 모델에게 돌려줍니다(7장의 자동 함수 호출). maximum_remote_calls=4는 그 왕복을 최대 네 번으로 묶는 안전장치입니다.
도구를 고르는 것은 모델이고, 어떤 도구를 줄지와 몇 번까지 허락할지를 정한 것은 우리 코드입니다.
4. 정책 노드 — 14장의 검색 후 답변을 노드로 감쌌다
def policy_node(state: CSState) -> dict:
"""14장의 검색·근거 선택·메타데이터 출처 표기를 그대로 재사용한다."""
answer = answer_policy_question(state["question"])
return {"answer": answer, "log": state["log"] + ["정책 RAG 노드 처리"]}```
이 노드에는 `tools=`가 없습니다. **검색은 모델이 요청해서가 아니라 코드가 먼저 실행합니다.** 14장의 `answer_policy_question()`이 가까운 청크 네 개(`k=4`)를 찾아 프롬프트에 넣습니다. 모델이 답변과 근거 발췌 번호를 반환하면, 코드는 번호를 검증하고 해당 청크의 `doc_title · article`로 출처를 붙입니다. 근거가 없거나 응답을 검증할 수 없으면 출처 없이 확인 필요 문구를 반환합니다.
`from lesson14_rag_retriever import answer_policy_question`으로 수정된 14장 함수를 가져옵니다. 그 모듈이 13장에서 만든 `chroma_db/`를 읽고 준비 상태도 확인합니다. 따라서 **14장 파일과 13장의 저장소가 모두 필요**합니다. 출처에는 선택한 청크의 `article` 전체가 표시되므로 실제로 사용한 조항보다 범위가 넓을 수 있습니다.
---
## 5. 상담원 노드 — LLM이 없는 노드
```python
def human_node(state: CSState) -> dict:
"""상담원 연결 갈래 — 티켓 생성."""
t = tools["create_ticket"](category=state["intent"],
summary=state["question"][:80], urgency="보통")
return {"answer": f"상담원 연결이 필요한 문의로 접수했습니다. "
f"접수번호는 {t['ticket_id']} 입니다. 순차적으로 연락드릴게요.",
"log": state["log"] + [f"티켓 발행 {t['ticket_id']}"]}
tools["create_ticket"](...)은 파이썬 함수를 직접 부른 것입니다. 모델에게 "티켓을 만들까요?"라고 묻지 않습니다. 이 갈래에 도착했다는 것이 곧 "접수한다"는 결정이기 때문입니다. 답변 문장도 정해진 틀에 접수번호만 끼워 넣습니다.
모든 노드가 LLM을 불러야 하는 것은 아닙니다. 할 일이 정해져 있으면 코드로 합니다. 빠르고, 비용이 들지 않고, 매번 같은 결과가 나옵니다.
6. 일반 노드 — 표에 없는 문의가 모이는 곳
def general_node(state: CSState) -> dict:
r = client.models.generate_content(
model=MODEL, contents=state["question"],
config=types.GenerateContentConfig(
system_instruction="하루마켓 상담원. 도구로 조회한 상품 정보로만 답변. "
"상품 설명에 없는 성능·기능은 설명에 없다고만 답하고 "
"추측이나 권장을 덧붙이지 않는다. "
"하루마켓 쇼핑과 무관한 질문에는 답하지 않고 "
"쇼핑 문의를 도와드린다고 안내. 존댓말 3문장.",
tools=[tools["search_products"], tools["check_stock"]]),
)
return {"answer": r.text, "log": state["log"] + ["일반 노드 처리"]}
상품 문의와 기타 문의가 이 노드로 옵니다. 세 노드의 지시를 나란히 놓습니다.
| 노드 | 시스템 프롬프트의 핵심 | 답의 근거 |
|---|---|---|
order |
도구로 조회한 데이터로만 답변, 값은 그대로 옮긴다 | 주문 데이터 |
policy |
정책 발췌 내용으로만 답변, 없으면 확인 불가 | 정책 문서 |
general |
도구로 조회한 상품 정보로만 답변, 설명에 없으면 없다고 | 상품 데이터 |
세 노드 모두 "~로만"으로 끝납니다. 노드마다 답의 근거가 되는 곳을 하나로 정해 준 것입니다. 일반 노드에는 한 줄이 더 있습니다. 어느 유형에도 들지 않은 문의가 모이는 곳이라, 쇼핑과 무관한 질문에는 답하지 않는다는 테두리를 함께 적었습니다.
핵심 정리
- 갈래 노드는 자기 일에 필요한 도구만 가집니다. 도구 여섯 개를 한 노드에 몰아주지 않습니다.
- 길을 나누면 호출마다 실리는 도구 설명과 잘못 고를 여지가 함께 줄어듭니다.
- 정책 노드는 도구 없이 코드가 먼저 검색하고 모델은 발췌만 보고 답합니다.
- 상담원 노드에는 LLM 호출이 없습니다. 정해진 일은 코드가 합니다.
- 노드마다 시스템 프롬프트가 다릅니다. 세 노드 모두 답의 근거가 되는 곳을 하나로 정해 줍니다.