페이지 목차
이 페이지에서 검색
로컬 LLM
이 페이지에 묻기
로컬 LLM · 현재 차시와 목차 본문에서만 찾아 답합니다. 답을 지어내지 않고 해당하는 대목을 그대로 보여 주며, 여기에 없으면 없다고 알려 드립니다.
생성형 AI가 아니라 이 페이지 본문을 찾아 그대로 보여 주는 방식입니다. 외부로 나가는 요청이 없습니다.
LOCAL · LLM
로컬 LLM
생성 수정
로컬 모델 실행과 GPU·메모리 이해부터 서빙·RAG·도구 호출·업무형 앱 구현까지 차례로 살펴봅니다.
42개 문서 · 4일차 · 27 / 42
26차시 · Streaming 기반 AI 서비스
지도 위치 : 4일차 · ⑤애플리케이션 층 — 응답을 받는 방식 | 이전: 25차시 로컬 LLM API 애플리케이션 | 다음: 27차시 대화형 AI 서비스 구현 키워드 : Streaming Response, 실시간 응답 처리, SSE 기반 서비스 구현
이 차시를 다 읽으면
- 스트리밍이 줄이는 시간과 줄이지 못하는 시간 을 TTFT · E2E로 구별해 말할 수 있다.
- SSE를 유와 종차로 정의하고, 폴링 · WebSocket과 한 표로 구별할 수 있다.
- SSE의
data: ...\n\n형식과 7차시 Ollama의 NDJSON 이 어떻게 다른지 말할 수 있다. - FastAPI에서 LLM 스트림을 받아 브라우저로 다시 흘려보내는 두 구간 을 그릴 수 있다.
- 스트리밍이 "안 되는 것처럼 보이는" 원인 세 가지(프록시 버퍼링 · 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) | SSE | WebSocket | |
|---|---|---|---|
| 방향 | 클라이언트가 계속 물어봄 | 서버 → 클라이언트 | 양방향 |
| 프로토콜 | 일반 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.content | choices[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_timeout | nginx는 뒤에서 아무것도 안 오는 시간 이 이 값을 넘으면 끊습니다(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 |
| 3 | SSE를 유와 종차로 정의하면? WebSocket과 무엇으로 갈리나? | 2.1 |
| 4 | SSE 조각에서 글자는 어느 필드에 있고, 끝 신호는 무엇인가? | 2.2 |
| 5 | Ollama /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를 쓰는 이유", "프록시 버퍼링"은 반드시 포함해줘.
답은 내가 답한 다음에 채점하고, 틀린 이유를 노트의 절 번호를 근거로 설명해줘.
LangTailor