실무 Multi-Agent 오케스트레이션 24장 · 서비스 통합과 실행 9 / 9 ← 이전목차다음 → TechLead Cro

24장. 서비스 통합과 실행

실습문제와 해답

서버에 직접 요청을 보내 보며 24장 내용을 손에 익힙니다. 먼저 스스로 해 본 뒤 해답을 펼쳐 보세요. 해답은 새 파일로 만들어 실행합니다.

세 문제 모두 서버가 켜져 있어야 합니다. 터미널을 두 개 씁니다.

터미널 하는 일
첫 번째 python -m uvicorn app.main:app --reload --port 8000 으로 서버를 띄워 둔다
두 번째 (+로 새로 연다) conda activate myenv 후 아래 파일들을 실행한다

해답 파일은 app 폴더가 아니라 haru-market 폴더 맨 위에 만듭니다.

아래 실행 결과는 처리 기록(memory_store/actions.json, tickets.json)을 지우고 서버를 새로 켠 뒤 문제 1, 2, 3을 차례로 돌린 것입니다. 기록이 남아 있으면 처리번호가 그 뒤로 이어집니다.


문제 1 — 화면 없이 이벤트를 받아 보는 파이썬 파일

채팅 화면이 하는 일을 파이썬으로 해 보세요. 새 파일 ex24_client.py에 함수 ask(session_id, message)를 만듭니다.

  • http://localhost:8000/api/chat에 session_id와 message를 JSON으로 보낸다(POST).
  • 흘러오는 줄 가운데 data:로 시작하는 줄을 JSON으로 읽어, 이벤트 종류에 따라 [위임], [답변], [끝]으로 출력한다.
  • 최종 답변 글을 돌려준다.
  • 힌트: 파이썬에 기본으로 들어 있는 urllib.request를 씁니다. 따로 설치할 것이 없습니다.
  • 파일을 직접 실행하면 "제 최근 주문 지금 어디까지 왔어요?"(표준 질문 Q1)를 보내고, 뒤에 글을 적어 실행하면 그 글을 보내게 합니다.
해답 보기
import json
import sys
import urllib.request

URL = "http://localhost:8000/api/chat"


def ask(session_id: str, message: str) -> str:
    """문의 하나를 보내고, 흘러오는 이벤트를 차례로 출력한다. 최종 답변을 돌려준다."""
    body = json.dumps({"session_id": session_id, "message": message}).encode("utf-8")
    req = urllib.request.Request(URL, data=body,
                                 headers={"Content-Type": "application/json"})
    answer = ""
    with urllib.request.urlopen(req) as res:
        for raw in res:                               # 한 줄씩 도착하는 대로 읽는다
            line = raw.decode("utf-8").strip()
            if not line.startswith("data:"):
                continue
            ev = json.loads(line[5:])
            if ev["type"] == "delegate":
                print(f"  [위임] {ev['agent']} ← {ev['request']}")
            elif ev["type"] == "answer":
                answer = ev["text"]
                print(f"  [답변] {answer}")
            elif ev["type"] == "done":
                print("  [끝]")
    return answer


if __name__ == "__main__":
    message = sys.argv[1] if len(sys.argv) > 1 else "제 최근 주문 지금 어디까지 왔어요?"
    print(f"고객: {message}")
    ask("cli-001", message)
$ python ex24_client.py

실행 결과 (문장은 실행마다 다릅니다)

고객: 제 최근 주문 지금 어디까지 왔어요?
  [위임] 주문조회 에이전트 ← 고객의 최근 주문과 배송 상태
  [답변] 가장 최근에 주문하신 '콜드브루 원액 500ml 2병'은 현재 '결제완료' 단계입니다. 배송이나 다른 주문 건에 대해 추가로 궁금한 점이 있으신가요?
  [끝]

해설 — 이 파일은 index.html의 자바스크립트가 하는 일을 파이썬으로 옮긴 것입니다. 나란히 놓으면 같은 약속을 따르고 있습니다.

하는 일 화면 (index.html) 이 파일
보낸다 fetch("/api/chat", {method: "POST", body: …}) urllib.request.Request(URL, data=body, …)
이벤트를 가른다 빈 줄(\n\n)로 자른다 한 줄씩 읽어 data:로 시작하는 줄만 본다
읽는다 JSON.parse(line.slice(5)) json.loads(line[5:])
그린다 점선 상자를 한 줄 쌓고 말풍선을 붙인다 print

