· 약 12분 읽기

Mac Mini M4 + MLX + Qwen으로 API 비용 0원 AI 비서 만들기


사실 오픈클로가 성능 면에서는 훨씬 뛰어난게 사실이다. 하지만 오픈클로를 쓰려면 ChatGPT Oauth 구독료를 매달 지불해야 하는데 개인적으로 단순한 업무 보조 정도 기능만 쓰는 편이라서 굳이 비용을 지출할 필요가 있을까 하는 생각이 들었다. 맥 미니 M4 24GB에 이미 로컬 LLM을 돌리고 있었으니까, 이걸로 실제 업무를 도와주는 심플 에이전트를 만들어보기로 했다.

결론부터 말하면 — API 비용 0원으로 이메일 발송, 캘린더 관리, Google Drive 동기화, 보고서 작성까지 되는 AI 비서를 만들었다. 완벽하진 않지만, 실용적이다.


출발점: MLX + Qwen 듀얼 모델

이전 글에서 MLX로 Qwen3.5-35B-A3B 3bit 모델을 올려서 59.5 tok/s를 달성했었다. 여기에 더해 깊은 사고가 필요한 작업을 위해 Qwen3.5-27B Opus Distilled 모델도 함께 쓰기로 했다.

모델용도속도메모리
35B-A3B 3bit (Fast)도구호출, 빠른 응답~59 tok/s15.2GB
27B Opus Distilled (Deep)분석, 추론, 작문~4 tok/s17.8GB

두 모델을 하나의 에이전트에서 전환하며 쓰는 구조다. MLX 서버를 하나만 띄우고, 모델 전환 시 서버를 재시작한다 (약 10초 소요).


첫 번째 삽질: 자동 라우팅의 환상

처음에는 “사용자 입력을 분석해서 적절한 모델로 자동 라우팅”하는 멋진 구조를 만들었다. 키워드 기반으로 “분석”, “설명”, “왜” 같은 단어가 있으면 Deep 모델로, “파일”, “실행”, “검색” 같은 단어가 있으면 Fast 모델로 보내는 식이다.

DEEP_KEYWORDS = ["분석", "설명", "왜", "비교", "작성해", "써줘", ...]
FAST_KEYWORDS = ["파일", "실행", "검색", "코드", "git", ...]

def route_model(user_input):
    fast_score = sum(1 for kw in FAST_KEYWORDS if kw in text)
    deep_score = sum(1 for kw in DEEP_KEYWORDS if kw in text)
    return "fast" if fast_score > deep_score else "deep"

현실: 잘 안 된다. “보고서 작성해줘”는 Deep으로 가야 하는데 “파일 읽고 보고서 작성해줘”는 양쪽 키워드가 다 걸린다. 자동 라우팅이 틀릴 때마다 10초짜리 모델 전환이 발생하니 체감은 더 나빠진다.

더 근본적인 문제도 있었다. 로컬 모델은 멀티턴 대화에서 맥락을 유지하는 능력이 약하다. “방금 저장한 보고서 보여줘”라고 하면 이전 대화를 참조하지 못하고 엉뚱한 응답을 한다. 모델이 작성한 보고서를 확인해보면 파일을 실제로 읽지도 않고 제목만 보고 내용을 지어내는 경우도 있었다.

핵심 깨달음

로컬 LLM은 대화형 AI가 아니라, 자동화 파이프라인의 부품으로 써야 한다.

특정 지시사항을 원샷으로 보내고 결과값만 쓰는 방식. 이 깨달음이 프로젝트의 방향을 완전히 바꿨다.


방향 전환: 스킬 기반 자동화

대화형 AI를 포기하고, 일상 업무를 자동화하는 스킬 시스템으로 전환했다. 모델은 스킬이 필요할 때만 호출하고, 나머지는 코드가 처리한다.

3단계 입력 라우터

사용자 입력이 들어오면 이 순서로 처리한다:

1단계: /명령어 → 직접 스킬 호출 (/memo, /mail, /cal 등)
2단계: 자연어 키워드 감지 → 스킬 자동 매칭 ("메일 확인해줘" → mail 스킬)
3단계: 매칭 없으면 → LLM 모델에게 전달 (범용 대화)

스킬 아키텍처

class Skill(abc.ABC):
    name: str = ""
    description: str = ""
    detect_keywords: list[str] = []    # 자연어 감지용
    exclude_keywords: list[str] = []   # 이 키워드가 있으면 감지 스킵

    @abc.abstractmethod
    def execute(self, raw_input, client, model) -> SkillResult:
        ...

각 스킬은 detect_keywords로 자연어 매칭되고, exclude_keywords로 잘못된 매칭을 방지한다. 예를 들어 draft 스킬은 “작성해줘”에 반응하지만, “읽고”, “분석해” 같은 단어가 함께 있으면 모델에게 넘긴다 (스킬은 파일을 못 읽으니까).


구현한 7가지 스킬

1. /memo — 메모

> 우유 사기 메모해줘
✅ 메모 저장: 우유 사기

