본문으로 건너뛰기
페이지 목차
이 페이지에서 검색

로컬 LLM

이 페이지에 묻기

로컬 LLM · 현재 차시와 목차 본문에서만 찾아 답합니다. 답을 지어내지 않고 해당하는 대목을 그대로 보여 주며, 여기에 없으면 없다고 알려 드립니다.

생성형 AI가 아니라 이 페이지 본문을 찾아 그대로 보여 주는 방식입니다. 외부로 나가는 요청이 없습니다.

LOCAL · LLM

로컬 LLM

생성 수정

로컬 모델 실행과 GPU·메모리 이해부터 서빙·RAG·도구 호출·업무형 앱 구현까지 차례로 살펴봅니다.

42개 문서 · 4일차 · 26 / 42

25차시 · 로컬 LLM API 애플리케이션

지도 위치 : 4일차 · ⑤애플리케이션 층의 출발점 (④ API 위에 앱을 세운다) | 이전: 24차시 Ollama와 vLLM 비교 | 다음: 26차시 Streaming 기반 AI 서비스 키워드 : OpenAI 호환 API를 활용한 Python LLM 애플리케이션 개발

이 차시를 다 읽으면

  1. 문의봇을 UI → 앱 서버 → LLM 서버 세 층으로 나누고, 브라우저가 LLM 서버를 직접 부르면 안 되는 이유를 말할 수 있다.
  2. 헥사고날(포트 · 어댑터) 폴더 구조에서 LLM 클라이언트가 어느 폴더 에 들어가는지 짚을 수 있다.
  3. base_url · model · api_key를 코드가 아니라 환경변수 에 두는 이유를 말할 수 있다.
  4. LLM 호출의 세 가지 실패( 타임아웃 · 서버 다운 · finish_reason="length" )를 구별하고 각각의 대응을 말할 수 있다.
  5. "매번 답이 다른 LLM 호출도 멱등하다 "를 설명하고, 그래도 재시도를 조심해야 하는 곳을 짚을 수 있다.

0. 이 차시의 판별 축

#축왼쪽 (0)오른쪽 (1)이 축으로 갈리는 것큰 축 (README 5장)
1계약인가 구현인가LLM 포트 (추상)OpenAI 호환 어댑터 (구상)유스케이스가 아는 것 ↔ 모르는 것⭐ ② 추상 ↔ 구상
2누가 먼저 부르는가인바운드 (밖 → 안)아웃바운드 (안 → 밖)FastAPI 라우터 ↔ LLM 클라이언트② 추상 ↔ 구상 (어댑터를 방향으로 나눔)
3값이 어디에 있는가코드에 박힘환경변수Ollama ↔ vLLM 전환이 코드 수정 인가 설정 변경 인가② 추상 ↔ 구상 (코드는 그대로, 값만 바꿈)
4다시 보내도 되는가멱등 (재시도 안전)비멱등 (재시도 위험)생성 호출 ↔ 결과를 쌓는 저장멱등 ↔ 비멱등 (⑦의 파생)

이 차시의 질문 — 앞에서 배운 대로 OpenAI 호환 API는 포트, Ollama · vLLM은 어댑터 입니다(20차시). 다시 짚으면, 포트 는 "무엇을 주고받는가"만 정한 바깥과의 약속 이고, 어댑터 는 그 약속을 특정 기술로 구현한 부품 입니다. 약속만 같으면 어댑터를 갈아 끼워도 안쪽은 모릅니다. 20차시에서는 이 경계를 엔진 쪽 에서 그었습니다. 이번 차시가 답하는 질문은 이것입니다 — 우리 앱 안에도 같은 경계를 그으면, 앱 코드는 어떤 모양이 되는가?


1. 층 구조 — 브라우저는 LLM 서버를 모른다

1.1 세 층

[상담원 브라우저 (UI)]
      │  우리 API     POST /inquiries/{id}/summary
      ▼
[앱 서버 (FastAPI)]     ← 로그인 확인 · 프롬프트 조립 · 결과 검증 · 저장 · 로그
      │  OpenAI 호환   POST /v1/chat/completions
      ▼
