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으로 흐름을 끝맺습니다.