실무 Multi-Agent 오케스트레이션 22장 · 오케스트레이션 4 / 8 ← 이전목차다음 → TechLead Cro

22장. 오케스트레이션

정확한 값은 코드가 보증한다 — 처리번호, 티켓번호, 빈 답변

한 줄 요약

Supervisor의 답은 도구 → 전문 에이전트 → Supervisor를 거쳐 나옵니다. 단계마다 LLM이 글을 다시 쓰므로, 처리번호처럼 한 글자도 틀리면 안 되는 값과 "처리했는가"라는 사실은 모델이 옮겨 적게 두지 않습니다. 코드가 기록 파일을 실행 전후로 비교해 직접 확인하고, 그 결과를 보고 끝에 붙입니다.


1. 전달 사슬

배송지 변경 하나가 처리되어 고객에게 번호가 안내되기까지를 따라가 봅니다.

단계 누가 무엇을 하나 번호를 다루는 방식
1 change_shipping_address (코드) 판정하고, 기록을 쓰고, 처리번호를 만든다 코드가 만든다 — 틀릴 수 없다
2 요청처리 에이전트 (LLM) 처리 결과를 글로 보고한다 모델이 옮겨 적는다
3 Supervisor (LLM) 보고를 읽고 고객 답변을 쓴다 모델이 또 옮겨 적는다

2번과 3번에서 번호는 모델의 손을 두 번 거칩니다. 모델은 대부분 정확히 옮기지만, 3장에서 본 것처럼 그럴듯한 글을 쓰는 것이 본업입니다.

존재하지 않는 처리번호를 받은 고객은 나중에 문의할 방법이 없습니다. 더 곤란한 것은 번호가 아니라 사실이 바뀌는 경우입니다. 도구는 "기간이 지나 처리할 수 없다"고 돌려줬는데 보고를 거치며 "접수했습니다"가 되면, 고객은 오지 않을 회수 기사를 기다립니다. "정확히 옮기세요"라고 프롬프트에 적는 것만으로는 부족한 이유입니다.


2. 해법 — 기록을 실행 전후로 비교한다

ask_action_agent 도구의 몸통입니다. 요청처리 에이전트를 부르기 전과 후에 기록 파일 두 개를 읽습니다.

n_actions, n_tickets = len(load_actions()), len(_tickets())
result = agents["action"].invoke(
    {"messages": [{"role": "user", "content": situation}]})
report = extract_text(result["messages"][-1])
called = [tc["name"] for m in result["messages"]
          for tc in (getattr(m, "tool_calls", None) or [])]

actions, tickets = load_actions(), _tickets()
# 에이전트가 아무 도구도 부르지 않았으면(되물음 등) 코드가 상담원에게 접수한다 — 폴백
if not called:
    raw_tools["create_ticket"](category="기타", summary=situation[:100])
    tickets = _tickets()
    report = "요청을 직접 처리하지 못해 상담원에게 접수했습니다."

confirmed = []
for a in actions[n_actions:]:
    confirmed.append(f"처리번호 {a['action_id']} · {a['type']} · 상태: {a['status']}")
for t in tickets[n_tickets:]:
    confirmed.append(f"티켓번호 {t['ticket_id']} · 상담원 접수")
if not confirmed:
    confirmed.append("새로 처리되거나 접수된 것은 없음")
return report + "\n\n[시스템 확인] " + " / ".join(confirmed)
겹 코드 막는 일
전후 비교 실행 전의 기록 수(n_actions, n_tickets)를 기억해 두고, 실행 뒤에 새로 늘어난 기록만 잘라 낸다(actions[n_actions:]) "처리했습니다"라고 말만 하고 기록에는 없는 경우
번호 붙이기 새 기록에서 처리번호·종류·상태, 티켓번호를 읽어 보고 끝에 [시스템 확인] 줄로 붙인다 번호나 상태가 전달 중에 바뀌거나 지어내지는 경우
없으면 없다고 새 기록이 하나도 없으면 새로 처리되거나 접수된 것은 없음이라고 붙인다 처리되지 않은 요청이 "처리됐다"로 전해지는 경우
대신 접수 에이전트가 아무 도구도 부르지 않았으면 코드가 create_ticket을 직접 불러 상담원에게 넘긴다 에이전트가 되묻기만 하고 끝내 요청이 사라지는 경우

마지막 줄처럼 원래 방법이 실패했을 때 쓰는 대체 경로를 폴백(fallback) 이라고 부릅니다. 요청처리 에이전트의 프롬프트에 "되묻지 않습니다"라고 적어 두었지만(21장), 그래도 되묻고 끝내는 경우에 대비해 고객의 요청이 어디에도 남지 않는 일만은 없게 한 것입니다.

[시스템 확인] 줄은 상황에 따라 세 가지 모양으로 붙습니다.

