본문으로 건너뛰기
AIDevOps
  • Learn
  • Learning Paths
  • Practice
  • Open Source
  • Books
  • Engineering

    AI DevOpsAI 서비스 개발·운영 전체 지도LLMOpsLLM 배포·평가·관측실전 프로젝트AI Agent 프로젝트 실습

    Knowledge

    Docs기술 문서 모음Blog엔지니어링 아티클Plogger개발 기록 피드

    Validate

    Certification3단계 역량 인증 · 준비 중
AI Models
LlamaMistralGemmaDeepSeekQwen
🧠 AI Agent 개발
금융 AI AgentLLM API 서버주식 투자 AgentAIOps AI Agent교육 AI Agent코딩 AI Agent
🤖 AI 실전 개발
AI 실전 입문 & 로드맵Hugging FaceLangChainLlamaIndexLLMOps|LangGraphMCPMulti-AgentAgent Evaluation
🧠 AI Core
AI 입문 & 로드맵ML FundamentalsLLM Fundamentals|Python AIC++|PyTorchTensorFlowJAX
🌱 Spring Cloud
Spring 입문 & 로드맵Spring Cloud GatewaySpring BootJava|Spring AISpring SecuritySpring BatchSpring JPA
🐳 DevOps
DevOps 입문 & 로드맵LinuxDockerCI/CD|Kubernetes 기본K8s 심화/실무PrometheusGrafana
🧱 인프라
인프라 입문 & 로드맵NginxRedis
☁️ 클라우드
클라우드 입문 & 로드맵AWSGCPAzureNCPCloudflare
🎨 Frontend
Frontend 입문 & 로드맵JavaScriptTypeScript|ReactNext.js|VueNuxt
📱 Mobile
Mobile 입문 & 로드맵KotlinAndroidFlutter
⚙️ Backend
Backend 입문 & 로드맵Python 기본FastAPIDjangoFlask|CGoGinNode.js
💾 Database
DB 입문 & 로드맵공통 SQLOracleMySQLPostgreSQL|MongoDB벡터 DB
🧪 검증
k6JMeternGrinder
AIDevOps

Engineering AI. From Code to Production.
AI와 AI Agent를 개발하고 운영하기 위한 엔지니어링 학습 플랫폼

Learn

  • 전체 가이드
  • Learning Paths
  • Practice
  • Books

Resources

  • AI DevOps
  • LLMOps
  • 실전 프로젝트
  • Docs
  • Blog
  • Plogger
  • Open Source
  • Certification (준비 중)

Start Here

  • AI Core 로드맵
  • AI 실전 개발 로드맵
  • Spring Cloud 로드맵
  • DevOps 로드맵
  • 인프라 로드맵

 

  • 클라우드 로드맵
  • Frontend 로드맵
  • Mobile 로드맵
  • Backend 로드맵
  • Database 로드맵
© 2026 AI DevOps Korea. All rights reserved.
이용약관개인정보처리방침Sitemaptestforge.kr
LLM Infra · Laptop API Server

💻 Laptop LLM API Server 프라이빗 AI Agent 서버

Visitors

Docker Compose 하나로 노트북에 Ollama LLM Engine + FastAPI + Chroma Vector DB + LangGraph Agent를 구축합니다. 클라우드 AI Agent는 이 서버만 호출하고, 외부 유료 API는 쓰지 않습니다.

← 프로젝트 목록
💻

목차

0 / 16
  1. 아키텍처 & 역할 분리
  2. 사전 요구사항
  3. 프로젝트 파일 구조
  4. Step 1 — Docker Compose
  5. Step 2 — FastAPI 서버
  6. main.py & auth
  7. LLM Chat 엔드포인트
  8. RAG 파이프라인
  9. LangGraph Agent
  10. Step 3 — Ollama 모델 설치
  11. Step 4 — 문서 적재 & 검증
  12. Step 5 — Cloudflare Tunnel
  13. Step 6 — Next.js 클라이언트
  14. Step 7 — k6 성능 검증
  15. 최종 검증 체크리스트
  16. 트러블슈팅
목차 16개 섹션
  1. 아키텍처 & 역할 분리
  2. 사전 요구사항
  3. 프로젝트 파일 구조
  4. Step 1 — Docker Compose
  5. Step 2 — FastAPI 서버
  6. main.py & auth
  7. LLM Chat 엔드포인트
  8. RAG 파이프라인
  9. LangGraph Agent
  10. Step 3 — Ollama 모델 설치
  11. Step 4 — 문서 적재 & 검증
  12. Step 5 — Cloudflare Tunnel
  13. Step 6 — Next.js 클라이언트
  14. Step 7 — k6 성능 검증
  15. 최종 검증 체크리스트
  16. 트러블슈팅

