一、你可能用错了 AI

过去两周,我修复了一个 C++ 无人机地面站服务的两类问题:

  • 内存问题:Valgrind 报告 112 个 use-after-free 错误,分布在关机顺序、容器清理、回调生命周期等多个维度。
  • 性能问题:gperftools 火焰图显示 CPU 被三个瓶颈吞噬——一个 0.5ms 间隔的定时器线程独占 50.7% 采样,日志系统的无差别 flush 占 17.4%,protobuf 热路径深拷贝占 11.6%。

两次排查经历得出同一个结论:AI 最适合的角色不是"修 bug 的人",而是"读工具输出的人"。

这不是一个关于 AI 能力边界的哲学讨论。这是两组真实火焰图、112 个 Valgrind 错误、和 11 个代码修复的工程复盘。


二、一个典型的 AI 调试场景(以及它为什么失败)

假设你遇到这样一个问题:程序在退出时偶尔 crash,日志的最后几行是乱序的。

如果你把出问题的代码直接丢给 AI:

1
2
3
4
5
6
7
用户:这段代码退出时崩溃,帮我修一下

void SystemFactory::stop() {
EmergencyFactory::getInstance().stop();
DeviceManager::getInstance().stop();
MqttClient::getInstance().Stop();
}

AI 会怎么回答?它可能会建议加 try-catch,可能会建议检查指针是否为空,可能会建议加日志——但它极不可能发现真正的问题:Logger 的 worker 线程没有被 stop,而 MqttClient 被提前停止了。

为什么?因为 AI 看到的只是你丢给它的那几行代码。它不知道:

  • Logger 有一个后台线程在消费日志队列
  • Logger 依赖 LogPublisher,LogPublisher 依赖 MqttClient
  • 这个依赖链意味着停止顺序必须是 Logger 先、MqttClient 后
  • C++ 静态对象的析构顺序在不同编译单元之间是不确定的

AI 的输入决定了 AI 的输出。 你给它一个函数,它修一个函数。你给它一份 Valgrind 报告,它才能看到整个调用链。


三、工具输出:AI 真正能读懂的"语言"

反过来看另一个场景。把 Valgrind 的输出直接给 AI:

1
2
3
4
5
6
7
8
==3300263== Invalid read of size 1
==3300263== at 0x4852A10: memmove
==3300263== by 0x15C2DB: MqttClient::publish (mqttClient.cpp:382)
==3300263== by 0x1935E7: LogPublisher::publish (LogPublisher.cpp:68)
==3300263== by 0x191540: Logger::workerThread() (csLog.cpp:315)
==3300263== Address 0x6a308de is 14 bytes inside a block of size 31 free'd
==3300263== at 0x484BB6F: operator delete
==3300263== by 0x1936E5: LogPublisher::~LogPublisher() (LogPublisher.h:9)

AI 立刻就能读出:

  1. 谁在访问已释放的内存Logger::workerThread()LogPublisher::publish()MqttClient::publish()
  2. 谁释放的LogPublisher::~LogPublisher(),在程序退出时被调用
  3. 根因假设:Logger 的线程还在运行,但 LogPublisher 已经析构了——说明 Logger 没有被正确停止

这个分析不需要 AI 理解整个项目的架构。工具已经把"发生了什么"写清楚了,AI 只需要做它擅长的事——理解文本中的因果关系。

同样的逻辑适用于火焰图。一张火焰图本质上是"CPU 时间花在哪里"的可视化答案。让 AI 读火焰图,你不需要描述你的代码,你只需要把图给它。


四、火焰图对比:优化前 vs 优化后

以下是同一个 UAV 地面站程序的两张 gperftools CPU 火焰图。左边是优化前,右边是优化后。

4.1 全局视野

