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

24장. 서비스 통합과 실행

따라하기 — 서버 만들고 채팅 화면과 상담원 화면 열기

수업 모델: 모든 장은 config.py의 MODEL을 사용합니다. .env의 GEMINI_MODEL=gemini-3.8-flash를 확인하세요.

목표

app/main.py 파일 하나를 만들어 서버를 띄우고, 브라우저 탭 두 개에 채팅 화면과 상담원 화면을 엽니다. 채팅으로 주문 취소를 요청하고, 상담원 화면에서 승인하고, 다시 채팅으로 결과를 묻는 흐름을 끝까지 따라가며 23장의 승인 게이트가 웹에서 어떻게 움직이는지 확인합니다. 같은 요청을 터미널에서 직접 보내 서버가 흘려보내는 이벤트도 눈으로 봅니다.


0. 실습 준비

이 장의 실습 고객 — 회원가입·로그인은 만들지 않습니다. 서버가 C003 김도윤 고객(VIP 등급) 한 명으로 로그인된 상태라고 정해 두고, 채팅 화면의 모든 문의를 이 고객의 것으로 처리합니다.

VS Code에서 haru-market 폴더를 열고 터미널에서 환경을 켭니다.

$ conda activate myenv

새로 설치할 것은 없습니다. FastAPI와 Uvicorn은 2장에서 requirements.txt로 함께 설치했습니다. 폴더에 다음이 있는지 확인합니다.

있어야 하는 것 만든 곳 없으면
haru_tools.py 8장 서버가 시작하다 ModuleNotFoundError로 멈춘다
haru_actions.py 21장 위와 같다
haru_agents.py 21장 위와 같다
haru_supervisor.py 22장 위와 같다
chroma_db/ 폴더 13장을 실행하면 생김 서버가 시작하다 [준비 필요] chroma_db/ 폴더가 없습니다. … 를 내고 멈춘다
app/frontend/index.html 처음부터 제공됨 채팅 화면이 열리지 않는다
app/frontend/admin.html 처음부터 제공됨 상담원 화면이 열리지 않는다

처리 기록을 비우고 시작한다

21~23장을 실습하며 memory_store 폴더에 처리 기록(actions.json)과 티켓(tickets.json)이 쌓여 있습니다. 상담원 화면을 빈 상태에서 보기 위해, VS Code 탐색기에서 두 파일을 지우고 시작합니다(파일을 마우스 오른쪽 버튼으로 눌러 삭제). 두 파일은 첫 기록이 생길 때 다시 만들어집니다.


1. 파일 만들기 — 이번에는 app 폴더 안에

이 파일만 위치가 다릅니다. 지금까지의 파일은 모두 haru-market 폴더 맨 위에 만들었습니다. main.py는 app 폴더 안에 만듭니다.

VS Code 왼쪽 탐색기에서 app 폴더를 마우스 오른쪽 버튼으로 눌러 새 파일(New File) 을 고르고, 이름을 입력합니다.

main.py

만든 뒤 탐색기가 이렇게 보여야 합니다. main.py가 frontend 폴더와 나란히 있습니다.

haru-market/
├── app/
│   ├── frontend/
│   │   ├── admin.html
│   │   └── index.html
│   └── main.py          ← 여기
├── config.py
└── (나머지 파일들)
이렇게 되어 있으면 고치는 법
main.py가 config.py 옆(맨 위)에 있다 탐색기에서 끌어다 app 폴더에 넣는다
main.py가 frontend 폴더 안에 있다 한 칸 위 app 폴더로 끌어 옮긴다

아래 코드 전체를 복사해 app/main.py에 붙여 넣고 저장합니다.

# -*- coding: utf-8 -*-
"""[24장] 하루마켓 고객지원 멀티에이전트 — FastAPI 백엔드

지금까지 만든 것을 전부 조립해 HTTP API 로 서빙한다.
  - Supervisor(22장) + 전문 에이전트 3종(21장) + 도구 계층(8장)
  - 세션별 대화 이력(16장)
  - SSE 스트리밍: 위임 과정 → 최종 답변을 실시간 전송

실행:
  cd <프로젝트 루트>
  uvicorn app.main:app --reload --port 8000
브라우저:
  http://localhost:8000        ← 채팅 화면 (app/frontend/index.html)
"""
import sys
from pathlib import Path

ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))

import json
import logging

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, StreamingResponse
from pydantic import BaseModel

from haru_actions import decide, load_actions, pending_approvals
from haru_agents import extract_text
from haru_supervisor import build_supervisor, finalize_if_empty
from haru_tools import CUSTOMERS, CustomerSession, mask_name

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s")
log = logging.getLogger("haru")