[LLM 서버 (vLLM :8000 / Ollama :11434)]   ← GPU 위에서 토큰 생성만
층아는 것모르는 것
UI우리 API 주소, 화면 상태LLM 서버 주소, 프롬프트, 모델 이름
앱 서버업무 규칙, 프롬프트, 사용자 권한GPU, 양자화, 배칭
LLM 서버모델, 토큰, KV 캐시누가 물었는지, 답이 업무상 맞는지

⭐ "모르는 것" 열이 설계입니다. 모르는 것이 많을수록 갈아 끼우기 쉽습니다.

FastAPI — [Python 웹 서버 프레임워크]인데, [ 함수 위에 @router.post("/주소")를 붙이는 것만으로 HTTP 엔드포인트를 만들고, 요청 본문을 선언한 모양대로 자동 검사한다 ]는 점에서 LLM을 돌리는 추론 엔진(vLLM · Ollama)과 구별된다. 이 과정에서 앱 서버 를 만드는 도구입니다. 모델은 모르고, 요청을 받아 업무 코드를 부르는 일만 합니다.

1.2 ⚠️ 브라우저가 LLM 서버를 직접 부르면

생기는 일왜 문제인가
사용자 구분이 없다Ollama는 인증이 없고, vLLM --api-key는 키 하나 뿐. 상담원별 권한(아래 인가)을 걸 자리가 없음
프롬프트가 브라우저에 있다개발자 도구로 시스템 프롬프트를 보고 고쳐서 보낼 수 있음
검증 · 저장 자리가 없다분류값 검증(29차시), 결과 저장, 개인정보 마스킹 로그(32차시)를 할 곳이 없음
LLM 서버의 네트워크 포트가 바깥에 열린다브라우저가 닿으려면 LLM 서버의 포트 번호(:8000, :11434)를 사내망 전체에 열어야 함. 그러면 앱을 거치지 않고 누구나 GPU를 직접 쓸 수 있음. LLM 서버는 앱 서버만 닿게 둡니다 (예: Docker에서 포트를 호스트 바깥으로 내보내지 않기, 127.0.0.1에만 바인딩)
용어정의
인증 (Authentication)[접근 통제 단계]인데, "너는 누구냐"를 확인한다 는 점에서 인가와 구별된다
인가 (Authorization)[접근 통제 단계]인데, [ 확인된 그 사람이 "이것을 해도 되냐"를 판정한다 ]는 점에서 인증과 구별된다. 예: 로그인한 상담원 A가 상담원 B 담당 문의를 볼 수 있는가

⚠️ 같은 "포트"라는 단어가 두 뜻으로 나옵니다. 네트워크 포트 (:8000 같은 번호, 서버 프로그램이 요청을 받는 문 번호)와 설계의 포트 (바깥과의 약속, 2절)는 다른 것입니다.

1.3 반례 — 이건 이 차시의 구조가 아닙니다

  • 앱 서버 안에서 transformers로 모델을 직접 로딩 — 축 ⑤ 앱 안에 모델 ↔ 모델 서버 분리 (17차시)의 왼쪽. 앱을 재시작할 때마다 모델도 다시 올라가고, 워커마다 VRAM을 따로 씁니다.
  • Open WebUI 같은 채팅 화면 — 사람이 모델과 대화하는 도구입니다. 업무 규칙 · 검증 · 저장이 없습니다.

2. ⭐ 헥사고날 폴더에 LLM 클라이언트 꽂기

2.1 폴더 트리

헥사고날 아키텍처 (Hexagonal Architecture, 포트와 어댑터) — [소프트웨어 구조 설계 방식]인데, [ 업무 로직을 가운데 두고 바깥 기술(웹 서버 · DB · LLM)과는 포트(약속)로만 연결해, 안쪽이 바깥 기술을 전혀 모르게 한다 ]는 점에서 화면 → 업무 → DB를 위에서 아래로 쌓기만 하는 계층 구조와 구별된다.

칸무엇인가문의봇에서
유스케이스업무 한 가지를 처리하는 안쪽 코드"문의를 요약한다"
포트안쪽이 바깥에 요구하는 약속 (추상)"말을 넣으면 말이 나온다"
인바운드 어댑터바깥이 안을 부르는 입구FastAPI 라우터 (HTTP 요청 → 유스케이스)
아웃바운드 어댑터안이 바깥을 부르는 출구. 포트를 특정 기술로 구현openai 라이브러리로 LLM 서버를 부르는 클래스
DTO층 사이에 값을 실어 나르는 단순한 데이터 묶음 (Data Transfer Object)요약 결과 (본문, 잘림 여부, 토큰 수)

