[로컬 LLM 구축기 - 3부] 극한의 튜닝으로 토큰 쥐어짜기
[로컬 LLM 구축기 - 2부] 요청 하나가 답변이 되기까지[로컬 LLM 구축기 - 1부] 외장 GPU 없이도 된다고요?이번 포스팅은 로컬 LLM 구축기의 첫 번째 시리즈입니다. 용도가 애매했던 미니 PC를 중고로
tech-recipe.tistory.com
지난 포스팅에서는 로딩/Prefill/Decode 구간을 나누어 각 구간의 성능을 측정하고 튜닝한 결과를 정리했습니다. 성능 측정에 사용한 스크립트는 매번 프롬프트 앞에 무작위 단어(nonce)를 붙였고, 그 이유에 대한 자세한 설명을 다음 포스팅으로 미루었습니다. 이번 포스팅에서는 그 이유와 함께 kagent 세션에서는 질문 간에 캐시가 왜 하나도 맞지 않았는지에 대한 원인을 추적하고, 원인이 된 버그 발견과 이를 수정한 PR이 merge 되기까지의 과정에 대해 적어 보겠습니다.
1. kagent 세션에서 비정상적으로 긴 Prefill 시간 발견
kagent는 CNCF 샌드박스 프로젝트로, 쿠버네티스 환경에서 발생하는 장애와 이벤트를 감지하고, AI를 활용해 자율적으로 원인을 진단 및 복구하는 클라우드 네이티브 오픈소스 AI 에이전트 프레임워크로 여러 LLM을 백앤드로 하여 사용할 수 있습니다. 기존에 테스트 용도로 Google gemini 3.1 flash lite API를 붙여서 사용하다가 이번에 구축한 로컬 LLM을 붙여보기로 했습니다.
1.1 39,000 tokens/s가 어떻게 나왔나?
3부의 측정 스크립트는 Ollama 응답에 담긴 입력 토큰 수(prompt_eval_count)를 Prefill 시간(prompt_eval_duration)으로 나눠 Prefill 속도를 구했습니다. 여기서 입력 토큰 수는 캐시에서 재사용한 토큰까지 센 입력 전체의 크기를 의미하지만, Prefill 시간은 캐시에 없어서 새로 계산 한 부분의 시간만을 측정합니다. 같은 프롬프트를 두 번 보내면 두 번째 요청은 대부분을 캐시에서 가져오므로, Prefill 시간이 거의 없게 되고, 이를 입력 전체로 나누게 되니 비약적으로 빠른 속도(39,000 tokens/s)로 계산이 되는 것입니다.