app = FastAPI(title="하루마켓 고객지원 멀티에이전트")

# CORS: 로컬 개발 편의 (프론트를 다른 포트에서 열어도 동작)
app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"], allow_methods=["*"], allow_headers=["*"],
)

# ── 세션 저장소 (수업용 인메모리 — 서버 재시작 시 초기화) ─────────────
# 데모 로그인 고객: C003
DEMO_CUSTOMER = "C003"
session = CustomerSession(DEMO_CUSTOMER)
supervisor = build_supervisor(session, verbose=False)
histories: dict[str, list] = {}          # session_id → messages


class ChatRequest(BaseModel):
    session_id: str
    message: str


class Decision(BaseModel):
    approve: bool


@app.get("/")
def index():
    return FileResponse(ROOT / "app" / "frontend" / "index.html")


@app.get("/api/health")
def health():
    return {"ok": True, "customer": session.customer_id}


@app.post("/api/chat")
def chat(req: ChatRequest):
    """SSE 스트리밍 응답.

    이벤트 종류
      delegate : Supervisor 가 전문 에이전트에 위임했다 (화면에 진행 표시)
      answer   : 최종 답변
      done     : 스트림 종료
    """
    log.info("[%s] 문의: %s", req.session_id, req.message)
    history = histories.get(req.session_id, [])

    def event(data: dict) -> str:
        return f"data: {json.dumps(data, ensure_ascii=False)}\n\n"

    def generate():
        messages = list(history) + [{"role": "user", "content": req.message}]
        final_text = ""
        seen = len(history)          # 이전 턴의 메시지는 건너뛴다 (이번 턴 것만 이벤트로)
        agent_names = {"ask_order_agent": "주문조회 에이전트",
                       "ask_policy_agent": "정책안내 에이전트",
                       "ask_action_agent": "요청처리 에이전트"}
        try:
            # stream_mode="values": 단계마다 전체 상태를 받아 새 메시지만 처리
            for chunk in supervisor.stream(
                {"messages": messages},
                {"recursion_limit": 12},
                stream_mode="values",
            ):
                msgs = chunk["messages"]
                for msg in msgs[seen:]:
                    kind = msg.__class__.__name__
                    if kind == "AIMessage" and getattr(msg, "tool_calls", None):
                        for tc in msg.tool_calls:
                            name = agent_names.get(tc["name"], tc["name"])
                            req_text = str(tc["args"].get("request")
                                           or tc["args"].get("situation") or "")
                            yield event({"type": "delegate", "agent": name,
                                         "request": req_text})
                    elif kind == "AIMessage" and msg.content:
                        final_text = extract_text(msg)
                seen = len(msgs)
                last_state = chunk

            new_messages = list(last_state["messages"])
            if not final_text.strip():          # 빈 응답 방어 (경량 모델 간헐 이슈)
                final_text, new_messages = finalize_if_empty(supervisor, new_messages)
            yield event({"type": "answer", "text": final_text})
            # 이력 갱신 (16장: 이력이 곧 기억)
            histories[req.session_id] = new_messages
        except Exception as e:                      # 서버는 죽지 않는다
            log.exception("chat error")
            yield event({"type": "answer",
                         "text": "일시적인 오류가 발생했어요. 잠시 후 다시 시도해 주세요."})
        yield event({"type": "done"})

    return StreamingResponse(generate(), media_type="text/event-stream",
                             headers={"Cache-Control": "no-cache"})


# ── 상담원 화면: 사람이 승인하고, 넘겨받은 문의를 본다 (23장의 승인 게이트) ──
@app.get("/admin")
def admin():
    return FileResponse(ROOT / "app" / "frontend" / "admin.html")


def with_customer_names(records):
    """상담원 화면에서 고객 ID와 마스킹된 이름을 함께 확인한다."""
    names = dict(zip(CUSTOMERS["customer_id"], CUSTOMERS["name"]))
    return [
        {**record, "customer_name": mask_name(names[record["customer_id"]])
         if record.get("customer_id") in names else "고객 정보 없음"}
        for record in records
    ]


@app.get("/api/actions")
def actions():
    """에이전트가 처리한 내역 전체 (배송지 변경, 교환 접수, 환불·취소 요청)."""
    return with_customer_names(load_actions())


@app.get("/api/approvals")
def approvals():
    """승인을 기다리는 환불·주문 취소 요청."""
    return with_customer_names(pending_approvals())


