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

24장. 서비스 통합과 실행

SSE — 과정이 보이는 응답

한 줄 요약

Supervisor는 위임을 거듭하며 답을 만들기 때문에 몇 초에서 십여 초가 걸립니다. 그동안 빈 화면을 보여 주지 않으려고, 서버가 연결을 열어 둔 채 진행 상황을 조금씩 보냅니다. 이 방식의 표준이 SSE(Server-Sent Events, 서버가 보내는 이벤트) 입니다.


1. 한 번에 주는 응답과 흘려보내는 응답

보통의 응답 스트리밍 응답
서버가 보내는 때 전부 끝난 뒤 한 번 일이 진행될 때마다 여러 번
기다리는 동안 화면 아무것도 없다 "주문조회 에이전트가 확인하고 있어요…"
걸리는 시간 같다 같다

걸리는 시간은 줄지 않습니다. 달라지는 것은 기다리는 동안 고객이 무엇을 보는가입니다.

2장에서 본 스트리밍과 비교해 둡니다.

2장의 스트리밍 이번 장의 스트리밍
흘려보내는 단위 답변의 글자 조각 처리의 단계 (누구에게 위임했나)
보여 주는 것 답이 써지는 모습 지금 무슨 일을 하고 있는가

여러 에이전트가 일하는 시스템에서는 글자 몇 개보다 "지금 누가 무엇을 확인하고 있는가" 가 더 쓸모 있는 정보입니다.


2. SSE의 모양

SSE는 형식이 단순합니다. data:로 시작하는 줄 하나가 이벤트 하나이고, 빈 줄이 이벤트 사이를 나눕니다.

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

data: {"type": "answer", "text": "가장 최근에 주문하신 ..."}

data: {"type": "done"}

data: 뒤에 무엇을 넣을지는 우리가 정합니다. 우리는 JSON을 넣고, 이벤트를 세 종류로 설계했습니다.

type 언제 나가나 담긴 것 화면이 하는 일
delegate Supervisor가 전문 에이전트에게 위임할 때마다 에이전트 이름, 요청문 진행 표시 줄을 한 줄 쌓는다
answer 최종 답변이 준비됐을 때 답변 글 마지막 진행 줄을 "…했어요"로 바꾸고 말풍선을 붙인다
done 맨 마지막에 한 번 없음 흐름이 끝났음을 안다

22장에서 터미널에 표시하던 [위임 →] 줄이 delegate 이벤트가 되었습니다. 개발자가 보던 기록이 고객에게 보여 주는 진행 표시로 바뀐 것입니다.


3. 서버 쪽 — yield 할 때마다 이벤트 하나

흘려보내는 응답은 제너레이터(generator) 로 만듭니다. 제너레이터는 return으로 한 번에 돌려주는 대신 yield로 값을 하나씩 내놓는 함수입니다.

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

def generate():
    ...
    yield event({"type": "delegate", "agent": name, "request": req_text})
    ...
    yield event({"type": "answer", "text": final_text})
    ...
    yield event({"type": "done"})

return StreamingResponse(generate(), media_type="text/event-stream",
                         headers={"Cache-Control": "no-cache"})
줄 하는 일
event(...) 딕셔너리를 data: {…} + 빈 줄 모양의 글로 바꾼다. ensure_ascii=False는 한글을 그대로 내보내라는 뜻
yield event(...) 이 줄이 실행되는 순간 이벤트 하나가 브라우저로 나간다
StreamingResponse(generate(), ...) 제너레이터가 내놓는 대로 흘려보내는 응답
media_type="text/event-stream" "이 응답은 SSE다"라고 브라우저에 알린다
Cache-Control: no-cache 중간에서 응답을 저장해 두었다가 재사용하지 말라는 표시