아키텍처 & 역할 분리

이 프로젝트는 클라우드(공개 접점)와 노트북(프라이빗 AI 엔진)을 명확히 분리합니다.

TEXT
브라우저 / 모바일
        │  HTTPS
        ▼
┌─────────────────────────────┐
│  Cloud AI Agent             │  Cloudflare Pages / Next.js
│  - 사용자 인증, 세션 관리    │
│  - Rate limit, CORS 처리    │
│  - 감사 로그, 정책 검사      │
└──────────┬──────────────────┘
           │  x-api-key  (서버 사이드 전용)
           │  Cloudflare Tunnel or VPN
           ▼
┌─────────────────────────────┐
│  Laptop — Docker Compose    │  i7-12700H · 16GB · CPU only
│                             │
│  ┌─────────┐ ┌───────────┐  │
│  │ Ollama  │ │  Chroma   │  │
│  │ :11434  │ │  :8001    │  │
│  └────┬────┘ └─────┬─────┘  │
│       └──────┬─────┘        │
│          ┌───▼────────┐     │
│          │  FastAPI   │     │  포트 8000 (로컬 바인딩)
│          │  /v1/chat  │     │
│          │  /v1/rag   │     │
│          │  /v1/agent │     │
│          └────────────┘     │
└─────────────────────────────┘
계층담당외부 공개
Cloud AI Agent인증, 세션, 라우팅, 정책, 감사 로그O (HTTPS)
FastAPI (노트북)LLM 호출, RAG, Agent 워크플로우X (Tunnel만)
Ollama (노트북)LLM 추론 엔진X (내부망)
Chroma (노트북)벡터 DBX (내부망)

사전 요구사항

항목버전확인 명령
Docker Desktop4.30+docker --version
Python3.11+python --version
Node.js (클라우드 연동 시)20 LTSnode --version
여유 RAM8GB 이상작업 관리자
여유 디스크30GB 이상탐색기
ℹ️

이 가이드는 i7-12700H (14코어), 16GB RAM, GPU 없는 CPU 전용 환경을 기준으로 합니다. GPU가 없어도 llama3.1:8b는 토큰당 ~5 tok/s로 동작합니다.

BASH
# Docker Desktop 설치 확인
docker --version          # Docker version 27.x
docker compose version    # Docker Compose version v2.x

# Docker Desktop 실행 중인지 확인
docker ps

프로젝트 파일 구조

TEXT
laptop-llm-api-server/
├── docker-compose.yml          # 서비스 정의
├── .env                        # 환경 변수 (git 제외)
├── .env.example                # 예시 파일 (git 포함)
│
├── api/                        # FastAPI 서버
│   ├── Dockerfile
│   ├── requirements.txt
│   └── app/
│       ├── main.py             # FastAPI 앱 진입점
│       ├── auth.py             # API Key 인증
│       ├── chat.py             # LLM Chat 엔드포인트
│       ├── rag.py              # RAG 파이프라인
│       ├── agent.py            # LangGraph Agent
│       └── schemas.py          # Pydantic 모델
│
└── documents/                  # RAG 원본 문서 (git 제외)
    └── sample.md               # 샘플 문서

Step 1 — Docker Compose

docker-compose.ymlYAML
services:
  ollama:
    image: ollama/ollama:latest
    container_name: local-ollama
    ports:
      - "127.0.0.1:11434:11434"   # 외부 미공개
    volumes:
      - ollama_data:/root/.ollama
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:11434/api/tags"]
      interval: 30s
      timeout: 10s
      retries: 3

  chroma:
    image: chromadb/chroma:latest
    container_name: local-chroma
    ports:
      - "127.0.0.1:8001:8000"     # 외부 미공개
    volumes:
      - chroma_data:/chroma/chroma
    environment:
      IS_PERSISTENT: "TRUE"
      ANONYMIZED_TELEMETRY: "FALSE"
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8000/api/v1/heartbeat"]
      interval: 30s
      timeout: 5s
      retries: 3

  api:
    build:
      context: ./api
      dockerfile: Dockerfile
    container_name: local-agent-api
    ports:
      - "127.0.0.1:8000:8000"     # 외부 미공개
    env_file:
      - .env
    volumes:
      - ./documents:/app/documents:ro
      - ./logs:/app/logs
    depends_on:
      ollama:
        condition: service_healthy
      chroma:
        condition: service_healthy
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
      interval: 30s
      timeout: 5s
      retries: 3

