본문으로 건너뛰기
페이지 목차
이 페이지에서 검색

로컬 LLM

이 페이지에 묻기

로컬 LLM · 현재 차시와 목차 본문에서만 찾아 답합니다. 답을 지어내지 않고 해당하는 대목을 그대로 보여 주며, 여기에 없으면 없다고 알려 드립니다.

생성형 AI가 아니라 이 페이지 본문을 찾아 그대로 보여 주는 방식입니다. 외부로 나가는 요청이 없습니다.

LOCAL · LLM

로컬 LLM

생성 수정

로컬 모델 실행과 GPU·메모리 이해부터 서빙·RAG·도구 호출·업무형 앱 구현까지 차례로 살펴봅니다.

42개 문서 · 4일차 · 27 / 42

26차시 · Streaming 기반 AI 서비스

지도 위치 : 4일차 · ⑤애플리케이션 층 — 응답을 받는 방식 | 이전: 25차시 로컬 LLM API 애플리케이션 | 다음: 27차시 대화형 AI 서비스 구현 키워드 : Streaming Response, 실시간 응답 처리, SSE 기반 서비스 구현

이 차시를 다 읽으면

  1. 스트리밍이 줄이는 시간과 줄이지 못하는 시간 을 TTFT · E2E로 구별해 말할 수 있다.
  2. SSE를 유와 종차로 정의하고, 폴링 · WebSocket과 한 표로 구별할 수 있다.
  3. SSE의 data: ...\n\n 형식과 7차시 Ollama의 NDJSON 이 어떻게 다른지 말할 수 있다.
  4. FastAPI에서 LLM 스트림을 받아 브라우저로 다시 흘려보내는 두 구간 을 그릴 수 있다.
  5. 스트리밍이 "안 되는 것처럼 보이는" 원인 세 가지(프록시 버퍼링 · EventSource의 GET 제한 · 끊김 처리)를 짚고, 브라우저 개발자 도구로 직접 확인 할 수 있다.

0. 이 차시의 판별 축

#축왼쪽 (0)오른쪽 (1)이 축으로 갈리는 것큰 축 (README 5장)
1언제 받는가다 끝나고 한 번에만들어지는 대로 조각으로stream: false ↔ stream: true⑥ 동기 ↔ 비동기의 파생
2어느 시간을 보는가첫 조각까지 (TTFT)마지막 조각까지 (E2E)체감 지연 ↔ 실제 처리 시간한 건의 시간 ↔ 전체 처리량 (⑤의 파생)
3누가 말할 수 있는가서버 → 클라이언트 한 방향 (SSE)양쪽 다 (WebSocket)답 흘려보내기 ↔ 실시간 협업파생 축 (통신 방향)
4서버가 기다리는 동안 다른 요청을 받는가def (스레드풀)async def (이벤트 루프)동시 스트림 여러 개를 감당하는가⑥ 동기 ↔ 비동기

앞에서 이어집니다 — 14차시에서 TTFT(첫 토큰까지) 와 E2E(끝까지) 를 따로 쟀습니다. 2차시에서는 TTFT가 Prefill , 글자가 흘러나오는 속도가 Decode 라고 했습니다. 스트리밍은 이 둘 중 사용자가 무엇을 기다리게 할지 를 바꾸는 기술입니다.

⚠️ 축 1과 축 4는 다른 축 입니다. 스트리밍은 "조각으로 보내는가"이고 async는 "서버가 기다리는 동안 다른 요청을 받는가"입니다. 동기 코드로도 스트리밍은 됩니다. 다만 상담원 10명이 동시에 스트림을 열면 async가 필요해집니다(4.2).


1. 왜 스트리밍인가

1.1 ⭐ 체감 지연은 TTFT가 정한다

답변 초안 300토큰을 만드는 요청 하나를 그려 보면 이렇습니다.

[한 번에 받기]  요청 ─────────────── (Prefill + Decode 300토큰) ───────────────▶ 화면에 전부 표시
                사용자는 이 동안 빈 화면을 봄

[스트리밍]      요청 ── Prefill ──▶ 첫 글자 표시 ─ 글자 ─ 글자 ─ ... ─ 글자 ─▶ 끝
                       (TTFT)        여기서부터 읽기 시작

첫 글자를 보는 시점은 E2E 끝 → TTFT 직후 로 당겨지고, 마지막 글자를 보는 시점은 E2E 그대로 입니다.

1.2 ⚠️ 스트리밍은 빨라지는 게 아니다

