From d72a1fec17b4fcfd28eb526927ebea15a29e948c Mon Sep 17 00:00:00 2001 From: link2026 Date: Sun, 31 May 2026 23:22:50 +0800 Subject: [PATCH] =?UTF-8?q?```=20feat(AI):=20=E6=B7=BB=E5=8A=A0MLX?= =?UTF-8?q?=E5=86=85=E5=AD=98=E7=AE=A1=E7=90=86=E5=92=8CAI=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E4=BA=92=E6=96=A5=E5=8D=B8=E8=BD=BD=E6=9C=BA=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为防止应用因内存溢出被系统终止,在项目中添加了MLX框架依赖, 并在应用启动时配置GPU缓存限制,设置256MB缓存上限以避免内存过度使用。 同时实现了LLM和VL模型的互斥卸载机制,确保大模型不会同时常驻内存, 通过在加载一个模型前先卸载另一个模型来控制内存使用,防止jetsam OOM。 chore(project): 配置代码签名授权文件 refactor(localization): 调整本地化字符串并清理冗余条目 修正了提醒任务和建议相关的本地化文本,调整了多个UI字符串, 清理了过时和重复的本地化条目,更新了AI识别相关的新字符串资源。 ``` --- 康康.xcodeproj/project.pbxproj | 2 + 康康/AI/AIRuntime.swift | 39 ++++++++++ 康康/App/KangkangApp.swift | 5 ++ 康康/Localizable.xcstrings | 135 ++++++++++++++++++++++++--------- 康康/康康.entitlements | 14 ++++ 5 files changed, 161 insertions(+), 34 deletions(-) create mode 100644 康康/康康.entitlements diff --git a/康康.xcodeproj/project.pbxproj b/康康.xcodeproj/project.pbxproj index d91af43..1a484b9 100644 --- a/康康.xcodeproj/project.pbxproj +++ b/康康.xcodeproj/project.pbxproj @@ -408,6 +408,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = "康康/康康.entitlements"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = F2C8C774FG; @@ -459,6 +460,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = "康康/康康.entitlements"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = F2C8C774FG; diff --git a/康康/AI/AIRuntime.swift b/康康/AI/AIRuntime.swift index 262f72f..ccb9cc8 100644 --- a/康康/AI/AIRuntime.swift +++ b/康康/AI/AIRuntime.swift @@ -1,4 +1,5 @@ import Foundation +import MLX enum AIRuntimeError: Error, LocalizedError { case notReady @@ -33,6 +34,16 @@ actor AIRuntime { private init() {} + /// App 启动时调用一次:给 MLX 的 GPU 缓冲池设上限,避免 reuse cache 在大模型常驻之上 + /// 继续膨胀、把峰值推过单 App 内存上限。仅真机生效(模拟器走 CPU,且部分 Metal 路径会 abort)。 + /// 与 increased-memory-limit entitlement + LLM/VL 互斥卸载配合,三管齐下防 jetsam OOM。 + nonisolated static func configureMLXMemory() { + #if !targetEnvironment(simulator) + // 256MB cache 上限:够复用、不至于在 3GB 模型之上再囤几百 MB 空闲缓冲。 + MLX.GPU.set(cacheLimit: 256 * 1024 * 1024) + #endif + } + /// 加载模型。首次调用会真正加载,后续幂等。 func prepare() async throws { switch status { @@ -52,6 +63,10 @@ actor AIRuntime { throw AIRuntimeError.notReady } + // OOM 闸门(§3.1):LLM(~1GB)与 VL(~3GB)不可同时常驻,叠加会冲过单 App 内存上限被 jetsam 杀。 + // 加载 LLM 前先卸 VL,释放其 ModelContainer + MLX 显存缓存。 + unloadVL() + status = .loading do { let session = try await LLMSession.load( @@ -120,6 +135,10 @@ actor AIRuntime { throw AIRuntimeError.notReady } + // OOM 闸门(§3.1):加载 VL(~3GB)前先卸 LLM(~1GB),否则两者常驻叠加冲过内存上限被 jetsam 杀 + // —— 这正是「异常项快拍识别时 App 自动退出」的主因。 + unloadLLM() + vlStatus = .loading do { let session = try await VLSession.load( @@ -133,6 +152,26 @@ actor AIRuntime { } } + // MARK: - 卸载(OOM 闸门) + + /// 卸载 LLM,释放 ModelContainer 引用并清 MLX 显存缓存。幂等。 + /// 注:若此刻有 generate() 的流仍在跑,它持有 session 快照,真正释放要等流结束; + /// 但快拍/归档场景下没有并发文本流,卸载即时生效。 + private func unloadLLM() { + guard llmSession != nil else { return } + llmSession = nil + status = .notReady + MLX.GPU.clearCache() + } + + /// 卸载 VL,释放 ModelContainer 引用并清 MLX 显存缓存。幂等。 + private func unloadVL() { + guard vlSession != nil else { return } + vlSession = nil + vlStatus = .notReady + MLX.GPU.clearCache() + } + /// 图像 → JSON 字符串(由 VLPrompts.reportExtraction 引导)。 /// 调用方负责解析 + 失败回退(§3.2)。 /// AIRuntime 是 actor,本调用与 LLM.generate() 自然串行,不会 OOM。 diff --git a/康康/App/KangkangApp.swift b/康康/App/KangkangApp.swift index f3b7cd6..bdde285 100644 --- a/康康/App/KangkangApp.swift +++ b/康康/App/KangkangApp.swift @@ -5,6 +5,11 @@ import SwiftData struct KangkangApp: App { @State private var lang = LanguageManager.shared + init() { + // 启动即给 MLX 显存缓存设上限,配合 entitlement + LLM/VL 互斥卸载防 jetsam OOM。 + AIRuntime.configureMLXMemory() + } + var sharedModelContainer: ModelContainer = { let schema = Schema([ Indicator.self, diff --git a/康康/Localizable.xcstrings b/康康/Localizable.xcstrings index 168d58a..310c6cb 100644 --- a/康康/Localizable.xcstrings +++ b/康康/Localizable.xcstrings @@ -31,9 +31,6 @@ }, "·" : { - }, - "· · ·" : { - }, "· %lld" : { @@ -395,6 +392,28 @@ } } }, + "%lld 个建议" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld suggestions" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld 件の提案" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "제안 %lld 개" + } + } + } + }, "%lld 个提醒任务" : { "localizations" : { "en" : { @@ -439,28 +458,6 @@ } } }, - "%lld 个建议" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld suggestions" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld 件の提案" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "제안 %lld 개" - } - } - } - }, "%lld 个月" : { "localizations" : { "en" : { @@ -886,9 +883,6 @@ }, "+%lld" : { - }, - "< 3.40" : { - }, "⚠️ 通知权限已关闭,去「设置 → 康康 → 通知」打开" : { "localizations" : { @@ -977,9 +971,6 @@ } } } - }, - "3.84" : { - }, "100% 本地推理 · 模型仅需下载一次" : { "localizations" : { @@ -1002,6 +993,9 @@ } } } + }, + "100%% 本地推理 · 已用 %llds" : { + }, "2026 / 05 / 25 · 协和医院体检中心" : { "localizations" : { @@ -1092,6 +1086,7 @@ } }, "AI 已识别到 1 项指标" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -1376,9 +1371,6 @@ }, "kg" : { - }, - "LDL-C" : { - }, "lo" : { @@ -1485,6 +1477,9 @@ } } } + }, + "VL 模型未就绪,手动补充" : { + }, "VL 输出无法解析:%@" : { "localizations" : { @@ -1991,6 +1986,9 @@ } } } + }, + "仅核对用 · 不保存照片" : { + }, "今天" : { "localizations" : { @@ -2235,6 +2233,7 @@ } }, "低密度脂蛋白 3.84 mmol/L ↑" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -3027,6 +3026,7 @@ } }, "全部保存(%lld)" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -3249,6 +3249,7 @@ } }, "再拍一项" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -3803,6 +3804,9 @@ } } } + }, + "去设置" : { + }, "参考" : { "localizations" : { @@ -4243,6 +4247,9 @@ } } } + }, + "图片编码失败,手动补充或重拍" : { + }, "在「+ 新建 → 指标记录 → %@」记录一次" : { "localizations" : { @@ -4889,6 +4896,7 @@ } }, "对准异常的那一行就好 · 不用拍整张" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -5153,6 +5161,9 @@ } } } + }, + "已取消识别,手动补充或重拍" : { + }, "已处理 %.1fs · 比云端快 4.2×" : { "localizations" : { @@ -5353,6 +5364,7 @@ } }, "已裁剪" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -5838,6 +5850,9 @@ } } } + }, + "异常项快拍需要相机。去「设置 → 康康 → 相机」打开后再回来。" : { + }, "强度" : { "localizations" : { @@ -6058,6 +6073,9 @@ } } } + }, + "快超时了,>%llds 会自动转手动录入" : { + }, "性别" : { "localizations" : { @@ -6104,6 +6122,7 @@ } }, "总胆固醇 TC 5.42 mmol/L" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -6410,6 +6429,9 @@ } } } + }, + "把异常项放进框里 · 对准一两行" : { + }, "抑郁/焦虑" : { "localizations" : { @@ -6545,6 +6567,9 @@ } } } + }, + "拍到的局部" : { + }, "拍报告的小贴士" : { "localizations" : { @@ -6567,6 +6592,9 @@ } } } + }, + "拍摄异常项" : { + }, "拍摄报告" : { "localizations" : { @@ -6811,6 +6839,7 @@ } }, "指标名 · 可编辑" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -8402,6 +8431,7 @@ } }, "本次已记录 %lld 项" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -8565,6 +8595,7 @@ } }, "核对后一次保存" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -8585,6 +8616,9 @@ } } } + }, + "核对异常项" : { + }, "核对识别结果" : { "localizations" : { @@ -9028,6 +9062,12 @@ } } } + }, + "没有识别到指标,点「加一项」手动补充,或返回重拍" : { + + }, + "没读出指标,手动补充或重拍" : { + }, "测试 PROMPT" : { "localizations" : { @@ -9272,6 +9312,7 @@ } }, "甘油三酯 TG 1.78 mmol/L" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -9603,6 +9644,9 @@ }, "症状详情" : { + }, + "相机权限未开启" : { + }, "程度" : { @@ -9630,6 +9674,7 @@ } }, "空腹血糖 GLU" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -10032,6 +10077,7 @@ } }, "继续拍下一项" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -10338,6 +10384,9 @@ } } } + }, + "范围" : { + }, "范围 %@ %@" : { "localizations" : { @@ -10725,6 +10774,9 @@ } } } + }, + "识别到的指标 (%lld)" : { + }, "识别失败:%@" : { "localizations" : { @@ -10747,6 +10799,9 @@ } } } + }, + "识别框内指标" : { + }, "识别没有读出指标,请手动补充" : { "localizations" : { @@ -10771,6 +10826,7 @@ } }, "识别用时 0.4s · 本地" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -10857,6 +10913,9 @@ } } } + }, + "识别超时(>%llds),手动补充或重拍" : { + }, "该测%@了" : { "localizations" : { @@ -11013,6 +11072,7 @@ } }, "超过参考上限 0.44,属轻度偏高。建议关注饮食结构(减少动物脂肪摄入),3 个月内复查。若家族有心血管病史,可与医生沟通是否需要药物干预。" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -11035,6 +11095,7 @@ } }, "超过参考上限 0.44,属轻度偏高。点击展开详细解读 ›" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -11233,6 +11294,7 @@ } }, "载脂蛋白 A1 1.42 g/L" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -11277,6 +11339,7 @@ } }, "载脂蛋白 B 1.04 g/L" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -11719,6 +11782,9 @@ } } } + }, + "重拍" : { + }, "重新生成" : { "localizations" : { @@ -12073,6 +12139,7 @@ } }, "高密度脂蛋白 1.21 mmol/L" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { diff --git a/康康/康康.entitlements b/康康/康康.entitlements new file mode 100644 index 0000000..ff111f6 --- /dev/null +++ b/康康/康康.entitlements @@ -0,0 +1,14 @@ + + + + + + com.apple.developer.kernel.increased-memory-limit + + +