블로그워드프레스에 AI 고객지원 챗봇 만들기 (4) — Ollama 로컬 AI 연동

워드프레스에 AI 고객지원 챗봇 만들기 (4) — Ollama 로컬 AI 연동

집에 놀고 있는 PC가 있다면 AI 챗봇을 무료로 돌릴 수 있다. OpenAI API 비용이 부담스럽다면 Ollama라는 오픈소스 프로젝트를 살펴보자. 로컬에서 LLM을 실행하는 도구인데, 설치하고 모델 다운로드하면 바로 API 서버가 뜬다. 내가 이걸 선택한 이유는 단순하다. 비용 0원, 개인정보 외부 유출 없음, 인터넷 끊겨도 동작.

wp-config.php로 엔진 선택

챗봇의 AI 엔진은 wp-config.php에서 설정한다. 이 값에 따라 규칙 기반만 돌 수도 있고, Ollama를 쓸 수도 있고, Claude API를 쓸 수도 있다.

// wp-config.php에 추가
define('NALKKUL_CHATBOT_AI', 'ollama');  // 'ollama' 또는 'claude' 또는 주석처리
define('NALKKUL_OLLAMA_URL', 'https://your-tunnel-url.trycloudflare.com');
define('NALKKUL_OLLAMA_MODEL', 'qwen2.5:3b');

PHP에서 이 설정을 읽는 방식:

$ai_engine = defined('NALKKUL_CHATBOT_AI') ? NALKKUL_CHATBOT_AI : '';

if ($ai_engine === 'ollama') {
    $reply = nalkkul_chatbot_call_ollama($messages);
} elseif ($ai_engine === 'claude') {
    $reply = nalkkul_chatbot_call_claude($messages);
} else {
    wp_send_json_error(['message' => 'AI 엔진이 설정되지 않았습니다.']);
    return;
}

NALKKUL_CHATBOT_AI를 정의하지 않으면 AI가 비활성화된다. 규칙 기반과 검색만 동작한다. 이렇게 하면 서버 비용 걱정 없이 챗봇을 운영할 수 있다. AI는 나중에 여유 있을 때 켜면 된다.

이 설정값은 프론트엔드 JS에도 전달된다:

$ai_engine = defined('NALKKUL_CHATBOT_AI') ? NALKKUL_CHATBOT_AI : '';
// ... HTML 출력 중 ...
var AI_ENGINE = ;

JS에서 AI_ENGINE이 빈 문자열이면 규칙 기반 매칭 실패 시 폴백 버튼을 보여준다. truthy 값이면 서버로 AJAX 요청을 보낸다.

모델 선택 — 삽질의 기록

Ollama에서 쓸 수 있는 모델이 수십 개다. 처음에 gemma3:1b를 골랐다. Google이 만든 1B 파라미터 모델이니 가볍고 빠를 거라고 생각했다.

결론부터 말하면 gemma3:1b는 오히려 느렸다. 왜? 응답을 너무 길게 생성했다. 짧게 답변하라고 프롬프트에 써도 무시하고 장문을 쏟아냈다. 토큰을 많이 생성하면 시간도 오래 걸린다. 작은 모델 = 빠른 모델이 아니다.

최종 선택은 qwen2.5:3b. Alibaba의 Qwen 2.5 시리즈인데, 한국어를 꽤 잘 이해하고 응답도 간결하다. 14b 모델은 품질이 더 좋지만 집 PC 사양에서 응답이 10초를 넘겨서 3b로 타협했다.

모델크기평균 응답 시간한국어 품질비고
gemma3:1b1B8~15초보통응답이 너무 길음
qwen2.5:3b3B3~8초좋음현재 사용 중
qwen2.5:14b14B10~25초매우 좋음PC 사양 필요
llama3.2:3b3B3~7초보통한국어 약함

Cloudflare Tunnel — 집 PC를 서버로

웹서버는 클라우드에 있고, Ollama는 집 PC에서 돌린다. 두 환경을 연결해야 한다. Cloudflare Tunnel의 Quick Tunnel을 사용하면 도메인 등록 없이 바로 터널을 뚫을 수 있다.

# 집 PC에서 실행
cloudflared tunnel --url http://localhost:11434