volumes:
  ollama_data:
  chroma_data:
.envBASH
# API 인증
LOCAL_API_KEY=change-this-to-a-random-secret-key-32chars

# Ollama (컨테이너 내부 네트워크 주소)
OLLAMA_BASE_URL=http://ollama:11434
LLM_MODEL=llama3.1:8b
LLM_MODEL_FAST=llama3.2:3b
MODEL_TIMEOUT_SECONDS=120

# Chroma (컨테이너 내부 네트워크 주소)
CHROMA_HOST=chroma
CHROMA_PORT=8000
RAG_COLLECTION=private_docs

# 임베딩
EMBEDDING_MODEL=nomic-embed-text

# 로깅
LOG_LEVEL=INFO
api/DockerfileDOCKERFILE
FROM python:3.11-slim

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY app/ ./app/

EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
api/requirements.txtTEXT
fastapi==0.115.12
uvicorn[standard]==0.34.3
httpx==0.28.1
pydantic==2.11.5
python-dotenv==1.1.0
chromadb==1.0.12
langchain==0.3.25
langchain-community==0.3.25
langchain-ollama==0.3.3
langgraph==0.4.8
sentence-transformers==3.4.1

Step 2 — FastAPI 서버

main.py & auth

api/app/schemas.pyPYTHON
from pydantic import BaseModel

class ChatRequest(BaseModel):
    message: str
    system: str | None = None
    model: str | None = None

class RagRequest(BaseModel):
    query: str
    collection: str | None = None
    top_k: int = 4

class IngestRequest(BaseModel):
    text: str
    metadata: dict = {}
    collection: str | None = None

class AgentRequest(BaseModel):
    task: str
    context: str | None = None

class StandardResponse(BaseModel):
    answer: str
    model: str = ""
    latency_ms: int = 0
    citations: list[dict] = []
    trace_id: str = ""
api/app/auth.pyPYTHON
import os
import secrets
from fastapi import Header, HTTPException

_API_KEY = os.getenv("LOCAL_API_KEY", "")

def require_api_key(x_api_key: str | None = Header(default=None, alias="x-api-key")) -> None:
    """모든 private 엔드포인트에서 API Key를 검증합니다."""
    if not _API_KEY:
        raise RuntimeError("LOCAL_API_KEY 환경변수가 설정되지 않았습니다.")
    if not x_api_key or not secrets.compare_digest(x_api_key, _API_KEY):
        raise HTTPException(status_code=401, detail="Invalid API key")
api/app/main.pyPYTHON
import time
import uuid
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from .chat import router as chat_router
from .rag import router as rag_router
from .agent import router as agent_router

logging.basicConfig(level=__import__('os').getenv('LOG_LEVEL', 'INFO'))
logger = logging.getLogger(__name__)

@asynccontextmanager
async def lifespan(app: FastAPI):
    logger.info("Local AI Agent API 서버 시작")
    yield
    logger.info("서버 종료")

app = FastAPI(
    title="Private Local AI Agent API",
    version="1.0.0",
    docs_url="/docs",       # 개발 중 Swagger UI 사용
    lifespan=lifespan,
)

app.add_middleware(
    CORSMiddleware,
    allow_origins=["http://localhost:3000"],  # 로컬 개발만 허용
    allow_methods=["POST", "GET"],
    allow_headers=["x-api-key", "content-type"],
)

app.include_router(chat_router,  prefix="/v1")
app.include_router(rag_router,   prefix="/v1")
app.include_router(agent_router, prefix="/v1")

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

LLM Chat 엔드포인트

api/app/chat.pyPYTHON
import os
import time
import httpx
from fastapi import APIRouter, Depends
from .auth import require_api_key
from .schemas import ChatRequest, StandardResponse

router = APIRouter(tags=["chat"])

OLLAMA_URL   = os.getenv("OLLAMA_BASE_URL", "http://ollama:11434")
DEFAULT_MODEL = os.getenv("LLM_MODEL", "llama3.1:8b")
TIMEOUT       = int(os.getenv("MODEL_TIMEOUT_SECONDS", "120"))

