ch22 诊断方法论:三个真实事故的根因分析

学习目标

读完本章,你应当能够:

  1. 掌握一套可复用的排查框架:现象复现 → 隔离变量 → 最小对照实验 → 修正 → 回归验证
  2. 用工程里真实发生过的多起事故,学会从"用户看到的怪现象"反推"代码里的错";
  3. 理解为什么这类 bug 常常是"静默失败"(不报错、只输出错乱)而非崩溃;
  4. 掌握针对"静默失败"的专项对策:不变量 + 断言 + 模拟验证
  5. 会用"二分法 "缩小范围,会用"最小对照实验"隔离变量;
  6. 能独立给工程里另一处可疑代码写一份根因分析报告

前置 :ch17(模板)、ch18(移窗)、ch08(Flow)

对应源码 / 文档

  • BUG_AUDIT_2026-09-10.md(工程内真实审查报告,21 项)

  • lib/src/main/cpp/ai_chat.cppchat_add_and_format / shift_context / decode_tokens_in_batches / unload

  • app/.../ui/MainScreenState.ktsendMessage / 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 出了问题------崩溃、输出乱码、速度慢。你怎么找到问题在哪?

新手的做法是"瞎猜 ":改这里试试、改那里试试、重启一下、清个缓存......

改完跑一跑,没好?再换个地方改。效率极低,而且经常越改越糟。

诊断方法论 就是"一套系统的排查步骤":让你不用瞎猜,也能一步步把问题定位到具体的代码行。

💡 类比:医生看病

病人捂着肚子进诊室:"医生,我肚子疼。"

  • 庸医听了直接开刀------结果打开一看,肚子里根本不是他以为的那个问题。
  • 良医 不会一上来就动手。他会:
    1. 问症状:什么时候开始疼?吃了什么?疼的位置在哪?(= 复现步骤)
    2. 量体温、听诊:看看有没有明显异常(= 看日志)
    3. 抽血、拍片:做针对性检查(= 加调试代码、加断点)
    4. 排除法:先排除急症,再排除常见病(= 隔离变量)
    5. 确诊:找到真正的病因(= 找到根因)
    6. 开药:对症下药(= 修复代码)

程序员查 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(修控制流,不是把占位符文字改长一点);
  • 事故四:catchdeletePartialFile(修异常路径清理,不是在 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.txt vs prompt_bad.txt(只差 user 消息开头 24 字节):A 正常 B 输出数字
⑤ 根因 "按长度切片"求增量,假设"上一轮渲染是本轮前缀",Hy-MT2 模板结尾分叉 → 偏 24 字节
⑥ 修复 改成"前缀比较",不依赖模板行为
⑦ 回归验证 第二轮✅ 多轮✅ 换 Qwen✅ 单轮✅

① 复现

内容
现象 Hy-MT2 模型下,第二轮起只输出孤立数字(10 / 1 / 2 / 3
可复现性 稳定(每次都这样)
边界条件 只在第 2 轮及以后;第 1 轮正常

"只在第 2 轮"这个信息已经非常有价值

它暗示"问题与历史有关"。

② 隔离变量

可能的原因

  1. 模型文件坏了?
  2. 量化有问题?
  3. 手机 CPU 不行?
  4. 提示词拼接错了?
  5. 推理循环错了?

怎么排除 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) ✅ 正常(因为前缀比较与模板无关)
旧功能(单轮) ✅ 没坏

💡 这个事故的方法论价值

  1. "只在第 2 轮"提示了方向(与历史相关);
  2. 命令行工具帮我们排除了"环境问题"
  3. 对照实验把范围缩到"提示词"
  4. 修复方案"不依赖具体模板"------所以对新模型也有效。

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_discardstop_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 看不到也删不掉
⑥ 修复 catchdeletePartialFile(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-cnvllama-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 项)。

它的审查方式是(✅ 报告开头原文):

复制代码
审查方式:逐文件通读 + 对可疑点做静态推演;关键结论标注了可复现的触发条件。

三个特点值得学

  1. 逐文件通读(不是随机看);
  2. 静态推演(不跑代码,靠推理);
  3. 标注触发条件("什么情况下会触发"------这决定了优先级)。

报告还做了分级(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 未注册;
  • 或注册在已被取消的作用域里;
  • applySamplingParamslaunch 的作用域失效。

影响

用户改动看似无效,容易误判为"功能坏了"或"引擎有问题"。

修复

确保第 ⑤ 环注册在 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"你也没构造对(回到对模型格式的理解)。

关键细节

  1. 采样参数要跟 App 对齐--temp 0.3 --top-k 40 --top-p 0.9),
    否则复现不出用户看到的现象(本工程 runbook 第 260 行特意强调了这点);
  2. llama-completion 而非 llama-cli (后者不支持 -no-cnv);
  3. 两组 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 != nullptrg_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

怎么用这些不变量?

  1. 开发期加断言#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
  1. 写成单测/模拟断言 (像 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 章学到的能力**全部串起来用一遍**。
> 然后我们再讨论"怎么把它加回成一个完整产品"。
相关推荐
ai2work1 小时前
附录 D 速查索引与阅读指南
kotlin
Kapaseker2 小时前
你有搞明白 Volatile 什么意思吗?
android·kotlin
alexhilton11 小时前
藏在设备上的秘密,终究藏不住
android·kotlin·android jetpack
ai2work17 小时前
ch15 加载模型与初始化上下文
kotlin
hai_android17 小时前
Kotlin / Android 常用函数使用示例手册
android·java·kotlin
hai_android19 小时前
Android MeasureSpec 详解
android·java·kotlin
ai2work21 小时前
ch11 持久化:DataStore + Gson 多会话
kotlin
JMchen1 天前
实战案例:实现120fps流畅的渐变进度条
android·kotlin·canvas
传奇开心果编程2 天前
【Jetpack Compose基础语法学与练】第8课 rememberSaveable,页面旋转/系统重建保留状态
android·学习·ui·kotlin·android jetpack