실무 Multi-Agent 오케스트레이션 21장 · 전문 에이전트 구현 3 / 9 ← 이전목차다음 → TechLead Cro

21장. 전문 에이전트 구현

읽는 에이전트와 처리하는 에이전트 — 위험한 도구를 쥔 쪽을 하나로 좁힌다

한 줄 요약

세 에이전트 가운데 둘은 읽기만 합니다. 주문조회 에이전트는 데이터를 읽고, 정책안내 에이전트는 문서를 읽습니다. 실제로 무언가를 바꾸는 도구는 요청처리 에이전트 하나에게만 줍니다. 셋 모두 약속은 같습니다. 글로 된 요청을 받아 글로 된 보고를 돌려주고, 맡지 않은 일에는 답을 지어내지 않습니다.


1. 읽는 도구와 바꾸는 도구

8장에서 도구를 읽기 도구와 쓰기 도구로 나눴습니다. 읽기 도구는 몇 번을 불러도 세상이 그대로입니다. 쓰기 도구는 부르는 순간 기록이 남고 일이 벌어집니다.

읽는 에이전트 (주문조회 · 정책안내) 처리하는 에이전트 (요청처리)
도구가 하는 일 조회, 검색 배송지 변경, 교환 접수, 승인 요청 등록, 상담원 접수
잘못 불렀을 때 답이 틀린다. 다시 물으면 된다 일이 실제로 일어난다. 되돌려야 한다
같은 요청을 두 번 보내면 같은 답이 두 번 나온다 처리가 두 번 될 수 있다
확인할 곳 답변과 원본 데이터 기록 파일 (memory_store/actions.json)

이 차이 때문에 처리하는 쪽에는 읽는 쪽에 없는 장치가 필요합니다. 가능한지 따지는 판정, 처리 기록, 사람의 승인입니다. 이 장치들은 다음 절에서 봅니다.


2. 위험한 도구를 쥔 쪽을 하나로

haru_agents.py에서 세 에이전트가 받는 도구 목록입니다.

