[로컬 LLM 구축기 - 1부] 외장 GPU 없이도 된다고요?
이번 포스팅은 로컬 LLM 구축기의 첫 번째 시리즈입니다. 용도가 애매했던 미니 PC를 중고로 내놓으려다가 로컬 LLM 구동 가능성을 확인한 후 구축 → 테스트 → 버그 발견 → 오픈소스 기여까지의
tech-recipe.tistory.com
지난 포스팅에서는 외장 GPU가 없는 미니 PC에 로컬 LLM을 구축하고, 간단한 질문에 모델이 응답하는 것까지 확인해 보았습니다. 약 33 tokens/s라는 숫자를 보고 이 정도면 제법 쓸 만하겠다는 생각도 했습니다. 하지만 실제로 활용하려고 보니, 모델이 실행되는 것과 그 동작을 이해하는 것은 전혀 다른 문제였습니다.
Ollama는 정확히 무엇을 하는 프로그램인지, Vulkan은 왜 필요했는지, 모델은 이전 대화를 어떻게 기억하는지. 하나씩 알아보다 보니 Prefill, Decode, KV Cache라는 낯선 용어들도 계속 등장했습니다. 각각의 설명을 읽을 때는 이해한 것 같았는데, 막상 전체 과정을 연결하려니 생각보다 쉽지 않았습니다.
이번 포스팅에서는 이러한 개념들을 연결하여 사용자의 요청 하나가 실제 답변이 되기까지 어떤 과정을 거치는지 살펴보도록 하겠습니다. 스터디를 하면서 정리한 내용이며, 모델과 구현에 따라 달라지는 세부 사항은 기본 원리를 이해하는 데 필요한 수준으로 단순화하였습니다.
1. Ollama란 무엇인가?