@app.post("/api/approvals/{action_id}")
def approve(action_id: str, body: Decision):
    """상담원이 승인(approve=true) 또는 반려(approve=false)한다."""
    result = decide(action_id, approve=body.approve, by="상담원")
    log.info("[승인] %s → %s", action_id, result.get("status", result))
    return result


@app.get("/api/tickets")
def tickets():
    """상담원용: 에이전트가 처리하지 못해 넘겨받은 문의 목록."""
    path = ROOT / "memory_store" / "tickets.json"
    if not path.exists():
        return []
    return json.loads(path.read_text(encoding="utf-8"))

2. 코드에서 볼 곳

(1) 맨 위 두 줄 — app 폴더 안에서 맨 위의 파일을 찾는다

ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))

main.py는 app 폴더 안에 있는데, 가져다 쓸 haru_supervisor.py는 한 칸 위에 있습니다. 이 두 줄이 "한 칸 위 폴더에서도 파일을 찾아라"라고 알려 줍니다. parent.parent는 main.py → app → haru-market으로 두 번 올라간다는 뜻입니다.

(2) 가져다 쓰는 것 — 새로 만든 것이 거의 없다

from haru_actions import decide, load_actions, pending_approvals
from haru_agents import extract_text
from haru_supervisor import build_supervisor, finalize_if_empty
from haru_tools import CUSTOMERS, CustomerSession, mask_name
가져온 것 만든 곳 여기서의 쓰임
CustomerSession 8장 로그인 고객(C003)을 정한다
load_actions 21장 처리 내역 전체를 읽는다 → /api/actions
pending_approvals 21장 승인 대기 요청만 고른다 → /api/approvals
decide 21장 (23장에서 사용) 승인·반려를 기록한다 → POST /api/approvals/{action_id}
extract_text 21장 메시지에서 글만 뽑는다
build_supervisor 22장 Supervisor를 만든다
finalize_if_empty 22장 빈 답변을 막는다

이 파일이 새로 하는 일은 주소를 붙이고, 이력을 이름표별로 보관하고, 이벤트로 흘려보내는 것 셋뿐입니다. 판단하고 조회하고 처리하고 기록하는 일은 전부 앞 장에서 만든 것이 합니다.

(3) 채팅 엔드포인트의 흐름

chat 함수는 길어 보이지만 순서는 다섯 단계입니다.

순서 코드 하는 일
1 histories.get(req.session_id, []) 이 대화의 이력을 꺼낸다
2 supervisor.stream(...) Supervisor를 실행하며 단계마다 상태를 받는다
3 yield event({"type": "delegate", ...}) 위임이 보이면 곧바로 내보낸다
4 yield event({"type": "answer", ...}) 최종 답변을 내보낸다
5 histories[req.session_id] = new_messages 이력을 갱신해 둔다

(4) 상담원 쪽 주소 — 23장의 승인 게이트가 여기에 있다

@app.get("/admin")
def admin():
    return FileResponse(ROOT / "app" / "frontend" / "admin.html")


def with_customer_names(records):
    """상담원 화면에서 고객 ID와 마스킹된 이름을 함께 확인한다."""
    names = dict(zip(CUSTOMERS["customer_id"], CUSTOMERS["name"]))
    return [
        {**record, "customer_name": mask_name(names[record["customer_id"]])
         if record.get("customer_id") in names else "고객 정보 없음"}
        for record in records
    ]


@app.get("/api/actions")
def actions():
    return with_customer_names(load_actions())


@app.get("/api/approvals")
def approvals():
    """승인을 기다리는 환불·주문 취소 요청."""
    return with_customer_names(pending_approvals())


@app.post("/api/approvals/{action_id}")
def approve(action_id: str, body: Decision):
    """상담원이 승인(approve=true) 또는 반려(approve=false)한다."""
    result = decide(action_id, approve=body.approve, by="상담원")
    log.info("[승인] %s → %s", action_id, result.get("status", result))
    return result
주소 23장에서는 무엇이었나
GET /admin 터미널에 출력하던 "상담원 화면에 뜨는 내용"이 진짜 화면이 됐다
GET /api/approvals 실습문제에서 부른 pending_approvals()
POST /api/approvals/{action_id} Command(resume="approve")로 넣던 사람의 결정. 끝에서 같은 decide() 가 불린다

채팅 쪽 코드에는 승인에 관한 줄이 하나도 없습니다. 요청처리 에이전트가 request_refund로 actions.json에 승인대기를 남기고, 상담원 쪽 주소가 같은 파일을 읽고 고칩니다. 두 쪽은 그 파일로만 이어져 있습니다.


3. 서버 실행하기

터미널 위치가 haru-market 폴더 맨 위인지 확인하고 실행합니다. app 폴더 안으로 들어가서 실행하지 않습니다.