서버는 상대가 브라우저인지 파이썬인지 모릅니다. 약속된 주소로 약속된 모양의 요청이 오면 같은 답을 합니다. 그래서 같은 서버에 앱 화면을 붙일 수도, 자동 검수 프로그램을 붙일 수도 있습니다. 따라하기의 열두 질문 검수도 이런 파일로 돌린 것입니다.

화면에서는 보이지 않던 요청문("고객의 최근 주문과 배송 상태")이 여기서는 보입니다. 이벤트에는 들어 있었고, 화면이 그리지 않았을 뿐입니다.


문제 2 — 대화가 섞이지 않는지 확인하기

session_id가 다르면 대화가 섞이지 않는다는 것을 확인하는 파일 ex24_sessions.py를 만드세요. 문제 1의 ask 함수를 가져다 씁니다.

  1. 세션 tab-A로 "쿠션 운동화 블랙 270 재고 있어요?"를 보낸다.
  2. 같은 tab-A로 "그거 가격은 얼마예요?"를 보낸다.
  3. 세션 tab-B로 앞 대화 없이 "그거 가격은 얼마예요?"를 보낸다.
  • 실행하기 전에 짐작해 보세요. 3번에서 서버는 무엇이라고 답할까요?
해답 보기
from ex24_client import ask

print("■ 세션 A — 첫 번째 턴")
ask("tab-A", "쿠션 운동화 블랙 270 재고 있어요?")

print("\n■ 세션 A — 두 번째 턴 ('그거')")
ask("tab-A", "그거 가격은 얼마예요?")

print("\n■ 세션 B — 앞 대화 없이 같은 질문")
ask("tab-B", "그거 가격은 얼마예요?")
$ python ex24_sessions.py

실행 결과 (문장은 실행마다 다릅니다)

■ 세션 A — 첫 번째 턴
  [위임] 주문조회 에이전트 ← 쿠션 운동화 블랙 270 재고 확인
  [답변] 쿠션 운동화 블랙 270 사이즈의 상품 전체 기준 재고는 70개입니다. 주문을 도와드릴까요?
  [끝]

■ 세션 A — 두 번째 턴 ('그거')
  [위임] 주문조회 에이전트 ← 쿠션 운동화 가격 확인
  [답변] 쿠션 운동화의 가격은 49,800원입니다. 이 상품의 주문을 원하시면 말씀해 주세요.
  [끝]

■ 세션 B — 앞 대화 없이 같은 질문
  [답변] 이전 대화에서 어떤 상품을 말씀하시는지 확인되지 않아 가격을 안내해 드리지 못했습니다. 보시던 상품의 이름이나 종류를 알려 주시면 바로 확인해 드리겠습니다.
  [끝]

해설 — 같은 문장 "그거 가격은 얼마예요?"에 답이 갈렸습니다.

세션 이력 "그거"를 위임
tab-A 앞 턴에 쿠션 운동화 블랙 270이 있다 "쿠션 운동화" 로 풀어 넘겼다 주문조회
tab-B 비어 있다 풀지 못하고 되물었다 없음

세션 A의 두 번째 위임 요청문을 보세요. 고객은 "그거"라고 했는데 요청문에는 "쿠션 운동화 가격 확인"이 적혀 있습니다. 이력을 꺼내 붙인 것은 서버 코드(histories.get)이고, 그 이력에서 "그거"를 읽어 낸 것은 모델입니다.

세션 B는 위임 없이 되물었습니다. 이력이 비어 있으니 "그거"가 가리키는 것이 없고, 모르는 것을 짐작으로 채우지 않았습니다. 두 세션의 대화가 섞이지 않았다는 확인입니다. 가격 49,800원과 재고 70은 data/products.csv의 값과 같고, 재고는 "상품 전체 기준"이라고 밝혔습니다.

이 이력은 서버의 메모리에 있습니다. 파일 실행이 끝나도 서버가 켜져 있는 동안은 tab-A의 대화가 남아 있고, 서버를 Ctrl + C로 껐다 켜면 tab-A도 tab-B처럼 빈 이력에서 시작합니다. 서버를 다시 켠 뒤 ask("tab-A", "그거 가격은 얼마예요?")만 보내서 직접 확인해 보세요.


