Blog
llmollamaqwenopenai-apistreaming

OpenAI 호환 하나로 ollama·vLLM·OpenAI 통합 — provider 분기 없는 LLM 레이어

Argus Catalog 에이전트가 base_url + model만 바꿔 어떤 LLM 백엔드든 사용하는 방법을 해부합니다. OpenAI 호환 엔드포인트 통합, generate·chat·chat_stream 세 가지 호출, 그리고 qwen2.5:7b 같은 소형 모델을 안정적으로 다루는 기법까지 정리합니다.

Data Dynamics2026年6月11日10 min read
This post is not yet translated. The original Korean version is shown below.

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_urlmodel만 알면 됩니다.

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 루프, 그리고 사용자 권한을 그대로 위임하는 보안 모델을 살펴봅니다.