상황 붙는 줄
배송지를 바꿨다 [시스템 확인] 처리번호 A00002 · 배송지변경 · 상태: 완료
기간이 지나 처리하지 못했다 [시스템 확인] 새로 처리되거나 접수된 것은 없음
상담원에게 넘겼다 [시스템 확인] 티켓번호 T00001 · 상담원 접수

Supervisor 프롬프트의 원칙 7번이 이 줄을 가리킵니다. "처리번호(A로 시작)와 티켓번호(T로 시작)는 요청처리 에이전트 보고의 [시스템 확인]에 있는 번호를 그대로 인용합니다. 번호를 만들어내지 않습니다." 그 번호는 모델이 쓴 문장이 아니라 코드가 파일에서 읽어 붙인 줄에 있습니다.

번호를 만든 것도 코드, 고객 답변 직전까지 옮긴 것도 코드입니다. 모델이 하는 일은 그 번호를 문장에 넣는 것 하나로 줄었습니다.


3. 21장의 판정과 이어 보면

코드가 맡은 일을 처음부터 끝까지 이어 봅니다.

물음 누가 답하나 어디서
이 요청을 처리해도 되는가 (출고 전인가, 기간 안인가, 재고가 있는가) 코드 처리 도구 안의 판정 (21장)
실제로 처리됐는가 코드 기록 파일의 전후 비교 (이 절)
처리번호가 무엇인가 코드 [시스템 확인] 줄 (이 절)
환불을 실행해도 되는가 사람 승인 (23장)

모델이 맡은 것은 그 사이입니다. 어느 에이전트에게 맡길지 고르고, 요청문을 쓰고, 도구 인자를 채우고, 결과를 고객에게 맞는 말로 옮깁니다.


4. 빈 답변을 받친다 — finalize_if_empty

같은 생각의 방어가 하나 더 있습니다. 모델이 도구 결과를 받은 뒤 아무 글도 쓰지 않고 실행을 끝내는 일이 드물게 있습니다. 고객에게 빈 말풍선을 보여 줄 수는 없습니다.

def finalize_if_empty(supervisor, messages: list) -> tuple[str, list]:
    text = _extract_text(messages[-1])
    if text.strip():
        return text, messages
    result = supervisor.invoke(
        {"messages": list(messages) + [{
            "role": "user",
            "content": "(시스템) 위 확인 결과를 바탕으로 "
                       "고객에게 보낼 최종 답변을 작성하세요."}]},
        config={"recursion_limit": 6},
    )
    return _extract_text(result["messages"][-1]), result["messages"]
줄 하는 일
if text.strip(): return 답이 있으면 아무것도 하지 않는다. 대부분의 실행은 여기서 끝난다
supervisor.invoke(...) 답이 비었으면 이력을 그대로 둔 채 "최종 답변을 쓰라"고 한 번만 더 요청한다
recursion_limit: 6 이 마무리 요청에도 상한을 건다

빈 답변은 드물어서 여러분의 실행에서는 한 번도 나오지 않을 수 있습니다. 그래도 이 함수는 남겨 둡니다. 드물게 일어나는 상황에도 대비해야 하기 때문입니다.


5. 모델에게 맡기는 것과 코드가 하는 것

이 과정에서 여러 번 나온 구분을 Supervisor 층에서 다시 정리합니다.

모델(AI)에게 맡기는 것 코드(개발자)가 보증하는 것
누구에게 위임할지 고르기 전문 에이전트 실행
전문 에이전트에게 넘길 요청문 쓰기 처리가 실제로 기록됐는지 확인
여러 보고를 한 답으로 묶기 처리번호·티켓번호·상태를 파일에서 읽어 넣기
고객에게 보낼 문장 쓰기 아무 도구도 불리지 않았을 때 상담원에게 접수하기
답이 비었을 때 다시 요청하기, 실행 단계 상한

기준은 하나입니다. 틀려도 다시 물으면 되는 일은 모델에게, 틀리면 되돌릴 수 없는 값은 코드에게.


핵심 정리

  • 답은 여러 LLM의 손을 거쳐 나옵니다. 단계마다 값과 사실이 바뀔 수 있습니다.
  • ask_action_agent는 처리 기록과 티켓 기록을 실행 전후로 비교해, 새로 생긴 것만 [시스템 확인] 줄로 붙입니다.
  • 새로 생긴 것이 없으면 없다고 붙입니다. 처리하지 못한 요청이 처리된 것으로 전해지지 않습니다.
  • 에이전트가 아무 도구도 부르지 않으면 코드가 상담원에게 접수합니다(폴백).
  • 번호는 모델의 문장이 아니라 코드가 파일에서 읽어 붙인 줄에서 옵니다.
  • finalize_if_empty는 빈 답변이 나왔을 때 한 번 더 마무리를 요청합니다.
  • 틀리면 안 되는 값은 코드가 보증하고, 모델은 그것을 문장으로 옮기는 일만 합니다.
← 이전 절무한 위임을 막는 두 겹 — 구조와 상한다음 절 →Supervisor 프롬프트의 운영 원칙 — 열두 줄에 담긴 것
오명운 · macro@prag-ai.com