编程 自托管 AI 栈七个月:四个值得记录的 Bug

2026-09-06 00:15:09

自托管 AI 栈七个月:四个值得记录的 Bug

一位开发者在 Dev.to 上分享了他团队自托管 AI 栈七个月的经历。他们原以为一个周末就能搞定的事情,最终花了七个月,代码仓库积累了约 2100 次提交。文章记录了四个在变更日志中可以明确指出的 Bug,以及从中得到的经验教训。

项目背景

这个团队在自己的硬件上运行团队的 AI 栈。开发日志从 2026 年 1 月 19 日那周开始,代码仓库在 2 月 3 日创建,采用 MIT 开源协议。到文章发布时,仓库已有约 2100 次提交。

为什么选择自托管而不是使用云端 AI 服务?

  • 数据隐私:团队的代码和数据不希望离开本地环境
  • 成本控制:长期来看,自托管的硬件成本可能低于持续的 API 调用费用
  • 定制化需求:需要对模型和推理流程进行深度定制
  • 学习和探索:团队希望深入理解 AI 基础设施的运作方式

Bug 1:模型加载时的内存碎片化

问题描述

第一个严重的 Bug 出现在模型加载阶段。团队发现,在加载多个模型后,即使释放了模型内存,后续加载更大的模型时仍然会出现内存不足(OOM)。

根本原因

问题出在 GPU 内存的碎片化上。CUDA 的内存分配器在频繁分配和释放不同大小的内存块后,会产生大量的内存碎片。虽然总空闲内存足够,但没有足够大的连续内存块来加载新模型。

具体来说:

  • 加载模型 A(占用 7GB)→ 释放 → 加载模型 B(占用 5GB)→ 释放
  • 此时 GPU 有 12GB 空闲内存,但被分割成了 7GB 和 5GB 两个不连续的块
  • 尝试加载 10GB 的模型 C 时,虽然总空闲 12GB,但没有 10GB 的连续块,导致 OOM

解决方案

  1. 使用 CUDA 内存池:引入 cudaMallocAsynccudaFreeAsync,使用 CUDA 的流式内存池分配器,它能更好地处理内存碎片
  2. 预分配最大内存:在启动时预分配预期最大的内存块,避免运行时的动态分配
  3. 模型卸载策略:实现模型的 CPU/GPU 切换,不需要的模型卸载到 CPU 内存,需要时再加载回 GPU
  4. 内存碎片整理:定期进行内存碎片整理,将分散的内存块合并

经验教训

  • GPU 内存不是无限的,碎片化是真实存在的问题
  • 在多模型场景下,需要精心设计内存管理策略
  • CUDA 12 的异步内存分配器是解决碎片化的有效工具
  • 监控和记录 GPU 内存使用情况对于排查问题至关重要

Bug 2:推理服务的请求饥饿

问题描述

第二个 Bug 出现在推理服务的请求调度上。团队发现,当有长时间运行的推理请求时,后续的短请求会被"饿死",等待时间极长,甚至超时。

根本原因

问题出在推理服务的请求队列设计上。最初的实现使用了简单的 FIFO(先进先出)队列,所有请求按到达顺序排队处理。当一个长时间运行的请求(如处理长文档的 RAG 查询)占用了推理资源时,后续的所有请求都必须等待它完成。

更糟糕的是,团队最初实现了请求批处理(batching)来提高吞吐量,但批处理逻辑没有考虑请求的等待时间。一个长请求会"拖累"整个批次,导致批次中的其他请求也被延迟。

解决方案

  1. 优先级队列:将 FIFO 队列替换为优先级队列,根据请求的预期处理时间和等待时间动态调整优先级
  2. 短请求优先:实现短请求优先(Shortest Job First)策略,确保简单查询不会被复杂查询阻塞
  3. 请求超时和抢占:为长时间运行的请求设置超时,超时后可以被更高优先级的请求抢占
  4. 分离的推理通道:为不同类型的请求设置独立的推理通道(如简单查询通道和复杂分析通道),避免相互影响
  5. 动态批处理:改进批处理逻辑,根据请求的等待时间和处理时间动态组成批次,避免长请求拖累短请求

经验教训

  • FIFO 队列在多类型请求场景下不是最优选择
  • 吞吐量和延迟是两个不同的优化目标,需要平衡
  • 请求批处理可以提高吞吐量,但如果设计不当会增加延迟
  • 需要为不同类型的请求设置合理的 SLA(服务等级协议)
  • 监控请求的等待时间分布比监控平均等待时间更有意义

Bug 3:向量数据库的索引损坏

问题描述

第三个 Bug 出现在向量数据库上。团队在进行大规模向量插入后,发现查询结果出现了明显的质量下降——返回的最相似向量与查询向量的实际相似度远低于预期,甚至返回完全不相关的结果。

根本原因

