본문으로 건너뛰기

© 2026 Molayo

Qiita헤드라인2026. 05. 20. 17:02

제한된 메모리 환경에서 Local LLM (Gemma4 Qwen3.6)을 여러 명이 사용하는 방법

요약

64GB Apple Silicon 환경에서 35B급 로컬 LLM을 병렬로 호출할 때 발생하는 runner stall과 swap 문제를 해결하기 위한 최적화 방법을 다룹니다. Ollama의 설정을 통해 추론을 직렬화하고 Hermes Agent를 전단에 배치하여, 여러 요청을 병렬로 접수하되 실제 추론은 순차적으로 처리하는 '준병렬 처리' 구조를 제안합니다.

핵심 포인트

  • 메모리 제한적인 로컬 환경에서 무리한 병렬 추론은 runner stall과 시스템 성능 저하를 유발함
  • OLLAMA_NUM_PARALLEL 및 OLLAMA_MAX_LOADED_MODELS 설정을 통해 추론 프로세스를 직렬화하여 안정성 확보 가능
  • Hermes Agent를 활용해 요청 접수는 병렬로, 실제 LLM 추론은 큐(Queue)를 통해 순차 처리하는 구조가 효과적임
  • Ollama 버전 업데이트 및 API endpoint 변경이 응답 속도 개선에 복합적인 영향을 미칠 수 있음
  • 64GB Apple Silicon (Mac Studio M1 Ultra) 단일 기기에서 35B급 로컬 LLM을 여러 채널에서 동시에 호출하면, 거대 프롬프트 발생 시 runner stall과 swap 지옥이 발생했다.
  • Ollama를
    OLLAMA_NUM_PARALLEL=1
    ,
    OLLAMA_MAX_LOADED_MODELS=1
    ,
    OLLAMA_MAX_QUEUE=10
    으로 설정하여, 실제 추론을 직렬화했다. - Hermes Agent는 여러 채널로부터의 병렬 접수와 진행 상황 통지를 담당하고, Ollama 측에서 1건씩 처리하는 구성으로 만들었다.
  • 같은 타이밍에 Ollama를 v0.22.1 → v0.24.0으로 업데이트하였고, 검증 시의 API endpoint도
    /v1/chat/completions
    에서 /v1/completions
    로 변경해 버렸다. 속도 측면의 개선 (응답 시간 11분 → 1분 14초)은 버전 업데이트, 직렬화, endpoint 차이의 복합적인 효과이며, 양측의 기여도는 본고에서 분리하여 다루지 않았다. - 한편, stall의 근절과 4개 입구 동시 투입 시의 완주 가능성은 KV/context 경합이 구조적으로 사라진다는 이유로 직렬화의 기여가 크다고 생각된다. 이는 속도 문제와는 별개의 축으로 다룬다.

로컬 LLM을 에이전트 기반으로 구축하여 본격적으로 운용하려고 하면 반드시 한 번은 부딪히는 벽이 있습니다.

"적어도 64GB Apple Silicon 단일 기기에서 35B급 모델에 방대한 컨텍스트를 던질 경우, 여러 채널로부터 병렬 요청을 받으면 추론 성능이 올라가기는커녕 runner stall과 swap 지옥에 빠진다"는 것입니다.

여기서 말하는 "빠진다"는 비유가 아니라, 실측치로서 다음과 같은 증상이 재현됩니다.

  • 1개 요청의 추론에 11분이 소요됨
  • 추론 프로세스가 CPU 23%에서 멈춰 응답을 반환하지 않음
  • /api/tags는 즉각 응답하지만 /api/generate만 무한 타임아웃 발생
  • 최종적으로 SIGKILL로 프로세스를 강제 종료해야만 함

클라우드 LLM의 감각으로 "여러 채널에서 동시에 던져도 서비스 측에서 알아서 처리해 주겠지"라고 생각하고 구축하면 확실히 이 벽에 부딪히게 됩니다.

이 기사는 Hermes Agent를 전단에 배치하여, 로컬 LLM으로의 질의를 **병렬이 금지된 큐 (Queue)**로 흘려보냄으로써, 겉으로는 여러 요청을 받으면서 실제 추론은 1건씩 순차적으로 처리하는 구성 ― 본고에서는 이를 "준병렬 처리"라고 부릅니다 ― について, 실제로 관측한 수치와 함께 작성한 것입니다.

