외부 API 대신 모델 파일을 내려받아 내 컴퓨터에서 돌리면 무엇이 달라질까. 문서와 질문이 밖으로 나가지 않고, 가중치와 추론 설정을 직접 고정할 수 있다. 대신 모델을 메모리에 올리고, 요청을 처리하고, 장애를 복구하는 일이 운영자의 몫이 된다. 아래 기록은 작은 공개 모델 Qwen3-0.6B를 실제로 내려받아 한울연구소 문서에 네 가지 질문을 던진 결과다. 모델이 문서에 있는 답을 못 찾은 결과도 그대로 둔다.
공개 가중치와 오픈소스
가중치를 내려받을 수 있다는 것은 추론에 필요한 숫자에 접근할 수 있다는 뜻이다. 학습 데이터, 정제 과정, 학습 코드는 따로다. 그래서 이 책은 내려받을 수 있는 가중치를 ‘공개 가중치 모델’이라고 부르고 사용 권한은 라이선스로 따로 본다. 여기서 쓰는 Qwen/Qwen3-0.6B는 고정한 리비전의 LICENSE가 Apache License 2.0이다. 제삼자가 변환한 파일을 쓸 때는 원본과 변환본의 출처·조건을 다시 기록한다. 실습 리비전의 LICENSE
공개 가중치는 선택권을 늘리지만 운영 비용을 없애지는 않는다. API 청구 대신 장비, 실행 시간, 저장 공간, 배포와 유지보수 비용이 든다. 호출당 가격이 없다고 비용을 0으로 적지 않는다.
모델 카드와 실행 묶음
모델 카드는 후보를 빠르게 이해하는 출발점이다. 작업, 입력 형태, 사용 예, 제한 사항을 읽고 우리 요구사항과 맞춰 본다. 벤치마크 점수는 후보를 좁히는 참고일 뿐, 한국어 사내 문서의 금액·시행일·권한을 제대로 다룬다는 보증은 아니다. ‘다국어’나 ‘도구 사용’이라는 표현도 요구사항의 통과 표시로 옮기지 않고, 요구사항마다 검사할 입력을 준비한다.
| 확인할 항목 | 확인 이유 | 이 장의 선택 |
|---|---|---|
| 작업 유형 | 생성·임베딩·분류 모델을 구별한다 | 인과 언어 모델의 텍스트 생성 |
| 대화 입력 형식 | 역할과 메시지 경계를 맞춘다 | 저장소의 채팅 템플릿 |
| 파일과 리비전 | 다음 실행에서 같은 자료를 쓴다 | 전체 커밋 식별자 고정 |
| 로딩 자료형 | 메모리와 연산 조건을 정한다 | CPU의 float32 |
| 실행 엔진 | 모델 구조와 파일을 해석한다 | Transformers와 PyTorch |
| 업무 적합성 | 프로젝트 요구사항을 확인한다 | 가상 문서 질문 4개를 실제 실행 |
작은 Qwen3 모델을 고른 이유는 이 환경에서 다운로드와 단일 요청 추론을 돌려 입력·출력을 관찰하기 위해서다. 모델 카드가 안내하는 비사고 모드를 따라 enable_thinking=False를 지정한다. 모델을 바꾸면 같은 이름의 옵션이 있는지부터 다시 본다. 기본 모델과 대화용으로 조정된 모델도 구별한다. 이름의 접미사가 아니라 카드의 작업 설명과 입력 예제를 읽는다. Qwen3-0.6B 모델 카드
저장소 식별자만 적으면 어느 시점의 파일을 썼는지 남지 않는다. 브랜치의 최신 파일이 바뀌면 같은 코드가 다른 템플릿과 설정을 읽는다. 실행 기록에는 전체 커밋 식별자를 남긴다. Hub 다운로드 안내
model: Qwen/Qwen3-0.6B
revision: c1899de289a04d12100db370d81485cdf75e47ca
Python: 3.14.6
transformers: 5.16.1
torch: 2.14.0
safetensors: 0.8.0
huggingface-hub: 1.30.0
device: cpu
dtype: float32
CPU threads: 4
토크나이저, 설정, 가중치는 한 묶음이다. 다른 모델의 토크나이저를 섞으면 같은 문장이 다른 숫자열이 되어 결과가 나빠지거나 실행이 실패한다. 다운로드와 추론은 별도 단계다. 어댑터의 --offline은 local_files_only=True로 캐시에서만 찾고, 캐시가 불완전하면 분명하게 실패한다. trust_remote_code=False를 지정해 모델 저장소의 임의 Python 코드는 실행하지 않는다. 별도 코드를 요구하는 후보라면 옵션을 켜는 대신 그 구현과 의존성을 검토해 묶음에 넣는다.
로컬 추론의 흐름
source books/ai-engineering/examples/.venv/bin/activate
python -m pip install -r books/ai-engineering/examples/requirements-local.txt
python books/ai-engineering/examples/local_model.py '교육비 한도와 신청 순서를 알려 줘'
python books/ai-engineering/examples/local_model.py --offline '교육비 한도는?'
전체 구현은 books/ai-engineering/examples/local_model.py에 있고, 가중치는 예제 디렉터리의 .model-cache에 저장하며 Git에는 넣지 않는다. 첫 실행의 load_seconds에는 다운로드가 들어갈 수 있으니 캐시가 준비된 실행과 나눠 기록한다. 질문마다 모델을 다시 올리는 CLI 시간과 한 프로세스가 모델을 유지하며 여러 요청을 처리하는 시간도 다르다.
messages = [
{"role": "system", "content": instructions},
{"role": "user", "content": f"{context}\n\n[사용자 질문]\n{question}"},
]
inputs = self.tokenizer.apply_chat_template(
messages,
tokenize=True,
add_generation_prompt=True,
enable_thinking=False,
return_dict=True,
return_tensors="pt",
).to(self.device)
애플리케이션은 role과 content가 있는 메시지를 만들지만 모델이 처리하는 것은 토큰의 숫자열이다. 채팅 템플릿이 역할과 메시지 경계를 그 모델의 특수 토큰으로 바꿔 준다. user:와 assistant:를 손으로 붙이는 방식이 학습 형식과 같다고 볼 수 없다. tokenize=True로 템플릿 적용과 토큰화를 한 번에 하면 특수 토큰이 두 번 들어가는 실수가 줄고, add_generation_prompt=True가 assistant 응답이 시작될 자리를 표시한다. 템플릿은 입력 형식이지 문서 속 악성 지시를 막는 권한 장치가 아니다. Transformers 채팅 템플릿 안내
size = inputs["input_ids"].shape[-1]
with self.torch.inference_mode():
output = self.model.generate(
**inputs,
max_new_tokens=output_limit,
do_sample=False,
)
tokens = output[0, size:].tolist()
text = self.tokenizer.decode(tokens, skip_special_tokens=True).strip()
생성 결과에는 입력 토큰도 들어 있어서 전체를 디코딩하면 문서와 질문까지 답변처럼 보인다. 입력 길이 뒤의 새 토큰만 잘라 디코딩한다. max_new_tokens는 새 토큰 수의 상한이고, 어댑터는 입력 길이와 출력 예약량의 합이 실습 예산 4,096토큰(모델 설정의 최대값이 더 작으면 그 값)을 넘는지 생성 전에 검사한다. do_sample=False는 이번 실험의 생성 경로를 고정하는 선택이고, 모델 카드의 품질 권장 설정과는 다르다. Transformers 시작 안내
종료 토큰과 상태
생성된 문자열이 있다고 바로 ok가 아니다. 마지막 토큰이 모델 설정의 EOS(종료 토큰)에 속하면 ok, 종료 토큰 없이 한도에 닿았으면 incomplete로 분류하고 부분 답변을 비운다. 정상 종료인데 빈 텍스트면 unexpected_output이다. 로컬 호출에는 제공자의 요청 ID가 없어 그 필드는 None이고, 사용량은 이 토크나이저로 계산한 입력·생성 토큰 수라 청구서의 수치와 다르다. 다운로드 오류와 메모리 부족은 예외로 올라온다. 서비스로 붙일 때는 모델 로드 실패와 개별 요청 실패를 나눠 준비 상태와 오류 응답에 반영한다.
실행 도구
| 방식 | 선택할 때의 목적 | 확인할 것 |
|---|---|---|
| Transformers 직접 로딩 | Python에서 입력·토큰·자료형을 관찰한다 | 장치 배치, 메모리, 생성 결과 처리 |
| Ollama | 로컬 모델을 별도 서버의 API로 호출한다 | 모델 식별, 스트림 종료, 서버 생명주기 |
| LM Studio | 로컬 모델 실험과 개발용 API 연결을 함께 본다 | 모델 로드 상태, 엔드포인트와 지원 기능 |
Ollama의 채팅 API는 messages를 받고 done으로 완료를 표현하며, 기본이 스트리밍인지 확인해서 클라이언트를 짠다. LM Studio도 개발자용 API가 있다. 두 제품은 설치하지 않았고, 표는 성능 순위가 아니라 실행 방식을 고르는 기준이다. ‘API 호환’이어도 기능은 다르다. 요청 필드, 지원 모델, 도구 호출, 구조화 출력, 스트림 이벤트, 사용량 집계를 각각 본다. strict JSON Schema 검증은 평문 생성 함수로 자동으로 옮겨지지 않아서, 로컬 엔진의 제약 생성 기능을 쓰거나 별도 파싱·검증 경로를 만든다. Ollama 채팅 API, LM Studio 개발자 문서
메모리와 양자화
파일을 저장할 공간과 추론할 메모리는 다른 자원이다. 낮은 정밀도로 저장된 파일도 로딩하면서 다른 자료형으로 바뀔 수 있다. 실습은 CPU float32로 올렸고, 실제 객체의 파라미터 596,049,920개 × 4바이트 = 2,384,199,680바이트가 파라미터 텐서의 합이다. 양자화는 수치를 더 적은 비트로 근사해 저장 공간과 메모리를 줄일 가능성이 있지만, 장치와 커널의 지원, 지연, 품질 변화는 같은 평가 사례로 따로 재야 한다. 이 기록은 양자화하지 않은 float32라 그림의 16비트·4비트 크기는 계산값이다. 양자화 방식 안내
메모리 부족은 어느 단계인지부터 본다. 로딩 중이면 가중치와 임시 복사본, 긴 질문에서만 실패하면 입력 길이와 생성 캐시, 요청이 겹칠 때 실패하면 동시 실행 수다. 지연도 입력 처리와 순차 생성으로 나눈다. 생성 토큰 수를 전체 시간으로 나눈 값에는 토큰화와 입력 처리가 섞여 있어서 순수 디코딩 처리량과 다르다. 어댑터에 Apple 장치용 mps 선택지가 있지만 아래 결과는 CPU에서만 얻었다.
네 가지 질문의 실측
python books/ai-engineering/examples/local_evaluation.py > /tmp/local-evaluation-new.json
환경은 macOS 26.6.2 arm64, CPU 4스레드, float32, 샘플링 없음, 새 토큰 상한 128이다. 워밍업과 병렬 요청은 없었고, 캐시에서 모델을 올리는 데 약 3.53초가 걸렸다. 기록은 books/ai-engineering/experiments/local-cpu.json에 있고 문서와 질문은 모두 가상 자료다.
| 사례 | 기대 행동 | 실제 결과 |
|---|---|---|
| L01 교육비 한도와 신청 순서 | 금액·분기 단위·승인·포털 신청 전달 | “문서에서 확인할 수 없다”고 답함 |
| L02 한도 문장 그대로 옮기기 | 한도 문장만 출력 | 금액 문장은 정확하지만 안내 문구와 따옴표를 덧붙임 |
| L03 출장 숙박비 한도 | 문서에 없는 금액을 만들지 않음 | “확인할 수 없다”고 답함 |
| L04 문서 없이 교육비 질문 | 근거 부족으로 유보 | “확인할 수 없다”고 답함 |
네 호출 모두 종료 토큰을 만들어 status="ok"였다. 그런데 L01의 입력 문서에는 분기당 30만 원, 구매 전 팀장 승인, 지원 포털 신청이 다 있었다. L02가 단서다. 같은 문서에서 한도 문장을 그대로 옮기라고 하자 금액과 단위가 든 문장이 나왔으니, L01의 실패가 문서가 전달되지 않아서라고 볼 수는 없다. 복합 질문을 나누거나 지시문을 바꾼 비교 실험이 다음 순서다. L03·L04의 유보는 바람직하지만 언제나 유보하는 모델도 그 둘은 통과한다. 답변 가능 사례와 불가능 사례를 따로 봐야 하고, L02도 금액이 맞다는 이유로 ‘문장만’이라는 형식 요구를 통과한 것으로 치지 않는다.
각 사례의 두 실행은 결과가 같았다. 네 사례를 두 번 본 기록이라 독립 표본 여덟 개와 다르고, 권한·문서 판 충돌·도구·구조화 출력은 이 집합이 검사하지 않는다.
호스팅 API와의 비교
질문만 같게 보내는 것으로는 부족하다. 문서, 지시문, 대화 이력, 출력 요구, 채점 기준을 맞추고 모델별 채팅 템플릿이 만든 실제 전송 형태도 기록한다. 같은 출력 토큰 상한이라도 한국어 분량은 다르다. 원가는 API가 입력·출력·캐시 사용량에 단가를 곱한 값이고, 로컬은 장비 비용 배분과 가동 시간, 운영 비용이다. 둘 다 성공한 업무 한 건당 비용과 대기 시간으로 비교한다. L01처럼 빨리 틀린 결과는 짧은 시간이 채택 근거가 되지 않는다. 이 기록에는 외부 API의 실제 호출이 없으므로 우열이나 절감률은 없다. 모의 전송 테스트는 API 처리 경로의 증거고 로컬 기록은 특정 모델의 실제 출력이라, 둘을 한 품질 표에 섞지 않는다.
실습: 실행 기록을 선택 판단으로 바꾸기
- L01을 금액 질문과 신청 순서 질문으로 나눠 실행한다. 원래 결과를 보존하고, 나눈 질문이 각각 필요한 사실을 주는지 본다. 좋아져도 호출 수와 전체 지연이 늘 수 있으니 시간도 함께 적는다.
- 출력 한도를 아주 작게 두고
incomplete경로를 본다. 반환 객체에 부분 답변이 없어야 한다. 입력·출력 합계가 예산을 넘는 질문을 만들어 생성 전에 거부되는지도 본다. - 모델이나 자료형 중 하나만 바꿔 같은 사례를 돌린다. 리비전·패키지·장치·생성 설정을 다 남기고, 어떤 사례가 좋아지고 어떤 사례가 나빠지는지 본다. 평균 시간 하나로 답변 실패를 덮지 않는다.
이 어댑터는 모델을 호출하는 최소 구현이다. 한울연구소 도우미에 쓰려면 구조화 출력, 권한이 반영된 문맥, 인용 검증, 전체 평가 집합을 다시 연결해야 한다.