fix(core): 代码审查修复 AI 并发/隐私/解析等多处缺陷
- AIRuntime 加 actor 内串行推理闸门,封死 LLM/VL in-flight 并发解码窄口(jetsam OOM 根因) - prepare 的 .loading 改轮询等待消除假就绪竞态;就绪判据 isReady→isComplete 防半下载崩溃 - applyReanalyzed 重新解读时 unlink 旧 Asset,消除 Vault 孤儿图片(§6 隐私承诺) - parseReportJSON 改 extractBalancedJSON + 裸数组兜底,防 VL 多项输出被静默截断丢指标 - 临时文件改 completeUnlessOpen 修锁屏写失败;parseDate 支持多格式防归档年份错位 - TimelineEntry/DayDetailSheet 修「偏高」文案与血压箭头方向(偏低指标不再显示相反结论) - FileVault.wipe 容错;HealthExportSheet 异常关键词排除否定句;modelTag 取实际枚举值 - 删除 B1-B5 + ArchiveFlow 死代码(含违反 §6 的 AES 加密文案) - 补 3 个回归测试,编译 + 测试全部通过
This commit is contained in:
@@ -32,6 +32,40 @@ actor AIRuntime {
|
||||
private var llmSession: LLMSession?
|
||||
private var vlSession: VLSession?
|
||||
|
||||
// MARK: - 串行推理闸门(§3.1 OOM 防护的真正落地)
|
||||
//
|
||||
// actor 只串行化「方法入口」,但 generate() 同步返回流、真正解码在内部 Task;
|
||||
// analyzeReport 也在 await 期间让出 actor。若不加闸门,LLM 流正在解码时触发 VL,
|
||||
// 两个模型会同时在 GPU 上解码 → 冲过单 App 内存上限被 jetsam 杀
|
||||
//(MEMORY 记录的「in-flight 流并发窄口」)。
|
||||
//
|
||||
// 这里用 actor 内信号量(count = 1):所有「会占显存的重活」(解码 + 模型加载)
|
||||
// 进入前先 await acquireGate(),结束后 releaseGate()。actor 串行执行保证
|
||||
// gateBusy / gateWaiters 的读写天然无并发。
|
||||
private var gateBusy = false
|
||||
private var gateWaiters: [CheckedContinuation<Void, Never>] = []
|
||||
|
||||
private func acquireGate() async {
|
||||
if !gateBusy {
|
||||
gateBusy = true
|
||||
return
|
||||
}
|
||||
await withCheckedContinuation { (cont: CheckedContinuation<Void, Never>) in
|
||||
gateWaiters.append(cont)
|
||||
}
|
||||
// 被 releaseGate 唤醒时即已持有闸门(gateBusy 保持 true)。
|
||||
}
|
||||
|
||||
private func releaseGate() {
|
||||
if gateWaiters.isEmpty {
|
||||
gateBusy = false
|
||||
} else {
|
||||
// 把闸门直接交给队首等待者,gateBusy 维持 true,不留空窗。
|
||||
let next = gateWaiters.removeFirst()
|
||||
next.resume()
|
||||
}
|
||||
}
|
||||
|
||||
private init() {}
|
||||
|
||||
/// App 启动时调用一次:给 MLX 的 GPU 缓冲池设上限,避免 reuse cache 在大模型常驻之上
|
||||
@@ -46,25 +80,30 @@ actor AIRuntime {
|
||||
|
||||
/// 加载模型。首次调用会真正加载,后续幂等。
|
||||
func prepare() async throws {
|
||||
switch status {
|
||||
case .ready:
|
||||
return
|
||||
case .loading:
|
||||
// 已有其他调用方在加载;本次 prepare 直接返回,
|
||||
// 调用方需稍后 await prepare() 再判 status,或自行轮询 / 显示加载 UI。
|
||||
// W3 引入 prepare 队列时优化。
|
||||
return
|
||||
case .error, .notReady:
|
||||
break
|
||||
// 已有其他调用方在加载时,轮询等其结束再判定结果。
|
||||
// 不能像旧实现那样裸 return:那会让调用方误以为已 ready,随后 generate 的
|
||||
// `guard status == .ready` 失败 → 用户撞上「假错误屏」(模型其实正常加载中)。
|
||||
while status == .loading {
|
||||
try await Task.sleep(nanoseconds: 80_000_000)
|
||||
}
|
||||
if status == .ready { return }
|
||||
|
||||
guard ModelStore.shared.isReady(.llm) else {
|
||||
// 用 isComplete(逐文件字节校验)而非 isReady(只看 config.json):config.json 最小最先下完,
|
||||
// 半下载时 isReady 仍 true 会让加载在残缺 safetensors 上崩溃。与 ModelDownloadService 的
|
||||
// 完成判据保持一致(它也用 isComplete)。
|
||||
guard ModelStore.shared.isComplete(for: .llm) else {
|
||||
status = .error("LLM 模型未就绪")
|
||||
throw AIRuntimeError.notReady
|
||||
}
|
||||
|
||||
// 进闸门:等所有在跑的推理(可能是 VL 解码)结束,再卸 VL + 载 LLM,
|
||||
// 避免「VL 解码 + LLM 加载」内存峰值叠加 OOM。
|
||||
await acquireGate()
|
||||
defer { releaseGate() }
|
||||
// 拿到闸门后复查:排队期间可能已被别的调用方加载好,避免重复 load。
|
||||
if status == .ready { return }
|
||||
|
||||
// OOM 闸门(§3.1):LLM(~1GB)与 VL(~3GB)不可同时常驻,叠加会冲过单 App 内存上限被 jetsam 杀。
|
||||
// 加载 LLM 前先卸 VL,释放其 ModelContainer + MLX 显存缓存。
|
||||
unloadVL()
|
||||
|
||||
status = .loading
|
||||
@@ -93,6 +132,8 @@ actor AIRuntime {
|
||||
continuation.finish(throwing: AIRuntimeError.notReady)
|
||||
return
|
||||
}
|
||||
// 进闸门:保证本次 LLM 解码与任何 VL 解码 / 模型加载串行,绝不并发占显存。
|
||||
await self.acquireGate()
|
||||
do {
|
||||
// session.generate 跨 actor 边界,需要 await
|
||||
let stream = await session.generate(prompt: prompt, maxTokens: maxTokens)
|
||||
@@ -109,6 +150,9 @@ actor AIRuntime {
|
||||
} catch {
|
||||
continuation.finish(throwing: AIRuntimeError.inferenceFailed("\(error)"))
|
||||
}
|
||||
// 正常结束 / 异常 / 取消(checkCancellation 抛出后被上面 catch 吞掉)都会走到这,
|
||||
// 闸门一定释放,不会死锁后续推理。
|
||||
self.releaseGate()
|
||||
}
|
||||
// 消费者取消/流终止时取消内部 Task(与 LLMSession / HealthExportService 一致)。
|
||||
continuation.onTermination = { _ in task.cancel() }
|
||||
@@ -123,20 +167,24 @@ actor AIRuntime {
|
||||
|
||||
/// 加载 VL 模型。幂等,首调真正 load。
|
||||
func prepareVL() async throws {
|
||||
switch vlStatus {
|
||||
case .ready, .loading:
|
||||
return
|
||||
case .error, .notReady:
|
||||
break
|
||||
while vlStatus == .loading {
|
||||
try await Task.sleep(nanoseconds: 80_000_000)
|
||||
}
|
||||
if vlStatus == .ready { return }
|
||||
|
||||
guard ModelStore.shared.isReady(.vl) else {
|
||||
// 同 prepare():用 isComplete 排除半下载(避免在残缺权重上崩溃),与下载服务判据一致。
|
||||
guard ModelStore.shared.isComplete(for: .vl) else {
|
||||
vlStatus = .error("VL 模型未就绪")
|
||||
throw AIRuntimeError.notReady
|
||||
}
|
||||
|
||||
// OOM 闸门(§3.1):加载 VL(~3GB)前先卸 LLM(~1GB),否则两者常驻叠加冲过内存上限被 jetsam 杀
|
||||
// —— 这正是「异常项快拍识别时 App 自动退出」的主因。
|
||||
// 进闸门:等所有在跑的推理(可能是 LLM 文本流)结束,再卸 LLM + 载 VL。
|
||||
// —— 这正是「异常项快拍识别时 App 自动退出」的主因防护。
|
||||
await acquireGate()
|
||||
defer { releaseGate() }
|
||||
if vlStatus == .ready { return }
|
||||
|
||||
// OOM 闸门(§3.1):加载 VL(~3GB)前先卸 LLM(~1GB),否则两者常驻叠加冲过内存上限被 jetsam 杀。
|
||||
unloadLLM()
|
||||
|
||||
vlStatus = .loading
|
||||
@@ -155,8 +203,7 @@ actor AIRuntime {
|
||||
// MARK: - 卸载(OOM 闸门)
|
||||
|
||||
/// 卸载 LLM,释放 ModelContainer 引用并清 MLX 显存缓存。幂等。
|
||||
/// 注:若此刻有 generate() 的流仍在跑,它持有 session 快照,真正释放要等流结束;
|
||||
/// 但快拍/归档场景下没有并发文本流,卸载即时生效。
|
||||
/// 注:只在持有推理闸门时调用(prepareVL 内),此刻不会有 LLM 流在解码,卸载即时生效。
|
||||
private func unloadLLM() {
|
||||
guard llmSession != nil else { return }
|
||||
llmSession = nil
|
||||
@@ -174,13 +221,15 @@ actor AIRuntime {
|
||||
|
||||
/// 图像 → JSON 字符串(由 VLPrompts.reportExtraction 引导)。
|
||||
/// 调用方负责解析 + 失败回退(§3.2)。
|
||||
/// AIRuntime 是 actor,本调用与 LLM.generate() 自然串行,不会 OOM。
|
||||
/// 推理闸门保证本调用与 LLM.generate() 的解码串行,不会同时占显存 OOM。
|
||||
func analyzeReport(imageURLs: [URL],
|
||||
prompt: String,
|
||||
maxTokens: Int = 512) async throws -> String {
|
||||
guard vlStatus == .ready, let session = vlSession else {
|
||||
throw AIRuntimeError.notReady
|
||||
}
|
||||
await acquireGate()
|
||||
defer { releaseGate() }
|
||||
do {
|
||||
return try await session.analyze(
|
||||
imageURLs: imageURLs,
|
||||
|
||||
Reference in New Issue
Block a user