翻译单引擎化(用户拍板):E2B 唯一引擎,两级架构下线——translateOne1B+translateFallback 合并为 translateOne(E2B+温度递进重试+全质检+逐字止损),ensureTranslateEngine 改配 E2B(ctx8192/录音4线程/空闲6),tap 模型检查与 toast 改指 E2B;gemma-3-1b 两常量删除;LlmEngine gemma3 模板分支退役(仅剩 gemma4);净+24/-96

This commit is contained in:
liwenhao2 2026-09-16 14:07:39 +08:00
parent a39a08ae1c
commit fe7c696589
3 changed files with 27 additions and 111 deletions

View File

@ -17,7 +17,7 @@
## 1. 聊天模板(以代码为准)
模板由 `LlmEngine.renderChatPrompt` 按模型家族渲染:gemma4 / gemma3(2026-09-16 起 MiniCPM 分支已摘除)。PC 调试直接加 --jinja 用内置模板即可。
模板由 `LlmEngine.renderChatPrompt` 渲染(gemma4;2026-09-16 起 gemma3/MiniCPM 分支已随各自引擎退役)。PC 调试直接加 --jinja 用内置模板即可。
---

View File

@ -46,7 +46,7 @@ class LlmEngine(
nThreads: Int = 6, // 8 Gen3 大核 4 个,给摘要留余量:6 线程够用
kvTypeK: String = "q8_0", // KV 用 q8_0 压显存/内存(§3)
kvTypeV: String = "f16", // V 保持 f16:K 量化到 q8_0 是 llama.cpp 推荐档
chatTemplate: String = "gemma4", // "gemma4" | "gemma3",须与 GGUF 内嵌模板一致
chatTemplate: String = "gemma4", // 现仅 "gemma4";须与 GGUF 内嵌模板一致
) {
companion object {
private const val TAG = "sherpa-onnx"
@ -62,18 +62,7 @@ class LlmEngine(
append("<|turn>model\n")
}
// ---- Gemma 3 官方模板的单轮渲染(ggml-org/gemma-3-1b-it-GGUF)----
// 官方 jinja:system 不作为独立 turn,而是拼进第一个 user turn;
// <start_of_turn>user\n{system}\n\n{user}<end_of_turn>\n<start_of_turn>model\n
// EOG token 是 <end_of_turn>(gemma3 的 eot),依赖 native 按 eog 停止。
fun renderGemma3Prompt(system: String, user: String): String =
buildString {
append("<start_of_turn>user\n")
append(system.trim()).append("\n\n")
append(user.trim())
append("<end_of_turn>\n")
append("<start_of_turn>model\n")
}
// Gemma 3 模板分支 2026-09-16 随翻译单引擎化退役(历史见 git history)
// MiniCPM5 模板分支 2026-09-16 随引擎退役摘除(三连空模板回显,
// 用户拍板弃用;历史实现见 git history)
@ -81,7 +70,6 @@ class LlmEngine(
fun renderChatPrompt(system: String, user: String,
template: String = "gemma4"): String =
when (template) {
"gemma3" -> renderGemma3Prompt(system, user)
else -> renderGemma4Prompt(system, user)
}
}

View File

@ -323,12 +323,8 @@ class MainActivity : AppCompatActivity() {
@Volatile private var summarizeEngine: LlmEngine? = null
// ---- 点击字幕句翻译(原"批次二自动双语",2026-09-15 改造)-----------------
// 独立翻译模型:Gemma-3-1B-it Q4_K_M(arch gemma3,模板 renderGemma3Prompt)。
// 不再自动翻译:点击某句 → 带同轮上下文译该句,译文行内小字斜体挂在句下,
// 再点收起(缓存于 Sentence.trans,原文变化时失效)。首次点击懒加载引擎,
// 每次翻译按防污染协议重建引擎;闲置 60s 自动卸载。模型文件缺失 → toast 提示。
private val translateModelDirName = "gemma-3-1b-it"
private val translateGgufName = "gemma-3-1b-it-Q4_K_M.gguf"
// 翻译引擎 = 纪要引擎 E2B(2026-09-16 单引擎化;原 gemma-3-1b-it
// 独立模型退役,历史见 git history)
private val translateIdleUnloadMs = 60_000L
private val translateThread = HandlerThread("translate").also { it.start() }
private val translateHandler by lazy { Handler(translateThread.looper) }
@ -2125,11 +2121,11 @@ class MainActivity : AppCompatActivity() {
Toast.LENGTH_SHORT).show()
return
}
val gguf = File(File(externalModelDir, translateModelDirName),
translateGgufName)
val gguf = File(File(externalModelDir, summarizeModelDirName),
summarizeGgufName)
if (!gguf.isFile) {
Toast.makeText(this, getString(R.string.trans_no_model,
"adb push $translateGgufName ${gguf.parent}/"),
"adb push $summarizeGgufName ${gguf.parent}/"),
Toast.LENGTH_LONG).show()
return
}
@ -2268,26 +2264,20 @@ class MainActivity : AppCompatActivity() {
return s.ifBlank { null }
}
// 点击翻译总入口:1B 主链失败 → 升档兜底(摘要引擎,多语言能力强得多)。
// 歌词体/文言中文("最贪富贵的潦倒一生…")超 1B 边界,温度重试无用,
// 唯一解是换更大的模型再试一次。
// 点击翻译总入口:单引擎 E2B(2026-09-16 用户拍板,两级架构下线——
// 1B 主链失败率过高,"先小后大"平均耗时反而不如直接上 E2B;日语/中文/
// 文学体全面更强)。温度递进 0→0.3→0.7;各闸门拒收即重试;被拒样本
// 逐字相同立即止损(换温度无用)。
private fun translateOne(sid: Long, src: String): String? {
// 时间预算:录音中 45s、空闲 95s。超预算宁可失败(toast 可重试),
// 也不让重试链长时间抢 ASR 的核(饥饿会引发第一遍字幕复读)。
val deadline = SystemClock.elapsedRealtime() +
if (isRecording) 45_000L else 95_000L
translateOne1B(sid, src, deadline)?.let { return it }
return translateFallback(sid, src, deadline)
}
private fun translateOne1B(
sid: Long, src: String, deadline: Long,
): String? {
// 重试预算:E2B 单次尝试约 10~15s;录音中 1 次(两次≈30s 封顶),
// 空闲 2 次
val maxGenRetry = if (isRecording) 1 else 2
var temp = 0f
var genRetry = 0
// 重试预算:录音中只允许 1 次(两次尝试≈30s),给兜底留出时间窗——
// 现场:三次尝试 15s×3 吃满 45s 预算,兜底被 skipped=budget 拒之门外
val maxGenRetry = if (isRecording) 1 else 2
var lastRej = ""
while (true) {
if (SystemClock.elapsedRealtime() > deadline) {
@ -2299,10 +2289,8 @@ class MainActivity : AppCompatActivity() {
val raw = map?.get(sid.toString())?.trim()
val srcTag = " src=" + SummarizePipeline.detectLanguage(src) +
":" + src.take(20).replace("\n", " ")
// 译文内部复读治理(2026-09-15):1B 批译长句会把某个译文段复制
// 多份(拼回后"大量重复某一句")。先折叠;折叠即腰斩 → 判退化。
// 但先对照原文:ASR 自己复读的句子("数据显示,数据显示,数据显示"),
// 译文忠实复现不算退化 → 原文也有同等复读时放行原文不折叠。
// 译文内部复读治理:先折叠;折叠即腰斩 → 判退化。原文自复读
// ("数据显示,数据显示,…")时忠实复现放行原文不折叠
val tr = when {
raw == null -> null
else -> {
@ -2336,81 +2324,20 @@ class MainActivity : AppCompatActivity() {
val sample = tr.replace("\n", " ")
Log.i(TAG, "TAP-MISS sid=$sid reason=langdrift temp=$temp" +
" sample=" + sample.take(24) +
// 早前版本只打前 24 字,曾误导"译文看起来没问题为何拒"
// ——判错常在后半段(尾巴滑回源语言),尾 16 字必打
// 尾 16 字必打:判错常在后半段(尾巴滑回源语言)
" tail=" + sample.takeLast(16) +
" len=" + sample.length + srcTag)
if (sample == lastRej) return null
lastRej = sample
// 漂移一次即弃治疗直接升档:1B 对中文口语/文学体的
// zh→ja 是系统性弱项(繁体+脑补续写),温度递进只是
// 在同一口浅井里打水,不如省 20s 交给兜底引擎
return null
if (genRetry >= maxGenRetry) return null
genRetry++; temp = if (temp == 0f) 0.3f else 0.7f
}
else -> return tr
}
}
}
// 升档兜底:1B 全链失败后,用 E2B 引擎(已随纪要常驻外部
// 目录)低温再译一次。临时引擎用完即 release(KV 污染协议);预算将尽
// (剩不到 8s)不启用。产物过同样的 echo/drift 质检,宁缺毋滥。
private fun translateFallback(
sid: Long, src: String, deadline: Long,
): String? {
if (SystemClock.elapsedRealtime() > deadline - 8_000) {
Log.i(TAG, "TAP-FALLBACK sid=$sid skipped=budget")
return null
}
val gguf = File(File(externalModelDir, summarizeModelDirName),
summarizeGgufName)
if (!gguf.isFile) {
Log.i(TAG, "TAP-FALLBACK sid=$sid skipped=no-model " +
gguf.absolutePath)
return null
}
// 内存峰值加固(2026-09-16):兜底(Big,1.5~3GB)加载前先释放 1B 引擎
// ——它刚连败无保留价值;此刻释放把瞬时峰值直接降 ~0.8GB。
// 本函数跑在翻译线程,与 translateNumbered 的引擎生命周期同线程,安全
translateEngine?.let { runCatching { it.release() } }
translateEngine = null
var e: LlmEngine? = null
return try {
e = LlmEngine(gguf.absolutePath, SUMMARY_CTX,
if (isRecording) SUMMARY_THREADS_BUSY else SUMMARY_THREADS,
chatTemplate = summarizeChatTemplate)
val out = e.generate(
systemPrompt = "你是专业翻译。把给出的句子翻译成" + transTargetName() +
"。只输出译文本身,不要解释、不要引号、不要抄写原文," +
"不要以「翻訳:」等任何标签开头," +
"禁止道歉或说明无法完成,翻不了就输出空字符串。" +
"文学性表达按原文风格意译,保留重复与排比。" +
transLangHint() + "。",
userPrompt = src,
maxNewTokens = (src.length * 2 + 96).coerceAtMost(1200),
temperature = 0.2f, repeatPenalty = 1.1f,
)
val tr = cleanTrans(out, src)
when {
tr.isNullOrBlank() || isEcho(src, tr, echoJaccardTh(src)) ||
!targetLangOk(tr) -> {
Log.i(TAG, "TAP-FALLBACK sid=$sid rejected src=" + src.take(20))
null
}
else -> {
Log.i(TAG, "TAP-FALLBACK sid=$sid applied")
tr
}
}
} catch (t: Throwable) {
Log.w(TAG, "TAP-FALLBACK sid=$sid error: ${t.message}")
null
} finally {
e?.let { runCatching { it.release() } }
}
}
// 翻译目标语名/提示语/上下文块(1B 与兜底两路共用)
// 翻译目标语名/提示语(单引擎共用)
private fun transTargetName(): String = when (uiLanguage()) {
"ja" -> "日本語"; "en" -> "English"; else -> "简体中文"
}
@ -2455,16 +2382,17 @@ class MainActivity : AppCompatActivity() {
}
private fun ensureTranslateEngine(): LlmEngine? {
val f = File(File(externalModelDir, translateModelDirName), translateGgufName)
val f = File(File(externalModelDir, summarizeModelDirName), summarizeGgufName)
if (!f.isFile) {
Log.i(TAG, "translate model missing at ${f.absolutePath}; tap-translate off")
return null
}
return try {
// 录音中降载:1 线程给第一遍/第二遍 ASR 让核(实测:满线程重建风暴
// 会让 nemotron 饥饿→字幕复读某一小句);空闲时才用 2 线程
LlmEngine(f.absolutePath, 4096, if (isRecording) 1 else 2,
chatTemplate = "gemma3")
// 单引擎 E2B(2026-09-16):录音中 4 线程给 ASR 让核(纪要真机
// 实测零丢帧),空闲 6;ctx 与纪要同级 8192
LlmEngine(f.absolutePath, SUMMARY_CTX,
if (isRecording) SUMMARY_THREADS_BUSY else SUMMARY_THREADS,
chatTemplate = summarizeChatTemplate)
.also { translateEngine = it }
} catch (t: Throwable) {
Log.w(TAG, "translate engine init failed: ${t.message}")