문제 3 — 승인 흐름을 코드로 확인하고, 이번에는 반려하기

따라하기에서는 상담원 화면의 승인 버튼을 눌렀습니다. 이번에는 같은 흐름을 코드로 돌리되 반려해 봅니다. 새 파일 ex24_approval.py를 만드세요. 문제 1의 ask 함수를 가져다 씁니다.

  1. http://localhost:8000/api/approvals에서 승인 대기 목록을 받아 건수를 출력한다(GET).
  2. "무드등 주문 취소해 주세요."(표준 질문 Q10 ①)를 보낸다.
  3. 승인 대기 목록을 다시 받아 마지막 요청의 처리번호·종류·주문번호·상태를 출력하고, 답변 글에 나온 처리번호와 같은지 확인한다.
  4. 그 처리번호로 POST /api/approvals/{처리번호}에 {"approve": false}를 보내 반려한다.
  5. 같은 세션으로 "취소 요청한 거 어떻게 됐어요?"를 보내고, 마지막으로 /api/actions의 처리 기록을 출력한다.
  • 힌트: GET 요청은 urllib.request.urlopen(주소)만으로 됩니다. POST는 문제 1의 ask처럼 urllib.request.Request(주소, data=…, headers=…)를 만듭니다. 답변에서 번호를 찾을 때는 re.findall(r"A\d{5}", answer)를 씁니다.
  • 이 문제를 시작하기 전에 무드등 주문의 취소 요청이 승인 대기에 남아 있지 않아야 합니다. 남아 있으면 상담원 화면에서 먼저 승인하거나 반려해 두세요.
  • 실행하기 전에 짐작해 보세요. 반려한 뒤 고객이 결과를 물으면 에이전트는 무엇이라고 답할까요?
해답 보기
import json
import re
import urllib.request

from ex24_client import ask

BASE = "http://localhost:8000"


def get(path: str) -> list:
    """상담원 화면이 쓰는 주소에서 목록을 받아 온다 (GET)."""
    with urllib.request.urlopen(BASE + path) as res:
        return json.loads(res.read().decode("utf-8"))


def decide(action_id: str, approve: bool) -> dict:
    """상담원 화면의 승인·반려 버튼이 하는 일 (POST)."""
    body = json.dumps({"approve": approve}).encode("utf-8")
    req = urllib.request.Request(f"{BASE}/api/approvals/{action_id}", data=body,
                                 headers={"Content-Type": "application/json"})
    with urllib.request.urlopen(req) as res:
        return json.loads(res.read().decode("utf-8"))


print(f"보내기 전 승인 대기: {len(get('/api/approvals'))}건")

print("\n고객: 무드등 주문 취소해 주세요.")
answer = ask("approval-check", "무드등 주문 취소해 주세요.")

pending = get("/api/approvals")
print(f"\n보낸 뒤 승인 대기: {len(pending)}건")
last = pending[-1]
for key in ("action_id", "type", "order_id", "status"):
    print(f"  {key}: {last[key]}")

in_answer = re.findall(r"A\d{5}", answer)          # 답변 글에서 처리번호를 찾는다
print(f"답변에 나온 번호: {in_answer}")
print(f"기록의 번호와 같은가: {last['action_id'] in in_answer}")

result = decide(last["action_id"], approve=False)  # 상담원이 반려한다
print(f"\n상담원 반려 → {result['action_id']} 상태: {result['status']}")
print(f"반려 뒤 승인 대기: {len(get('/api/approvals'))}건")

print("\n고객: 취소 요청한 거 어떻게 됐어요?")
ask("approval-check", "취소 요청한 거 어떻게 됐어요?")

print("\n처리 기록:")
for a in get("/api/actions"):
    print(f"  {a['action_id']} · {a['type']} · {a['status']}")
$ python ex24_approval.py

실행 결과 (문장은 실행마다 다릅니다. 처리번호는 지금까지 쌓인 기록에 따라 다릅니다)

보내기 전 승인 대기: 0건

