로컬 Gemma를 Claude Code 백엔드로 쓰기

로컬 Gemma를 Claude Code 백엔드로 쓰기

ollama 없이, 파이썬 FastAPI 서버만으로 Gemma를 띄우고 LAN의 어떤 PC에서든 Claude Code가 이를 백엔드로 사용하게 하는 방법입니다.


전체 구조

[Host PC - Windows]
  gemma_server.py  (FastAPI + transformers)
  └─ /v1/messages  ← Anthropic Messages API
  └─ port 8000  →  LAN 전체 허용

────────── LAN ──────────

[Client - Windows / Linux]
  ANTHROPIC_BASE_URL = http://192.168.x.x:8000
  claude → Gemma로 응답

⚠️ 중요: Claude Code는 OpenAI 포맷(/chat/completions)이 아니라 Anthropic Messages API 포맷(/v1/messages)을 사용합니다.


1. 패키지 설치

pip install fastapi uvicorn transformers accelerate torch

2. 서버 코드 (gemma_server.py)

import json, os, time, uuid
from threading import Thread
from typing import Optional
import torch, uvicorn
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from transformers import AutoModelForCausalLM, AutoTokenizer, TextIteratorStreamer

MODEL_ID = os.environ.get("MODEL_ID", "google/gemma-3-4b-it")
HF_TOKEN = os.environ.get("HF_TOKEN")

if HF_TOKEN:
    from huggingface_hub import login
    login(token=HF_TOKEN)

# 최초 실행 시 자동 다운로드, 이후 캐시 사용
tokenizer = AutoTokenizer.from_pretrained(MODEL_ID)
model = AutoModelForCausalLM.from_pretrained(
    MODEL_ID,
    device_map="auto",
    torch_dtype=torch.bfloat16,
)
model.eval()

app = FastAPI()

class Message(BaseModel):
    role: str
    content: str | list

class MessagesRequest(BaseModel):
    model: str
    messages: list[Message]
    max_tokens: Optional[int] = 1024
    temperature: Optional[float] = 0.7
    stream: Optional[bool] = False
    system: Optional[str] = None

def extract_text(content):
    if isinstance(content, str): return content
    return "".join(b.get("text","") for b in content if isinstance(b,dict) and b.get("type")=="text")

@app.get("/v1/models")
def list_models():
    return {"object":"list","data":[{"id":MODEL_ID,"object":"model","owned_by":"local"}]}

@app.post("/v1/messages")
async def messages(req: MessagesRequest):
    hf_msgs = []
    if req.system:
        hf_msgs += [{"role":"user","content":f"[System]: {req.system}"},
                    {"role":"assistant","content":"Understood."}]
    for m in req.messages:
        hf_msgs.append({"role":m.role,"content":extract_text(m.content)})

    ids = tokenizer.apply_chat_template(
        hf_msgs, add_generation_prompt=True, return_tensors="pt"
    ).to(model.device)

    gen = dict(input_ids=ids, max_new_tokens=req.max_tokens,
               temperature=max(req.temperature,1e-7), do_sample=req.temperature>0)

    if req.stream:
        streamer = TextIteratorStreamer(tokenizer, skip_prompt=True, skip_special_tokens=True)
        gen["streamer"] = streamer
        Thread(target=model.generate, kwargs=gen, daemon=True).start()

        async def event_stream():
            cid = f"msg_{uuid.uuid4().hex}"
            yield f"event: message_start\ndata: {json.dumps({'type':'message_start','message':{'id':cid,'type':'message','role':'assistant','content':[],'model':req.model,'stop_reason':None}})}\n\n"
            yield f"event: content_block_start\ndata: {json.dumps({'type':'content_block_start','index':0,'content_block':{'type':'text','text':''}})}\n\n"
            for token in streamer:
                yield f"event: content_block_delta\ndata: {json.dumps({'type':'content_block_delta','index':0,'delta':{'type':'text_delta','text':token}})}\n\n"
            yield f"event: content_block_stop\ndata: {json.dumps({'type':'content_block_stop','index':0})}\n\n"
            yield f"event: message_stop\ndata: {json.dumps({'type':'message_stop'})}\n\n"
        return StreamingResponse(event_stream(), media_type="text/event-stream")

    with torch.no_grad():
        out = model.generate(**gen)
    text = tokenizer.decode(out[0][ids.shape[-1]:], skip_special_tokens=True)
    return {"id":f"msg_{uuid.uuid4().hex}","type":"message","role":"assistant",
            "content":[{"type":"text","text":text}],"model":req.model,"stop_reason":"end_turn"}

