AI 블로그 완성기(5)- 챗봇 속도가 너무 느려요...
AI 요약
이 블로그의 AI 챗봇 응답 속도가 느리다는 문제점을 해결하기 위해 코드를 수정했습니다. Qwen3 thinking 모드, RAG 쿼리 임베딩, 매 요청 DB 풀스캔 세 가지가 시간 소모 원인으로 확인되었습니다. Qwen3 thinking 모드 비활성화, 프론트엔드 인공 딜레이 제거, RAG 인메모리 캐시를 통해 응답 속도를 크게 개선했습니다. 이로 인해 TTFT(첫 토큰 시간)이 25초에서 26초까지 단순히 감소하는 효과를 보였습니다.
TL;DR
포트폴리오 블로그의 RAG 챗봇 응답이 너무 느렸습니다. 병목 분석 결과 Qwen3 thinking 모드, 프론트엔드 인공 딜레이, 매 요청 DB 풀스캔 세 가지가 핵심 원인이었고, 코드 변경만으로 체감 7-19초 → 2-5초로 개선했습니다.
현재 구조
이 블로그의 AI 챗봇은 다음 스택으로 동작합니다:
- LLM: Ollama + Qwen3:8B (로컬 GPU 추론)
- 임베딩: nomic-embed-text (768차원)
- RAG: SQLite에 청크 저장 → cosine similarity 검색
- 프론트엔드: SSE 스트리밍 + 단어별 애니메이션
구조 자체는 심플한데, 실제로 질문을 던져보면 첫 글자가 나오기까지 체감 5초 이상, 전체 응답 완료까지 10초 넘게 걸리는 상황이었습니다.
병목 분석
코드를 한 줄씩 추적해서 어디서 시간이 소모되는지 정리했습니다.
| 구간 | 소요 시간 | 원인 |
|---|---|---|
Qwen3 <think> 블록 생성 | 2-5초 | 모델이 숨겨진 reasoning 토큰을 생성 |
| RAG 쿼리 임베딩 | 100-200ms | Ollama 임베딩 API 호출 |
| RAG 청크 로드 + JSON.parse | 50-200ms | 매 요청 전체 DB 조회 + 파싱 |
| LLM 토큰 생성 | 3-8초 | 모델 속도 (불가피) |
| 프론트엔드 단어별 딜레이 | 2-6초 | delay(40ms) × 단어 수 |
| 합계 | 7-19초 |
LLM 토큰 생성 속도는 하드웨어 한계라 건드릴 수 없지만, 나머지는 전부 소프트웨어 문제였습니다.
최적화 1: Qwen3 Thinking 모드 비활성화
Qwen3는 기본적으로 <think>...</think> 블록 안에서 먼저 "생각"을 한 뒤 답변을 생성합니다. 기존 코드에서는 이 블록을 받은 뒤 스트리핑하고 있었는데, 문제는 think 토큰을 생성하는 시간 자체가 소모된다는 점이었습니다.
Ollama는 think: false 파라미터로 thinking 모드를 원천 차단할 수 있습니다:
// src/app/api/chat/route.ts
const ollamaRes = await fetch(`${OLLAMA_URL}/api/chat`, {
body: JSON.stringify({
model: CHAT_MODEL,
messages,
stream: true,
think: false, // thinking 토큰 생성 자체를 막음
options: {
temperature: 0.1,
num_ctx: 4096, // 8192 → 4096 (충분)
num_predict: 512, // 2000 → 512 (3-5문장에 맞게)
repeat_penalty: 1.3,
},
}),
});
think 블록을 파싱하고 스트리핑하던 60줄의 코드도 함께 제거했습니다.
num_ctx도 8192에서 4096으로 줄였습니다. 시스템 프롬프트 + RAG 컨텍스트 + 대화 히스토리를 합쳐도 3000 토큰 이하라 충분합니다. 컨텍스트 윈도우가 작을수록 prefill이 빨라집니다.
효과: TTFT(첫 토큰 시간) -2~5초
최적화 2: 프론트엔드 인공 딜레이 제거
챗봇 프론트엔드에 "타이핑 효과"를 위해 단어마다 40ms 딜레이가 들어있었습니다:
// ChatWidget.tsx — 변경 전
const revealWords = async () => {
while (wordBuffer.length > 0) {
const word = wordBuffer.shift()!;
accumulated += word;
setMessages([...updated, { role: "assistant", content: accumulated }]);
await delay(40); // 100단어 = 4초 인공 지연
}
};
Ollama의 스트리밍 속도(GPU에서 15-30 tok/s)가 이미 자연스러운 타이핑 느낌을 줍니다. 인공 딜레이는 그 위에 불필요한 지연만 추가하고 있었습니다. delay(0)으로 변경해서 토큰이 도착하는 즉시 렌더링되도록 수정했습니다.
효과: -2~6초 (응답 길이에 비례)
최적화 3: RAG 인메모리 캐시
기존 retrieveContext()는 매 요청마다:
prisma.postChunk.findMany()— 전체 청크 DB 조회- 각 청크의
embedding필드를JSON.parse()— 768차원 float 배열 역직렬화 embedText(query)— Ollama에 쿼리 임베딩 요청
포트폴리오 블로그의 청크 수는 100개 내외로 적지만, 매 요청마다 이 과정을 반복할 이유는 없습니다.
// src/lib/rag.ts — 청크 캐시
let chunkCache: CachedChunk[] | null = null;
let cacheLoadedAt = 0;
const CHUNK_CACHE_TTL = 5 * 60 * 1000; // 5분
async function getChunks(): Promise<CachedChunk[]> {
if (chunkCache && Date.now() - cacheLoadedAt < CHUNK_CACHE_TTL) {
return chunkCache; // DB 조회 + JSON.parse 스킵
}
const raw = await prisma.postChunk.findMany({ ... });
chunkCache = raw.map((c) => ({
...c,
embedding: JSON.parse(c.embedding) as number[],
}));
cacheLoadedAt = Date.now();
return chunkCache;
}
쿼리 임베딩도 동일 질문이 반복될 때 캐시에서 제공합니다. 챗봇 UI의 추천 질문 버튼("어떤 프로젝트를 만들었나요?")을 누를 때 특히 효과적입니다.
관리자가 RAG를 reindex하면 invalidateChunkCache()로 캐시를 무효화합니다.
효과: -100300ms (반복 쿼리는 -200400ms 추가)
결과
| 구간 | Before | After |
|---|---|---|
| TTFT (첫 토큰) | 5-10초 | 1-3초 |
| 전체 응답 | 7-19초 | 2-5초 |
| 코드 변경량 | — | ~80줄 수정 |
클라우드 API 전환이나 모델 교체 없이, 기존 코드의 비효율만 제거해서 체감 속도를 3배 이상 개선했습니다.
배운 것
- 보이지 않는 토큰도 시간을 먹는다: Qwen3의 think 모드는 출력에 안 보여도 GPU 시간을 소모합니다. 모델의 기본 동작을 이해하고 필요 없는 기능은 끄는 게 중요합니다.
- 인공 딜레이를 의심하라: UX를 위해 넣은 delay가 오히려 UX를 망칠 수 있습니다. 스트리밍 자체가 이미 자연스러운 타이핑 효과를 제공합니다.
- 캐시는 단순하게: Redis 같은 외부 캐시 없이 모듈 레벨 변수로 충분했습니다. 포트폴리오 블로그 규모에서는 TTL 기반 인메모리 캐시가 최적입니다.
num_ctx와num_predict를 실제 사용량에 맞춰라: 기본값을 그대로 쓰지 말고, 실제 토큰 사용량을 계산해서 최소한으로 설정하면 prefill 속도가 빨라집니다.
댓글 (0)
아직 댓글이 없습니다.