> /memo list
  1. 우유 사기 [2026-03-16]
  2. 프로젝트 미팅 준비 [2026-03-15]

~/.agent/memos.jsonl에 저장. 자연어 입력에서 “메모해줘”, “적어둬”, “기억해줘” 같은 지시어를 제거하고 핵심만 추출한다. 이 “자연어 정리” 로직이 생각보다 까다로웠다 — “적어”가 “적어둬”의 일부인데 먼저 매칭되어 “둬”만 남는 버그가 있었다. 가장 긴 패턴부터 매칭하는 방식으로 해결했다.

2. /draft — 문서 초안

> /draft report 분기 실적 현황
✅ 보고서 초안 생성 완료
   파일: ~/drafts/2026-03-16-report-분기-실적-현황.md

> /draft list
  1. 2026-03-16-report-분기-실적-현황.md (1,523자)
  2. 2026-03-16-email-미팅-일정-조율.md (347자)

> /draft read 1
> /draft delete 2

보고서, 이메일, 커버레터, 기획서 템플릿을 지원한다. ~/drafts/ 폴더에 날짜-유형-제목.md 형식으로 저장되어 다른 스킬에서 참조할 수 있다.

3. /search — 파일 검색

> /search 삼성전자 리포트
  파일명 매칭: 삼성전자_리포트.txt (26KB)
  내용 매칭: summary/삼성전자_분석.txt

4. /backup — 백업

> stock 폴더 백업해줘
✅ 백업 완료
   파일: ~/backups/stock-20260316-143022.tar.gz
   크기: 1.2MB

자연어에서 “백업해줘”, “압축해줘”, “통째로” 같은 지시어를 제거하고 폴더 경로를 추출한다. CWD 하위 → ~/projects 하위까지 단계적으로 검색한다.

5. /mail — Gmail

> /mail
읽지 않은 메일 ~15건 (최근 10건):
  1. [김영수] 프로젝트 진행 상황 공유
  2. [이미영] 3월 회의 일정 변경 안내
  ...

> /mail read 1
> 방금 보고서 메일로 보내줘    # ~/drafts/ 최신 파일을 본문으로 자동 첨부

Gmail API로 읽기/발송/회신을 지원한다. 이메일 주소를 생략하면 기본 주소(본인)로 발송된다. 발송 전에 반드시 미리보기를 보여주고 확인을 받는다.

6. /cal — Google Calendar

> /cal
오늘 일정 3건:
  1. [03/16 10:00] 팀 스탠드업
  2. [03/16 14:00] 코드 리뷰
  3. [03/16 16:30] 1:1 미팅

> /cal add 내일 14시 치과
✅ 일정 등록 완료
   제목: 치과
   시작: 2026-03-17 14:00

자연어 날짜/시간 파싱이 핵심이다. “내일 오후 2시”, “3/25 10시”, “모레 15시30분” 등을 정규식으로 처리한다.

7. /drive — Google Drive

> /drive upload stock
Google Drive 동기화 완료: stock
   새 파일: 2개
   업데이트: 1개
   변경없음: 5개 (스킵)
   링크: https://drive.google.com/...

> /drive list
> /drive search 보고서

폴더 업로드 시 동기화 방식으로 동작한다. 같은 이름의 Drive 폴더를 찾아서 새 파일만 업로드하고, 수정된 파일만 업데이트하고, 변경 없는 파일은 스킵한다. .git, __pycache__, node_modules 등은 자동 제외.


Google API 연동: OAuth의 늪

Calendar, Gmail, Drive를 한 번의 인증으로 쓰기 위해 Google OAuth 2.0을 통합했다.

SCOPES = [
    "https://www.googleapis.com/auth/calendar",
    "https://www.googleapis.com/auth/gmail.modify",
    "https://www.googleapis.com/auth/gmail.send",
    "https://www.googleapis.com/auth/drive.file",
]

토큰은 ~/.agent/google_token.json에 저장하고, 만료 시 자동 갱신된다. scope를 추가할 때마다 토큰을 삭제하고 재인증해야 하는데, 이걸 몰라서 한참 “invalid_scope” 에러와 싸웠다.


버그와의 전쟁 (하이라이트)

한글 입력이 씹히는 문제

Rich 라이브러리의 console.input()을 쓰고 있었는데, 한글 IME 조합 중에 글자가 날아갔다. “백업해줘”를 치면 “업해줘”만 입력되는 식. prompt_toolkit으로 교체해서 해결했다. 입력 지점이 5곳이나 있어서 전부 찾아 바꿔야 했다.

이메일 정규식의 함정

# Before: 한글까지 캡처됨
r'[\w.+-]+@[\w-]+\.[\w.]+'
# "ms2byeong@gmail.com으로" → "ms2byeong@gmail.com으로" 매칭

# After: ASCII만
r'[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}'

파이썬의 \w는 한글도 포함한다는 걸 이때 알았다.

모델이 내용을 지어내는 문제

“단편소설 파일 읽고 감상문 작성해줘”라고 하면, draft 스킬이 가로채서 모델에게 “단편소설 감상문 써줘”라고만 전달한다. 모델은 파일을 읽은 적이 없으니 내용을 그럴듯하게 지어낸다. 370바이트짜리 껍데기.