优化前(69 samples):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
__clone3 (79.71%)
├── start_thread
│ ├── initMillisecondThread::{lambda} ─── 35 samples (50.72%) ← 一半 CPU!
│ │ ├── usleep ─── 16 samples (23.19%)
│ │ │ └── clock_nanosleep ─── 15 samples (21.74%)
│ │ ├── getCurrentMicrosecondOrigin ─── 6 samples (8.70%)
│ │ └── shared_ptr::operator* ─── 8 samples (11.59%)
│ │ └── __atomic_base::store ─── 2 samples (2.90%)
│ │
│ ├── Logger::workerThread ─── 12 samples (17.39%)
│ │ ├── LogPublisher::publish → MqttClient::publish → mosquitto
│ │ └── openFileOnce ─── 7 samples (10.14%)
│ │ ├── cleanupOldLogFiles ─── 3 samples (4.35%)
│ │ │ └── directory_iterator ─── 2 samples (2.90%)
│ │ └── createNewLogFile / fwrite / fstatat ...
│ │
│ └── MqttClient::loopForever ─── 5 samples (7.25%)

└── main (20.29%)
└── MainNode::tick ─── 8 samples (11.59%)
└── SSPC::getSnapshot ─── 4 samples (5.80%)
└── make_shared + CopyFrom ← 每次 tick 深拷贝整个 protobuf

火焰图最宽的那一条就是 initMillisecondThread——它独占了一半的 CPU 采样。一个 0.5ms 间隔的时间戳更新线程,每秒唤醒 2000 次,每次做系统调用。这不是 bug,是设计问题。

优化后(110 samples):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
__clone3 (84.5%)
├── start_thread
│ ├── initMillisecondThread::{lambda} ─── 71 samples (64.5%)
│ │ ├── clock_nanosleep ─── 45 samples (40.9%)
│ │ ├── usleep ─── 6 samples (5.5%) ← 从 23.19% 降到 5.5%
│ │ ├── getCurrentMicrosecondOrigin ─── 5 samples (4.5%)
│ │ └── shared_ptr::operator* ─── 9 samples (8.2%) ← 从 11.59% 降到 8.2%
│ │
│ ├── Logger::workerThread ─── 15 samples (13.6%)
│ │ ├── LogPublisher::publish ─── 8 samples (7.3%)
│ │ └── openFileOnce ─── 4 samples (3.6%) ← 从 10.14% 降到 3.6%
│ │
│ └── MqttClient::loopForever ─── 7 samples (6.4%)

└── main (15.5%)
└── MainNode::tick ─── 11 samples (10.0%) ← 从 11.59% 降到 10.0%

4.2 三处关键变化

变化一:usleep 热区大幅收窄(23.19% → 5.5%)

优化前,usleep 占了近四分之一的总采样。一个 0.5ms 精度的定时器,每秒 2000 次系统调用,每次都要进出内核态。对于 UAV 遥测场景,这个精度完全没有必要——GPS 更新频率才 1-10Hz,飞控数据通常 10-50Hz。

1
2
3
4
5
// 优化前:2000Hz,此线程独占 CPU 50%+
usleep(500);

// 优化后:100Hz,CPU 降低约 20 倍
usleep(10000);

一行的改动,火焰图上 usleep 的宽度直接缩到原来的四分之一。

变化二:shared_ptr::operator* 缩减(11.59% → 8.2%)

优化前,shared_ptr::operator* 是一个 11.59% 的宽条,散布在 initMillisecondThreadMainNode::tick 两条热路径中。根因是每次 tick 都 make_shared 深拷贝 protobuf:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 优化前:每次 getSnapshot() 都深拷贝
std::shared_ptr<const SSPCSystemInfo> SSPC::getSnapshot() const {
return std::make_shared<const SSPCSystemInfo>(*_latestInfo);
}

// 优化后:缓存快照,仅数据变更时重建
std::shared_ptr<const SSPCSystemInfo> SSPC::getSnapshot() const {
if (_snapshotDirty) {
_snapshotCache = std::make_shared<SSPCSystemInfo>(*_latestInfo);
_snapshotDirty = false;
}
return _snapshotCache;
}

火焰图上的变化是直观的:优化后,SSPC::getSnapshot 这个函数块从火焰图中直接消失——不是缩小,是消失。缓存命中后,它的 CPU 消耗降到了 profiler 的采样阈值以下。

变化三:日志写盘路径收窄(openFileOnce 10.14% → 3.6%,libc_write 14.5% → 12.7%)

优化前的日志系统有三个问题叠加:

  • 每次 openFileOnce()rotate() 都调用 cleanupOldLogFiles(),扫描全量日志目录
  • 每条 ERROR 日志绕过 64KB 缓冲区直接 flush() 写盘
  • 控制台每条日志都 flush()