$ python -m uvicorn app.main:app --reload --port 8000

이렇게 나오면 성공입니다. (경로와 대괄호 안의 숫자는 컴퓨터마다 다릅니다)

INFO:     Will watch for changes in these directories: ['.../haru-market']
INFO:     Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
INFO:     Started reloader process [61430] using WatchFiles
INFO:     Started server process [61433]
INFO:     Waiting for application startup.
INFO:     Application startup complete.

마지막 줄 Application startup complete. 가 보이면 서버가 요청을 기다리고 있는 것입니다. 지금까지의 실습 파일과 달리 프로그램이 끝나지 않고 계속 떠 있습니다. 터미널에 다음 명령을 입력할 수 없는 것이 정상입니다.

이렇게 나오면 원인과 조치
ModuleNotFoundError: No module named 'app' 터미널 위치가 haru-market 맨 위가 아닙니다(app 폴더 안에서 실행하면 이 오류가 납니다). pwd로 위치를 확인하고 cd ..로 올라옵니다
app.main을 찾을 수 없다는 오류 main.py가 app 폴더 안에 없습니다. 탐색기에서 위치를 확인합니다
ModuleNotFoundError: No module named 'haru_supervisor' 22장의 haru_supervisor.py가 폴더 맨 위에 없습니다
ModuleNotFoundError: No module named 'haru_actions' 21장의 haru_actions.py가 폴더 맨 위에 없습니다
[준비 필요] chroma_db/ 폴더가 없습니다. 13장 lesson13_rag_embedding.py 를 먼저 실행하세요. 정책 문서 저장소가 없어 서버가 요청을 받지 않습니다. Ctrl + C로 끄고 python lesson13_rag_embedding.py를 실행한 뒤 서버를 다시 켭니다
No module named uvicorn (myenv)가 꺼져 있습니다. conda activate myenv
error while attempting to bind on address ('127.0.0.1', 8000) 8000번을 다른 프로그램이 쓰고 있습니다(맥에서는 뒤에 address already in use가 붙습니다). --port 8001로 바꿔 실행하고, 브라우저 주소도 8001로 엽니다

4. 브라우저 탭 두 개 열기

브라우저에서 탭을 두 개 열고 주소를 하나씩 입력합니다. 두 탭을 나란히 놓으면 보기 좋습니다.

탭 주소 누구의 화면인가
첫 번째 http://localhost:8000 고객 — 채팅 화면
두 번째 http://localhost:8000/admin 상담원 — 상담원 화면

채팅 화면

1장에서 "완성된 모습"으로 본 그 화면입니다.

하루마켓 고객지원 채팅 화면

화면의 자리 보이는 것
초록색 머리글 "하루마켓 고객센터"
머리글 바로 아래 "시연 고객 C003 님으로 로그인되어 있습니다". 화면이 /api/health에 물어 받아 온 고객 번호다
대화 영역 오늘 날짜, 인사 말풍선, 말풍선마다 아래에 보낸 시각
아래쪽 빠른 질문 버튼 네 개, 입력창과 전송 버튼

그림의 날짜와 시각, 답변 문장은 여러분의 화면과 다릅니다. 날짜와 시각은 여러분의 컴퓨터 시계를 따르고, 문장은 실행할 때마다 달라집니다.

상담원 화면

어두운 머리글에 "하루마켓 상담원 화면"이라고 적혀 있고, 아래로 세 구역이 있습니다. 지금은 셋 다 비어 있습니다.

구역 무엇이 올라오나 지금 보이는 글
승인 대기 환불·주문 취소 요청. 카드마다 승인, 반려 버튼이 있다 승인을 기다리는 요청이 없습니다.
처리 내역 에이전트가 직접 처리했거나 승인·반려가 끝난 요청 처리 내역이 없습니다.
넘겨받은 문의 에이전트가 처리하지 못해 접수한 티켓 넘겨받은 문의가 없습니다.

이 화면은 3초마다 서버에 처리 내역과 티켓을 다시 물어 새로 그립니다. 새로고침을 누르지 않아도 채팅에서 생긴 일이 곧 보입니다.

첫 문의 보내기

실행하기 전에 짐작해 보세요. 채팅 화면에 "제 최근 주문 지금 어디까지 왔어요?"를 보내면 점선 상자에 어느 에이전트의 이름이 뜰까요?

채팅 화면 입력창에 적고 전송을 누릅니다.

제 최근 주문 지금 어디까지 왔어요?