폴더는 이 칸을 그대로 따릅니다. 새로 생기는 것은 포트 하나, 어댑터 하나 입니다.

munuibot/
├── adapter/
│   ├── inbound/api/v1/inquiry_router.py          ← FastAPI 라우터 (인바운드 어댑터)
│   ├── inbound/api/schemas/inquiry_schema.py     ← 요청·응답 검증 (문지기)
│   └── outbound/llm/openai_compat_adapter.py     ← ⭐ LLM 클라이언트 (아웃바운드 어댑터)
├── app/
│   ├── ports/output/llm_port.py                  ← ⭐ LLM 포트 (추상)
│   ├── use_cases/summarize_inquiry.py            ← 문의 요약 유스케이스
│   └── dtos/
├── domain/
├── prompts/summarize.md                          ← 프롬프트 (28차시)
└── core/config.py                                ← 환경변수 읽기 (3절)

포트는 "말을 넣으면 말이 나온다"는 계약(추상) , 어댑터는 그 계약을 openai 라이브러리로 만든 기술적 구상 , 유스케이스는 "문의를 요약한다"는 업무 쪽 구상 입니다.

💡 어느 폴더에 둘지 헷갈리면 이렇게 묻습니다 — "이걸 다른 회사 프로젝트에 그대로 복사해도 말이 되는가?" 그렇다면 업무 지식이 없는 바깥 껍질(어댑터)입니다. LLM 클라이언트가 바로 그렇습니다.

2.2 포트 — 유스케이스가 아는 전부

# app/ports/output/llm_port.py
from abc import ABC, abstractmethod
from dataclasses import dataclass

@dataclass
class LLMResult:
    text: str
    finish_reason: str          # "stop" | "length" | "tool_calls"
    prompt_tokens: int
    completion_tokens: int

class LLMPort(ABC):
    @abstractmethod
    async def chat(self, messages: list[dict], temperature: float = 0.2,
                   max_tokens: int = 512) -> LLMResult: ...

🔍 읽는 포인트

  • ABC · @abstractmethod — Python의 추상 클래스 . "이 이름의 메서드를 반드시 만들어라"는 틀만 있고 내용은 없는 설명서 입니다. 포트를 코드로 쓰면 이 모양이 됩니다.
  • LLMResult에 finish_reason 이 있고(4절), openai라는 단어는 한 번도 안 나옵니다 (검수 포인트).

2.3 어댑터 — openai 라이브러리를 아는 유일한 파일

# adapter/outbound/llm/openai_compat_adapter.py
from openai import AsyncOpenAI
from app.ports.output.llm_port import LLMPort, LLMResult

class OpenAICompatAdapter(LLMPort):
    def __init__(self, base_url: str, api_key: str, model: str, timeout: float):
        self.client = AsyncOpenAI(base_url=base_url, api_key=api_key,
                                  timeout=timeout, max_retries=0)
        self.model = model

    async def chat(self, messages, temperature=0.2, max_tokens=512) -> LLMResult:
        r = await self.client.chat.completions.create(
            model=self.model, messages=messages,
            temperature=temperature, max_tokens=max_tokens)
        c = r.choices[0]
        return LLMResult(text=c.message.content or "", finish_reason=c.finish_reason,
                         prompt_tokens=r.usage.prompt_tokens,
                         completion_tokens=r.usage.completion_tokens)

🔍 읽는 포인트

  • AsyncOpenAI + await — LLM 호출은 바깥세상을 기다리는 I/O-bound 작업입니다. 아래 표를 보세요.
  • base_url — 이 값 하나로 Ollama(:11434/v1) · vLLM(:8000/v1) · llama-server(:8080/v1)가 갈립니다(20차시).
  • max_retries=0 — 재시도를 라이브러리에 맡기지 않고 우리가 정한 규칙으로 하겠다는 뜻(5절).
작업 종류시간을 어디에 쓰나예Python에서
CPU-bound내 CPU가 계산하느라 바쁨큰 파일 압축, 이미지 변환일반 def (기다릴 틈이 없으므로 비동기 이득 없음)
I/O-bound네트워크 · 디스크 · 다른 서버의 답을 기다리느라 놂LLM 서버 호출, DB 조회⭐ async def + await

