페이지 목차
이 페이지에서 검색
로컬 LLM
이 페이지에 묻기
로컬 LLM · 현재 차시와 목차 본문에서만 찾아 답합니다. 답을 지어내지 않고 해당하는 대목을 그대로 보여 주며, 여기에 없으면 없다고 알려 드립니다.
생성형 AI가 아니라 이 페이지 본문을 찾아 그대로 보여 주는 방식입니다. 외부로 나가는 요청이 없습니다.
LOCAL · LLM
로컬 LLM
생성 수정
로컬 모델 실행과 GPU·메모리 이해부터 서빙·RAG·도구 호출·업무형 앱 구현까지 차례로 살펴봅니다.
42개 문서 · 3일차 · 21 / 42
20차시 · OpenAI 호환 API 활용
지도 위치 : 3일차 · ④API 층 · ⭐ 과정 전체의 "문장 3"을 회수하는 자리 | 이전: 19차시 vLLM 서버 구축 | 다음: 21차시 vLLM 추론 구조 이해 키워드 : OpenAI API 구조, Chat Completions, Python Client, 로컬 LLM API 연동
이 차시를 다 읽으면
- 포트 · 어댑터 · 헥사고날 아키텍처 를 유와 종차로 정의하고, 인바운드와 아웃바운드를 구별할 수 있다.
- OpenAI 호환 API = 포트, Ollama · vLLM · llama-server = 어댑터 라는 대응을 두 배율 (서비스 사이 · 앱 코드 안)에서 채울 수 있다.
- Chat Completions의 요청 · 응답 구조를 읽고,
finish_reason: "length"가 왜 위험한지 말할 수 있다. - 같은 Python 코드에서
base_url만 바꿔 Ollama와 vLLM을 오갈 수 있다. - 엔진 주소 · 키 · 모델 이름을 환경변수(.env) 로 빼는 이유를 의존성 주입(DI) 으로 설명할 수 있다.
- "OpenAI 호환"이 OpenAI로 데이터를 보낸다는 뜻이 아님 과, 그래도 생길 수 있는 사고 하나를 말할 수 있다.
- 엔진마다 지원 범위가 조금씩 다르다 는 것을 구문 ↔ 의미 축으로 설명할 수 있다.
0. 이 차시의 판별 축
| # | 축 | 왼쪽 (0) | 오른쪽 (1) | 이 축으로 갈리는 것 | 큰 축 (README 5장) |
|---|---|---|---|---|---|
| 1 | 무엇을 할지인가, 어떻게 할지인가 | 약속 = OpenAI 호환 API ( 포트 ) | 구현 = Ollama · vLLM · llama-server ( 어댑터 ) | 엔진을 갈아끼울 수 있는가 | ⭐ ② 추상 ↔ 구상 |
| 2 | 값을 누가 넣는가 | 코드에 주소 · 키를 박음 | 밖(환경변수)에서 넣어 줌 | 환경을 바꿀 때 코드를 고치는가 | ② 추상 ↔ 구상 (의존성 주입, 3.3) |
| 3 | 어디까지 같은가 | 형식이 같다 (요청 · 응답 JSON) | 동작까지 같다 (지원 기능 · 결과) | "호환"의 범위 | ⑧ 구문론 ↔ 의미론 |
이 차시는 README 7장 문장 3 , "표준 API(약속)로 층을 나누면 엔진(구현)을 갈아 끼울 수 있다"를 회수하는 자리입니다. 7차시 5절에서 Ollama 주소 끝에 /v1을 붙이면 다른 모양의 API가 열린다고 예고했고, 17차시 구조도에서는 API 서버와 모델 서버 사이의 화살표에 "포트"라는 이름표를 미리 붙여 두었습니다. 그 "포트"가 무엇인지, 왜 그것 하나로 엔진을 갈아 끼울 수 있는지를 이 차시에서 처음부터 설명합니다.
1. ⭐⭐ 포트와 어댑터 — 문장 3의 회수
1.1 먼저, 헥사고날 아키텍처
풀려는 문제
AI에게 "문의 요약 기능을 만들어 줘"라고 하면, 분류 규칙 · 프롬프트 조립 같은 업무 로직 한가운데에 http://localhost:11434 같은 엔진 주소나 Ollama 전용 호출 코드가 섞여 나오기 쉽습니다. 이렇게 되면 엔진을 vLLM으로 바꾸는 순간 업무 로직까지 뜯어 고쳐야 합니다. 기술은 자주 바뀌는데 업무 규칙은 잘 바뀌지 않습니다. 그래서 둘을 떼어 놓는 설계 방식이 있습니다.
안쪽과 바깥
| 안쪽 | 바깥 | |
|---|---|---|
| 무엇 | 업무 로직 — 문의를 어떻게 분류 · 요약하고 결과를 어떻게 검증하는가 | 기술 — 웹 프레임워크(FastAPI), LLM 엔진(Ollama · vLLM), 데이터베이스 |
| 얼마나 자주 바뀌나 | 드물게 (회사 규칙이 바뀔 때) | 자주 (더 나은 도구가 나올 때) |
| 무엇을 아는가 | 약속만 안다. 바깥 기술의 이름을 모른다 | 안쪽이 정한 약속에 자기를 맞춘다 |
⭐ 핵심 규칙은 한 줄입니다. 안쪽은 바깥 기술에 의존하지 않고, 자기가 정한 약속에만 의존한다.
정의
포트 (Port) — [약속(인터페이스)]인데, [ 안쪽이 바깥과 주고받을 일을 "무엇을 하는가"로만 정해 두고 "어떻게 하는가"는 정하지 않는다 ]는 점에서 그 약속을 실제로 지키는 어댑터와 구별된다.
어댑터 (Adapter) — [부품(구현)]인데, [ 포트라는 약속을 특정 기술로 실제로 지킨다 ]는 점에서 약속 자체인 포트와 구별된다. 같은 포트에 어댑터를 바꿔 끼울 수 있다.
헥사고날 아키텍처 (Hexagonal Architecture) — [소프트웨어 구조 설계 방식]인데, [ 업무 로직을 가운데 두고 바깥 기술과는 포트를 통해서만 만나게 해, 안쪽이 바깥 기술을 모르게 한다 ]는 점에서 업무 로직과 기술 코드가 한 파일에 뒤섞이는 구조와 구별된다. "포트와 어댑터 아키텍처"라고도 부릅니다. 이름의 육각형은 "바깥과의 연결이 여러 방향"이라는 그림일 뿐, 6이라는 숫자에 뜻은 없습니다.
인바운드와 아웃바운드
포트와 어댑터는 누가 누구를 부르는가 에 따라 두 방향으로 나뉩니다.
| 인바운드 (들어오는 쪽) | 아웃바운드 (나가는 쪽) | |
|---|---|---|
| 부르는 방향 | 바깥이 안쪽을 부른다 | 안쪽이 바깥을 부른다 |
| 포트 = 약속 | 안쪽이 제공하는 기능 ("문의를 요약한다") | 안쪽이 필요로 하는 기능 ("messages를 주면 답 문자열을 돌려준다") |
| 어댑터 = 구현 | 바깥의 요청(HTTP 등)을 받아 인바운드 포트를 부르는 부품 | 아웃바운드 포트를 구현해 실제 바깥 시스템에 요청하는 부품 |
| 문의봇에서 | FastAPI 라우터 POST /summarize | openai 클라이언트로 LLM 서버를 부르는 클래스 |
작은 그림
상담원 브라우저 ← 바깥 (남의 것)
│ HTTP POST /summarize
▼
[인바운드 어댑터] FastAPI 라우터 ← 바깥 (기술)
│ 부른다
▼
(인바운드 포트) "문의를 요약한다" ┐
│ │
[유스케이스] 프롬프트 조립 · 결과 검증 │ 안쪽 (업무 로직)
│ 부른다 │ 바깥 기술의 이름을 모른다
▼ │
(아웃바운드 포트) LLMPort: "messages → 답 문자열" ┘
△ 구현한다
│
[아웃바운드 어댑터] OpenAICompatLLM ← 바깥 (기술)
│ HTTP /v1/chat/completions
▼
Ollama 또는 vLLM 서버 ← 바깥 (남의 것)
( ) = 포트: 약속 · 추상 [ ] = 어댑터 · 유스케이스: 구현 · 구상
🔍 읽는 포인트
- 유스케이스 (안쪽의 업무 로직 한 덩어리)가 부르는 것은
(아웃바운드 포트)까지입니다. 그 아래가 Ollama인지 vLLM인지 모릅니다. - 아웃바운드 어댑터의 화살표는 위를 향합니다 (△ 구현한다). 안쪽이 약속을 정하고, 바깥이 그 약속에 자기를 맞춥니다. 이것이 "약속에만 의존한다"의 모양입니다.
반례 — 이건 헥사고날이 아닙니다
| 오해 | 바로잡기 |
|---|---|
| "여기의 포트 = 8000번 같은 네트워크 포트" | ❌ 같은 철자, 다른 개념(README 6장, 19차시 1.3). 여기의 포트는 코드 사이의 약속 |
| "인터페이스 클래스를 하나 만들었으니 헥사고날" | ❌ 유스케이스 파일이 여전히 openai나 fastapi를 직접 import하고 있으면 안쪽이 바깥 기술을 아는 것. 안쪽 파일에 바깥 기술 이름이 없어야 함 |
| "어댑터가 많을수록 좋은 설계" | ⚠️ 바꿀 일이 없는 곳까지 포트를 세우면 코드만 늘어남. 바뀔 가능성이 높은 바깥 (LLM 엔진)에 세움 |
💡 비유는 기억 보조로만 — 콘센트 규격(포트)과 그 규격에 맞춘 각 회사의 플러그(어댑터). 벽(안쪽)은 어느 회사 제품이 꽂힐지 모르고, 규격만 정해 둡니다(README 5.1 축 ②의 예).
1.2 OpenAI 호환 API — 여러 엔진이 함께 지키는 약속
OpenAI 호환 API — [HTTP API 규격]인데, [ OpenAI가 공개한 요청 · 응답 형식(경로 /v1/chat/completions, JSON 필드 이름)을 다른 엔진들이 똑같이 따른다 ]는 점에서 엔진마다 고유한 API(Ollama의 /api/chat 등, 7차시)와 구별된다.
API 는 원래 [약속]인데, [ 내 프로그램이 남에게 열어 주는 창구의 목록과 규격 ]입니다. OpenAI 호환 API는 여기서 한 걸음 더 갑니다. 한 회사가 만들었지만 여러 엔진이 함께 지키는 약속입니다. 여러 구현이 같은 약속을 지키니, 1.1의 포트 노릇을 할 수 있습니다.
1.3 같은 모양을 다른 곳에서도 봅니다
| 포트 (약속 · 추상) | 어댑터 (구현 · 구상) | |
|---|---|---|
| 전원 | 콘센트 규격 | 각 회사의 플러그 |
| 컨테이너 | OCI 표준 (컨테이너 이미지와 실행 방식의 공개 규격) | Docker, Podman (19차시 6.2) |
| 로컬 LLM | OpenAI 호환 API | Ollama :11434 · vLLM :8000 · llama-server :8080 |
세 줄 모두 한 문장으로 요약됩니다. "같은 약속(추상)을 여러 구현(구상)이 지키고 있다. 그래서 갈아끼울 수 있다. 표준이 존재하는 이유가 이것이다."
1.4 배율 1 — 서비스 사이
포트와 어댑터는 크기를 가리지 않는 모양 입니다. 함수가 매개변수의 형식만 알고 실제 값은 밖에서 받는 것, 클래스가 인터페이스만 알고 실제 객체는 밖에서 받는 것, 시스템이 API 규격만 알고 실제 서버는 주소로 받는 것이 모두 같은 모양의 다른 배율입니다.
가장 큰 배율로 보면, 문의봇 앱 전체가 "안쪽"이고 엔진 서버가 "바깥"입니다.
[ 문의봇 앱 ] ──의존──► ( OpenAI 호환 API ) ◄──구현── [ Ollama / vLLM / llama-server ]
쓰는 쪽 추상 (약속) 쓰이는 쪽
17차시 3.2의 MSA (기능별로 서버를 나누고 네트워크로 부르는 방식)에서 서비스와 서비스 사이를 잇는 API 약속이 정확히 이 자리입니다.
1.5 배율 2 — 앱 코드 안에서
1.1의 작은 그림을 문의봇 코드의 칸으로 채우면 이렇습니다.
| 1.1 그림의 자리 | 표준 명칭 | 문의봇에서 | 추상 / 구상 |
|---|---|---|---|
| 상담원 브라우저 | Client / UI | 상담원 브라우저 | 구상 · 남의 것 |
| 인바운드 어댑터 | Inbound Adapter | FastAPI 라우터 POST /summarize | 구상 · 내 것 |
| 인바운드 포트 | Inbound Port | SummarizeUseCase (인터페이스) | 추상 |
| 유스케이스 | Domain / Service | 프롬프트 조립 · 결과 검증 · 분류값 확인 | 구상 · 핵심 |
| 아웃바운드 포트 | Outbound Port | LLMPort.chat(messages) -> str | 추상 |
| 아웃바운드 어댑터 | Outbound Adapter | OpenAICompatLLM — openai 클라이언트로 base_url에 요청 | 구상 · 내 것 |
| 바깥 시스템 | External System | Ollama 또는 vLLM 서버 | 구상 · 남의 것 |
from abc import ABC, abstractmethod
from openai import OpenAI
class LLMPort(ABC): # Outbound Port (추상)
@abstractmethod
def chat(self, messages: list[dict]) -> str: ...
class OpenAICompatLLM(LLMPort): # Outbound Adapter (구상)
def __init__(self, base_url: str, api_key: str, model: str):
self.client = OpenAI(base_url=base_url, api_key=api_key)
self.model = model
def chat(self, messages: list[dict]) -> str:
r = self.client.chat.completions.create(model=self.model, messages=messages,
temperature=0.2, max_tokens=300)
return r.choices[0].message.content
class SummarizeService: # 유스케이스 (안쪽)
def __init__(self, llm: LLMPort): # 받는 것은 약속(포트)뿐
self.llm = llm
def summarize(self, system_prompt: str, inquiry: str) -> str:
return self.llm.chat([{"role": "system", "content": system_prompt},
{"role": "user", "content": inquiry}])
🔍 읽는 포인트
class LLMPort(ABC)+@abstractmethod— Python에서 구현 없이 모양만 있는 클래스 를 만드는 표준 도구입니다. 이것을 상속한 클래스는chat을 반드시 구현해야 합니다. 이것이 포트입니다.OpenAI(base_url=...)— 어댑터 안에서 주소 하나 만 엔진을 가리킵니다.SummarizeService(llm: LLMPort)— 유스케이스는 포트만 받습니다. 이 클래스 안에는openai도, Ollama도, vLLM도 나오지 않습니다. 실제 어댑터는 밖에서 넣어 줍니다 (3.3의 의존성 주입).
⭐ 두 배율을 나란히 놓으면 한 가지가 보입니다. 배율 1에서는 엔진이 어댑터이고, 배율 2에서는 엔진이 바깥 시스템이고 OpenAICompatLLM이 어댑터입니다. 둘 다 맞습니다. 같은 모양을 다른 크기로 본 것뿐입니다. 그리고 엔진들이 같은 약속을 지키기 때문에 배율 2의 어댑터가 하나로 충분합니다. 보통은 바깥 기술을 바꾸면(예: 데이터베이스를 PostgreSQL에서 MongoDB로) 그 기술용 어댑터를 새로 짜야 합니다. 여기서는 엔진마다 어댑터 클래스를 새로 짤 필요조차 없습니다. 한 단계 더 쉬운 상황입니다.
2. Chat Completions — 요청과 응답의 구조
2.1 요청
{
"model": "munuibot",
"messages": [
{"role": "system", "content": "너는 쇼핑몰 고객센터 문의를 분류하고 3줄 이내로 요약하는 도우미다."},
{"role": "user", "content": "주문한 운동화가 일주일째 안 와요. 주문번호 20261128-001"}
],
"temperature": 0.2,
"max_tokens": 300,
"stream": false
}
| 필드 | 뜻 | Ollama 고유 API에서는 (7차시) |
|---|---|---|
model | 부를 모델 이름 | 모델 태그 (qwen3:8b). vLLM은 --served-model-name 값 |
messages[].role | system(규칙) · user(질문) · assistant(이전 답) · tool(30차시) | /api/chat과 같은 모양 |
temperature | 무작위성 (6 · 13차시) | options.temperature |
max_tokens | 이번에 생성할 최대 토큰 | options.num_predict |
stream | 조각으로 받을지 | ⚠️ 기본값이 다릅니다. Ollama /api/chat은 기본 true, OpenAI 형식은 기본 false |
⚠️ API는 무상태입니다. 서버는 이전 대화를 기억하지 않으므로 messages에 대화 전체를 매번 담습니다(8차시, 27차시에서 본격적으로).
2.2 응답
{
"id": "chatcmpl-...",
"object": "chat.completion",
"model": "munuibot",
"choices": [
{
"index": 0,
"message": {"role": "assistant", "content": "[배송지연] 주문번호 20261128-001, 일주일째 미배송 ..."},
"finish_reason": "stop"
}
],
"usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
}
(형태만 보인 것입니다. usage의 숫자는 실제 응답에서 확인합니다.)
| 필드 | 뜻 | 왜 보는가 |
|---|---|---|
choices[0].message.content | 답 | 앱이 쓰는 값 |
usage.prompt_tokens | 입력 토큰 수 | 컨텍스트를 얼마나 썼나. 14차시 Ollama의 prompt_eval_count에 해당 |
usage.completion_tokens | 출력 토큰 수 | TPS 계산(22차시). Ollama의 eval_count에 해당 |
finish_reason | 왜 멈췄나 | 2.3 |
2.3 ⚠️ finish_reason: "length" = 잘렸다
| 값 | 뜻 | 앱이 할 일 |
|---|---|---|
"stop" | 모델이 스스로 끝냈거나 stop 문자열을 만남 | 정상 처리 |
"length" | max_tokens 또는 컨텍스트 상한에 걸려 중간에 끊김 | ⚠️ 결과를 믿지 않음. max_tokens를 늘리거나 입력을 줄여 재시도 |
"tool_calls" | 도구 호출을 요청함 | 30차시 |
잘린 요약은 마지막 항목이 빠진 채 그럴듯하게 보입니다. 잘린 JSON(29차시)은 파싱이 실패합니다. 그래서 finish_reason은 앱이 매번 확인하는 값 입니다. 사람이 눈으로 볼 값이 아닙니다.
⚠️ 컨텍스트를 넘는 요청의 처리는 엔진마다 다릅니다. vLLM은 대개 입력 + max_tokens가 --max-model-len을 넘으면 에러로 거절 하고, Ollama는 num_ctx에 맞춰 조용히 잘릴 수 있습니다(2차시 3절). 정확한 동작은 버전별로 확인합니다.
앞으로 다시 만납니다 —
stream: true로 바꾸면 응답이 SSE(text/event-stream) 조각으로 오고, 각 조각의choices[0].delta.content를 이어 붙이며, 끝은data: [DONE]입니다. 26차시에서 다룹니다.
3. Python 클라이언트 — base_url만 바꿔 엔진 전환
3.1 코드
import os
from openai import OpenAI
client = OpenAI(
base_url=os.environ["LLM_BASE_URL"], # 예: http://localhost:8000/v1
api_key=os.environ["LLM_API_KEY"],
)
resp = client.chat.completions.create(
model=os.environ["LLM_MODEL"],
messages=[
{"role": "system", "content": "문의를 [배송지연, 교환·반품, 결제오류, 상품문의, 기타] 중 하나로 분류하고 한 줄로 요약해."},
{"role": "user", "content": "결제는 됐는데 주문 내역에 안 떠요."},
],
temperature=0, max_tokens=150,
)
print(resp.choices[0].message.content)
print(resp.usage.prompt_tokens, resp.usage.completion_tokens, resp.choices[0].finish_reason)
🔍 읽는 포인트
- 엔진을 가리키는 것은
base_url한 줄 뿐입니다. 코드에 엔진 이름이 없습니다. os.environ["..."]— 값이 코드 밖 에서 들어옵니다(3.3). 대괄호 방식이라 값이 없으면 에러로 멈춥니다 (4.1에서 이게 중요해집니다).- 마지막 줄 — 2.2 표의 세 값을 매번 찍어 둡니다.
3.2 엔진별 값
| 엔진 | LLM_BASE_URL | LLM_API_KEY | LLM_MODEL |
|---|---|---|---|
| Ollama (5차시) | http://localhost:11434/v1 | 아무 값 (검사하지 않음) | qwen3:8b |
| vLLM (19차시) | http://localhost:8000/v1 | 19차시의 VLLM_API_KEY 값 | munuibot |
| llama-server (12차시) | http://localhost:8080/v1 | 설정했다면 그 값 | 서버 설정에 따름 |
⚠️ model 값이 엔진마다 다릅니다. Ollama는 모델 태그, vLLM은 --served-model-name입니다. 그래서 19차시에 이름을 munuibot으로 고정해 두면 앱 설정이 단순 해집니다.
⚠️ 같은 GPU 한 장에서 Ollama와 vLLM을 동시에 띄우면 메모리가 부딪힙니다(18차시 5.1). 전환 실습은 하나씩 합니다.
3.3 설정은 환경변수로 — .env와 .env.example
환경변수 는 프로그램 바깥(셸 · systemd · 컨테이너)에서 프로그램에 넘겨주는 이름=값 설정이고, 그 값들을 모아 둔 파일이 .env 입니다. 19차시 6.1에서 본 규칙 세 줄 그대로 씁니다. ① 비밀값은 코드에 쓰지 않는다 ② 값이 든 .env는 Git에 올리지 않는다 ③ 키 이름만 적은 .env.example을 대신 올린다.
# .env.example — 키 이름만. Git에 올린다
LLM_BASE_URL=
LLM_API_KEY=
LLM_MODEL=
# .env — 값. Git에 올리지 않는다 (.gitignore에 먼저 등록)
LLM_BASE_URL=http://localhost:8000/v1
LLM_API_KEY=여기에_비밀값
LLM_MODEL=munuibot
0절 축 2의 오른쪽입니다. 이 방식에는 이름이 있습니다.
의존성 주입 (DI, Dependency Injection) — [부품 연결 방식]인데, [ 쓰는 쪽이 필요한 부품을 스스로 만들거나 정하지 않고, 밖에서 넣어 받는다 ]는 점에서 코드 안에서 직접 생성 · 지정하는(하드코딩) 방식과 구별된다.
한 줄로 줄이면 "쓰는 쪽은 약속만 알고, 실제 물건은 밖에서 넣어 준다." 1.5의 SummarizeService(llm)가 클래스 수준의 DI이고, .env는 "밖에서 넣어 주는 값"을 코드 바깥의 파일 까지 밀어낸 것입니다. 개발 노트북의 .env는 Ollama를, 운영 서버의 .env는 vLLM을 가리키게 하면 코드 한 줄 안 바꾸고 README 7장 문장 3이 이루어집니다. 24차시의 결론 "개발은 Ollama, 운영은 vLLM"이 이 세 줄 덕분에 가능합니다.
4. 반례와 경계
4.1 ⚠️ "OpenAI 호환" ≠ OpenAI에 데이터를 보낸다
| 오해 | 사실 |
|---|---|
"openai 패키지를 쓰면 문의가 OpenAI로 간다" | ❌ 요청은 base_url이 가리키는 곳 으로만 갑니다. localhost면 내 서버 |
| "API 키는 OpenAI에서 발급받아야 한다" | ❌ 로컬 서버가 정한 키(vLLM --api-key)이거나, 검사하지 않는 아무 값(Ollama) |
| "호환이니까 OpenAI 서버와 같은 모델이다" | ❌ 같은 것은 형식 뿐. 뒤의 모델은 Qwen3-8B |
⚠️ 그래도 실제로 생길 수 있는 사고가 하나 있습니다. base_url을 비우거나 빠뜨리면 openai 라이브러리는 기본 주소(OpenAI의 서버) 로 요청을 보냅니다. 키가 틀려 401이 나더라도, 문의 본문은 이미 외부망을 건넌 뒤 입니다. 1차시 축 ④(로컬 ↔ 원격)가 코드 한 줄로 뒤집히는 자리입니다.
| 대책 | 어떻게 |
|---|---|
| 기본값을 주지 않는다 | os.environ["LLM_BASE_URL"](값이 없으면 멈춤). .get(..., 기본값)으로 조용히 넘어가게 하지 않음 |
| 시작할 때 확인한다 | 앱 기동 시 base_url을 로그에 찍고, 허용한 사내 주소가 아니면 기동 실패 |
| 네트워크로 막는다 | 폐쇄망 · 방화벽으로 외부 API 주소 차단 (1차시 4절) |
💡 이름이 목적지를 말해 주지 않습니다. "OpenAI 호환"이라는 규격 이름도,
openai라는 패키지 이름도 요청이 어디로 가는지와는 무관합니다. 목적지는 언제나base_url한 줄이고, 그 한 줄을 눈으로 확인 하는 것이 검수입니다.
4.2 ⚠️ "호환"은 형식까지 — 엔진별로 지원 범위가 다르다
0절 축 3입니다. OpenAI 호환은 구문(요청 · 응답의 모양)이 같다는 약속 이지, 모든 기능 · 모든 결과가 같다는 약속이 아닙니다.
| 항목 | 엔진마다 갈리는 방식 | 다루는 곳 |
|---|---|---|
표준에 없는 샘플링 값 (top_k 등) | 엔진별 확장. 클라이언트에서 extra_body로 보내고, 받는지는 문서 확인 | 13차시 |
구조화 출력 (response_format JSON 스키마) | 지원 여부 · 방식이 엔진 · 버전마다 다름 | 29차시 |
도구 호출 (tools) | vLLM은 기동 옵션 이 필요(도구 파서 지정), Ollama는 도구를 지원하는 모델만 | 30차시 |
| Qwen3 생각 모드 끄기 | 엔진마다 방법이 다름 (모델 카드 확인) | 18차시 4.2 |
| 컨텍스트 초과 처리 | 거절(vLLM) ↔ 잘림 가능(Ollama) | 2.3 |
설계에는 이 상황을 묻는 이름 붙은 질문이 있습니다. 리스코프 치환 원칙(LSP) — "같은 약속을 지키는 다른 부품을 이 자리에 넣어도 부르는 쪽이 아무것도 몰라도 되는가? " 1.1의 말로 하면 "어댑터를 바꿔 끼워도 안쪽이 그대로인가?"입니다. 이 질문으로 보면 위 표의 줄들이 치환이 깨지는 지점 입니다. 그래서 엔진을 바꾸면 평가셋으로 다시 확인 합니다(6차시, 24차시).
5. 실습 — 맡기고 검수하기
지시
같은 Python 스크립트로 Ollama와 vLLM을 번갈아 호출하는 실습을 해줘.
맥락: openai 공식 Python 라이브러리, Ollama의 OpenAI 호환 문서, vLLM의 OpenAI-compatible server 문서를 근거로.
제약:
- 엔진 주소 · 키 · 모델 이름은 코드에 쓰지 말고 .env에서 읽을 것. .env.example도 만들 것. .env는 .gitignore에.
- base_url이 없으면 기본값으로 넘어가지 말고 에러로 멈추게 할 것.
- GPU가 한 장이므로 Ollama와 vLLM을 동시에 띄우지 말 것. 전환 전후 nvidia-smi로 확인.
할 일:
1. .env.ollama, .env.vllm 두 벌을 만들고 같은 스크립트로 문의 5건(내가 준 것)을 분류 · 요약.
2. 엔진 전환 때 바뀐 파일 목록(git status / git diff --stat)을 보여줘.
3. LLM_BASE_URL을 지우고 실행했을 때의 출력.
보고 형식: [엔진 | 문의 # | 분류 | 요약 | prompt_tokens | completion_tokens | finish_reason] 표.
추측하지 말고 실행 출력 원문을 붙여.
받을 증거
- 결과표 (엔진 2 × 문의 5 = 10줄)
- 전환 시
git diff --stat출력 — 코드 파일 변경 0 이어야 함 LLM_BASE_URL없이 실행한 출력 (에러로 멈춘 원문)
내가 직접 볼 것
| 볼 것 | 이걸로 아는 것 / 왜 내가 보는가 |
|---|---|
엔진을 바꿀 때 바뀐 것이 .env뿐 인가 | 포트 · 어댑터가 실제로 작동한다는 증거 (1절) |
finish_reason이 전부 "stop"인가 | "length"가 있으면 잘린 결과 (2.3) |
같은 문의의 prompt_tokens가 엔진마다 다른가 | 채팅 템플릿 · 시스템 처리의 차이. "같은 모델"이어도 엔진마다 입력이 조금 다름 |
| 같은 문의의 분류 결과가 엔진마다 다른가 | Ollama 기본 태그는 대개 4bit GGUF, vLLM은 BF16 → 모델 형식이 달라서 일 수 있음. 24차시의 공정 비교 |
base_url 없이 실행했을 때 멈췄는가 | 외부 전송 사고를 막는 장치 (4.1) |
부록 A. 스스로 확인하기
| # | 질문 | 답이 있는 곳 |
|---|---|---|
| 1 | 포트 · 어댑터 · 헥사고날 아키텍처를 유와 종차로 정의하면? 안쪽이 "약속에만 의존한다"는 것은 무슨 뜻인가? | 1.1 |
| 2 | 인바운드와 아웃바운드는 무엇으로 갈리는가? 문의봇에서 각각의 어댑터는? | 1.1 |
| 3 | OpenAI 호환 API를 유와 종차로 정의하면? 왜 포트 노릇을 할 수 있는가? | 1.2 |
| 4 | OCI 표준 · Docker/Podman과 이 차시는 어떤 같은 구조인가? | 1.3 |
| 5 | 배율 1과 배율 2에서 "어댑터"는 각각 무엇인가? 둘 다 맞는 이유는? | 1.4, 1.5 |
| 6 | Ollama의 num_predict는 Chat Completions의 무엇에 해당하는가? | 2.1 |
| 7 | finish_reason: "length"를 받으면 앱은 무엇을 해야 하는가? | 2.3 |
| 8 | Ollama에서 vLLM으로 바꿀 때 바뀌는 값 세 개는? | 3.2 |
| 9 | 의존성 주입(DI)을 유와 종차로 정의하면? 설정을 .env로 빼는 것과 어떤 관계인가? | 3.3 |
| 10 | "OpenAI 호환"을 써도 데이터가 외부로 갈 수 있는 경우와 대책은? | 4.1 |
| 11 | 엔진을 바꾸면 평가셋으로 다시 확인해야 하는 이유를 LSP로 설명하면? | 4.2 |
부록 B. 자주 하는 실수
| 실수 | 바로잡기 |
|---|---|
| "openai 라이브러리 = OpenAI로 전송" | ❌ base_url이 목적지. 다만 비워 두면 기본 주소로 감 ⚠️ |
| 엔진 주소를 코드에 하드코딩 | ❌ 엔진마다 코드 수정. 환경변수로 |
유스케이스(업무 로직) 파일에서 openai를 직접 import | ❌ 안쪽이 바깥 기술을 앎. 유스케이스는 포트(LLMPort)만 받음 (1.1, 1.5) |
.env를 Git에 올림 | ❌ .env.example(키 이름만)을 올림 |
finish_reason을 안 봄 | ⚠️ 잘린 답이 정상처럼 보임 |
| "호환이니까 도구 호출 · JSON 스키마도 똑같이 된다" | ⚠️ 형식만 같음. 지원 범위는 엔진 · 버전마다 확인 |
| 결과가 다르면 "엔진이 이상하다" | ⚠️ 먼저 모델 형식(양자화) 이 같은지 확인 (24차시) |
부록 C. NotebookLM 복습 프롬프트
이 20차시 노트만 근거로 6문제를 내줘. 비전공자 기준으로.
"포트 · 어댑터 · 헥사고날 아키텍처의 정의", "OpenAI 호환 API = 포트, 엔진 = 어댑터의 두 배율", "finish_reason length", "base_url을 빠뜨렸을 때의 사고", "호환은 구문 수준이라는 것"은 반드시 포함해줘.
답은 내가 답한 다음에 채점하고, 틀린 이유를 노트의 절 번호를 근거로 설명해줘.
LangTailor