화면에서 이 순서로 일어납니다.

  1. 내 문의가 오른쪽 초록 말풍선으로 붙고, 그 아래에 보낸 시각이 찍힌다.
  2. 그 아래 점선 상자에 "생각 중…" 이 뜬다.
  3. 잠시 뒤 "주문조회 에이전트가 확인하고 있어요…" 로 바뀐다.
  4. 점선 상자가 "주문조회 에이전트가 주문 정보를 확인했어요" 로 바뀌어 남고, 그 아래 왼쪽에 답변 말풍선이 붙는다.

같은 화면에서 이어서 물어봅니다.

그중 이어폰은 언제 발송된 거예요?

"그중 이어폰"이라고만 했는데 답이 옵니다. 앞 문의의 이력이 이 화면의 session_id로 서버에 남아 있기 때문입니다. 상담원 화면은 그대로 비어 있습니다. 조회만 했고 처리한 것이 없으니 기록될 것이 없습니다.


5. 승인 흐름 — 채팅에서 요청하고, 상담원 화면에서 승인한다

이제 두 탭을 오가며 23장의 승인 게이트를 웹에서 움직여 봅니다.

실행하기 전에 짐작해 보세요. 채팅으로 주문 취소를 요청하면 점선 상자는 몇 줄 쌓일까요? 답변은 "취소했습니다"일까요?

(1) 고객 — 채팅 화면에서 취소를 요청한다

채팅 화면을 새로고침(F5) 해 새 대화로 시작한 뒤 보냅니다.

무드등 주문 취소해 주세요.

점선 상자가 두 줄 쌓입니다.

주문조회 에이전트가 주문 정보를 확인했어요
요청처리 에이전트가 요청을 처리했어요

그 아래 답변은 "취소했습니다"가 아니라, 승인 요청을 접수했고 처리번호가 A00001이며 담당자 승인 후 처리된다는 안내입니다.

(2) 상담원 — 상담원 화면에 요청이 올라온다

상담원 화면 탭으로 갑니다. 3초 안에 승인 대기 구역에 카드가 하나 생겨 있습니다.

카드에 보이는 것 내용
처리번호와 종류 A00001 · 주문취소요청 · 승인대기
요약 LED 무드등 우드/단일 · 결제 19900원 · 사유 · 출고 전 주문 취소
아래 줄 주문 HR20260720022 · 고객 C003 · 등록 시각
버튼 승인, 반려

승인을 누릅니다. 카드가 승인 대기 구역에서 사라지고, 처리 내역 구역에 승인완료 표시와 함께 나타납니다.

(3) 고객 — 채팅 화면에서 결과를 묻는다

채팅 화면 탭으로 돌아와 새로고침하지 않고 이어서 묻습니다.

취소 요청한 거 어떻게 됐어요?

점선 상자에 "요청처리 에이전트가 요청을 처리했어요" 한 줄이 쌓이고, 승인이 완료되었다는 답이 옵니다.


6. 서버가 주고받은 것을 직접 보기

화면은 서버가 보낸 것을 받아 그려 줄 뿐입니다. 서버가 실제로 무엇을 주고받는지 화면 없이 확인합니다.

서버는 켜 둔 채로, VS Code 터미널 오른쪽 위의 + 를 눌러 터미널을 하나 더 엽니다. 새 터미널에서 실행합니다.

$ curl http://localhost:8000/api/health
{"ok":true,"customer":"C003"}

curl 은 터미널에서 주소로 요청을 보내는 프로그램입니다. 브라우저가 하는 일을 화면 없이 하는 셈입니다. 서버가 살아 있고 C003으로 로그인되어 있다고 답했습니다. 채팅 화면 머리글 아래의 "시연 고객 C003 님"이 이 응답의 customer 값입니다.

(1) 채팅 — 이벤트가 흘러온다

채팅 엔드포인트에 문의를 보냅니다. 한 줄로 입력합니다.

$ curl -N -i -X POST http://localhost:8000/api/chat -H "Content-Type: application/json" -d '{"session_id":"demo-1","message":"제 최근 주문 지금 어디까지 왔어요?"}'
옵션 뜻
-N 모았다가 한꺼번에 보여 주지 말고 오는 대로 출력한다
-i 응답의 머리글(header)도 함께 보여 준다
-X POST POST 요청으로 보낸다
-H "Content-Type: application/json" 보내는 내용이 JSON이라고 알린다
-d '{…}' 보낼 내용. ChatRequest의 두 칸을 채운다

실행 결과 (답변 문장은 실행할 때마다 달라집니다)

HTTP/1.1 200 OK
date: Wed, 07 Oct 2026 16:39:00 GMT
server: uvicorn
cache-control: no-cache
content-type: text/event-stream; charset=utf-8
vary: Origin
transfer-encoding: chunked

