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"}딕셔너리로 돌려줍니다.