그래서, 설정에 따른 Prefill 속도를 측정할 때에 그 값이 캐시 히트 효과로 인해 결과가 왜곡 될 수 있어 매번 nonce를 붙여 프롬프트의 앞부분을 다르게 만들었습니다. 그럼 프롬프트의 앞부분만을 다르게 하고 그 뒤가 같으면 어떻게 되는지 의문을 가질 수 있는데, 이는 뒤에 나오는 lcp 개념과 함께 설명하겠습니다.
1.2 질문 하나에 모델을 여러번 호출하는 kagent의 작동 방식
kagent를 통해 사용자가 질문을 하나 하면, kagent 안에서는 대략 아래와 같은 일이 반복됩니다.
질문 입력
→ [모델 호출 1] 모델이 답 대신 "k8s_get_resources 도구를 이 인자로 실행해 달라"고 응답
→ kagent가 도구를 실행하고, 그 결과를 대화에 덧붙임
→ [모델 호출 2] 모델이 도구 결과를 읽고 다른 도구를 요청하거나 최종 답변을 생성
→ ... 최종 답변이 나올 때까지 반복
kagent에서 이 반복(모델 호출과 도구 실행)을 수행하는 부분을 에이전트 런타임이라고 부릅니다. kagent에서는 에이전트 런타임을 파이썬과 Go로 구현해 두었고 이 둘중 하나를 선택해서 사용할 수 있습니다.(최신 v1.0 업데이트부터는 Go 런타임 에이전트만을 사용하는 것으로 선회한 것으로 파악)
어쨌든, 위의 내용을 보면 질문이 하나여도 모델은 도구를 쓰는 횟수만큼 더 호출됩니다. 그리고 2부에서 언급했듯, 모델 서버는 이전 호출을 기억하지 않습니다. 그래서 kagent는 모델을 부를 때마다 시스템 프롬프트, 사용할 수 있는 도구의 목록과 사용법(도구 정의), 지금까지의 대화와 도구 실행 결과를 모두 한 요청에 담아 보냅니다. 그래서 호출할 때마다의 입력은 직전 입력 뒤에 새로운 내용을 조금 더 붙이는 형태가 됩니다. 앞부분이 매번 같다면, 2부에서 이야기한 Prefix Caching이 가장 큰 효과를 낼 수 있는 형태로, 캐시가 적중한 앞부분을 제외한 새로 붙은 도구 결과만을 계산(Prefill) 하면 됩니다.
[모델 호출 1] [시스템 프롬프트][도구 정의][질문]
[모델 호출 2] [시스템 프롬프트][도구 정의][질문][도구 호출 1][도구 결과 1]
[모델 호출 3] [시스템 프롬프트][도구 정의][질문][도구 호출 1][도구 결과 1][도구 호출 2][도구 결과 2]
1.3 질문 6개를 처리하는데 38분이 걸린 세션
그런데, 실제로 kagent에서 Go 에이전트 런타임을 사용하여 테스트 세션을 하나 돌려보니 질문 6개에 약 38분이나 걸렸습니다. 처음에는 성능이 낮은 하드웨어를 기반으로한 모델이라 느린 게 당연하다 생각했습니다. 그러면서 로그를 분석해 보았는데 질문 마지막 호출에 토큰이 28,315개 인 것을 확인했습니다. 아무리 느리다 해도 이건 너무 비정상적이라는 생각이 들었습니다. 그래서 Ollama 로그를 전체 분석해 보기로 합니다.
| 질문 | 입력 토큰 | Prefill 시간(초) | Prefill 속도(tokens/s) |
| 1 | 5,406 | 17.5 | 309 |
| 8013 | 27.6 | 290 | |
| 2 | 8075 | 27.8 | 290 |
| 11345 | 42.4 | 268 | |
| 14062 | 54.5 | 258 | |
| 17081 | 69.7 | 245 | |
| 3 | 17147 | 70.2 | 244 |
| 17779 | 72.9 | 244 | |
| 18204 | 75.8 | 240 | |
| 18663 | 78.3 | 238 | |
| 18834 | 79.2 | 238 | |
| 19137 | 81.1 | 236 | |
| 4 | 19147 | 81.7 | 234 |
| 19294 | 82.4 | 234 | |
| 5 | 19343 | 82.8 | 234 |
| 25875 | 121.3 | 213 | |
| 26178 | 124.1 | 211 | |
| 26481 | 125.8 | 211 | |
| 6 | 28315 | 137.7 | 206 |
| 총합 | 338,379 | 1,453 | 평균 233 (tokens/s) |
도구 결과가 붙을 때 마다 입력이 수천 토큰씩 늘어났습니다. 세 번째 질문에서는 도구 호출이 인자 형식 오류로 여러 번 실패하면서, 모델이 오류를 읽고 다시 도구를 호출하는 일이 반복되면서 모델 호출이 여섯 번이나 되었습니다. 그래서 6번의 질문 동안 모델은 총 19번이 호출되었으며, 총 처리 시간 38분에서 Prefill이 1,453초(전체 시간 대비 약 64%), 답변 생성이 809초(약 36%)를 차지했습니다. 앞선 테스트에서는 Prefill 속도가 답변 생성 속도보다 약 9~10배 정도 빨랐기 때문에, 대부분 답변 생성이 시간을 오래 잡아먹었을 것이라 예상하였는데 오히려 Prefill 시간이 더 걸리는 반대의 결과가 나왔습니다.
1.4 캐시가 동작했다면 걸렸을 시간
2부에서 정리한 것처럼 Prefix Caching이 동작했다면 이전 대화는 캐시에서 재사용하고, 새로 붙은 부분만을 계산해야 했습니다. 새로 붙은 부분을 모두 더한 것이 마지막 입력 토큰의 길이가 되므로, 이 세션의 Prefill은 모두 합해도 마지막 입력 토큰 전체를 한번 읽는 시간 정도가 되어야 한다고 생각했습니다.
그러나 결과는 캐시가 전혀 적중하지 않은 경우에 대한 결과로 해석할 수 밖에 없었습니다. 앞선 표의 총 19번 호출에 대해서 입력 토큰 수를 모두 더하면 338,379 토큰이고, 이것을 앞선 3부에서 측정한 긴 프롬프트 토큰 Prefill 속도인 대략 230 tokens/s 정도로 나누면 약 1,470초 정도가 나옵니다.
| 상황 | 실제 처리하는 토큰 양 | 예상 Prefill 시간 |
| 캐시가 작동하는 경우 | 마지막 입력 토큰(약 2.8만 토큰) | 약 120초 |
| 캐시가 전혀 적중하지 않을 경우 | 19번 호출의 입력 합계(약 34만 토큰) | 약 1,470초 |
| 실제 | - | 1,453초 |
실제 Prefill 시간은 캐시가 동작할 때 예상의 12배 수준이었으며, 캐시가 전혀 적중하지 않을 때의 예상과 거의 일치했습니다.