data: {"type": "delegate", "agent": "주문조회 에이전트", "request": "고객의 최근 주문과 배송 상태"}

data: {"type": "answer", "text": "고객님의 가장 최근 주문하신 '콜드브루 원액 500ml 2병'(주문번호: HR20260726004)은 현재 '결제완료' 단계입니다. \n\n그 이전에 주문하신 '무선 블루투스 이어폰'(주문번호: HR20260725002)은 현재 '배송중' 상태입니다. \n\n혹시 배송지 변경이나 다른 도움이 필요하신가요?"}

data: {"type": "done"}

같은 session_id로 한 번 더 보냅니다.

$ curl -N -X POST http://localhost:8000/api/chat -H "Content-Type: application/json" -d '{"session_id":"demo-1","message":"그중 이어폰은 언제 발송된 거예요?"}'
data: {"type": "delegate", "agent": "주문조회 에이전트", "request": "주문번호 HR20260725002 무선 블루투스 이어폰의 발송일시 또는 상세 배송 정보"}

data: {"type": "answer", "text": "'무선 블루투스 이어폰'은 2026년 7월 26일에 한진택배(운송장번호: 34622188536)로 발송되어 현재 배송 중입니다. \n\n배송과 관련하여 더 궁금하신 점이 있으신가요?"}

data: {"type": "done"}

이때 서버를 띄운 터미널에는 요청이 들어온 기록이 찍힙니다.

2026-10-08 01:39:01,025 [demo-1] 문의: 제 최근 주문 지금 어디까지 왔어요?
INFO:     127.0.0.1:60393 - "POST /api/chat HTTP/1.1" 200 OK
2026-10-08 01:39:01,897 HTTP Request: POST https://generativelanguage.googleapis.com/v1beta/models/gemini-3.8-flash:generateContent "HTTP/1.1 200 OK"
2026-10-08 01:39:03,202 HTTP Request: POST https://generativelanguage.googleapis.com/v1beta/models/gemini-3.8-flash:generateContent "HTTP/1.1 200 OK"
2026-10-08 01:39:05,590 HTTP Request: POST https://generativelanguage.googleapis.com/v1beta/models/gemini-3.8-flash:generateContent "HTTP/1.1 200 OK"
2026-10-08 01:39:06,656 HTTP Request: POST https://generativelanguage.googleapis.com/v1beta/models/gemini-3.8-flash:generateContent "HTTP/1.1 200 OK"

대괄호 안은 session_id입니다. 화면에서 보내면 web-으로 시작하는 이름표가 찍힙니다.

HTTP Request: POST https://generativelanguage… 줄 하나가 LLM 호출 한 번입니다. 위임이 한 번뿐인 이 문의에도 네 번 찍혔습니다. Supervisor가 위임을 정할 때 한 번, 주문조회 에이전트가 도구를 고르고 보고를 쓰느라 두 번, Supervisor가 답을 쓸 때 한 번입니다. "위임 한 번 안에 LLM 호출이 여러 번 들어 있다"(22장)는 것이 기록으로 보입니다.

(2) 승인 — 상담원 화면이 주고받는 것

5절에서 두 탭으로 한 일을, 이 교재를 준비하며 화면 없이 터미널로만 한 번 더 했습니다. 처리 기록을 비우고 서버를 새로 켠 상태에서 보낸 요청과 받은 응답을 그대로 싣습니다. 5절을 이미 마친 여러분은 읽으면서 자기 화면에서 본 것과 맞춰 보세요(똑같이 다시 보내면 취소 요청이 한 건 더 등록됩니다).

고객이 취소를 요청한다 — 채팅 화면이 보낸 요청과 같습니다.

$ curl -N -X POST http://localhost:8000/api/chat -H "Content-Type: application/json" -d '{"session_id":"demo-2","message":"무드등 주문 취소해 주세요."}'
data: {"type": "delegate", "agent": "주문조회 에이전트", "request": "고객의 최근 주문 중 무드등 상품이 포함된 주문 조회"}

data: {"type": "delegate", "agent": "요청처리 에이전트", "request": "주문 HR20260720022의 LED 무드등(우드/단일) 주문 취소 승인 요청"}

data: {"type": "answer", "text": "주문하신 'LED 무드등 (우드/단일)'의 주문 취소 승인 요청이 접수되었습니다. (처리번호: A00001) 담당자 승인 후 최종 처리될 예정입니다. 추가로 도움이 필요하신 사항이 있으신가요?"}

data: {"type": "done"}

