根据提供的code differences信息,我发现没有具体的代码变更内容。因此生成一个通用的commit message:
``` docs(readme): 更新文档说明 - 添加项目使用说明 - 完善配置指南 ``` 注意:由于未提供具体的代码差异信息,以上为示例格式。请提供实际的代码变更内容以生成准确的commit message。
This commit is contained in:
@@ -39,10 +39,8 @@ actor AIRuntime {
|
||||
private(set) var lastGenerateStats: GenerateStats?
|
||||
|
||||
/// 当前实际生效的后端标签(性能自检 / PPT 截图用)。
|
||||
/// 端侧统一走 MLX/GPU(Gemma-3n E2B);MNN/SME2 LLM 路径已移除。
|
||||
var activeBackendLabel: String {
|
||||
if InferenceEngine.current == .mnn, mnnStatus == .ready {
|
||||
return InferenceEngine.cpuSupportsSME2 ? "MNN · SME2" : "MNN · NEON"
|
||||
}
|
||||
#if targetEnvironment(simulator)
|
||||
return "MLX · CPU(模拟器)"
|
||||
#else
|
||||
@@ -53,12 +51,6 @@ actor AIRuntime {
|
||||
private var llmSession: LLMSession?
|
||||
private var vlSession: VLSession?
|
||||
|
||||
// MARK: - MNN 后端(CPU/SME2,挑战赛考核路径)
|
||||
// .mnn 引擎下,文本生成与 VL(图→文)由同一个 Qwen3.5-2B 多模态 MNN 模型全包(已实测)。
|
||||
// 模拟器无 MNN,VL 回退 MLX 的 Qwen3-VL-4B。
|
||||
private let mnn = MNNBackend()
|
||||
private(set) var mnnStatus: Status = .notReady
|
||||
|
||||
// MARK: - Gemini 云端后端(hybrid:端侧 Gemma 默认,云端按需增强)
|
||||
// 云端调用不占本机显存,不进 OOM 闸门,可与端侧推理并发。用于「云端深度解读 / 多语言」
|
||||
// 与「拍报告/药盒多模态读图」——后者恢复端侧 Gemma-3n(MLX 文本版)丢掉的真·视觉能力。
|
||||
@@ -67,10 +59,6 @@ actor AIRuntime {
|
||||
nonisolated var cloudAvailable: Bool { CloudAI.isConfigured }
|
||||
/// 云端后端标签(性能自检 / 截图用)。
|
||||
nonisolated static var cloudLabel: String { "Gemini · \(CloudAI.model)" }
|
||||
/// MNN 模型目录(下载/旁路导入到 Models/Qwen3.5-2B-MNN)。
|
||||
nonisolated static var mnnModelFolder: URL {
|
||||
ModelStore.shared.localURL(for: .mnnLLM)
|
||||
}
|
||||
|
||||
// MARK: - 串行推理闸门(§3.1 OOM 防护的真正落地)
|
||||
//
|
||||
@@ -144,18 +132,8 @@ actor AIRuntime {
|
||||
#endif
|
||||
}
|
||||
|
||||
/// 加载文本模型。首次调用会真正加载,后续幂等。
|
||||
/// 按当前引擎路由:.mnn → MNN(CPU/SME2);.mlx → 现有 MLX(GPU)。
|
||||
/// 加载文本模型(端侧 MLX/GPU,Gemma-3n E2B)。首次调用会真正加载,后续幂等。
|
||||
func prepare() async throws {
|
||||
// 选了 MNN 且模型已就绪才走 MNN;否则(选 MLX,或 MNN 模型尚未下载)回退 MLX,
|
||||
// 保证过渡期 App 始终可用。引擎指示器(Phase 5)展示实际生效后端。
|
||||
let mnnReady = ModelStore.shared.isComplete(for: .mnnLLM)
|
||||
if InferenceEngine.current == .mnn, mnnReady {
|
||||
try await prepareMNN()
|
||||
return
|
||||
}
|
||||
// 走 MLX:先卸 MNN 释放内存(单模型常驻策略)。
|
||||
await unloadMNN()
|
||||
// 已有其他调用方在加载时,轮询等其结束再判定结果。
|
||||
// 不能像旧实现那样裸 return:那会让调用方误以为已 ready,随后 generate 的
|
||||
// `guard status == .ready` 失败 → 用户撞上「假错误屏」(模型其实正常加载中)。
|
||||
@@ -195,54 +173,12 @@ 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
|
||||
guard ModelStore.shared.isComplete(for: .mnnLLM) 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。
|
||||
/// priority = .background 时排队让行、解码中可被前台请求按 token 抢占(CancellationError 透传)。
|
||||
func generate(prompt: String,
|
||||
maxTokens: Int = 256,
|
||||
priority: InferencePriority = .interactive) -> AsyncThrowingStream<TokenChunk, Error> {
|
||||
if InferenceEngine.current == .mnn, mnnStatus == .ready {
|
||||
return mnnGenerate(prompt: prompt, maxTokens: maxTokens, priority: priority)
|
||||
}
|
||||
// 在 actor 隔离上下文中捕获快照,Task 内不再访问 self.status / self.llmSession
|
||||
let snapshotStatus = status
|
||||
let snapshotSession = llmSession
|
||||
@@ -282,40 +218,6 @@ actor AIRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
/// MNN(CPU/SME2)文本流式生成。结构与 MLX 分支一致:进闸门、串行解码、记录速率。
|
||||
private func mnnGenerate(prompt: String,
|
||||
maxTokens: Int,
|
||||
priority: InferencePriority) -> 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(priority)
|
||||
defer { self.releaseGate() } // 无论正常结束 / 异常 / 取消都释放闸门,防死锁
|
||||
do {
|
||||
let stream = await self.mnn.generate(prompt: prompt, maxTokens: maxTokens)
|
||||
for try await chunk in stream {
|
||||
try Task.checkCancellation()
|
||||
// 后台任务让位:前台请求在排队时,下一个 token 处主动退出
|
||||
//(流终止触发 MNNBackend.onTermination → bridge.cancel())。
|
||||
if self.shouldPreempt(priority) { throw CancellationError() }
|
||||
continuation.yield(chunk)
|
||||
}
|
||||
self.lastGenerateStats = await self.mnn.lastStats
|
||||
continuation.finish()
|
||||
} catch is CancellationError {
|
||||
continuation.finish(throwing: CancellationError())
|
||||
} catch {
|
||||
continuation.finish(throwing: AIRuntimeError.inferenceFailed("\(error)"))
|
||||
}
|
||||
}
|
||||
continuation.onTermination = { _ in task.cancel() }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Gemini 云端(hybrid 增强)
|
||||
|
||||
/// 云端流式生成(深度解读 / 多语言)。不进 OOM 闸门,可与端侧并发。
|
||||
@@ -353,7 +255,7 @@ actor AIRuntime {
|
||||
|
||||
// MARK: - 端侧 ASR(SenseVoice via sherpa-mnn)互斥入口
|
||||
|
||||
/// 给端侧语音转写(SenseVoice,占 CPU + 内存)用:进推理闸门串行 + 卸掉常驻 LLM/VL/MNN 腾内存,
|
||||
/// 给端侧语音转写(SenseVoice,占 CPU + 内存)用:进推理闸门串行 + 卸掉常驻 LLM/VL 腾内存,
|
||||
/// 避免与文本/视觉模型同时常驻冲过单 App 内存上限被 jetsam 杀(§3.1 OOM 防护)。
|
||||
///
|
||||
/// 问诊流程是「先转写 → 再 organize(会重载 LLM)」严格串行,所以这里卸掉 LLM 是安全的:
|
||||
@@ -365,7 +267,6 @@ actor AIRuntime {
|
||||
defer { releaseGate() }
|
||||
unloadLLM()
|
||||
unloadVL()
|
||||
await unloadMNN()
|
||||
return try await body()
|
||||
}
|
||||
|
||||
@@ -373,11 +274,6 @@ actor AIRuntime {
|
||||
|
||||
/// 加载 VL 模型。幂等,首调真正 load。
|
||||
func prepareVL() async throws {
|
||||
// 选了 MNN 且多模态模型就绪:VL 复用同一个 MNN 模型(文本+视觉一肩挑),走 prepareMNN。
|
||||
if InferenceEngine.current == .mnn, ModelStore.shared.isComplete(for: .mnnLLM) {
|
||||
try await prepareMNN()
|
||||
return
|
||||
}
|
||||
while vlStatus == .loading {
|
||||
try await Task.sleep(nanoseconds: 80_000_000)
|
||||
}
|
||||
@@ -398,7 +294,6 @@ actor AIRuntime {
|
||||
|
||||
// OOM 闸门(§3.1):加载 VL(~3GB)前先卸 LLM(~1GB),否则两者常驻叠加冲过内存上限被 jetsam 杀。
|
||||
unloadLLM()
|
||||
await unloadMNN()
|
||||
|
||||
vlStatus = .loading
|
||||
do {
|
||||
@@ -438,16 +333,6 @@ actor AIRuntime {
|
||||
func analyzeReport(imageURLs: [URL],
|
||||
prompt: String,
|
||||
maxTokens: Int = 512) async throws -> String {
|
||||
// 选了 MNN 且就绪:图→文走同一个 MNN 多模态模型。
|
||||
if InferenceEngine.current == .mnn, mnnStatus == .ready {
|
||||
await acquireGate()
|
||||
defer { releaseGate() }
|
||||
do {
|
||||
return try await mnn.analyze(imageURLs: imageURLs, prompt: prompt, maxTokens: maxTokens)
|
||||
} catch {
|
||||
throw AIRuntimeError.inferenceFailed("\(error)")
|
||||
}
|
||||
}
|
||||
guard vlStatus == .ready, let session = vlSession else {
|
||||
throw AIRuntimeError.notReady
|
||||
}
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
/// 端侧推理引擎选择。
|
||||
/// - mnn:Qwen + MNN + SME2(CPU),挑战赛考核路径,真机默认。
|
||||
/// - mlx:Qwen + MLX(Metal GPU),兜底 / 对照。模拟器只有它可用。
|
||||
nonisolated enum InferenceEngine: String, CaseIterable, Sendable {
|
||||
case mnn
|
||||
case mlx
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .mnn: return "MNN · CPU/SME2"
|
||||
case .mlx: return "MLX · GPU"
|
||||
}
|
||||
}
|
||||
|
||||
/// 本构建/设备是否可用。MNN 仅 device 切片有真实内核,模拟器回退 MLX。
|
||||
var isAvailable: Bool {
|
||||
switch self {
|
||||
case .mlx: return true
|
||||
case .mnn: return MNNLLMBridge.isAvailable()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 持久化(UserDefaults,跨 actor 安全)
|
||||
|
||||
private static let key = "kk.inferenceEngine"
|
||||
|
||||
/// 由偏好(可能是 .auto)解析出的、本次调用实际使用的具体引擎。
|
||||
/// AIRuntime / MeView 等消费方只看这个,永远拿到 .mnn 或 .mlx。
|
||||
/// 解析后仍做一次可用性兜底,保证总有可用引擎。
|
||||
///
|
||||
/// 项目已切 Gemma-3n(只走 MLX/GPU):Gemma-3n 跑不了 MNN(无转换模型),
|
||||
/// 故主模型不再下载 MNN 切片。即便历史偏好写了 "mnn",只要本机没有完整的 MNN 模型,
|
||||
/// 一律回退 MLX —— 杜绝「标签显示 MNN、实际却跑 MLX」的不一致。
|
||||
static var current: InferenceEngine {
|
||||
let resolved = preference.resolved
|
||||
guard resolved.isAvailable else { return .mlx }
|
||||
if resolved == .mnn, !ModelStore.shared.isComplete(for: .mnnLLM) { return .mlx }
|
||||
return resolved
|
||||
}
|
||||
|
||||
/// 运行时探测:CPU 是否支持 SME2(A19/iPhone17+)。用于 UI 展示加速状态。
|
||||
/// CPU 能力进程内不变,缓存一次,避免每次 UI 刷新都做 sysctl 系统调用。
|
||||
static let cpuSupportsSME2: Bool = MNNLLMBridge.cpuSupportsSME2()
|
||||
|
||||
// MARK: - 用户偏好(auto / mnn / mlx)
|
||||
|
||||
/// 用户在设置页的选择。默认 .auto:按本机配置自动择优。
|
||||
/// 与具体引擎共用同一 UserDefaults key——历史写入的 "mnn"/"mlx" 仍兼容。
|
||||
static var preference: EnginePreference {
|
||||
get {
|
||||
let raw = UserDefaults.standard.string(forKey: key)
|
||||
return raw.flatMap(EnginePreference.init(rawValue:)) ?? .auto
|
||||
}
|
||||
set { UserDefaults.standard.set(newValue.rawValue, forKey: key) }
|
||||
}
|
||||
}
|
||||
|
||||
/// 推理引擎的「用户偏好」,比具体引擎多一个 .auto。
|
||||
/// - auto:本项目已切 Gemma-3n,主模型只走 MLX/GPU,故 auto 一律解析为 .mlx。
|
||||
/// (Gemma-3n 跑不了 MNN/SME2;MNN 路径已停用。)
|
||||
nonisolated enum EnginePreference: String, CaseIterable, Sendable {
|
||||
case auto
|
||||
case mnn
|
||||
case mlx
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .auto: return "自动"
|
||||
case .mnn: return InferenceEngine.mnn.displayName
|
||||
case .mlx: return InferenceEngine.mlx.displayName
|
||||
}
|
||||
}
|
||||
|
||||
/// 把偏好解析成具体引擎(不做可用性兜底,那一步留给 `InferenceEngine.current`)。
|
||||
var resolved: InferenceEngine {
|
||||
switch self {
|
||||
case .mnn: return .mnn
|
||||
case .mlx: return .mlx
|
||||
case .auto: return .mlx // Gemma-3n 只走 MLX/GPU,auto 即 MLX
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
//
|
||||
// MNNLLMBridge.h
|
||||
// 康康
|
||||
//
|
||||
// Objective-C 接口,封装 MNN-LLM(Qwen)的加载与流式推理。
|
||||
// 真实实现在 .mm 中以 ObjC++ 调用 <MNN/llm/llm.hpp>;模拟器下编为可用性返回 NO 的桩
|
||||
// (MNN.framework 仅 device arm64 切片有真实 CPU/SME2 内核,模拟器走 MLX 兜底)。
|
||||
//
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
/// 末次生成的性能统计(取自 MNN LlmContext)。
|
||||
@interface MNNGenerateStats : NSObject
|
||||
@property (nonatomic, readonly) int promptTokens;
|
||||
@property (nonatomic, readonly) int genTokens;
|
||||
@property (nonatomic, readonly) double prefillMs;
|
||||
@property (nonatomic, readonly) double decodeMs;
|
||||
/// 解码速率 tok/s = genTokens / (decodeMs/1000)。demo 卖点 #6 / Live Activity 用。
|
||||
@property (nonatomic, readonly) double decodeTokensPerSecond;
|
||||
@end
|
||||
|
||||
@interface MNNLLMBridge : NSObject
|
||||
|
||||
/// 本构建是否含真实 MNN 运行时(device=YES,simulator 桩=NO)。
|
||||
+ (BOOL)isAvailable;
|
||||
/// CPU 是否支持 SME2(运行时探测);A19/iPhone17 YES,A17/iPhone15Pro NO。仅用于 UI 展示加速状态。
|
||||
+ (BOOL)cpuSupportsSME2;
|
||||
|
||||
/// 用 MNN llm 的 config.json 路径加载模型(目录含 llm.mnn / 权重 / tokenizer)。失败返回 nil。
|
||||
- (nullable instancetype)initWithConfigPath:(NSString *)configPath;
|
||||
|
||||
@property (nonatomic, readonly) BOOL isLoaded;
|
||||
|
||||
/// 纯文本流式生成。onToken 每解码出一段文本回调一次(在调用线程,同步阻塞直到生成结束)。
|
||||
/// 返回末次统计。
|
||||
- (MNNGenerateStats *)generateText:(NSString *)prompt
|
||||
maxTokens:(int)maxTokens
|
||||
onToken:(void (^)(NSString *piece))onToken;
|
||||
|
||||
/// 图→文(VL,需 MNN_BUILD_LLM_OMNI 构建)。imagePaths 为本地文件路径。
|
||||
/// 当前文本构建未含 OMNI 时返回 nil 并置 error。
|
||||
- (nullable MNNGenerateStats *)analyzeImages:(NSArray<NSString *> *)imagePaths
|
||||
prompt:(NSString *)prompt
|
||||
maxTokens:(int)maxTokens
|
||||
onToken:(void (^)(NSString *piece))onToken
|
||||
error:(NSError *_Nullable *_Nullable)error;
|
||||
|
||||
/// 请求取消当前生成(best-effort:置标志,后续 token 不再回调)。
|
||||
- (void)cancel;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
@@ -1,216 +0,0 @@
|
||||
//
|
||||
// MNNLLMBridge.mm
|
||||
// 康康
|
||||
//
|
||||
// ObjC++ 实现。device 真机用 <MNN/llm/llm.hpp>;模拟器编为桩(返回不可用,上层回退 MLX)。
|
||||
//
|
||||
|
||||
#import "MNNLLMBridge.h"
|
||||
#include <sys/sysctl.h>
|
||||
|
||||
// MARK: - 性能统计(私有 readwrite 重声明)
|
||||
@interface MNNGenerateStats ()
|
||||
@property (nonatomic, readwrite) int promptTokens;
|
||||
@property (nonatomic, readwrite) int genTokens;
|
||||
@property (nonatomic, readwrite) double prefillMs;
|
||||
@property (nonatomic, readwrite) double decodeMs;
|
||||
@end
|
||||
|
||||
@implementation MNNGenerateStats
|
||||
- (double)decodeTokensPerSecond {
|
||||
return self.decodeMs > 0 ? (self.genTokens / (self.decodeMs / 1000.0)) : 0;
|
||||
}
|
||||
@end
|
||||
|
||||
// MARK: - SME2 / 可用性探测(device + simulator 都可编)
|
||||
|
||||
static BOOL kk_sysctlFlag(const char *name) {
|
||||
int64_t v = 0; size_t sz = sizeof(v);
|
||||
if (sysctlbyname(name, &v, &sz, NULL, 0) != 0) return NO;
|
||||
return v != 0;
|
||||
}
|
||||
|
||||
#if TARGET_OS_SIMULATOR
|
||||
|
||||
// ============ 模拟器桩:无真实 MNN ============
|
||||
@implementation MNNLLMBridge
|
||||
+ (BOOL)isAvailable { return NO; }
|
||||
+ (BOOL)cpuSupportsSME2 { return NO; }
|
||||
- (nullable instancetype)initWithConfigPath:(NSString *)configPath { return nil; }
|
||||
- (BOOL)isLoaded { return NO; }
|
||||
- (MNNGenerateStats *)generateText:(NSString *)prompt maxTokens:(int)maxTokens
|
||||
onToken:(void (^)(NSString *))onToken { return [MNNGenerateStats new]; }
|
||||
- (nullable MNNGenerateStats *)analyzeImages:(NSArray<NSString *> *)imagePaths prompt:(NSString *)prompt
|
||||
maxTokens:(int)maxTokens onToken:(void (^)(NSString *))onToken
|
||||
error:(NSError **)error {
|
||||
if (error) *error = [NSError errorWithDomain:@"MNN" code:-1
|
||||
userInfo:@{NSLocalizedDescriptionKey: @"MNN 在模拟器不可用"}];
|
||||
return nil;
|
||||
}
|
||||
- (void)cancel {}
|
||||
@end
|
||||
|
||||
#else
|
||||
|
||||
// ============ 真机:真实 MNN-LLM ============
|
||||
// MNN 第三方头文件的文档注释不规范,会触发一堆 -Wdocumentation 警告(Executor/
|
||||
// Tensor/Interpreter/ImageProcess.hpp)。只在解析 MNN 头时关掉该警告,不影响本项目。
|
||||
#pragma clang diagnostic push
|
||||
#pragma clang diagnostic ignored "-Wdocumentation"
|
||||
#include <MNN/llm/llm.hpp>
|
||||
#pragma clang diagnostic pop
|
||||
#include <string>
|
||||
#include <ostream>
|
||||
#include <streambuf>
|
||||
#include <atomic>
|
||||
|
||||
using MNN::Transformer::Llm;
|
||||
|
||||
namespace {
|
||||
/// 把 MNN 写入 ostream 的解码文本转成 NSString 回调;按 UTF-8 完整边界聚合,避免截断多字节。
|
||||
class TokenStreamBuf : public std::streambuf {
|
||||
public:
|
||||
TokenStreamBuf(void (^onToken)(NSString *), std::atomic<bool> *cancel)
|
||||
: _onToken(onToken), _cancel(cancel) {}
|
||||
void flush() {
|
||||
if (_pending.empty()) return;
|
||||
emitPending(); // 末尾尽力 emit(即便非完整 UTF-8 也交出去)
|
||||
_pending.clear();
|
||||
}
|
||||
protected:
|
||||
std::streamsize xsputn(const char *s, std::streamsize n) override {
|
||||
append(s, (size_t)n);
|
||||
return n;
|
||||
}
|
||||
int overflow(int c) override {
|
||||
if (c != EOF) { char ch = (char)c; append(&ch, 1); }
|
||||
return c;
|
||||
}
|
||||
private:
|
||||
void append(const char *s, size_t n) {
|
||||
if (_cancel && _cancel->load()) return; // 已取消,吞掉不回调
|
||||
_pending.append(s, n);
|
||||
// 仅当整个 pending 是合法 UTF-8 才 emit(token 通常是完整字/词,边界自然对齐)
|
||||
NSString *str = [[NSString alloc] initWithBytes:_pending.data()
|
||||
length:_pending.size()
|
||||
encoding:NSUTF8StringEncoding];
|
||||
if (str) { if (_onToken) _onToken(str); _pending.clear(); }
|
||||
}
|
||||
void emitPending() {
|
||||
NSString *str = [[NSString alloc] initWithBytes:_pending.data()
|
||||
length:_pending.size()
|
||||
encoding:NSUTF8StringEncoding];
|
||||
if (str && _onToken) _onToken(str);
|
||||
}
|
||||
void (^_onToken)(NSString *);
|
||||
std::atomic<bool> *_cancel;
|
||||
std::string _pending;
|
||||
};
|
||||
} // namespace
|
||||
|
||||
@implementation MNNLLMBridge {
|
||||
Llm *_llm;
|
||||
std::atomic<bool> _cancel;
|
||||
BOOL _loaded;
|
||||
}
|
||||
|
||||
+ (BOOL)isAvailable { return YES; }
|
||||
|
||||
+ (BOOL)cpuSupportsSME2 {
|
||||
// Apple 通过 sysctl 暴露 ARM 特性位:FEAT_SME2(A19/iPhone17+)。
|
||||
return kk_sysctlFlag("hw.optional.arm.FEAT_SME2");
|
||||
}
|
||||
|
||||
- (nullable instancetype)initWithConfigPath:(NSString *)configPath {
|
||||
self = [super init];
|
||||
if (!self) return nil;
|
||||
_cancel = false;
|
||||
_llm = Llm::createLLM(std::string(configPath.UTF8String));
|
||||
if (_llm == nullptr) return nil;
|
||||
// load 前以 merge-patch 调三件事(只翻这几个叶子,保留 chat_template 等其余配置):
|
||||
// ① enable_thinking=false:config.json 默认 true,模板会给每个 assistant 回合硬塞
|
||||
// <think>\n 开启思考,吞掉 token 预算并污染 JSON(prompt 里的 /no_think 对此模板无效)。
|
||||
// ② 降温:config.json 默认 temperature=1.0 对结构化 JSON 太高,随机性大→经常吐成非 JSON。
|
||||
// 本 App 所有任务都是"直答/JSON",压到 0.3 + topP 0.85 让输出更确定、JSON 更稳。
|
||||
// ③ 重复惩罚:MNN 默认 mixed_samplers 不含 "penalty"、penalty/ngram_factor=1.0(全关),
|
||||
// 叠加低温 → 长文本(如「关键指标」列表)会陷入逐行复读死循环(收缩压 107 mmHg ×N)。
|
||||
// 显式把 "penalty" 放进 mixed 链首,开 repetition penalty(1.1)+ n-gram 惩罚(ngram_factor 1.05):
|
||||
// n-gram 命中整段重复时惩罚升到 max_penalty,直接掐断逐行复读。
|
||||
_llm->set_config("{"
|
||||
"\"jinja\":{\"context\":{\"enable_thinking\":false}},"
|
||||
"\"sampler_type\":\"mixed\","
|
||||
"\"mixed_samplers\":[\"penalty\",\"topK\",\"topP\",\"temperature\"],"
|
||||
"\"temperature\":0.3,\"topP\":0.85,\"topK\":40,"
|
||||
"\"penalty\":1.1,\"n_gram\":8,\"ngram_factor\":1.05"
|
||||
"}");
|
||||
_loaded = _llm->load();
|
||||
if (!_loaded) { Llm::destroy(_llm); _llm = nullptr; return nil; }
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)dealloc {
|
||||
if (_llm) { Llm::destroy(_llm); _llm = nullptr; }
|
||||
}
|
||||
|
||||
- (BOOL)isLoaded { return _loaded; }
|
||||
|
||||
- (void)cancel { _cancel = true; }
|
||||
|
||||
// 统一生成:full 已是最终 prompt(文本,或含 <img>路径</img> 标签)。
|
||||
// 多模态模型 createLLM 返回 Omni,response 解析 <img> 标签并对路径 CV::imread(OMNI 框架内)。
|
||||
- (MNNGenerateStats *)runResponse:(NSString *)full
|
||||
maxTokens:(int)maxTokens
|
||||
onToken:(void (^)(NSString *))onToken {
|
||||
_cancel = false;
|
||||
TokenStreamBuf buf(onToken, &_cancel);
|
||||
std::ostream os(&buf);
|
||||
if (_llm) {
|
||||
// 红线:本 App 每次 generate/analyze 都是一次性独立推理(无多轮对话语义)。
|
||||
// MNN 的 Llm::response 默认把本轮 prompt+输出累积进 history_tokens / KV cache,
|
||||
// 不 reset 的话第二次导出会把上一次的完整上下文叠加进来 → all_seq_len 暴涨、
|
||||
// 冲过上下文上限 → 崩溃(用户报「再次导出死机」)。每轮先 reset 清空历史,
|
||||
// 与 MLX LLMSession 的「每次 generate 无状态」保持一致。
|
||||
_llm->reset();
|
||||
_llm->response(std::string(full.UTF8String), &os, nullptr, maxTokens);
|
||||
}
|
||||
buf.flush();
|
||||
return [self statsFromContext];
|
||||
}
|
||||
|
||||
- (MNNGenerateStats *)generateText:(NSString *)prompt
|
||||
maxTokens:(int)maxTokens
|
||||
onToken:(void (^)(NSString *))onToken {
|
||||
return [self runResponse:prompt maxTokens:maxTokens onToken:onToken];
|
||||
}
|
||||
|
||||
- (nullable MNNGenerateStats *)analyzeImages:(NSArray<NSString *> *)imagePaths
|
||||
prompt:(NSString *)prompt
|
||||
maxTokens:(int)maxTokens
|
||||
onToken:(void (^)(NSString *))onToken
|
||||
error:(NSError **)error {
|
||||
// 在 prompt 前拼 <img>本地路径</img>;Omni 解析标签并对路径 imread(需 OMNI 框架)。
|
||||
NSMutableString *full = [NSMutableString string];
|
||||
for (NSString *p in imagePaths) {
|
||||
[full appendFormat:@"<img>%@</img>", p];
|
||||
}
|
||||
[full appendString:prompt];
|
||||
return [self runResponse:full maxTokens:maxTokens onToken:onToken];
|
||||
}
|
||||
|
||||
- (MNNGenerateStats *)statsFromContext {
|
||||
MNNGenerateStats *s = [MNNGenerateStats new];
|
||||
if (_llm) {
|
||||
const MNN::Transformer::LlmContext *ctx = _llm->getContext();
|
||||
if (ctx) {
|
||||
s.promptTokens = ctx->prompt_len;
|
||||
s.genTokens = ctx->gen_seq_len;
|
||||
s.prefillMs = ctx->prefill_us / 1000.0;
|
||||
s.decodeMs = ctx->decode_us / 1000.0;
|
||||
}
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
#endif
|
||||
@@ -1,113 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
/// MNN(CPU / SME2)推理后端,封装 `MNNLLMBridge` 的文本流式生成。
|
||||
/// 与 `LLMSession`/`VLSession` 同款 actor 隔离;跨调用的串行化由上游 `AIRuntime` 闸门保证。
|
||||
///
|
||||
/// 文本与视觉(图→文)由同一个 Qwen3.5-2B 多模态 MNN 模型承担:`generate` 走文本,
|
||||
/// `analyze` 把图片拼成 <img> 标签交给 Omni 内核 imread 解码(需 OMNI 构建,xcframework 已含)。
|
||||
/// 已实测可用,真机走此单模型全包路径;模拟器无 MNN,VL 仍回退 MLX(见 `AIRuntime`)。
|
||||
actor MNNBackend {
|
||||
private var bridge: MNNLLMBridge?
|
||||
|
||||
/// 末次生成统计(供 AIRuntime 在流结束后取走,性能自检用)。
|
||||
private(set) var lastStats: GenerateStats?
|
||||
|
||||
private func record(_ s: GenerateStats) { lastStats = s }
|
||||
|
||||
var isLoaded: Bool { bridge?.isLoaded ?? false }
|
||||
|
||||
/// 从 MNN 模型目录加载(目录含 MNN llm 的 config.json + llm.mnn + 权重 + tokenizer)。
|
||||
func load(folderURL: URL) throws {
|
||||
let configPath = folderURL.appendingPathComponent("config.json").path
|
||||
guard FileManager.default.fileExists(atPath: configPath) else {
|
||||
throw AIRuntimeError.notReady
|
||||
}
|
||||
guard let b = MNNLLMBridge(configPath: configPath) else {
|
||||
throw AIRuntimeError.modelLoadFailed("MNN createLLM/load 失败")
|
||||
}
|
||||
bridge = b
|
||||
}
|
||||
|
||||
func unload() { bridge = nil }
|
||||
|
||||
/// 文本流式生成。`bridge.generateText` 同步阻塞、逐段回调,放在 detached 线程跑,
|
||||
/// 把每段文本 yield 成 `TokenChunk`(含即时 tok/s)。流被取消时调用 `bridge.cancel()`。
|
||||
func generate(prompt: String, maxTokens: Int) -> AsyncThrowingStream<TokenChunk, Error> {
|
||||
guard let bridge else {
|
||||
return AsyncThrowingStream { $0.finish(throwing: AIRuntimeError.notReady) }
|
||||
}
|
||||
let box = MNNUncheckedBox(bridge)
|
||||
return AsyncThrowingStream { continuation in
|
||||
let meter = MNNRateMeter()
|
||||
let task = Task.detached(priority: .userInitiated) {
|
||||
let stats = box.value.generateText(prompt, maxTokens: Int32(maxTokens)) { piece in
|
||||
let rate = meter.tick()
|
||||
continuation.yield(TokenChunk(text: piece, decodeRate: rate))
|
||||
}
|
||||
// ObjC 统计对象先抽成 Sendable 的 GenerateStats 再跨 actor 记录。
|
||||
await self.record(GenerateStats(
|
||||
promptTokens: Int(stats.promptTokens),
|
||||
genTokens: Int(stats.genTokens),
|
||||
prefillSeconds: stats.prefillMs / 1000.0,
|
||||
decodeSeconds: stats.decodeMs / 1000.0
|
||||
))
|
||||
continuation.finish()
|
||||
}
|
||||
continuation.onTermination = { _ in
|
||||
box.value.cancel()
|
||||
task.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 图→文(VL)。一次性收集(JSON 抽取不需流式)。桥接里把图片路径拼成 <img> 标签,
|
||||
/// MNN Omni 内部 imread 加载(需 OMNI 框架);blocking 调用放 detached 线程。
|
||||
func analyze(imageURLs: [URL], prompt: String, maxTokens: Int) async throws -> String {
|
||||
guard let bridge else { throw AIRuntimeError.notReady }
|
||||
let paths = imageURLs.map(\.path)
|
||||
let box = MNNUncheckedBox(bridge)
|
||||
return try await withCheckedThrowingContinuation { cont in
|
||||
Task.detached(priority: .userInitiated) {
|
||||
let sink = MNNTextSink()
|
||||
do {
|
||||
let stats = try box.value.analyzeImages(paths, prompt: prompt, maxTokens: Int32(maxTokens)) { piece in
|
||||
sink.append(piece)
|
||||
}
|
||||
await self.record(GenerateStats(
|
||||
promptTokens: Int(stats.promptTokens),
|
||||
genTokens: Int(stats.genTokens),
|
||||
prefillSeconds: stats.prefillMs / 1000.0,
|
||||
decodeSeconds: stats.decodeMs / 1000.0
|
||||
))
|
||||
cont.resume(returning: sink.text)
|
||||
} catch {
|
||||
cont.resume(throwing: AIRuntimeError.inferenceFailed(error.localizedDescription))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 单线程串行回调聚合文本,无竞争。
|
||||
private nonisolated final class MNNTextSink: @unchecked Sendable {
|
||||
private(set) var text = ""
|
||||
func append(_ s: String) { text += s }
|
||||
}
|
||||
|
||||
/// 把非 Sendable 的 ObjC 桥对象安全带过 detached 边界。
|
||||
/// 安全性来自 `AIRuntime` 闸门:同一时刻只有一个生成在跑,桥不会被并发访问。
|
||||
private nonisolated struct MNNUncheckedBox<T>: @unchecked Sendable {
|
||||
let value: T
|
||||
init(_ value: T) { self.value = value }
|
||||
}
|
||||
|
||||
/// 即时解码速率计:回调在单线程串行调用,内部计数无竞争。
|
||||
private nonisolated final class MNNRateMeter: @unchecked Sendable {
|
||||
private let start = Date()
|
||||
private var produced = 0
|
||||
func tick() -> Double {
|
||||
produced += 1
|
||||
let elapsed = Date().timeIntervalSince(start)
|
||||
return elapsed > 0 ? Double(produced) / elapsed : 0
|
||||
}
|
||||
}
|
||||
@@ -298,8 +298,7 @@ struct UnifiedCaptureFlow: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// 跨 detached 边界安全携带 UIImage 数组:图片只读、不并发改,封装免 Sendable 报错
|
||||
/// (同 MNNBackend.MNNUncheckedBox 思路)。
|
||||
/// 跨 detached 边界安全携带 UIImage 数组:图片只读、不并发改,封装免 Sendable 报错。
|
||||
private struct UncheckedImageBox: @unchecked Sendable {
|
||||
let images: [UIImage]
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import SwiftUI
|
||||
|
||||
/// 推理引擎设置:本项目已切 Gemma-3n(端侧 4bit),主模型只走 MLX/GPU。
|
||||
/// MNN/SME2 路径已停用(Gemma-3n 无 MNN 转换模型),引擎行保留但 MNN 置灰。
|
||||
/// 切换只改持久化选择;下一次 AI 调用(prepare/generate)按新引擎加载。
|
||||
/// 推理引擎设置:本项目主模型 Gemma-3n(端侧 4bit),**端侧统一走 MLX/GPU**。
|
||||
/// MNN/SME2 LLM 引擎已移除(Gemma-3n 无 MNN 转换模型),不再提供引擎选择——
|
||||
/// 端侧只此一种,页面改为展示当前端侧后端 + 性能自检 + 云端 AI 开关。
|
||||
struct InferenceSettingsView: View {
|
||||
@AppStorage("kk.inferenceEngine") private var engineRaw = EnginePreference.auto.rawValue
|
||||
// 云端 AI(Gemini)开关与 key —— 键名与 CloudAI 对齐,@AppStorage 写入即被后端读到。
|
||||
@AppStorage("cloud_ai_gemini_enabled") private var cloudEnabled = false
|
||||
@AppStorage("cloud_ai_gemini_key") private var cloudKey = ""
|
||||
@@ -12,10 +11,6 @@ struct InferenceSettingsView: View {
|
||||
/// 性能自检改为当前页就地展开,不再 push 新页面。
|
||||
@State private var showSelfTest = false
|
||||
|
||||
private var selected: EnginePreference {
|
||||
EnginePreference(rawValue: engineRaw) ?? .auto
|
||||
}
|
||||
|
||||
/// 性能自检需要主模型(Gemma-3n,MLX)就绪。
|
||||
private var modelReady: Bool {
|
||||
modelService.states[.llm]?.phase == .ready
|
||||
@@ -33,9 +28,7 @@ struct InferenceSettingsView: View {
|
||||
.padding(.top, 4)
|
||||
.padding(.bottom, 6)
|
||||
|
||||
ForEach(EnginePreference.allCases, id: \.self) { engine in
|
||||
engineRow(engine)
|
||||
}
|
||||
localEngineCard
|
||||
|
||||
selfTestSection
|
||||
cloudSection
|
||||
@@ -48,6 +41,35 @@ struct InferenceSettingsView: View {
|
||||
.onAppear { modelService.refreshStates() }
|
||||
}
|
||||
|
||||
/// 端侧后端信息卡(只此一种,不可切换):MLX · Metal GPU,端侧 Gemma-3n E2B。
|
||||
private var localEngineCard: some View {
|
||||
HStack(spacing: 12) {
|
||||
ZStack {
|
||||
Circle().fill(Tj.Palette.amber.opacity(0.25))
|
||||
Image(systemName: "bolt.fill")
|
||||
.font(.tjScaled(18))
|
||||
.foregroundStyle(Tj.Palette.ink)
|
||||
}
|
||||
.frame(width: 44, height: 44)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("MLX · GPU")
|
||||
.font(.tjScaled(15, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
Text("Metal GPU · 端侧推理 Gemma-3n E2B")
|
||||
.font(.tjScaled(12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
.lineLimit(2)
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.tjScaled(18))
|
||||
.foregroundStyle(Tj.Palette.leaf)
|
||||
}
|
||||
.padding(14)
|
||||
.tjCard()
|
||||
}
|
||||
|
||||
/// 性能自检入口:它是「动作/工具」而非引擎选择,所以做成描边动作按钮(TjGhostButton),
|
||||
/// 从视觉类别上和上方引擎/状态卡区分开。模型未就绪时禁用并给出下载提示而非死链。
|
||||
@ViewBuilder
|
||||
@@ -97,75 +119,6 @@ struct InferenceSettingsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func engineRow(_ engine: EnginePreference) -> some View {
|
||||
let available = isAvailable(engine)
|
||||
let isOn = (selected == engine)
|
||||
return Button {
|
||||
guard available else { return }
|
||||
engineRaw = engine.rawValue
|
||||
} label: {
|
||||
HStack(spacing: 12) {
|
||||
ZStack {
|
||||
Circle().fill(isOn ? Tj.Palette.amber.opacity(0.25) : Tj.Palette.sand2)
|
||||
Image(systemName: iconName(engine))
|
||||
.font(.tjScaled(18))
|
||||
.foregroundStyle(isOn ? Tj.Palette.ink : Tj.Palette.text2)
|
||||
}
|
||||
.frame(width: 44, height: 44)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(engine.displayName)
|
||||
.font(.tjScaled(15, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
Text(subtitle(engine, available: available))
|
||||
.font(.tjScaled(12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
.lineLimit(2)
|
||||
}
|
||||
Spacer()
|
||||
if isOn {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.tjScaled(18))
|
||||
.foregroundStyle(Tj.Palette.leaf)
|
||||
}
|
||||
}
|
||||
.padding(14)
|
||||
.tjCard()
|
||||
.opacity(available ? 1 : 0.45)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(!available)
|
||||
}
|
||||
|
||||
/// .auto 永远可用;MLX 看自身可用性。MNN 已停用(Gemma-3n 无 MNN 模型),恒置灰。
|
||||
private func isAvailable(_ engine: EnginePreference) -> Bool {
|
||||
switch engine {
|
||||
case .auto: return true
|
||||
case .mnn: return false
|
||||
case .mlx: return InferenceEngine.mlx.isAvailable
|
||||
}
|
||||
}
|
||||
|
||||
private func iconName(_ engine: EnginePreference) -> String {
|
||||
switch engine {
|
||||
case .auto: return "wand.and.stars"
|
||||
case .mnn: return "cpu.fill"
|
||||
case .mlx: return "bolt.fill"
|
||||
}
|
||||
}
|
||||
|
||||
private func subtitle(_ engine: EnginePreference, available: Bool) -> String {
|
||||
switch engine {
|
||||
case .auto:
|
||||
// 已切 Gemma-3n,auto 恒解析为 MLX。
|
||||
return String(appLoc: "按本机配置选择 · 当前 MLX · GPU")
|
||||
case .mnn:
|
||||
return String(appLoc: "已停用:Gemma-3n 无 MNN 转换模型,统一走 MLX")
|
||||
case .mlx:
|
||||
return String(appLoc: "Metal GPU · 端侧推理 Gemma-3n E2B")
|
||||
}
|
||||
}
|
||||
|
||||
private var cloudConfigured: Bool {
|
||||
cloudEnabled && !cloudKey.trimmingCharacters(in: .whitespaces).isEmpty
|
||||
}
|
||||
|
||||
@@ -167,12 +167,8 @@ struct MeView: View {
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private var engineDetail: String {
|
||||
switch InferenceEngine.current {
|
||||
case .mnn: return InferenceEngine.cpuSupportsSME2 ? "MNN · SME2" : "MNN · CPU"
|
||||
case .mlx: return "MLX · GPU"
|
||||
}
|
||||
}
|
||||
/// 端侧统一走 MLX/GPU(Gemma-3n E2B);MNN/SME2 LLM 引擎已移除。
|
||||
private var engineDetail: String { "MLX · GPU" }
|
||||
|
||||
private var languageCard: some View {
|
||||
NavigationLink {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import SwiftUI
|
||||
|
||||
/// 性能自检:跑固定 prompt,展示当前后端(MNN·SME2 / MNN·NEON / MLX·GPU)的
|
||||
/// prefill / decode 实测速度,并按后端存档对比 —— 挑战赛考核点的可见证据(§12 卖点 2/6)。
|
||||
/// 性能自检:跑固定 prompt,展示当前端侧后端(MLX·GPU,Gemma-3n E2B)的
|
||||
/// prefill / decode 实测速度,并按后端存档对比 —— 端侧推理性能的可见证据(§12 卖点 2/6)。
|
||||
struct ModelSelfTestView: View {
|
||||
@State private var output = ""
|
||||
@State private var phase: Phase = .idle
|
||||
|
||||
@@ -507,7 +507,7 @@ private struct TrendInsightCard: View {
|
||||
text = try await TrendInsightService.shared.generate(for: bucket)
|
||||
} catch {
|
||||
// 区分「模型没下载」与「下载了但这次推理没成功」,前者给下载引导(CLAUDE.md §4)。
|
||||
let downloaded = ModelStore.shared.isComplete(for: .mnnLLM) || ModelStore.shared.isComplete(for: .llm)
|
||||
let downloaded = ModelStore.shared.isComplete(for: .llm)
|
||||
failedMessage = downloaded
|
||||
? String(appLoc: "本地推理这次没成功,点右上「解读」重试")
|
||||
: String(appLoc: "AI 解读需先在「我的 · 模型管理」下载模型")
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Foundation
|
||||
|
||||
/// 单次性能自检结果。按后端标签归档,供「MNN·SME2 vs MLX·GPU」对比展示(§12 卖点 2/6)。
|
||||
/// 单次性能自检结果。按后端标签归档,供端侧(MLX·GPU)与云端(Gemini)对比展示(§12 卖点 2/6)。
|
||||
struct BenchmarkResult: Codable, Equatable {
|
||||
var backendLabel: String
|
||||
var promptTokens: Int
|
||||
|
||||
@@ -65,7 +65,7 @@ struct DiaryAssistService {
|
||||
|
||||
let prompt = DiaryAssistPrompts.suggest(content: content, coveredDimensions: coveredDimensions)
|
||||
|
||||
// 低温采样下 MNN 仍偶发吐非 JSON / 漏掉外层 {"questions":…} 包裹(换 MNN 后比 MLX 更常见)。
|
||||
// 端侧小模型低温采样下偶发吐非 JSON / 漏掉外层 {"questions":…} 包裹。
|
||||
// 首次解析不出就自动重试一次,两次都失败才报错 —— 守 §10.5「失败回退,不让用户卡在 AI 错误屏」。
|
||||
var lastRate: Double = 0
|
||||
var parsedButEmpty = false
|
||||
|
||||
@@ -3,5 +3,4 @@
|
||||
// 把 Objective-C 接口暴露给 Swift。
|
||||
//
|
||||
|
||||
#import "AI/MNN/MNNLLMBridge.h"
|
||||
#import "AI/MNN/SenseVoiceBridge.h"
|
||||
|
||||
Reference in New Issue
Block a user