@router.post("/chat", response_model=StandardResponse, dependencies=[Depends(require_api_key)])
async def chat(req: ChatRequest):
    """단순 LLM 대화 엔드포인트."""
    model = req.model or DEFAULT_MODEL
    messages = []
    if req.system:
        messages.append({"role": "system", "content": req.system})
    messages.append({"role": "user", "content": req.message})

    start = time.monotonic()
    async with httpx.AsyncClient(timeout=TIMEOUT) as client:
        res = await client.post(
            f"{OLLAMA_URL}/api/chat",
            json={"model": model, "messages": messages, "stream": False},
        )
        res.raise_for_status()

    data    = res.json()
    answer  = data["message"]["content"]
    elapsed = int((time.monotonic() - start) * 1000)

    return StandardResponse(answer=answer, model=model, latency_ms=elapsed)


@router.get("/models", dependencies=[Depends(require_api_key)])
async def list_models():
    """설치된 Ollama 모델 목록을 반환합니다."""
    async with httpx.AsyncClient(timeout=10) as client:
        res = await client.get(f"{OLLAMA_URL}/api/tags")
        res.raise_for_status()
    return {"models": [m["name"] for m in res.json().get("models", [])]}

RAG 파이프라인

api/app/rag.pyPYTHON
import os
import time
import httpx
import chromadb
from fastapi import APIRouter, Depends, HTTPException
from langchain.text_splitter import RecursiveCharacterTextSplitter
from .auth import require_api_key
from .schemas import RagRequest, IngestRequest, StandardResponse

router = APIRouter(tags=["rag"])

OLLAMA_URL   = os.getenv("OLLAMA_BASE_URL", "http://ollama:11434")
LLM_MODEL    = os.getenv("LLM_MODEL", "llama3.1:8b")
EMBED_MODEL  = os.getenv("EMBEDDING_MODEL", "nomic-embed-text")
COLLECTION   = os.getenv("RAG_COLLECTION", "private_docs")
TIMEOUT      = int(os.getenv("MODEL_TIMEOUT_SECONDS", "120"))

def _chroma():
    return chromadb.HttpClient(
        host=os.getenv("CHROMA_HOST", "chroma"),
        port=int(os.getenv("CHROMA_PORT", "8000")),
    )

async def _embed(text: str) -> list[float]:
    """Ollama 임베딩 API 호출."""
    async with httpx.AsyncClient(timeout=30) as client:
        res = await client.post(
            f"{OLLAMA_URL}/api/embed",
            json={"model": EMBED_MODEL, "input": text},
        )
        res.raise_for_status()
    return res.json()["embeddings"][0]

async def _llm(prompt: str) -> str:
    """Ollama LLM 호출."""
    async with httpx.AsyncClient(timeout=TIMEOUT) as client:
        res = await client.post(
            f"{OLLAMA_URL}/api/chat",
            json={
                "model": LLM_MODEL,
                "messages": [{"role": "user", "content": prompt}],
                "stream": False,
            },
        )
        res.raise_for_status()
    return res.json()["message"]["content"]

@router.post("/documents/ingest", dependencies=[Depends(require_api_key)])
async def ingest(req: IngestRequest):
    """문서를 청킹해 Chroma에 저장합니다."""
    splitter = RecursiveCharacterTextSplitter(chunk_size=512, chunk_overlap=64)
    chunks = splitter.split_text(req.text)
    if not chunks:
        raise HTTPException(400, "빈 문서입니다.")

    collection_name = req.collection or COLLECTION
    db = _chroma()
    col = db.get_or_create_collection(collection_name)

    embeddings = [await _embed(chunk) for chunk in chunks]
    ids = [f"doc-{i}-{hash(chunk) & 0xFFFFFF:06x}" for i, chunk in enumerate(chunks)]
    metadatas = [{**req.metadata, "chunk_index": i} for i in range(len(chunks))]

    col.add(documents=chunks, embeddings=embeddings, ids=ids, metadatas=metadatas)
    return {"ingested_chunks": len(chunks), "collection": collection_name}