修复不是改一行代码,而是四个点协同:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 1. cleanupOldLogFiles 限频:每 60 秒最多一次
if (elapsed >= 60s) { cleanupOldLogFiles(); }

// 2. ERROR 日志不强制 flush,设置阈值让下次循环触发
if (task.lvl <= LOG_LEVEL_ERROR) {
bytesSinceFlush = FLUSH_BYTES_THRESHOLD; // 而非立即 file.flush()
}

// 3. 控制台仅 ERROR 级别 flush
if (task.lvl <= LOG_LEVEL_ERROR) { std::cout.flush(); }

// 4. notify_all 批量化:每 16 条通知一次
if (++_notifyCounter >= 16) { cv.notify_all(); _notifyCounter = 0; }

火焰图不是告诉你"第几行代码慢",它告诉你"这条路线上有热度"。具体的修复方案仍然需要人来做——因为修复方案涉及对日志语义的理解(哪些日志必须立即持久化、哪些可以缓冲),这是 AI 从火焰图里读不出来的。


五、为什么"工具定位 + AI 解读 + 人决策"是最优解

以上两个案例(Valgrind + 火焰图)提炼出一个三阶段工作流:

1
2
工具定位  →  AI 解读  →  人决策
(探针) (翻译) (医生)

阶段一:工具定位

工具 回答的问题 输出形式
Valgrind (Memcheck) 谁在访问已释放的内存?谁泄漏了? 调用栈 + 内存地址
gperftools + FlameGraph CPU 时间花在哪条调用链上? 火焰图 SVG(宽度 = 耗时比例)
AddressSanitizer 哪行代码越界了?use-after-free 的精确位置? 编译时插桩,运行时精确报告
perf + perf-top 哪个内核函数最频繁被调用? 实时采样列表

这些工具的共同点:它们不猜测,只汇报。 它们的输出是可复现、可验证的。

阶段二:AI 解读

这个阶段 AI 做的是结构化翻译

  • Valgrind 报告 → 提取"谁分配、谁释放、谁还在用"的关系链
  • 火焰图 → 识别"最宽的条是什么函数、它的调用路径经过哪些层、同一条路径上有没有意外的热点"
  • perf-top → 对比优化前后的占比变化,找出哪些函数显著缩小了

AI 在这个阶段的核心优势是:它能从大量结构化文本中快速提取模式。给它 200 行 Valgrind 日志,它能归纳出 3 条问题链。给它一张火焰图,它能标出 5 个热区并分析它们的调用关系。这不是魔法——这就是语言模型最擅长的事,只是它的"语言"从人类语言变成了机器输出。

阶段三:人决策

AI 会给出如下输出:

"clock_nanosleep 占 40.9% 的采样,它是从 usleepinitMillisecondThread 调用下来的。这个线程似乎是一个高精度定时器。如果业务场景不需要毫秒级精度,可以考虑降低唤醒频率。"

但它不会做这个决定——因为只有你知道:

  • UAV 遥测对精度的真实要求是多少
  • 哪些日志必须立即持久化(安全事件),哪些可以缓冲
  • unregisterCallback 应该放在析构函数里还是在 stop() 里显式调用

工具告诉你"哪里出了问题",AI 帮你理解"问题是什么",你决定"怎么修"。


六、AI 直接修代码为什么低效

回到标题的问题。让 AI 直接修 bug 低效,不是因为 AI 不够聪明。是因为:

1. 代码级 bug 的根因往往在代码之外

112 个 Valgrind 错误的根因不是"少写了某个 delete",而是"关机顺序的设计缺失"和"C++ 静态对象析构的不确定性"。你把 stop() 函数丢给 AI,AI 只能看到 10 行代码,看不到散布在 6 个文件中的 11 个问题。

2. AI 缺乏运行时信息

AI 不知道你的程序启动了几个线程、每个线程在做什么、谁依赖谁。这些信息不在代码里,在运行时。而工具(Valgrind、gperftools、perf)的作用正是把运行时信息以结构化形式提取出来。

3. 修复方案涉及取舍,取舍需要上下文