이렇게 하면 https://xxx-random-words.trycloudflare.com 같은 URL이 생긴다. 이 URL을 wp-config.php의 NALKKUL_OLLAMA_URL에 넣으면 된다.

단점이 하나 있다. Quick Tunnel의 URL은 터널을 재시작할 때마다 바뀐다. PC를 재부팅하면 URL이 달라진다. 그래서 wp-config.php에서 관리하는 것이다. URL이 바뀌면 wp-config.php만 수정하면 된다. 코드는 건드릴 필요 없다.

영구 터널을 사용하면 URL이 고정되지만, 무료 도메인을 Cloudflare에 등록해야 한다. 설정도 더 복잡하다. Quick Tunnel로 충분한 상황이라 그냥 이걸 쓰고 있다.

Ollama API 호출 구현

WordPress의 wp_remote_post()로 Ollama API를 호출한다. Ollama의 채팅 API 엔드포인트는 /api/chat이다.

function nalkkul_chatbot_call_ollama($messages) {
    $url = defined('NALKKUL_OLLAMA_URL') ? NALKKUL_OLLAMA_URL : '';
    $model = defined('NALKKUL_OLLAMA_MODEL') ? NALKKUL_OLLAMA_MODEL : 'qwen2.5:14b';

    if (empty($url)) {
        return new WP_Error('no_url', 'Ollama URL이 설정되지 않았습니다.');
    }

    $response = wp_remote_post($url . '/api/chat', [
        'timeout' => 120,
        'headers' => ['Content-Type' => 'application/json'],
        'body' => json_encode([
            'model' => $model,
            'messages' => $messages,
            'stream' => false,
            'options' => ['num_ctx' => 2048, 'num_predict' => 512],
        ]),
    ]);

    if (is_wp_error($response)) {
        return new WP_Error('api_fail', 'AI 서버에 연결할 수 없습니다. 잠시 후 다시 시도해주세요.');
    }

    $code = wp_remote_retrieve_response_code($response);
    if ($code !== 200) {
        return new WP_Error('api_fail', 'AI 서버 오류 (HTTP ' . $code . ')');
    }

    $body = json_decode(wp_remote_retrieve_body($response), true);
    return $body['message']['content'] ?? 'AI 응답을 생성하지 못했습니다.';
}

핵심 파라미터들을 하나씩 살펴보면:

  • timeout: 120 — WordPress의 기본 HTTP 타임아웃은 5초다. LLM이 3~15초 걸리니 120초로 넉넉하게 설정. 처음에 기본값 그대로 뒀다가 모든 AI 응답이 타임아웃 에러를 뱉었다.
  • stream: false — true로 하면 토큰이 생성될 때마다 스트리밍되지만, wp_remote_post는 스트리밍을 지원하지 않는다. 전체 응답이 완성될 때까지 기다린다.
  • num_ctx: 2048 — 컨텍스트 윈도우 크기. 기본값(4096)보다 줄여서 메모리 사용량과 처리 시간을 단축.
  • num_predict: 512 — 최대 생성 토큰 수. 챗봇 답변은 길 필요가 없으니 512로 제한. gemma3:1b가 느렸던 이유가 이 값을 제한하지 않았기 때문이다.

시스템 프롬프트 설계

AI에게 역할과 제약을 알려주는 시스템 프롬프트. 짧을수록 좋다. 3b 모델은 긴 프롬프트를 제대로 따르지 못한다.

$system_prompt = "당신은 '무엇이든알아보자' 사이트(nalkkul.com)의 AI 도우미입니다. "
    . "한국어로 간결하게 답변. "
    . "모르는 건 '정확한 정보를 위해 검색엔진을 이용해주세요'라고 안내.";
$system_prompt .= "\n사이트맵: /sitemap-page/ , 문의: [email protected]";

“모르는 건 검색엔진을 이용하라”는 지침이 중요하다. 3b 모델은 확신 없는 정보도 자신있게 생성한다. “클라우드플레어가 뭐야?”라고 물으면 전혀 엉뚱한 답을 지어낸다. 차라리 모른다고 하는 게 낫다. 이 한 줄로 환각(hallucination) 문제를 많이 줄였다.

대화 히스토리 관리

