저는 사생활 보호가 보장되는 로컬 모델이 필요했습니다. 그리고 만약 제가 마스터 제어 노드로 프론티어 모델(frontier model)을 사용해야 한다면, 문제는 비용입니다. 프론티어 모델은 비싸고, 구독 사용량 제한은 점점 더 작아지는 것 같습니다.
그래서 질문은, 비싼 모델이 사고하는 역할을 하고 저렴한 모델이 타이핑하는 역할을 할 수 있는지, 그리고 그 결과물로 가치 있는 것이 나오는지 여부입니다. (이번 테스트에서 '로컬' 모델은 임대된 RunPod 카드를 사용했기 때문에 RunPod로부터 사적인 것은 아닙니다. 이는 소유하고 있는 어떤 24GB 카드에서도 실행되는 동일한 16.8 GB 파일입니다.)
Claude Code를 Ollama나 로컬 엔드포인트에 연결하는 방법에 대한 게시물은 이미 많이 있습니다. 제가 찾지 못한 것은, 실제로 이 분할(split) 방식이 단순히 로컬 모델에게 전체 작업을 맡기는 것보다 나은지 측정하는 사람이 아무도 없다는 점이었습니다. 그래서 저는 Claude에게 숨겨진 테스트가 포함된 작은 프로젝트를 설정하게 했고, 같은 카드에서 두 가지 방식으로 모두 실행했으며 동일한 스크립트로 평가했습니다. 결과는 다음과 같으며, 이 아이디어를 칭찬하지 않는 부분까지 포함합니다.
요약. 오케스트레이션(Claude가 다섯 개의 작은 작업 프롬프트를 작성하고, 모든 결과를 사양과 비교 검토하며, 결함을 재현 방법과 함께 되돌려 보내는 방식): 전체 테스트 스위트에서 각각 78/78점 및 77/78점을 기록했으며, 그중 58개의 테스트는 작업자(worker)에게 숨겨져 있었습니다. 동일 모델에 대한 단일 대형 프롬프트: **76점, 74점, 71점 (총 3회)**이었으며, 71점은 가장 많은 턴을 거친 실행이었습니다. 모든 원샷(one shot) 실행에서 동일한 버그가 발생했습니다(로그를 재실행하면 다시 기록됨). 오케스트레이션된 두 번의 실행에서도 이 버그가 있었지만, 검토 과정에서 발견되었습니다. 작업자는 llama.cpp에서 구동되는 Qwen3.8 27B Q4_K_M이었고, RTX PRO 4000 Blackwell 하나를 시간당 $0.57에 사용했으며, 컨텍스트는 32K였고 전체 세션 비용은 GPU로 약 40센트였습니다. 결함이 사라진 곳은 바로 검토 과정이었습니다. 이 설정만으로는 분할(splitting) 자체가 얼마나 많은 가치를 가져다주는지 말할 수 없습니다.
설정
두 가지 역할입니다. '로컬 모델을 사용한 Claude Code'는 두 가지 다른 설정을 위해 사용되므로, 어떤 것이 무엇인지 알려드리겠습니다.
첫 번째 설정은 Claude Code 자체를 로컬 엔드포인트에 연결하여, 로컬 모델이 전체 에이전트가 되게 합니다. 하지만 그렇지 않습니다. 여기서는 Claude가 그대로 Claude로 남아 있습니다. Claude는 계획을 세우고, 작업 프롬프트를 작성하며, 돌아오는 코드를 읽고, 프로브(probes)를 실행하고, 재작업을 위해 무엇을 돌려줄지 결정합니다. 로컬 모델은 작은 하네스(harness)를 통해 호출하는 워커입니다. 이 워커는 태스크당 하나의 상태 비저장 대화(stateless conversation)로 작동하며, 네 개의 툴과 작업 공간 폴더를 사용합니다.
| |
|---|
| 오케스트레이터 (Orchestrator) | Mac에서 Claude Code 세션의 Claude |
| ... | |
| 저는 $0.27/hr로 RTX A5000을 원했습니다. 이 가격은 실험의 첫 번째 라운드가 실행된 비용입니다. 하지만 RunPod에 문의했을 때 재고가 없었습니다. 다음으로 고려한 것은 PRO 4000이었습니다. 카드가 속도를 바꿉니다. 모델 파일과 설정은 동일하지만, 하네스가 라운드마다 바뀌었기 때문에 두 카드를 비교하는 것은 아닙니다. | |
팟(pod)의 서버는 한 줄로 구성됩니다:
llama-server -m /workspace/gguf/Qwen3.8-27B-Uncensored-Q4_K_M.gguf \
--host 127.0.0.1 --port 8080 -c 32768 -ngl 99 --jinja -fa on --alias local
이것은 localhost에서만 수신하며, RunPod의 공개 프록시 대신 터널을 통해 접속합니다:
ssh -f -N -p <pod-ssh-port> -i ~/.runpod/ssh/runpodctl-ssh-key \
-o ExitOnForwardFailure=yes -L 8091:127.0.0.1:8080 root@<pod-ip>
curl -s http://127.0.0.1:8091/health
사전 구축된 llama.cpp는 제가 소유한 Cloudflare R2 버킷에서 가져왔습니다. 측정 실행에 사용된 모델은 Hugging Face에서 가져왔으며, 현재 동일한 버킷에 미러링되었습니다. 아래 스크린샷 실행의 경우, 신규 팟이 R2에서 두 가지를 모두 가져와 379초 동안 서비스되었는데, 이는 6분이 조금 넘는 시간이며 그중 절반은 15.7 GiB 모델 다운로드 시간이었습니다. 만약 처음부터 시작한다면, vLLM 포스트에서 카드 임대 및 RunPod의 청구 방식에 대해 다루고 있습니다.
On the pod: nvidia-smi shows the RTX PRO 4000 Blackwell with 24467 MiB total and 18178 MiB used, the 16810714496 byte GGUF sits in /workspace/gguf, and llama-server runs on 127.0.0.1:8080 with -c 32768
위에서 실행된 내용 이후에 새로 생성된 pod의 스크린샷이므로, 여기에 표시된 시간과 토큰 수는 표와는 다릅니다.
테스트 프로젝트
장난감 같은 프로젝트도 모서리(edge case)가 있어야 유용합니다. 여기서는 minikv라는 이름의 키-값 저장소로, 순수 Python으로 작성되었으며 서로 의존하는 다섯 개의 모듈로 구성되어 있습니다:
| 모듈 | 기능 | 취약점 (모서리) |
|---|
store.py | 키(keys), 값(values), 만료 시간(expiry) | ttl 반올림, 만료된 키가 여전히 딕셔너리에 남아 있음 |
| ... | | |
명세는 한 페이지 분량입니다. 총 78개의 unittest 테스트 케이스가 있습니다: 작업자(worker)가 볼 수 있는 것 20개(모듈당 파일 하나)와, 모서리 사례 및 크로스 모듈 시나리오를 다루며 작업자가 절대 보지 못하는 58개입니다. 명세만으로 작성된 참고 솔루션은 78/78을 통과하므로 테스트는 만족 가능합니다. 채점기는 테스트 파일에서 분모(denominator)를 가져가고, 제출물에서는 절대 가져가지 않으며, 다섯 개의 명세 모듈만을 자체 격리 환경(sandbox)에 복사합니다. 따라서 작업 공간의 임의 파일이 어떤 것을 가리는(shadow) 일은 없습니다. 또한 Python을 -I 옵션으로 실행하고 러너 자체에서 결과를 계산하므로, 가짜 unittest.py나 꾸며낸 요약 정보는 아무런 효과가 없습니다.
작업자 하네스 (Worker Harness)
작업자는 worker.py라는 Python 스크립트이며, 이 스크립트는 OpenAI 스타일의 /v1/chat/completions 엔드포인트를 통해 모델과 하나의 대화를 유지하며 네 가지 도구(tools)를 제공합니다:
list_files와 read_file: 작업 공간 폴더에 국한됨
write_file: 동일하게 제한됨 (절대 경로, .. 및 심볼릭 링크로의 외부 이동은 거부됨)
run_command: python3 스크립트, python3 -c, python3 -m unittest, ls, cat, pwd에 한정되며 상대 경로만 허용됩니다.
또한 반복 방지(repeat guard) 기능도 있습니다. 턴(turn), 도구 호출(tool call), 결과가 포함된 동일한 시퀀스가 연속으로 네 번 반복되면 경고를 받고, 계속될 경우 repeat-stop으로 실행이 중단됩니다. 모든 호출은 하나의 JSON 라인을 출력합니다 (여기서는 잘림; done 상태의 실행도 모델의 요약 정보를 담습니다):
{"turns": 6, "calls": 6, "bad_calls": 0, "completion_tokens": 693, "max_prompt_tokens": 2060, "total_prompt_tokens": 9455, "repeat_warnings": 0, "finish": "done", "secs": 55.1}
여기서 중요한 필드는 finish입니다. done은 모델이 완료했다고 판단했음을 의미합니다. max-turns는 턴 제한에 도달했다는 뜻이며, 제가 실행해 본 경험으로는 보통 무한 루프에 빠졌다는 것을 의미했습니다.
[On the Mac: the health check returns status ok, store-ws starts with clock.py and test_store.py, one worker call ends done after 6 turns and 36.6 seconds with a summary saying all 4 tests pass, store.py appears, and the grader gives 4/4 visible and 8/8 hidden for the store](https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fa3d225ak5ag0lzj94fus.png
오케스트레이션된 실행 방식(How the orchestrated run works)
의존성 순서에 따라 모듈별로 총 5가지 작업이 진행됩니다:
- Stage A, 병렬 처리:
store.py, protocol.py, aof.py. 아직 서로 의존하는 것은 없습니다. 하나의 llama-server를 대상으로 세 가지 작업이 수행되었고, 가장 느린 작업은 87초가 걸렸습니다.
- 검토(Review). Claude는 각 파일을 명세서(spec)와 비교하여 읽고,
python3 -c 원 라인으로 테스트합니다: 명세서에 언급된 엣지 케이스, 오류 메시지, 왕복 테스트 등을 수행합니다.
- **수정 작업(Rework)**이 필요하면 수정하고, 다음 모듈이 시작되도록 합니다.
- Stage B:
txn.py (store가 필요함), 그 다음 session.py (모든 것이 필요함).
- 각 작업당 최대 두 번의 수정 라운드가 허용됩니다. 그 이후에는 현재 상태로 평가(grade)를 받게 됩니다.
각 작업 프롬프트는 해당 모듈의 명세서 텍스트, 사용되는 모듈들의 인터페이스, 테스트 명령어, 그리고 가장 많은 작업을 수행하는 문장(
store.py 작성 (테스트 파일: test_store).
작업 공간에는 clock.py와 모듈에 대한 보이는 테스트 파일이 포함되어 있습니다. 순수 Python 3 표준 라이브러리만 사용합니다. python3 -m unittest <testfile without .py>로 테스트 파일을 실행하고 실패한 부분을 수정하세요.
...
재작업 프롬프트는 결함을 명시하고, 짧은 재현 과정을 제공하며, 사양을 인용합니다. 원인에 대한 힌트는 없습니다. 이 사례는 다음과 같이 간소화되었습니다:
Review of the current txn.py against the spec found that, inside a transaction, a key that was deleted or has expired is still treated as present by some methods.
...
원샷(one shot) 조건에서는 전체 사양을 단일 프롬프트에 담았고, 다섯 개의 모듈 모두를 한 번에 다루었으며, 동일한 도구와 동일한 보이는 테스트를 사용했고, 90턴의 예산이 주어졌습니다.
결과
모든 실행은 동일한 파드(pod), 동일한 모델, 동일한 하네스(harness), 동일한 그레이더에서 진행되었습니다.
grade.py on the first orchestrated workspace: every visible and hidden file passes, TOTAL 78/78
두 스크린샷은 작업 공간에 남겨진 실행 결과물을 바탕으로 다시 채점되었습니다.
오케스트레이션(orchestrated) 실행의 워커 시간은 태스크 전반에 걸쳐 합산됩니다. 그중 세 개는 병렬로 실행되었기 때문에 실제 경과 시간(wall clock)은 더 짧았습니다.
헤드라인은 71에서 76 사이, 77에서 78 사이의 격차를 보이지만, 더 유용한 수치는 'Largest prompt' 열에 있습니다. 어떤 오케스트레이션된 프롬프트도 8K 토큰을 넘지 못했습니다. 원샷(one shot) 실행은 각각 10K와 8.5K까지 도달했고, 긴 실행은 32K 제한에 근접한 800 토큰 이내로 작동했습니다. 작은 작업들은 모델이 여전히 주의를 기울이는 컨텍스트 영역 안에 머무르게 합니다.
그 대가는 '턴(turns)'입니다. 오케스트레이션은 스스로 멈춘 원샷 실행보다 세 배에서 여덟 배 많은 워커 턴을 사용했습니다. 시간당 과금되는 카드 기준으로, 이는 몇 분의 추가 시간이었습니다: 전체 세션(두 가지 조건과 다섯 번의 실행 모두)이 포드 시간으로 총 42분이 소요되었고, 약 $0.40에 달합니다.
리뷰가 발견한 것들
아래 나열된 모든 결함은 눈에 보이는 테스트를 통과했습니다. 이것이 숨겨진 테스트(hidden ones)를 갖는 이유입니다.
| 모듈 | 결함 | Run 1 | Run 2 |
|---|
protocol.py | KEYS가 전혀 구현되지 않음; parse("KEYS")에서 "unknown command" 오류 발생 | 한 번의 재작업으로 수정됨 | 한 번의 재작업으로 수정됨 |
| ... | | | |
리플레이 버그(replay bug)는 제가 강조하고 싶은 부분입니다. 모든 원샷 실행이 이 문제를 안고 있었습니다: 세 가지 모두가 숨겨진 테스트 test_replay_does_not_relog를 통과하지 못했습니다. 오케스트레이션된 실행도 마찬가지였습니다, 두 번 모두요. 유일한 차이점은 Claude가 __init__을 읽고 리플레이 루프가 execute()를 호출하는 것을 보고, execute()가 로깅한다는 사실을 알았다는 점입니다. 작성하기 쉽고, 누군가 주의 깊게 본다면 발견하기도 쉬운 버그입니다. | | | |
[replay_check.py writes one SET, then restarts the session three times: on the one shot workspace the log grows to 2, 4 and 8 lines, on the orchestrated one it stays at 1](https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0jxlpy9g2kczvfksh2jv.png