@router.post("/rag/query", response_model=StandardResponse, dependencies=[Depends(require_api_key)])
async def rag_query(req: RagRequest):
    """Chroma 검색 + LLM 답변 생성."""
    collection_name = req.collection or COLLECTION
    db = _chroma()

    try:
        col = db.get_collection(collection_name)
    except Exception:
        raise HTTPException(404, f"컬렉션 '{collection_name}'이 없습니다. 먼저 문서를 적재하세요.")

    query_vec = await _embed(req.query)
    results = col.query(query_embeddings=[query_vec], n_results=req.top_k)

    docs = results["documents"][0]
    metas = results["metadatas"][0]

    if not docs:
        return StandardResponse(answer="관련 문서를 찾을 수 없습니다.")

    context = "\n\n---\n\n".join(docs)
    prompt = f"""다음 문서를 참고해 질문에 답하세요.
질문에 대한 답이 문서에 없으면 "문서에서 관련 내용을 찾을 수 없습니다"라고 답하세요.

[참고 문서]
{context}

[질문]
{req.query}

[답변]"""

    start = time.monotonic()
    answer = await _llm(prompt)
    elapsed = int((time.monotonic() - start) * 1000)

    citations = [{"text": d[:120] + "...", "metadata": m} for d, m in zip(docs, metas)]
    return StandardResponse(answer=answer, model=LLM_MODEL, latency_ms=elapsed, citations=citations)

LangGraph Agent

api/app/agent.pyPYTHON
import os
import time
from typing import TypedDict, Annotated
from fastapi import APIRouter, Depends
from langchain_ollama import ChatOllama
from langchain_core.messages import HumanMessage, AIMessage, BaseMessage
from langgraph.graph import StateGraph, END
from langgraph.graph.message import add_messages
from .auth import require_api_key
from .schemas import AgentRequest, StandardResponse

router = APIRouter(tags=["agent"])

OLLAMA_URL = os.getenv("OLLAMA_BASE_URL", "http://ollama:11434")
LLM_MODEL  = os.getenv("LLM_MODEL", "llama3.1:8b")

# ── 상태 정의 ──
class AgentState(TypedDict):
    messages: Annotated[list[BaseMessage], add_messages]
    task: str
    result: str

# ── 노드 함수들 ──
def _plan_node(state: AgentState) -> dict:
    """작업을 분석하고 실행 계획을 수립합니다."""
    llm = ChatOllama(model=LLM_MODEL, base_url=OLLAMA_URL, temperature=0)
    plan_prompt = f"""다음 작업을 분석하고 실행 단계를 3개 이내로 계획하세요.
작업: {state['task']}
계획 (번호 목록으로):"""
    response = llm.invoke([HumanMessage(content=plan_prompt)])
    return {"messages": [response]}

def _execute_node(state: AgentState) -> dict:
    """계획을 실행하고 결과를 생성합니다."""
    llm = ChatOllama(model=LLM_MODEL, base_url=OLLAMA_URL, temperature=0)
    plan = state["messages"][-1].content if state["messages"] else ""
    exec_prompt = f"""작업과 계획을 바탕으로 최종 결과를 생성하세요.
작업: {state['task']}
계획: {plan}
컨텍스트: {state.get('context', '')}
결과:"""
    response = llm.invoke([HumanMessage(content=exec_prompt)])
    return {"messages": [response], "result": response.content}

def _build_graph():
    builder = StateGraph(AgentState)
    builder.add_node("plan",    _plan_node)
    builder.add_node("execute", _execute_node)
    builder.set_entry_point("plan")
    builder.add_edge("plan", "execute")
    builder.add_edge("execute", END)
    return builder.compile()

_graph = _build_graph()

@router.post("/agent/run", response_model=StandardResponse, dependencies=[Depends(require_api_key)])
async def run_agent(req: AgentRequest):
    """LangGraph Agent를 실행합니다."""
    start = time.monotonic()
    result = _graph.invoke({
        "messages": [],
        "task": req.task,
        "context": req.context or "",
        "result": "",
    })
    elapsed = int((time.monotonic() - start) * 1000)
    return StandardResponse(answer=result["result"], model=LLM_MODEL, latency_ms=elapsed)

Step 3 — Ollama 모델 설치

Chroma와 Ollama를 먼저 띄운 뒤 모델을 내려받습니다.

BASH
# 1. Chroma + Ollama 먼저 시작
docker compose up -d ollama chroma

# 2. 컨테이너 상태 확인 (healthy 될 때까지 대기)
docker compose ps

# 3. 메인 모델 다운로드 (~5GB, 5~10분 소요)
docker exec -it local-ollama ollama pull llama3.1:8b

# 4. 임베딩 모델 다운로드 (~300MB)
docker exec -it local-ollama ollama pull nomic-embed-text

# 5. (선택) 빠른 응답용 경량 모델
docker exec -it local-ollama ollama pull llama3.2:3b

# 6. 설치 확인
docker exec -it local-ollama ollama list
BASH
# Ollama API 직접 테스트
curl http://localhost:11434/api/tags

