From 24aa58fc4285d6e097b4d1144645876df1126189 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Thu, 30 Apr 2026 02:53:00 +0900 Subject: [PATCH] feat: switch llm to local ollama qwen3 --- .env.example | 7 +-- README.md | 38 +++++++++++--- bun.lock | 3 -- package.json | 1 - src/audio/guild-voice-session.ts | 4 +- src/audio/local-voice-session.ts | 4 +- src/config.ts | 8 +-- src/discord-main.ts | 4 +- src/local-main.ts | 4 +- src/services/llm.ts | 5 ++ src/services/ollama-llm.ts | 85 ++++++++++++++++++++++++++++++++ src/services/openai-llm.ts | 64 ------------------------ 12 files changed, 136 insertions(+), 91 deletions(-) create mode 100644 src/services/llm.ts create mode 100644 src/services/ollama-llm.ts delete mode 100644 src/services/openai-llm.ts diff --git a/.env.example b/.env.example index 257d0c1..92ee550 100644 --- a/.env.example +++ b/.env.example @@ -2,13 +2,14 @@ DISCORD_BOT_TOKEN= DISCORD_APPLICATION_ID= DISCORD_COMMAND_GUILD_ID= -OPENAI_API_KEY= -OPENAI_MODEL=gpt-5.4-mini - ELEVENLABS_API_KEY= ELEVENLABS_VOICE_ID= ELEVENLABS_STT_MODEL=scribe_v2_realtime ELEVENLABS_TTS_MODEL=eleven_flash_v2_5 +OLLAMA_BASE_URL=http://localhost:11434 +OLLAMA_MODEL=qwen3:0.6b +OLLAMA_KEEP_ALIVE=5m +OLLAMA_NUM_CTX=4096 BOT_DEFAULT_LANGUAGE=ko MAX_CONVERSATION_TURNS=12 diff --git a/README.md b/README.md index 2ca0cc1..4a6dbad 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # realtime_voice_bot -디스코드 음성 채널 또는 로컬 PC 마이크/스피커에서 한국어 음성을 인식하고, LLM 응답을 생성한 뒤 ElevenLabs TTS로 다시 읽어주는 최소 프로토타입입니다. +디스코드 음성 채널 또는 로컬 PC 마이크/스피커에서 한국어 음성을 인식하고, 로컬 LLM 응답을 생성한 뒤 ElevenLabs TTS로 다시 읽어주는 최소 프로토타입입니다. ## 현재 구현 범위 @@ -10,7 +10,7 @@ - 48k stereo PCM을 16k mono로 내려서 유저별 VAD 처리 - Silero 계열 VAD(`avr-vad`)로 발화 시작/종료 감지 - ElevenLabs Scribe Realtime WebSocket으로 발화 단위 STT -- OpenAI Responses API로 짧은 한국어 답변 생성 +- Ollama 로컬 LLM으로 짧은 한국어 답변 생성 - ElevenLabs Flash v2.5 스트리밍 TTS - 채널 단위 단일 재생 큐 - 사용자 발화 시작 시 현재 TTS와 대기열 중단(barge-in) @@ -19,9 +19,9 @@ - Bun `1.3+` - Node.js `22.12+` +- Ollama - Discord bot with Voice permissions - ElevenLabs API key + 사용할 Voice ID -- OpenAI API key ## 환경 변수 @@ -29,7 +29,6 @@ 필수: -- `OPENAI_API_KEY` - `ELEVENLABS_API_KEY` - `ELEVENLABS_VOICE_ID` @@ -42,14 +41,21 @@ Discord 모드에서만 필수: - `DISCORD_COMMAND_GUILD_ID` - 테스트 서버에만 slash command를 즉시 반영하려면 설정 +- `OLLAMA_BASE_URL` + - 기본값: `http://localhost:11434` +- `OLLAMA_MODEL` + - 기본값: `qwen3:0.6b` + - 가장 빠른 무료 오픈웨이트 로컬 기본값 +- `OLLAMA_KEEP_ALIVE` + - 기본값: `5m` +- `OLLAMA_NUM_CTX` + - 기본값: `4096` - `LOCAL_AUDIO_SOURCE` - `pw-record --target` 에 넣을 PipeWire source id 또는 node name - `LOCAL_AUDIO_SINK` - `pw-play --target` 에 넣을 PipeWire sink id 또는 node name - `LOCAL_SPEAKER_NAME` - 로컬 테스트에서 프롬프트에 넣을 화자 이름 -- `OPENAI_MODEL` - - 기본값: `gpt-5.4-mini` - `ELEVENLABS_STT_MODEL` - 기본값: `scribe_v2_realtime` - `ELEVENLABS_TTS_MODEL` @@ -63,6 +69,20 @@ Discord 모드에서만 필수: bun install ``` +Ollama 준비: + +```bash +ollama pull qwen3:0.6b +``` + +속도보다 품질이 더 중요하면: + +```bash +ollama pull qwen3:1.7b +# 또는 +ollama pull qwen3:4b +``` + 디스코드 모드: ```bash @@ -98,7 +118,8 @@ bun run check 로컬 테스트: 1. `bun run audio:devices` 로 source/sink id 또는 이름 확인 -2. 필요하면 `.env` 에 `LOCAL_AUDIO_SOURCE`, `LOCAL_AUDIO_SINK` 설정 +2. `ollama pull qwen3:0.6b` +3. 필요하면 `.env` 에 `LOCAL_AUDIO_SOURCE`, `LOCAL_AUDIO_SINK`, `OLLAMA_MODEL` 설정 3. `bun run start:local` 4. 마이크로 바로 말해서 응답 확인 @@ -108,4 +129,5 @@ bun run check - 출력은 길드 세션당 단일 큐 - 로컬 모드는 단일 화자 입력 기준 - 화자 구분은 `speaker_id`, `speaker_name`을 LLM 프롬프트에 항상 포함 -- 최소 프로토타입이므로 Deepgram 대체 STT, 장기 메모리, 고급 명령 라우팅은 아직 포함하지 않았습니다. +- 현재 기본 LLM은 `qwen3:0.6b` 이며 속도 우선 설정이라 답변 품질이 약하면 `qwen3:1.7b` 또는 `qwen3:4b` 로 올리는 것을 권장합니다. +- STT/TTS는 아직 ElevenLabs API를 사용하므로 프로젝트 전체가 완전 무과금은 아닙니다. diff --git a/bun.lock b/bun.lock index 4dfaaa2..aa5c27c 100644 --- a/bun.lock +++ b/bun.lock @@ -10,7 +10,6 @@ "discord.js": "^14.26.3", "dotenv": "^17.4.2", "ffmpeg-static": "^5.3.0", - "openai": "^6.35.0", "opusscript": "^0.1.1", "prism-media": "^1.3.5", "ws": "^8.20.0", @@ -168,8 +167,6 @@ "onnxruntime-node": ["onnxruntime-node@1.24.3", "", { "dependencies": { "adm-zip": "^0.5.16", "global-agent": "^3.0.0", "onnxruntime-common": "1.24.3" }, "os": [ "linux", "win32", "darwin", ] }, "sha512-JH7+czbc8ALA819vlTgcV+Q214/+VjGeBHDjX81+ZCD0PCVCIFGFNtT0V4sXG/1JXypKPgScQcB3ij/hk3YnTg=="], - "openai": ["openai@6.35.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-L/skwIGnt5xQZHb0UfTu9uAUKbis3ehKypOuJKi20QvG7UStV6C8IC3myGYHcdiF4kms/bAvOJ9UqqNWqi8x/Q=="], - "opusscript": ["opusscript@0.1.1", "", {}, "sha512-mL0fZZOUnXdZ78woRXp18lApwpp0lF5tozJOD1Wut0dgrA9WuQTgSels/CSmFleaAZrJi/nci5KOVtbuxeWoQA=="], "parse-cache-control": ["parse-cache-control@1.0.1", "", {}, "sha512-60zvsJReQPX5/QP0Kzfd/VrpjScIQ7SHBW6bFCYfEP+fp0Eppr1SHhIO5nd1PjZtvclzSzES9D/p5nFJurwfWg=="], diff --git a/package.json b/package.json index 723214c..d0e214c 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,6 @@ "discord.js": "^14.26.3", "dotenv": "^17.4.2", "ffmpeg-static": "^5.3.0", - "openai": "^6.35.0", "opusscript": "^0.1.1", "prism-media": "^1.3.5", "ws": "^8.20.0", diff --git a/src/audio/guild-voice-session.ts b/src/audio/guild-voice-session.ts index 5437016..fb93718 100644 --- a/src/audio/guild-voice-session.ts +++ b/src/audio/guild-voice-session.ts @@ -24,7 +24,7 @@ import { float32ToPcm16Buffer, int16ArrayToFloat32, Stereo48kToMono16kDownsample import { ConversationMemory, type UserUtterance } from "../services/conversation.js"; import { ElevenLabsSttService } from "../services/elevenlabs-stt.js"; import { ElevenLabsTtsService, type PreparedSpeechAudio } from "../services/elevenlabs-tts.js"; -import { OpenAiLlmService } from "../services/openai-llm.js"; +import type { LlmService } from "../services/llm.js"; interface GuildVoiceSessionOptions { client: Client; @@ -35,7 +35,7 @@ interface GuildVoiceSessionOptions { textChannelId?: string; stt: ElevenLabsSttService; tts: ElevenLabsTtsService; - llm: OpenAiLlmService; + llm: LlmService; } interface SpeechJob { diff --git a/src/audio/local-voice-session.ts b/src/audio/local-voice-session.ts index e908e27..ddb5dd4 100644 --- a/src/audio/local-voice-session.ts +++ b/src/audio/local-voice-session.ts @@ -14,14 +14,14 @@ import { takeFrame, int16ArrayToFloat32, float32ToPcm16Buffer } from "./pcm.js"; import { ConversationMemory, type UserUtterance } from "../services/conversation.js"; import { ElevenLabsSttService } from "../services/elevenlabs-stt.js"; import { ElevenLabsTtsService, type PreparedSpeechAudio } from "../services/elevenlabs-tts.js"; -import { OpenAiLlmService } from "../services/openai-llm.js"; +import type { LlmService } from "../services/llm.js"; interface LocalVoiceSessionOptions { config: AssistantRuntimeConfig; logger: Logger; stt: ElevenLabsSttService; tts: ElevenLabsTtsService; - llm: OpenAiLlmService; + llm: LlmService; } interface SpeechJob { diff --git a/src/config.ts b/src/config.ts index 98e2299..cd33135 100644 --- a/src/config.ts +++ b/src/config.ts @@ -15,12 +15,14 @@ const envSchema = z.object({ DISCORD_BOT_TOKEN: emptyToUndefined, DISCORD_APPLICATION_ID: emptyToUndefined, DISCORD_COMMAND_GUILD_ID: emptyToUndefined, - OPENAI_API_KEY: emptyToUndefined, - OPENAI_MODEL: z.string().min(1).default("gpt-5.4-mini"), ELEVENLABS_API_KEY: emptyToUndefined, ELEVENLABS_VOICE_ID: emptyToUndefined, ELEVENLABS_STT_MODEL: z.string().min(1).default("scribe_v2_realtime"), ELEVENLABS_TTS_MODEL: z.string().min(1).default("eleven_flash_v2_5"), + OLLAMA_BASE_URL: z.string().min(1).default("http://localhost:11434"), + OLLAMA_MODEL: z.string().min(1).default("qwen3:0.6b"), + OLLAMA_KEEP_ALIVE: z.string().min(1).default("5m"), + OLLAMA_NUM_CTX: z.coerce.number().int().min(512).max(32768).default(4096), BOT_DEFAULT_LANGUAGE: z.string().min(2).default("ko"), MAX_CONVERSATION_TURNS: z.coerce.number().int().min(4).max(30).default(12), LOCAL_AUDIO_SOURCE: emptyToUndefined, @@ -35,7 +37,6 @@ const envSchema = z.object({ export type AppConfig = z.infer; export type AssistantRuntimeConfig = AppConfig & { - OPENAI_API_KEY: string; ELEVENLABS_API_KEY: string; ELEVENLABS_VOICE_ID: string; }; @@ -58,7 +59,6 @@ function requirePresent(value: string | undefined, name: string): string { export function requireAssistantRuntimeConfig(config: AppConfig): AssistantRuntimeConfig { return { ...config, - OPENAI_API_KEY: requirePresent(config.OPENAI_API_KEY, "OPENAI_API_KEY"), ELEVENLABS_API_KEY: requirePresent(config.ELEVENLABS_API_KEY, "ELEVENLABS_API_KEY"), ELEVENLABS_VOICE_ID: requirePresent(config.ELEVENLABS_VOICE_ID, "ELEVENLABS_VOICE_ID"), }; diff --git a/src/discord-main.ts b/src/discord-main.ts index cf8060c..f06045d 100644 --- a/src/discord-main.ts +++ b/src/discord-main.ts @@ -17,7 +17,7 @@ import { type DiscordRuntimeConfig } from "./config.js"; import { Logger } from "./logger.js"; import { ElevenLabsSttService } from "./services/elevenlabs-stt.js"; import { ElevenLabsTtsService } from "./services/elevenlabs-tts.js"; -import { OpenAiLlmService } from "./services/openai-llm.js"; +import { OllamaLlmService } from "./services/ollama-llm.js"; export async function runDiscordBot(config: DiscordRuntimeConfig, logger: Logger): Promise { const commands = [ @@ -39,7 +39,7 @@ export async function runDiscordBot(config: DiscordRuntimeConfig, logger: Logger const stt = new ElevenLabsSttService(config); const tts = new ElevenLabsTtsService(config); - const llm = new OpenAiLlmService(config); + const llm = new OllamaLlmService(config); const sessions = new Map(); function getVoiceChannel(interaction: ChatInputCommandInteraction): VoiceBasedChannel | null { diff --git a/src/local-main.ts b/src/local-main.ts index e28c929..782c1b9 100644 --- a/src/local-main.ts +++ b/src/local-main.ts @@ -8,7 +8,7 @@ import { Logger } from "./logger.js"; import { LocalVoiceSession } from "./audio/local-voice-session.js"; import { ElevenLabsSttService } from "./services/elevenlabs-stt.js"; import { ElevenLabsTtsService } from "./services/elevenlabs-tts.js"; -import { OpenAiLlmService } from "./services/openai-llm.js"; +import { OllamaLlmService } from "./services/ollama-llm.js"; function resolveFfmpegPath(): string { const ffmpegPath = ffmpegStatic as unknown as string | null; @@ -74,7 +74,7 @@ export async function printLocalAudioDevices(): Promise { export async function runLocalAssistant(config: AssistantRuntimeConfig, logger: Logger): Promise { const stt = new ElevenLabsSttService(config); const tts = new ElevenLabsTtsService(config); - const llm = new OpenAiLlmService(config); + const llm = new OllamaLlmService(config); const session = new LocalVoiceSession({ config, logger, diff --git a/src/services/llm.ts b/src/services/llm.ts new file mode 100644 index 0000000..5c01e73 --- /dev/null +++ b/src/services/llm.ts @@ -0,0 +1,5 @@ +import type { ConversationMemory, UserUtterance } from "./conversation.js"; + +export interface LlmService { + generateReply(memory: ConversationMemory, utterance: UserUtterance): Promise; +} diff --git a/src/services/ollama-llm.ts b/src/services/ollama-llm.ts new file mode 100644 index 0000000..58992d6 --- /dev/null +++ b/src/services/ollama-llm.ts @@ -0,0 +1,85 @@ +import type { AssistantRuntimeConfig } from "../config.js"; +import type { ConversationMemory, UserUtterance } from "./conversation.js"; +import type { LlmService } from "./llm.js"; + +const ASSISTANT_INSTRUCTIONS = [ + "너는 디스코드 음성 채널 또는 로컬 마이크 테스트에서 동작하는 한국어 음성 비서다.", + "답변은 짧고 실용적으로 한다.", + "기본은 한 문장, 길어도 두 문장을 넘기지 않는다.", + "말투는 자연스러운 한국어로 유지한다.", + "speaker_id와 speaker_name은 화자 구분용이므로 필요할 때만 자연스럽게 반영한다.", + "잘 못 들었거나 의미가 불명확하면 짧게 다시 물어본다.", + "목록, 마크다운, 코드블록은 쓰지 않는다.", + "생각 과정을 드러내지 말고 최종 답변만 말한다.", +].join(" "); + +interface OllamaChatResponse { + message?: { + content?: string; + thinking?: string; + }; + error?: string; +} + +function normalizeReply(text: string): string { + const strippedThink = text.replace(/[\s\S]*?<\/think>/gi, " "); + const compact = strippedThink.replace(/\s+/g, " ").trim(); + if (compact.length <= 180) { + return compact; + } + + const sentences = compact.match(/[^.!?]+[.!?]?/g); + if (!sentences || sentences.length === 0) { + return compact.slice(0, 180).trim(); + } + + return sentences.slice(0, 2).join(" ").trim().slice(0, 180).trim(); +} + +export class OllamaLlmService implements LlmService { + constructor(private readonly config: AssistantRuntimeConfig) {} + + async generateReply(memory: ConversationMemory, utterance: UserUtterance): Promise { + const url = new URL("/api/chat", this.config.OLLAMA_BASE_URL); + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model: this.config.OLLAMA_MODEL, + messages: [ + { + role: "system", + content: ASSISTANT_INSTRUCTIONS, + }, + { + role: "user", + content: memory.buildPrompt(utterance), + }, + ], + think: false, + stream: false, + keep_alive: this.config.OLLAMA_KEEP_ALIVE, + options: { + num_ctx: this.config.OLLAMA_NUM_CTX, + temperature: 0.4, + num_predict: 120, + }, + }), + }); + + const body = (await response.json().catch(() => ({}))) as OllamaChatResponse; + + if (!response.ok) { + throw new Error(body.error ?? `Ollama request failed with status ${response.status}`); + } + + const output = body.message?.content?.trim(); + if (!output) { + return "잘 못 들었습니다. 한 번만 다시 말씀해 주세요."; + } + + return normalizeReply(output); + } +} diff --git a/src/services/openai-llm.ts b/src/services/openai-llm.ts deleted file mode 100644 index d866d5f..0000000 --- a/src/services/openai-llm.ts +++ /dev/null @@ -1,64 +0,0 @@ -import OpenAI from "openai"; - -import type { AssistantRuntimeConfig } from "../config.js"; -import type { ConversationMemory, UserUtterance } from "./conversation.js"; - -const ASSISTANT_INSTRUCTIONS = [ - "너는 디스코드 음성 채널에서 동작하는 한국어 음성 비서다.", - "답변은 짧고 실용적으로 한다.", - "기본은 한 문장, 길어도 두 문장을 넘기지 않는다.", - "말투는 자연스러운 한국어로 유지한다.", - "speaker_id와 speaker_name은 화자 구분용이므로 필요할 때만 자연스럽게 반영한다.", - "잘 못 들었거나 의미가 불명확하면 짧게 다시 물어본다.", - "목록, 마크다운, 코드블록은 쓰지 않는다.", -].join(" "); - -function normalizeReply(text: string): string { - const compact = text.replace(/\s+/g, " ").trim(); - if (compact.length <= 180) { - return compact; - } - - const sentences = compact.match(/[^.!?]+[.!?]?/g); - if (!sentences || sentences.length === 0) { - return compact.slice(0, 180).trim(); - } - - return sentences.slice(0, 2).join(" ").trim().slice(0, 180).trim(); -} - -export class OpenAiLlmService { - private readonly client: OpenAI; - - constructor(private readonly config: AssistantRuntimeConfig) { - this.client = new OpenAI({ - apiKey: this.config.OPENAI_API_KEY, - }); - } - - async generateReply(memory: ConversationMemory, utterance: UserUtterance): Promise { - const response = await this.client.responses.create({ - model: this.config.OPENAI_MODEL, - instructions: ASSISTANT_INSTRUCTIONS, - input: [ - { - role: "user", - content: [ - { - type: "input_text", - text: memory.buildPrompt(utterance), - }, - ], - }, - ], - max_output_tokens: 120, - }); - - const output = response.output_text?.trim(); - if (!output) { - return "잘 못 들었습니다. 한 번만 다시 말씀해 주세요."; - } - - return normalizeReply(output); - } -}