从问题定位走到修复:AI调试技巧笔记

这类"明明看起来没问题但就是不对"的场景,在AI项目里太常见了。

下面只记真正影响结果的部分。

第一步:把"现象"和"判断"分开

最常见的错误是把"现象"当成了"判断"。比如"模型不收敛"其实是个判断,不是现象——真正的现象可能是"loss曲线震荡"、“梯度爆炸”、“验证集指标下降”,或者"输出全是重复的token"。

我有个习惯,开始调试前先在一个文本里写下三行:

现象:我看到的客观事实
假设:我认为可能的原因
计划:接下来要验证什么

这样至少能保证自己知道在干什么,而不是像个无头苍蝇一样改代码。

举例,一个LLM微调项目,输出结果总是乱码。现象不是"模型坏了",而是"解码出来的文本包含大量 <unk> 和乱序字符"。假设可能是tokenizer对齐问题、训练数据编码异常、或者batch size太大导致梯度不稳定。计划先检查tokenizer的vocab对齐,再看数据预处理日志,最后调整训练参数。

记得有次AI模型推理突然变慢,现象是"单次推理从200ms涨到3s",假设是模型文件损坏、GPU内存碎片化、或者新引入的特征工程增加了计算量。用 nvidia-smi 看了显存占用正常,怀疑是某个特征变换函数引入了CPU-GPU数据传输。最后通过 nvprof 和手动计时定位到问题——一个预处理函数忘记把tensor放到GPU,每次推理都要回传CPU处理,再把结果转回GPU。

现象和判断分开,能避免在一开始就走错方向。

第二步:先让问题可复现

有些bug像幽灵一样时隐时现,这种最难搞。我遇到过好几次"本地正常但线上挂了",或者"改了点无关代码就突然不对"。

解决方法是先最小化复现路径。创建一个最简单的测试用例,只保留核心逻辑,去掉所有decorator、异步调用、复杂依赖。能用脚本复现就不要等请求触发,能用固定输入就不要用随机数据。

# debug_ckpt.py
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer

# 固定随机种子
torch.manual_seed(42)

# 只加载模型,不加载任何业务逻辑
model = AutoModelForCausalLM.from_pretrained("path/to/model")
tokenizer = AutoTokenizer.from_pretrained("path/to/tokenizer")

# 用最简单的输入测试
input_text = "Hello"
input_ids = tokenizer.encode(input_text, return_tensors="pt")

# 单步推理,看每一步的输出
with torch.no_grad():
    outputs = model(input_ids, output_hidden_states=True)
    print(f"logits shape: {outputs.logits.shape}")
    print(f"first logits: {outputs.logits[0, -1, :5]}")

这种"裸模型测试"能帮你快速排除外部因素。如果裸模型都跑不通,问题多半在环境、版本或者模型文件本身。如果裸模型正常但集成后异常,那就把集成层逐层加回去,每次测试一下。

有次在线上环境复现不了本地bug,最后发现是Python版本差了0.1个minor号,某个依赖库的兼容性问题导致的。后来养成了习惯,每次新环境部署前先跑一个最小化的健康检查脚本,确认基础能力正常。

第三步:日志比断点更好用

AI项目的调试,断点很多时候不好使——推理是数据驱动的,单步调试看不到完整的数据流。更好的方式是埋日志。

但不是随便打印,要设计一个"诊断日志层"。在一个独立模块里定义日志函数,统一格式,方便后续分析。

# diagnostic.py
import time
from functools import wraps