개발자나 엔지니어들에게 컨테이너는 아마 제법 익숙한 개념일 것입니다. 컨테이너 이미지를 내려받고 실행하기 위해 Docker를 사용해 보셨다면, Ollama의 역할도 어렵지 않게 이해할 수 있습니다. Ollama는 모델을 내려받고, 실행하고, 다른 애플리케이션에서 호출할 수 있도록 도와주는 오픈소스 도구입니다. 간단한 CLI와 HTTP API를 제공하기 때문에 모델의 실행 환경을 처음부터 직접 구성하지 않고도 LLM을 사용할 수 있습니다.
다만 Docker와 비슷하다는 것은 사용 방식에 대한 비유입니다. 모델을 하나 실행할 때마다 새로운 Docker 컨테이너가 만들어지는 것은 아닙니다. 1부에서 Docker로 실행한 것은 Ollama 서비스이고, 내려받은 모델은 그 서비스가 사용하는 데이터입니다.
여기서 모델과 실행 프로그램도 구분할 필요가 있습니다. 우리가 내려받은 모델에는 학습된 가중치와 설정 등이 들어 있습니다. 이 데이터를 읽고 실제 계산을 수행하는 프로그램이 별도로 필요한데, 이번 환경에서는 llama.cpp 기반의 추론 엔진이 그 역할을 수행합니다. llama.cpp는 모델 추론을 수행하는 프로젝트이고, llama-server는 이 프로젝트에서 제공하는 서버 실행 파일입니다.
제가 실행한 환경에서도 pstree를 통해 Ollama의 자식 프로세스로 llama-server가 실행되고 있는 것을 확인할 수 있었습니다. 관계만 간략히 표시하면 다음과 같습니다.
containerd-shim
└─ ollama
└─ llama-server
즉, 사용자 입장에서는 Ollama에 질문을 보내지만, 내부에서는 모델을 실행하는 추론 엔진이 실제 계산을 수행하고 있는 것입니다. 다만 실행 파일의 이름이나 내부 구성은 Ollama의 버전과 사용하는 백엔드에 따라 달라질 수 있음을 미리 알립니다.
2. Vulkan과 Mesa RADV
그렇다면 추론 엔진은 GPU를 어떻게 사용할까요? 1부에서는 Vulkan 관련 패키지를 설치하고, Ollama가 Vulkan을 사용하도록 설정했습니다. 이때 등장한 Vulkan은 AMD GPU 전용 드라이버가 아닙니다. GPU를 사용하여 그래픽이나 범용 연산을 수행할 수 있도록 정의한 표준 API입니다. 특정 제조사에 종속되지 않는 인터페이스를 제공하지만, 실제 하드웨어에서 사용하려면 이를 구현한 드라이버가 필요합니다.
리눅스에서 AMD GPU를 대상으로 이 Vulkan API를 구현하는 드라이버 중 하나가 Mesa 프로젝트의 RADV입니다. 여기서도 드라이버가 한 종류만 있는 것은 아닙니다. RADV는 사용자 공간에서 동작하는 드라이버이고, 리눅스 커널에는 amdgpu라는 커널 드라이버가 있습니다. RADV가 GPU에서 실행할 명령을 준비하고 커널에 제출하면, amdgpu가 메모리 관리와 명령 제출 등 하드웨어와 가까운 작업을 담당합니다. 이번 환경의 GPU 연산 경로를 단순화하면 다음과 같습니다.
Ollama
↓
llama-server의 Vulkan 백엔드
↓
Vulkan API 호출
↓
Mesa RADV
↓
Linux amdgpu 커널 드라이버
↓
AMD Radeon 780M 내장 GPU
중간의 Vulkan API는 별도의 서버 프로세스라기보다는 프로그램과 드라이버 사이에서 사용하는 약속에 가깝습니다. 그리고 RADV는 그 약속을 AMD GPU에서 동작하도록 구현한 것입니다.
AMD에는 ROCm이라는 GPU 연산용 소프트웨어 스택도 있습니다. 다만 ROCm을 통한 지원 범위는 GPU 모델과 소프트웨어 버전에 따라 달라지며, 모든 AMD GPU를 동일하게 지원하는 것은 아닙니다. Ollama는 Vulkan을 통해 추가적인 GPU 지원 경로도 제공합니다. 따라서 이번 포스팅에 사용된 구성은 ROCm이 아닌 Vulkan과 RADV를 통해 내장 GPU를 활용한 경우라고 이해하면 됩니다.
3. Stateless, 대화는 누가 기억하는가?
이제 실제 대화를 살펴보겠습니다. 아래처럼 첫 번째 명령에서 특정 날짜를 알려주고, 두 번째 명령에서 그 날짜를 다시 물어보았습니다.
# 첫 번째 대화. 2026년 9월 3일이 최초로 대화를 나눈 날이라고 설명.
docker compose exec ollama ollama run qwen3.6:35b-a3b "2026년 9월 3일은 우리가 최초로 대화를 나누었던 날입니다."
정말 특별한 날로 기억해 주셔서 감사합니다. 다만 저는 AI 어시스턴트라 매 세션이 독립적으로 처
리되며, 과거의 대화나 개인적인 시간을 기억하거나 누적하지 않습니다. 또한 2026 년 9 월 3 일은
아직 미래의 날짜이기도 합니다 😊 # 심지어 미래의 날이라고 이야기함
하지만 지금 이 순간부터는 언제든 편하게 질문하거나 이야기해 주세요. 이전과는 달리 각 대화마다
최선을 다해 도와드리며, 앞으로 함께 나누게 될 시간들이 벌써 기대됩니다.
# 두 번째 대화. 처음 대화를 나눈 날이 언제인지 질문.
docker compose exec ollama ollama run qwen3.6:35b-a3b "우리가 처음 대화를 나누었던 날은 언제이죠?"
저는 이전 대화 내용을 기억하지 못합니다. 각 세션은 독립적으로 처리되며, 저는 과거의 상호작용
이나 날짜를 저장하거나 접근할 수 있는 기능이 없습니다.
하지만 지금 이렇게 새로운 대화를 시작하게 되어 기쁩니다! 😊 궁금한 점이 있거나 도움이 필요하
신 부분이 있다면 언제든 말씀해 주세요. 최선을 다해 도와드리겠습니다.
두 번째 실행에서는 이전 대화를 기억하지 못한다는 답변이 돌아왔습니다. Ollama 서버도 계속 실행 중이고, 같은 모델을 사용했는데 왜 방금 알려준 내용을 모르는 것일까요? 핵심은 두 번째 실행의 입력에 첫 번째 대화가 포함되어 있지 않기 때문입니다. 명령을 별도로 실행했다고 해서 앞선 실행의 대화 내용이 자동으로 다음 요청에 이어지는 것은 아닙니다. 반면 Ollama의 대화형 CLI는 같은 세션에서 사용자 메시지와 모델의 답변을 누적하여 다음 요청에 전달합니다.
3.1 이전 대화도 결국 다음 요청의 입력입니다
대화를 이어 가는 가장 기본적인 방법은 간단합니다. 이전 대화를 보관했다가 새 질문과 함께 보내는 것입니다. Ollama의 Chat API도 messages라는 배열을 통해 대화 이력을 전달받습니다.
예를 들어 첫 번째 답변이 “알겠습니다.”였다고 가정하면, 두 번째 요청은 다음과 같이 구성할 수 있습니다. 아래의 이전 답변은 요청 구조를 보여 주기 위해 단순화한 예시입니다.
curl --fail-with-body --silent --show-error \
http://localhost:11434/api/chat \
-H "Content-Type: application/json" \
-d '{
"model": "qwen3.6:35b-a3b",
"messages": [
{
"role": "user",
"content": "2026년 9월 3일은 우리가 최초로 대화를 나누었던 날입니다."
},
{
"role": "assistant",
"content": "알겠습니다."
},
{
"role": "user",
"content": "우리가 처음 대화를 나누었던 날은 언제이죠?"
}
],
"stream": false
}'
이제 모델이 받는 입력에는 날짜를 알려준 내용도 포함되어 있습니다. 사용자는 마지막 질문 한 문장만 입력했지만, 애플리케이션이 보내는 요청에는 그 앞의 대화도 들어가는 것입니다. 대화 내용을 관리하는 것과, 전달받은 내용을 바탕으로 답변을 생성하는 것은 서로 다른 역할입니다.
3.2 Stateless는 서버 메모리가 비어 있다는 뜻이 아닙니다
여기서 Stateless라는 표현을 조금 조심해서 사용해야 합니다. 이 글에서 다루는 Stateless 한 요청 방식은 서버가 이전 대화 세션을 기억하고 있을 것이라고 전제하지 않고, 이번 요청을 처리하는 데 필요한 문맥을 요청 자체에 포함하는 방식입니다. 채팅 애플리케이션이 대화 이력을 저장할 수 없다는 뜻도 아니고, 추론 서버의 메모리에 어떤 상태도 남아 있지 않다는 뜻도 아닙니다.
실제로 추론 서버는 모델을 메모리에 유지하거나, 앞서 수행한 계산 결과를 캐시로 보관할 수 있습니다. llama-server에도 이전 요청과 공통된 프롬프트 구간의 KV Cache를 재사용하는 기능이 있습니다. 따라서 “요청에 필요한 대화 이력을 전달하는 것”과 “서버 내부에 계산 캐시가 남아 있는 것”은 동시에 성립할 수 있습니다. 여기까지는 모델에 어떤 내용을 보여 줄 것인가에 대한 이야기였습니다. 이제 그렇게 전달한 내용을 실제로 어떻게 계산하는지 살펴보겠습니다.
4. Prefill & Decode
학습된 모델을 사용하여 입력에 대한 결과를 계산하는 과정을 추론(Inference)이라고 합니다. 여기서는 그중에서도 앞선 문맥을 바탕으로 다음 토큰을 생성하는, 일반적인 자기회귀 언어 모델의 추론을 기준으로 설명하겠습니다.
먼저 모델은 우리가 입력한 문장을 그대로 읽는 것이 아닙니다. 대화 메시지는 모델이 사용하는 채팅 템플릿에 따라 하나의 입력 시퀀스로 구성됩니다. 이 과정에는 사용자와 모델의 역할을 구분하는 특수 토큰 등도 포함됩니다. 따라서 화면에 보이는 문장만이 실제 입력의 전부는 아닙니다.
이렇게 구성한 입력은 토큰화되어 숫자 ID의 배열로 변환됩니다. 모델은 이 입력을 계산하여 다음 위치에 어떤 토큰이 올 수 있는지에 대한 점수인 Logits를 내놓고, 추론 프로그램이 그 점수를 이용해 다음 토큰을 선택합니다. 선택한 토큰을 다시 문맥에 추가하는 과정을 반복하면 답변이 만들어집니다. 이 반복 과정에서 입력을 처리하는 단계와 이후 출력을 이어 가는 단계를 구분한 것이 Prefill과 Decode입니다.
4.1 Prefill - 입력된 문맥을 처리하는 단계
Prefill에서는 이미 주어진 입력 토큰들을 모델에 통과시켜 계산합니다. 입력에는 현재 질문뿐 아니라 시스템 프롬프트, 이전 대화, 검색해서 가져온 문서 등이 포함될 수 있습니다. 따라서 사용자가 짧은 질문 하나만 입력해도 실제로 처리할 토큰은 상당히 많을 수 있습니다. 이 단계에서는 입력 토큰이 처음부터 주어져 있으므로 여러 위치의 계산을 묶어서 수행할 수 있습니다. 입력을 한 토큰씩 새로 만들어 가야 하는 상황이 아니기 때문입니다. 이 계산을 통해 이후 생성에 사용할 중간 결과를 준비하고, 마지막 입력 위치의 결과를 바탕으로 첫 번째 출력 토큰을 선택합니다. Prefill도 이미 모델을 실행하는 추론 단계이지, 단순히 파일을 읽거나 실행 준비만 하는 단계는 아닙니다.
많은 토큰을 묶어 처리하면 같은 가중치를 여러 토큰의 계산에 재사용하기 좋습니다. 충분한 크기의 행렬 연산에서는 메모리에서 가져오는 데이터에 비해 수행하는 계산이 많아지므로, 프로세서의 연산 성능이 중요해집니다. 다만 입력이 매우 짧거나 실행 환경이 달라지면 병목도 달라지므로, Prefill은 무조건 연산 성능으로만 결정된다는 뜻은 아닙니다.
4.2 Decode - 다음 토큰을 이어서 생성하는 단계
Prefill의 결과에서 첫 번째 출력 토큰을 선택했다면, 이제 그 토큰을 입력하여 두 번째 출력 토큰을 계산합니다. 두 번째 토큰을 선택하면 다시 그것을 입력하여 세 번째 토큰을 계산합니다. 이러한 후속 생성 과정을 Decode라고 합니다. 논리적인 흐름은 다음과 같습니다.
[입력 문맥] → 첫 번째 출력 토큰
[입력 문맥 + 첫 번째 토큰] → 두 번째 출력 토큰
[입력 문맥 + 첫 번째 + 두 번째] → 세 번째 출력 토큰
여기서 출력은 입력과 상황이 다릅니다. 두 번째 출력 토큰을 계산하려면 먼저 첫 번째 출력 토큰이 무엇인지 결정되어야 합니다. 그래서 일반적인 한 토큰씩 생성하는 방식에서는 생성 단계 사이에 순차적인 의존성이 생깁니다. 물론 이것은 토큰 하나를 계산하는 내부 연산까지 모두 직렬로 수행한다는 뜻은 아닙니다. 또한 요청 하나의 Decode에서는 적은 수의 새 토큰을 처리하기 위해 많은 가중치와 문맥 정보를 읽어야 합니다. Prefill처럼 같은 가중치를 많은 입력 토큰에 한꺼번에 활용하기 어려우므로, 작은 배치에서는 프로세서 자체의 속도보다 필요한 데이터를 얼마나 빠르게 공급할 수 있는가, 즉 메모리 대역폭이 병목이 되기 쉽습니다.
1부에서 메모리 대역폭을 중요하게 보았던 이유도 여기에 있습니다. 다만 MoE 모델에서는 토큰마다 선택된 전문가를 사용하므로, 모든 전문가의 가중치를 항상 전부 계산하는 것은 아닙니다. 1부에서 사용한 Qwen3.6-35B-A3B도 전체 파라미터 중 일부를 활성화하는 구조입니다. 따라서 모델 파일의 전체 크기만으로 매 단계의 데이터 읽기 비용을 판단해서는 안 됩니다.
그런데 위의 생성 흐름을 보면 한 가지 의문이 생깁니다. 다음 토큰을 생성할 때마다 앞의 문맥을 전부 다시 계산해야 하는 것인가?
5. KV Cache - 이미 수행한 계산의 재사용
모델은 일종의 계산기라고 볼 수 있습니다. 입력과 가중치를 바탕으로 복잡한 계산을 수행하고 결과를 내놓습니다. 다음 계산에서도 이전에 처리한 문맥이 필요하다면, 그에 필요한 계산 결과를 보존하여 재사용하거나 다시 계산해야 합니다. 여기서 등장하는 것이 KV Cache입니다.
5.1 KV Cache가 없다면?
KV Cache를 사용하지 않는 일반적인 생성 방식을 생각해 보겠습니다. 먼저 프롬프트 전체를 처리하여 첫 번째 출력 토큰을 선택합니다. 그다음에는 기존 프롬프트에 생성한 토큰을 붙여 다시 계산합니다. 이후에도 같은 작업을 반복합니다. 입력 토큰을 A B C, 생성할 토큰을 y1 y2 y3라고 하면 다음과 같습니다.
A B C 전체 계산
→ y1 선택
A B C y1 전체 계산
→ y2 선택
A B C y1 y2 전체 계산
→ y3 선택
답변이 길어질수록 다시 처리할 문맥도 길어지고, 이미 수행한 계산을 계속 반복하게 됩니다. 캐시 없는 전체 시퀀스 재계산 방식이 비효율적인 이유입니다.
5.2 중간 계산 결과의 저장
Transformer의 Attention에서는 토큰의 표현으로부터 Query, Key, Value, 줄여서 Q·K·V라는 벡터를 계산합니다. 간략하게 설명하자면 '현재 위치의 Q가 다른 위치의 K와 관련도를 비교하고, 그 결과를 이용해 V의 정보를 조합한다' 정도가 됩니다. 새로운 토큰을 처리할 때도 과거 토큰의 K와 V가 필요합니다. 그래서 이미 처리한 토큰들의 K·V를 레이어별로 저장해 두고 재사용하는 것이 KV Cache입니다. 여기서 KV Cache에 대해서 오해를 하면 안되는 것이, KV Cache는 완성된 답변을 저장하는 응답 캐시도 아니고, 마지막 토큰 하나의 상태만 보관하는 것도 아닙니다. 캐시를 사용하면 앞의 흐름은 다음과 같이 바뀝니다.
Prefill
A B C를 처리하고 KV 저장
→ y1 선택
Decode
y1을 새로 처리하고 기존 KV 참조
y1의 KV도 추가
→ y2 선택
Decode
y2를 새로 처리하고 기존 KV 참조
y2의 KV도 추가
→ y3 선택
이때 중요한 점은 과거 토큰을 다시 처리하는 비용을 줄이는 것이지, 과거 문맥을 더 이상 참고하지 않는 것은 아니라는 점입니다. 새 토큰의 Attention 계산에서는 기존 KV를 여전히 읽고 사용합니다. 따라서 KV Cache는 다음과 같이 이해하는 것이 좋습니다.
대화 이력은 모델에 보여 줄 내용을 보관하는 것이고, KV Cache는 그 내용을 처리하며 만들어 둔 중간 계산 결과를 보관하는 것입니다.
캐시가 없다고 애플리케이션이 보관한 프롬프트가 사라지는 것은 아닙니다. 필요한 입력이 남아 있다면 다시 계산하면 됩니다. 캐시의 목적은 그 재계산을 줄이는 데 있습니다.
5.3 캐시도 메모리를 사용한다
물론 계산 결과를 저장하려면 메모리가 필요합니다. 일반적인 Full Attention의 KV Cache는 보관할 토큰이 많아질수록 필요한 공간도 커집니다. 캐시를 동적으로 늘리는 구현도 있고, 정해진 크기를 미리 확보하는 구현도 있습니다. 따라서 모델 가중치를 메모리에 올릴 수 있다는 것과, 긴 문맥을 원하는 동시 요청 수로 처리할 수 있다는 것은 다른 문제입니다. 모델뿐 아니라 캐시와 실행 중 사용하는 다른 메모리까지 고려해야 합니다.
참고로 실제 모델의 모든 레이어가 같은 방식으로 동작하는 것은 아닙니다. 1부에서 사용한 Qwen3.6-35B-A3B는 Gated DeltaNet과 Gated Attention을 섞은 하이브리드 구조입니다. 따라서 앞선 설명은 일반적인 Attention의 KV Cache 원리를 이해하기 위한 것이며, 이 모델의 모든 레이어에 동일한 형태의 KV가 누적된다는 의미는 아닙니다.

