본문 바로가기
C.W.K.
Stream
Lesson 05 of 05 · published

Local AI fallback ship하기

~22 min · ops, production, fallback

Level 0Downloader
0 XP0/41 lessons0/11 achievements
0/120 XP to next level120 XP to go0% complete

이 마지막 lesson 모양

여기서 quest를 하나로 묶어서 진짜 물건을 하나 띄워. 작은 backend service인데, cloud provider를 골라서 user prompt에 답하고, cloud에 못 닿을 땐 조용히 Ollama로 넘어가. 앞의 모든 track에서 배운 패턴이 production 모양으로 돌아간다는 걸 보이는, 가장 작은 증거야.

Production check-list

  1. Adapter 패턴. Cloud랑 Ollama 둘 다 stream() 구현.
  2. Health check. 둘 다 빠른 timeout 가진 health_check().
  3. Orchestrator. Cloud를 먼저 부르고, 실패하거나 health가 나쁘면 Ollama로 넘어가. Priority는 기본값이 아니라 인자야. adapters 트랙은 같은 클래스를 local-first로 만들거든. cloud-first를 뜻하는 서비스면 그렇게 말해 줘야 해.
  4. Streaming. 어느 vessel이 답하든 frontend는 끊김 없는 SSE stream 하나만 봐.
  5. Pin한 모델 tag. Ollama 쪽도 cloud 쪽도 버전을 못 박아둬.
  6. Observability. 매 request마다 뭐가 답했는지 남겨. 아래 skeleton은 model tag를 기록해. chunk가 실어 오는 게 그거니까. vessel 키가 필요하면 orchestrator가 그걸 실어 보내게 해. 그리고 믿기 전에 그 로그를 한 번은 읽어봐. 이름 필드가 아예 없는 게 아니라 빈 값이면, 로그는 평생 빈칸만 성실하게 찍거든. 나중에 어떤 답이 유독 다르게 느껴질 때 이 로그가 없으면 아무것도 못 해.
  7. Mini mode. Local 모델이 작으면 system prompt를 확 덜어내.
  8. Warm-up. Startup task에서 keep_alive: -1로 local 모델을 미리 올려둬. 그래야 첫 fallback이 견딜 만한 속도로 나와. tag는 adapter에서 읽어야 데운 모델이랑 부르는 모델이 갈라지지 않아.

이게 동작할 때 만든 거

privacy도, 비용도, 장애도 아는 서비스야. Cloud가 죽어도 user는 답을 계속 받아. Cloud가 rate-limit을 걸어도 계속 받고. Cloud가 비싸지면 bulk batch job이 local로 흘러가. Orchestrator는 단순하고 adapter는 좁아. 그래서 잘 안 부러져.

Code

End-to-end skeleton (FastAPI + orchestrator)·python
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
import json

# Cloud 먼저, local은 장애 대비 그물. adapters 트랙의 기본 priority는
# 반대 방향이니까, 여기서 어느 쪽인지 분명히 말해 줘야 해.
ORCHESTRATOR = AIOrchestrator(priority=["claude", "codex", "gemini", "ollama"])

@asynccontextmanager
async def lifespan(app: FastAPI):
    # 첫 cold fallback이 30초 대기가 되지 않게 local fallback을 미리 올려둬.
    # tag를 다시 적지 말고 adapter에서 읽어. 자기 모델 이름을 따로 적어 둔
    # warm-up은 adapter가 바뀌는 순간 엉뚱한 모델을 데워.
    local = ORCHESTRATOR.vessels["ollama"]
    try:
        await local.client.post(
            f"{local.base_url}/api/generate",
            json={"model": local.model, "keep_alive": -1},   # the number, not "-1"
            timeout=120.0,
        )
    except Exception:
        pass  # local이 안 깔려 있어도 괜찮아
    yield

app = FastAPI(lifespan=lifespan)

@app.post("/chat/stream")
async def chat_stream(req: dict):
    """cloud→local fallback으로 user에게 답을 stream."""
    answered_by = {"model": None}

    async def gen():
        async for chunk in ORCHESTRATOR.stream(req["messages"]):
            # 실제로 보이는 첫 model tag를 기록해. `is None`만으로 막으면
            # 첫 chunk의 빈 문자열 기본값이 그대로 박혀서 영원히 빈 값만 찍혀.
            # 뭘 얻는지도 분명히 해 두자. vessel 키가 아니라 model tag야.
            # 키가 필요하면 orchestrator가 그걸 실어 보내게 해.
            if not answered_by["model"] and chunk.model:
                answered_by["model"] = chunk.model
            payload = {"content": chunk.content, "done": chunk.done}
            yield f"data: {json.dumps(payload)}\n\n"
        # 어느 vessel이 답했는지 로그 (ops용)
        print(f"[/chat/stream] model={answered_by['model']}")

    return StreamingResponse(gen(), media_type="text/event-stream")

# 실행: uvicorn main:app --host 0.0.0.0 --port 9000

External links

Exercise

FastAPI 서비스 빌드. 실행. 4 시나리오 test: (1) cloud 닿음, (2) cloud firewall에서 차단, (3) cloud 닿는데 느림, (4) Ollama daemon 멈춤. 네 경우 다 사용자 눈에 보이는 동작이 매끄러운지 확인해. 각 실패 모드에서 '매끄럽다'가 정확히 뭔지 글로 적어봐. 그게 네 진짜 ops 계약이야.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인 — 댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.