```
feat(iOS): 更新MNN后端模型配置优化性能 将MNN主模型从Qwen3.5-4B(~2.64GiB)降级为Qwen3.5-2B(~1.1GiB),因为4B版本 实测运行过慢,影响用户体验。iPhone17+/SME2设备使用2B模型,保留MLX 兜底方案用于模拟器和备用场景,确保AI推理性能和存储效率的平衡。 ```
This commit is contained in:
@@ -33,11 +33,11 @@ actor AIRuntime {
|
||||
private var vlSession: VLSession?
|
||||
|
||||
// MARK: - MNN 后端(CPU/SME2,挑战赛考核路径)
|
||||
// .mnn 引擎下,文本生成与 VL(图→文)由同一个 Qwen3.5-4B 多模态 MNN 模型全包(已实测)。
|
||||
// .mnn 引擎下,文本生成与 VL(图→文)由同一个 Qwen3.5-2B 多模态 MNN 模型全包(已实测)。
|
||||
// 模拟器无 MNN,VL 回退 MLX 的 Qwen3-VL-4B。
|
||||
private let mnn = MNNBackend()
|
||||
private(set) var mnnStatus: Status = .notReady
|
||||
/// MNN 模型目录(下载/旁路导入到 Models/Qwen3.5-4B-MNN)。
|
||||
/// MNN 模型目录(下载/旁路导入到 Models/Qwen3.5-2B-MNN)。
|
||||
nonisolated static var mnnModelFolder: URL {
|
||||
ModelStore.shared.localURL(for: .mnnLLM)
|
||||
}
|
||||
@@ -266,7 +266,7 @@ actor AIRuntime {
|
||||
}
|
||||
if vlStatus == .ready { return }
|
||||
|
||||
// MLX VL 改用 .llm 的 Qwen3.5-4B 多模态(VLMModelFactory 走 qwen3_5 视觉路径),
|
||||
// MLX VL 改用 .llm 的 Qwen3.5-2B 多模态(VLMModelFactory 走 qwen3_5 视觉路径),
|
||||
// 不再单独需要 Qwen3-VL-4B。用 isComplete 排除半下载,与下载服务判据一致。
|
||||
guard ModelStore.shared.isComplete(for: .llm) else {
|
||||
vlStatus = .error("VL 模型未就绪")
|
||||
@@ -274,7 +274,7 @@ actor AIRuntime {
|
||||
}
|
||||
|
||||
// 进闸门:等所有在跑的推理(可能是 LLM 文本流)结束,再卸 LLM + 载 VL。
|
||||
// —— 这正是「异常项快拍识别时 App 自动退出」的主因防护。
|
||||
// —— 这正是「指标速记识别时 App 自动退出」的主因防护。
|
||||
await acquireGate()
|
||||
defer { releaseGate() }
|
||||
if vlStatus == .ready { return }
|
||||
|
||||
@@ -26,16 +26,52 @@ nonisolated enum InferenceEngine: String, CaseIterable, Sendable {
|
||||
|
||||
private static let key = "kk.inferenceEngine"
|
||||
|
||||
/// 当前选择。无效/不可用时回退到 .mlx(保证总有可用引擎)。真机默认 .mnn。
|
||||
/// 由偏好(可能是 .auto)解析出的、本次调用实际使用的具体引擎。
|
||||
/// AIRuntime / MeView 等消费方只看这个,永远拿到 .mnn 或 .mlx。
|
||||
/// 解析后仍做一次可用性兜底,保证总有可用引擎。
|
||||
static var current: InferenceEngine {
|
||||
get {
|
||||
let raw = UserDefaults.standard.string(forKey: key)
|
||||
let chosen = raw.flatMap(InferenceEngine.init(rawValue:)) ?? .mnn
|
||||
return chosen.isAvailable ? chosen : .mlx
|
||||
}
|
||||
set { UserDefaults.standard.set(newValue.rawValue, forKey: key) }
|
||||
let resolved = preference.resolved
|
||||
return resolved.isAvailable ? resolved : .mlx
|
||||
}
|
||||
|
||||
/// 运行时探测:CPU 是否支持 SME2(A19/iPhone17+)。用于 UI 展示加速状态。
|
||||
static var 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:按本机配置自动选——真机优先 MNN(考核路径,含 SME2/NEON),
|
||||
/// MNN 不可用(模拟器)时回退 MLX。
|
||||
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 InferenceEngine.mnn.isAvailable ? .mnn : .mlx
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,10 +45,16 @@ actor LLMSession {
|
||||
let task = Task {
|
||||
do {
|
||||
try await Self.withDeviceOverride {
|
||||
// 低温:本 App 文本任务多为"直答/JSON 抽取",高温随机性会经常吐成非 JSON。
|
||||
// 0.3 + topP 0.85 让输出更确定、JSON 更稳(与 MNN set_config 降温对齐)。
|
||||
// repetitionPenalty:低温 + 无惩罚时,长文本(如「关键指标」列表)会逐行复读
|
||||
// 进入死循环;1.1 的重复惩罚 + 64 token 上下文窗口掐断复读(与 MNN penalty 对齐)。
|
||||
let parameters = GenerateParameters(
|
||||
maxTokens: maxTokens,
|
||||
temperature: Float(0.6),
|
||||
topP: Float(0.9)
|
||||
temperature: Float(0.3),
|
||||
topP: Float(0.85),
|
||||
repetitionPenalty: Float(1.1),
|
||||
repetitionContextSize: 64
|
||||
)
|
||||
|
||||
try await container.perform { (context: ModelContext) in
|
||||
|
||||
@@ -127,6 +127,22 @@ private:
|
||||
_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;
|
||||
|
||||
@@ -3,7 +3,7 @@ import Foundation
|
||||
/// MNN(CPU / SME2)推理后端,封装 `MNNLLMBridge` 的文本流式生成。
|
||||
/// 与 `LLMSession`/`VLSession` 同款 actor 隔离;跨调用的串行化由上游 `AIRuntime` 闸门保证。
|
||||
///
|
||||
/// 文本与视觉(图→文)由同一个 Qwen3.5-4B 多模态 MNN 模型承担:`generate` 走文本,
|
||||
/// 文本与视觉(图→文)由同一个 Qwen3.5-2B 多模态 MNN 模型承担:`generate` 走文本,
|
||||
/// `analyze` 把图片拼成 <img> 标签交给 Omni 内核 imread 解码(需 OMNI 构建,xcframework 已含)。
|
||||
/// 已实测可用,真机走此单模型全包路径;模拟器无 MNN,VL 仍回退 MLX(见 `AIRuntime`)。
|
||||
actor MNNBackend {
|
||||
|
||||
@@ -18,18 +18,20 @@ nonisolated enum ModelManifest {
|
||||
static func files(for kind: ModelKind) -> [ModelFile] {
|
||||
switch kind {
|
||||
case .llm:
|
||||
// Qwen3.5-4B-4bit:多模态仓库,MLX 兜底用它同时做文本(LLMModelFactory qwen3_5 文本路径)
|
||||
// 与视觉(VLMModelFactory qwen3_5)。字节数取自 mlx-community/Qwen3.5-4B-4bit
|
||||
// 仓库实际 blob 大小(HF API,2026-06 核对)。镜像全部运行文件(含视觉预处理配置),
|
||||
// 排除 README.md / .gitattributes。
|
||||
// Qwen3.5-2B-4bit:多模态仓库,但走 LLMModelFactory 的 qwen3_5 文本路径加载。
|
||||
// 字节数取自 mlx-community/Qwen3.5-2B-4bit 仓库实际 blob 大小(HF API,2026-06 核对)。
|
||||
// 该仓库 tokenizer 体系为 vocab.json + tokenizer.json(无 merges.txt /
|
||||
// special_tokens_map.json / added_tokens.json),chat_template 改为 .jinja。
|
||||
// 一并镜像视觉预处理配置(preprocessor / processor / video_preprocessor),
|
||||
// 文本加载用不到但体积可忽略,保持与仓库一致避免漏文件。
|
||||
return [
|
||||
ModelFile(path: "config.json", bytes: 3_366),
|
||||
ModelFile(path: "model.safetensors", bytes: 3_034_300_695),
|
||||
ModelFile(path: "model.safetensors.index.json", bytes: 101_944),
|
||||
ModelFile(path: "config.json", bytes: 3_113),
|
||||
ModelFile(path: "model.safetensors", bytes: 1_722_271_785),
|
||||
ModelFile(path: "model.safetensors.index.json", bytes: 81_722),
|
||||
ModelFile(path: "tokenizer.json", bytes: 19_989_343),
|
||||
ModelFile(path: "tokenizer_config.json", bytes: 1_139),
|
||||
ModelFile(path: "vocab.json", bytes: 6_722_759),
|
||||
ModelFile(path: "chat_template.jinja", bytes: 7_756),
|
||||
ModelFile(path: "chat_template.jinja", bytes: 7_755),
|
||||
ModelFile(path: "preprocessor_config.json", bytes: 390),
|
||||
ModelFile(path: "processor_config.json", bytes: 1_300),
|
||||
ModelFile(path: "video_preprocessor_config.json", bytes: 385),
|
||||
@@ -58,18 +60,17 @@ nonisolated enum ModelManifest {
|
||||
ModelFile(path: "video_preprocessor_config.json", bytes: 817),
|
||||
]
|
||||
case .mnnLLM:
|
||||
// taobao-mnn/Qwen3.5-4B-MNN 预转换 MNN 格式(HF API 实测,2026-06)。
|
||||
// taobao-mnn/Qwen3.5-2B-MNN 预转换 MNN 格式(HF API 实测,2026-06)。
|
||||
// 运行时必需:config.json(MNN llm 配置)+ llm_config.json(超参)+ llm.mnn(图)
|
||||
// + llm.mnn.weight(量化权重 ~2.45GB)+ tokenizer.txt + visual.mnn/visual.mnn.weight(多模态,
|
||||
// 文本路径不用但配置含 mllm,带上避免缺文件)。排除 README/.gitattributes 与可读 dump。
|
||||
// + llm.mnn.weight(量化权重 ~1.1GB)+ tokenizer.txt + visual.mnn(多模态,文本路径不用但配置含 mllm)。
|
||||
// 排除 README/.gitattributes 与可读 dump(llm.mnn.json / export_args.json)。
|
||||
return [
|
||||
ModelFile(path: "config.json", bytes: 652),
|
||||
ModelFile(path: "llm_config.json", bytes: 8_693),
|
||||
ModelFile(path: "llm.mnn", bytes: 3_651_096),
|
||||
ModelFile(path: "llm.mnn.weight", bytes: 2_629_387_626),
|
||||
ModelFile(path: "llm_config.json", bytes: 8_692),
|
||||
ModelFile(path: "llm.mnn", bytes: 2_148_136),
|
||||
ModelFile(path: "llm.mnn.weight", bytes: 1_176_647_702),
|
||||
ModelFile(path: "tokenizer.txt", bytes: 6_465_727),
|
||||
ModelFile(path: "visual.mnn", bytes: 488_096),
|
||||
ModelFile(path: "visual.mnn.weight", bytes: 196_768_960),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,19 +2,19 @@ import Foundation
|
||||
|
||||
nonisolated enum ModelKind: String, CaseIterable {
|
||||
/// 也是沙盒 Models/ 下的子目录名 / CDN 路径段。
|
||||
/// 同一个 Qwen3.5-4B,两种格式两种引擎:
|
||||
/// - mnnLLM:MNN(CPU/SME2,考核路径)文本+视觉一肩挑,taobao-mnn 预转换。真机主用,只露它。
|
||||
/// - llm:MLX(GPU)兜底,Qwen3.5-4B-4bit 多模态(同时兜底文本与视觉,走 qwen3_5)。
|
||||
/// 同一个 Qwen3.5-2B,两种格式两种引擎:
|
||||
/// - mnnLLM:MNN(CPU/SME2,考核路径)文本+视觉一肩挑,taobao-mnn 预转换。iPhone17+(A19/SME2)主用,只露它。
|
||||
/// - llm:MLX(GPU)兜底,Qwen3.5-2B-4bit 多模态(同时兜底文本与视觉,走 qwen3_5)。
|
||||
/// - vl:已废弃(MLX VL 改走 .llm 多模态),保留枚举避免动一圈穷举 switch,不再下载/展示。
|
||||
case llm = "Qwen3.5-4B-4bit"
|
||||
case llm = "Qwen3.5-2B-4bit"
|
||||
case vl = "Qwen3-VL-4B-Instruct-4bit"
|
||||
case mnnLLM = "Qwen3.5-4B-MNN"
|
||||
case mnnLLM = "Qwen3.5-2B-MNN"
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .llm: return "Qwen3.5-4B (MLX)"
|
||||
case .llm: return "Qwen3.5-2B (MLX)"
|
||||
case .vl: return "Qwen3-VL-4B"
|
||||
case .mnnLLM: return "Qwen3.5-4B (MNN/SME2)"
|
||||
case .mnnLLM: return "Qwen3.5-2B (MNN/SME2)"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ nonisolated enum ModelKind: String, CaseIterable {
|
||||
var sentinelFilename: String { "config.json" }
|
||||
|
||||
/// 面向用户的模型集合:模型管理页 / 下载全部 / 就绪计数对外只暴露统一的
|
||||
/// Qwen3.5-4B(MNN,文本+视觉全包,真机走它)。
|
||||
/// Qwen3.5-2B(MNN,文本+视觉全包,iPhone17+ 走它)。
|
||||
/// MLX 的 .llm/.vl 仅作模拟器与兜底路径,保留枚举与下载能力(旁路导入仍可单独导),
|
||||
/// 但不在「我的 · 模型管理」展示,也不计入「下载全部」与就绪计数。
|
||||
static let userFacing: [ModelKind] = [.mnnLLM]
|
||||
|
||||
@@ -88,9 +88,9 @@ JSON schema(严格):
|
||||
现在请识别图片并输出 JSON:
|
||||
"""#
|
||||
|
||||
// MARK: - 局部小框识别(异常项快拍)
|
||||
// MARK: - 局部小框识别(指标速记)
|
||||
|
||||
/// 异常项快拍专用:输入是报告/化验单的**局部照片**(常常只有一两行指标)。
|
||||
/// 指标速记专用:输入是报告/化验单的**局部照片**(常常只有一两行指标)。
|
||||
/// 只要 indicators 数组,不要报告标题/机构/日期等元信息 —— 这条路径只存数值,不建 Report。
|
||||
static func regionExtraction(today: Date = .now) -> String {
|
||||
let f = DateFormatter()
|
||||
|
||||
Reference in New Issue
Block a user