# 응답 예시:
# {"models":[
#   {"name":"llama3.1:8b","size":4661224960},
#   {"name":"nomic-embed-text","size":274302480}
# ]}
💡

메모리 관리: 16GB RAM에서는 Docker Desktop에 8GB, Ollama(llama3.1:8b)에 5~7GB가 필요합니다. IDE와 브라우저 탭을 최소화하세요. 버거우면 llama3.2:3b(~2GB)로 교체합니다.

Step 4 — API 서버 시작 & 문서 적재

BASH
# API 서버 시작
docker compose up -d api

# 전체 서비스 상태 확인
docker compose ps

# EXPECTED:
# local-ollama     running (healthy)
# local-chroma     running (healthy)
# local-agent-api  running (healthy)

# Health check
curl http://localhost:8000/health
# {"status":"ok","version":"1.0.0"}

# API Key 없는 요청 → 401 확인
curl -X POST http://localhost:8000/v1/chat \
  -H "content-type: application/json" \
  -d '{"message":"test"}'
# {"detail":"Invalid API key"}

# API Key 있는 요청 → 정상
curl -X POST http://localhost:8000/v1/chat \
  -H "content-type: application/json" \
  -H "x-api-key: change-this-to-a-random-secret-key-32chars" \
  -d '{"message":"안녕하세요. 간단히 자기소개해주세요."}'
documents/sample.mdMARKDOWN
# 회사 소개

## 서비스 개요
AIDevOps는 AI Agent 개발과 운영을 위한 기술 플랫폼입니다.
주요 서비스: 금융 AI Agent, 시스템 성능 최적화, LLMOps 가이드

## 주요 기능
- 실시간 시장 데이터 분석
- 공시 문서 RAG 검색
- 포트폴리오 리밸런싱 자동화

## 기술 스택
Python, FastAPI, LangChain, LangGraph, ChromaDB, Ollama
BASH
# 문서 적재 테스트
curl -X POST http://localhost:8000/v1/documents/ingest \
  -H "content-type: application/json" \
  -H "x-api-key: change-this-to-a-random-secret-key-32chars" \
  -d '{
    "text": "AIDevOps는 AI Agent 개발 플랫폼입니다. 주요 기능으로 금융 분석, RAG 검색, LangGraph 오케스트레이션을 제공합니다.",
    "metadata": {"source": "sample", "date": "2026-06-06"}
  }'
# {"ingested_chunks":1,"collection":"private_docs"}

# RAG 질의 테스트
curl -X POST http://localhost:8000/v1/rag/query \
  -H "content-type: application/json" \
  -H "x-api-key: change-this-to-a-random-secret-key-32chars" \
  -d '{"query": "AIDevOps의 주요 기능은?"}'
BASH
# 폴더 전체 문서 일괄 적재 (Python 스크립트)
python - <<'EOF'
import httpx, pathlib, os

API = "http://localhost:8000"
KEY = os.getenv("LOCAL_API_KEY", "change-this-to-a-random-secret-key-32chars")

for path in pathlib.Path("documents").rglob("*.md"):
    text = path.read_text(encoding="utf-8")
    r = httpx.post(
        f"{API}/v1/documents/ingest",
        headers={"x-api-key": KEY},
        json={"text": text, "metadata": {"filename": path.name}},
    )
    print(f"{path.name}: {r.json()}")
EOF

Step 5 — Cloudflare Tunnel (클라우드 연동)

클라우드 AI Agent가 노트북 FastAPI를 호출하려면 안전한 터널이 필요합니다.

⚠️

절대 금지: FastAPI 포트(8000)를 인터넷에 직접 공개하지 마세요. Cloudflare Tunnel은 브라우저가 아닌 서버 사이드 클라이언트에서만 접근하도록 Cloudflare Access 정책을 함께 설정합니다.

BASH
# 1. cloudflared 설치 (Windows)
winget install --id Cloudflare.cloudflared

# 2. Cloudflare 로그인
cloudflared tunnel login

# 3. 터널 생성
cloudflared tunnel create laptop-ai-agent

# 4. 설정 파일 생성
# C:Users[user].cloudflaredconfig.yml

# 5. 임시 테스트 터널 (도메인 없이 바로 사용)
cloudflared tunnel --url http://localhost:8000
~/.cloudflared/config.ymlYAML
tunnel: laptop-ai-agent
credentials-file: C:\Users\[user]\.cloudflared\[tunnel-id].json