승인 대기 목록을 본다 — 이 요청은 읽기만 하므로 여러분도 언제든 보내 볼 수 있습니다.

$ curl http://localhost:8000/api/approvals
[{"action_id":"A00001","type":"주문취소요청","customer_id":"C003","order_id":"HR20260720022","detail":"LED 무드등 우드/단일 · 결제 19900원 · 사유: 고객 주문 취소 요청 · 분류: 확인필요 · 출고 전 주문 취소","status":"승인대기","created_at":"2026-10-08 01:48"}]

상담원이 승인한다 — 상담원 화면의 승인 버튼이 보내는 요청입니다.

$ curl -X POST http://localhost:8000/api/approvals/A00001 -H "Content-Type: application/json" -d '{"approve": true}'
{"action_id":"A00001","type":"주문취소요청","customer_id":"C003","order_id":"HR20260720022","detail":"LED 무드등 우드/단일 · 결제 19900원 · 사유: 고객 주문 취소 요청 · 분류: 확인필요 · 출고 전 주문 취소","status":"승인완료","created_at":"2026-10-08 01:48","decided_by":"상담원","decided_at":"2026-10-08 01:48"}

승인 대기 목록을 다시 받으면 비어 있습니다.

$ curl http://localhost:8000/api/approvals
[]

서버 터미널에는 승인한 기록이 한 줄 남습니다.

2026-10-08 01:48:18,188 [승인] A00001 → 승인완료
INFO:     127.0.0.1:60957 - "POST /api/approvals/A00001 HTTP/1.1" 200 OK

고객이 결과를 묻는다 — 같은 session_id(demo-2)로 보냅니다.

$ curl -N -X POST http://localhost:8000/api/chat -H "Content-Type: application/json" -d '{"session_id":"demo-2","message":"취소 요청한 거 어떻게 됐어요?"}'
data: {"type": "delegate", "agent": "요청처리 에이전트", "request": "처리번호 A00001의 처리 내역 확인"}

data: {"type": "answer", "text": "요청하신 LED 무드등(우드/단일) 주문 취소는 담당자 승인이 완료되어 처리가 끝났습니다. 추가로 도움이 필요하신 사항이 있으신가요?"}

data: {"type": "done"}

윈도우의 터미널에서 한글이 깨지거나 오류가 나면 이 절은 눈으로만 따라와도 됩니다. 실습문제 1에서 같은 일을 하는 파이썬 파일을 만듭니다.


7. 무엇을 관찰했나

이벤트가 순서대로 왔다

delegate → answer → done. -N 옵션으로 보면 delegate 줄이 먼저 찍히고, 몇 초 뒤에 answer가 따라옵니다. 응답이 한 덩어리로 오지 않고 흘러온 것입니다. 머리글의 content-type: text/event-stream이 "이것은 SSE다"라는 표시입니다.

화면의 점선 상자는 delegate 이벤트였다

취소 요청에서 delegate가 두 번 왔고, 화면의 점선 상자도 두 줄 쌓였습니다. 화면은 "agent" 값을 꺼내 문장에 끼워 넣었을 뿐입니다. "request"에 담긴 요청문은 화면에 보여 주지 않지만 이벤트에는 들어 있습니다.

요청문에 주문번호가 들어 있다

고객은 "무드등 주문"이라고만 했습니다. Supervisor는 먼저 주문조회 에이전트로 어느 주문인지 확인하고, 요청처리 에이전트에게는 "주문 HR20260720022의 LED 무드등(우드/단일) 주문 취소 승인 요청" 이라고 주문번호를 적어 넘겼습니다(22장 운영 원칙 6번). 결과를 물을 때도 "취소 요청한 거"가 "처리번호 A00001의 처리 내역 확인" 으로 풀려 넘어갔습니다. 이력을 꺼내 붙여 보낸 것은 서버 코드이고, 그 이력에서 무엇을 가리키는지 읽어 낸 것은 모델입니다.

취소는 사람이 승인한 뒤에만 끝났다

처리 기록 A00001의 상태가 바뀐 때를 따라갑니다.

때 status 누가 바꿨나 고객이 들은 말
채팅으로 취소를 요청한 직후 승인대기 요청처리 에이전트가 고른 request_refund (실행은 코드) "승인 요청이 접수되었습니다. 담당자 승인 후 처리됩니다"
상담원이 승인한 직후 승인완료, decided_by: 상담원 decide — 사람이 버튼을 누른 뒤에만 (아직 묻지 않았다)
채팅으로 결과를 물었을 때 승인완료 바뀌지 않았다. 요청처리 에이전트가 기록을 읽었다 "담당자 승인이 완료되어 처리가 끝났습니다"