def log_inference(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        start_time = time.time()
        result = func(*args, **kwargs)
        elapsed = time.time() - start_time

        # 记录输入输出摘要,不记录全部数据
        input_summary = summarize_input(args[0])
        output_summary = summarize_output(result)

        print(f"[DIAG] {func.__name__} | input: {input_summary} | output: {output_summary} | time: {elapsed:.3f}s")
        return result
    return wrapper

def summarize_input(data):
    # 根据数据类型返回合适摘要
    if isinstance(data, str):
        return f"len={len(data)}, first_chars={data[:20]}"
    elif isinstance(data, torch.Tensor):
        return f"shape={data.shape}, dtype={data.dtype}, device={data.device}"
    elif isinstance(data, dict):
        return f"keys={list(data.keys())[:5]}"
    return str(type(data))

def summarize_output(data):
    # 同上
    if isinstance(data, torch.Tensor):
        return f"shape={data.shape}, min={data.min().item():.3f}, max={data.max().item():.3f}"
    elif isinstance(data, list):
        return f"len={len(data)}, first_elem_type={type(data[0])}"
    return str(type(data))

这种日志的好处是可以打开或关闭,不影响正常代码流。而且记录的是摘要,不是完整数据,既节省空间又能看出大概情况。

有次调试一个多模态模型,图像和文本对齐总是出错。通过这种诊断日志,发现图像encoder输出的embedding维度居然在某个条件下从512变成了768——原来是用错了一个预训练权重版本。如果用断点,很难在那么多数据里找到这个细节差异。

第四步:逐步简化假设

假设太多会把自己绕进去。我习惯每次只做一个假设,验证完再下一个。

比如一个RAG系统检索效果差,可能的假设有很多:向量模型不对、chunk大小不合适、相似度阈值太低、索引策略有问题、prompt不够清楚、文档预处理有bug……

正确的做法是:

  1. 先验证向量模型:用简单的相似度查询,看语义相近的内容是否能被检索出来
  2. 再验证chunk策略:调整不同chunk大小,看检索覆盖率
  3. 然后验证相似度阈值:用不同阈值测试召回率
  4. 最后验证prompt和生成

每一步都单独测试,不要混合验证。如果第一步就发现问题,后面几步根本不用跑。

# 验证向量模型的简单脚本
python test_embedding.py \
  --model "sentence-transformers/all-MiniLM-L6-v2" \
  --query "如何提高RAG检索效果?" \
  --docs "docs/" \
  --top_k 5

# 验证chunk策略
python test_chunking.py \
  --chunk_sizes 256 512 1024 \
  --overlap 50 100 \
  --metric "recall"

# 验证相似度阈值
python test_threshold.py \
  --thresholds 0.3 0.5 0.7 0.9 \
  --metric "precision recall f1"

这种单元式的测试比在完整系统里东改西改高效得多。

第五步:用对比来定位问题

很多问题单看代码看不出毛病,但一对比就明显了。对比可以是在不同配置间、不同版本间、或者成功和失败案例间。

我遇到过一个问题:微调后的模型总是输出重复内容,即使temperature调高也没用。后来把微调前后的模型输出做个对比,发现微调模型在某个特定token的概率分布异常——原来训练数据里有大量重复模式,模型"学到"了这种偏好。

# 对比两个模型的输出分布
def compare_outputs(model1, model2, prompt, n_samples=10):
    tokenizer = get_tokenizer()
    tokens = tokenizer.encode(prompt, return_tensors="pt")

    samples1 = generate_samples(model1, tokens, n_samples)
    samples2 = generate_samples(model2, tokens, n_samples)

    # 计算输出多样性
    diversity1 = calculate_diversity(samples1)
    diversity2 = calculate_diversity(samples2)

    print(f"Model1 diversity: {diversity1}")
    print(f"Model2 diversity: {diversity2}")

    # 找出频繁出现的token
    freq1 = get_token_frequency(samples1)
    freq2 = get_token_frequency(samples2)

    print("Top frequent tokens in model1:", freq1[:5])
    print("Top frequent tokens in model2:", freq2[:5])

这种对比能帮你看到隐藏在统计规律里的异常。

另一个常用技巧是"二分法验证"——在一个长pipeline里,找到中间点验证输入输出是否正常。如果中间点就出问题了,问题在前半段;否则在后半段。不断二分,总能缩小范围。

graph TD A[完整Pipeline] --> B{中间点1正常?} B -->|是| C[问题在后半段] B -->|否| D[问题在前半段] C --> E{中间点2正常?} E -->|是| F[问题在后1/4段] E -->|否| G[问题在3/4段] D --> H{中间点3正常?} H -->|是| I[问题在2/4段] H -->|否| J[问题在1/4段]

第六步:不要相信"显而易见"的事情

很多bug看起来"显而易见",但实际原因往往不是那个。

我调试过一个图像分类模型,准确率突然从92%跌到65%。第一反应是训练数据有污染,检查后发现数据没问题。然后怀疑模型文件损坏,重新加载还是一样。最后发现问题出在推理时的图像预处理——某个update引入了一个新的resize函数,默认参数跟之前的实现不一样,导致图像被意外压缩。

另一个例子是LLM推理显存占用突然暴涨。第一反应是模型加载有问题,或者batch size设置错了。查了半天发现是某个库的版本升级后,默认启用了gradient checkpointing,虽然能节省显存但推理时会有额外开销。

所以,当直觉告诉你"肯定是X问题"时,先假设直觉可能错了,再找证据。验证要用客观数据,不要用"看起来应该没问题"这种主观判断。

第七步:保存现场,不要急着改

发现问题后第一个冲动往往是"赶紧修复",但更好的习惯是先保存现场。

# 保存当前环境的详细信息
python -c "import sys; print(sys.version)" > env_info.txt
pip list > requirements.txt
nvidia-smi > gpu_info.txt

# 备份相关的模型文件
cp -r model_dir model_dir_backup_$(date +%Y%m%d_%H%M%S)

# 保存复现脚本
cat > reproduce_issue.py << 'EOF'
import torch
from transformers import AutoModel

# 这个脚本能稳定复现问题
model = AutoModel.from_pretrained("path/to/model")
input_tensor = torch.randn(1, 3, 224, 224)
output = model(input_tensor)
print(f"Expected shape: torch.Size([1, 1000]), got: {output.shape}")
EOF

这样做的好处是,如果修复过程中引入新问题,可以随时回退。而且复现脚本也是很好的文档,能帮助其他人理解问题本质。

有次修复一个bug花了两天,最后发现改错了地方。幸亏之前保存了复现脚本和环境信息,否则根本不知道哪个版本是"好"的。

第八步:把调试过程本身记录下来

最后一条习惯,是把调试过程当作文档来写。不只是最后的结果,还有中间的尝试、失败的假设、碰壁的经历。

## 问题记录:LLM推理结果不一致

### 日期:2026-06-15

**现象**:同一个prompt,不同次推理返回的结果差异很大,即使temperature设为0。

**尝试1**:检查random seed设置
- 发现模型代码里确实设置了seed
- 但仍有差异,说明不是seed问题

**尝试2**:检查tokenizer
- 发现tokenizer里有`do_sample=True`的默认值
- 改为False后,结果一致了

**结论**:即使temperature=0,如果`do_sample=True`仍然会有随机性。需要同时设置`do_sample=False`
**相关文件**`src/inference/llm_engine.py`, line 45

这种记录不只是给别人看的,也是给自己看的。过几个月再遇到类似问题,能快速回忆起当时的解决思路。

最后说一句

AI项目里的调试,多半是在和概率、分布、统计规律打交道。传统的线性思维在这里常常失效,需要一点"侦探式"的推理,一点"统计式"的验证,还有一点"保存现场"的谨慎。

这些问题没什么标准答案,很多时候靠的是经验和运气。但至少,有一套系统的排查流程,能让你少走几条弯路,少熬夜几次。

调试本身也是一种学习——每个坑都藏着对系统更深层的理解。爬出来之后,你会对整个架构有更清晰的认识。


补充说明:文章里提到的大部分脚本都是简化版本,实际使用时需要根据项目具体情况调整。诊断日志的设计、复现脚本的编写、对比策略的选择,这些都跟业务场景紧密相关。工具本身只是辅助,真正的debug能力来自于对系统的理解和大量实践积累。

版权声明: 本文首发于 指尖魔法屋-从问题定位走到修复:AI调试技巧笔记https://blog.thinkmoon.cn/post/203-ai-debugging-practices-troubleshooting-fixes/) 转载或引用必须申明原指尖魔法屋来源及源地址!