From 9d12133cfe591073a85036af11d75ae229854d46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=EC=B0=AC?= Date: Sat, 11 Apr 2026 09:28:55 +0900 Subject: [PATCH] =?UTF-8?q?fix(desktop):=20qwen3=20reasoning=20=EB=AA=A8?= =?UTF-8?q?=EB=93=9C=EB=A1=9C=20LLM=20refine=2039=EC=B4=88=20+=20=EB=B9=88?= =?UTF-8?q?=20=EC=9D=91=EB=8B=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 증상: - STT 426ms로 빠른데 LLM refine 단계가 39초 소요 후 빈 문자열 반환 - _completeSession('') → finalText.length === 0이라 paste 호출 자체 건너뜀 - 사용자에게는 '느리고 paste 안 됨'으로 보임 원인: - 사용자가 ollama pull qwen3:4b 한 직후 첫 호출 (모델 cold start 일부 있음) - qwen3는 reasoning model이라 응답에 ... 블록을 길게 출력 - system prompt에 '/no_think' 토큰 없음 → reasoning mode ON - generate()는 data.response.trim() 그대로 반환 → think 블록 + 빈 본문이면 trim 후 빈 문자열 - 빈 문자열에 대한 fallback이 없어서 그대로 _completeSession('') 수정: - SYSTEM_PROMPTS 모두에 '/no_think' 헤더 추가 - qwen3 reasoning 비활성화 → 응답 속도 대폭 단축 - 다른 모델(llama, mistral, gemma)은 토큰 무시 → 호환성 OK - stripReasoningBlocks() 추가 - ... + ... 블록 제거 (gi flag) - /no_think를 무시하는 모델 + 응답에 think tag가 섞여 들어오는 케이스 안전망 - processText() 결과: - stripReasoningBlocks(result.text) - 빈 문자열이면 원본 transcript fallback + warn 로그 --- .../src/main/services/LocalLLMService.ts | 44 ++++++++++++++++--- 1 file changed, 38 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/main/services/LocalLLMService.ts b/apps/desktop/src/main/services/LocalLLMService.ts index 39b3625..7e520a5 100644 --- a/apps/desktop/src/main/services/LocalLLMService.ts +++ b/apps/desktop/src/main/services/LocalLLMService.ts @@ -70,25 +70,47 @@ interface LocalLLMEvents { // 시스템 프롬프트 (설계서 Phase 4 참조) // ============================================================ +// 시스템 프롬프트. +// `/no_think`는 qwen3 계열 reasoning model의 thinking mode를 비활성화하는 토큰. +// 다른 모델에서는 무시되므로 호환성에 문제 없음. +const NO_THINK = '/no_think' + const SYSTEM_PROMPTS: Record = { - refine: `다음 음성 전사 텍스트를 자연스럽고 격식 있는 문어체로 다듬어주세요. + refine: `${NO_THINK} +다음 음성 전사 텍스트를 자연스럽고 격식 있는 문어체로 다듬어주세요. 원래 의미를 유지하면서 문법 오류를 수정하고, 불필요한 반복이나 필러를 제거하세요. 다듬어진 텍스트만 출력하세요. 설명이나 부가 문구를 붙이지 마세요.`, - translate: `다음 텍스트를 {{targetLanguage}}로 번역해주세요. + translate: `${NO_THINK} +다음 텍스트를 {{targetLanguage}}로 번역해주세요. 자연스럽고 정확한 번역만 출력하세요. 원문이나 설명을 붙이지 마세요.`, - summarize: `다음 텍스트의 핵심 내용을 3줄 이내로 요약해주세요. + summarize: `${NO_THINK} +다음 텍스트의 핵심 내용을 3줄 이내로 요약해주세요. 요약문만 출력하세요.`, - grammar: `다음 텍스트의 문법 오류만 수정해주세요. + grammar: `${NO_THINK} +다음 텍스트의 문법 오류만 수정해주세요. 원래 의미와 톤을 유지하면서 문법 오류만 수정하세요. 수정된 텍스트만 출력하세요.`, - expand: `다음 텍스트를 더 자세하고 풍부하게 확장해주세요. + expand: `${NO_THINK} +다음 텍스트를 더 자세하고 풍부하게 확장해주세요. 확장된 텍스트만 출력하세요.` } +/** + * Reasoning model(qwen3, deepseek-r1 등)이 응답에 포함하는 + * ... 블록을 제거한다. /no_think 토큰을 무시하는 + * 모델에서도 안전하게 동작하도록. + */ +function stripReasoningBlocks(text: string): string { + return text + .replace(/[\s\S]*?<\/think>\s*/gi, '') + .replace(/[\s\S]*?<\/thinking>\s*/gi, '') + .trim() +} + // ============================================================ // LocalLLMService // ============================================================ @@ -437,7 +459,17 @@ class LocalLLMService extends EventEmitter { } const result = await this.generate(text, { systemPrompt }) - return result.text.trim() + const cleaned = stripReasoningBlocks(result.text) + // reasoning 블록 제거 후 빈 응답이면 원본 텍스트 폴백 + // (모델이 thinking만 하고 출력은 안 한 경우 / 응답 파싱 실패 케이스) + if (cleaned.length === 0) { + logger.warn( + `LLM returned empty after reasoning strip — falling back to original transcript ` + + `(raw length=${result.text.length})` + ) + return text + } + return cleaned } cancelGeneration(): void {