챗봇이 맥락을 유지하려면 이전 대화를 AI에게 보내야 한다. 프론트엔드에서 대화 히스토리를 관리하고, AJAX 요청에 포함한다.

var aiHistory = []; // 대화 히스토리

function callAI(text) {
    var typing = showTyping();
    var startTime = Date.now();

    // 히스토리에 추가
    aiHistory.push({role: 'user', content: text});

    var fd = new FormData();
    fd.append('action', 'nalkkul_chatbot_ai');
    fd.append('nonce', NONCE);
    fd.append('message', text);
    fd.append('history', JSON.stringify(aiHistory.slice(-6)));  // 최근 6개만

    // ...
}

서버에서는 히스토리를 검증하고 시스템 프롬프트와 결합한다:

// Conversation history from POST
$history = [];
if (!empty($_POST['history'])) {
    $raw_history = json_decode(stripslashes($_POST['history']), true);
    if (is_array($raw_history)) {
        // Keep last 6 messages for context
        $raw_history = array_slice($raw_history, -6);
        foreach ($raw_history as $h) {
            $role = ($h['role'] ?? '') === 'user' ? 'user' : 'assistant';
            $content = sanitize_textarea_field($h['content'] ?? '');
            if (!empty($content)) {
                $history[] = ['role' => $role, 'content' => $content];
            }
        }
    }
}

$messages = [['role' => 'system', 'content' => $system_prompt]];
$messages = array_merge($messages, $history);
$messages[] = ['role' => 'user', 'content' => $user_msg];

히스토리를 6개로 제한하는 이유: 3b 모델의 컨텍스트 윈도우가 작다. 대화가 길어지면 초반 맥락을 잃어버린다. 6개면 3턴(사용자 3 + 봇 3)의 맥락을 유지할 수 있다. 보안도 신경 써야 한다. role 값은 ‘user’ 또는 ‘assistant’만 허용하고, content는 sanitize_textarea_field()로 정화한다.

Rate Limiting — 남용 방지

AI API 호출은 리소스를 소비한다. 로컬 Ollama라도 CPU/GPU를 점유하니까 무제한으로 허용할 수 없다. IP당 시간당 20회로 제한했다.

// Rate limiting: 20 AI requests per IP per hour
$ip = sanitize_text_field($_SERVER['REMOTE_ADDR'] ?? '0.0.0.0');
$rl_key = 'nalkkul_chatai_rl_' . md5($ip);
$rl = get_transient($rl_key);
if ($rl && $rl >= 20) {
    wp_send_json_error(['message' => 'AI 응답 한도를 초과했습니다. 잠시 후 다시 시도해주세요.']);
}
set_transient($rl_key, ($rl ? $rl + 1 : 1), HOUR_IN_SECONDS);

WordPress의 Transient API를 활용했다. set_transient으로 IP별 카운터를 만들고 1시간 후 자동 만료. Redis 같은 외부 스토리지 없이도 간단하게 rate limiting을 구현할 수 있다.

Claude API 연동 — 대안 엔진

Ollama가 안 될 때(집 PC 꺼짐, 터널 끊김)를 대비해서 Claude API도 구현해 뒀다. wp-config.php에서 'claude'로 바꾸면 전환된다.

function nalkkul_chatbot_call_claude($messages) {
    $api_key = defined('NALKKUL_CLAUDE_API_KEY') ? NALKKUL_CLAUDE_API_KEY : '';

    if (empty($api_key)) {
        return new WP_Error('no_key', 'Claude API 키가 설정되지 않았습니다.');
    }

    // Extract system message
    $system = '';
    $chat_messages = [];
    foreach ($messages as $msg) {
        if ($msg['role'] === 'system') {
            $system = $msg['content'];
        } else {
            $chat_messages[] = $msg;
        }
    }

    $response = wp_remote_post('https://api.anthropic.com/v1/messages', [
        'timeout' => 60,
        'headers' => [
            'Content-Type' => 'application/json',
            'x-api-key' => $api_key,
            'anthropic-version' => '2023-06-01',
        ],
        'body' => json_encode([
            'model' => 'claude-haiku-4-5-20251001',
            'max_tokens' => 500,
            'system' => $system,
            'messages' => $chat_messages,
        ]),
    ]);

    if (is_wp_error($response)) {
        return new WP_Error('api_fail', 'Claude API 연결 실패');
    }

    $body = json_decode(wp_remote_retrieve_body($response), true);
    return $body['content'][0]['text'] ?? 'AI 응답을 생성하지 못했습니다.';
}