전체 생성 시간(E2E)은 그대로 입니다. GPU는 같은 토큰을 같은 속도로 만듭니다. 조각마다 HTTP로 보내는 비용 때문에 아주 조금 늘 수도 있습니다.

README 5.1의 "비동기는 더 빠른 것이 아니다" (축 ⑥)와 같은 구조의 문장입니다. 스트리밍이 바꾸는 것은 사람이 느끼는 시간 이지 기계가 일하는 시간이 아닙니다.

증상스트리밍으로 해결되나손볼 곳
첫 글자가 늦다❌ (TTFT 자체는 그대로)입력 줄이기, 프리픽스 캐시(21차시)
글자가 느릿느릿 나온다❌Decode — 양자화, GPU(15차시)

1.3 반례 — 스트리밍이 필요 없는 곳

기능이유
분류 (배송지연 / 교환·반품 / ...)답이 한 단어. 흘려볼 것이 없음
정보 추출 JSON (29차시)다 와야 파싱 · 검증할 수 있음. 반쪽 JSON은 쓸 데가 없음
밤사이 문의 1,000건 일괄 처리보는 사람이 없음. 처리량(22차시)이 중요

⭐ 문의봇에서 스트리밍이 의미 있는 곳은 답변 초안 (사람이 읽는 긴 글) 하나입니다.


2. SSE — 정의와 경계

2.1 정의

SSE (Server-Sent Events) — [HTTP 기반 서버 푸시 방식]인데, [ 하나의 HTTP 응답을 닫지 않고 서버 → 클라이언트 한 방향으로 이벤트를 줄 단위 텍스트로 계속 흘려보낸다 ]는 점에서 연결을 업그레이드해 양방향으로 주고받는 WebSocket과 구별된다.