async def로 만든 함수는 await에서 기다리는 동안 서버가 다른 요청을 처리하러 갑니다 (README 5.1 축 ⑥ 비동기). 앱 서버 입장에서 LLM 호출은 수 초 동안 답을 기다리기만 하는 일이므로 async def가 맞습니다. ⚠️ 비동기는 호출 하나를 빠르게 하지 않습니다. 같은 시간에 받아 낼 수 있는 요청 수 가 늘 뿐입니다.

2.4 조립과 교체

유스케이스는 LLMPort만 알기 때문에, 실제로 어떤 어댑터를 끼울지 는 따로 정해 줘야 합니다. 이 일을 하는 곳이 의존성 조립소 — 어댑터 객체를 만들어 유스케이스에 넣어 주는 함수 한 곳 입니다. FastAPI의 Depends 는 라우터가 요청을 받을 때마다 이 조립 함수를 불러 완성된 유스케이스를 건네주는 기능입니다. 테스트에서는 조립소가 가짜 LLM 을 넣습니다.

상황조립소가 넣는 것 (유스케이스 코드는 셋 다 그대로)
개발 · 운영OpenAICompatAdapter(base_url=Ollama 또는 vLLM)
테스트FakeLLM() — 정해 둔 답을 바로 돌려줌. GPU 없이 테스트

이렇게 안쪽(유스케이스)이 바깥 기술이 아니라 약속(포트)에 기대고, 바깥 기술이 그 약속에 맞추는 원칙을 DIP(의존성 역전 원칙) 라고 부릅니다.

⚠️ AI가 만든 코드는 이 원칙을 절반만 지키는 경우가 흔합니다. 포트 파일은 만들어 놓고 유스케이스에서 라이브러리를 바로 부르는 식입니다. 유스케이스에 from openai import가 한 줄이라도 있으면 포트가 뚫린 것 입니다. grep -rn "openai" app/이 비어 있어야 합니다.


3. 설정은 환경변수로

환경변수 설정 — [설정 관리 방식]인데, [ 같은 코드를 그대로 두고 실행 환경마다 값만 바꿔 넣는다 ]는 점에서 코드에 값을 박아 두는 하드코딩과 구별된다.

# .env  (git에 올리지 않음 → .gitignore)
LLM_BASE_URL=http://localhost:11434/v1     # 개발: Ollama
# LLM_BASE_URL=http://vllm:8000/v1         # 운영: vLLM (도커 네트워크 안)
LLM_MODEL=qwen3:8b                          # vLLM이면 --served-model-name 값
LLM_API_KEY=dev-dummy                       # Ollama는 무시, vLLM은 --api-key와 같아야 함
LLM_TIMEOUT_SECONDS=60
# core/config.py
from pydantic_settings import BaseSettings, SettingsConfigDict

class LLMSettings(BaseSettings):
    model_config = SettingsConfigDict(env_file=".env", env_prefix="LLM_")
    base_url: str
    model: str
    api_key: str
    timeout_seconds: float = 60

Pydantic — [Python 라이브러리]인데, [ 데이터의 모양(필드 이름 · 타입 · 필수 여부)을 클래스로 선언해 두면, 들어온 값이 그 모양에 맞는지 자동으로 검사한다 ]는 점에서 값을 그냥 담기만 하는 일반 딕셔너리와 구별된다. 모양이 틀린 값을 안으로 들이지 않으므로 이 노트에서는 문지기 라고 부릅니다. 이렇게 선언한 데이터 모양 자체를 스키마(Schema) 라고 합니다.

🔍 읽는 포인트 — BaseSettings는 Pydantic의 환경변수 전용 모델 입니다(pydantic-settings 패키지를 따로 설치). .env의 LLM_BASE_URL이 base_url로 들어가고, 값이 없으면 기동할 때 바로 에러 가 납니다 — 늦게 터지는 것보다 낫습니다.

💡 .env는 환경변수를 적어 두는 텍스트 파일입니다. 비밀(키)이 들어 있으므로 git에 올리지 않고 , 대신 값을 비운 .env.example 을 올려 "어떤 변수가 필요한지"만 알립니다.