Claude API는 Ollama와 메시지 형식이 다르다. system 메시지를 별도 필드로 분리해야 한다. Ollama는 messages 배열에 system role을 넣지만, Claude는 top-level system 파라미터를 사용한다. 그래서 messages에서 system을 빼고 따로 넣는 코드가 있다.

모델은 claude-haiku-4-5-20251001. Haiku는 Claude 모델 중 가장 저렴하고 빠르다. 챗봇 용도에는 이걸로 충분하다. max_tokens도 500으로 Ollama와 맞췄다.

응답 시간 표시

AI 응답 아래에 소요 시간을 표시한다. 사용자가 “왜 이렇게 느리지?” 하고 불안해하는 걸 줄여준다. 기다리는 이유를 알면 참을 수 있다.

function callAI(text) {
    var startTime = Date.now();
    // ... AJAX 호출 ...
    .then(function(res) {
        removeTyping();
        var elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
        if (res.success && res.data.reply) {
            var reply = res.data.reply + "\n\n⏱️ " + elapsed + "초";
            aiHistory.push({role: 'assistant', content: res.data.reply});
            addMessage(reply, 'bot');
        }
    })
}

AJAX 요청을 보내기 전에 Date.now()를 찍고, 응답이 오면 차이를 계산한다. 네트워크 왕복 시간도 포함된 값이라 실제 AI 추론 시간보다 조금 더 길게 나온다.

에러 처리와 타임아웃

AI 서버는 불안정하다. 집 PC가 꺼져 있을 수도 있고, Cloudflare 터널이 끊길 수도 있다. 에러 상황에서 사용자가 막다른 골목에 빠지지 않도록 폴백 버튼을 제공한다.

// 에러 응답 처리
var errMsg = (res.data && res.data.message) ? res.data.message : 'AI 응답을 받지 못했습니다.';
addMessage(errMsg + "\n\n다음 중 선택해보세요:", 'bot', {
    buttons: [
        {text: "🔮 운세", action: "운세"},
        {text: "🧮 계산기", action: "계산기"},
        {text: "📧 문의하기", action: "문의"}
    ]
});

// 네트워크 에러
.catch(function() {
    removeTyping();
    addMessage("네트워크 오류가 발생했습니다. 😅 잠시 후 다시 시도해주세요.", 'bot');
});

프론트엔드에서 120초 타임아웃도 설정해 뒀다:

var controller = new AbortController();
var timeoutId = setTimeout(function() { controller.abort(); }, 120000);

fetch(AJAX_URL, {method: 'POST', body: fd, signal: controller.signal})
.then(function(r) { clearTimeout(timeoutId); return r.json(); })

AbortController로 120초 후 자동 취소. 서버 타임아웃(PHP 120초)과 클라이언트 타임아웃을 모두 설정해서 사용자가 무한 대기하는 상황을 방지한다.

3b 모델의 현실적인 한계

솔직히 말하면 3b 모델의 품질은 GPT-4나 Claude Sonnet과 비교가 안 된다. 실제로 겪은 문제들:

  • “클라우드플레어가 뭐야?” → 전혀 엉뚱한 답변. CDN 서비스라는 걸 모른다.
  • “이 사이트에 게임이 몇 개야?” → 시스템 프롬프트에 정보가 있는데도 엉뚱한 숫자를 말한다.
  • 반복 질문 → 같은 답변을 약간씩 변형해서 반복한다.
  • 한국어 문법 오류 → 가끔 어색한 표현이 나온다.

그래도 “안녕”, “뭐 재미있는 거 있어?”, “이 사이트 좋다” 같은 간단한 대화에는 충분하다. 핵심 기능(서비스 안내, 글 검색)은 규칙 기반과 RAG 검색이 처리하고, AI는 보조 역할만 한다. 이 구조 덕분에 3b 모델로도 쓸만한 챗봇이 된다.


이 시리즈의 다른 글

이 글이 도움이 되셨다면 공유해주세요!

댓글 남기기

무엇이든 물어보세요! 💬