ingress:
  - hostname: laptop-api.your-domain.com
    service: http://localhost:8000
    originRequest:
      noTLSVerify: false
  - service: http_status:404
BASH
# 터널 실행
cloudflared tunnel run laptop-ai-agent

# Cloudflare Dashboard에서 Access Policy 설정 필요:
# - Service Auth: Service Token 생성
# - 클라우드 서버 IP만 허용 (Bypass 정책)

# 연결 테스트
curl https://laptop-api.your-domain.com/health \
  -H "CF-Access-Client-Id: your-service-token-id" \
  -H "CF-Access-Client-Secret: your-service-token-secret"

Step 6 — Next.js / TypeScript 클라이언트

클라우드 AI Agent(Next.js)에서 노트북 FastAPI를 호출하는 서버 사이드 코드입니다. 브라우저에서 직접 호출하지 않습니다.

lib/laptop-agent.tsTYPESCRIPT
const LAPTOP_API_URL = process.env.LAPTOP_AGENT_API_URL!;
const LAPTOP_API_KEY = process.env.LAPTOP_AGENT_API_KEY!;

interface AgentResponse {
  answer: string;
  model: string;
  latency_ms: number;
  citations: { text: string; metadata: Record<string, unknown> }[];
}

async function callLaptopApi(path: string, body: unknown): Promise<AgentResponse> {
  const url = LAPTOP_API_URL + path;
  const res = await fetch(url, {
    method: "POST",
    headers: {
      "content-type": "application/json",
      "x-api-key": LAPTOP_API_KEY,
    },
    body: JSON.stringify(body),
    signal: AbortSignal.timeout(130_000),
  });

  if (!res.ok) {
    const err = await res.text();
    throw new Error("Laptop API " + res.status + ": " + err);
  }
  return res.json();
}

export async function chat(message: string, system?: string): Promise<AgentResponse> {
  return callLaptopApi("/v1/chat", { message, system });
}

export async function ragQuery(query: string, collection?: string): Promise<AgentResponse> {
  return callLaptopApi("/v1/rag/query", { query, collection });
}

export async function runAgent(task: string, context?: string): Promise<AgentResponse> {
  return callLaptopApi("/v1/agent/run", { task, context });
}
app/api/ask/route.tsTYPESCRIPT
import { NextRequest, NextResponse } from "next/server";
import { ragQuery } from "@/lib/laptop-agent";
import { getServerSession } from "next-auth";   // 인증 미들웨어

export async function POST(req: NextRequest) {
  const session = await getServerSession();
  if (!session) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  const { question } = await req.json();
  if (!question?.trim()) {
    return NextResponse.json({ error: "question is required" }, { status: 400 });
  }

  try {
    const result = await ragQuery(question);
    return NextResponse.json(result);
  } catch (err) {
    const msg = err instanceof Error ? err.message : "Unknown error";
    return NextResponse.json({ error: msg }, { status: 502 });
  }
}
.env.local (클라우드 서버)BASH
# 클라우드 서버 환경 변수 (브라우저에 절대 노출 금지)
LAPTOP_AGENT_API_URL=https://laptop-api.your-domain.com
LAPTOP_AGENT_API_KEY=change-this-to-a-random-secret-key-32chars

Step 7 — k6 성능 검증

k6/laptop-api-test.jsJAVASCRIPT
import http from "k6/http";
import { check, sleep } from "k6";
import { Trend } from "k6/metrics";

const llmLatency = new Trend("llm_latency_ms", true);

export const options = {
  scenarios: {
    // 시나리오 1: Health check (빠른 검증)
    health: {
      executor: "constant-vus",
      vus: 1,
      duration: "10s",
      exec: "healthCheck",
    },
    // 시나리오 2: LLM Chat (순차 — 모델이 한 번에 하나만 처리)
    chat: {
      executor: "constant-vus",
      vus: 1,
      duration: "2m",
      startTime: "15s",
      exec: "chatTest",
    },
    // 시나리오 3: RAG 질의
    rag: {
      executor: "constant-vus",
      vus: 1,
      duration: "2m",
      startTime: "15s",
      exec: "ragTest",
    },
  },
  thresholds: {
    "http_req_failed":  ["rate<0.05"],
    "llm_latency_ms":   ["p(95)<60000"],   // AI는 60초 이내
  },
};

const BASE  = "http://localhost:8000";
const HEADERS = {
  "content-type": "application/json",
  "x-api-key": __ENV.API_KEY || "change-this-to-a-random-secret-key-32chars",
};