⚠️ 자주 걸리는 곳바로잡기
모델 이름이 엔진마다 다름Ollama는 태그(qwen3:8b), vLLM은 HF 이름(Qwen/Qwen3-8B) 또는 --served-model-name 값. GET {base_url}/models로 실제 이름을 확인
도커 안에서 localhost컨테이너 안의 localhost는 컨테이너 자기 자신 입니다. 같은 Docker 네트워크의 다른 컨테이너는 컨테이너(서비스) 이름 (http://vllm:8000)으로, 호스트 PC에서 돌고 있는 Ollama는 host.docker.internal(Linux는 별도 설정 필요)로 부릅니다
키를 코드 여기저기에 직접 씀설정을 읽는 곳은 core/config.py 한 곳 . 나머지 코드는 이 객체만 받아 씀

4. 실패를 갈라 보기

4.1 세 가지 실패 ⭐

실패증상앱에서 보이는 모습대응
타임아웃응답이 너무 늦음 (긴 입력, 동시 요청 몰림, max_tokens가 큼)openai.APITimeoutError출력 길이에 맞게 타임아웃 설정, 사용자에게 "지연" 안내, 스트리밍(26차시)
서버 다운 · 연결 실패LLM 서버가 꺼짐, 주소 틀림, 모델 로딩 중openai.APIConnectionError, 5xx짧게 1~2회 재시도 → 그래도 안 되면 503 으로 돌려주고 알림
finish_reason="length"200 OK인데 답이 중간에 잘림에러가 나지 않음 ⚠️로그 · 플래그, max_tokens 재검토, JSON이면 파싱 실패(29차시)

⭐ 셋째 줄이 가장 위험합니다. 에러가 나지 않는 실패 이기 때문입니다. 요약이 "고객은 배송 지연에 대해 문의하였으며 주문번호는"에서 끊겨도 앱은 정상 응답으로 처리합니다. 그래서 2.2절의 포트가 finish_reason을 반드시 위로 올려 보내게 만들었습니다.

⚠️ 컨텍스트 초과는 엔진마다 다르게 실패합니다. vLLM은 --max-model-len을 넘는 입력에 400 에러 를 냅니다. Ollama는 num_ctx를 넘는 입력을 조용히 잘라서 처리할 수 있습니다(13차시). 개발은 Ollama, 운영은 vLLM이라면 개발 때 안 보이던 에러가 운영에서 처음 납니다.

4.2 우리 API가 돌려줄 것

  • 200 — 정상. 잘렸으면 버리지 않고 truncated: true 를 달아 상담원이 알게 함
  • 503 / 504 — 앱 서버 뒤에 있는 LLM 서버가 없다(연결 못 함) / 늦다(제때 답하지 않음). 앱 서버 자신의 버그(500)와 구별해야 원인을 바로 찾습니다
  • 422 — 요청 본문이 스키마에 안 맞음 (FastAPI가 Pydantic으로 알아서 검사)

5. 재시도와 멱등성

5.1 ⭐ 매번 답이 달라도 멱등하다

멱등성 (Idempotency) — [요청의 성질]인데, [ 같은 요청을 한 번 보내든 여러 번 보내든 서버의 최종 상태가 같다 ]는 점에서 보낼 때마다 상태가 쌓이거나 바뀌는 비멱등 요청과 구별된다.

예첫 번째두 번째최종 상태멱등?
주문 조회 (GET)주문 정보같은 주문 정보변화 없음O
문의 #7 삭제 (DELETE)200 삭제됨404 이미 없음둘 다 "#7이 없다"O
결제 요청 (POST)결제 1건결제 2건돈이 두 번 나감X

⚠️ 둘째 줄을 보세요. 응답은 200과 404로 다른데 멱등 입니다. "응답이 같아야 멱등"은 가장 흔한 오해 입니다. 멱등성이 보는 곳은 응답이 아니라 서버 안의 최종 상태 입니다. 멱등 중에서도 상태를 아예 바꾸지 않는 요청(조회)을 따로 안전(Safe) 하다고 부릅니다.

temperature가 0보다 크면 같은 문의를 두 번 요약해도 문장이 다르게 나옵니다. 그래서 "LLM 호출은 멱등하지 않다"고 생각하기 쉽습니다. 바로 위의 오해가 이 자리에서 나옵니다. LLM 서버는 생성을 해도 서버 상태를 바꾸지 않습니다 (KV 캐시는 요청이 끝나면 반납됩니다). 그러니 멱등이고, 더 정확히는 안전 에 가깝습니다.

호출자원 상태 변화멱등?재시도
LLM 생성 호출 (/v1/chat/completions)없음O안전
요약을 DB에 새 행으로 추가부를 때마다 행이 늘어남X⚠️ 중복 저장
요약을 문의 ID 기준으로 덮어쓰기 (upsert)몇 번 해도 같은 한 행O안전
주문 취소 도구 실행 (30차시)상태가 바뀜X⚠️ 멱등성 키 필요

⭐ 둘째 · 셋째 줄의 차이는 "값을 정해 넣기(set)는 멱등, 하나 더 보태기(add · increment)는 비멱등" 입니다. LLM 호출이 아니라 그 결과를 저장하는 동사 가 재시도의 위험을 정합니다.

💡 멱등성 키 (Idempotency Key) — 비멱등 요청을 안전하게 재시도하려고 요청마다 붙이는 고유 번호 입니다. 서버는 이미 처리한 번호가 다시 오면 실행하지 않고 저장해 둔 결과를 돌려줍니다. 30차시 4.5에서 주문 취소에 씁니다.

5.2 ⚠️ 안전하다 ≠ 공짜다

함정왜규칙
재시도는 GPU 일을 두 배로서버가 과부하라서 늦은 것인데 다시 보내면 더 늦어짐횟수 제한(1~2회), 간격을 늘려 가며 (backoff)
타임아웃 후 재시도우리는 포기했지만 LLM 서버는 아직 생성 중 일 수 있음 → 같은 일을 두 번타임아웃은 재시도보다 사용자 안내 를 먼저
이중 재시도openai 클라이언트에도 자체 재시도가 있음(기본 횟수는 버전에 따라 다르니 문서 확인)재시도는 한 층에서만 . 그래서 2.3절의 max_retries=0

6. 한 바퀴 — /summarize 엔드포인트

# adapter/inbound/api/v1/inquiry_router.py
@router.post("/inquiries/{inquiry_id}/summary", response_model=SummaryResponse)
async def summarize(inquiry_id: str, req: SummarizeRequest,
                    uc: SummarizeInquiry = Depends(get_summarize_use_case)):
    return await uc.execute(inquiry_id, req.body)
요청 JSON ─▶ SummarizeRequest (문지기: body가 비었거나 너무 길면 422)
          ─▶ SummarizeInquiry.execute()   ← prompts/summarize.md를 읽어 messages 조립
          ─▶ LLMPort.chat()               ← 유스케이스는 여기까지만 앎
          ─▶ OpenAICompatAdapter          ← base_url 너머의 Ollama 또는 vLLM
          ◀─ LLMResult(text, finish_reason, tokens)
          ◀─ SummaryResponse(summary, truncated, tokens)

🔍 읽는 포인트

  • 라우터는 한 줄짜리입니다. 업무 로직이 라우터에 없어야 합니다.
  • SummarizeRequest는 3절의 Pydantic 스키마 = 문지기 입니다. 본문 길이 상한을 여기서 걸면 컨텍스트 초과를 LLM 서버까지 가기 전에 막습니다.

앞으로 다시 만납니다 — 이 뼈대에 26차시가 스트리밍을, 27차시가 대화 기록을, 29차시가 JSON 검증을, 30차시가 주문 조회 도구를 붙입니다. 유스케이스와 어댑터만 늘어나고 폴더 구조는 그대로 입니다. 32차시에서 전체를 다시 그립니다.


7. 실습 — 맡기고 검수하기

지시

FastAPI로 "문의 요약 API"의 뼈대를 만들어줘.
목표: POST /inquiries/{id}/summary 가 문의 본문을 받아 로컬 LLM으로 3줄 요약을 돌려준다.
구조(반드시 지킬 것):
 - app/ports/output/llm_port.py 에 LLMPort(ABC)와 LLMResult(text, finish_reason, prompt_tokens, completion_tokens)
 - adapter/outbound/llm/openai_compat_adapter.py 에서만 openai(AsyncOpenAI)를 import
 - app/use_cases/ 아래 파일에는 openai, fastapi 를 import 하지 말 것
 - 설정은 pydantic-settings 로 .env 의 LLM_BASE_URL, LLM_MODEL, LLM_API_KEY, LLM_TIMEOUT_SECONDS 에서 읽기
 - 연결 실패는 503, 타임아웃은 504, finish_reason="length" 는 200 + truncated: true
 - 재시도는 어댑터에서만 최대 1회, AsyncOpenAI 의 max_retries=0
 - tests/ 에 FakeLLM 으로 유스케이스 단위 테스트 3개(정상, length, 연결 실패)
맥락: openai 파이썬 라이브러리 공식 문서와 FastAPI 공식 문서 기준으로. 모르는 옵션은 추측하지 말고 "확인 필요"라고 써.
보고 형식: [시나리오 | 실행한 명령 | HTTP 상태 | 응답 본문 일부] 표 + 아래 증거의 실행 출력 원문을 붙여줘.

받을 증거

  • tree 출력, grep -rn "openai" app/ 출력( 비어 있어야 함 )
  • curl 세 번의 출력 원문: ① 정상 ② LLM 서버를 끈 상태(sudo systemctl stop ollama 또는 vLLM 종료 — systemctl은 리눅스에서 프로그램을 서비스로 상시 실행 · 재시작해 주는 관리자 systemd를 다루는 명령. ⚠️ ollama stop은 모델만 내리고 서버는 살아 있음) ③ max_tokens를 10으로 낮춘 상태
  • pytest 실행 출력

내가 직접 볼 것

볼 것이걸로 아는 것
grep 결과가 비었는가포트가 진짜 포트인지 — 유스케이스가 엔진을 모르는가
② 의 HTTP 상태가 503 인가 (500이 아니라)서버 다운을 구별해서 처리하는가
③ 의 응답에 truncated: true가 있는가에러가 안 나는 실패 를 잡는가
.env의 LLM_BASE_URL만 바꿔 Ollama ↔ vLLM이 둘 다 되는가축 ② — 코드 수정 없이 엔진이 바뀌는가
.env가 git status에 안 보이는가키가 저장소로 새지 않는가

부록 A. 스스로 확인하기

#질문답이 있는 곳
1세 층(UI · 앱 서버 · LLM 서버)이 각각 모르는 것은?1.1
2브라우저가 LLM 서버를 직접 부르면 안 되는 이유 네 가지는?1.2
3LLM 포트와 OpenAI 호환 어댑터는 어느 폴더에 있고, 추상 · 구상 중 무엇인가?2.1
4유스케이스에 from openai import가 있으면 무엇이 깨진 것인가?2.4
5Ollama와 vLLM에서 model 값이 다른 이유와 확인 방법은?3
6에러가 나지 않는 실패는 무엇이고, 어떻게 잡는가?4.1
7매번 답이 다른 LLM 호출이 멱등한 이유는?5.1
8재시도가 안전한데도 조심해야 하는 이유 세 가지는?5.2

부록 B. 자주 하는 실수

실수바로잡기
"프론트에서 LLM 서버를 바로 부르면 빠르고 간단하다"❌ 인증 · 프롬프트 보호 · 검증 · 로그 자리가 사라짐. 앱 서버를 거친다
유스케이스에서 openai를 직접 import❌ 포트가 뚫림. openai는 어댑터 한 파일 만 안다
base_url과 모델 이름을 코드에 직접 씀❌ 엔진을 바꿀 때마다 배포. 환경변수 로
HTTP 200이면 정상 응답이라고 판단⚠️ finish_reason="length"는 200으로 온다
"temperature > 0이면 LLM 호출은 비멱등"❌ 멱등은 응답이 아니라 서버의 최종 상태 를 본다 (5.1)

부록 C. NotebookLM 복습 프롬프트

이 25차시 노트만 근거로 6문제를 내줘. 비전공자 기준으로.
"LLM 클라이언트가 헥사고날 구조에서 들어가는 폴더", "finish_reason=length가 위험한 이유", "매번 답이 달라도 멱등한 이유"는 반드시 포함해줘.
답은 내가 답한 다음에 채점하고, 틀린 이유를 노트의 절 번호를 근거로 설명해줘.