해결: exclude_keywords 시스템을 도입했다. “읽고”, “분석해”, “폴더” 같은 단어가 포함되면 draft 스킬이 빠지고, 모델이 도구(read_file)를 써서 직접 파일을 읽도록 했다.

draft 저장 경로 문제

Deep 모델에게 “draft에 저장해줘”라고 하면 write_file 도구로 현재 작업 디렉토리에 draft.md라는 파일을 만들어버렸다. ~/drafts/에 날짜 포함 파일명으로 저장해야 /draft list에서 보이는데.

해결: 시스템 프롬프트에 명시적으로 저장 규칙을 추가했다.

draft/초안 저장 규칙:
- 반드시 ~/drafts/ 폴더에 저장하세요.
- 파일명 형식: {오늘날짜}-report-{간단한제목}.md

모델이 규칙을 따르게 하려면, 코드가 아닌 프롬프트로 유도해야 할 때도 있다.


v2: 수동 전환의 승리

자동 라우팅을 포기하고, 사용자가 직접 fast/deep을 전환하는 v2를 만들었다.

핵심 변경점:

  • Deep 모델에도 도구 호출 활성화 (이미지 분석만 제외)
  • /think, /nothink으로 질문별 모드 임시 전환
  • fast, deep 명령으로 모드 영구 전환

실제 워크플로우

> [deep] 주식 리포트 폴더 최근 파일 읽고 요약해줘
# Deep이 list_directory → read_file → 요약문 작성 (3.7 tok/s)

> [deep] draft에 저장해줘
# ~/drafts/2026-03-16-report-주식-리포트-요약.md로 저장

> fast
# 모델 전환 (10초)

> [fast] /draft read 1
# 방금 저장한 파일 내용 확인

> [fast] 방금 보고서 메일로 보내줘
# ~/drafts/ 최신 파일을 본문으로 자동 발송

Deep에서 파일을 읽고 분석/작성하고, Fast로 전환해서 발송하는 파이프라인이 자연스럽게 동작한다. 자동 라우팅보다 오히려 이게 더 직관적이었다.


현재 상태와 성능

지원 명령어

스킬슬래시 명령자연어 예시
memo/memo 우유 사기우유 사기 메모해줘
draft/draft list, /draft read 1보고서 목록 알려줘
search/search 삼성전자파일 찾아줘 삼성전자
backup/backup stockstock 폴더 백업해줘
mail/mail, /mail read 3메일 확인해줘
cal/cal add 내일 14시 치과내일 일정 뭐 있어?
drive/drive upload stock드라이브에 올려줘

수치

  • Fast 모델: ~59 tok/s, 15.2GB VRAM
  • Deep 모델: ~4 tok/s, 17.8GB VRAM
  • 모델 전환: ~10초
  • API 비용: 0원
  • Google API: Calendar + Gmail + Drive 통합

핵심 교훈

1. 로컬 LLM의 올바른 사용법

대화형 AI처럼 쓰면 실망한다. 맥락 유지가 약하고, 멀티턴에서 hallucination이 심하다. 하지만 명확한 입력을 주고 결과만 받는 파이프라인 부품으로 쓰면 꽤 쓸만하다.

2. 모델이 못하는 건 코드로 보완

자연어 파싱, 파일 경로 추출, 이메일 주소 검증 — 이런 건 모델에게 맡기면 불안정하다. 정규식과 조건문으로 확실하게 처리하고, 모델은 “문장 생성”이 필요한 곳에서만 쓴다.

3. 프롬프트 엔지니어링 != 코드

시스템 프롬프트에 ”~/drafts/에 저장하세요”라고 쓰면 모델이 따른다. 하지만 “이메일 주소가 없으면 기본 주소를 쓰세요”는 코드로 처리해야 확실하다. 규칙의 중요도에 따라 코드 vs 프롬프트를 구분하는 감각이 필요하다.

4. 24GB면 충분하다

3bit 양자화 덕분에 35B 모델이 15GB에 올라간다. 8.8GB 여유가 있어서 안정적으로 돌아간다. GPU 메모리 한도 설정(sudo sysctl iogpu.wired_limit_mb=20480)도 도움이 되지만, 근본적으로는 양자화 레벨을 낮추는 게 정답이었다.


남은 과제

  • draft 스킬의 파일 읽기: 현재는 모델이 도구로 직접 읽어야 한다. 스킬 자체에 파일 참조 기능을 넣으면 더 편해질 것
  • Google Drive 클라우드 동기화: 메모와 백업을 Drive에 자동 동기화
  • 자연어 파싱 고도화: 현재는 정규식 기반이라 “다음 주 수요일 3시” 같은 복합 표현에 약하다

완벽한 AI 비서와는 거리가 있지만, 비용 0원으로 일상 업무의 상당 부분을 자동화할 수 있다는 건 확실히 증명했다. 로컬LLM으로 이 정도면, 나쁘지 않다.