페이지 목차
이 페이지에서 검색
로컬 LLM
이 페이지에 묻기
로컬 LLM · 현재 차시와 목차 본문에서만 찾아 답합니다. 답을 지어내지 않고 해당하는 대목을 그대로 보여 주며, 여기에 없으면 없다고 알려 드립니다.
생성형 AI가 아니라 이 페이지 본문을 찾아 그대로 보여 주는 방식입니다. 외부로 나가는 요청이 없습니다.
LOCAL · LLM
로컬 LLM
생성 수정
로컬 모델 실행과 GPU·메모리 이해부터 서빙·RAG·도구 호출·업무형 앱 구현까지 차례로 살펴봅니다.
42개 문서 · 4일차 · 28 / 42
27차시 · 대화형 AI 서비스 구현
지도 위치 : 4일차 · ⑤애플리케이션 층 — 대화의 기억을 앱이 진다 | 이전: 26차시 Streaming 기반 AI 서비스 | 다음: 28차시 Prompt 기반 AI 기능 구현 키워드 : Conversation History, Message 관리, Context 관리
이 차시를 다 읽으면
- "LLM은 대화를 기억하지 않는다"를 축 ⑦로 설명하고, 기억이 실제로 어디에 있는지 말할 수 있다.
messages의 네 역할(system · user · assistant · tool)을 구별할 수 있다.- 토큰 예산식 을 쓰고 문의봇 숫자로 대화 기록에 쓸 수 있는 몫을 계산할 수 있다.
- 기록 관리 전략 네 가지를 비교하고, 대화가 길수록 TTFT와 KV 캐시 가 왜 늘어나는지 말할 수 있다.
- 세션 저장 위치(메모리 dict ↔ Redis · DB)를 고르고, 다른 상담원의 대화가 섞이는 사고 를 검수할 수 있다.
0. 이 차시의 판별 축
| # | 축 | 왼쪽 (0) | 오른쪽 (1) | 이 축으로 갈리는 것 | 큰 축 (README 5장) |
|---|---|---|---|---|---|
| 1 | 기억하는가 | LLM API (무상태) | 앱의 대화 기록 (상태) | 매 요청에 무엇을 다시 실어야 하는가 | ⭐⭐ ⑦ 무상태 ↔ 상태 |
| 2 | 기록을 어디에 두는가 | 프로세스 메모리 (dict) | 외부 저장소 (Redis · DB) | 서버를 재시작하면 대화가 사라지는가 | ① 디스크 ↔ 메모리 |
| 3 | 얼마나 다시 보내는가 | 전부 | 일부 (최근 N턴 · 요약 · 고정) | 정확도 ↔ 토큰 · 지연 · 메모리 | 트레이드오프 |
| 4 | 누구의 기억인가 | 한 세션만 | 여러 세션이 섞임 | 정상 ↔ 개인정보 사고 | ⑦의 파생 — 누구의 상태인가 (인가, 25차시 1.2) |
앞에서 이어집니다 — 8차시에서 Ollama 챗봇을 만들 때
messages리스트에 질문과 답을 계속 append해서 통째로 보냈습니다. "왜 매번 전부 보내지?"라는 질문을 남겨 두었는데, 이 차시가 그 답입니다. 축 ⑦이 이 과정에서 가장 높이 올라오는 자리 입니다.
1. ⭐⭐ LLM API는 무상태, 기억은 앱이 진다
1.1 실험으로 보기
요청 1 messages=[user: "주문번호는 20261128-001이에요"] → "확인했습니다"
요청 2 messages=[user: "그 주문 언제 와요?"] → "어느 주문인지 알려 주세요"
요청 2' messages=[user: "주문번호는 20261128-001이에요",
assistant: "확인했습니다",
user: "그 주문 언제 와요?"] → 주문번호를 알고 답함
요청 2는 요청 1을 모릅니다. 요청 2'가 맥락을 아는 이유는 모델이 기억해서가 아니라 앱이 앞의 대화를 다시 실어 보냈기 때문 입니다.
대화 기록 (Conversation History) — [LLM 입력의 일부]인데, [ 앱이 저장해 두었다가 매 요청의 messages 배열에 다시 실어 보내는 이전 턴들 ]이라는 점에서 가중치에 학습된 지식이나 서버 안의 KV 캐시와 구별된다.
1.2 무상태 — 웹에서 흔한 구조
무상태 (Stateless) — [서버의 동작 방식]인데, [ 이전 요청을 기억하지 않고 이번 요청에 담긴 내용만 보고 응답을 정한다 ]는 점에서 이전 요청의 결과를 기억해 두었다가 다음 응답에 쓰는 상태 (Stateful) 방식과 구별된다.
무상태로 만드는 이유는 서버를 늘리기 쉬워서 입니다. 어느 서버가 요청을 받아도 같은 답을 내므로, 서버를 두 대 · 열 대로 늘려도 "그 손님은 1번 서버만 기억한다" 같은 문제가 없습니다. 대신 대가가 있습니다.
| 예 | 무상태인 곳 | 상태(기억)를 들고 있는 곳 | 무상태의 대가 |
|---|---|---|---|
| HTTP · REST API | 요청 하나하나 — 서버는 앞 요청을 모름 | 클라이언트(브라우저) · DB | 매 요청에 필요한 정보를 다 담아야 함 |
| JWT 로그인 | 서버 — 토큰의 서명만 검사 | 토큰을 들고 다니는 클라이언트 | 출입증을 매 요청마다 내밀어야 함 |
| 이 차시 | LLM API | 앱의 대화 저장소 | 기록을 매 요청마다 전부 보냄 → 토큰 · TTFT · KV 증가 |
💡 JWT (JSON Web Token) — 로그인에 성공하면 서버가 서명해서 내주는 출입증 입니다. 서버는 누가 로그인했는지 따로 적어 두지 않고, 요청이 올 때마다 출입증의 서명만 확인합니다. 입구 경비가 얼굴을 기억하지 못하니 들어갈 때마다 출입증을 보여 주는 것 과 같습니다. (README 6장 — 이 "토큰"은 LLM의 토큰과 같은 철자, 다른 범주 입니다.)
⭐ 셋을 겹치면 한 문장이 남습니다 — 무상태인 것은 통신 구간(서버의 처리 한 번)뿐이고, 기억은 시스템 어딘가에 반드시 있다. LLM 서버는 입력만으로 출력이 정해지는 쪽 이고, 대화 저장소를 가진 앱이 기억을 지는 쪽 입니다.
⚠️ KV 캐시는 대화 기억이 아닙니다. KV 캐시는 요청 하나를 처리하는 동안 의 계산 결과이고 요청이 끝나면 반납됩니다(9차시). 프리픽스 캐시(21차시)가 앞부분을 재사용해 주기도 하지만, 그것은 성능을 위한 캐시 일 뿐 앱이 기록을 안 보내도 되는 장치가 아닙니다.
1.3 반례 — "ChatGPT는 기억하던데요?"
ChatGPT 앱은 LLM을 탑재한 서비스 입니다(1차시 1.3). 모델을 뇌 , 그 둘레의 앱을 몸 에 비유하면, 기억하는 것은 몸(앱) 이고, 뇌(모델)는 매번 앱이 넣어 준 글을 처음 읽습니다. 우리가 만드는 문의봇 앱이 바로 그 "몸"입니다.
2. messages의 네 역할
| role | 누가 쓰나 | 담는 것 | 문의봇 예 |
|---|---|---|---|
| system | 앱 (개발자) | 역할 · 규칙 · 출력 형식 | prompts/draft.md의 내용 (28차시) |
| user | 상담원 | 이번 요청 | "환불 규정을 넣어서 다시 써 줘" |
| assistant | 모델 → 앱이 저장해 다시 보냄 | 이전 답 | 직전 초안 |
| tool | 앱 (도구 실행 결과) | 도구가 돌려준 값 | 주문 조회 결과 JSON (30차시) |
🔍 엔진은 이 배열을 채팅 템플릿 으로 한 줄의 긴 글로 바꿔 모델에 넣습니다. Qwen 계열이면 대략 <|im_start|>system ... <|im_end|><|im_start|>user ... 같은 모양입니다. 모델이 보는 것은 역할 표시가 붙은 글 하나 입니다. 템플릿은 모델마다 다르고, GGUF 메타데이터나 tokenizer_config.json에 들어 있습니다.
⚠️ assistant 메시지는 앱이 마음대로 넣고 뺄 수 있습니다. 기록 편집이 가능하다는 것은 4절 전략의 근거이자, 기록이 섞이면 남의 대화가 내 대화처럼 들어간다는 위험(5.2)의 근거이기도 합니다.
문의봇의 대화 단위 : 문의봇은 잡담 챗봇이 아닙니다. 대화 하나 = 상담원 한 명 × 문의 한 건 입니다. "더 짧게", "사과 문장 빼고", "주문 상태 확인해서 다시" 같은 후속 요청이 같은 대화에 쌓입니다.
3. 토큰 예산
3.1 ⭐ 예산식
system + 대화 기록 + 이번 질문 + 출력 여유 ≤ 컨텍스트 길이 (Ollama num_ctx / vLLM --max-model-len)
2차시 3절의 식에서 "출력 여유"를 따로 떼어 둔 것 이 핵심입니다. 문의봇 숫자로 넣어 보면(예시, 실측 아님):
| 항목 | 토큰 | 비고 |
|---|---|---|
| 컨텍스트 길이 | 8,192 | 19차시에서 정한 --max-model-len |
| system | 600 | 28차시 프롬프트 + few-shot |
| 이번 질문 (문의 본문 포함) | 1,500 | 긴 메일 한 통 |
| 출력 여유 | 800 | 초안 최대 길이 = max_tokens |
| 대화 기록에 쓸 수 있는 몫 | 5,292 | 8,192 − 600 − 1,500 − 800 |
| 토큰을 세는 법 | 정확도 |
|---|---|
직전 응답의 usage.prompt_tokens | 가장 정확 (엔진이 센 값) |
| 같은 모델의 토크나이저로 세기 | 정확 (2차시 실습) |
| 글자 수로 근사 | ⚠️ 한국어는 크게 틀림 — 2차시에서 본 대로 언어마다 토큰 수가 다름 |
⚠️ 출력 여유를 빼먹으면 입력이 컨텍스트를 거의 다 채워 답이 몇 토큰 만에 잘리거나 (finish_reason="length", 25차시 4.1) vLLM이 400 에러 를 냅니다. Ollama라면 앞부분이 조용히 잘릴 수 있습니다(13차시).
3.2 대화가 길수록 비싸다
턴마다 앞의 기록 전체를 다시 보내므로 입력이 계속 자랍니다.
| 늘어나는 것 | 이유 | 앞 차시 |
|---|---|---|
| TTFT | 입력 전체를 Prefill해야 첫 글자가 나옴 | 2, 14차시 |
| 요청당 KV 캐시 | 토큰 수에 비례. Qwen3-8B FP16 ≈ 144KB/토큰 → 기록 5,000토큰이면 요청 하나에 ≈ 0.7GB (이론값) | 9차시 |
| 동시 처리 한도 | 요청당 KV가 커지면 vLLM 로그의 Maximum concurrency가 줄어듦 | 19, 21차시 |
💡 기록을 뒤에 붙이기만(append) 하면 매 요청의 앞부분이 같으므로 vLLM 프리픽스 캐시가 재사용됩니다(21차시). 반대로 앞부분을 요약하거나 잘라 내면 앞이 바뀌어 재사용이 깨집니다. 4절 전략을 고를 때 이 대가도 같이 봅니다.
4. 기록 관리 전략
| 전략 | 방법 | 장점 | 단점 |
|---|---|---|---|
| 전부 보내기 | 기록을 그대로 | 정확, 프리픽스 캐시 유리 | 길어지면 예산 초과 · 느려짐 |
| 최근 N턴 (슬라이딩 윈도) | 마지막 N개 턴만 | 단순, 예산 예측 쉬움 | ⚠️ 앞부분을 잊음 — 첫 턴의 주문번호가 사라짐 |
| 요약 | 오래된 턴을 LLM으로 요약해 한 메시지로 | 긴 대화 유지 | LLM 호출이 하나 더, 요약에서 빠진 사실은 영영 소실 |
| 중요 정보 고정 | 주문번호 · 고객명 · 분류 같은 핵심을 앞에 고정 | 핵심 사실 보존 | 무엇이 중요한지 미리 정해야 함 |
⭐ 문의봇의 조합 : 29차시에서 뽑은 추출 결과(고객명 · 주문번호 · 요청사항)를 system 안에 고정 + 최근 N턴 + 예산을 넘으면 오래된 턴부터 버림.
def build_messages(system, pinned_facts, history, question, budget):
head = [{"role": "system", "content": f"{system}\n\n# 확정된 문의 정보\n{pinned_facts}"}]
tail = [{"role": "user", "content": question}]
kept = []
for turn in reversed(history): # 최근 턴부터 거꾸로 담는다
if count_tokens(head + [turn] + kept + tail) > budget:
break
kept.insert(0, turn)
return head + kept + tail
🔍 읽는 포인트
reversed(history)— 최근 것부터 담다가 예산을 넘으면 멈춥니다. 버려지는 것은 오래된 턴입니다.- system과 고정 정보(
head)는 절대 버리지 않습니다. 둘을 system 메시지 하나로 합친 이유 — 모델에 따라 채팅 템플릿이 system을 맨 앞 하나만 받습니다. budget= 컨텍스트 길이 − 출력 여유. 3.1절의 식이 그대로 코드가 된 것입니다.
⚠️ 30차시의 도구 호출이 들어오면 assistant의 tool_calls 메시지와 그 결과인 tool 메시지를 짝으로 버려야 합니다. 하나만 남으면 엔진이 에러를 내거나 모델이 혼란스러워합니다.
5. 저장 위치와 세션 분리
5.1 어디에 둘 것인가
| 저장 위치 | 장점 | 단점 | 맞는 곳 |
|---|---|---|---|
프로세스 메모리 (dict) | 빠름, 설치 없음 | 재시작하면 소멸 . 워커가 여러 개면 워커마다 다른 기록 → "가끔 기억을 잃어요" | 개발 · 실습 |
| Redis | 빠름, 여러 워커 · 서버가 공유, 만료(TTL) 지정 | 서버 하나 더 운영 | 운영 중 대화 세션 |
| DB | 영속, 상담 이력으로 남음 | 상대적으로 느림 | 이력 보존 · 감사가 필요할 때 |
1.2절의 무상태 이유와 같은 이야기입니다 — 앱 서버의 메모리에 상태를 두면 서버(워커)를 늘리는 순간 깨집니다. 그래서 상태는 Redis(단기, 빠른 메모리 저장소) 나 DB(영속) 처럼 앱 바깥에 맡깁니다. 축 ①로 보면 프로세스 메모리는 휘발 , Redis · DB는 프로세스 바깥 입니다.
💡 25차시 구조에 그대로 꽂힙니다. 대화 저장소도 아웃바운드 포트 (ConversationStorePort)이고, InMemoryStore · RedisStore가 어댑터입니다. 개발은 dict, 운영은 Redis — 설정만 바꿔서 .
⚠️ 대화 기록에는 고객 개인정보가 그대로 들어 있습니다. 보관 기간(TTL) 을 정하고, 기록을 볼 수 있는 사람을 제한합니다.
5.2 ⚠️ 세션이 섞이면 개인정보 사고
AI가 만든 첫 버전에 자주 나오는 코드입니다.
history = [] # ❌ 모듈 전역 — 모든 상담원이 같은 리스트를 씀
sessions: dict[str, list] = {} # ⭕ 세션 키별로 따로
key = f"{agent_id}:{inquiry_id}" # 상담원 × 문의
🔍 읽는 포인트 — 첫 줄은 혼자 테스트할 때는 완벽하게 동작합니다. 상담원 두 명이 동시에 쓰는 순간 A 고객의 이름과 주문번호가 B 상담원의 초안에 들어갑니다. 혼자 테스트해서는 절대 안 보이는 버그입니다.
| 지킬 것 | 근거 |
|---|---|
| 세션 키는 서버가 로그인한 상담원 ID로 만든다 | 클라이언트가 보낸 세션 ID를 그대로 믿지 않음 |
| 남의 세션 키로 요청하면 403 | 앞에서 배운 인가 — "이것을 해도 되냐" (25차시 1.2) |
| 검수는 두 계정으로 동시에 | 혼자 테스트로는 안 잡힘 (6절) |
5.3 ⚠️ 번역어 주의 — 두 개의 "컨텍스트"
| 이 차시의 Context 관리 | 코딩 에이전트의 컨텍스트 엔지니어링 | |
|---|---|---|
| 다루는 것 | 요청 하나의 messages 배열 | 에이전트가 반복해서 읽을 md 파일 |
| 단위 | 토큰 | 파일 |
| 질문 | 이번 요청에 무엇을 실을까 | 무엇을 기억시킬지 설계 |
| 공통점 | 둘 다 "모델은 기억하지 못하니 밖에서 넣어 준다" (축 ⑦) |
컨텍스트 엔지니어링 은 코딩 에이전트(코드를 맡기는 AI 도구)에서 쓰는 말로, 모델이 작업할 때마다 읽을 규칙 · 기억 파일(md)을 설계하는 일 입니다. 에이전트가 새 대화를 열어도 프로젝트 규칙을 "기억"하는 것처럼 보이는 이유가 이것입니다. 기억은 모델이 아니라 디스크의 메모 파일 에 있고, 에이전트가 시작할 때 그 파일을 읽어 입력에 넣습니다.
6. 실습 — 맡기고 검수하기
지시
문의봇 FastAPI 앱에 "초안 수정 대화" 기능을 추가해줘.
- 세션 키는 서버에서 f"{로그인한 상담원ID}:{문의ID}" 로 만든다. 남의 세션이면 403.
- 대화 저장소는 app/ports/output/conversation_store_port.py 포트 + InMemoryStore, RedisStore 어댑터 (설정으로 선택)
- 메시지 조립: system + 고정 정보(고객명·주문번호·요청사항) + 최근 턴 + 이번 질문
예산 = 컨텍스트 길이(설정값) − 출력 여유(max_tokens). 넘으면 오래된 턴부터 버린다. system·고정 정보는 버리지 않는다.
- 토큰 수는 직전 응답의 usage.prompt_tokens 와 토크나이저 계산을 둘 다 로그에 남긴다.
- 모듈 전역 리스트에 대화를 저장하지 말 것.
맥락: openai 파이썬 라이브러리, FastAPI, redis-py 공식 문서. 추측하지 말고 실행 출력 원문을 붙여.
보고 형식: [턴 | prompt_tokens | 버린 턴 수 | TTFT(ms)] 표 (12턴까지).
받을 증거
- 12턴 대화의 턴별 표와 로그 원문 (턴이 늘수록
prompt_tokens가 자라다가 예산 근처에서 멈추는가) - 상담원 A · B 두 계정으로 동시에 다른 문의를 대화한 로그
- B 계정으로 A의 세션 키를 넣어 요청했을 때의 HTTP 상태
- 서버 재시작 전후로 대화가 이어지는지 (InMemory ↔ Redis 각각)
내가 직접 볼 것
| 볼 것 | 이걸로 아는 것 |
|---|---|
| B의 초안에 A 고객 이름이 한 번이라도 나오는가 | 세션 분리 — 나오면 즉시 중단할 사고 |
| 12턴 뒤에도 첫 턴의 주문번호 를 답에 쓰는가 | 고정 정보 전략이 작동하는가 |
| 턴별 TTFT가 기록 길이와 함께 늘다가 멈추는가 | 3.2 — 예산이 실제로 지켜지는가 |
| 재시작 후 InMemory는 끊기고 Redis는 이어지는가 | 축 ① — 기록이 어디에 사는가 |
grep -n "^history\s*=" -r . 결과 | 전역 리스트가 남아 있지 않은가 |
부록 A. 스스로 확인하기
| # | 질문 | 답이 있는 곳 |
|---|---|---|
| 1 | 요청 2'가 주문번호를 아는 이유는 모델이 기억해서인가? | 1.1 |
| 2 | 무상태를 유와 종차로 정의하면? LLM API 외에 무상태인 예 두 가지와 그 대가는? | 1.2 |
| 3 | KV 캐시가 대화 기억이 아닌 이유는? | 1.2 |
| 4 | messages의 네 역할과 각각을 쓰는 주체는? | 2 |
| 5 | 토큰 예산식을 쓰고, 출력 여유를 빼먹으면 생기는 일은? | 3.1 |
| 6 | 대화가 길수록 늘어나는 세 가지는? | 3.2 |
| 7 | 최근 N턴 전략의 약점과 문의봇의 보완책은? | 4 |
| 8 | 대화 기록을 프로세스 메모리에 두면 생기는 두 가지 문제는? | 5.1 |
| 9 | 혼자 테스트하면 안 보이는 세션 사고는 어떤 코드에서 나오나? | 5.2 |
부록 B. 자주 하는 실수
| 실수 | 바로잡기 |
|---|---|
| "LLM 서버가 대화를 기억해 준다" | ❌ LLM API는 무상태 . 기억은 앱이 다시 실어 보내는 것 |
| "KV 캐시가 있으니 기록을 안 보내도 된다" | ❌ KV 캐시는 요청 하나 동안의 계산 결과. 대화 기억이 아님 |
| 예산에서 출력 여유를 빼먹음 | ⚠️ 답이 잘리거나 400 에러, Ollama는 조용히 잘림 |
| 글자 수로 토큰 예산 계산 | ❌ 한국어는 크게 틀림. usage 또는 토크나이저로 |
| 최근 N턴만 남겨 주문번호를 잊음 | ⚠️ 핵심 사실은 고정 |
| 대화를 모듈 전역 리스트에 저장 | ❌ 상담원 간 대화가 섞임 = 개인정보 사고 |
| 혼자 테스트하고 "세션 분리 확인 완료" | ⚠️ 두 계정 동시 테스트로만 확인됨 |
부록 C. NotebookLM 복습 프롬프트
이 27차시 노트만 근거로 6문제를 내줘. 비전공자 기준으로.
"LLM API는 무상태이고 기억은 앱이 진다", "토큰 예산식", "최근 N턴 전략의 약점", "세션이 섞이는 사고"는 반드시 포함해줘.
답은 내가 답한 다음에 채점하고, 틀린 이유를 노트의 절 번호를 근거로 설명해줘.
LangTailor