feat(iOS): 更新MNN后端模型配置优化性能 将MNN主模型从Qwen3.5-4B(~2.64GiB)降级为Qwen3.5-2B(~1.1GiB),因为4B版本 实测运行过慢,影响用户体验。iPhone17+/SME2设备使用2B模型,保留MLX 兜底方案用于模拟器和备用场景,确保AI推理性能和存储效率的平衡。 ```
10 KiB
康康 · 踩坑与排查记录
本地推理 / SwiftData / 端侧模型这类问题不好复现也不好搜,踩过的坑按统一模板记在这里,方便回查。 新增条目往最上面加(倒序),模板见文末。
2026-06-09 · 生成身体档案报告时,LLM 逐行复读死循环
现象
多轮「身体档案」对话点生成报告后,「## 关键指标」整段陷入死循环:同一行
⚠️ 收缩压 (107 mmHg) 连续重复几十遍,最后被 maxTokens 截断成半行「⚠️ 收缩」。
(本质是小模型 repetition / degeneration loop,不是数据真有几十条。)
根因(确认)
采样器完全没有重复惩罚,叠加低温 → 几乎必然复读。两个后端都有问题:
| 后端 | 位置 | 原配置 | 问题 |
|---|---|---|---|
| MNN(主) | MNNLLMBridge.mm initWithConfigPath 的 set_config |
temperature 0.3, topP 0.85 |
无 penalty |
| MLX(兜底) | LLMSession.swift GenerateParameters |
temperature 0.3, topP 0.85 |
无 repetitionPenalty |
关键细节(读 MNN 源码 transformers/llm/engine/src/):
llmconfig.hpp:mixed_samplers默认{topK, tfs, typical, topP, min_p, temperature}—— 不含penalty;penalty/ngram_factor默认1.0(=全关)。sampler.cppconfigMixed:只会把penalty「移到链首(如果存在)」,不会自动插入。 所以光设"penalty":1.1没用,必须把"penalty"显式写进mixed_samplers。sampler.cppstepPenalty:repetition_penalty对 logits 乘法惩罚;n-gram 命中整段重复时惩罚直接升到max_penalty—— 这正是掐断「整行复读」最有效的开关。
为什么低温反而更糟:temperature 0.3 接近贪心,一旦吐出 收缩压 (107 mmHg)\n,
最高概率的后续就是再吐一遍同样的行,无惩罚就永远出不来。
排查过程(可复用思路)
- 看现象先判定是「数据重复」还是「生成复读」—— 被截断成半行
收缩说明是 token 级复读,不是数据。 grep -niE "penalty|temperature|top_?p|sampler" 康康/AI/一把定位两个后端的采样配置 → 都没 penalty。- 不猜 MNN 配置键,直接读构建用的源码
MNN_SRC=/Users/xuhuayong/apps/MNN-src的llmconfig.hpp/sampler.cpp,确认键名、默认值、mixed_samplers不自动插 penalty。 - MLX 侧读 SPM checkout 的
MLXLMCommon/Evaluate.swift,确认GenerateParameters有repetitionPenalty: Float?+repetitionContextSize: Int。
修复
- MNN
MNNLLMBridge.mm:set_config显式开重复惩罚 + 把penalty放进 mixed 链首:(注意:JSON merge-patch 对数组是整体替换,所以这里会覆盖掉默认{ "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 }mixed_samplers,符合预期。) - MLX
LLMSession.swift:GenerateParameters(..., repetitionPenalty: 1.1, repetitionContextSize: 64)。
取值都偏保守:penalty 1.1 / ngram_factor 1.05 是业界常用档(MNN 自带 omni 默认 1.05),
低温 + 轻惩罚既能掐复读,又不破坏 JSON / 结构化输出的稳定性。
验证
xcodebuild ... -destination generic/platform=iOS编译通过(两个后端均编进)。- ⚠️ 真机/模拟器跑一遍多轮导出生成报告,确认不再复读 —— 复读属推理期行为,单测覆盖不到,必须实跑。
预防 / 相关注意
- 任何新增的「长文本生成」(非 JSON 抽取)都走同一套带惩罚的采样参数,别再裸 temperature。
- 相关隐患(未修,留观):
HealthExportService.retrieveDialogueSnapshot取指标时 没有prefix截断(窗口检索版retrieve截了prefix(20))。指标极多时 prompt 会膨胀、 也更易诱发复读。若复发,优先给 dialogue snapshot 也加上限。
以下几条据 W1–W2(2026-05~06)记忆补记,细节以代码/提交为准。
2026-06-09 · MNN 路径 Qwen3.5 强制思考,只吐 <think> / JSON 解析失败
现象
MNN 真机路径上模型自检只显示 <think> 思考过程,AI 辅助拿不到 JSON(解析失败);
同样的 prompt 走 MLX 兜底却正常。
根因
模型自带 config.json(taobao-mnn 预转换件)写死 "jinja":{"context":{"enable_thinking":true}},
Qwen3.5 聊天模板据此每个 assistant 回合硬塞 <think>\n 开思考,吞掉 token 预算。
prompt 里的 /no_think 对 MNN 无效 —— 模板只读 enable_thinking,不看文本软开关。
只在真机爆是因为 MLX 经 swift-transformers 套模板时不传 enable_thinking → 走 else 空 think 块,天然不思考。
(这点从仓库代码看不出来,config.json 是下载/旁路导入的模型产物,不在 git 里。)
修复
MNNLLMBridge.mm 在 createLLM 后、load() 前 merge-patch 关闭:
set_config("{\"jinja\":{\"context\":{\"enable_thinking\":false}}}")。不改模型文件、不动字节校验。stripThink 保留兜底。
预防
再遇 MNN 只出思考 / JSON 解析失败,先查 config.json 的 enable_thinking,别去调 /no_think 或加大预算。
2026-06-07 · 「记录指标·拍照识别」VL 直读化验单不稳 → 改 Vision OCR + LLM
现象
Qwen-VL 直读密集小字化验单经常返回 {"indicators":[]}(读不出指标)。
根因 / 决策
小模型 VL 对密集中文小字不稳。改链路:DocumentScanner 整页扫描 → Apple Vision OCR(zh-Hans/Hant/en) → Qwen3 LLM 解析(VLPrompts.indicatorsFromText)→ stripThink → parseIndicatorsJSON → 确认页人工校对 → 存。
Vision OCR 是系统框架、100% 本地,不违反隐私红线。
预防
这条路不要改回 VL 直读。VL 仍只用于「体检报告归档」整份解读,两者分开。OCR 行分组偶有错位,靠确认页人工校正兜底。
2026-06-01 · git 全量 push 撞 HTTP 413(历史里有 165MB 构建产物)
现象
git push 到 myv0(Gitea 反代有上传体积限制)报 HTTP 413。
根因
旧 commit 误把 build/ 构建产物提交进库(最大单文件 xcarchive DWARF 165MB),后来虽 git rm --cached + .gitignore,
但对象仍留在历史 → .git 87MB,全量 push 超反代上限。
修复
对主仓库 git filter-repo --path build/ --invert-paths --force 从全历史剥离 → .git 87M→2.9M,不再 413。
注意:① 重写了所有 commit hash(内容不变),旧克隆需重新 clone;② filter-repo 会移除所有 remote,事后须重新 git remote add origin;③ 凭证不写入 .git/config。
预防
build/ 必须在 .gitignore;别把构建产物 / 大二进制提交进库。
2026-05-31 · 快拍 VL 识别时 App 自动退出(jetsam OOM,非崩溃)
现象
iPhone 15 Pro Max 上 VL 识别时 App 直接退出。
根因
不是代码崩溃(catch 只切 warning 屏,Swift 报错不会杀进程),是 OS 内存超限 jetsam kill。三因叠加: ① 无 entitlement(8GB 设备默认单 App 上限 ~3GB,VL ~3GB 常驻冲过);② 从不卸载模型(LLM ~1GB + VL ~3GB 同驻 → 4GB+);③ 没设 MLX cache 上限。
修复
① 新建 康康.entitlements 加 com.apple.developer.kernel.increased-memory-limit=true;
② AIRuntime 加 unloadLLM/unloadVL 做常驻互斥(两大模型永不同时驻留)+ actor 内串行推理闸门(GPU 同一时刻只一个解码/加载);
③ GPU.set(cacheLimit: 256MB),启动调一次。
验证
编译 + 单测通过。⚠️ 真机 OOM 是否真消失仍需 iPhone 15 Pro Max 实测(本机无法跑真机)。
2026-05-30 · 每次重打包 SwiftData 数据被清空
现象
W2 期每次重新打包安装,本地数据全没了。
根因
KangkangApp.swift 里 ModelContainer 创建失败的 catch 块原本直接删 store 文件。
SwiftData 只对纯增量改动自动轻量迁移;一旦 schema 改动超纲(最常见:给已存在 @Model 新增「非可选且无内联默认值」属性)→ 迁移抛错 → 进 catch → 删库。
修复
catch 改为把旧 store(含 -wal/-shm)挪到 Application Support/StoreBackups/<时间戳>/ 再重建,不删除。
预防
给已存在 @Model 加属性一律给可选或内联默认值(如 var x: String = "daily"),才走轻量迁移。正式发布前升级为 VersionedSchema + SchemaMigrationPlan。
(无明确日期)· 编辑 Localizable.xcstrings 炸出上万行噪声 diff
现象
改 Localizable.xcstrings 新增 3 个 key,却产生 ~15000 行 diff。
根因
仓库里该文件是 Xcode 规范格式("key" : { 冒号两侧带空格、2 空格缩进、key 按 Xcode 排序、结尾无换行);
用 python json.dump(indent=2) 重写会把分隔符变成 ": " 且顺序不同 → 几乎每行都 diff。
修复 / 正确做法
基于 HEAD 原始文本做文本插入:把新 key 块按 Xcode 格式( "<key>" : + separators=(',', ' : ') 的 value)拼到 strings 段末尾,保持结尾无换行。不要整文件 json.dump 回写。
附:命令行编译方式(排查时拿真实错误/警告)
- 系统默认是 Command Line Tools,裸
xcodebuild不可用,需显式指向完整 Xcode:export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer - 必须用独立 derivedDataPath(如
-derivedDataPath /tmp/kk-derived-xxx),否则和 Xcode 抢同一把build.db锁报database is locked(不是代码错)。 - 增量编译会吞警告:要看某文件警告先
touch它强制重编,再 greperror:|warning:|BUILD (SUCCEEDED|FAILED)。 - 工程是 Swift 5 +
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;跨到nonisolated调 MainActor 成员的隔离警告(标 "error in Swift 6 mode")在 Swift 5 下不阻塞构建。
模板(复制下面这段新增条目)
## YYYY-MM-DD · 一句话标题
### 现象
(用户看到什么 / 怎么触发)
### 根因(确认)
(定位到的真正原因,不是猜测;贴关键文件:行)
### 排查过程
(怎么一步步定位的,方便下次复用思路)
### 修复
(改了什么,贴 diff 要点或配置)
### 验证
(怎么确认修好了;不能单测的要写明需实跑)
### 预防 / 相关注意
(怎么避免再犯;顺带发现的隐患)