学习目标
读完本章,你应当能够:
- 掌握一套可复用的排查框架:现象复现 → 隔离变量 → 最小对照实验 → 修正 → 回归验证;
- 用工程里真实发生过的多起事故,学会从"用户看到的怪现象"反推"代码里的错";
- 理解为什么这类 bug 常常是"静默失败"(不报错、只输出错乱)而非崩溃;
- 掌握针对"静默失败"的专项对策:不变量 + 断言 + 模拟验证;
- 会用"二分法 "缩小范围,会用"最小对照实验"隔离变量;
- 能独立给工程里另一处可疑代码写一份根因分析报告。
前置 :ch17(模板)、ch18(移窗)、ch08(Flow)
对应源码 / 文档:
BUG_AUDIT_2026-09-10.md(工程内真实审查报告,21 项)
lib/src/main/cpp/ai_chat.cpp(chat_add_and_format/shift_context/decode_tokens_in_batches/unload)
app/.../ui/MainScreenState.kt(sendMessage/translate的错误处理)
app/.../util/ModelImporter.kt(半成品清理)
🧭 读本章前,请先确认前置 :ch17(模板事故)、ch18(位置事故)、ch08(
catch事故)------
本章就是拿这几起真实事故当教材。没读它们也能读,但收获会少一半。需要的基础 :无特殊要求 。这是一章"方法论",更像思维训练。
本章会出现的生词 :最小对照实验、隔离变量、二分法、不变量 、
静默失败、根因分析。部分在 附录 B.9("口头禅"解释)。读法建议 :本章比代码章更该慢读 。
读完后建议做一件事:从BUG_AUDIT_2026-09-10.md里挑一项,自己写一份根因分析
(22.9 给了可套用的模板)------那才是把方法论真正变成能力的动作。
本章导读前面 21 章你学会了"怎么写对 "。但真实开发里,你大部分时间在做另一件事:
搞清楚"为什么错了"。
而且你会发现一个残酷的事实:
越难查的 bug,越不报错。
回顾本书讲过的几个事故,你会发现它们有个共同点:
事故 有异常吗 有崩溃吗 有日志吗 用户看到什么 多轮对话崩坏(ch17) ❌ ❌ ❌ 从第二轮起只输出数字 长对话输出乱(ch18) ❌ ❌ ❌ 聊久了开始胡说 错误提示看不见(ch08) ✅(被吞了) ❌ ✅(但没人看) 永远显示"(本次回复未生成正文)" 没有异常、没有崩溃、没有明显日志 ------
这类问题没法靠"看报错"解决 ,只能靠理解原理 + 系统化排查。
💡 类比:医生诊断
- 庸医:听你说"头疼",直接开止痛药。
- 良医 :先问清症状 (什么时候开始、什么情况下加重),
再做针对性检查 (验血、CT),排除其它可能,最后才下结论。本章教的就是"良医"的思维:
用"缩小范围"代替"猜"。
本章的结构:先给通用框架 (22.1),再给难点分类 (22.2),
然后用五起真实事故 把框架走一遍(22.3--22.7),
最后给你一份可以直接套用的报告模板(22.9)。
22.1 通用框架:五步走
先建立一个直觉。
你写的 App 出了问题------崩溃、输出乱码、速度慢。你怎么找到问题在哪?
新手的做法是"瞎猜 ":改这里试试、改那里试试、重启一下、清个缓存......
改完跑一跑,没好?再换个地方改。效率极低,而且经常越改越糟。
诊断方法论 就是"一套系统的排查步骤":让你不用瞎猜,也能一步步把问题定位到具体的代码行。
💡 类比:医生看病
病人捂着肚子进诊室:"医生,我肚子疼。"
- 庸医听了直接开刀------结果打开一看,肚子里根本不是他以为的那个问题。
- 良医 不会一上来就动手。他会:
- 问症状:什么时候开始疼?吃了什么?疼的位置在哪?(= 复现步骤)
- 量体温、听诊:看看有没有明显异常(= 看日志)
- 抽血、拍片:做针对性检查(= 加调试代码、加断点)
- 排除法:先排除急症,再排除常见病(= 隔离变量)
- 确诊:找到真正的病因(= 找到根因)
- 开药:对症下药(= 修复代码)
程序员查 bug 跟医生看病一模一样 :用户说"App 坏了"(= 病人说"肚子疼"),
你不能直接动手改代码(= 直接开刀),要先问清症状、做检查、排除法 ,
最后才确诊开药。
这个类比在"慢性病 "上不精确------有些 bug 像慢性病,需要长期观察;
但对本章的大多数事故,"急诊室流程"足够用了。
这就是本章的核心,请先记住这五步。
① 复现 能把问题稳定地"演"出来
↓
② 隔离变量 把"可能的原因"一个个排除
↓
③ 最小对照 构造"只差一个因素"的两组,观察差异
↓
④ 修正 改代码,让③的"错误组"变成"正确组"
↓
⑤ 回归验证 确认修好了,且没弄坏别的
22.1.1 第一步:复现
这是什么?
复现 = 找到一组稳定的操作步骤,每次按这组步骤操作,bug 都会出现。
比如:"打开 App → 选 Hy-MT2 → 问第二轮 → 只输出数字"------
每次都这样 ,这就叫"稳定复现"。
如果"有时候出现、有时候不出现",那叫"偶发",还不算复现。
💡 类比:犯罪现场重建
警察破案不能只看"现场一片狼藉",他要重现犯罪过程 :
几点钟、从哪扇门进来、先做了什么后做了什么------
重现得越精确,越能找到线索。
程序员也一样:你要重现 bug 发生的全过程 ,
才能知道"在哪个环节出了问题"。
复现不出过程,就像警察只看到一地碎片却不知道怎么碎的------没法破案。
为什么必须先复现?
| 理由 | 说明 |
|---|---|
| 不能复现 = 不能验证修复 | 你不知道"改完之后它还会不会出现" |
| 复现过程本身就在缩小范围 | "只在第 2 轮出现"、"只在长对话出现"------这些信息极有价值 |
| 能反复观察 | 可以加日志、加断点,随便折腾 |
📝 最小示例:怎么写复现步骤
假设你发现"导入大模型后磁盘空间少了"。一个合格的复现步骤长这样:
① 打开 App
② 导入一个 7GB 的 .gguf 模型
③ 导入进度走到一半时,按"取消"
④ 检查系统设置里的"存储空间"
⑤ 重复步骤 ②--④ 三次
关键在于:每一步都要"可操作、可观察"。
- "导入大模型"不够具体------7GB?1GB?哪个文件?
- "按取消"不够具体------走到 10% 按?50% 按?
- 写得越精确,复现率越高,排查越快。
这个例子对应事故四(22.6),工程里真的这么复现出来的。
✅ 工程真身 (ch17):
"第二轮起只输出数字"
"------稳定复现 (不是偶发),
而且只在 Hy-MT2 模型 + 多轮时出现 。
这两条信息立刻排除了"偶发问题"和"所有模型都有问题"。
📌 复现不出来怎么办?
记录所有 观察到的信息(设备、模型、操作步骤、网络/电量状态),
尝试极端化 (更长、更短、更快、更慢),
或者在代码里加日志 等它下次出现。
"复现不出来"是最大的障碍------值得花大力气。
22.1.2 第二步:隔离变量
这是什么?
隔离变量 = 一次只改变一个条件,其他条件全部保持不变 ,
然后观察:这个条件改了之后,问题还在不在?
- 改了这个条件,问题消失了 → 这个条件就是嫌疑人;
- 改了这个条件,问题还在 → 排除这个条件,换下一个。
💡 类比:对照实验
科学家测试一种新药:一组病人吃药,一组吃"糖丸"(安慰剂),
两组的年龄、性别、病情、饮食......全部一样 ,唯一区别是"吃没吃药"。
最后看哪组康复率高------这样才能确认"是药起了作用",而不是别的因素。
如果两组一个吃真药一个吃安慰剂、还一个在冬天一个在夏天、一个男的多一个女的多,
那康复率不一样,你根本不知道是药的效果还是季节的效果。
变量混在一起,结论就不可信。
核心思路 :把一个大系统切成几段,逐段排除。
常用手段:二分法
问题出现在"整个链路"里
↓ 在链路中间加一个观察点
├─ 前半段输出正常?→ 问题在后半段
└─ 前半段就错了? → 问题在前半段
↓ 继续二分......
直到锁定一小段代码
📝 最小示例:一次只换一个东西
怀疑是"模型文件坏了"导致 App 崩溃:
| 第几次 | 改什么 | 其他条件 | 结果 |
|---|---|---|---|
| 第 1 次 | 换一个模型文件 | 手机、App 版本、操作步骤全不变 | 崩溃消失?→ 是模型文件的问题 |
| 第 2 次 | 换回原模型,换一台手机 | App 版本、操作步骤不变 | 崩溃又出现?→ 不是手机的问题 |
| 第 3 次 | 不换模型不换手机,只换操作步骤 | ------ | 缩小到"哪个操作触发" |
每次只动一个变量 ,其他原样。这样每一步的结论都可信。
如果你"换模型 + 换手机 + 重装 App"一起来,崩溃好了------你根本不知道是哪一步救了它。
✅ 工程真身 (ch17):
问题可能出在"提示词拼接"或"推理循环"或"量化"或"手机"。
怎么快速排除后三者? ------ 用命令行工具喂裸文本(见第三步)。
22.1.3 第三步:最小对照实验
这是什么?
最小对照实验 = 做一个最简单 的实验来验证你的假设。
如果你怀疑"X 导致了 Y",那就构造一个只包含 X、不包含其他干扰 的最小场景,
看 Y 会不会发生。
- 在最小场景里 Y 发生了 → 假设正确,X 就是元凶;
- 在最小场景里 Y 没发生 → 假设错误,换一个假设。
💡 类比:试管实验 vs 工厂反应釜
化学家试新配方,不会直接在工厂的大反应釜里倒一吨原料------
那样万一配方错了,损失巨大,而且反应釜里有上千种原料干扰,
根本看不出是哪一步出了问题。
他会先在试管 里用几毫升试剂 试:
只放 A 和 B,看会不会沉淀。试管里没有杂质、没有干扰,结论干净。
确认试管里 OK,再一步步放大到烧杯、反应釜。
程序员也一样:别在完整 App 里验证假设------
App 里有 UI、有网络、有数据库、有几十个模块在跑,
随便一个干扰都能让你的实验结果失效。
先写一个"只有假设条件"的最小程序,跑通了再回 App 里确认。
这是最有效的武器。
构造两组输入,只让"一个因素"不同,观察输出差异。
📝 最小示例:用最小 App 验证 ABI 假设
假设你怀疑"打包时 ABI 过滤把 x86 的 .so 文件漏掉了,导致 x86 手机崩溃"。
- ❌ 不要:直接在完整 App 里删一个
.so、跑一遍、看崩不崩------
App 太大,崩溃了也不知道是不是这个.so的问题。 - ✅ 要:新建一个空白 App ,只放一行
System.loadLibrary("llama"),
打包成 x86 版本,装到 x86 模拟器上:- 一启动就崩、报
UnsatisfiedLinkError→ 假设正确,ABI 过滤确实漏了; - 正常加载 → 假设错误,问题在别处(可能是加载路径、文件权限等)。
- 一启动就崩、报
空白 App 只有几行代码,排除了所有干扰,结论一秒出。
✅ 工程真身(ch17,✅ 真实做法):
bash
# 对照 A:完整、正确的两轮对话文本
llama.cpp/build/bin/Release/llama-completion.exe -no-cnv -f prompt_ok.txt
# 对照 B:上一轮被"砍掉 24 字节"后的等价上下文
llama.cpp/build/bin/Release/llama-completion.exe -no-cnv -f prompt_bad.txt
结果:A 正常,B 输出数字。
这两组只差"user 消息开头 24 字节",模型行为就天差地别
→ 问题 100% 在提示词拼接。
⚠️ 工具选择有讲究 :必须用
llama-completion(支持-no-cnv),
llama-cli不支持-no-cnv,会退化成交互模式,做不了对照实验。这是本工程踩过的细节。
22.1.4 第四步:修正
先搞清楚一对概念:根因 vs 表象。
这是什么?
- 表象(symptom) = 问题表现出来的现象------用户看到的、程序报错的、日志里写的。
- 根因(root cause) = 导致这个现象的根本原因------代码里真正写错的那一行。
修表象 = 头痛医头脚痛医脚 :看着好了,过几天换个地方又坏。
修根因 = 釜底抽薪:问题从根上消失,不会再回来。
💡 类比:发烧 vs 感染
病人 39°C 高烧。
- 发烧是表象(症状)------体温计上的数字。
- 感染是根因(病因)------细菌在身体里繁殖。
吃退烧药(= 修表象):体温降到 37°C,病人感觉好了,
但细菌还在繁殖------几小时后又烧起来,甚至烧得更高。
吃抗生素(= 修根因):把细菌杀掉,烧自然退了,而且不会反复。
新手查 bug 最爱"吃退烧药":崩溃了就加个
try-catch把异常吞掉,界面不红了------但导致崩溃的那个空指针还在,
下次换个输入条件,照样崩。
📝 最小示例
以事故五(22.7,double free)为例:
| 做法 | 效果 | |
|---|---|---|
| 修表象 | 加 try-catch 吞掉 native 崩溃 / 屏蔽堆破坏警告 |
✅ 界面不红了;❌ 崩溃下次还来 |
| 修根因 | llama_batch_free(g_batch) 后立刻 g_batch = llama_batch{} 置空 |
✅ 第二次 unload 释放的是空,安全 |
判据 :修完之后,原来的触发条件再走一遍,问题还会不会出现?
会 → 你修的是表象;不会 → 你修的是根因。
✅ 工程真身
本工程的五个事故,全部是"修根因":
- 事故一:从"按长度切片"改成"前缀比较"(修拼接逻辑,不是把输出数字改成正常文字);
- 事故二:三处位置同步(修位置推进算法,不是把越界 position 钳回 8191);
- 事故三:
failed标志位 + 提前 return(修控制流,不是把占位符文字改长一点); - 事故四:
catch里deletePartialFile(修异常路径清理,不是在 UI 上把残留文件藏起来); - 事故五:释放后置空(修所有权,不是屏蔽崩溃日志)。
有了根因,修正通常是最轻松的一步 ------
因为你知道"要改成什么"。
但要注意:
| 要点 | 说明 |
|---|---|
| 改根因,不是改现象 | 比如"把输出数字改成输出别的"是治标 |
| 保留证据 | 在注释里写清"原来错在哪、为什么"(本工程做得极好) |
| 考虑副作用 | 改动会不会影响别的路径? |
本工程的例子 (ch17):从"按长度切片"改成"前缀比较"。
注释里完整记录了原来错在哪 (✅ ai_chat.cpp:465-484,20 行注释)。
22.1.5 第五步:回归验证
"修好了"要有证据,不能凭感觉。
| 验证方式 | 例子 |
|---|---|
| 复现场景不再出现 | 再测一遍"第二轮",正常了 |
| 换环境再测 | 换个模型也正常(ch17 练习 5) |
| 没弄坏别的 | 原来的功能还正常 |
| 加不变量/测试防回归 | 见 22.8.3 |
本工程的例子 (ch18):用位置一致性模拟 验证------
修复前写到 position 8999(越界)、修复后最大 4999(连续)。
💡 回归验证的深层意义 :
它把"这次修好了"变成"以后不容易再坏 "。
如果不做回归验证,同一个 bug 可能改三次。
22.2 为什么这类 bug 难查:静默失败的分类
把"难查"的原因分个类,你就知道该用什么对策。
22.2.1 类型一:完全不报错
特征 :没有异常、没有日志、程序继续跑。
例子:
- ch18 位置错乱(
llama_decode不检查位置合理性); - ch17 增量错位(只是拼错了字符串,不报错)。
对策:
- 理解原理(知道"位置必须连续唯一"、"前缀必须匹配");
- 写不变量 + 断言(22.8.3);
- 纯逻辑模拟(不需要跑真机)。
22.2.2 类型二:错误被"降级"或"覆盖"
特征 :本来有错误信息,但被后来的操作覆盖了。
例子 (ch08 + BUG_AUDIT 第 1 项):
.catch 里写了错误提示,但不重抛 → flow 视为正常完成 →
后面的收尾代码用空 buffer 覆盖了错误信息。
对策:
- 检查"失败路径后续代码是否还在跑";
- 用标志位 + 提前返回(本工程的修复);
- 或者让错误往上抛,在外层统一处理。
22.2.3 类型三:只在特定条件下出现
特征 :日常测试发现不了。
例子:
- ch17 只在第 2 轮及以后;
- ch18 只在上下文顶满(8192)时;
- ch10 只在导入中途取消时。
对策:
- 把"边界条件"加入测试清单(多轮/长/中断/异常);
- 记住通用规律:"第一次"和"第二次"往往走不同代码路径(ch17 5.5)。
22.2.4 类型四:现象与根因距离很远
特征 :现象在 A 处,根因在 Z 处。
例子 (ch14):
DEFAULT_SKIP_KEYS 里多放了 tokenizer.chat_template →
TokenizerInfo.chatTemplate 永远 null →
(如果去用它)多轮对话出问题。
"跳过列表多一项" 和 "聊天功能异常" 看起来毫不相关。
对策:
- 顺着数据流走(从输入到输出,逐环节检查);
- 用二分法(在中间加观察点)。
22.2.5 分类对照表
| 类型 | 特征 | 首选对策 |
|---|---|---|
| 完全不报错 | 静默 | 原理 + 不变量 + 模拟 |
| 错误被覆盖 | 有信息但看不见 | 检查失败路径的后续代码 |
| 条件苛刻 | 难复现 | 边界条件入测试清单 |
| 距离远 | 现象≠根因 | 数据流 + 二分法 |
22.3 事故一:第二轮起只输出数字(ch17 的方法论复盘)
现在我们用五步框架完整走一遍这个事故。
📋 排查框架速查(本事故七步)
步 做了什么 ① 现象 Hy-MT2 模型,第二轮起只输出孤立数字(10/1/2/3) ② 复现 问第一轮正常 → 问第二轮必现 → 稳定复现 ③ 隔离变量 换命令行工具喂裸文本:复现 → 排除 App/模型/CPU;正常 → 锁定 App ④ 最小对照 prompt_ok.txtvsprompt_bad.txt(只差 user 消息开头 24 字节):A 正常 B 输出数字⑤ 根因 "按长度切片"求增量,假设"上一轮渲染是本轮前缀",Hy-MT2 模板结尾分叉 → 偏 24 字节 ⑥ 修复 改成"前缀比较",不依赖模板行为 ⑦ 回归验证 第二轮✅ 多轮✅ 换 Qwen✅ 单轮✅
① 复现
| 项 | 内容 |
|---|---|
| 现象 | Hy-MT2 模型下,第二轮起只输出孤立数字(10 / 1 / 2 / 3) |
| 可复现性 | 稳定(每次都这样) |
| 边界条件 | 只在第 2 轮及以后;第 1 轮正常 |
"只在第 2 轮"这个信息已经非常有价值 :
它暗示"问题与历史有关"。
② 隔离变量
可能的原因:
- 模型文件坏了?
- 量化有问题?
- 手机 CPU 不行?
- 提示词拼接错了?
- 推理循环错了?
怎么排除 1--3?
换一个简单工具 :用 llama-completion 直接喂文本------
如果命令行工具也复现,说明不是 App 的问题 ;
如果命令行正常,说明是 App 的代码问题。
③ 最小对照实验
对照组 A:正确、完整的两轮对话 prompt
对照组 B:模拟"被砍 24 字节"后的 prompt
结果:A 正常,B 输出数字。
结论 :问题在提示词拼接,与模型/量化/CPU 无关。
进一步 :读代码发现用的是"按长度切片"求增量(ch17.5.3)------
而这个做法隐含假设"上一轮渲染结果是本轮的前缀"。
Hy-MT2 的模板结尾分叉,假设不成立 → 偏移 24 字节。
📝 注意这一步的"两次二分" :
第一次二分:App vs 命令行 (排除环境/模型);
第二次二分:代码里的哪一段 (定位到
chat_add_and_format)。
④ 修正
改成"前缀比较"(ch17.6):
cpp
} else if (target.size() >= g_kv_text.size() &&
target.compare(0, g_kv_text.size(), g_kv_text) == 0) {
formatted = target.substr(g_kv_text.size());
} else {
reset_kv_cache_only();
formatted = target;
}
关键 :不依赖模板行为,只依赖"我们亲自维护的 g_kv_text"。
⑤ 回归验证
| 验证 | 结果 |
|---|---|
| 第 2 轮再测 | ✅ 正常 |
| 连续多轮 | ✅ 正常 |
| 换个模型(如 Qwen) | ✅ 正常(因为前缀比较与模板无关) |
| 旧功能(单轮) | ✅ 没坏 |
💡 这个事故的方法论价值:
- "只在第 2 轮"提示了方向(与历史相关);
- 命令行工具帮我们排除了"环境问题";
- 对照实验把范围缩到"提示词";
- 修复方案"不依赖具体模板"------所以对新模型也有效。
22.4 事故二:长对话输出崩坏(ch18 的方法论复盘)
📋 排查框架速查
步 做了什么 ① 现象 聊久了输出变乱、胡说 ② 复现 上下文聊到 ≥ 8192 必现 ③ 隔离变量 无法做输入对照 → 改用纯逻辑模拟(不加载模型、不跑真机) ④ 最小对照 同一输入(8000+1000)跑两种实现:修复前 position 最大 8999、有空洞;修复后 4999、连续 ⑤ 根因 移窗后 start_pos没跟着前移,仍按旧位置写⑥ 修复 三处同步:返回 n_discard、stop_generation_position 前移、decode 自推进位置 ⑦ 回归验证 位置模拟连续且 ≤8192✅ 真机长对话✅ 短对话✅
① 复现
| 项 | 内容 |
|---|---|
| 现象 | 聊久了输出变乱、胡说 |
| 可复现性 | 稳定(但需要聊到上下文顶满) |
| 边界条件 | 只在上下文 ≥ 8192 时 |
"只在长对话" 立刻指向 移窗(ch18)。
② 隔离变量
这里有个难点 :这个 bug 没法用"最小对照实验" ------
因为它是位置管理问题,不像 ch17 那样可以抽出"两段 prompt"对比。
改用"纯逻辑模拟"(ch18.4.3):
写一段不依赖 Android、不加载模型的脚本,模拟"位置怎么推进":
python
# 📝 伪代码(对应 .workbuddy/verify_p02.py 的思路)
current_position = 8000 # 已有 8000 token
start_pos = current_position # ❌ 原实现记下"移窗前"的位置
# 要写 1000 个 token
for i in range(0, 1000, 512):
batch = min(512, 1000 - i)
if current_position + batch >= 8192 - 4:
n_discard = (current_position - 0) // 2
current_position -= n_discard # 移窗
# ❌ 原实现用 start_pos 写
write_positions = [start_pos + i + j for j in range(batch)]
start_pos += batch
模拟结果(✅ 本工程实测):
| 修复前 | 修复后 | |
|---|---|---|
| 最大写入 position | 8999(越过 8192!) | 4999 |
| 位置是否连续 | ❌ 有空洞 | ✅ 连续 |
③ "最小对照实验"的替代品
当无法构造"两组输入"时,用"两组实现"对照:
| 修复前算法 | 修复后算法 | |
|---|---|---|
| 输入 | 相同(8000 + 1000) | 相同 |
| 输出 | 最大 position 8999、有空洞 | 最大 4999、连续 |
"同一输入、两种实现、对比输出" ------这同样是一种最小对照。
④ 修正
三处同步 (ch18.6):返回 n_discard、stop_generation_position 一起前移、
decode_tokens_in_batches 自推进位置。
⑤ 回归验证
- 位置模拟:修复后连续且 ≤ 8192 ✅
- 真机长对话:不再崩坏 ✅
- 短对话:没受影响 ✅
💡 这个事故的方法论价值 :
不是所有 bug 都能做"输入对照"。
对于"内部状态管理"类的 bug,"算法对照 + 状态推演"是替代方案 ------
而且它不需要跑真机,效率极高。
22.5 事故三:错误提示看不见(ch08 + BUG_AUDIT 第 1 项)
📋 排查框架速查
步 做了什么 ① 现象 生成失败时,界面只显示「(本次回复未生成正文)」 ② 复现 让生成失败(如 check 失败 / 切模型)必现 ③ 隔离变量 在 replaceMessage加日志:同一 messageId 被写了两次④ 最小对照 对比两次入参: .catch里写了正确错误信息,收尾代码用空 buffer 覆盖⑤ 根因 Flow.catch{}不重抛 → flow 视为正常完成 → 收尾代码照常跑 → 空 buffer 覆盖错误⑥ 修复 failed标志位 + 提前 return,不覆盖已写入的错误⑦ 回归验证 制造失败 → 看到真正错误;正常生成 → 不受影响
① 复现
| 项 | 内容 |
|---|---|
| 现象 | 生成失败时,界面只显示「(本次回复未生成正文)」 |
| 可复现性 | 稳定(只要让生成失败) |
| 边界条件 | 任何"上游失败"的情况 |
"看不到错误" 这个现象本身就很有信息量------
它说明代码执行到了"写占位符"那一步。
② 隔离变量(用 BUG_AUDIT 的原文)
✅ 取自 BUG_AUDIT_2026-09-10.md 第 1 项
**代码结构**
engine.sendUserPrompt(input)
.catch { throwable -> // ← 捕获后【不重抛】
withContext(Dispatchers.Main) {
replaceMessage(assistantMsgId, "抱歉,生成响应时出错: ${throwable.message}")
}
}
.collect { token -> /* 追加到 buffer */ }
// ↓↓ 异常时这里仍会执行,且 buffer 是空的 ↓↓
val finalVisible = ThinkStripper.strip(buffer.toString())
withContext(Dispatchers.Main) {
replaceMessage(assistantMsgId, finalVisible.ifBlank { EMPTY_REPLY_PLACEHOLDER })
}
根因(✅ 原文):
Flow.catch {}的 action 不重抛异常时,flow 视为正常完成 ,collect之后的代码会照常执行。此时
buffer为空 →finalVisible为空 → 用
"(本次回复未生成正文)"覆盖掉刚刚写入的错误信息。
③ "对照实验"怎么设计
这个事故的对照很巧妙 ------用日志观察"两次 replaceMessage 的值":
| 时刻 | replaceMessage 的参数 |
|---|---|
.catch 里 |
"抱歉,生成响应时出错: xxx" ← 正确的错误信息 |
| 收尾代码里 | "" → 变成 "(本次回复未生成正文)" ← 覆盖了上一条 |
在 replaceMessage 里加一行日志 ,就能看到"同一个 messageId 被写了两次,第二次覆盖了第一次"。
💡 这是一种很实用的对照形式:
"同一个操作被调用了多次,后一次覆盖前一次" ------
加日志打印每次的入参,差异立刻可见。
④ 修正
✅ 取自上引报告:
kotlin
var failed = false
engine.sendUserPrompt(input)
.catch { e -> failed = true; /* 原错误处理 */ }
.collect { ... }
if (failed) return@launch // 不要覆盖已写入的错误信息
两个手段 :标志位 + 提前返回。
⑤ 回归验证
- 制造一次失败(比如
processUserPrompt返回非 0)→ 应该看到真正的错误信息; - 正常生成 → 不受影响(
failed为 false,走原来的收尾)。
💡 这个事故的方法论价值 :
它揭示了**"catch 不重抛"这个语义陷阱**------
"我 catch 了"不等于"我处理完了" 。
你必须明确决定:继续往下走,还是终止。
22.6 事故四:导入被取消,残留 GB 级半成品(ch10 复盘)
📋 排查框架速查
步 做了什么 ① 现象 磁盘空间莫名变少,有看不到、删不掉的 .gguf ② 复现 导入 7GB 模型中途按取消 → files/ 多一个半成品 ③ 隔离变量 adb shell ls看 files/:导入成功正常、中途取消多残留、正常使用无影响④ 最小对照 A 导入成功 vs B 导入中途退出:差异只在"是否中途退出" ⑤ 根因 拷贝中断(IO 错误/协程取消)时,半成品 .gguf 不属于任何 ModelManager 记录,UI 看不到也删不掉 ⑥ 修复 catch里deletePartialFile(target)再重抛⑦ 回归验证 再测中途退出无残留✅ 日志有 cancelled✅ 正常导入不受影响✅
① 复现
| 项 | 内容 |
|---|---|
| 现象 | 磁盘空间莫名变少;看不到、删不掉的 .gguf |
| 可复现性 | 稳定(导入大模型时中途按返回) |
| 边界条件 | 拷贝中途被取消/失败 |
② 隔离变量
观察点 :直接看 filesDir 里有什么。
bash
adb shell run-as work.ai4easy.offlineai ls -la /data/data/work.ai4easy.offlineai/files/
对比三种操作:
- 导入成功 → 有完整的
.gguf; - 导入中途取消 → 多了一个"半成品"文件 ← 问题在这里;
- 正常使用 → 不受影响。
③ 最小对照实验("做/不做某操作"对照)
| 操作 | 结果 | |
|---|---|---|
| A | 导入成功后退出 | 文件正常,记录正常 |
| B | 导入中途退出 | 残留半成品,且 UI 看不到 |
差异只在"是否中途退出" → 问题在异常/取消路径的清理。
④ 修正
✅ 取自本工程 ------ ModelImporter.kt:180-186
cpp
} catch (e: Throwable) {
// 拷贝中断(IO 错误 / 协程取消)会在 filesDir 留下一个半成品 .gguf:
// 它不属于任何 ModelManager 记录,UI 看不到也删不掉,会永久占用 GB 级空间。
// 因此失败即回收,再原样抛出(CancellationException 必须重抛,不能吞)。
deletePartialFile(target);
throw e;
}
加上"清理 + 重抛" ,并单独写了 deletePartialFile(失败只记日志,不覆盖原始异常)。
⑤ 回归验证
- 再测"中途退出" →
files/里没有残留; - 日志里能看到
Model import cancelled; - 正常导入不受影响。
💡 这个事故的方法论价值 :
"异常路径"是最容易被忽略的代码路径。
写"正常流程"时人很仔细,写"失败分支"时常敷衍了事------
而失败分支恰恰是最容易泄漏资源的地方。
22.7 事故五:unload 后 double free(ch15 复盘)
📋 排查框架速查
步 做了什么 ① 现象 随机崩溃(无 Java 堆栈)或堆破坏警告 ② 复现 难复现,但定位到" unload被调两次"的调用序列③ 隔离变量 读 destroy():异常态走unload(); shutdown();→ 二次 unload④ 最小对照 调用序列推演:load→unload 正常;load→unload→unload 修复前 double free、修复后 no-op ⑤ 根因 g_batch是结构体,free 的是内部指针成员,结构体本身还在 → 第二次 free 同一地址⑥ 修复 llama_batch_free(g_batch); g_batch = llama_batch{};释放后置空⑦ 回归验证 推演第二次 unload 不释放✅ 真机反复切模型不崩✅
💡 "double free / use-after-free" 是什么?(0 基础版解释)
C/C++ 里你手动
free(指针)就是"把这块内存还给系统"。double free = 同一块内存还了两次------第二次还的时候,系统已经把它发给别人了,
你这一还就把别人正在用的内存弄坏了,程序通常会随机崩溃 (没有清晰报错)。
use-after-free = 内存已经还了,你还拿着旧指针去用------相当于把已经退租的房间又拿来住。
类比:你把一本书还回图书馆,又拿同一个书号去"还第二次"------
图书馆已经把这本书借给别人了,你的第二次操作会把别人手里的书弄坏。
这就是为什么修复方法是"还完书就把书号擦掉(指针置空)"。
① 复现
| 项 | 内容 |
|---|---|
| 现象 | 随机崩溃(无 Java 堆栈),或堆破坏警告 |
| 可复现性 | 难(只在特定调用序列下出现) |
| 边界条件 | unload 被调用两次时 |
② 隔离变量
为什么 unload 会被调两次?
读 Kotlin 侧 destroy()(✅ InferenceEngineImpl.kt:327-331):
kotlin
when(_state.value) {
is InferenceEngine.State.Uninitialized -> {}
is InferenceEngine.State.Initialized -> shutdown()
else -> { unload(); shutdown() }
}
注释说明 (✅ ai_chat.cpp:861-862):
Kotlin 侧
destroy()在异常态会走unload(); shutdown();,若
g_batch仍保留已释放的结构,二次llama_batch_free就是 double free。
③ "对照实验":状态推演
无法做输入对照 (这是内部状态问题),改用调用序列推演:
| 序列 | 期望 | 修复前 | 修复后 |
|---|---|---|---|
正常流程:load → unload |
释放一次 | ✅ 正常 | ✅ 正常 |
异常流程:load → unload → unload |
第二次应是 no-op | ❌ double free | ✅ 安全(已置空) |
关键洞察 :g_batch 是结构体 ,free 的是它内部的指针成员,
结构体本身(及其中的旧地址)还在 → 第二次 free 就是对同一地址再释放。
④ 修正
cpp
llama_batch_free(g_batch);
g_batch = llama_batch{}; // 已释放,清零避免二次 free
核心原则 :释放后立刻把句柄置空/置无效 ------
这样"重复释放"就变成"释放 null"(安全的 no-op)。
⑤ 回归验证
- 推演调用序列:第二次
unload不再释放任何东西; - 真机反复切模型:不再随机崩溃。
💡 这个事故的方法论价值 :
"释放后置空"是一条通用纪律 ,本工程在三处用到(ch10
cleanupScope.cancel()后、ch12
pendingFlush = null、ch15 所有 native 指针置nullptr)。记住它,就能避开一整类 double free / use-after-free。
22.8 工具箱:六个实用手段
把前面用到的技巧归纳成"工具箱"。
22.8.1 手段一:日志(有讲究)
不只是"打日志",而是"在关键决策点打日志"。
| 打什么 | 例子 |
|---|---|
| 入参 | LOGi("setSamplingParamsNative: temp=%f ...", temp, ...) |
| 分支选择 | LOGi("Discarding %d tokens")(走进了移窗分支) |
| 状态迁移 | _state.value = State.Generating 前后 |
| 异常路径 | catch 里打印 throwable |
关键原则:
在"可能出错的决策点"打日志,而不是"到处乱打"。
本工程的
LOGw/LOGe都打在了"异常或降级"的地方------看日志就能知道"代码走了哪条路"。
22.8.2 手段二:最小对照实验
要点:
| 要点 | 说明 |
|---|---|
| 只差一个变量 | 其余全一样,否则无法归因 |
| 用最简单的工具 | 能用命令行就别用 App(少一层变量) |
| 注意工具能力 | llama-completion 支持 -no-cnv,llama-cli 不支持 |
22.8.3 手段三:不变量 + 断言("静默失败"的专项对策)
这是本章最有价值的一招。
为什么需要它?
因为"静默失败"类 bug 没法靠运行时排查 ------它不报错。
所以你只能把"正确性要求"显式写出来,让它自己喊。
做法:找出代码"永远该成立的性质",写成断言:
cpp
#ifdef DEBUG
// 不变量:位置不能超过上下文容量
if (current_position > DEFAULT_CONTEXT_SIZE) {
LOGe("INVARIANT: current_position=%d > n_ctx=%d",
(int) current_position, DEFAULT_CONTEXT_SIZE);
}
// 不变量:系统提示是 KV 前缀,位置不该小于它
if (current_position < system_prompt_position) {
LOGe("INVARIANT: current_position < system_prompt_position");
}
#endif
好处:
- 用
#ifdef DEBUG包住 → release 无开销; - 开发期每次运行都在检查 → 回归立刻被发现。
本工程这类"隐式不变量"举例:
| 不变量 | 出处 |
|---|---|
current_position ≤ n_ctx |
ch18 |
| 位置连续无空洞 | ch16/ch18 |
stop_generation_position ≥ current_position |
ch18.6.2 |
g_batch.n_tokens ≤ BATCH_SIZE |
ch15 |
每轮 messages 的 role 交替 |
ch16(repair_missing_assistant) |
22.8.4 手段四:纯逻辑模拟
不依赖真机、不加载模型,只模拟算法逻辑。
本工程的例子 (ch18):用脚本模拟"位置怎么推进",
在开发阶段就发现了"写到 8999 越界"。
好处:快、可复现、可对比、可在 CI 跑。
22.8.5 手段五:二分法
在链路的中间加观察点,判断"问题在前半段还是后半段"。
本工程的例子(ch17):
- 第一次二分:App vs 命令行 → 排除环境/模型;
- 第二次二分:拼接 vs 推理循环 → 定位到
chat_add_and_format。
22.8.6 手段六:系统性审查(BUG_AUDIT 的做法)
本工程有一份真实的审查报告 (BUG_AUDIT_2026-09-10.md,21 项)。
它的审查方式是(✅ 报告开头原文):
审查方式:逐文件通读 + 对可疑点做静态推演;关键结论标注了可复现的触发条件。
三个特点值得学:
- 逐文件通读(不是随机看);
- 静态推演(不跑代码,靠推理);
- 标注触发条件("什么情况下会触发"------这决定了优先级)。
报告还做了分级(P0/P1/P2):
| 级别 | 含义 |
|---|---|
| P0 | 确定性缺陷,建议尽快修 |
| P1 | 有明确影响,但需要特定条件 |
| P2 | 代码卫生/健壮性 |
分级的意义:让人知道"先修哪个"。
💡 这份报告本身就是"可交付物" ------
它把"散落的隐患"变成"可追踪的清单"(还带修复状态表)。
建议你也为自己的项目定期做一次。
22.9 怎么给"可疑代码"写根因分析
这是本章的实操部分。 给你一份可以直接套用的模板。
22.9.1 报告模板
markdown
### <编号>. <一句话描述现象>
**位置**
- <文件:行号>(函数名)
**现象**
<用户看到什么 / 什么情况下出现 / 能否稳定复现>
**代码结构**
```<语言>
<贴出关键代码,标注问题点>
根因
<为什么会这样。要能解释"现象">
触发条件
<什么情况下会触发。要具体>
影响
<后果有多严重>
修复
<怎么改。给代码>
验证
<怎么确认修好了>
### 22.9.2 完整示例(照 `BUG_AUDIT` 的样式写一个)
**题目**:给 ch19 讲过的"设置项改了当次不生效"写一份根因分析。
```markdown
### N. 采样参数改动当次不生效,重启后才生效
**位置**
- `ui/MainScreenState.kt:380-382`(`observeSettings` 的 collect 注册)
- `ui/MainScreenState.kt:413-415`(`applySamplingParams`)
**现象**
设置页拖动 temperature 滑块后立即发消息,生成的随机性与改之前一致;
但重启 App 后再发消息,新参数生效了。
**代码结构**
```kotlin
// ① 状态层写 DataStore(这一步没问题)
fun changeTemperature(v: Float) { scope.launch { appSettings.setTemperature(v) } }
// ② 推送引擎(如果这一行没注册,就会出现本问题)
settingsJobs.add(scope.launch {
appSettings.temperature.collect { temperature = it; applySamplingParams() }
})
根因
采样参数从 UI 到 native 要经过六环(ch19):
UI → 接线 → 状态 → 落盘 → 推送 → native。
第 ⑤ 环(collect + applySamplingParams)缺失或未生效时:
- 值已写入 DataStore(所以重启能读到 → 重启生效);
- 但没人通知引擎 (所以当次用的是旧的
g_sampler)。
触发条件
- 第 ⑤ 环的
collect未注册; - 或注册在已被取消的作用域里;
- 或
applySamplingParams内launch的作用域失效。
影响
用户改动看似无效,容易误判为"功能坏了"或"引擎有问题"。
修复
确保第 ⑤ 环注册在 viewModelScope 的生命周期内,且不被过早取消:
kotlin
settingsJobs.add(scope.launch { appSettings.temperature.collect { temperature = it; applySamplingParams() } })
并在 onCleared 里统一 settingsJobs.forEach { it.cancel() }。
验证
① 加日志:拖动滑块后,logcat 应同时出现"状态层收到新值"与"native 收到参数"两条;
② 不重启,直接发消息 ------ 生成随机性应立刻变化;
③ 重启后 ------ 也应保持新值(持久化生效)。
### 22.9.3 写报告的三条心法
| 心法 | 说明 |
|---|---|
| **"现象"要写得能复现** | 别写"有时候会崩",写"在 A 条件下、做 B 操作、稳定出现" |
| **"根因"要能解释"现象"** | 如果解释不了,说明还没找到根因 |
| **"验证"要可执行** | 别写"应该好了",写"做什么操作、看到什么算好" |
> 💡 **第三条最容易被忽略**:
> 很多人写报告写到"修复方案"就结束了,**没写怎么验证**。
> 结果下次回归时**没法确认问题是否真的解决**。
---
## 22.10 常见错误与排查
**这一节反过来讲:诊断过程中你自己容易犯的错。**
| 常见错误 | 为什么错 | 正确做法 |
|---|---|---|
| **不先复现就开始猜** | 猜出来的"根因"没法验证 | 先想办法稳定复现 |
| **一次改多个地方** | 不知道哪一个改动起了作用 | **一次只改一个变量** |
| **只改现象不改根因** | 问题会以别的形式回来 | 找到并修根因 |
| **不看日志就下结论** | 可能走错了分支 | 先看日志确认"代码走了哪条路" |
| **相信"我觉得"** | 直觉经常错 | **用对照实验验证假设** |
| **修完不做回归** | 可能弄坏别的 | 回归验证 + 加不变量 |
| **忽略异常路径** | 异常路径最容易泄漏/出错 | 专门测"中途取消/失败" |
| **现象与根因混为一谈** | 会修错地方 | 先描述现象,再推根因 |
| **不做二分,直接通读全部代码** | 浪费时间 | 在链路中间加观察点,二分缩小 |
| **报告不写触发条件** | 无法判断优先级 | 写清"什么条件下触发" |
---
## 22.11 动手验证:把三个事故各复现一次
### 22.11.1 复现事故一(第二轮输出数字)
ch17 给过方法:
1. 临时把 `ai_chat.cpp:503-505` 的前缀比较改成 `formatted = target.substr(g_kv_text.size());`(去掉检查);
2. 重新构建,用 Hy-MT2 问第 2 轮;
3. 观察"只输出数字";
4. 改回来。
### 22.11.2 推演事故二(位置错乱)
ch18 给过方法:跑 `.workbuddy/verify_p02.py`(位置一致性模拟),
对比"修复前 8999"与"修复后 4999"。
**或者自己写一段**:模拟"记下移窗前 start_pos、移窗后仍用它写",
打印写入的位置序列,观察空洞。
### 22.11.3 复现事故三(错误提示被覆盖)
ch08 给过方法:
1. 让 `sendUserPrompt` 的 `check(...)` 失败(比如生成中途切换模型);
2. 观察界面显示的是"(本次回复未生成正文)"而不是真正的错误;
3. 在 `replaceMessage` 里加日志,看到**同一个 messageId 被写两次**。
### 22.11.4 给一个"可疑代码"写根因分析
**从 `BUG_AUDIT_2026-09-10.md` 里选一项**(除已修复的),
按 22.9.1 的模板写一份报告,**包含"验证"章节**。
> 📝 **建议选 P1 里的一项**(比如"`MainActivity.AppSettings` 未 remember"),
> 因为它的根因和后果都比较好理解,适合练习。
---
## 22.12 小结
| 概念 | 一句话 | 工程示例 |
|---|---|---|
| 五步框架 | 复现 → 隔离 → 对照 → 修正 → 回归 | 22.1 |
| **复现** | 不能复现 = 不能验证修复 | ch17 稳定复现 |
| **隔离变量** | 二分法缩小范围 | App vs 命令行 |
| **最小对照** | 只差一个变量,对比输出 | `prompt_ok` vs `prompt_bad` |
| **修正** | 改根因不是改现象,并在注释留证据 | `:465-484` 的 20 行注释 |
| **回归验证** | 有证据 + 换环境 + 防回归 | 位置模拟 8999/4999 |
| 静默失败四类 | 不报错 / 被覆盖 / 条件苛刻 / 距离远 | 22.2 |
| 事故一 | 第二轮输出数字(前缀切片) | ch17 |
| 事故二 | 长对话崩坏(位置错乱) | ch18 |
| 事故三 | 错误提示被覆盖(catch 不重抛) | ch08 + `BUG_AUDIT` 1 |
| 事故四 | 导入残留半成品 | ch10 |
| 事故五 | double free(释放后没置空) | ch15 |
| 工具①日志 | 打在"决策点" | `LOGw`/`LOGe` |
| 工具②对照实验 | 只差一个变量 | 命令行裸文本 |
| **工具③不变量+断言** | 静默失败的专项对策 | `current_position ≤ n_ctx` |
| 工具④纯逻辑模拟 | 不跑真机,快且可复现 | `verify_p02.py` |
| 工具⑤二分 | 中间加观察点 | 22.8.5 |
| 工具⑥系统审查 | 逐文件通读 + 分级 | `BUG_AUDIT` 21 项 |
| 报告模板 | 现象/位置/根因/触发/影响/修复/验证 | 22.9.1 |
| 三条心法 | 现象可复现 / 根因能解释现象 / 验证可执行 | 22.9.3 |
---
## 22.13 本章你学会了什么
逐条自测。**任何一条打不了勾,回到对应小节再看一遍。**
- [ ] 我能背出五步排查框架。
- [ ] 我能解释为什么"必须先复现"。
- [ ] 我会用二分法缩小范围。
- [ ] 我能设计一个最小对照实验(只差一个变量)。
- [ ] 我理解"修正要改根因,并在注释里留证据"。
- [ ] 我知道回归验证该做什么。
- [ ] 我能说出"静默失败"的四种类型及各自对策。
- [ ] 我能完整复盘事故一(现象→根因→修复→验证)。
- [ ] 我能完整复盘事故二,并知道它为什么要用"算法对照"而非"输入对照"。
- [ ] 我能完整复盘事故三,并说清"catch 不重抛"的语义陷阱。
- [ ] 我能说清事故四、五的根因与修复。
- [ ] 我知道日志该打在"决策点"而不是到处乱打。
- [ ] **我理解"不变量 + 断言"为什么是静默失败的专项对策。**
- [ ] 我能说出至少 4 个本工程应该成立的不变量。
- [ ] 我会用 22.9.1 的模板写一份根因分析。
- [ ] 我能说出写报告的三条心法。
---
## 22.14 练习
### 练习 1:设计一个"最小对照实验"
假设你怀疑"某条消息没有被正确发送给模型"。请设计一个最小对照实验。
<details>
<summary>解题思路提示</summary>
回看 ch17 的做法:**抽出两段文本,只差一个因素,用最简单的工具跑**。
你的"变量"是什么?用什么工具排除 App 这一层?
</details>
<details>
<summary>参考答案要点</summary>
**实验设计**:
**① 确定"变量"**
"某条消息有没有被包含进 prompt"------所以变量是"**prompt 里有没有那条消息**"。
**② 抓出实际 prompt**
在 `chat_add_and_format` 里已经有日志(✅ `ai_chat.cpp:515-516`):
ai-chat: Formatted and added user message (+N chars): <内容>
把 App 实际渲染出的 prompt **完整打印出来**(可以临时加一行 `LOGi` 打印 `target`)。
**③ 构造两组**
- **A 组**:App 实际发出的 prompt(原样);
- **B 组**:你手工构造的"正确 prompt"(确认包含那条消息)。
**④ 用最简单的工具跑**(排除 App 层):
```bash
llama.cpp/build/bin/Release/llama-completion.exe -no-cnv -f prompt_A.txt -n 120 --temp 0.3 --top-k 40 --top-p 0.9
llama.cpp/build/bin/Release/llama-completion.exe -no-cnv -f prompt_B.txt -n 120 --temp 0.3 --top-k 40 --top-p 0.9
⑤ 判断
- A 输出异常、B 正常 → 问题在 prompt 构造(App 的拼接逻辑);
- A、B 都正常 → 问题不在 prompt(可能在解析返回值、状态管理、UI 显示等);
- A、B 都异常 → 可能连"正确的 prompt"你也没构造对(回到对模型格式的理解)。
关键细节:
- 采样参数要跟 App 对齐 (
--temp 0.3 --top-k 40 --top-p 0.9),
否则复现不出用户看到的现象(本工程 runbook 第 260 行特意强调了这点); - 用
llama-completion而非llama-cli(后者不支持-no-cnv); - 两组 prompt 要只差目标变量,其它内容完全一致。
方法论:
这个实验的价值在于把"App 的一整条链路"压缩成"一个文本文件" ------
一旦问题能在一个文件里复现,排查难度就下降一个数量级。
"能不能把问题抽出来单独跑",是排查能力的重要标志。
练习 2:为"不变量"列清单
请为本工程的 native 侧列出尽可能多的"应该永远成立的性质"(不变量)。
解题思路提示
从三个角度想:
① 位置/索引的范围;
② 指针/句柄的有效性;
③ 数据结构的一致性。
参考答案要点
① 位置/索引类
| 不变量 | 出处 |
|---|---|
current_position ≤ DEFAULT_CONTEXT_SIZE |
ch18(修复前的 bug 正是越界到 8999) |
current_position ≥ system_prompt_position |
系统提示是 KV 前缀 |
stop_generation_position ≥ current_position(当它 > 0) |
ch18.6.2 的安全钳制 |
| 写入的 position 连续且唯一 | ch16/ch18 的核心要求 |
g_batch.n_tokens ≤ BATCH_SIZE |
batch 容量 |
② 指针/句柄类
| 不变量 | 出处 |
|---|---|
g_model == nullptr ⟺ 模型未加载 |
ch15 |
g_sampler != nullptr ⟹ g_model != nullptr |
sampler 依赖 model |
| 释放后指针必须为 null/nullptr | ch15(防 double free) |
g_context != nullptr 时才能调 llama_decode |
各 JNI 函数入口检查 |
③ 数据结构一致性类
| 不变量 | 出处 |
|---|---|
g_kv_text 是"KV 中实际文本"的账本 |
ch17 |
chat_msgs 的 role 应该交替(user/assistant) |
ch16 repair_missing_assistant |
system_prompt_position 之前的内容永不被移窗丢弃 |
ch18.3 |
g_backend_initialized 与 backend 实际状态一致 |
ch15 |
怎么用这些不变量?
- 开发期加断言 (
#ifdef DEBUG,零 release 开销):
cpp
#ifdef DEBUG
if (current_position > DEFAULT_CONTEXT_SIZE) {
LOGe("INVARIANT VIOLATED: current_position=%d > n_ctx=%d",
(int) current_position, DEFAULT_CONTEXT_SIZE);
}
#endif
-
写成单测/模拟断言 (像
verify_p02.py那样):断言:所有写入的 position 都 ≤ 8192 且连续
价值:
这些不变量把"隐式的正确性要求"变成"显式的可检查项 "。
一旦某条被破坏,你能立刻知道是哪条、在什么位置 ------
而不是等到"用户说输出变乱了"才来猜。
对于静默失败型 bug,这是唯一有效的运行时防线。
练习 3:给工程里另一处可疑代码写根因分析
从 BUG_AUDIT_2026-09-10.md 选一项(未修复的),按 22.9.1 的模板写报告。
解题思路提示
选 P1 里的一项会比较好写(根因和后果都好理解)。
务必包含"验证"章节------那是很多人会漏的部分。
参考答案要点
以 BUG_AUDIT 的 P1 第 5 项(AppSettings 未 remember)为例:
markdown
### N. MainActivity 的 AppSettings 未 remember,导致每次重组重启 DataStore 收集
**位置**
- `MainActivity.kt:59`(`setContent` 里的 `AppSettings(this)`)
**现象**
流式输出期间(UI 每 ~100ms 重组一次)CPU 占用偏高;
理论上每次重组都会重新创建 AppSettings 与其所有 Flow。
**代码结构**
```kotlin
setContent {
val settings = AppSettings(this) // ← 每次重组都新建
val themeMode by settings.themeMode.collectAsState(initial = ...)
...
}
根因
AppSettings 的每个 Flow 属性都是构造时新建的对象 (AppSettings.kt:26-41)。
collectAsState 以 flow 实例为 key------
flow 实例变了,就会取消旧收集、重启新收集 (重新读盘)。
而 AppSettings(this) 写在 setContent 的 lambda 里,
每次重组都会执行→ 每次重组都重启 DataStore 收集。
触发条件
任何导致重组的操作;流式输出时每 ~100ms 一次 → 高频触发。
影响
不必要的 DataStore 读取与协程重启,CPU/IO 浪费;
极端情况下可能观察到界面轻微卡顿。
修复
用 remember 只创建一次:
kotlin
setContent {
val settings = remember { AppSettings(this) } // ← 修复
...
}
验证
① 在 AppSettings 的构造函数里加日志,观察重组时是否只打印一次;
② 流式输出时用 Profiler 看 CPU 占用是否下降。
**注意这份报告的写法**:
- **"现象"写得可观察**(CPU 偏高,而不是"感觉慢");
- **"根因"引用了具体行号**(`AppSettings.kt:26-41`);
- **"触发条件"具体**("任何重组"+"流式时高频");
- **"验证"给出两条可执行的操作**。
**对照模板检查你的报告**:
| 项 | 有吗 |
|---|---|
| 位置(文件:行号) | ☐ |
| 现象(可观察、可复现) | ☐ |
| 代码结构(贴关键代码) | ☐ |
| 根因(能解释现象) | ☐ |
| 触发条件(具体) | ☐ |
| 影响(严重程度) | ☐ |
| 修复(给代码) | ☐ |
| **验证(可执行)** | ☐ ← 最容易漏 |
</details>
### 练习 4:为什么"一次只改一个变量"
请解释:排查问题时,为什么不能"同时试几个改动"?
<details>
<summary>解题思路提示</summary>
如果改了三处,问题消失了------你知道是哪一处起的作用吗?
如果问题还在------你能排除什么?
</details>
<details>
<summary>参考答案要点</summary>
**问题一:改好了也不知道为什么好**
你改了 A、B、C 三处,问题消失了:
- 是 A 起作用?还是 B?还是 C?
- 还是 A+B 一起才有效?
- **你无法知道"真正的根因是什么"** → 下次遇到同类问题还是不会。
**问题二:改坏了也不知道为什么坏**
问题还在,或者出现了新问题:
- 是哪个改动引入的?
- **你只能一个个回退**,重新来一遍。
**问题三:可能"相互掩盖"**
比如:
- A 改动引入了 bug,B 改动恰好掩盖了它 → 问题"看起来好了",
但**根因还在**,将来会在别的场景暴露。
**正确做法**:
一次只改一个变量 → 观察结果 → 记录 → 再改下一个
**这在实践中意味着**:
1. 改一处 → **立即验证**(别攒着一起测);
2. 用**版本控制**(git)记录每一步------出问题能精确回退;
3. 如果确实需要"组合改动",**逐一单独测试每个改动**。
**一个例外**:
如果多个改动是"同一个逻辑修改的不同部分"(比如 ch18 的三处同步必须一起改),
那它们**逻辑上是一个变量**,可以一起改。
**判断标准:它们是不是"为了同一个目的、且必须同时生效"?**
**推广**:
> **"控制变量法"是科学实验的基本原则**------
> 在软件排查里同样适用。
> **它保证了"你能从观察结果中得出确定结论"。**
>
> 违背它会让你陷入"改来改去,问题时有时无"的泥潭。
</details>
### 练习 5:给本工程做一次小型审查
选工程里**一个文件**(比如 `TTSManager.kt` 或 `ModelManager.kt`),
逐行读一遍,找出至少 2 个"可疑点",并分级。
<details>
<summary>解题思路提示</summary>
按 `BUG_AUDIT` 的思路问自己:
① 有没有"异常路径没清理"?
② 有没有"状态读写在错误的线程"?
③ 有没有"定义了没人用的字段"?
④ 有没有"错误被吞掉"?
⑤ 有没有"资源没释放"?
</details>
<details>
<summary>参考答案要点</summary>
**以 `TTSManager.kt` 为例,可以问这些问题**:
| 检查角度 | 具体问题 | 可能的分级 |
|---|---|---|
| **资源释放** | 所有路径都会 `shutdown()` 吗?`pendingFlush` 会取消吗? | P1 |
| **线程安全** | 每个碰状态的公开方法都加锁了吗? | P0/P1 |
| **异常路径** | `onError` 的处理和 `onDone` 一致吗? | P1 |
| **状态残留** | `stopInternal` 的 `finally` 覆盖了所有状态吗? | P1 |
| **生命周期** | 回调会不会在 `shutdown` 之后还进来? | P1 |
| **队列一致性** | `textQueue` 和 `accumulatedText` 会不会不同步? | P2 |
**以 `ModelManager.kt` 为例**:
| 检查角度 | 具体问题 | 可能的分级 |
|---|---|---|
| **事务边界** | 读-改-写都在同一个 `edit {}` 里吗? | P0(ch11 讲过) |
| **慢 IO** | 有没有把慢操作塞进事务? | P1 |
| **错误兜底** | JSON 解析失败会不会拖垮整个列表? | P1 |
| **键复用** | 键对象是常量吗(避免每次重建)? | P2 |
| **一致性** | `removeModel` 删记录和删文件会不会不一致? | P1 |
**分级标准**(参考 `BUG_AUDIT`):
- **P0**:确定性缺陷,**必然触发**且影响严重;
- **P1**:有明确影响,但需要特定条件;
- **P2**:代码卫生 / 健壮性 / 性能。
**关键的产出**:
1. **每条都要写"触发条件"**(否则无法定级);
2. **每条都要写"影响"**(用户会看到什么);
3. **能用代码引用的就引用**(文件:行号)。
**这份练习的真正目的**:
> 训练你"**带着怀疑眼光读代码**"的能力。
>
> 读代码有两种模式:
> - **"看懂它在做什么"**(学习模式)------本书前面 21 章主要在训练这个;
> - **"找出它可能错在哪"**(审查模式)------**本章训练这个**。
>
> 成熟的开发者能在两种模式间自由切换。
> **而且审查模式的经验会反过来提升学习模式**------
> 你会开始注意"这里为什么这么写""不这么写会怎样"。
</details>
---
## 22.15 自测题(附答案)
> 本章的自测**不考记忆,考"你会不会用这套方法"**。
> 后三道简答题建议**先自己写一遍再对答案**。
**一、判断对错**
1. 排查问题时**一次改多个地方**能更快找到根因。
2. 把**现象**改掉(比如换个提示语)就等于修好了。
3. "静默失败"类的 bug 靠**加更多日志**就能排查出来。
4. 正确做法是**先稳定复现,再开始排查**。
**二、选择**
5. 五步框架的**第一步**是?
A. 修正 B. **复现** C. 隔离变量 D. 回归验证
6. "**只差一个变量**"的两组对比,叫什么?
A. 单元测试 B. **最小对照实验** C. 压力测试 D. 冒烟测试
7. 对付"静默失败"最有效的**运行期**手段是?
A. 加更多日志 B. **不变量 + 断言** C. 重启 D. 升级依赖
**三、简答**
8. 为什么说"**不能复现 = 不能验证修复**"?
9. 事故二(长对话崩坏)为什么**不能**用"输入对照"?
本工程用了什么**替代方案**?
10. 写根因分析报告的**三条心法**是什么?
<details>
<summary>答案与解析</summary>
**一、判断**
1. ❌ **错**。改好了你不知道**是哪个改动起作用**;改坏了你也不知道**是哪个引入的**;
更糟的是几个改动可能**互相掩盖**(22 练习 4)。
**一次只改一个变量**。
2. ❌ **错**。那是治标。**问题会以别的形式回来**(22.10)。
3. ❌ **错**。静默失败**不报错、可能连日志都没有**(比如 ch18 的位置错乱)。
靠"看日志"根本发现不了。
有效手段是**不变量 + 断言 + 纯逻辑模拟**(22.2.1、22.8.3)。
4. ✅ **对**。这是五步框架的第一步(22.1.1)。
**二、选择**
5. **B(复现)**(22.1.1)。
6. **B(最小对照实验)**(22.1.3)。
7. **B**。把"正确性要求"显式写成断言(`#ifdef DEBUG` 零开销),
让它**自己喊**------这是静默失败的**唯一有效运行时防线**(22.8.3)。
**三、简答(要点)**
8. 因为"修复"的证据就是"**原来能复现的场景不再出现**"。
如果问题本身不能稳定复现,你改完后**无法判断是真修好了还是碰巧没出现**(22.1.1)。
另外,**复现过程本身就在缩小范围**------
比如"只在第 2 轮""只在长对话",这些信息价值极高。
9. 因为事故二是**内部状态管理**问题(位置编号错乱),
**不像 ch17 那样能抽出"两段文本"来对比**。
**替代方案**:**"算法对照 + 状态推演"**------
同一输入、**两种实现**、对比输出(修复前写到 8999 越界、修复后最大 4999 连续)。
好处是**不需要跑真机**,纯逻辑就能验证(22.4.2--22.4.3)。
10. 三条(22.9.3):
① **"现象"要写得能复现**------别写"有时候会崩",写"A 条件下做 B 操作稳定出现";
② **"根因"要能解释"现象"**------解释不了说明还没找到根因;
③ **"验证"要可执行**------别写"应该好了",写"做什么操作、看到什么算好"。
> 第 ③ 条最容易被忽略------很多人写到"修复方案"就结束了。
**评分建议**:本章没有"答对几题算过"的标准。
**如果你能独立完成第 9、10 题,说明这套方法论已经属于你了。**
</details>
---
> **下一章**:ch23 综合复刻:从零做一个最小可用版本(capstone)。
> **全书的最后一章。**
> 这一章你会**从零搭一个最小可用的端侧对话 App**:
> 只保留"选模型 → 加载 → 流式对话"三件事,
> 把前面 22 章学到的能力**全部串起来用一遍**。
> 然后我们再讨论"怎么把它加回成一个完整产品"。