order_agent = create_agent(
    model=_llm(),
    tools=[tool(raw["get_my_orders"]), tool(raw["get_order_status"]),
           tool(raw["get_my_membership"]),
           tool(raw["search_products"]), tool(raw["check_stock"])],
    ...
policy_agent = create_agent(
    model=_llm(),
    tools=[search_policy],
    ...
action_agent = create_agent(
    model=_llm(),
    tools=[tool(act["change_shipping_address"]), tool(act["request_exchange"]),
           tool(act["request_refund"]), tool(act["get_my_requests"]),
           tool(raw["get_my_orders"]), tool(raw["create_ticket"])],
    ...
에이전트 도구 수 무언가를 바꾸는 도구
주문조회 5 없음
정책안내 1 없음
요청처리 6 change_shipping_address, request_exchange, request_refund, create_ticket

주문조회 에이전트가 어떤 질문을 받든, 프롬프트를 바꾸려는 말이 섞여 들어오든, 배송지를 바꿀 방법이 없습니다. 프롬프트에 "배송지를 바꾸지 마라"고 적어서가 아니라 그 도구를 주지 않았기 때문입니다. 5장에서 본 대로 모델은 목록에 있는 도구만 부를 수 있습니다.

조심해야 할 도구는 가진 쪽이 적을수록 지켜보기 쉽습니다. 처리 기록에 이상한 줄이 생겼다면 볼 곳은 요청처리 에이전트 하나입니다.


3. 세 에이전트의 공통 약속

읽든 처리하든, 세 에이전트를 쓰는 방법은 같습니다.

약속
받는 것 자연어 요청 한 건
돌려주는 것 자연어 보고 한 건
안에서 하는 일 밖에서 보이지 않는다. 도구를 몇 번 부르든 상관없다

함수로 말하면 받는 값과 돌려주는 값의 모양이 셋 다 같다는 뜻입니다. 이렇게 맞춰 두면 에이전트를 쓰는 쪽은 상대가 도구 다섯 개를 가졌는지 하나를 가졌는지 알 필요가 없습니다. 이렇게 안쪽의 복잡함을 밖에 드러내지 않는 것을 흔히 캡슐화(encapsulation) 라고 합니다.

이번 장의 코드에서 이 약속은 함수 하나로 드러납니다.

def run_agent(agent, question: str, verbose: bool = True) -> str:
    """에이전트 실행 + 도구 사용 추적. 마지막 AI 메시지를 반환한다."""
    result = agent.invoke({"messages": [{"role": "user", "content": question}]})
    if verbose:
        for msg in result["messages"]:
            if msg.__class__.__name__ == "AIMessage" and getattr(msg, "tool_calls", None):
                for tc in msg.tool_calls:
                    print(f"    [도구] {tc['name']}({tc['args']})")
    return extract_text(result["messages"][-1])

문자열이 들어가고 문자열이 나옵니다. 어느 에이전트를 넣어도 쓰는 방법이 같습니다. verbose=True이면 안에서 부른 도구를 화면에 찍어 주지만, 돌려주는 값은 마지막 보고 하나뿐입니다.

보고를 읽는 것은 고객이 아니라 이 에이전트에게 일을 맡긴 쪽입니다. 그래서 프롬프트의 낱말이 "답변합니다"가 아니라 "보고합니다"입니다. 공감 표현이나 인사말보다 주문번호, 금액, 조항, 처리번호 같은 사실이 빠짐없이 들어가는 것이 중요합니다.


4. 하지 않는 일 — 경계

주문조회 에이전트가 "반품 배송비 얼마예요?"라는 질문을 받으면 어떻게 해야 할까요.

이 에이전트에게는 정책 문서를 검색하는 도구가 없습니다. 그래도 모델은 "보통 3,000원 정도입니다" 같은 답을 쓸 수 있습니다. 3장에서 본 대로, 모델은 근거가 없어도 그럴듯한 금액을 만들어 냅니다. 그래서 프롬프트에 이 줄이 있습니다.

- 정책(환불 규정·멤버십 혜택 기준 등) 질문에는 답하지 않고
  '정책 담당 확인 필요'라고 보고합니다.

이런 규칙을 경계 규칙이라고 부르겠습니다. 자기 책임 밖의 요청에 답을 지어내지 않고 물러서게 하는 규칙입니다.

에이전트 경계 규칙
주문조회 정책 질문에는 답하지 않고 "정책 담당 확인 필요"라고 보고한다
정책안내 검색 결과에 없는 내용은 "정책 문서에서 확인되지 않음"이라고 보고한다
요청처리 도구가 처리하지 못했다고 돌려주면 처리했다고 말하지 않는다. 도구로 처리할 수 없는 일은 상담원에게 접수한다

"그건 제 일이 아닙니다"는 불친절이 아니라 안전장치입니다. 일을 나눈 시스템에서 가장 위험한 것은 모르는 일을 아는 척 처리하는 담당입니다.

물러선 보고는 쓸모없는 보고가 아닙니다. "정책 담당 확인 필요"라는 말은 일을 맡긴 쪽에게 "이 질문은 다른 에이전트에게 보내라" 는 신호가 됩니다.


5. 요청처리 에이전트의 프롬프트

처리하는 에이전트의 프롬프트는 읽는 에이전트와 결이 다릅니다.

- 전달받은 요청을 도구로 처리합니다. 필요한 정보가 부족하면 확인합니다.
- 배송지 변경은 change_shipping_address, 교환은 request_exchange,
  환불·반품·주문 취소는 request_refund 를 호출합니다.
- 반품·교환 사유는 문장 전체의 의미로 분류해 reason_category에 전달합니다.
  단순변심, 상품하자, 오배송, 확인필요 중 하나이며 부정 표현도 반영합니다.
  사유가 불명확하면 확인필요를 전달하고, 도구가 needs_clarification=True를
  반환하면 고객에게 사유를 확인해야 한다고 보고합니다.
- 주문번호가 전달되지 않았으면 get_my_orders 로 본인 주문에서 해당 주문을
  찾아 그 주문번호로 처리합니다.
- 도구가 ok=False 를 돌려주면 처리하지 않은 것입니다. 그 reason 을 그대로
  보고하고, 처리했다고 말하지 않습니다.
- request_refund 는 '승인 요청'만 만듭니다. 환불이 완료됐다고 말하지 않고
  '승인 요청을 등록했고 담당자 승인 후 처리된다'고 보고합니다.
줄 왜 넣었나
필요한 정보가 부족하면 확인한다 사유를 추측해 접수하지 않습니다. 확인이 필요하다는 보고를 받은 Supervisor가 고객에게 질문합니다
요청과 도구를 짝지어 준다 "반품"과 "주문 취소"가 같은 도구(request_refund)라는 것은 이름만 봐서는 알기 어렵습니다
주문번호가 없으면 직접 찾는다 되묻지 않으려면 스스로 찾을 수단이 있어야 합니다. 그래서 get_my_orders를 줬습니다
ok=False면 처리하지 않은 것 도구가 "할 수 없다"고 돌려준 결과를 "처리했습니다"로 옮기지 않게 합니다
환불은 승인 요청만 승인 요청을 올린 것과 환불이 끝난 것은 다른 일입니다

"되묻지 않는다"를 걱정할 필요는 없습니다. 조건이 맞지 않는 요청은 모델이 아니라 도구 안의 코드가 걸러 냅니다. 에이전트가 망설이지 않고 도구를 불러도 되는 이유가 거기에 있습니다.


6. 세션에 묶인 에이전트

세 에이전트는 함수 하나가 한꺼번에 만들어 돌려줍니다.

def build_agents(session: CustomerSession):
    """고객 세션에 묶인 전문 에이전트 3종을 생성한다."""
    raw = make_tools(session)
    act = make_action_tools(session)
    ...
    return {"order": order_agent, "policy": policy_agent, "action": action_agent}

8장의 make_tools(session)과 같은 방식입니다. 로그인한 고객이 누구인지(session)를 받아, 그 고객의 데이터만 보고 그 고객의 주문만 처리할 수 있는 도구로 에이전트를 만듭니다. 처리 도구도 같은 세션에 묶입니다. 남의 주문번호로 배송지를 바꿔 달라는 요청이 들어와도, 도구는 없는 주문번호와 똑같이 "해당 주문을 찾을 수 없습니다"를 돌려줍니다.

돌려주는 것은 딕셔너리입니다. agents["order"], agents["policy"], agents["action"]으로 꺼내 씁니다.


핵심 정리

  • 주문조회·정책안내 에이전트는 읽기만 하고, 요청처리 에이전트만 무언가를 바꿉니다.
  • 읽기 도구를 잘못 부르면 답이 틀리고, 처리 도구를 잘못 부르면 일이 실제로 일어납니다.
  • 처리 도구를 한 에이전트에게만 줍니다. 나머지 둘은 도구가 없어서 아무것도 바꿀 수 없습니다.
  • 세 에이전트의 약속은 같습니다. 자연어 요청을 받아 자연어 보고를 돌려줍니다(캡슐화).
  • 경계 규칙은 자기 일이 아닌 질문에 답을 지어내지 않고 물러서게 합니다.
  • 요청처리 에이전트는 필요한 정보가 있으면 처리하고, 사유가 불명확하면 확인하며, 도구가 못 했다고 하면 못 했다고 보고합니다.
  • build_agents(session)은 고객 세션에 묶인 에이전트 셋을 {"order", "policy", "action"} 딕셔너리로 돌려줍니다.
← 이전 절에이전트를 나누는 기준 — 도구, 프롬프트, 책임다음 절 →처리를 위험도로 나눈다 — 직접 처리, 승인 요청, 상담원에게
오명운 · macro@prag-ai.com