Docker Compose 하나로 노트북에 Ollama LLM Engine + FastAPI + Chroma Vector DB + LangGraph Agent를 구축합니다. 클라우드 AI Agent는 이 서버만 호출하고, 외부 유료 API는 쓰지 않습니다.
이 프로젝트는 클라우드(공개 접점)와 노트북(프라이빗 AI 엔진)을 명확히 분리합니다.
브라우저 / 모바일
│ 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 (노트북) | 벡터 DB | X (내부망) |
| 항목 | 버전 | 확인 명령 |
|---|---|---|
| Docker Desktop | 4.30+ | docker --version |
| Python | 3.11+ | python --version |
| Node.js (클라우드 연동 시) | 20 LTS | node --version |
| 여유 RAM | 8GB 이상 | 작업 관리자 |
| 여유 디스크 | 30GB 이상 | 탐색기 |
이 가이드는 i7-12700H (14코어), 16GB RAM, GPU 없는 CPU 전용 환경을 기준으로 합니다. GPU가 없어도 llama3.1:8b는 토큰당 ~5 tok/s로 동작합니다.
# Docker Desktop 설치 확인
docker --version # Docker version 27.x
docker compose version # Docker Compose version v2.x
# Docker Desktop 실행 중인지 확인
docker pslaptop-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 # 샘플 문서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:# 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=INFOFROM 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"]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.1from 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 = ""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")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"}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", [])]}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)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)Chroma와 Ollama를 먼저 띄운 뒤 모델을 내려받습니다.
# 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# 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)로 교체합니다.
# 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":"안녕하세요. 간단히 자기소개해주세요."}'# 회사 소개
## 서비스 개요
AIDevOps는 AI Agent 개발과 운영을 위한 기술 플랫폼입니다.
주요 서비스: 금융 AI Agent, 시스템 성능 최적화, LLMOps 가이드
## 주요 기능
- 실시간 시장 데이터 분석
- 공시 문서 RAG 검색
- 포트폴리오 리밸런싱 자동화
## 기술 스택
Python, FastAPI, LangChain, LangGraph, ChromaDB, Ollama# 문서 적재 테스트
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의 주요 기능은?"}'# 폴더 전체 문서 일괄 적재 (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클라우드 AI Agent가 노트북 FastAPI를 호출하려면 안전한 터널이 필요합니다.
절대 금지: FastAPI 포트(8000)를 인터넷에 직접 공개하지 마세요. Cloudflare Tunnel은 브라우저가 아닌 서버 사이드 클라이언트에서만 접근하도록 Cloudflare Access 정책을 함께 설정합니다.
# 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:8000tunnel: 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# 터널 실행
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"클라우드 AI Agent(Next.js)에서 노트북 FastAPI를 호출하는 서버 사이드 코드입니다. 브라우저에서 직접 호출하지 않습니다.
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 });
}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 });
}
}# 클라우드 서버 환경 변수 (브라우저에 절대 노출 금지)
LAPTOP_AGENT_API_URL=https://laptop-api.your-domain.com
LAPTOP_AGENT_API_KEY=change-this-to-a-random-secret-key-32charsimport 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);
}# 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로 순차 실행합니다.
# ✅ 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 killed | 16GB 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으로 확인 |
# 로그 확인
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