问题出在向量索引的构建过程中。团队使用的是 HNSW(Hierarchical Navigable Small World)索引,这是一种常用的近似最近邻索引算法。在大规模并发插入时,索引的图结构出现了损坏:

  1. 并发插入冲突:多个线程同时修改索引的图结构,导致边的连接关系出现不一致
  2. 内存中的索引与磁盘不同步:索引在内存中修改后,没有及时持久化到磁盘,异常重启后索引状态不一致
  3. 参数设置不当:HNSW 的 efConstruction 参数设置过低,导致索引构建质量不足,召回率下降
  4. 向量归一化不一致:部分向量在插入前没有正确归一化,导致余弦相似度计算出现偏差

解决方案

  1. 加锁保护索引修改:在索引修改操作上加写锁,确保同一时间只有一个线程修改索引结构
  2. WAL(预写日志):引入预写日志,所有索引修改先写入日志,再应用到索引,确保崩溃后可以恢复
  3. 定期重建索引:定期从原始向量数据重建索引,修复可能存在的结构损坏
  4. 调整 HNSW 参数:增大 efConstructionefSearch 参数,提高索引质量和查询精度
  5. 统一向量预处理:在插入前统一进行向量归一化,确保所有向量在同一度量空间中
  6. 索引质量监控:添加索引质量监控,定期使用已知查询测试召回率,发现异常及时告警

经验教训

  • 近似最近邻索引的质量不是理所当然的,需要监控和验证
  • 并发修改复杂数据结构必须有正确的同步机制
  • 持久化是数据库系统的核心难题,不能忽视
  • 向量的预处理(归一化、降维等)对查询质量有直接影响
  • 定期重建索引是一种简单有效的"自愈"机制

Bug 4:模型版本切换时的状态不一致

问题描述

第四个 Bug 出现在模型版本切换时。团队在升级模型版本后,发现部分请求仍然使用旧版本模型的行为,导致输出不一致。更严重的是,某些请求在处理过程中发生了模型切换,导致同一个请求的不同部分使用了不同版本的模型。

根本原因

问题出在模型版本管理的设计上。最初的实现中,模型版本是一个全局变量,请求处理时直接读取当前版本。当版本切换发生时:

  1. 正在处理的请求:已经加载了旧模型的请求继续使用旧模型,新开始的请求使用新模型,导致同一时间存在两种行为
  2. 多步骤请求:一个请求可能包含多个模型调用(如先摘要再翻译),如果在两个调用之间切换了版本,就会出现不一致
  3. 缓存污染:旧模型生成的缓存结果在新版本上线后仍然被使用,导致新旧结果混合
  4. 配置不同步:模型版本切换时,相关的配置(如提示词模板、后处理参数)没有同步更新

解决方案

  1. 请求级版本锁定:每个请求在开始时确定使用的模型版本,在整个请求生命周期内保持不变
  2. 原子性版本切换:版本切换操作具有原子性,要么所有新请求使用新版本,要么都使用旧版本,不存在中间状态
  3. 版本感知缓存:缓存键包含模型版本信息,不同版本的结果分开存储,版本切换后旧缓存自动失效
  4. 配置版本化:将模型版本和相关配置绑定在一起,版本切换时配置同步更新
  5. 灰度发布:新版本先在小流量上验证,确认无误后再全量切换
  6. 回滚机制:新版本出现问题时,可以快速回滚到旧版本,同时清理新版本的缓存

经验教训

  • 全局可变状态在并发系统中是危险的,需要精心管理
  • 模型版本切换不是简单地替换模型文件,需要考虑整个系统的状态一致性
  • 缓存是版本管理中容易被忽视的环节
  • 灰度发布和快速回滚是生产系统的必备能力
  • 请求级的版本锁定是确保一致性的简单有效方法

总结

七个月的自托管 AI 栈经历,四个值得记录的 Bug,给我们的启示:

  1. 基础设施比想象中复杂:自托管 AI 栈涉及模型管理、推理服务、向量数据库、内存管理等多个方面,每个方面都有其独特的挑战
  2. Bug 是最好的老师:这四个 Bug 分别涉及内存管理、请求调度、数据一致性和版本管理,覆盖了分布式系统的核心难题
  3. 监控和可观测性至关重要:如果没有完善的监控,很多问题可能长时间不被发现
  4. 渐进式改进:不要试图一开始就构建完美的系统,先让它运行起来,然后在实践中不断改进
  5. 记录和分享:将遇到的问题和解决方案记录下来,不仅帮助自己回顾,也能帮助其他遇到类似问题的人

对于考虑自托管 AI 栈的团队来说,这篇文章提供了一个真实的视角。自托管不是一个周末就能搞定的事情,它需要持续的投入和维护。但如果你有数据隐私、成本控制或定制化的需求,自托管可能是值得的投资。

原文链接:https://dev.to/openmake/i-thought-self-hosting-our-ai-would-take-a-weekend-it-took-seven-months-3ob7

推荐文章

程序员茄子在线接单