폴링 (Polling)SSEWebSocket
방향클라이언트가 계속 물어봄서버 → 클라이언트양방향
프로토콜일반 HTTP 요청 반복일반 HTTP 응답 하나HTTP로 시작해 별도 프로토콜(ws://)로 전환
문의봇에 맞나❌ 낭비⭕ 답이 한 방향으로 흐름과함 — 상담원이 생성 중에 말을 걸 일이 없음

⭐ OpenAI 호환 API의 stream: true가 바로 SSE입니다. vLLM · Ollama(/v1) · llama-server가 모두 이 형식으로 흘려보냅니다.

💡 채용 공고나 포트폴리오에서 " SSE 스트리밍 채팅 "이라는 말을 자주 봅니다. 바로 이 차시의 내용입니다.

2.2 형식

data: {"choices":[{"index":0,"delta":{"content":"고객"}}]}

data: {"choices":[{"index":0,"delta":{"content":"님,"}}]}

data: {"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}

data: [DONE]

🔍 읽는 포인트

  • 한 이벤트 = data: 로 시작하는 줄 + 빈 줄 (\n\n). 빈 줄이 이벤트의 끝 표시입니다.
  • 조각의 글자는 message.content가 아니라 delta.content 에 있습니다. 한 번에 받을 때와 자리가 다릅니다.
  • finish_reason은 마지막 조각 에 옵니다. 스트리밍에서도 "length"(잘림, 25차시 4.1)를 확인해야 합니다.
  • data: [DONE] — JSON이 아니라 끝 신호 문자열 . 파싱하려 들면 에러가 납니다.
  • SSE에는 event:(이벤트 이름) · id: · retry: 줄도 있습니다. 우리 앱은 event: done, event: error처럼 이름을 붙여 씁니다(3.2).

2.3 ⚠️ 7차시 Ollama NDJSON과 같지 않다

7차시에서 Ollama 고유 API(/api/chat)의 스트리밍을 봤습니다. 그것은 SSE가 아닙니다.

Ollama 고유 API (/api/chat)OpenAI 호환 (/v1/chat/completions)
형식NDJSON — 한 줄에 JSON 하나SSE — data: + JSON + 빈 줄
기본값stream: true가 기본stream: false가 기본
글자 위치message.contentchoices[0].delta.content
끝 표시마지막 줄 "done": true + 통계(나노초)data: [DONE]

⭐ 둘 다 "조각으로 흘려보낸다"는 같은 종류 지만 포장이 다릅니다. 앱이 OpenAI 호환 클라이언트(25차시 어댑터)만 쓰면 이 차이를 신경 쓸 일이 없습니다. 이것도 포트의 이득입니다.


3. 두 구간 — LLM 서버 → 앱 서버 → 브라우저

[vLLM / Ollama]  ──SSE(OpenAI 형식)──▶  [FastAPI]  ──SSE(우리 형식)──▶  [브라우저]
                  구간 ①: 어댑터가 받음            구간 ②: 라우터가 보냄

⭐ 구간 ①의 조각을 그대로 구간 ②로 넘기지 않습니다. 우리 형식({"delta": "..."})으로 바꿔 보냅니다. 브라우저가 OpenAI 형식을 알게 되면 엔진 형식이 UI까지 새어 나온 것입니다(축 ②).

3.1 구간 ① — 어댑터가 조각을 받아 글자만 내보낸다

# adapter/outbound/llm/openai_compat_adapter.py (추가)
async def stream(self, messages, temperature=0.4, max_tokens=600):
    s = await self.client.chat.completions.create(
        model=self.model, messages=messages,
        temperature=temperature, max_tokens=max_tokens, stream=True)
    try:
        async for chunk in s:
            if chunk.choices and chunk.choices[0].delta.content:
                yield chunk.choices[0].delta.content
    finally:
        await s.close()          # 중간에 멈추면 LLM 서버와의 연결도 닫는다

🔍 읽는 포인트

  • stream=True 한 단어로 SSE를 받습니다. data: 줄을 직접 자르는 코드는 라이브러리가 합니다.
  • async for + yield — async generator . 조각이 올 때마다 하나씩 위로 넘깁니다.
  • if chunk.choices and ... — 빈 조각(역할만 오는 첫 조각, 마지막 조각)을 건너뜁니다.
  • finally: await s.close() — 4.3절의 끊김 처리 와 연결됩니다.

3.2 구간 ② — 라우터가 우리 형식으로 다시 흘려보낸다

# adapter/inbound/api/v1/inquiry_router.py (추가)
@router.post("/inquiries/{inquiry_id}/draft/stream")
async def stream_draft(inquiry_id: str, request: Request,
                       uc: DraftReply = Depends(get_draft_use_case)):
    async def events():
        try:
            async for piece in uc.stream(inquiry_id):
                if await request.is_disconnected():
                    break
                yield f"data: {json.dumps({'delta': piece}, ensure_ascii=False)}\n\n"
            yield "event: done\ndata: {}\n\n"
        except LLMUnavailable:
            yield 'event: error\ndata: {"message": "LLM 서버 응답 없음"}\n\n'
    return StreamingResponse(events(), media_type="text/event-stream",
                             headers={"Cache-Control": "no-cache",
                                      "X-Accel-Buffering": "no"})

🔍 읽는 포인트

  • StreamingResponse(..., media_type="text/event-stream") — 이 두 단어가 "이건 SSE다"라는 선언입니다.
  • async def — LLM 조각을 기다리는 I/O-bound 작업입니다. 앞에서 배운 CPU-bound는 def, I/O-bound는 async def (25차시 2.3) 그대로입니다.
  • ensure_ascii=False — 한국어가 고객으로 바뀌지 않게.
  • event: error — ⚠️ 스트림이 시작된 뒤에는 HTTP 상태 코드를 바꿀 수 없습니다. 이미 200을 보냈기 때문입니다. 그래서 중간 실패는 에러 이벤트 로 알립니다.
  • X-Accel-Buffering: no — 5.1절의 프록시 버퍼링 대책.

3.3 브라우저 — EventSource가 아니라 fetch

EventSource (브라우저 내장)fetch + ReadableStream
HTTP 메서드GET만POST 가능
요청 본문못 보냄보냄 (문의 본문, 옵션)
헤더 (Authorization)못 붙임붙임
자동 재연결있음직접 구현
data: 줄 자르기자동직접

문의봇은 POST로 문의 본문을 보내고 로그인 토큰을 헤더에 붙여야 하므로 fetch 를 씁니다.

const res = await fetch(`/api/inquiries/${id}/draft/stream`, {
  method: "POST", headers: { "Content-Type": "application/json", Authorization: token },
  body: JSON.stringify({ tone: "정중" }),
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = "";
while (true) {
  const { done, value } = await reader.read();
  if (done) break;
  buf += decoder.decode(value, { stream: true });
  const events = buf.split("\n\n");
  buf = events.pop();                    // 아직 덜 온 마지막 조각은 남겨 둔다
  for (const ev of events) handle(ev);   // data: 줄을 꺼내 화면에 붙인다
}

🔍 읽는 포인트

  • buf = events.pop() — ⚠️ 네트워크 조각은 이벤트 경계와 상관없이 잘려 옵니다. 반쪽 이벤트를 버리면 글자가 빠집니다.
  • decode(value, { stream: true }) — 한국어 한 글자(UTF-8 3바이트)가 두 조각에 걸쳐 올 수 있습니다. 이 옵션이 없으면 가끔 �가 찍힙니다.

4. 끊김과 동시성

4.1 ⚠️ 클라이언트가 끊으면 생성도 멈춰야 한다

상담원이 초안 생성 도중 다른 문의를 클릭했다고 합시다. 브라우저는 연결을 끊습니다. 그런데 앱 서버가 이를 모르면 LLM 서버는 600토큰을 끝까지 만듭니다. 아무도 읽지 않는 글에 GPU를 씁니다.

브라우저 끊김 ─▶ request.is_disconnected() == True ─▶ 루프 break
            ─▶ 어댑터 finally: s.close() ─▶ LLM 서버와의 연결 닫힘 ─▶ 엔진이 요청 중단

확인은 세 곳에서 합니다 — 앱 서버 로그 (끊김 감지 줄), vLLM 로그 (해당 요청이 중단(abort)됐다는 줄, 문구는 버전에 따라 다름), /metrics · nvidia-smi (끊은 직후 실행 중 요청 수 · GPU 사용률이 내려가는가).

4.2 동시 스트림 10개

스트림 하나는 수 초~수십 초 동안 연결을 붙잡고 있습니다. 상담원 10명이 동시에 초안을 만들면 연결 10개가 동시에 열려 있습니다. async def면 이벤트 루프 하나가 10개를 번갈아 돌보고, def면 스레드풀의 스레드 10개를 차지합니다. LLM 쪽 동시 처리(배칭)는 3일차의 vLLM 몫 이고, 앱 서버는 연결을 막지 않고 들고 있는 것 이 몫입니다.


5. "스트리밍이 안 돼요" — 세 가지 원인

5.1 프록시 버퍼링

리버스 프록시 (Reverse Proxy) — [중계 서버]인데, [ 사용자 요청을 먼저 받아 뒤의 앱 서버로 넘겨주고 응답을 다시 전달한다 ](HTTPS 처리 · 주소 정리 · 부하 분산)는 점에서 앱 서버 자신과 구별된다. 운영 환경에서는 브라우저와 FastAPI 사이에 보통 nginx 같은 리버스 프록시가 하나 끼어 있습니다.

nginx는 기본적으로 뒤에서 오는 응답을 모아서(버퍼링) 보냅니다. 앱은 조각으로 보냈는데 nginx가 다 모은 뒤 한 번에 넘기면, 브라우저에서는 스트리밍이 아닌 것처럼 보입니다.

대책위치
proxy_buffering off;nginx 설정의 해당 location
X-Accel-Buffering: no 헤더앱 응답 (3.2절) — nginx가 이 응답만 버퍼링 안 함
proxy_read_timeoutnginx는 뒤에서 아무것도 안 오는 시간 이 이 값을 넘으면 끊습니다(504). 대기열이 길어 첫 조각이 늦으면 여기 걸림
Cloudflare 등 중간 장비설정 · 요금제마다 다르니 실제로 확인

5.2 원인을 층별로 가르기 — curl -N

# ① LLM 서버 직접 — 여기서 안 흐르면 엔진 문제
curl -N http://localhost:8000/v1/chat/completions -H "Content-Type: application/json" \
  -d '{"model":"munuibot","stream":true,"messages":[{"role":"user","content":"인사해줘"}]}'
# ② 앱 서버 직접 — 여기서 안 흐르면 앱 코드 문제
curl -N -X POST http://localhost:8080/api/inquiries/123/draft/stream -H "Content-Type: application/json" -d '{}'
# ③ nginx 너머 — 여기서만 안 흐르면 프록시 버퍼링
curl -N -X POST https://munuibot.example.local/api/inquiries/123/draft/stream -d '{}'

🔍 읽는 포인트 — -N(--no-buffer)이 없으면 curl 자신이 모아서 출력해 스트리밍이 안 되는 것처럼 보입니다. 요청 한 건이 지나는 길(LLM 서버 → 앱 서버 → 프록시 → 브라우저)을 한 칸씩 끊어 봅니다. 안쪽(LLM 서버)부터 확인하면, 처음으로 안 흐르는 칸이 범인입니다.

5.3 ⭐ 브라우저 개발자 도구로 직접 보기

단계볼 것
F12 → Network 탭 → 스트림 요청 클릭응답 헤더 Content-Type: text/event-stream
EventStream 탭이벤트가 한 줄씩 시간과 함께 쌓이는가. EventSource로 연 연결은 여기에 보임. fetch로 연 경우는 브라우저 버전에 따라 이 탭이 없을 수 있음
Timing 탭Waiting (TTFB)이 짧고 Content Download가 길게 늘어지면 스트리밍 중. 반대로 Waiting이 길고 Download가 순간이면 어딘가에서 버퍼링

6. 실습 — 맡기고 검수하기

지시

25차시에서 만든 문의봇 FastAPI 앱에 "답변 초안 스트리밍"을 추가해줘.
- 어댑터: AsyncOpenAI stream=True 로 받아 delta.content 만 yield, finally 에서 스트림 close
- 라우터: POST /inquiries/{id}/draft/stream, StreamingResponse(media_type="text/event-stream")
  우리 형식: data: {"delta": "..."} / 끝은 event: done / 중간 실패는 event: error
  응답 헤더 Cache-Control: no-cache, X-Accel-Buffering: no
- request.is_disconnected() 로 끊김을 감지해 생성 중단
- 최소 HTML 한 장: fetch + ReadableStream 으로 받아 화면에 이어 붙이기 (EventSource 쓰지 말 것)
맥락: FastAPI StreamingResponse 공식 문서, openai 파이썬 라이브러리 스트리밍 문서 기준. 추측하지 말고 실행 출력 원문을 붙여.
보고 형식: [측정 | 한 번에 받기 | 스트리밍] 표로 TTFT(첫 data 이벤트까지)와 E2E(done 까지)를 각 5회 중앙값으로.

받을 증거

  • curl -N으로 받은 출력 원문 (조각이 시간차를 두고 찍힌 것)
  • TTFT · E2E 측정 스크립트와 출력 원문 (워밍업 1회 제외, temperature 0, max_tokens 고정)
  • 생성 도중 브라우저 탭을 닫았을 때의 앱 서버 로그와 vLLM(또는 Ollama) 로그 원문

내가 직접 볼 것

볼 것이걸로 아는 것
측정표에서 TTFT는 크게 줄고 E2E는 거의 같은가1.2 — 스트리밍은 체감만 바꾼다
개발자 도구 Timing 탭의 Waiting ↔ Content Download진짜로 흘러오는가, 어딘가에서 모이는가
탭을 닫은 직후 nvidia-smi GPU 사용률이 내려가는가끊김이 LLM 서버까지 전달되는가
화면의 한국어에 �가 섞이지 않는가조각 경계 처리를 제대로 했는가

부록 A. 스스로 확인하기

#질문답이 있는 곳
1스트리밍이 줄이는 시간과 줄이지 못하는 시간은?1.1, 1.2
2문의봇에서 스트리밍이 필요 없는 기능 두 가지는?1.3
3SSE를 유와 종차로 정의하면? WebSocket과 무엇으로 갈리나?2.1
4SSE 조각에서 글자는 어느 필드에 있고, 끝 신호는 무엇인가?2.2
5Ollama /api/chat 스트리밍과 OpenAI 호환 스트리밍의 차이는?2.3
6스트림 도중 실패를 HTTP 상태 코드로 알릴 수 없는 이유는?3.2
7문의봇이 EventSource 대신 fetch를 쓰는 이유는?3.3
8앱은 조각으로 보내는데 브라우저에 한 번에 뜬다면 어디부터 의심하나?5.1, 5.2

부록 B. 자주 하는 실수

실수바로잡기
"스트리밍하면 응답이 빨라진다"❌ E2E는 그대로 . 첫 글자를 보는 시점만 당겨짐
분류 · JSON 추출도 스트리밍⚠️ 다 와야 쓸 수 있는 결과. 스트리밍 이득 없음
LLM 서버의 SSE 조각을 그대로 브라우저로 중계⚠️ 엔진 형식이 UI까지 샘. 우리 형식으로 바꿔 보냄
네트워크 조각 = 이벤트 하나라고 가정❌ 반쪽 이벤트 · 반쪽 한글이 옴. 버퍼에 모아 \n\n으로 자름
끊김을 처리하지 않음⚠️ 아무도 안 읽는 답에 GPU를 씀

부록 C. NotebookLM 복습 프롬프트

이 26차시 노트만 근거로 6문제를 내줘. 비전공자 기준으로.
"스트리밍은 빨라지는 게 아니다", "SSE와 Ollama NDJSON의 차이", "EventSource 대신 fetch를 쓰는 이유", "프록시 버퍼링"은 반드시 포함해줘.
답은 내가 답한 다음에 채점하고, 틀린 이유를 노트의 절 번호를 근거로 설명해줘.