OpenAI 호환 하나로 ollama·vLLM·OpenAI 통합 — provider 분기 없는 LLM 레이어
Argus Catalog 에이전트가 base_url + model만 바꿔 어떤 LLM 백엔드든 사용하는 방법을 해부합니다. OpenAI 호환 엔드포인트 통합, generate·chat·chat_stream 세 가지 호출, 그리고 qwen2.5:7b 같은 소형 모델을 안정적으로 다루는 기법까지 정리합니다.
LLM 백엔드는 종류가 많습니다. 로컬엔 ollama, GPU 서버엔 vLLM, 클라우드엔 OpenAI, 게이트웨이엔 LiteLLM. 보통은 이 차이를 흡수하려고 provider별 분기 코드를 짭니다. Argus Catalog Agent는 정반대로 갑니다 — 분기 코드를 0줄로 두고, base_url + model만 바꿔 어떤 백엔드든 그대로 씁니다.
이 글은 아키텍처 편에 이은 시리즈 2편으로, 에이전트의 심장인 LLM 레이어(llm.py)를 다룹니다.
1. 왜 qwen2.5:7b를 기본 모델로?
기본 모델은 ollama 위에 올린 qwen2.5:7b입니다. 거대 모델 대신 7B 로컬 모델을 기본으로 둔 이유는 분명합니다.
- 데이터 주권 — 메타데이터 생성과 PII 감지에는 실제 스키마·샘플 데이터를 모델에 보여줘야 합니다. 로컬 모델이면 이 데이터가 호스트 밖으로 나가지 않습니다.
- 비용 — 카탈로그 전체를 일괄 생성하면 수천 건의 호출이 발생합니다. 로컬 모델은 호출당 비용이 0입니다.
- 충분함 — 메타데이터 작성은 정해진 스키마를 근거로 요약·설명하는 제한된 과제라, 잘 설계된 프롬프트와 함께라면 7B로도 안정적으로 동작합니다.
물론 더 강한 모델이 필요하면 환경변수 한 줄로 바꿉니다. 그게 가능한 건 다음 절의 통합 구조 덕분입니다.
2. OpenAI 호환 하나로 통일
ollama·vLLM·OpenAI·LiteLLM은 모두 POST /chat/completions라는 동일한 OpenAI 호환 엔드포인트를 제공합니다. 그렇다면 provider를 분기할 이유가 없습니다. 클라이언트는 base_url과 model만 알면 됩니다.
class LLMClient:
"""OpenAI 호환 /chat/completions 최소 클라이언트."""
def __init__(self, base_url: str, model: str, api_key: str = "ollama",
temperature: float = 0.3, max_tokens: int = 2048,
timeout: int = 300) -> None:
self.base = base_url.rstrip("/")
self.model = model
...설정은 이게 전부입니다.
# ollama (기본)
AGENT_LLM_URL=http://localhost:11434/v1
AGENT_MODEL=qwen2.5:7b
AGENT_LLM_API_KEY=ollama # ollama 는 임의 문자열이면 됨
# OpenAI 로 바꾸려면
AGENT_LLM_URL=https://api.openai.com/v1
AGENT_MODEL=gpt-4o-mini
AGENT_LLM_API_KEY=sk-...provider 추상화 라이브러리를 끼워 넣는 대신, 이미 표준이 된 OpenAI 와이어 포맷 자체를 추상화 계층으로 삼은 것입니다. 외부 SDK도 필요 없어, 호출은 표준 라이브러리 urllib로 직접 짭니다.
3. 세 가지 호출 메서드
같은 클라이언트가 세 가지 호출 방식을 제공합니다. 실행 모드마다 필요한 모양이 다르기 때문입니다.
| 메서드 | 용도 | 쓰는 곳 |
|---|---|---|
generate() | 단발 프롬프트 → 텍스트 1개 | 배치 메타데이터 생성 |
chat() | 멀티턴 + tool-calling (비스트리밍) | 도구 호출 루프 |
chat_stream() | tool-calling + 토큰 실시간 스트리밍 | 어시스턴트 채팅 |
generate()는 가장 단순합니다 — 시스템 프롬프트와 사용자 프롬프트를 보내고 텍스트와 토큰 수를 돌려받습니다.
def generate(self, prompt: str, system_prompt: str | None = None) -> dict:
"""프롬프트를 보내고 {"text", "prompt_tokens", "completion_tokens"} 를 반환."""
messages = []
if system_prompt:
messages.append({"role": "system", "content": system_prompt})
messages.append({"role": "user", "content": prompt})
...
return {
"text": text.strip(),
"prompt_tokens": usage.get("prompt_tokens"),
"completion_tokens": usage.get("completion_tokens"),
}chat()은 messages 배열과 tools 스키마를 함께 보내, 모델이 도구를 부르려 하면 tool_calls가 담긴 메시지를 돌려줍니다. 호출자(어시스턴트 루프)가 도구를 실행하고 role=tool 메시지를 붙여 다시 호출하는 식으로 대화가 이어집니다. (도구 부분은 3편에서 자세히 다룹니다.)
4. 토큰을 실시간으로 — chat_stream의 SSE 파싱
채팅 어시스턴트는 답변이 다 만들어질 때까지 기다리지 않고 토큰이 도착하는 즉시 화면에 흘려야 합니다. chat_stream()은 stream: True로 요청해 OpenAI 호환 SSE(data: {choices:[{delta:{...}}]})를 직접 파싱합니다.
for raw in resp:
line = raw.decode(errors="replace").strip()
if not line.startswith("data:"):
continue
data = line[len("data:"):].strip()
if data == "[DONE]":
break
chunk = json.loads(data)
delta = (chunk.get("choices") or [{}])[0].get("delta") or {}
if delta.get("content"):
yield {"type": "content", "text": delta["content"]} # 답변 조각 실시간
for tc in delta.get("tool_calls") or []:
... # 도구 호출 인자는 조각으로 나뉘어 와 누적 조립까다로운 부분은 tool_calls가 조각으로 나뉘어 온다는 점입니다. 함수 이름과 인자 JSON 문자열이 여러 청크에 걸쳐 도착하므로, index별로 슬롯을 만들어 이어 붙인 뒤 스트림이 끝나면 비스트리밍 chat()과 동형(同形)의 메시지로 조립합니다. 덕분에 호출부는 스트리밍 여부와 무관하게 같은 도구 실행 로직을 씁니다.
# 스트림 종료 — 비스트리밍 chat() 과 동형의 메시지로 조립
message = {"role": "assistant", "content": "".join(content_parts) or None}
if tool_calls:
message["tool_calls"] = [...]
yield {"type": "final", "message": message, "usage": usage}일부 백엔드(OpenAI)는 stream_options: {include_usage: True}를 줘야 마지막 청크에 토큰 사용량을 실어 줍니다. 이 한 줄로 ollama·vLLM·OpenAI 모두에서 토큰 계측이 일관되게 동작합니다.
5. 소형 모델을 길들이는 기법
7B 모델은 강력하지만 거대 모델만큼 지시를 곧이곧대로 따르지는 않습니다. 에이전트는 몇 가지 방어 장치로 이 간극을 메웁니다.
① JSON 코드펜스 방어적 제거. 소형 모델은 JSON을 요청해도 ```json ... ```으로 감싸는 일이 잦습니다. generate_json()은 이를 벗겨낸 뒤 파싱하고, 실패하면 None을 돌려줘 호출부가 우아하게 처리하게 합니다.
def generate_json(self, prompt, system_prompt=None):
result = self.generate(prompt, system_prompt)
text = result["text"]
if text.startswith("```"):
lines = text.split("\n")
# 첫 줄(```json)과 마지막 펜스 제거
lines = lines[1:-1] if lines[-1].strip().startswith("```") else lines[1:]
text = "\n".join(lines)
try:
return json.loads(text), result
except json.JSONDecodeError:
logger.warning("LLM JSON 응답 파싱 실패 — 원문 일부: %s", text[:200])
return None, result② 보수적인 샘플링. 기본값은 temperature=0.3, max_tokens=2048입니다. 메타데이터는 창의성보다 사실 충실성이 중요해 온도를 낮게 둡니다.
③ 넉넉한 타임아웃. 로컬 7B 모델은 첫 호출에서 모델을 메모리에 올리느라 느릴 수 있어 timeout=300(초)로 잡습니다.
6. 친절한 에러 — 운영자가 바로 고치게
표준 라이브러리로 짠 만큼 에러 메시지도 직접 다듬습니다. LLM 서버에 닿지 못하면, 단순한 스택 트레이스가 아니라 무엇을 확인해야 하는지 알려 줍니다.
except urllib.error.URLError as e:
raise RuntimeError(
f"LLM 서버에 연결할 수 없습니다 ({self.base}): {e.reason} — "
"ollama 가 실행 중인지, --llm-url 이 올바른지 확인하세요."
) from e작은 차이지만, 폐쇄망에서 처음 띄워 보는 운영자에게는 큰 차이입니다.
마치며
LLM 레이어의 설계 원칙은 표준에 올라타기입니다. provider 추상화를 직접 만드는 대신 이미 표준이 된 OpenAI 와이어 포맷을 추상화 계층으로 삼아, 분기 코드 없이 어떤 백엔드든 흡수했습니다. 그 위에 단발·도구 호출·스트리밍 세 가지 모양을 얹고, 소형 모델용 방어 장치로 7B에서도 안정적으로 돌게 만들었습니다.
다음 편에서는 모델이 스스로 카탈로그를 조회하게 만드는 도구(Function Calling) 레이어를 다룹니다 — 읽기 전용 도구 9종, tool-use 루프, 그리고 사용자 권한을 그대로 위임하는 보안 모델을 살펴봅니다.