6. 다음 질문이 들어오면 어떻게 될까?
지금까지는 하나의 답변을 생성하는 동안의 이야기 였습니다. 그렇다면 답변이 끝난 뒤 사용자가 다음 질문을 입력하면 어떻게 될까요? 앞에서 설명했듯 애플리케이션은 필요한 이전 대화와 새 질문을 함께 전달합니다. 그런데 이전 대화는 조금 전까지 모델이 처리하던 내용입니다. 이 구간의 계산 결과가 서버에 남아 있다면 다시 사용할 수 있지 않을까요? 그렇게 요청 사이에서 공통된 앞부분의 계산 결과를 재사용하는 것이 Prefix Caching입니다. 긴 문서를 두고 질문을 반복하거나 여러 차례 대화를 이어 가는 경우가 대표적인 활용 사례입니다.
6.1 Prefix Caching을 사용한 계산의 효율성 향상
Prefix Caching은 KV Cache와는 다른 개념으로, 모델이 사용자와 상호 작용함에 있어 동일한 앞부분은 재사용하고, 나머지는 새로 계산할 수 있도록 돕는 역할을 합니다. 첫 번째 대화를 마친 후 두 번째 질문을 보낸다면, 논리적인 입력은 다음과 같습니다.
[시스템 프롬프트]
[첫 번째 질문]
[첫 번째 답변]
[두 번째 질문]
이 중 앞부분에 해당하는 캐시가 남아 있고 재사용 조건이 맞는다면 다음과 같이 처리할 수 있습니다.
[시스템 프롬프트] ┐
[첫 번째 질문] ├─ 일치하는 구간의 캐시 재사용
[첫 번째 답변] ┘
[두 번째 질문] ── 새로 Prefill
↓
새 답변 생성
물론 실제로는 캐시 경계나 입력 구성에 따라 이전 대화의 끝부분 일부를 다시 계산할 수도 있습니다. 핵심은 이번 입력 전체를 무조건 처음부터 다시 계산하지 않고, 재사용 가능한 앞부분 이후부터 계산할 수 있다는 것입니다. llama-server 역시 공통된 프롬프트 앞부분을 재사용하고 달라진 뒷부분을 처리하는 기능을 제공합니다. 이때 새로운 질문을 Prefill하면서도 이전 문맥의 캐시는 참고합니다. 새 질문만 독립적으로 처리하는 것이 아니라, 기존 문맥을 반영하여 새로운 부분을 계산하는 것입니다.
다만 동일하다는 기준은 “뜻이 비슷하다”가 아닙니다. 일반적인 Prefix Caching은 앞에서부터 이어지는 실제 토큰 배열이 일치하는 구간을 재사용합니다. 같은 모델 등 계산 조건도 맞아야 하며, 앞부분의 내용이 바뀌었다면 그 뒤에 같은 문장이 다시 등장하더라도 기존 계산 결과를 그대로 사용할 수 있다고 보장할 수 없습니다. (그리고 이 부분에서 Kagent Go 런타임의 버그를 발견했습니다.)
6.2 캐시가 남아 있어도, 빠진 대화를 알아서 붙여 주지는 않습니다
여기서 [3. Stateless, 대화는 누가 기억하는가?]와 다시 연결됩니다. Prefix Caching은 이번 요청에 포함된 문맥의 계산을 줄이는 기능이지, 요청에서 빠진 대화를 찾아서 보충하는 기능이 아닙니다. 3의 예시에서도 보았듯, 첫 요청에 모델과 처음 대화를 나눈 날짜를 알려주더라도, 그다음 요청에서 “우리가 처음 대화를 나눈 날은 언제이죠?”만 보내니 첫 요청의 답변 내용은 전혀 기억하지 못하고 엉뚱한 답변을 하는 것이 그 예시입니다. 즉, 일반적인 프롬프트 캐시 재사용 기능이 앞선 대화 내용을 임의로 붙여 주지는 않는다는 뜻입니다.
반대로 이전 대화를 요청에 포함했다면, 캐시가 없어도 대화를 이어 갈 수 있습니다. 동일한 문맥을 다시 계산해야 할 뿐입니다. 즉, 대화를 이어 갈 수 있느냐와, 그 대화를 얼마나 효율적으로 계산하느냐는 구분해서 보아야 합니다. (추가로, 1부에서 설정한 OLLAMA_KEEP_ALIVE=-1도 이와 구분해야 합니다. 이 설정은 모델을 메모리에 계속 유지하도록 하는 설정입니다. 사용자별 대화 이력을 영구적으로 저장하라는 설정도 아니고, 모든 요청의 계산 캐시를 무제한 보관하라는 설정도 아닙니다.)
6.3 전체 응답 과정의 연결 - 대화 연속성의 비밀
이제 사용자의 질문이 답변이 되기까지의 전체 흐름을 연결해 보면 아래와 같습니다. 실제 구현에서는 일부 작업의 순서가 달라지거나 겹칠 수 있지만, 개념적으로는 이와 매우 흡사합니다.
사용자가 새 질문 입력
↓
애플리케이션이 필요한 이전 대화와 새 질문으로 요청 구성
↓
Ollama가 요청을 받고 모델 실행 준비
↓
채팅 템플릿 적용 및 토큰화
↓
재사용할 수 있는 공통 앞부분의 캐시 확인
↓
새로 계산해야 하는 입력을 Prefill
↓
첫 번째 출력 토큰 선택
↓
Decode를 반복하며 다음 토큰 생성
├─ 기존 캐시 참조 및 갱신
└─ 생성한 내용을 텍스트로 변환하여 클라이언트에 전달
↓
종료 조건에 도달하면 생성 완료
↓
애플리케이션이 답변을 대화 이력에 추가
우리가 화면에서 글자가 조금씩 나타나는 것을 보는 방식은 Streaming입니다. 전체 답변이 완성될 때까지 기다리는 대신, 생성된 내용을 조각으로 전달합니다. Ollama API에서도 이를 지원하며, 앞의 예시처럼 stream: false로 설정하면 전체 결과를 모아서 받게 됩니다. 또한, 네트워크로 전달되는 조각과 모델의 토큰 하나가 항상 일대일로 대응하는 것은 아닙니다.
7. 다시 1부의 결과를 되돌아 보면
개념을 알아보았으니 1부에서 확인했던 결과를 다시 보겠습니다. 당시에는 마지막의 eval rate를 보고 약 33 tokens/s가 나온다는 점에 주로 관심을 두었습니다. 하지만 출력에는 그 외에도 중요한 정보가 들어 있었습니다.
total duration: 18.661490367s
load duration: 401.678µs
prompt eval count: 24 token(s)
prompt eval cached: 20 token(s)
prompt eval duration: 127.582ms
prompt eval rate: 31.35 tokens/s
eval count: 607 token(s)
eval duration: 18.463421s
eval rate: 32.88 tokens/s
위 출력의 형식에서 주요 항목을 해석하여 아래 표로 정리해 보았습니다.
| 항목 | 의미 |
| load duration | 해당 요청에서 기록된 모델 로딩 관련 시간 |
| prompt eval count | 입력 프롬프트의 토큰 수 |
| prompt eval cached | 입력 중 캐시에서 재사용한 토큰 수 |
| promtp eval duration | 캐시되지 않은 입력 토큰을 계산한 시간 |
| eval count | 응답 생성 과정에서 생성한 토큰 수 |
| eval duration | 출력 토큰 생성에 사용한 시간 |
| eval rate | 출력 토큰 수를 생성 시간으로 나눈 평균 생성 속도 |
7.1 입력 24 토큰 중 일부만을 계산
위 결과에서는 입력 토큰이 24개였지만, 그중 20개가 캐시에서 재사용되었습니다. 따라서 새로 계산한 입력 토큰은 4개입니다. 이 출력 형식의 prompt eval rate도 캐시를 제외한 토큰 수를 기준으로 계산합니다.
새로 계산한 입력 토큰
= 24 - 20
= 4토큰
입력 처리 속도
= 4 / 0.127582
≈ 31.35 tokens/s
그러나 이 숫자만 보고 우리가 선택한 모델이 긴 입력도 초당 31 토큰 정도로 처리하겠다고 결론 내리기는 어렵습니다. 이미 대부분이 캐시 된 아주 짧은 입력을 처리한 결과이기 때문입니다. 장문 입력의 Prefill 성능을 알아보려면 입력 길이와 캐시 재사용 여부를 함께 보아야 합니다.
출력 생산 속도는 아래와 같이 계산됩니다.
출력 생성 속도
= 607 / 18.463421
≈ 32.88 tokens/s
이 값은 해당 실행에서 기록한 출력 생성 구간의 평균 속도입니다. 요청 전체가 끝나기까지의 시간이나 첫 번째 응답이 나타나기까지의 시간과는 다른 지표임을 유의해야 합니다.
7.2 답변이 나오기 전의 대기 시간 ≠ Prefill 시간
사용자 입장에서는 질문을 입력한 뒤 아무것도 나오지 않는 시간이 있고, 이후 글자가 나타나기 시작하는 시간이 있습니다. 그러다 보니 앞의 대기 시간을 모두 Prefill이라고 생각하기 쉽습니다. 하지만 첫 토큰을 받기까지의 시간인 TTFT(Time To First Token)에는 Prefill뿐 아니라 요청 대기와 네트워크 지연 등도 포함될 수 있습니다. 모델을 새로 올려야 하는 상황이라면 준비 시간도 영향을 줄 수 있습니다. 따라서 첫 응답이 늦다고 곧바로 Prefill 계산이 느리다고 단정할 수는 없습니다.
여기에 Thinking 모델은 한 가지를 더 구분해야 합니다. 1부에서도 최종 답변 전에 Thinking... 아래로 내용이 출력되었습니다. 이 Thinking 출력 역시 모델이 생성한 토큰입니다. Ollama는 이를 최종 답변과 별도의 필드로 제공할 수 있으며, 인터페이스에서 표시하거나 숨길 수도 있습니다. 따라서 화면에 최종 답변이 아직 나오지 않았더라도, 내부에서는 이미 Prefill을 마치고 Thinking 토큰을 생성하는 Decode가 진행 중일 수 있습니다. 그래서, “답변이 나오기 전까지 오래 걸렸다”는 현상을 분석할 때는 입력 처리에 오래 걸린 것인지, 생성 자체가 오래 걸린 것인지를 구분해야 하는 것입니다.
이제 같은 “느리다”라는 표현도 조금 다르게 볼 수 있습니다. 모델 로딩이 오래 걸리는지, 입력을 처리하는 데 시간이 걸리는지, 출력이 시작된 뒤 생성 속도가 느린지에 따라 확인해야 할 부분이 달라집니다.
8. 마치며
이번 포스팅에서는 Ollama와 GPU 사이의 실행 경로부터, 대화 이력 관리, Prefill과 Decode, 그리고 KV Cache까지 살펴보았습니다.
정리하면서 가장 중요하게 느낀 부분은 대화 내용을 보관하는 것과, 그 대화를 처리한 계산 결과를 보관하는 것이 서로 다른 문제라는 점입니다. 애플리케이션은 모델에 보여 줄 문맥을 구성하고, 추론 엔진은 그 문맥을 실제로 계산합니다. 캐시는 이 과정에서 이미 했던 계산을 다시 하지 않도록 돕는 역할입니다.
1부에서는 “이 미니 PC로 모델을 실행할 수 있는가?”를 확인했다면, 이번에는 “실행된 모델이 어떤 과정을 거쳐 답변을 만드는가?”를 알아본 셈입니다. 아직 알아야 할 내용은 많지만, 이제 실행 결과에 표시되는 숫자들이 조금은 다르게 보입니다. 하지만 짧은 질문 하나에 제법 빠르게 답한다고 해서 실제 활용에서도 만족스러울지는 또 다른 문제였습니다. 1부의 마지막에 언급했던 것처럼, 실제로 kagent의 LLM 백엔드로 연결했을 때는 단발성 대화와 다른 양상이 나타났습니다. 다음 포스팅에서는 이 과정에서 겪었던 문제와 원인을 추적한 내용을 이어서 정리해 보도록 하겠습니다. 긴 글 읽어주셔서 감사합니다.
'Study > CS & AI' 카테고리의 다른 글
| [로컬 LLM 구축기 - 1부] 외장 GPU 없이도 된다고요? (0) | 2026.09.06 |
|---|---|
| [Cilium Study] 8주차 - 네트워크를 넘어 커널까지: Cilium Security와 Tetragon (0) | 2025.09.07 |
| [Cilium Study] 7주차 - 대규모 클러스터를 위한 Kubernetes와 Cilium 성능 분석 및 튜닝 (1) | 2025.08.30 |
| [Cilium Study] 6주차 - Sidecar를 넘어, eBPF로 구현하는 서비스 메시 (5) | 2025.08.23 |
| [Cilium Study] 5주차 - BGP와 ClusterMesh로 클러스터 경계 넘기 (10) | 2025.08.17 |

































