1.3의 표를 그래프로 그려보면 위와 같습니다. 캐시가 적중했다면 Prefill 시간에는 새로 붙은 부분을 계산하는 시간만 들어가므로, Prefill 속도는 일반적인 Prefill 속도보다 한참 높은 수천 ~ 수만 tokens/s가 나와야 합니다. 그런데 이번 세션에서는 206 ~ 309 tokens/s였고 이는 3부에서 입력 토큰 4.8k에 351 tokens/s, 28k에 235 tokens/s와 비슷한 수준이었습니다. 즉, 호출할 때마다 입력 전체를 처음부터 다시 읽고(Prefill) 있었다는 뜻으로 해석할 수 있습니다.
1.5 로그를 더 들여다 보기
kagent에서는 모델이 응답에 사용한 토큰 수에 대한 로그가 남지 않습니다. 그래서 해석이 정말 맞는지 알아보기 위해 Ollama 로그를 더 살펴 보았습니다. 캐시의 적중 정도를 보기 위해서는 아래 3가지 항목을 확인하면 됩니다.
# 이번 요청에서 캐시에서 재사용한 토큰 수
cached n_tokens = 0
# 재사용할 수 있는 것이 없어 처음부터 다시 계산
forcing full prompt re-processing due to lack of cache data
# 저장해 둔 이전 요청과 앞에서부터 같은 토큰 수
prompt with length 5562, lcp = 206
특히 세번째 줄의 lcp(longest common prefix)는 이전 캐시의 토큰 배열과 현재 입력된 토큰의 배열을 앞에서부터 비교해 처음으로 달라지기 직전까지의 같은 토큰의 수를 나타내는 값입니다. 예를 들어 토큰의 배열이 [A B C D]가 있고 [A B D C]가 있다면 lcp는 2가 됩니다. 그래서 이 lcp 값이 Prefix Caching이 재사용할 수 있는 최대 길이가 되는 값이며, 위 로그에서는 서버에 저장해 둔 이전 요청(5,562 토큰)과 이번 요청이 앞 206 토큰까지 그 길이가 같다는 뜻입니다.
찾아본 19번 모두 cached n_tokens=0(이번 요청에서 캐시에서 재사용한 토큰 수)이었고, forcing full prompt re-processing(재사용 할 수 있는 것이 없어 처음부터 다시 계산을 의미)이 19번 모두 찍혀 있었습니다. 앞선 해석이 맞는 순간입니다.
여기서 한 가지 이상한 점이 있습니다. 앞에서부터 206 토큰은 같았는데 왜 재사용 토큰 수가 0일까요? 이는 qwen 3.6 모델의 독특한 구조(Gated DeltaNet과 Gated Attention이 섞인 하이브리드 구조)로 나타나는 현상으로 Gated DeltaNet 레이어는 KV Cache를 쌓는 대신 고정된 크기의 상태를 계속 덮어쓰도록 작동하는데, 이 때문에 계산을 특정 토큰 위치로 되돌릴 수 없습니다. 그래서 서버는 Prefill 중간중간 상태를 저장해 둔 체크포인트라는 것을 남기게 되고 이 체크포인트로만 되돌아갈 수 있습니다.

