feat(AI): 双后端路由 MNN/MLX,AIRuntime 按引擎分发(Phase 3 核心)

- InferenceEngine:引擎枚举(.mnn 默认 / .mlx 兜底)+ UserDefaults 持久化
  + 可用性/SME2 运行时探测(经 MNNLLMBridge)
- MNNBackend:actor 封装 MNNLLMBridge 文本流式生成,detached 线程跑同步
  response、按 UTF-8 边界 yield TokenChunk,串行化交给 AIRuntime 闸门
- AIRuntime:prepare/generate 按引擎分发;.mnn 且模型就绪→MNN,否则回退 MLX
  (过渡期 App 始终可用);prepareVL/单模型常驻时互卸 MNN↔MLX 释放内存
  公有 API 不变,各 Service 零改动

模拟器 BUILD SUCCEEDED,0 error。引擎切换 UI + SME2 指示留待 Phase 5。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
link2026
2026-06-08 18:58:27 +08:00
parent afc6a79dd7
commit f6c0ba7077
3 changed files with 205 additions and 1 deletions

View File

@@ -32,6 +32,15 @@ actor AIRuntime {
private var llmSession: LLMSession?
private var vlSession: VLSession?
// MARK: - MNN (CPU/SME2,)
// .mnn MNN;VL() MLX(MNN VL OMNI )
private let mnn = MNNBackend()
private(set) var mnnStatus: Status = .notReady
/// MNN (/ Models/Qwen3.5-2B-MNN)
nonisolated static var mnnModelFolder: URL {
ModelStore.shared.rootURL.appendingPathComponent("Qwen3.5-2B-MNN", isDirectory: true)
}
// MARK: - (§3.1 OOM )
//
// actor , generate() Task;
@@ -78,8 +87,19 @@ actor AIRuntime {
#endif
}
/// ,
/// ,
/// :.mnn MNN(CPU/SME2);.mlx MLX(GPU)
func prepare() async throws {
// MNN MNN;( MLX, MNN )退 MLX,
// App (Phase 5)
let mnnReady = FileManager.default.fileExists(
atPath: Self.mnnModelFolder.appendingPathComponent("config.json").path)
if InferenceEngine.current == .mnn, mnnReady {
try await prepareMNN()
return
}
// MLX: MNN ()
await unloadMNN()
// ,
// return: ready, generate
// `guard status == .ready` ()
@@ -119,9 +139,52 @@ actor AIRuntime {
}
}
/// MNN : MLX LLM/VL
private func prepareMNN() async throws {
while mnnStatus == .loading {
try await Task.sleep(nanoseconds: 80_000_000)
}
if mnnStatus == .ready { return }
let folder = Self.mnnModelFolder
let config = folder.appendingPathComponent("config.json").path
guard FileManager.default.fileExists(atPath: config) else {
mnnStatus = .error("MNN 模型未就绪")
throw AIRuntimeError.notReady
}
await acquireGate()
defer { releaseGate() }
if mnnStatus == .ready { return }
// : MLX LLM/VL, MNN
unloadLLM()
unloadVL()
mnnStatus = .loading
do {
try await mnn.load(folderURL: folder)
mnnStatus = .ready
} catch {
mnnStatus = .error("\(error)")
throw AIRuntimeError.modelLoadFailed("\(error)")
}
}
/// MNN,
private func unloadMNN() async {
guard mnnStatus != .notReady else { return }
await mnn.unload()
mnnStatus = .notReady
MLX.Memory.clearCache()
}
/// await prepare()
/// :, actor LLMSession await
func generate(prompt: String, maxTokens: Int = 256) -> AsyncThrowingStream<TokenChunk, Error> {
if InferenceEngine.current == .mnn, mnnStatus == .ready {
return mnnGenerate(prompt: prompt, maxTokens: maxTokens)
}
// actor ,Task 访 self.status / self.llmSession
let snapshotStatus = status
let snapshotSession = llmSession
@@ -159,6 +222,33 @@ actor AIRuntime {
}
}
/// MNN(CPU/SME2) MLX :
private func mnnGenerate(prompt: String, maxTokens: Int) -> AsyncThrowingStream<TokenChunk, Error> {
let ready = (mnnStatus == .ready)
return AsyncThrowingStream { continuation in
let task = Task {
guard ready else {
continuation.finish(throwing: AIRuntimeError.notReady)
return
}
await self.acquireGate()
do {
let stream = await self.mnn.generate(prompt: prompt, maxTokens: maxTokens)
for try await chunk in stream {
try Task.checkCancellation()
self.recordRate(chunk.decodeRate)
continuation.yield(chunk)
}
continuation.finish()
} catch {
continuation.finish(throwing: AIRuntimeError.inferenceFailed("\(error)"))
}
self.releaseGate()
}
continuation.onTermination = { _ in task.cancel() }
}
}
private func recordRate(_ rate: Double) {
if rate > 0 { lastDecodeRate = rate }
}
@@ -186,6 +276,7 @@ actor AIRuntime {
// OOM (§3.1): VL(~3GB) LLM(~1GB), jetsam
unloadLLM()
await unloadMNN()
vlStatus = .loading
do {