export function healthCheck() {
  const res = http.get(`${BASE}/health`, { headers: HEADERS });
  check(res, { "health ok": (r) => r.status === 200 });
  sleep(1);
}

export function chatTest() {
  const start = Date.now();
  const res = http.post(
    `${BASE}/v1/chat`,
    JSON.stringify({ message: "한국 주식 시장의 특징을 3가지 알려줘" }),
    { headers: HEADERS },
  );
  check(res, {
    "chat 200":    (r) => r.status === 200,
    "has answer":  (r) => JSON.parse(r.body)?.answer?.length > 0,
  });
  llmLatency.add(Date.now() - start);
  sleep(2);
}

export function ragTest() {
  const start = Date.now();
  const res = http.post(
    `${BASE}/v1/rag/query`,
    JSON.stringify({ query: "AIDevOps의 주요 기능은?" }),
    { headers: HEADERS },
  );
  check(res, {
    "rag 200":     (r) => r.status === 200,
    "has answer":  (r) => JSON.parse(r.body)?.answer?.length > 0,
  });
  llmLatency.add(Date.now() - start);
  sleep(2);
}
BASH
# k6 설치 (Windows)
choco install k6

# 테스트 실행
k6 run k6/laptop-api-test.js

# 환경 변수로 API Key 주입
k6 run -e API_KEY=your-key k6/laptop-api-test.js

# 기대 결과 (CPU 전용 기준):
# llm_latency_ms  p(95)=18,420ms  ✓ (18초, 정상)
# http_req_failed rate=0.00%      ✓
ℹ️

CPU 전용(llama3.1:8b)에서 LLM 응답은 요청 길이에 따라 5~30초가 정상 범위입니다. k6 threshold를 60초로 여유 있게 설정하세요. 동시 요청(vus=2+)은 메모리 부족을 유발할 수 있으므로 vus=1로 순차 실행합니다.

최종 검증 체크리스트

BASH
# ✅ 1. 모든 컨테이너 healthy 상태 확인
docker compose ps
# 3개 서비스 모두 "running (healthy)" 이어야 함

# ✅ 2. Ollama 모델 목록 확인
curl http://localhost:11434/api/tags
# llama3.1:8b, nomic-embed-text 포함 확인

# ✅ 3. Chroma 상태 확인
curl http://localhost:8001/api/v1/heartbeat
# {"nanosecond heartbeat": ...}

# ✅ 4. API Key 없는 요청 차단 확인 (401 응답)
curl -X POST http://localhost:8000/v1/chat \
  -H "content-type: application/json" \
  -d '{"message":"test"}'

# ✅ 5. LLM Chat 동작 확인
curl -X POST http://localhost:8000/v1/chat \
  -H "content-type: application/json" \
  -H "x-api-key: change-this-to-a-random-secret-key-32chars" \
  -d '{"message":"1+1=?"}'

# ✅ 6. RAG 질의 확인 (문서 적재 후)
curl -X POST http://localhost:8000/v1/rag/query \
  -H "content-type: application/json" \
  -H "x-api-key: change-this-to-a-random-secret-key-32chars" \
  -d '{"query":"AIDevOps의 주요 기능은?"}'

# ✅ 7. Agent 실행 확인
curl -X POST http://localhost:8000/v1/agent/run \
  -H "content-type: application/json" \
  -H "x-api-key: change-this-to-a-random-secret-key-32chars" \
  -d '{"task":"AAPL과 삼성전자의 공통점을 분석해줘"}'

트러블슈팅

증상원인해결
Ollama 응답 없음 / 타임아웃메모리 부족, 모델 로딩 중docker logs local-ollama 확인 후 llama3.2:3b로 교체
OOM killed16GB RAM 부족Docker Desktop 메모리 할당 줄이기, llama3.2:3b 사용
Chroma 연결 실패컨테이너 미시작docker compose up -d chroma
401 Unauthorized.env API Key 불일치.env 파일 재확인, docker compose restart api
RAG 빈 답변문서 미적재Step 4 문서 적재 재실행
포트 충돌다른 프로세스가 8000 사용netstat -ano | findstr 8000으로 확인
BASH
# 로그 확인
docker logs local-agent-api --tail 50
docker logs local-ollama --tail 20

# 전체 재시작
docker compose down && docker compose up -d

# 컨테이너 내부 접속 (디버깅)
docker exec -it local-agent-api bash
docker exec -it local-ollama bash