위 그림에서 볼 수 있듯 첫 체크포인트가 lcp 206보다 한참 멀리 떨어져있고, 이번 요청이 이전 요청과 토큰의 배열이 같은 구간은 206까지 이니 되돌아갈 체크포인트가 206 이전에는 없어서 19번의 요청 모두에서 처음부터 다시 계산되었던 것입니다. 일반적인 모델이어다면 206 토큰을 재사용했겠지만, 입력 전체에 비하면 작은 양이라 Prefill 시간에 큰 차이는 없었을 것입니다.
2. 요청의 앞부분은 어디서 달라지는가?
2.1 요청마다 다른 lcp 값
이번에는 매 요청에 대해서 앞부분이 어디서부터 달라지는지를 살펴 보았습니다. Ollama 로그의 lcp 값은 아래와 같았습니다.
prompt with length 5562, lcp = 206
prompt with length 8046, lcp = 206
prompt with length 5562, lcp = 1400
prompt with length 8337, lcp = 1400
앞에서 206 토큰까지만 같을 때도 있고, 1400 토큰까지 같을 때도 있었습니다. 만약 요청 시각처럼 매번 바뀌는 값이 시스템 프롬프트에 들어가 있었다면 항상 같은 위치에서 달라졌을 텐데, lcp가 206과 1,400 두 가지로 나온다는 것은 요청마다 토큰 배열이 어긋나는 위치가 변한다는 것을 어떻게 해석해야 할지 도무지 감이 잡히지 않았습니다.
2.2 무엇이 이런 문제를 일으키나?
Ollama 로그를 거슬러 올라가 보니, 캐시가 적중했던 기록도 있었습니다. 재사용 토큰이 5,530 → 29,420 → 43,578 → 73,211로 대화가 진행됨에 따라 늘어나는것을 확인했습니다. 재사용 토큰이 늘어나던 때는 파이썬 런타임을 쓰고 있을 때였고, Go 런타임을 쓸때는 재사용 토큰이 0이 된다는 것을 발견하게 됩니다. 도대체 Go 런타임에 무슨 문제가 있는 걸까요?
2.3 후보 좁히기
요청 앞부분에 들어가는 것 중 요청마다 바뀔 수 있는 것의 후보를 추리고 하나씩 확인했습니다.
| 후보 | 확인 결과 |
| 시스템 프롬프트 | 고정된 문자열 |
| MCP 서버가 주는 도구 목록의 순서 | 6번 모두 같은 순서 확인 |
| 요청 안의 도구 순서 | Go의 슬라이스에 담겨 순서가 고정 |
| 도구마다의 파라미터 순서 | Go의 맵에 담겨 있음 |
위 후보 중 요청마다 순서가 바뀔 수 있는 것은 맵에 담긴 도구별 파라미터가 유일했습니다. 아무래도 여기가 의심스러워집니다.
3. Go 맵은 순서를 보장하지 않는다!
3.1 언어의 기본 동작
Go 언어의 명세에는 맵의 순회 순서에 대해서 이렇게 적혀 있습니다.
The iteration order over maps is not specified and is not guaranteed to be the same from one iteration to the next.
(맵의 순회 순서는 정해져 있지 않으며, 한 번의 순회와 다음 순회의 순서가 같다는 보장도 없다.)
Go는 1.0부터 맵에 순서를 사용하지 못하도록, 같은 맵을 같은 코드로 순회해도 순서를 예측할 수 없도록 했습니다. 파라미터가 5개짜리 맵을 다섯 번 순회해 본 예시입니다.
props := map[string]string{
"project": "project slug",
"family": "change family",
"bucket": "severity bucket",
"since": "lower bound",
"limit": "max rows",
}
for i := 1; i <= 5; i++ {
fmt.Print(i, ": ")
for name := range props {
fmt.Print(name, " ")
}
fmt.Println()
}
# go 1.25로 실행한 결과
1: project family bucket since limit
2: project family bucket since limit
3: since limit project family bucket
4: project family bucket since limit
5: since limit project family bucket
# 한 번 더 실행
1: project family bucket since limit
2: project family bucket since limit
3: project family bucket since limit
4: project family bucket since limit
5: family bucket since limit project
결과를 보면 원래 순서에서 시작 위치만 달라지고, 그 뒤는 원래 순서대로 이어지는 것을 확인할 수 있습니다. 즉, 맵의 순회 시작 위치를 무작위 정하는 것입니다.

