增强 AI 链路性能剖析并拆分 ai_service 结构:一次面向可观测性与可维护性的重构实践
在端侧 AI 图片分析链路中,真正困难的往往不是“把模型跑起来”,而是当整体吞吐下降时,能不能快速回答两个问题:时间到底花在哪一段了?代码结构是否还支撑得住后续持续优化?这次改动围绕这两个问题展开,主要做了四件事。
摘要
在端侧 AI 图片分析链路中,真正困难的往往不是“把模型跑起来”,而是当整体吞吐下降时,能不能快速回答两个问题:
- 时间到底花在哪一段了?
- 代码结构是否还支撑得住后续持续优化?
这次改动围绕这两个问题展开,主要做了四件事:
- 把 AI 主链路的性能剖析从粗粒度总耗时,升级为覆盖输入加载、预处理、推理、标签检索、OCR、人脸链路、数据库写回等多阶段的细粒度 profile。
- 新增 final.completed-only、final.cache-miss-only 两种汇总口径,并补充 wallP50 / wallP90 指标,让统计结果真正能支撑优化决策。
- 将原来难以定位的 faceStoreMs 继续拆细,覆盖 faceRead、faceDecodeSrc、faceWarm、faceTemp、faceEmbed、faceStoreIsar、faceStoreObx 等子阶段。
- 使用 Dart part 机制将 AIAnalysisProgress、模型类、profiler 从 ai_service.dart 中拆出,在不改变行为的前提下收缩主文件体积,降低后续重构成本。
这次不是一次“直接把性能打爆”的优化,而是一次更重要的基础建设:先把观测面补齐,再让后续的每一刀优化都有证据支撑。
一、背景:为什么要先做可观测性,而不是继续盲调模型
项目里的 AI 图片分析链路本身已经比较长,核心阶段包括:
- 图片输入加载
- MobileCLIP embedding
- 标签检索
- OCR
- 人脸检测与人脸 embedding
- Caption 生成
- Isar / ObjectBox 写回
在这种链路下,如果只有“单张总耗时”或者“某个模块大概慢”的印象,基本没法做有效优化。
例如,在一次实际真机测试里,整体 wallAvgMs 明显偏高,但真正的大头并不是一开始以为的标签检索或向量索引,而是后处理链路里的 caption、faceStore、输入加载与辅助文件处理。这类问题如果没有结构化 profile,靠猜是很难猜准的。
所以这次改动的第一优先级,并不是继续调 NNAPI、XNNPACK 或数据库,而是先把“AI 主链路耗时账本”建立起来。
二、改动目标
这次改动的目标很明确:
- 让单张图片处理路径具备可解释的耗时明细
- 让批处理汇总结果能够区分不同样本口径
- 让人脸链路从黑盒变成白盒
- 让 ai_service.dart 从继续膨胀的趋势中刹车
换句话说,这次更像是一次“为后续优化铺路的工程化重构”。
三、核心设计
1. 从粗粒度耗时升级为多层次性能剖析
这次没有只在 AIService 里补几个 Stopwatch,而是把 profiling 分成了三层:
第一层:视觉 embedding 内部阶段
在 MobileClipVisionService 中,新增了三类 profile:
- MobileClipVisionPreprocessProfile
- MobileClipVisionRunProfile
- MobileClipVisionEmbeddingProfile
对应的剖析阶段包括:
- decodeMs
- resizeNormalizeMs
- tensorBuildMs
- inferenceMs
这样可以把原来一个笼统的“embedding 耗时”拆成真正可操作的两段:
- 预处理是不是大头
- 推理本身是不是大头
这一步非常关键,因为很多时候“模型慢”其实是“Dart 预处理慢”。
第二层:embedding 结果与向量索引写回阶段
在 MobileClipEmbeddingService 中,新增了 MobileClipEmbeddingProfile 与 MobileClipEmbeddingResolution 的扩展能力,除了 embedding 本身,还继续记录:
- 当前 backend / provider
- cache hit 还是 cache miss
- vectorIndexWriteMs
这让系统可以清楚区分三种不同情况:
- 直接命中 ObjectBox 缓存
- 重新走 embedding 推理
- embedding 完成后再写向量索引
这也是为什么后续可以打出 final.cache-miss-only 汇总,因为“缓存命中样本”和“真实推理样本”的成本根本不是一个量级。
第三层:AI 主链路单张画像
在 AIService 中引入 _AiPhotoProfile,把单张图片的完整处理过程都串起来了,覆盖字段包括:
- 输入加载:loadMs、thumbReadMs、fileReadMs
- 视觉链路:decodeMs、resizeNormMs、tensorMs、inferenceMs
- 标签与过滤:junkMs、tagMs
- 后处理:auxMs、ocrMs、analysisDecodeMs
- 人脸:faceMs、faceStoreMs
- 持久化:isarMs、objectBoxMs
- 总体:wallMs
同时还记录了上下文信息:
- inputSource
- usedThumbnail
- embeddingCacheHit
- captionDeferred
- outcome
这意味着一条单图日志已经不再只是“这个样本花了几秒”,而是真正能回答:
- 是缩略图路径慢,还是原图路径慢
- 是 cache miss 导致慢,还是 completed 图本身慢
- 是 face detect 慢,还是 face 持久化慢
- 是 caption 阻塞了主链路,还是 caption 已经异步化
四、汇总口径升级:让统计结果真正可用于决策
如果只看总平均,复杂 AI 链路很容易被“低成本样本”稀释。
这次在 _AiPipelineRunProfiler 中新增了三种关键汇总口径:
- final
- final.completed-only
- final.cache-miss-only
同时增加了:
- wallP50Ms
- wallP90Ms
为什么这几个指标重要?
1. completed-only
completed 图会完整经过 OCR、人脸、caption、写回等阶段,而 junk_filtered、prepare_failed 这些样本的成本明显更低。
如果把它们混在一起求平均,得到的不是“完整主链路的平均成本”,而只是“所有 outcome 混合后的平均成本”。
所以 final.completed-only 才更接近真实用户感知。
2. cache-miss-only
很多图片在第二轮、第三轮跑时已经命中了 embedding 缓存,这时再看总平均,会把真正的 embedding 成本压得很低。
因此 final.cache-miss-only 的价值在于:
- 它更接近首次分析的真实成本
- 它能帮助判断“预处理 / 推理 / 写索引”这一段是否仍值得继续优化
3. wallP50 / wallP90
平均值很容易掩盖长尾。
图片分析链路是典型的长尾业务:
- 有的图几乎没有 OCR
- 有的图人脸很多
- 有的图 caption 很重
- 有的图 faceStore 会特别长
因此只看 wallAvgMs 不够,必须同时看:
- wallP50Ms:典型样本耗时
- wallP90Ms:长尾样本耗时
这样才能知道到底是“普遍偏慢”,还是“少量极端样本把平均值拉高”。
五、把 faceStoreMs 从黑盒拆成白盒
在人脸链路里,之前只有一个相对笼统的 faceStoreMs。但如果 faceStoreMs 很大,问题可能来自完全不同的地方:
- 读取已有人脸数据
- 源图再次解码
- 裁脸
- 写临时文件
- 人脸 embedding
- Isar 删除旧人脸并写入新结果
- ObjectBox 替换索引
- 调试图清理
所以这次在 FacePipelineService 中引入了 FacePipelineProfile,把人脸链路拆成:
- existingReadMs
- sourceDecodeMs
- embeddingWarmUpMs
- cropMs
- debugCropMs
- tempFileMs
- embeddingMs
- isarWriteMs
- objectBoxWriteMs
- cleanupMs
- totalMs
然后在 AI 主链路里,把这些字段回填到 _AiPhotoProfile 中,形成完整单图日志。
这一步的工程价值
以前看到 faceStoreMs=1000+ms,只能说“人脸链路慢”。
现在看到之后,可以进一步回答:
- 是源图二次解码慢
- 是裁脸和临时文件慢
- 是人脸 embedding 慢
- 是 Isar 写回慢
- 还是 ObjectBox 替换慢
这类拆分对于性能优化非常重要,因为不同瓶颈对应的优化策略完全不一样。
六、把 Caption 从主链路中摘出来
虽然这次 commit 的标题更偏 profiling 和结构拆分,但代码里实际上还做了一件很关键的事情:
远程 caption 改为异步补全
在 PhotoCaptionService 中增加了:
bool get prefersAsyncGeneration => _llmService.isVisionApiConfigured;
在 AIService 中则增加了一个受控并发的异步 caption 队列:
- _pendingCaptionTasks
- _activeCaptionTasks
- _maxConcurrentCaptionWorkers = 2
也就是说:
- 如果当前 caption 依赖远程 vision API
- 并且不是停止状态
- 那么主链路只先完成 embedding、tags、face、主结果写回
- caption 改为后台异步补全
为什么这么做?
因为在真实链路里,caption 往往不是“主分析完成”的必要条件,但却会显著拉长单张图片的 wallMs。
把它从热路径里摘出来,有两个明显收益:
- 主链路吞吐提升
- wallAvgMs 更接近核心 AI 流水的真实成本
这也是一次典型的“把 enrich 字段改为最终一致”的工程优化。
七、减少热路径里的无效开销
这次改动还顺手做了两个很实用的优化。
1. 去掉重复 ObjectBox 向量写入
现在 embedding 写索引主要在 resolvePhotoEmbedding() 中完成,后续 _markAsAnalyzed() 支持 skipVectorIndexWrite,避免同一张图在热路径里重复写 ObjectBox。
这对 Isar + ObjectBox 的混合架构尤其重要,因为真正拖吞吐的往往不是“用不用双库”,而是“同一批数据是不是在双库上反复同步写”。
2. 关闭默认的 face debug crop 落盘
在人脸链路里,调试裁剪图默认不再持久化,只有显式设置 FACE_DEBUG_CROPS=true 时才会写盘。
这降低了:
- 裁脸路径的磁盘写入开销
- 调试文件的清理压力
- 非必要 I/O 对 face 链路的干扰
对于线上或真机压测,这是一个非常合理的默认策略。
八、结构拆分:先把 ai_service.dart 从继续恶化中拉回来
除了性能剖析,这次还做了一次低风险结构拆分。
原本 ai_service.dart 承担了太多角色:
- 主服务逻辑
- 进度状态模型
- 单图 profile 模型
- profiler 聚合器
- 运行态快照模型
这会导致两个问题:
- 文件越来越长,阅读和维护成本急剧上升
- 后续拆分时风险变得越来越高
所以这次先用 Dart part 做了第一层拆分:
part 'ai_service_progress.dart'; part 'ai_service_models.dart'; part 'ai_service_profiler.dart';
拆分后的职责
- ai_service_progress.dart
负责 AIAnalysisProgress - ai_service_models.dart
负责 _PreparedAnalysisInput、_PhotoProcessResult、_AiPersistenceProfile、_AsyncCaptionTask、_RuntimeSnapshot - ai_service_profiler.dart
负责 _AiPhotoProfile 和 _AiPipelineRunProfiler
为什么选择 part 而不是直接 public class 抽模块?
因为这次目标是“先收缩主文件,且不改变行为边界”。
用 part 的好处是:
- 可以保留原来的私有类可见性
- 不需要一次性大改调用关系
- 风险比直接 public 化/模块化更低
这是一个很典型的“先做低风险结构瘦身,再做进一步职责拆分”的工程策略。
九、这次改动带来的直接收益
这次提交的直接收益,不应该简单理解为“性能立刻提升了多少”,而应该从三个层面看。
1. 可观测性增强
现在已经可以精确回答:
- completed 图和 cache miss 图到底各自多慢
- 预处理和推理谁更慢
- face pipeline 的真正瓶颈在哪
- caption 是否仍在阻塞主链路
这比单纯看“总耗时”强太多。
2. 优化路径更明确
有了新的日志之后,下一步优化就不再是拍脑袋,而是可以根据数据决定:
- 如果 decode + resizeNorm + tensor 最大,继续优化预处理
- 如果 faceTemp + faceEmbed 最大,优先优化人脸 embedding 输入链路
- 如果 faceStoreIsar 或 faceStoreObx 最大,优先优化持久化策略
- 如果 caption 仍然高,继续优化异步化策略和并发上限
3. 代码结构开始从“堆积”转向“可演进”
ai_service.dart 主文件体积已经明显缩小,结构上也开始形成边界。
这意味着后续可以继续做更清晰的拆分,比如:
- 运行态控制
- 单图处理
- 异步 caption 协调器
- profiler 模块
工程风险会比继续在一个超长文件里堆逻辑低很多。
十、这次改动的局限与后续计划
需要强调的是,这次并不是终点。
当前局限
- part 只是第一层拆分,不是最终架构形态
- 人脸链路虽然拆细了,但还没有进一步降本
- caption 虽然异步化了,但是否真正改善 wallAvgMs 还需要干净真机跑来验证
- ai_service.dart 仍然偏大,后续还需要继续拆职责
后续计划
下一阶段更适合做的是:
- 用新的 summary 口径做干净真机验证
- 对比 thumbnailDataWithSize 与原图直读路径
- 优化人脸链路里的临时文件与二次解码
- 继续把 ai_service.dart 拆到更清晰的模块边界
- 等 AI 主链路收敛后,再继续推进 ANN 在检索/候选召回中的应用
结语
在复杂 AI 链路里,真正高质量的优化往往不是“一把把耗时砍掉”,而是先把系统从不可解释变成可解释。
这次改动最核心的价值不在于某个单点指标,而在于它完成了三件长期更重要的事:
- 建立了可用于决策的性能观测体系
- 把人脸链路从黑盒拆成了白盒
- 用低风险方式开始治理 ai_service 的结构膨胀
只有先把这些基础做好,后面的每一次优化,才不会变成新的技术债。
更多推荐
所有评论(0)