주장은 한 문장이면 충분합니다.

로컬 LLM은 무리하게 병렬 실행을 시키기보다, 에이전트 측에서 접수는 병렬로 유지하되 추론을 직렬 큐로 흘려보내는 편이 멈추지 않는다.

실측한 환경은 다음과 같습니다.

항목
하드웨어Mac Studio M1 Ultra (Apple Silicon, unified memory 64 GB)
모델gemma4:26b-mxfp8 (26GB), qwen3.6:35b-a3b-coding-mxfp8 (37GB), gemma4:26b-a4b-it-q4_K_M (17GB), qwen3.6:35b-a3b-q4_K_M (23GB) 외

Hermes Agent는 여러 프로필 (profile)을 가지고 있으며, 프로필마다 서로 다른 채팅 토큰으로 접속하고 있습니다. 입구는 3~4개 계통이 있으며, 모두 동일한 127.0.0.1:11434의 Ollama를 향하고 있는 구성입니다. 이것이 문제의 본질적인 토양이었습니다.

참고로, Apple Silicon은 CPU/GPU 간에 통합 메모리 (unified memory)를 공유하는 설계이기 때문에, 본고에서 "메모리 압박"이라고 쓴 부분은 모두 통합 메모리에 관한 이야기입니다. "VRAM 압박"과는 내부적으로 같은 것을 의미합니다.

어느 날 12:03:46에, 4개의 채널로부터 동시에 inbound가 들어왔습니다.

12:03:46 4개 thread로부터 동시 inbound
chat=...329 / ...860 / ...101 / ...156

각각의 응답 시각은 다음과 같았습니다.

순서chat id응답 시간
1 번째...860169.4 초
...

로그만 보면 어느 정도 순차적으로 반환되고 있는 것처럼 보입니다.

하지만 실제로는 이 이면에서 추론 프로세스 (Inference process)가 여러 번 멈추고, 수동 SIGKILL → 자동 respawn → 재개를 반복하고 있었습니다. 운 좋게 완주한 것들부터 반환되었을 뿐입니다.

Ollama 서버 로그에서 prompt processing 시의 피크 메모리 (Peak memory)와 cache 상태를 추출하면 다음과 같습니다.

peak memory size = 28.91 GiB (1st req)
peak memory size = 32.55 GiB (2nd req)
peak memory size = 35.19 GiB (3rd req)
...

total은 새로운 prompt의 총 토큰 (token) 수이며, cached는 KV cache 히트 분량입니다. 최종적인 프롬프트는 약 47,324 token에 달했습니다. 글자 수로는 약 35,000자입니다.

Discord의 동일한 스레드 내에서 대화 이력이 누적된 결과입니다.

이 거대한 프롬프트를 4개의 입구(entry)에서 동시에 동일한 모델로 던지는 것 — 이것이 애초에 무리한 설정이었습니다.

v0.22 시절의 로그에는 다음과 같은 행들이 나열되어 있습니다.

[GIN] POST /v1/chat/completions took=10m59s status=200
[GIN] POST /v1/chat/completions took=11m6s status=500 ← timeout 파생 에러

이를 v0.24 + 병렬 금지로 전환한 후, 동일한 크기의 prompt로 측정한 결과가 아래와 같습니다.

POST /v1/completions took=1m14.05s status=200
POST /v1/completions took=1m14.78s status=200
POST /v1/completions took=1m16.68s status=200