반면 파이썬의 dict는 3.7부터 넣은 순서를 그대로 유지하는 것이 언어 명세로 보장됩니다.
3.2 kagent의 변환 코드
MCP 서버가 주는 도구 정의는 JSON 텍스트입니다. 그래서 파라미터가 적힌 순서가 정해져 있습니다. 그런데 kagent의 Go 런타임 코드를 보면, 이 파라미터를 맵에 담아 다루기 때문에 이 단계에서 그 순서가 사라져 버립니다! kagent는 도구 정의를 Ollama 형식(JSON)으로 바꿔서 요청을 넣는데 그 요청 변환 Go 런타임의 변환 코드는 아래와 같습니다.
// kagent Go 런타임: 도구 정의를 Ollama 형식으로 바꾸는 부분
for name, propAny := range props { // Go 맵을 순회: 순서가 매번 다르다
...
params.Properties.Set(name, prop) // 넣은 순서를 그대로 유지하는 구조에 저장
}
for문 안의 params.Properties.Set(name, prop)은 Ollama 형식으로 만드는 도구 파라미터(params)의 Properties에 파라미터 하나를 추가하는 메서드 호출입니다. Properties의 타입은 kagent가 가져다 쓰는 Ollama Go 패키지의 ToolPropertiesMap으로, 파이썬의 dict처럼 넣은 순서를 유지하는 맵입니다. Go 언어 표준 라이브러리에는 순서를 유지하는 key-valude 타입의 자료형이 없어서 Ollama는 외부 라이브러리(go-ordered-map)를 이용해 이 타입을 정의해 두었습니다.
즉, 이 코드는 순서가 없는 Go 맵(props)에서 파라미터를 하나씩 꺼내, 순서를 유지하는 맵에 넣고 있었습니다. 꺼낼 때마다 순서가 달라지니 ToolPropertiesMap에 들어간 순서도 요청마다 달라졌고, Ollama로 보내는 요청에도 달라진 순서 그대로 전달되었습니다. 오히려 받는 쪽이 Go의 보통 맵이었다면 encoding/json이 JSON으로 바꿀 때 맵의 키를 이름순으로 정렬하므로 매번 같은 결과가 나왔을 것입니다.
3.3 파라미터 순서와 Prefix Caching
파라미터 순서만 바뀐 두 도구 정의가 요청에 실리는 형식을 단순화한 예시를 보면 아래와 같습니다.
요청 1: {"name":"list_changes","parameters":{"properties":{"project":…,"family":…,"bucket":…,"since":…,"limit":…}}}
요청 2: {"name":"list_changes","parameters":{"properties":{"since":…,"limit":…,"project":…,"family":…,"bucket":…}}}
JSON 명세(RFC 8259)에서 객체는 순서가 없는 이름/값 쌍의 모음이기 때문에, 데이터로 보면 위의 두 요청에서의 도구 정의는 서로 같은 것으로 볼 수 있습니다. 하지만 Ollama는 요청을 텍스트로 받기 때문에 이 JSON 형식으로 되어 있어도 그 순서가 다르면 토큰의 배열도 달라 서로 다른 요청이 되어 버립니다. 2부에서는 이 부분을 아래와 같이 정리했습니다.
다만 동일하다는 기준은 "뜻이 비슷하다"가 아닙니다. 일반적인 Prefix Caching은 앞에서부터 이어지는 실제 토큰 배열이 일치하는 구간을 재사용합니다.
그런데 하필이면 순서가 바뀔 수 있는 도구 정의가 LLM에게 요청하는 텍스트의 거의 맨 앞에 들어갑니다. 그래서 첫 호출에 Go 맵에서 추출되어 순서가 고정되어 버린 도구 정의를 텍스트로 입력받은 LLM 서버가 첫 번째 프리필에서 Prefix Cache를 생성해 두었지만, 그 다음 호출에서는 Go 맵의 추출 과정에서 도구 정의의 순서가 변하면서 이를 텍스트로 입력받은 LLM 서버 입장에서는 토큰 배열이 변했다고 판단하고, 그 앞에 위치한 체크포인트부터 다시 Prefill 계산을 하는 현상이 발생했던 것입니다.(그런데 lcp의 위치가 너무 초반이라 첫번째 체크포인트를 지나지 못했기 때문에 Prefill을 다시 모두 처음부터 진행할 수밖에 없었던 것입니다.)
3.4 테스트 조건에서 Prefix Caching이 성공할 확률
당시 에이전트에 연결된 도구는 9개였고, 그중 8개가 파라미터를 두 개 이상 가지고 있었습니다.(파라미터 2개인 도구 3개, 3개인 도구 3개, 5개인 도구 2개) 앞서 보았든 Go 맵은 그 구성 요소가 n개이면 n가지 순서가 나오므로 각 도구의 파라미터 수를 모두 곱하면 2×2×2×3×3×3×5×5 = 5,400가지가 나올 수 있었습니다. 따라서 19번 중에 한 번도 적중하지 않은 것이 너무나도 당연한 결과였습니다.
4. 해결
4.1 다시 파이썬 런타임으로 교체
원인을 파악한 뒤 에이전트를 다시 파이썬 런타임으로 변경하고, 같은 질문 6개로 세션을 다시 돌려 보았습니다.
| 항목 | Go 런타임 | 파이썬 런타임 |
| 처음부터 다시 계산 | 19회 | 0회 |
| 입력 중 캐시에서 재사용한 비율 | 0% | 약 64% |
| 모델 호출 | 19번 | 14번 |
| Prefill 전체 시간 합계 | 1,453초 | 260초 |
| 모델 요청 처리 시간 | 37.7분 | 10.5분 |
위 표에서 알 수 있듯 처음부터 다시 계산하는 경우는 완전히 사라졌습니다. Prefill 시간 합계도 확연히 감축되었고, Go 런타임에서 세 번째 질문에서 발생하던 인자 형식 오류도 사라져서 14번의 모델 호출만 하게 되었습니다. 하지만, 문제의 Go 에이전트의 원인을 이미 파악해서 PR을 올리기로 했습니다.
4.2 PR - 이름을 정렬해서 Go map 생성할 수 있도록 수정
Go 런타임을 고치는 방법은 간단했습니다. 파라미터 이름을 정렬한 뒤 그 순서대로 넣어 주도록 했습니다.
// 수정 전: 맵에서 이름과 값을 함께 꺼낸다 (꺼내는 순서가 매번 다르다)
for name, propAny := range props {
if propMap, ok := propAny.(map[string]any); ok {
...
// 수정 후: 이름만 뽑아 정렬한 뒤, 그 순서대로 값을 찾는다
for _, name := range slices.Sorted(maps.Keys(props)) {
if propMap, ok := props[name].(map[string]any); ok {
...
수정의 주요 내용은 먼저 maps.Keys(props)로 props에서 Key, 즉 파리미터 이름만을 꺼내어가 slices.Sorted를 통해 이름순으로 정렬하여 슬라이스로 돌려주도록 한 것입니다. 이렇게 하면 이름순으로 순서가 고정되어 Ollama에 입력되는 요청 텍스트의 초반 부분 변화가 없게 되어 Prefix Caching을 노릴 수 있게 됩니다.
이를 PR로 올려 kagent-dev/kagent#2693으로 등록되어 9월 8일 머지되었으며, kagent v1.0.0-alpha1 릴리스에 포함되었습니다.
4.3 v1.0에서 다시 돌려보고 확인한 두 번째 버그 - 도구 호출 인자에서 동일한 문제 발견
PR이 머지된 후, kagent v1.0.0 alpha 버전을 Go 런타임으로 같은 질문 6개를 다시 돌려 보았습니다. 도구 정의 부분은 고쳐졌습니다. 실제로 시스템 프롬프트와 도구 정의를 합친 약 5.5천 토큰은 14번의 호출에서 모두 같았습니다. 그런데 질문을 처리하는 중간 호출들이 여전히 8,145 ~ 8,254 토큰 부근에서 달라지는 것을 발견하게 됩니다. 대화에서 처음으로 도구 호출(k8s_get_resources)이 들어간 지점이었습니다.
원인도 도구 정의 때와 같았습니다. kagent는 모델이 요청한 도구 호출도 대화 이력에 넣어서 보내는데, 이때 호출 인자를 또다시 Go 맵에서 꺼내어서 Ollama 요청 텍스트로 사용하고 있었습니다.
// kagent Go 런타임: 대화 이력 속 도구 호출을 Ollama 형식으로 바꾸는 부분
for k, v := range part.FunctionCall.Args { // Args: 호출 인자를 담은 Go 맵. 꺼내는 순서가 매번 다르다
toolCall.Function.Arguments.Set(k, v) // 꺼낸 순서대로 ToolCallFunctionArguments에 추가
}
part.FuctionCall.Args는 모델이 이전에 요청했던 도구 호출의 인자(인자 이름과 값)를 담는 Go 맵입니다. 코드는 이것을 Ollama로 보낼 도구 호출에서 인자 필드(toolCall.Fuction.Arguments)에 옮겨 담는데, 이 필드의 타입이 ToolCallFunctionArguments로 ToolPropertiesMap처럼 Ollama 패키지에 들어있는 입력한 순서를 유지하는 맵(ordered map)입니다.
이번에는 첫 번째 체크포인트를 지난 이후에 나타난 현상이라 첫번째 체크포인트인 5,502 토큰부터 다시 Prefill을 계산하는 모습을 보였습니다.

고치는 방법도 앞과 같아서, 인자 이름을 뽑아 정렬하고 그 순서대로 값을 찾아 넣게 하였습니다. 이는 PR kagent-dev/kagent#2988로 요청하여 9월 28일 머지되었습니다.
이후 같은 질문 6개를 세 가지 조건으로 돌려 보았습니다. 모델의 답이 매번 달라서 조건마다 모델 호출 횟수와 대화의 크기가 다르기 때문에 세션 전체 시간 대신 질문 처리 중 호출만 모아 요청을 단위별로 비교해 보았고 그 결과는 아래와 같습니다.
| 항목 | 수정 없음 | #2693 | #2693 + #2988 |
| 모델 호출 | 20번 | 14번 | 15번 |
| 처음부터 다시 계산한 호출 | 20번 | 1번 | 0번 |
| 질문 처리 중 호출 | 14번 | 8번 | 9번 |
| 그중 직접 입력의 끝보다 앞서서 Prefix Cache가 깨진 호출 | 14번 | 6번 | 0번 |
| 질문 처리 중 새로 붙은 토큰 양(A) | 57,328토큰 | 37,255토큰 | 73,594토큰 |
| 질문 처리 중 다시 읽은 토큰 양(B) | 501,102토큰 | 165,343토큰 | 73,630토큰 |
| B ÷ A | 약 8.7배 | 약 4.4배 | 약 1.0배 |

캐시가 제대로 작동하면 이전 질문 대비 새로 붙은 내용에 대해서만 토큰을 처리하면 되므로 B ÷ A는 1에 가까워져야 합니다. 수정 없음 조건에서는 호출 20번을 모두 처음부터 다시 계산했고, 앞부부니 달라지는 위치도 요청마다 재각각이었습니다. #2693을 적용하고 나서는 처음부터 다시 계산하는 호출은 1번으로 줄었습니다. 단 #2693도 캐시가 깨지면 체크포인트부터 다시 읽으면서 질문 처리에 읽은 토큰 양이 새로 붙은 양 대비 약 4.4배였습니다. 두 버그가 모두 해결되고 난 후(#2693 + #2988)에는 Cache가 거의 적중하여 질문 처리 중 토큰을 다시 읽은 양과 새로 붙은 양이 거의 같아졌습니다.
5. 마치며
이번 포스팅에서는 kagent의 요청에서 캐시가 한 번도 적중하지 않았던 원인을 추적한 과정을 정리해 보았습니다. 원인은 kagent Go 런타임이 도구 정의를 만들 때 Go 맵을 순회한 순서대로 파라미터를 넣은 데 있었고, 파라미터 이름을 정렬하도록 몇 줄을 고쳐 해결했습니다. 고친 뒤 1.0에서 다시 돌려 보니 대화 이력 속 도구 호출 인자에서도 순서가 바뀌는 버그를 하나 더 발견했고, 똑같이 이름을 정렬하는 방법(#2988)으로 고쳤습니다.
정리하면서 가장 크게 느낀 것은, 이 버그에 관련된 동작이 각각은 모두 정상이었다는 점입니다. Go의 맵이 순서를 보장하지 않는 것은 언어 명세대로의 동작이고, 넣은 순서를 지키는 Ollama의 구조나 토큰 배열이 같은 구간만 재사용하는 Prefix Caching도 설계대로 동작했습니다. 그런데 이 동작들이 교묘하게 상호작용 하면서 캐시를 무효화하고 있었습니다.
Ollama 로그로는 요청의 앞부분이 매번 다른 곳에서 달라진다는 것까지만 확인할 수 있었고, 그 이유는 Go 맵의 순회 순서를 알고 나서야 설명할 수 있었습니다. Go 코드만 봤다면 순서가 바뀌는 것이 왜 문제인지 알기 어려웠을 것이라, 언어의 기본 동작과 LLM이 입력을 처리하는 방식을 함께 보아야 찾을 수 있는 문제였다고 생각합니다.
이번 시리즈 포스팅을 통해 LLM의 기본 동작과 개념들 정리할 수 있었습니다. 또한 하드웨어 제약 속에서 로컬 LLM을 구동 및 성능을 위한 여러 가지 튜닝, 그럼에도 불구하고 넘을 수 없었던 물리적인 한계까지 많은 것을 얻어갈 수 있는 시간이었습니다.
지금까지, 긴 글 읽어주셔서 감사합니다.
'Study > CS & AI' 카테고리의 다른 글
| [로컬 LLM 구축기 - 3부] 극한의 튜닝으로 토큰 쥐어짜기 (0) | 2026.09.27 |
|---|---|
| [로컬 LLM 구축기 - 2부] 요청 하나가 답변이 되기까지 (0) | 2026.09.15 |
| [로컬 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 |


































