usleep(500)usleep(10000) 这个改动,AI 可以建议,但它不知道 UAV 遥测对精度的要求。如果换成一个金融交易系统,这个建议就是灾难性的。AI 能做的是标记"这里有一个高频唤醒",人做的是判断"100Hz 够不够"。

4. 逻辑错误和语法错误有本质区别

语法错误 逻辑错误
表现 编译不过 看起来对,跑起来错
AI 修复成功率 低(需要上下文)
定位方法 编译器报错 Valgrind / ASan / 火焰图
根因 写错了 设计错了

这个项目里 11 个问题全部是逻辑错误。没有一个是因为"少写了分号"或"引用了错误的变量"。


七、一个可操作的工作流

下次遇到 bug 或性能问题,尝试这个顺序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
1. 先跑工具,拿到输出
├── 内存问题 → Valgrind --leak-check=full --track-origins=yes
├── 性能问题 → gperftools + FlameGraph
├── 越界/UAF → -fsanitize=address
└── 锁竞争 → perf lock + perf report

2. 把工具输出(不是你的代码)丢给 AI
"这是一个 Valgrind 报告 / 火焰图数据,帮我分析:
- 最主要的 N 个问题是什么
- 它们的调用链是怎样的
- 可能的根因假设"

3. 带着 AI 的分析,回到代码验证假设
- AI 说"Logger 线程没被停" → grep 你的 SystemFactory::stop()
- AI 说"某个函数被高频执行" → 检查调用频率和缓存策略

4. 你决定修复方案,AI 辅助实现
- 你决定"关机顺序应该是 A→B→C",AI 帮你把 stop() 重构成这个顺序
- 你决定"这个阈值是 60 秒",AI 帮你写限频逻辑

5. 再跑工具,验证修复
- Valgrind: ERROR SUMMARY: 0
- 火焰图: 热区宽度显著收窄

工具定位,AI 解读,人决策,工具验证——四个环节,各做各擅长的事。


八、一张图的差距

说一千道一万,不如直接看。

优化前的火焰图initMillisecondThread 占据了画面最宽的那一条——50.72% 的 CPU 采样都在这个 0.5ms 间隔的定时器上。usleep 占了 23.19%。shared_ptr::operator* 占了 11.59%。整张图被三四条大宽条主导。

优化后的火焰图:大宽条被拆散了。clock_nanosleep 虽然还是宽(40.9%),但这是因为线程睡了 20 倍长的时间——实际的 CPU 消耗已经大幅下降。usleep 缩到 5.5%。shared_ptr::operator* 降到 8.2%。日志系统的 openFileOnce 从 10.14% 降到 3.6%,cleanupOldLogFiles 相关的 directory_iterator 不再出现在火焰图中。SSPC::getSnapshot 直接消失。

这不是语义分析,这是视觉证据。 两图并排,任何人都能看出"优化有效"——不需要理解 C++,不需要懂 protobuf 序列化。

而 AI 能帮你读这张图。你把优化前后的火焰图都给它,它能告诉你:

  1. 哪些热区缩窄了(usleepshared_ptr::operator*openFileOnce
  2. 哪些热区消失了(SSPC::getSnapshotcleanupOldLogFiles
  3. 哪些热区仍然存在但原因变了(clock_nanosleep 从"高频唤醒"变成"长睡眠等待")
  4. 优化是否达到了预期效果

这就是工具 + AI 的正确打开方式:工具负责"可视化真相",AI 负责"解读真相",你负责"改变真相"。


九、总结

过去两周的工作可以用三句话概括:

  1. 不要直接让 AI 修 bug。 它的上下文窗口装不下你的整个运行时。给它工具输出,不是你的源代码。

  2. 大多数"代码问题"其实不是代码问题。 是设计问题、是顺序问题、是生命周期问题。工具(Valgrind、火焰图、ASan)比你更早看到这些问题——因为它们跑的是你的程序的真实执行路径,不是你的源代码。

  3. 工具 + AI > 工具 or AI。 工具给你数据,AI 帮你理解数据,你来做决策。三者各司其职,效率远超任何一个单独使用。

如果你正在维护一个 C++ 多线程服务,现在就可以做一件事:

跑一次 Valgrind,跑一次 gperftools,把输出和火焰图一起丢给 AI,问它"你看到了什么"。

你可能会发现一些你在代码 review 中永远看不到的东西。