AI는 승인대기를 만들 수는 있어도 승인완료로 바꿀 수는 없습니다. decide는 에이전트의 도구 목록에 없고, 그 함수에 닿는 길은 POST /api/approvals/{action_id} 하나입니다. 23장에서 "사람을 지나야만 닿는 자리"라고 한 것이 웹에서는 상담원 화면의 버튼입니다.

두 화면은 같은 파일을 본다

채팅 화면과 상담원 화면은 서로를 모릅니다. 서로에게 직접 보내는 것이 없습니다. 둘을 잇는 것은 memory_store/actions.json입니다.

채팅 쪽 상담원 쪽
쓰는 것 요청처리 에이전트의 도구가 기록을 더한다 decide가 상태를 바꾼다
읽는 것 get_my_requests가 이 고객의 기록을 읽는다 /api/actions가 전체를 읽는다 (3초마다)

터미널에서 하던 것과 달라진 것은 통로뿐이다

22~23장 24장
문의가 들어오는 곳 파이썬 코드 안의 문자열 HTTP 요청의 message
위임이 보이는 곳 터미널의 [위임 →] 줄 delegate 이벤트 → 화면의 점선 상자
답이 나가는 곳 print answer 이벤트 → 화면의 말풍선
이력을 보관하는 곳 변수 하나 session_id별 딕셔너리
사람의 승인이 들어오는 곳 Command(resume="approve") 상담원 화면의 버튼 → POST /api/approvals/…
판단하고 조회하고 처리하는 것 Supervisor와 전문 에이전트 똑같다
승인을 기록하는 것 decide() 똑같다

8. 서버 끄기

서버를 띄운 터미널을 누르고 Ctrl + C 를 누릅니다.

INFO:     Shutting down
INFO:     Waiting for application shutdown.
INFO:     Application shutdown complete.
INFO:     Finished server process [61433]
INFO:     Stopping reloader process [61430]

프롬프트가 다시 나타나면 꺼진 것입니다. 이 상태에서 브라우저의 채팅 화면에 문의를 보내면 "연결에 문제가 생겼어요. 서버가 켜져 있는지 확인해 주세요."라고 나옵니다. 화면은 브라우저에 남아 있지만 말을 걸 상대가 없어진 것입니다.

서버를 껐다 켜면 사라지는 것과 남는 것이 다릅니다.

어디에 있나 서버를 껐다 켜면
대화 이력 (histories) 서버의 메모리 사라진다
처리 기록 (actions.json), 티켓 (tickets.json) 파일 남는다

9. 지금 폴더의 모습

haru-market/
├── app/
│   ├── frontend/
│   │   ├── admin.html            (제공 — 상담원 화면)
│   │   └── index.html            (제공 — 채팅 화면)
│   └── main.py                   ← 이번 장 (app 폴더 안)
├── chroma_db/
├── data/
├── memory_store/
│   ├── actions.json              (처리 기록 — 승인 대기와 처리 내역)
│   └── tickets.json              (넘겨받은 문의)
├── config.py
├── haru_tools.py                 (8장)
├── haru_actions.py               (21장)
├── haru_agents.py                (21장)
├── haru_supervisor.py            (22장)
│   (중략)
├── lesson22_supervisor.py
└── lesson23_hitl.py

이 과정에서 만드는 파일은 이것이 마지막입니다.


핵심 정리

  • main.py는 app 폴더 안에 만들고, 서버는 haru-market 맨 위에서 python -m uvicorn app.main:app --reload --port 8000으로 띄웁니다.
  • 탭 두 개를 엽니다. http://localhost:8000 이 채팅 화면, http://localhost:8000/admin 이 상담원 화면입니다. 끌 때는 Ctrl + C 입니다.
  • 서버는 delegate → answer → done 순서로 이벤트를 흘려보냅니다. 화면의 점선 상자가 delegate이고, 위임마다 한 줄씩 쌓여 남습니다.
  • 주문 취소는 채팅에서 승인대기 까지만 가고, 상담원이 승인 버튼을 눌러야 승인완료 가 됩니다. 버튼은 23장과 같은 decide()를 부릅니다.
  • 두 화면은 같은 처리 기록 파일로 이어집니다. 상담원 화면은 3초마다 새로 읽습니다.
  • main.py가 새로 한 일은 주소, 이력 보관, 이벤트 셋뿐입니다. 나머지는 앞 장에서 만든 것이 합니다.
← 이전 절SSE — 과정이 보이는 응답다음 절 →따라하기 — 표준 질문 12개로 최종 검수
오명운 · macro@prag-ai.com