출근 전에 켜두는 로컬 소설 제작기: M1 16GB의 Ollama·MLX와 이어쓰기의 조건
M1 16GB의 Ollama와 MLX를 오가며 기획·초안·비평·수정·요약을 파일로 분리한 소설 자동화 실험, 실제 오류와 가상환경 문제, 작품별 외부 기억과 재개 설계의 한계를 다룬다.
이 글의 목차 · 9개 절
아침에 작업을 걸어 놓고 저녁에 결과를 받으려면, 언어모델에게 여덟 시간 동안 계속 “좋은 소설을 써 달라”고 말하는 것만으로는 부족하다. 이 작업의 본질은 채팅이 아니라 입력·기획·집필·검토·수정·기억 갱신·저장을 분리한 배치 시스템을 만드는 일이다. 모델은 장면을 만들지만, 어떤 작품을 쓰고 있는지, 어디까지 썼으며 무엇이 실패했는지를 판정하는 것은 모델 바깥의 코드와 파일이어야 한다.
Apple M1과 통합 메모리 16GB라는 제한은 이 구분을 선명하게 만든다. 외장 GPU가 없는 노트북에 초대형 모델을 무리하게 올리기보다, 작은 양자화 모델을 순서대로 호출하고 서사의 상태를 파일에 남기는 구성이 현실적이다. 목표는 출판 가능한 장편의 자동 완성을 보장하는 것이 아니라, 사람이 다시 읽고 채택하거나 고칠 수 있는 초안과 판단 기록을 안전하게 축적하는 데 있다.
로컬 에이전시와 장시간 배치 작업은 어디서 갈라지는가
범용 에이전트는 다음 행동을 스스로 선택하고 도구를 활용하며 계획을 바꿀 수 있다. 소설 제작의 첫 구현은 그보다 좁다. 기획자가 세계관·인물·회차 개요를 만들고, 작가가 본문을 쓰며, 비평가가 문제를 지적하고, 편집자가 수정한 뒤, 요약자가 다음 회차용 상태를 기록한다. 역할 이름은 다섯 개지만 동시에 다섯 모델을 띄우는 구조는 아니다. 한 호출이 끝나면 다음 역할의 프롬프트를 구성하는 순차 파이프라인이다.
이러한 분리는 속도보다 작업 단위의 식별에 의미가 있다. 같은 모델을 다섯 역할에 써도 초안과 비평을 별도 파일로 남길 수 있다. 반대로 각 역할을 서로 다른 모델에 맡겨도 검토가 피상적이면 품질은 개선되지 않는다. “작가가 쓴 뒤 비평가가 비판한다”는 도식만으로 독립된 심사나 객관적인 점수를 얻는 것은 아니다. 실제 문장을 읽고 교정할 기준과 중단 조건이 필요하다.
처음 제안된 선택지에는 소프트웨어 작업 에이전트인 OpenHands, Aider, Continue, 개인 비서형 OpenClaw와, 간단한 Python 반복 실행 방식이 있었다. 이 가운데 소설처럼 산출물 형식과 단계가 고정된 작업은 복잡한 범용 오케스트레이터 없이도 구현할 수 있다. 편집 단계와 기억 관리의 통제 가능성이 높기 때문이다.
M1 16GB: 디스크 여유와 메모리 여유는 다르다
Ollama는 앱과 로컬 서버, CLI가 함께 작동한다. 앱이 서버를 열어 두지 않았을 때 CLI 요청은 127.0.0.1:11434에서 연결 거절을 받을 수 있고, 앱이 실행 중이라면 ollama ps, ollama list, ollama run qwen3로 각각 로드 상태·보유 모델·대화 실행을 확인할 수 있다. 다운로드를 하는 창과 프로젝트 코드를 수정하는 창은 분리해도 된다.
한 실험 환경에서는 남은 저장 공간이 약 131GiB였으며, Ollama의 Qwen3 다운로드는 약 5.2GB, Devstral은 약 14GB 규모였다. 디스크에 내려받을 수 있다는 사실은 16GB 메모리에 원활히 올릴 수 있다는 뜻이 아니다. Devstral은 소프트웨어 엔지니어링 지향의 큰 모델로 기획·비평 역할의 후보였지만, 해당 환경에서 로드와 응답에 부담을 주었다. 소설 비평에서 다른 모델보다 우수하다고 검증된 것도 아니다.
작은 Qwen 계열을 권한 이유는 한국어 생성, Ollama에서의 접근성, 다양한 크기 선택과 실험 편의성에 있었다. 그러나 특정 모델을 “문장력 최고”라고 확정할 근거는 없었다. 역할별 모델의 초기 구상은 Qwen(작가·편집자)과 Devstral(기획자·비평가)의 조합이었지만, 실험을 거치며 가벼운 4bit Qwen 하나로 모든 역할을 순서대로 실행하는 쪽으로 변경되었다. 이는 역할 설계와 실제 사용 모델을 구별해야 한다는 사례다.
Ollama에서 MLX로: 호출부를 분리하면 파이프라인을 살릴 수 있다
Ollama는 HTTP API 서버이고, MLX는 Apple Silicon에 특화된 머신러닝 프레임워크이며, mlx-lm은 그 위에서 언어모델을 실행하는 패키지다. 두 도구는 동일한 모델 파일을 그대로 공유하는 형태가 아니다. Ollama가 받아 둔 가중치가 있어도 MLX용 모델은 별도로 내려받아야 했다. 따라서 엔진 이전은 다운로드 이전이 아니라 호출 인터페이스 이전이다.
파이프라인의 나머지 코드가 chat(prompt, model_name=...)라는 얇은 인터페이스만 사용하면, llm_client.py 하나에서 Ollama의 /api/chat 방식과 MLX의 load()·generate() 방식을 고를 수 있다. 실제로 LLM_BACKEND=mlx와 LLM_BACKEND=ollama라는 환경변수 스위치를 두었고, 기획·작가·비평·편집·요약 모델 이름을 각 백엔드에 맞춰 배정했다. main.py는 역할 순서, 파일 저장과 재시도를 담당하므로 원칙적으로 백엔드의 내부 구현을 몰라도 된다.
그러나 “교체가 쉽다”는 말에 조건이 붙는다. 같은 프롬프트라도 채팅 템플릿, 출력 토큰 제한, thinking 동작, 오류와 타임아웃의 처리 방식은 다를 수 있다. 최초 MLX 코드에는 max_tokens=900이 설정되어 있었다. 긴 소설을 요구하는 프롬프트가 있어도 출력 길이를 제한하므로, “1,500~2,500자 요청”과 “요구한 분량이 실제 생성됨”은 다른 문제다. 또한 함수 호출에서 TypeError가 발생하면 모델 인자가 없는 옛 함수로 재시도하는 호환 코드가 제안되었는데, 이는 함수 인자 오류 외의 내부 TypeError까지 감출 수 있어 장기 운영용으로는 다듬어야 한다.
가상환경·모델 형식: 실동작을 막은 두 오류
mlx와 mlx-lm을 ~/mlx-test/.venv에 설치한 뒤 시스템 python3로 main.py를 실행하자 ModuleNotFoundError: No module named 'mlx_lm'가 발생했다. 패키지 설치 자체의 실패가 아니라 설치한 파이썬과 실행한 파이썬이 달랐기 때문이다. 가상환경을 활성화하고 python -c "import mlx_lm"으로 확인한 뒤 같은 환경에서 실행하자 모델 호출이 진행되었다. py_compile 통과는 문법 확인이지 외부 패키지나 모델 실행의 검증은 아니라는 점도 드러났다.
MLX용 mlx-community/Qwen3-4B-4bit는 모델 가중치 약 2.26GB를 처음 다운로드하고 기획과 초안 작성에 성공했다. 반면 비평 담당으로 지정했던 mlx-community/Qwen3.5-4B-MLX-4bit는 약 3.03GB를 받은 뒤 Model type qwen3_5 not supported 오류가 났다. 당시 가상환경의 mlx_lm이 그 아키텍처를 지원하지 못한 것으로 나타났다. 이에 비평·요약도 같은 Qwen3-4B-4bit로 통일했다. 다운로드 완료, 라이브러리 import 성공, 모델 로드 성공, 업무 단계 완주는 서로 다른 성공 조건이다.
파일에 남는 다섯 공정과 두 종류의 기억
첫 구조는 input.txt에서 소설의 방향을 받아 story_bible.md, characters.md, outline.md를 만든 다음, chapters/ch_01_draft.md와 chapters/ch_01_critic.md, chapters/ch_01_final.md, summaries/ch_01_summary.md를 차례로 저장하는 방식이었다. 여기서 최종 본문과 요약은 목적이 다르다. 최종 본문은 읽고 수정할 작품, 요약은 다음 화 작성을 위한 압축된 작업 기억이다. 요약만 남기면 앞 장면의 문장과 리듬을 회수할 수 없다.
세계관 파일은 서사의 지속적 제약, 인물 파일은 성격과 관계의 제약, 개요는 앞으로 쓸 장면의 목표, 요약은 이미 발생한 사건을 담당한다. 모델에 모든 회차를 통째로 반복 입력하지 않고 필요한 파일과 최근 두 회의 요약만 전달하려는 이유는 컨텍스트·메모리 예산에 있다. 하지만 최근 두 회만 읽으면 더 이전에 설치한 복선이나 인물의 변화를 잊을 수 있다. 장기 연재에서는 GPT 위키 제0호기가 구별한 확정 설정(canon), 결정 장부, 미해결 질문, 집필용 문맥 묶음까지 고려해야 한다.
이 지점에서 외부 기억의 역설이 드러난다. 모델이 잘 기억하지 못하므로 요약을 만들지만, 요약에서 빠진 것이 다음 세대의 기억에서는 존재하지 않게 된다. 특히 꿈속에서 타인의 성격이 훼손된다는 이미지나 고집이 뿌리째 제거되는 모티프처럼 문학의 핵심이 되는 요소는 “인물 상태 변화”라는 항목에만 넣으면 사라진다. 사건 요약과 반복해서 보존할 핵심 이미지·문체·미해결 갈등을 구별해야 한다. 그루터기: 꿈과 무력화의 단편은 이러한 모티프가 담긴 원본 창작 단편이다.
단일 실행에서 작품별 이어쓰기까지
한 작품의 기존 결과를 지우지 않고 새 실행에서 계속 쌓는 문제는 단순히 회차 수를 늘리는 것으로 해결되지 않는다. 최초 main.py는 실행할 때마다 기획을 다시 만들고 ch_01을 덮어쓰는 구조였다. 다음 단계에서 TOTAL_CHAPTERS로 반복 회차 수를 지정하고 각 회차가 끝날 때 초안·비평·수정·요약을 저장하도록 바꾸었다. 에러를 로그에 기록하고 뒤의 회차로 진행하도록 했지만, 실패한 회차가 빠진 상태에서 다음 화를 작성하면 이야기의 연속성이 훼손될 수 있다. 안전한 재개는 “예외를 무시함”이 아니라 실패한 단계에서 멈추거나 그 단계부터 다시 진행하는 방식이어야 한다.
이어쓰기 설계는 이를 작품 단위로 확장했다. input.txt의 키워드: 그루터기 같은 명시적 표지를 projects/그루터기/의 프로젝트 ID로 바꾸고, 그 안에 seed_input.txt·latest_input.txt·세계관·인물·개요·회차·요약을 둔다. 처음 실행에서 씨앗 입력을 고정하고, 다음 실행에서는 완성된 마지막 화의 번호에 1을 더해 다음 화부터 작성한다. 개요가 부족하면 기존 개요·최근 요약·원래 입력을 바탕으로 다음 몇 화만 추가하는 기획자 프롬프트를 호출한다.
왜 이 변화가 필요한가? 처음의 집필 프롬프트에는 사용자 아이디어가 직접 전달되지 않고 세계관과 인물의 모델이 재해석한 결과만 전달되었다. 그 결과 소설의 핵심 이미지와 의도에서 멀어질 위험이 생겼다. 이후 작성자 프롬프트의 인자로 user_input을 명시적으로 추가하여 매 장마다 원문을 전달하도록 고쳤다. 다만 이 구현도 seed_input.txt만 계속 읽는다면 나중에 수정한 latest_input.txt를 본문에는 적용하지 않는다. 초기 원문의 고정과 후속 지시의 반영은 서로 다른 기능이며, 다음 개정에서는 어느 항목이 우선하는지 지정해야 한다.
입력 원문에는 길고 비유적인 서사 문단도 포함될 수 있다. 그 문장을 main.py의 한 줄짜리 문자열에 직접 붙여 넣으면 파이썬의 문자열 종료·줄바꿈 문제가 날 수 있지만, 실제로 나타난 EOL 오류의 정확한 종류와 파일 위치는 확인되지 않았다. CRLF 줄끝 변환이나 따옴표를 원인으로 확정해서는 안 된다. 아이디어 원문은 가능하면 UTF-8 텍스트 파일에 보존하고, Python은 파일을 읽어 프롬프트에 전달하는 역할만 하게 한다. 본문을 의도적으로 짧은 항목형 문장으로 바꿔 놓으면 파싱은 쉬워져도 원래 문학적 이미지가 소실될 수 있으므로, 작품의 씨앗 문단과 키워드·장르 같은 제어 필드를 함께 보관하는 편이 낫다.
초기 기본 개요 추출이 실패하면 chapter_goal에 미리 적어 둔 항구 도시의 야간 순찰 장면을 넣는 코드도 있었다. 실제 입력이 그와 다른 세계와 인물을 요구할 때는 입력 해석 실패가 특정 장르의 상투적 장면으로 덮이는 문제가 생긴다. 이 기본값은 성공적인 아웃라인 추출을 가장한 대체 지시여서는 안 되며, 실패를 명시적으로 표시하거나 해당 프로젝트의 씨앗 입력에서만 목표를 다시 구성해야 한다.
또 다른 함정은 명시적 키워드가 없을 때 첫 줄을 프로젝트명으로 삼는 기본값이다. 실제 출력에서 장르 설명의 첫 줄이 장르_현대_판타지라는 프로젝트 키가 되었다. 이는 키워드 자동 인식의 성공이 아니라 작품 식별자 입력이 없음을 잘못 처리한 사례다. 안전한 운용에서는 키워드:를 필수로 하거나 프로젝트 선택을 한 번 명시적으로 확인하는 방식이 적절하다. 프로젝트명만으로 재실행 내용을 이어붙이는 설계에는 이름 충돌과 이전 원고를 잘못 재사용할 위험도 남는다.
진행 표시·잠자기·복구의 실무 조건
LLM 호출에서 stream: false로 응답 전체를 기다리면 “초안 생성 시작” 이후 터미널이 오랫동안 조용하다. 이는 현재 단계에 진입했다는 뜻이지, 중간 토큰이 성공적으로 생성되고 있다는 증거는 아니다. print(..., flush=True)로 단계 시작·완료를 출력하고 logs/run.log를 남기면 단계 경계는 관찰할 수 있다. tail -f는 이 로그를 실시간으로 읽지만, 응답 내부의 생성 진도를 보여 주지는 않는다.
실행 중 작업을 유지하려면 Ollama 서버가 필요한 백엔드인지 확인하고, 시스템이 잠자기에 들지 않아야 한다. macOS의 caffeinate -dimsu는 화면·시스템의 유휴 잠자기를 억제하는 방법으로 제시되었다. 외출 중에는 전원 연결과 덮개 닫힘 상태도 별도로 확인해야 한다. nohup과 셸 백그라운드 실행은 터미널 세션과 작업을 분리하는 방법이지, 시스템 잠자기나 모델의 메모리 부족, 파이썬 예외를 치유하는 방법이 아니다.
보다 중요한 것은 저장 시점이다. 기획과 초안이 완성된 뒤에만 파일을 쓰면 모델 생성 중 장애가 났을 때 생성 중인 문장은 보존되지 않는다. 저장된 산출물의 존재, 유효성, 대응하는 입력·모델 설정, 처리 단계의 성공 여부를 따로 확인해야 한다. 번역에서 문단 ID와 체크포인트가 중요했던 것처럼, 소설에서는 작품 ID·회차 ID·단계 ID·입력 버전이 재개의 기준이 된다. 이 점은 로컬 책 번역 파이프라인의 중단 재개 문제와 직접 연결된다.
검증된 완주와 아직 시험되지 않은 자동화
확인 가능한 터미널 출력에는 MLX Qwen3-4B-4bit 모델 하나로 1화와 2화 각각 초안 → 비평 → 수정본 → 요약을 호출하고 “작업 완료”를 출력한 사례가 있다. 이는 기본 반복 파이프라인이 한 번 실제 실행되었음을 보여 준다. 다만 파일 내용의 소설적 품질, 요청 분량 충족, 각 회차의 설정 일관성은 별도로 검증되지 않았다.
뒤이어 추가한 키워드별 프로젝트 분리·씨앗 입력 보존·개요 자동 확장·이어쓰기 변경은 소스 수정과 일부 실행 시작 로그가 확인되지만, 변경된 버전으로 기존 작품 다음 화를 끝까지 생성했다는 결과는 확인되지 않는다. 특히 개요 추출 함수가 작성된 마크다운 소제목을 정확히 인식하는지, 기존 회차 번호를 어떻게 판정하는지, 실패 후 산출물이 있는 회차를 건너뛰지 않는지 시험이 필요하다.
장시간 자동 작업의 다음 진전은 무조건 더 많은 회차를 생산하는 데 있지 않다. 중간 결과를 믿을 수 있는 파일로 만들고, 어디서부터 다시 해야 하는지 알고, 작성자가 준 씨앗과 이전에 승인한 설정을 끝까지 보존하는 것이다.
관련 문서
연결된 글
자료·검토 표기: 내부 총합·외부 신규 조사 없음·대화에 표시된 실행 로그와 설계 상태만 구별
댓글