보안 담당자가 봇이 도는 것을 보다가 한 가지를 물었다. 사내 규정 원문이 매 요청마다 외부 API로 나가고 있는데 그래도 되느냐는 것이었다. 교육비 규정 정도야 그렇다 쳐도, 총무팀이 넘기겠다는 인사·복리후생 문서까지 같은 통로로 나간다고 생각하니 개발팀도 대답이 나오지 않았다.
그래서 개발팀은 모델 파일을 내려받아 사내에서 직접 돌리는 쪽을 따져 봤다. 그렇게 하면 문서와 질문이 밖으로 나가지 않고, 가중치와 추론 설정도 직접 고정할 수 있다. 대신 모델을 메모리에 올리고, 요청을 처리하고, 장애를 복구하는 일이 운영자의 몫이 된다. 개발팀은 작은 공개 모델 Qwen3-0.6B를 실제로 내려받아 한울연구소 문서에 네 가지를 물어봤다. 모델이 문서에 있는 답을 못 찾은 것도 실행 기록에 그대로 남겼다.
공개 가중치와 오픈소스
가중치를 내려받을 수 있다는 것은 추론에 필요한 숫자에 접근할 수 있다는 뜻이다. 학습 데이터, 정제 과정, 학습 코드는 따로다. 그래서 이 책에서는 내려받을 수 있는 가중치를 ‘공개 가중치 모델’이라고 부르고 사용 권한은 라이선스로 따로 본다. 개발팀이 고른 Qwen/Qwen3-0.6B는 고정한 리비전의 LICENSE가 Apache License 2.0이다. 제삼자가 변환한 파일을 쓸 때는 원본과 변환본의 출처·조건을 다시 기록한다. 이 리비전의 LICENSE
공개 가중치는 선택권을 늘리지만 운영 비용을 없애지는 않는다. API 청구 대신 장비, 실행 시간, 저장 공간, 배포와 유지보수 비용이 든다. 호출당 가격이 없다고 비용을 0으로 적지 않는다.
모델 카드와 실행 묶음
모델 카드는 후보를 빠르게 이해하는 출발점이다. 작업, 입력 형태, 사용 예, 제한 사항을 읽고 봇이 지켜야 할 요구사항과 맞춰 본다. 벤치마크 점수는 후보를 좁히는 참고일 뿐, 한국어 사내 문서의 금액·시행일·권한을 제대로 다룬다는 보증은 아니다. ‘다국어’나 ‘도구 사용’이라고 적혀 있다고 그 요구사항을 통과한 것으로 치지 않는다. 항목마다 검사할 입력을 따로 준비한다.
| 확인할 항목 | 확인 이유 | 이번에 고른 값 |
|---|---|---|
| 작업 유형 | 생성·임베딩·분류 모델을 구별한다 | 인과 언어 모델의 텍스트 생성 |
| 대화 입력 형식 | 역할과 메시지 경계를 맞춘다 | 저장소의 채팅 템플릿 |
| 파일과 리비전 | 다음 실행에서 같은 자료를 쓴다 | 전체 커밋 식별자 고정 |
| 로딩 자료형 | 메모리와 연산 조건을 정한다 | CPU의 float32 |
| 실행 엔진 | 모델 구조와 파일을 해석한다 | Transformers와 PyTorch |
| 업무 적합성 | 프로젝트 요구사항을 확인한다 | 가상 문서 질문 4개를 실제 실행 |
개발팀은 다운로드와 단일 요청 추론을 직접 돌려 입력과 출력을 눈으로 보려고 작은 Qwen3 모델을 골랐다. 모델 카드가 안내하는 비사고 모드로 돌린다. 모델을 바꾸면 같은 이름의 옵션이 있는지부터 다시 보고 기본 모델인지 대화용으로 조정된 모델인지도 구별한다. 이때 보는 것은 이름의 접미사가 아니라 카드의 작업 설명과 입력 예제다. 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
토크나이저, 설정, 가중치는 한 묶음이다. 다른 모델의 토크나이저를 섞으면 같은 문장이 다른 숫자열이 되어 결과가 나빠지거나 실행이 실패한다. 다운로드와 추론은 서로 다른 단계로 나뉜다. 오프라인으로 돌리면 캐시에서만 모델을 찾고, 캐시가 불완전하면 그대로 오류를 낸다. 모델 저장소에 딸려 온 임의 Python 코드는 실행하지 않도록 막아 둔다. 별도 코드를 요구하는 후보라면 옵션을 켜는 대신 그 구현과 의존성을 검토해 묶음에 넣는다.
로컬 추론의 흐름
내려받은 가중치는 따로 캐시에 두고 저장소에는 넣지 않는다. 첫 실행에는 다운로드 시간이 섞이니 캐시가 준비된 실행과 나눠 기록한다. 질문마다 모델을 다시 올리는 CLI와 모델을 유지한 채 여러 요청을 처리하는 프로세스도 시간을 따로 잰다.
애플리케이션은 role과 content가 있는 메시지를 만들지만 모델이 처리하는 것은 토큰의 숫자열이다. 채팅 템플릿이 역할과 메시지 경계를 그 모델의 특수 토큰으로 바꿔 준다. user:와 assistant:를 손으로 붙이는 방식이 학습 형식과 같다고 볼 수 없다. 템플릿 적용과 토큰화를 한 번에 시키면 특수 토큰이 두 번 들어가는 실수가 준다. 그리고 답이 시작될 자리를 템플릿이 표시하게 둔다. 템플릿은 입력 형식이지 문서 속 악성 지시를 막는 권한 장치가 아니다. Transformers 채팅 템플릿 안내
생성 결과에는 입력 토큰도 들어 있어서 전체를 디코딩하면 문서와 질문까지 답변처럼 보인다. 입력 길이 뒤의 새 토큰만 잘라 디코딩한다. 새로 만들 토큰 수에 상한을 두고, 입력 길이와 출력 예약량의 합이 예산 4,096토큰(모델 설정의 최대값이 더 작으면 그 값)을 넘는지 생성 전에 검사한다. 샘플링을 끄면 이번 실험의 생성 경로가 고정된다. 모델 카드가 품질을 위해 권하는 설정은 따로 있다. Transformers 시작 안내
종료 토큰과 상태
생성된 문자열이 있다고 바로 ok가 아니다. 마지막 토큰이 모델 설정의 EOS(종료 토큰)에 속하면 ok로 분류한다. 종료 토큰 없이 한도에 닿았으면 incomplete로 분류하고 부분 답변을 비운다. 정상 종료인데 빈 텍스트면 unexpected_output이다. 로컬 호출에는 제공자의 요청 ID가 없어 그 자리는 비어 있고, 사용량은 이 토크나이저로 계산한 입력·생성 토큰 수다. 청구서에 찍히는 수치는 제공자가 자기 기준으로 센다. 다운로드 오류와 메모리 부족은 예외로 올라온다. 서비스로 붙일 때는 모델 로드 실패와 개별 요청 실패를 나눠 준비 상태와 오류 응답에 반영한다.
실행 도구
| 방식 | 선택할 때의 목적 | 확인할 것 |
|---|---|---|
| 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에서만 돌렸다.
실제 문서로 돌려 본 기록
환경은 macOS 26.6.2 arm64, CPU 4스레드, float32, 샘플링 없음, 새 토큰 상한 128이다. 워밍업과 병렬 요청은 없었고 캐시에서 모델을 올리는 데 약 3.53초가 걸렸다. 문서와 질문은 모두 가상 자료다.
| 사례 | 기대 행동 | 실제 결과 |
|---|---|---|
| L01 교육비 한도와 신청 순서 | 금액·분기 단위·승인·포털 신청 전달 | “문서에서 확인할 수 없다”고 답함 |
| L02 한도 문장 그대로 옮기기 | 한도 문장만 출력 | 금액 문장은 정확하지만 안내 문구와 따옴표를 덧붙임 |
| L03 출장 숙박비 한도 | 문서에 없는 금액을 만들지 않음 | “확인할 수 없다”고 답함 |
| L04 문서 없이 교육비 질문 | 근거 부족으로 유보 | “확인할 수 없다”고 답함 |
네 호출 모두 종료 토큰을 만들어 상태가 ok였다. 그런데 개발팀이 L01에 넣은 문서를 다시 열어 보니 분기당 30만 원, 구매 전 팀장 승인, 지원 포털 신청이 정작 거기 다 있었다. 단서는 L02였다. 같은 문서에서 한도 문장을 그대로 옮기라고 하자 금액과 단위가 든 문장이 나왔으니, L01의 실패가 문서가 전달되지 않아서라고 볼 수는 없다. 그래서 다음에는 복합 질문을 나눠 보고 지시문도 바꿔 가며 다시 물어본다. L03·L04의 유보는 바람직하지만 언제나 유보하는 모델도 그 두 사례는 통과한다. 답변 가능 사례와 불가능 사례는 따로 봐야 한다. L02도 금액이 맞다는 이유로 ‘문장만’이라는 형식 요구를 통과한 것으로 치지 않는다.
개발팀은 같은 네 사례를 한 번 더 돌려 봤는데 결과가 그대로였다. 두 번씩 돌렸어도 물어본 것은 네 가지라 표본은 여덟이 아니라 넷이다. 권한·문서 리비전 충돌·도구·구조화 출력은 이 네 질문으로 확인하지 못한다.
호스팅 API와의 비교
질문만 같게 보내는 것으로는 부족하다. 문서, 지시문, 대화 이력, 출력 요구, 채점 기준을 맞추고 모델별 채팅 템플릿이 만든 실제 전송 형태도 기록한다. 같은 출력 토큰 상한이라도 한국어는 담기는 분량이 줄어든다. 비용은 API가 입력·출력·캐시 사용량에 단가를 곱한 값이고, 로컬은 장비 비용 배분과 가동 시간, 운영 비용이다. 둘 다 성공한 업무 한 건당 비용과 대기 시간으로 비교한다. L01처럼 빨리 나왔어도 답이 틀렸으면 짧은 시간을 채택 근거로 삼지 못한다. 이 기록에는 외부 API의 실제 호출이 없으므로 우열이나 절감률은 없다. 모의 전송 테스트는 API 처리 경로의 증거고 로컬 기록은 특정 모델의 실제 출력이라, 둘을 한 품질 표에 섞지 않는다.
개발팀은 규정 원문을 한 줄도 밖으로 내보내지 않고 사내 장비에서 답을 받아 냈다. 보안 담당자가 그래도 되느냐고 물었을 때 내놓을 것이 없었는데 이제는 그 실행 기록이 있다. 다만 사내에서 돌린 쪽만 재 봤으니 외부 API를 같은 조건으로 불러 견주는 일이 남았고, 무엇을 쓸지는 그다음에 고른다.