고객: 무드등 주문 취소해 주세요.
  [위임] 주문조회 에이전트 ← 고객의 최근 주문 중 무드등 주문 내역 확인
  [위임] 요청처리 에이전트 ← 주문 HR20260720022 LED 무드등 우드/단일 주문 취소 승인 요청
  [답변] 주문하신 'LED 무드등(우드/단일)'의 주문 취소 승인 요청이 접수되었습니다.
처리번호 A00001로 담당자 승인 후 처리될 예정입니다.
  [끝]

보낸 뒤 승인 대기: 1건
  action_id: A00001
  type: 주문취소요청
  order_id: HR20260720022
  status: 승인대기
답변에 나온 번호: ['A00001']
기록의 번호와 같은가: True

상담원 반려 → A00001 상태: 반려
반려 뒤 승인 대기: 0건

고객: 취소 요청한 거 어떻게 됐어요?
  [위임] 요청처리 에이전트 ← 처리번호 A00001의 처리 내역 확인
  [답변] 요청하신 'LED 무드등(우드/단일)' 주문 취소 건은 담당자 확인 결과 반려되었습니다.
상담원 연결이나 추가 도움이 필요하시면 언제든 말씀해 주세요.
  [끝]

처리 기록:
  A00001 · 주문취소요청 · 반려

해설 — 문의 하나가 고객 쪽과 상담원 쪽 양쪽에 남긴 것을 맞춰 봤습니다.

확인한 것 결과 누가 한 일인가
승인 요청이 실제로 등록됐다 0건 → 1건, 승인대기 요청처리 에이전트가 request_refund를 골랐고, 코드가 등록했다 (21장)
답변의 번호 = 기록의 번호 A00001로 같다 번호는 코드가 기록 파일에서 읽어 [시스템 확인]으로 붙였다 (22장)
반려가 기록됐다 반려, 승인 대기 1건 → 0건 이 파일의 decide 함수가 보낸 요청을 받아, 서버가 haru_actions.decide() 로 기록했다 (23장과 같은 함수)
고객이 들은 결과 "반려되었습니다" 요청처리 에이전트가 처리 내역을 읽어 전했다. 새로 처리한 것은 없다

이 파일의 decide 함수는 상담원 화면의 버튼이 하는 일을 파이썬으로 옮긴 것입니다. admin.html의 자바스크립트도 같은 주소에 같은 모양({"approve": true 또는 false})을 보냅니다. 서버는 상대가 버튼인지 파이썬인지 모릅니다.

승인과 반려는 같은 주소, 같은 함수이고 approve 값 하나만 다릅니다. 23장 실습문제에서 Command(resume="reject")로 반려했을 때와 기록에 남는 모양이 같습니다. 반려된 뒤에도 에이전트는 "취소됐다"고 말하지 않았고, 기록에 적힌 상태를 그대로 전했습니다.

이 파일은 검수를 코드로 옮긴 것이기도 합니다. 따라하기에서는 답변의 번호와 상담원 화면의 번호를 눈으로 맞췄습니다. 여기서는 기록의 번호와 같은가: True 한 줄이 그 일을 대신합니다. 표준 질문 열두 개의 검증 포인트도 이런 식으로 하나씩 코드로 옮길 수 있고, 그렇게 모은 것이 프롬프트를 고칠 때마다 돌리는 자동 검수가 됩니다.

이 교재를 준비하며 세 문제를 세 번씩 실행했습니다. 문장은 매번 달랐고, 위임한 에이전트와 처리번호, 기록의 상태는 세 번 모두 위와 같았습니다.


핵심 정리

  • 서버는 상대가 브라우저인지 파이썬인지 모릅니다. 약속된 주소와 모양이면 같은 답을 합니다.
  • session_id가 같으면 대화가 이어지고, 다르면 섞이지 않습니다.
  • 이력을 꺼내 붙이는 것은 서버 코드, 그 이력에서 "그거"를 읽는 것은 모델입니다.
  • 이력은 서버의 메모리에 있습니다. 서버를 껐다 켜면 사라집니다.
  • 답변의 처리번호는 상담원 쪽 주소의 번호와 같습니다. 눈으로 하던 확인은 코드로 옮길 수 있습니다.
  • 승인과 반려는 같은 주소, 같은 decide() 이고 approve 값만 다릅니다. 화면의 버튼이 하는 일을 코드로도 할 수 있습니다.
← 이전 절정리와 체크리스트  
오명운 · macro@prag-ai.com