# 康康 · 踩坑与排查记录

> 本地推理 / 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.cpp` `configMixed`:只会把 `penalty`「**移到链首(如果存在)**」,**不会自动插入**。
  所以光设 `"penalty":1.1` 没用,必须把 `"penalty"` 显式写进 `mixed_samplers`。
- `sampler.cpp` `stepPenalty`:`repetition_penalty` 对 logits 乘法惩罚;**n-gram 命中整段重复时惩罚直接升到 `max_penalty`** —— 这正是掐断「整行复读」最有效的开关。

**为什么低温反而更糟**:temperature 0.3 接近贪心,一旦吐出 `收缩压 (107 mmHg)\n`,
最高概率的后续就是再吐一遍同样的行,无惩罚就永远出不来。

### 排查过程(可复用思路)
1. 看现象先判定是「数据重复」还是「生成复读」—— 被截断成半行 `收缩` 说明是 token 级复读,不是数据。
2. `grep -niE "penalty|temperature|top_?p|sampler" 康康/AI/` 一把定位两个后端的采样配置 → 都没 penalty。
3. 不猜 MNN 配置键,直接读构建用的源码 `MNN_SRC=/Users/xuhuayong/apps/MNN-src`
   的 `llmconfig.hpp` / `sampler.cpp`,确认键名、默认值、`mixed_samplers` 不自动插 penalty。
4. MLX 侧读 SPM checkout 的 `MLXLMCommon/Evaluate.swift`,确认 `GenerateParameters` 有
   `repetitionPenalty: Float?` + `repetitionContextSize: Int`。

### 修复
- **MNN** `MNNLLMBridge.mm`:`set_config` 显式开重复惩罚 +
  把 `penalty` 放进 mixed 链首:
  ```jsonc
  {
    "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
  }
  ```
  (注意:JSON merge-patch 对数组是**整体替换**,所以这里会覆盖掉默认 `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` 它强制重编,再 grep `error:|warning:|BUILD (SUCCEEDED|FAILED)`。
- 工程是 Swift 5 + `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor`;跨到 `nonisolated` 调 MainActor 成员的隔离警告(标 "error in Swift 6 mode")在 Swift 5 下不阻塞构建。

---

## 模板(复制下面这段新增条目)

```markdown
## YYYY-MM-DD · 一句话标题

### 现象
（用户看到什么 / 怎么触发）

### 根因(确认)
（定位到的真正原因,不是猜测;贴关键文件:行)

### 排查过程
（怎么一步步定位的,方便下次复用思路)

### 修复
（改了什么,贴 diff 要点或配置)

### 验证
（怎么确认修好了;不能单测的要写明需实跑)

### 预防 / 相关注意
（怎么避免再犯;顺带发现的隐患)
```