@app.get("/health")
def health(): return {"status":"ok","model":MODEL_ID}

if __name__ == "__main__":
    uvicorn.run(app, host="0.0.0.0", port=int(os.environ.get("PORT",8000)))

3. 서버 실행 (Host PC)

# HuggingFace 토큰 설정 (Gemma 라이선스 동의 필요)
$env:HF_TOKEN = "hf_xxxxxxxxxxxx"

# 실행 → 최초 1회 자동 다운로드 후 서빙
python gemma_server.py

모델은 ~/.cache/huggingface/hub/에 자동 저장되고, 다음 실행부터는 캐시에서 로드합니다.

Windows 방화벽 허용 (관리자 PowerShell):

New-NetFirewallRule -DisplayName "Gemma LAN" -Direction Inbound -Protocol TCP -LocalPort 8000 -Action Allow

4. Claude Code 연결 (Client PC)

Claude Code는 Opus / Sonnet / Haiku 3개 역할 모두를 같은 모델로 지정해야 합니다.

Windows:
$env:ANTHROPIC_BASE_URL            = "http://192.168.1.xxx:8000"
$env:ANTHROPIC_API_KEY             = "local"
$env:ANTHROPIC_DEFAULT_OPUS_MODEL   = "google/gemma-3-4b-it"
$env:ANTHROPIC_DEFAULT_SONNET_MODEL = "google/gemma-3-4b-it"
$env:ANTHROPIC_DEFAULT_HAIKU_MODEL  = "google/gemma-3-4b-it"
claude
Linux:
export ANTHROPIC_BASE_URL="http://192.168.1.xxx:8000"
export ANTHROPIC_API_KEY="local"
export ANTHROPIC_DEFAULT_OPUS_MODEL="google/gemma-3-4b-it"
export ANTHROPIC_DEFAULT_SONNET_MODEL="google/gemma-3-4b-it"
export ANTHROPIC_DEFAULT_HAIKU_MODEL="google/gemma-3-4b-it"
claude
영구 설정 (~/.claude/settings.json):
{
  "env": {
    "ANTHROPIC_BASE_URL": "http://192.168.1.xxx:8000",
    "ANTHROPIC_API_KEY": "local",
    "ANTHROPIC_DEFAULT_OPUS_MODEL": "google/gemma-3-4b-it",
    "ANTHROPIC_DEFAULT_SONNET_MODEL": "google/gemma-3-4b-it",
    "ANTHROPIC_DEFAULT_HAIKU_MODEL": "google/gemma-3-4b-it"
  }
}

핵심 정리

항목내용
API 포맷Anthropic Messages API (/v1/messages) — OpenAI 아님
모델 다운로드최초 실행 시 HuggingFace에서 자동, 이후 캐시
HF 토큰Gemma 라이선스 동의 후 발급 필요
모델명 envOpus / Sonnet / Haiku 3개 모두 동일하게 지정
GPUdevice_map="auto" 로 자동 배분, CPU도 동작하나 느림

댓글

이 블로그의 인기 게시물

키움 API 를 이용한 자동매매 프로그램 개발 (삽질일기), KIWOOM REST API AUTO TRADING BOT

gitea / ubuntu linux 개발 환경 구축기