FastAPI 0.135부터는 SSE 전용 응답 클래스(fastapi.sse의 EventSourceResponse)도 제공됩니다(https://fastapi.tiangolo.com/tutorial/server-sent-events/). 우리는 data: 줄을 직접 만들어 보는 쪽을 택했습니다. 형식이 눈에 보이고, 화면의 코드와 한 줄씩 맞춰 볼 수 있기 때문입니다.


4. 위임을 어떻게 알아채는가

22장의 run_supervisor는 supervisor.invoke(...)로 끝난 뒤의 결과만 받았습니다. 중간 과정을 보려면 stream을 씁니다.

seen = len(history)          # 이전 턴의 메시지는 건너뛴다 (이번 턴 것만 이벤트로)
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:
                ...
                yield event({"type": "delegate", "agent": name, "request": req_text})
        elif kind == "AIMessage" and msg.content:
            final_text = extract_text(msg)
    seen = len(msgs)
줄 하는 일
supervisor.stream(..., stream_mode="values") 그래프가 한 단계 진행할 때마다 그때까지의 전체 상태를 내준다
msgs[seen:] 이미 본 메시지는 건너뛰고 새로 생긴 메시지만 본다
seen = len(history) 처음에는 앞 턴의 이력만큼 건너뛴다. 그러지 않으면 지난 턴의 위임이 이번 턴의 이벤트로 다시 나간다
tool_calls가 있는 AI 메시지 Supervisor가 위임을 결정한 순간. delegate 이벤트를 내보낸다
글이 있는 AI 메시지 최종 답변. 기억해 두었다가 끝에서 answer로 내보낸다

5장에서 "모델이 도구를 부르겠다고 하면 응답에 도구 호출 정보가 담겨 온다"고 했습니다. 그 정보(tool_calls)를 여기서 진행 표시의 재료로 씁니다. 위임할 에이전트를 고른 것은 모델이고, 그것을 이벤트로 바꿔 내보내는 것은 우리 코드입니다.

recursion_limit: 12와 빈 답변 방어(finalize_if_empty)도 22장 그대로 들어 있습니다.


5. 화면 쪽 — 이벤트를 받아 그린다

제공된 index.html이 하는 일은 서버가 보낸 형식을 거꾸로 푸는 것입니다.

while ((idx = buf.indexOf("\n\n")) >= 0) {          // 빈 줄로 이벤트를 나눈다
  const line = buf.slice(0, idx).trim();
  buf = buf.slice(idx + 2);
  if (!line.startsWith("data:")) continue;
  const ev = JSON.parse(line.slice(5));             // "data:" 뒤를 JSON 으로 읽는다
  if (ev.type === "delegate") {          // 위임이 올 때마다 한 줄씩 쌓는다
    thinking.remove();
    finish(step);
    step = { agent: ev.agent, div: trace(ev.agent, "확인하고 있어요…") };
  } else if (ev.type === "answer") {
    thinking.remove();
    finish(step);
    add("msg bot", ev.text);
  }
}
서버가 보낸 것 화면에 일어나는 일
(보내자마자) "생각 중…" 줄이 뜬다
delegate "생각 중…"이 사라지고 "주문조회 에이전트가 확인하고 있어요…" 줄이 붙는다
delegate 한 번 더 앞 줄이 "…주문 정보를 확인했어요"로 바뀌고, 다음 에이전트의 줄이 아래에 하나 더 쌓인다
answer 마지막 줄도 "…했어요"로 바뀌고, 그 아래에 답변 말풍선이 붙는다

진행 줄은 답이 온 뒤에도 지워지지 않고 남습니다. 그래서 대화를 다시 올려 보면 문의마다 어느 전문 에이전트가 일했는지가 그대로 보입니다.

"…했어요"로 바뀔 때의 문구는 에이전트마다 다릅니다. 화면 코드에 표로 적혀 있습니다.

delegate의 agent 값 일하는 동안 끝난 뒤
주문조회 에이전트 주문조회 에이전트가 확인하고 있어요… 주문조회 에이전트가 주문 정보를 확인했어요
정책안내 에이전트 정책안내 에이전트가 확인하고 있어요… 정책안내 에이전트가 규정을 찾았어요
요청처리 에이전트 요청처리 에이전트가 확인하고 있어요… 요청처리 에이전트가 요청을 처리했어요

agent 값은 서버의 agent_names 딕셔너리가 위임 도구 이름(ask_order_agent, ask_policy_agent, ask_action_agent)을 한국어 이름으로 바꿔 넣은 것입니다.

서버의 event() 함수와 화면의 이 코드는 같은 약속의 양쪽 끝입니다. 서버가 "type"의 이름을 바꾸면 화면도 함께 바꿔야 합니다.


6. 오류가 나도 흐름은 끝맺는다

try:
    ...
except Exception as e:                      # 서버는 죽지 않는다
    log.exception("chat error")
    yield event({"type": "answer",
                 "text": "일시적인 오류가 발생했어요. 잠시 후 다시 시도해 주세요."})
yield event({"type": "done"})

Supervisor 실행 중에 무슨 일이 생겨도(상한 초과, 일시적인 호출 실패) 고객에게는 안내 문장이 가고, done으로 끝납니다. 자세한 오류는 서버 터미널의 기록(log.exception)에만 남습니다. 6장에서 "도구는 오류로 죽지 않고 상황을 알려 준다"고 한 원칙을 서버 층에서 다시 쓴 것입니다.


핵심 정리

  • 스트리밍은 시간을 줄이지 않습니다. 기다리는 동안 무엇을 보여 줄지를 바꿉니다.
  • SSE는 data: … 한 줄 + 빈 줄이 이벤트 하나인 단순한 형식입니다.
  • 이벤트는 delegate(위임) · answer(답변) · done(끝) 세 종류로 설계했습니다.
  • 서버는 제너레이터에서 yield 할 때마다 이벤트 하나를 내보냅니다.
  • 위임은 supervisor.stream으로 새로 생긴 메시지의 tool_calls 를 보고 알아챕니다.
  • 오류가 나도 안내 문장과 done 으로 흐름을 끝맺습니다.
← 이전 절세션과 대화 이력 — 브라우저마다 대화가 따로 있다다음 절 →따라하기 — 서버 만들고 채팅 화면과 상담원 화면 열기
오명운 · macro@prag-ai.com