참고로, 위의 11분 로그는 별도의 타이밍에 수행된 단발 추론 (4개 입구 동시 투입과는 별개

[Discord] bot A, bot B, bot C, bot D 가 동시에 메시지 수신
↓
[Hermes] 4개의 aiohttp 연결을 127.0.0.1:11434로 생성
...

핵심은, 설령 병렬 처리 (Parallel Processing)가 발생하더라도 모델 가중치 (Model Weights)가 4배로 늘어나는 것은 아니라는 점입니다. 주로 늘어나는 것은 KV 캐시 (KV cache)와 컨텍스트 윈도우 (Context Window) 측면이며, 이것이 이미 47K 토큰 급이었기 때문에 필요한 메모리가 통합 메모리 (Unified Memory)의 물리적 상한을 단번에 초과하여 스왑 스래싱 (Swap Thrashing)에 이르렀다는 것이 가장 정합성 있는 설명입니다.

여기서 일반적인 발상은 "Hermes 측에 작업 큐 (Job Queue)를 구현한다"일 것입니다.

실제로 그것도 유효하지만, 이번에 채택한 최단 경로는 훨씬 더 단순하고 직설적인 것이었습니다.

Ollama 서버 자체에 동시 추론 수 = 1을 강제한다.

이것만으로 Hermes Agent 측의 코드를 한 줄도 바꾸지 않고, Ollama가 여러 요청을 내부적으로 큐잉 (Queuing)하여 한 건씩 처리해 줍니다. Hermes Agent 입장에서는 여러 채널에 대해 병렬로 응답하는 것처럼 보이지만, 실제 추론은 내부에서 순차적으로 이루어집니다.

제목의 "에이전트를 요청 병렬 큐로 사용하기"라는 말은,

여기서 말하는 대로 이용자 입장에서는 에이전트가 병렬 접수 창구 겸 큐 (Queue) 역할을 하고 있다는 뜻입니다.

구현상의 직렬화 (Serialization)를 담당하는 것은 Ollama이지만, 논리적 구조로는 "에이전트 = 큐", "Ollama = 1개의 병렬 워커 (Worker)"라는 모델이 됩니다.

launchctl setenv OLLAMA_NUM_PARALLEL 1
launchctl setenv OLLAMA_MAX_LOADED_MODELS 1
launchctl setenv OLLAMA_MAX_QUEUE 10

그 후, Ollama.app을 완전히 재시작합니다. 이 부분이 은근히 중요합니다. launchctl setenv로 환경 변수를 설정하더라도, 이미 실행 중인 Ollama 프로세스에는 반영되지 않습니다.

killall Ollama
pkill -f "ollama serve"
pkill -f "ollama runner"
...

~/Library/LaunchAgents/ai.ollama.envsetter.plist:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
...

로드 (Load):

launchctl load ~/Library/LaunchAgents/ai.ollama.envsetter.plist

이를 /Library/LaunchAgents/가 아닌 ~/Library/LaunchAgents/에 두는 것도 의도적인 것입니다. Ollama.app은 GUI 앱이며, 사용자의 로그인 세션 내에서 동작합니다. 시스템 LaunchAgents에 두면 Ollama.app에서 보이지 않습니다.

killall Ollama
mv /Applications/Ollama.app /Applications/Ollama.app.bak-v0.22.1
curl -L -o /tmp/Ollama-darwin.zip \
...

xattr -dr com.apple.quarantine을 잊으면, Mac의 Gatekeeper가 첫 실행을 차단합니다.

변수기본값효과
OLLAMA_NUM_PARALLEL11 (※주)동시 추론 수를 1로 명시적 고정. 본 기사의 주인공
OLLAMA_MAX_LOADED_MODELS13 × GPU 수 (CPU는 3)통합 메모리에 동시에 로드할 수 있는 모델을 1개로 제한. 전환 시 오래된 모델을 즉시 언로드 (Unload) 하여 메모리 압박 방지
OLLAMA_MAX_QUEUE10512큐 상한 10개. 이를 초과하면 Ollama는 503을 반환

MAX_QUEUE

MAX_QUEUE를 10으로 제한한 것은 의도적인 설정입니다. 저희 구성에서는 입구 채널 합계 3개 계통 × 동시 대화 수를 예상하더라도, 현실적으로 대기하는 요청은 몇 건 정도입니다. 기본값인 512로 두면, 병목이 발생했을 때 통합 메모리(Unified Memory)만 계속 점유하여 상황을 파악할 수 없게 됩니다. 10건이 넘으면 빠르게 503 에러로 거절하는 편이, Hermes Agent 측에서 "지금은 만원입니다"라고 사용자에게 전달할 수 있다는 트레이드오프(Trade-off)를 고려한 것입니다.

이 부분이 본문에서 가장 빠지기 쉬운 지점이므로 자세히 설명하겠습니다.

단발성 거대 프롬프트(Prompt) 관측값과 4개 입구 동시 투입 시의 관측값은 성질이 다르므로 별도의 표로 구분합니다.

(a) 단발 거대 프롬프트의 추론 시간

조건엔드포인트 (Endpoint)응답 시간
v0.22.1, NUM_PARALLEL 미명시/v1/chat/completions약 11분
v0.24.0, NUM_PARALLEL=1 명시/v1/completions약 1분 14초

(b) 4개 입구 동시 투입 시의 동작

조건최저 응답 시간Stall (멈춤)SIGKILL
v0.22.1, NUM_PARALLEL 미명시407초있음필요
v0.24.0, NUM_PARALLEL=1 명시약 300초없음불필요

게다가 Hermes 측의 타임아웃(Timeout) 에러는 빈번한 발생에서 제로(0)로 줄었으며, 수동 SIGKILL이 필요한 횟수도 몇 시간에 수 회에서 제로(0)가 되었습니다.

이 Before/After에서 동시에 변경된 변수는 3가지가 있습니다.

  • Ollama v0.22.1 → v0.24.0로 버전 업그레이드
  • NUM_PARALLEL 미명시 → OLLAMA_NUM_PARALLEL=1 명시를 통한 직렬화(Serialization)
  • 로그 취득 시의 API 엔드포인트(Endpoint)가 /v1/chat/completions/v1/completions로 변경

세 번째는 본래 통제해야 할 요소로, 이것만으로도 프롬프트 정형(Prompt Formatting) 경로 및 일부 전처리가 바뀔 가능성이 있어 엄밀한 벤치마크(Benchmark)의 형태를 갖추지는 못했습니다. 따라서 "11분 → 1분 14초, 약 9배 속도 향상"을 오로지 직렬화의 효과로 해석하는 것은 옳지 않습니다. 실체는 **"버전 업그레이드 + 직렬화 + 엔드포인트 차이의 복합 효과"**입니다. 본 기사의 주제는 병렬 금지 큐(Queue)이지만, 벤치마크로서 인과관계를 분리하려면 최소한 다음 4가지 조건을 동일한 엔드포인트에서 측정해야 합니다.

조건목적
v0.22.1 + NUM_PARALLEL 미명시Before
v0.22.1 + NUM_PARALLEL=1 명시직렬화만의 효과
v0.24.0 + NUM_PARALLEL 미명시버전 업그레이드만의 효과
v0.24.0 + NUM_PARALLEL=1 명시최종 구성

이번에는 이 A/B/C/D를 모두 수행하지 못했습니다. 따라서 9배 속도 향상 중 어느 정도가 버전 업그레이드에 의한 것이고, 어느 정도가 직렬화에 의한 것인지는 본문에서 불명으로 남겨둡니다.

다만, 직렬화의 기여가 크다고 판단할 수 있는 근거는 2가지가 있습니다.

  • 4개 입구 동시 투입이 완주할 수 있게 되었다 — Before는 Stall이 발생하면 SIGKILL로 강제 깨워야 겨우 완주할 수 있었습니다. After는 Stall 자체가 사라졌습니다. 이는 내부 추론을 1로 고정함으로써 KV/Context 경합과 통합 메모리 스래싱(Thrashing)이 해소되었기 때문이며, 버전 업그레이드의 기여는 부차적이라고 설명할 수 있습니다.
  • 최저 응답 시간이 407초 → 약 300초로 단축되었다 — 순차화(Sequentialization)를 했는데도 최저 응답 시간까지 단축된 것은, 건당 순수 처리 시간이 스왑 스래싱(Swap Thrashing) 해소로 인해 정상화되었기 때문입니다.

즉,

"속도가 빨라졌다"의 총량은 버전 업그레이드 + 직렬화 + 엔드포인트 차이의 합산으로 평가한다.
"멈추지 않게 되었다", "완주할 수 있다"는 직렬화에 강하게 귀속된다.

라는 정리가 가장 성실한 서술 방식이 됩니다. 참고로 47K 토큰의 거대 프롬프트 자체는 전혀 줄어들지 않았습니다. 버전 업그레이드로 처리 속도가 올라가서 1분 14초 만에 처리할 수 있게 된 것일 뿐, 프롬프트 비대화는 별도의 compression.threshold 조정이나 정기적인 /new...

대처해야 합니다. 이 내용은 후술할 「남은 과제」에 남겨두었습니다.

설정 변경 자체는 단순합니다. 본질은 그 이후의 운영 설계에 있습니다.

Hermes Agent의 역할은 여기서부터 두 가지로 나뉩니다.

병렬로 처리해도 되는 것 (Hermes가 담당)

  • 여러 채널 (Discord / CLI / webhook 등)로부터의 접수
  • 작업 (Job) 등록, 상태 관리, 진행 상황 알림
  • 가벼운 분류 (이것을 로컬로 보낼 것인가? 클라우드로 보낼 것인가?)
  • 완료 결과를 채널로 반환

직렬로 처리해야 하는 것 (Ollama가 담당)

  • 무거운 추론 (Inference)
  • 큰 컨텍스트 (Context)를 동반하는 생성
  • 모델 로드 (Model Load)를 동반하는 처리

사용자 입장에서는 「여러 스레드에서 병행하여 대화하고 있는」 상태가 유지됩니다. Hermes Agent는 접수 시 즉시 리액션 (reaction)을 보내 처리 상태를 보여주고, 완료 시 스레드로 답장을 보냅니다. 실제 추론은 그 이면에서 한 건씩 담담하게 진행됩니다.

이 구조가 본고에서 말하는 준병렬 처리 (Semi-parallel processing) 입니다. 접수·알림·분류는 병렬, 추론은 직렬입니다. 에이전트는 LLM의 래퍼 (Wrapper)가 아니라, LLM 앞에 배치하는 교통 정리 역할이 됩니다.

참고를 위해 로컬 Ollama 연결 부분만 발췌해 둡니다 (기사의 주제가 아닌 외부 LLM 프로바이더 (provider) 설정은 생략).

providers:
  ollama:
    api_key: ollama # 더미 값으로 OK
...

경량 4bit 모델로 교체하는 설정은:

model:
  default: gemma4:26b-a4b-it-q4_K_M # 4bit 경량 MoE (17GB)
  provider: ollama
...

설정 변경 후에는 hermes gateway restart로 반영합니다.

Hermes 측에서 「동일 세션 (session) 내의 병렬 입력」을 어떻게 다룰지는 별개의 문제입니다. 본고에서 채택한 Ollama 측의 NUM_PARALLEL=1 방식이, 여러 프로필(profile) 및 여러 채널을 가로지르는 병렬 처리를 일괄적으로 억제한다는 점에서 더 확실합니다.

구현 중에 빠졌던 함정들을 남겨둡니다.

1. launchctl setenv의 반영 타이밍

환경 변수를 설정해도 Ollama.app이 실행 중이면 반영되지 않습니다.
/api/version으로 작동 중임을 확인해도 내부의 환경 변수 (env)는 그대로입니다.
→ 반드시 killall Ollama를 한 뒤 open -a Ollama를 해야 합니다.

2. 「작동하는 척」하며 멈춰버리는 러너 (runner)

Ollama runner가 CPU를 23% 점유하고 있음에도, /api/generate가 무한히 응답하지 않는 상태가 발생합니다. ps 명령어로만 보면 활발하게 돌아가는 것처럼 보입니다.
/api/generate를 직접 호출하여 30초 타임아웃을 확인합니다. 확정되면 SIGKILL을 보내고 자동 재시작 (respawn) 시킵니다.

3. 세션 (Session) 이력 비대는 압축 (compression)만으로는 막을 수 없음

자동 압축을 활성화했음에도 47K 토큰 (token)에 도달한 사례가 있습니다. 임계값 (threshold) 계산이 「컨텍스트 창 (context window)에 대한 비율」이기 때문에, 큰 모델의 경우 임계값에 도달하기 전에 이미 프롬프트가 무거워지기 때문입니다.
→ 장시간 사용하는 스레드에서는 정기적으로 /new를 실행하거나, compression.threshold를 낮춥니다.

4. 시스템 LaunchAgents에 두면 작동하지 않음

/Library/LaunchAgents/에 plist를 두어도, Ollama.app은 GUI 앱으로서 사용자 세션에 종속되므로 효과가 없습니다.
~/Library/LaunchAgents/ 하위에 두어야 합니다.

5. Mac의 격리 (Quarantine) 속성

브라우저로 다운로드한 .zip에서 추출한 .app에는 격리 속성이 붙어 있어, 최초 실행 시 Gatekeeper 다이얼로그에서 멈춥니다. launch agent를 통해 실행하면 이 단계에서 막히게 됩니다.
xattr -dr com.apple.quarantine /Applications/Ollama.app 명령어를 사용하세요.

로컬 LLM (Local LLM) 운용에서 "여러 개의 동시 요청을 처리할 수 있도록 만들기"라는 방향으로 나아가다 보면 난관에 부딪히게 됩니다. 적어도 64GB 급의 Apple Silicon 단일 기기에서 35B 급 모델과 47K 토큰 (token) 급 프롬프트 (prompt)를 다루는 경우, 병렬로 구동할 여유는 없습니다.

정답은 반대 방향에 있습니다. 바로 접수의 병렬성을 에이전트 (Agent)에게, 추론 (Inference)의 직렬성을 LLM 서버 (LLM Server)에게 분리하는 것입니다. 이번에는 Ollama의 OLLAMA_NUM_PARALLEL=1과 같은 제한적인 환경 변수 (Environment Variable) 설정만으로 이를 달성할 수 있었습니다. Hermes Agent 측의 코드는 단 한 줄도 작성하지 않았습니다.

그 결과 얻은 것은 다음과 같습니다.

  • 완주 가능성: 4개의 입구에서 동시에 투입했을 때 정체 (stall)되던 것이 약 300초 만에 전 건 완료되었습니다. 수동으로 SIGKILL을 사용할 필요가 없어졌습니다. 이는 직렬화 (Serialization) 덕분이라고 강하게 말할 수 있습니다.
  • 응답 시간: 1건당 11분에서 1분 14초로 단축되었습니다. 다만, 이는 버전 업그레이드, 직렬화, 엔드포인트 (endpoint) 차이의 복합적인 효과이며, 순수하게 병렬 실행을 금지한 효과만은 아니라는 점에 주의해야 합니다.
  • 에러 소멸: Hermes 측의 타임아웃 (timeout) 에러가 0이 되었습니다.

에이전트를 병렬 큐 (Queue)로 사용하는 것은 LLM 앞에 무거운 구현을 얹는 문제가 아니라, "LLM의 동시 실행 수를 1로 설정하고, 에이전트를 유일한 요청 접수 창구로 만드는" 설계상의 역할 분담에 관한 이야기였습니다. 이것이 바로 **준병렬 처리 (Semi-parallel processing)**라는 발상의 핵심입니다.

# 환경 변수 확인
launchctl getenv OLLAMA_NUM_PARALLEL
launchctl getenv OLLAMA_MAX_LOADED_MODELS
...

본고의 현상은 다음 조건에서 재현되었습니다.

  • Apple Silicon 단일 기기 (Mac Studio M1 Ultra, 통합 메모리 64GB)
  • 35B 급 모델 + 47K 토큰 (token) 급 프롬프트 (prompt)
  • 여러 입구 채널 (Discord의 여러 프로필 (profile)을 포함한 3개 계통)로부터의 동시 액세스 (access)
  • 동일한 Ollama 인스턴스 (instance)에 대한 여러 Hermes 프로필 (profile)의 동시 접속

경량 모델이나 짧은 문장의 프롬프트, 혹은 더 큰 메모리를 탑재한 머신에서는 이 정도로 심각한 증상이 나타나지 않을 가능성이 있습니다.

역설적으로 말하면, 로컬 LLM의 병렬 실행은 환경 의존성이 극도로 강하며, 겉보기에는 작동하는 것처럼 보여도 임계치를 넘는 순간 파탄 난다는 뜻이기도 합니다.

본고의 주장 또한 그 임계치 안쪽에서 읽어주시길 바랍니다.

AI 자동 생성 콘텐츠

본 콘텐츠는 Qiita AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0