로컬 LLM 서버가 살아 있는데 틀리게 돌 때: 스트림 오류·빠진 의존성·설정 차이
- 로컬 모델 서버의 실패는 대개 조용하다. 서버는 떠 있고 채팅도 된다. 틀리는 것은 특정 기능, 스트리밍 중 오류, 실행 설정, 모델 버전이다.
- JSON 형식을 강제하는 기능에 필요한 부가 패키지가 빠져 그 요청만 실패했다. 평범한 채팅은 멀쩡해서 늦게 알았다. 쓰는 기능마다 요청을 하나씩 보내 보는 것이 기동 점검이다.
- 스트리밍 도중의 실패는 정상 응답(200)이 이미 나간 뒤 본문 안에 온다. 이걸 놓치면 "답이 비었다"로 잘못 읽고 엉뚱한 설정을 고친다.
- 손으로 띄운 서버는 정한 설정과 다르게 돌았고, 같은 이름의 모델 새 버전은 기본 샘플링 값이 바뀌어 있었다. 설정과 모델 버전을 고정하고, 띄울 때마다 실제 값과 대조한다.
32GB 메모리의 맥 미니(M6)에서 Qwen3.8-27B 모델을 돌렸다. 서버는 애플의 머신러닝 프레임워크 MLX 위에서 도는 오픈소스 추론 서버 MTPLX다. Ollama, LM Studio, llama.cpp 같은 다른 서버도 원리는 같지만 설정 이름과 기본값은 다르다.
- 가중치: 모델이 학습한 숫자들이 담긴 파일. 모델 파일의 대부분이다.
- 샘플링 값: 다음 단어를 고를 때 얼마나 무작위로 고를지 정하는 값. temperature가 높을수록 답이 다양해진다.
- 스트리밍: 답이 다 만들어질 때까지 기다리지 않고 한 조각씩 받아 오는 방식. 이 글의 예시는 OpenAI 호환 API의 스트리밍 형식이다.
1.헬스체크가 정상인데 무엇이 틀릴 수 있나
로컬에 모델 서버를 띄웠다. 헬스체크는 정상이고 모델 목록도 나온다. 채팅을 보내면 답도 온다. 그래서 준비됐다고 본다. 그런데 며칠 쓰다 보면 이상한 실패가 섞인다.
헬스체크가 알려 주는 것은 프로세스가 살아 있고 요청 하나를 처리할 수 있다는 것까지다. 서버가 의도한 대로 돌고 있는지는 말해 주지 않는다. 틀릴 수 있는 곳은 대략 네 군데다.
| 틀리는 곳 | 헬스체크 | 실제 증상 |
|---|---|---|
| 일부 기능에 필요한 패키지 | 정상 | 그 기능을 쓰는 요청만 실패 |
| 스트리밍 중 오류 | 정상 | "답이 비었다"로 잘못 읽힌다 |
| 실행 설정 | 정상 | 입력 길이 한도·메모리 설정이 의도와 다르다 |
| 모델 버전 | 정상 (이름도 같다) | 기본 샘플링이 바뀌어 답의 성격이 달라진다 |
2.일부 요청만 실패할 때
에이전트가 결과를 정해진 JSON 형식으로 받으려고 스키마를 걸어 요청한다. 이 요청만 거절된다. 스키마 없이 보내면 잘 된다. 요청 형식을 의심하게 되지만, 원인은 서버 설치에 있었다.
답을 스키마에 맞게 강제하는 기능은 보통 별도 라이브러리가 한다. 이 서버는 그 라이브러리를 "서버 기능을 쓸 때만 필요한 선택 항목"으로 두었는데, 패키지 관리자로 설치한 환경에는 빠져 있었다. 서버는 문제없이 뜨고, 그 기능을 쓰는 요청이 올 때만 실패한다.
- 쓰는 기능마다 요청을 하나씩 보내 본다. 평범한 채팅, 도구 호출, JSON 스키마, 긴 입력을 각각 한 번. 이 네 요청이 헬스체크보다 나은 기동 점검이다.
- 선택 항목까지 버전을 고정한다. 없거나 버전이 다르면 서버를 띄우지 않게 한다.
- 서버를 업그레이드한 뒤에는 다시 확인한다. 업그레이드하면서 설치 환경이 새로 만들어져, 손으로 넣은 패키지가 사라질 수 있다.
3.스트리밍 도중의 실패는 어디로 오나
긴 분석 도중 요청 하나가 실패했다. 클라이언트 기록은 "답이 비었다 — 출력 한도를 올려라"였다. 그런데 실제 원인은 서버의 메모리 부족 거절이었다. 게다가 이 실패를 다시 시도할 수 없는 오류로 분류해, 작업이 통째로 멈췄다.
답을 한 글자씩 받아 오는 스트리밍 응답은, 첫 글자를 보내기 전에 "성공(200)"이라는 상태를 먼저 보낸다. 그 뒤에 생긴 실패는 상태로 알릴 방법이 없으니 서버가 본문 조각 안에 오류를 담아 보낸다.
클라이언트가 상태와 답 글자만 본다면 오류 조각은 그냥 지나간다. 남는 것은 "성공인데 답이 없다"는 관찰이고, 거기서 가장 그럴듯한 추측이 "출력 한도에 닿았나"다. 그래서 스트림을 읽을 때는 조각마다 오류가 들어 있는지 먼저 본다.
for chunk in stream: err = chunk.get("error") if err or chunk["choices"][0].get("finish_reason") == "error": if (err or {}).get("code") == "insufficient_memory": raise MemoryBusy(err) # 잠시 뒤 다시 시도할 오류 raise StreamFailed(err) # "빈 답"으로 삼키지 않는다 ...
메모리 부족을 따로 구분하는 이유는 대응이 다르기 때문이다. 요청이 잘못된 것이 아니라 지금 자리가 없다는 뜻이니 기다리면 풀린다. 몇 초에서 1분 간격으로 몇 번까지만 다시 시도하게 둔다. 서버가 왜 메모리가 모자라다고 판단하는지는 메모리를 나누는 글에 정리했다.
32GB 맥에서 큰 로컬 LLM 돌릴 때 메모리 나누는 법: GPU 한도와 서버 캐시
서버는 요청을 넣었을 때 메모리가 넘칠 것 같으면 거절한다. 서버에 준 메모리는 서버가 캐시로 거의 다 쓴다.
4.손으로 띄운 서버와 서비스로 띄운 서버는 같은가
개발하면서 터미널에서 서버를 한 번 띄웠다. 잘 돌아서 그대로 뒀다. 나중에 보니 이 서버는 정한 설정과 다르게 돌고 있었다. 명령에 옵션 몇 개를 빠뜨렸고, 빠진 자리는 서버 기본값이 채웠다.
| 항목 | 정한 값 | 손으로 띄운 서버 |
|---|---|---|
| 받아 줄 입력 길이 | 6만5천 토큰 | 3만2천 토큰 (기본값) |
| 문맥 캐시(KV 캐시)를 8bit로 저장해 메모리 아끼기 | 켬 | 꺼짐 |
둘 다 서버가 오류 없이 받아들이는 값이라 바로 드러나지 않는다. 긴 입력이 잘리거나 메모리가 예상보다 빨리 차는 식으로 한참 뒤에 증상이 나온다.
- 서버를 띄우는 경로를 하나로 만든다. 설정 파일 한 곳에서 옵션을 만들어 띄우는 스크립트를 두고, 운영체제의 서비스 관리자(macOS라면 launchd)가 그 스크립트로만 띄우게 한다.
- 띄운 뒤 실제로 무엇으로 떴는지 본다. 서버가 시작할 때 찍는 로그, 프로세스의 실행 인자, 상태 조회 API에서 입력 길이 한도와 메모리 설정을 읽어 정한 값과 비교한다.
5.같은 모델인데 답의 성격이 달라졌다면
모델 저장소의 이름은 그대로인데 내용은 바뀔 수 있다. 가중치가 그대로여도, 서버가 함께 읽는 설정 파일 하나만 바뀌면 답이 달라진다. 이번에 바뀐 것은 샘플링 기본값이었다.
받아 둔 모델과 저장소의 최신 버전을 파일마다 해시로 비교했다. 가중치 파일은 전부 같았고, 달라진 파일은 설정 파일 하나였다. 그 안에서 기본 temperature가 0.6에서 1.0으로 바뀌어 있었다. 요청에 temperature를 적지 않는 클라이언트라면 이 변경 하나로 답이 더 다양해진다. 모델 이름만 보면 아무 일도 없었다.
- 모델은 이름이 아니라 버전(커밋)으로 고정한다.
- 가중치는 파일마다 해시를 기록해 둔다. 새 버전으로 올릴 때 무엇이 바뀌었는지 파일 단위로 비교한다.
- 샘플링 값은 서버 기본값에 맡기지 않고 요청에 적는다.
6.띄우기 전에 무엇을 대조하나
네 가지 모두 정한 값과 실제 값을 나란히 놓으면 보인다. 서버를 띄우기 전과 띄운 직후에 대조할 목록이다.
| 대조할 것 | 실제 값을 읽는 곳 |
|---|---|
| 서버 버전 | 버전 확인 명령 |
| 모델 버전·가중치 해시 | 받아 둔 모델의 출처 기록, 파일 해시 |
| 입력 길이 한도·메모리 설정 | 시작 로그, 실행 인자, 상태 조회 API |
| 선택 패키지 버전 | 서버가 설치된 환경의 패키지 목록 |
| 기능별 동작 | 채팅·도구 호출·JSON 스키마·긴 입력 요청 각 1회 |
점검 스크립트가 어떤 값을 읽지 못하면 "문제없음"이 아니라 "확인 불가"로 알린다. 예를 들어 관리자 권한으로 설정한 값은 일반 권한의 점검에서 안 보일 수 있다. 점검이 볼 수 있는 곳과 없는 곳을 먼저 구분해 둔다.
7.이 결론이 안 통하는 조건
- 클라우드 API. 서버 설정과 모델 버전은 제공자가 관리한다. 다만 스트리밍 도중의 오류 처리(3절)는 클라우드 API에서도 필요할 수 있으니, 제공자 문서에서 스트림 중 오류가 어떻게 오는지 확인해 둔다.
- 컨테이너 이미지로 고정해 배포하는 서버. 서버·패키지·가중치를 이미지에 함께 넣고 버전 태그 대신 이미지 내용으로 만든 고유 해시(다이제스트)로 띄우면 2·5절의 문제가 크게 줄어든다. 실행 설정(4절)은 여전히 확인한다.
- 스트리밍을 쓰지 않는 클라이언트. 답을 한 번에 받으면 오류가 응답 상태로 온다. 대신 첫 글자를 보기까지 오래 기다린다.
8.정리
- 설정·모델 버전·가중치 해시·선택 패키지 버전을 한 파일에 적어 고정한다.
- 서버는 그 파일을 읽는 스크립트로만 띄우고, 서비스 관리자가 관리하게 한다.
- 띄운 직후 실제 값을 읽어 정한 값과 대조한다.
- 쓰는 기능마다 요청을 하나씩 보내 본다.
- 스트림은 조각마다 오류를 먼저 본다. 메모리 부족은 몇 번만 재시도한다.
- 샘플링 값은 요청에 적는다.
- 읽지 못한 점검은 "확인 불가"로 둔다.
네 번 모두 서버는 살아 있었고 헬스체크는 정상이었다. 살아 있다는 신호와 의도대로 돈다는 증거는 따로 모아야 한다.
'프로그래밍 > AI' 카테고리의 다른 글
| 추론 모델 에이전트가 느린 이유는 생각 토큰: thinking을 끄기 전에 확인할 것 (0) | 2026.09.26 |
|---|---|
| 로컬 LLM이 매번 입력을 처음부터 다시 읽을 때: prefix 캐시 확인하는 법 (0) | 2026.09.25 |
| 32GB 맥에서 큰 로컬 LLM 돌릴 때 메모리 나누는 법: GPU 한도와 서버 캐시 (0) | 2026.09.25 |
| 맥에서 로컬 LLM 속도 미리 가늠하기: 메모리 대역폭과 투기적 디코딩 (0) | 2026.09.25 |
| AI가 만든 JSON이 계속 퇴짜맞을 때: LLM 호출을 75% 줄인 세 가지 처방 (0) | 2026.08.23 |
댓글