fix(desktop): qwen3 reasoning 모드로 LLM refine 39초 + 빈 응답

증상:
- STT 426ms로 빠른데 LLM refine 단계가 39초 소요 후 빈 문자열 반환
- _completeSession('') → finalText.length === 0이라 paste 호출 자체 건너뜀
- 사용자에게는 '느리고 paste 안 됨'으로 보임

원인:
- 사용자가 ollama pull qwen3:4b 한 직후 첫 호출 (모델 cold start 일부 있음)
- qwen3는 reasoning model이라 응답에 <think>...</think> 블록을 길게 출력
- 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() 추가
  - <think>...</think> + <thinking>...</thinking> 블록 제거 (gi flag)
  - /no_think를 무시하는 모델 + 응답에 think tag가 섞여 들어오는 케이스 안전망
- processText() 결과:
  - stripReasoningBlocks(result.text)
  - 빈 문자열이면 원본 transcript fallback + warn 로그
This commit is contained in:
윤찬 2026-04-11 09:28:55 +09:00
parent a41b8a4c4b
commit 9d12133cfe

View file

@ -70,25 +70,47 @@ interface LocalLLMEvents {
// 시스템 프롬프트 (설계서 Phase 4 참조)
// ============================================================
// 시스템 프롬프트.
// `/no_think`는 qwen3 계열 reasoning model의 thinking mode를 비활성화하는 토큰.
// 다른 모델에서는 무시되므로 호환성에 문제 없음.
const NO_THINK = '/no_think'
const SYSTEM_PROMPTS: Record<string, string> = {
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 )
* <think>...</think> . /no_think
* .
*/
function stripReasoningBlocks(text: string): string {
return text
.replace(/<think>[\s\S]*?<\/think>\s*/gi, '')
.replace(/<thinking>[\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 {