一次真刀真枪的工程化改造实录

写在前面:很多同学觉得软件构造这门课就是学学设计模式、背背 UML 图,实验随便糊弄就能过。但当我真正拿到 CogmAIt 这个项目的源码——一个基于 FastAPI 的 AI 模型管理平台后端——试图 pip install 的那一刻,我就知道这门课想教的东西,远比课本上的定义深刻得多。这篇博客记录的,是我如何把一个"装不上、跑不起来、没测试"的项目,改造成"依赖可复现、服务能启动、96% 覆盖率兜底"的工程化项目的全过程。如果你也在做类似的事情,或者你想理解"工程化"到底意味着什么——希望这篇文章能给你一些启发。


一、项目背景

CogmAIt 是一个分层架构的 AI 后端平台,核心能力是管理多种 AI 模型 Provider(OpenAI、Anthropic、Google 等),并提供智能体对话、知识库检索、图谱查询等功能。它的目录结构大致长这样:

source_code_agent/
├── run.py                  # 启动入口(双进程:主服务 + MCP 服务)
├── app/
│   ├── main.py             # FastAPI 入口 + 统一响应中间件
│   ├── core/config.py      # Pydantic Settings 配置中心
│   ├── api/v1/endpoints/   # REST API 路由层(12 个端点模块)
│   ├── models/             # SQLAlchemy ORM 模型
│   ├── schemas/            # Pydantic 数据校验
│   ├── services/           # 业务逻辑层
│   ├── providers/          # 可插拔 AI 模型提供商(watchdog 热加载)
│   ├── db/                 # 数据库会话与初始化
│   └── utils/              # 工具函数库
├── requirements.txt        # 原始依赖清单(剧透:有坑)
└── pyproject.toml          # Poetry 迁移后新增

这个项目有一个非常漂亮的设计:可插拔 Provider 体系ProviderManager 启动时用 pkgutil.iter_modules 自动扫描 app/providers/ 目录,配合 watchdog 实现热加载——新增一个 Provider 只需要新建一个 .py 文件,不需要动路由、不需要改配置。这是开闭原则(OCP)的一个教科书级正面范例,后面的实验会反复印证它。

但漂亮的架构掩盖不了一个残酷的事实:这个项目,根本跑不起来。


二、踩进"依赖泥潭"

2.1 pip install 的当头一棒

拿到代码,第一件事当然是建虚拟环境、装依赖:

python3 -m venv venv && source venv/bin/activate
pip install -r requirements.txt

然后,终端给了我一个大红的 ResolutionImpossible

mineru[core] 2.1.4 depends on pillow>=11.0.0
The user requested pillow==10.0.0
ERROR: ResolutionImpossible

问题出在 requirements.txtpillow==10.0.0pypdf==3.15.5neo4j-graphrag==1.3.0 等硬编码版本,它们和 mineru[core] 之间存在不可调和的版本矛盾。手动去掉版本锁定勉强装完后,python run.py 仍然起不来——终端接着报缺失包,pydantic-settings 还抛了一个 SettingsError

说实话,这个体验让我深刻理解了一个词:依赖地狱(Dependency Hell)

我的理解requirements.txt 本质上是一个"扁平化的愿望清单"——它只能告诉 pip"我想要什么版本",但没有能力自动解决传递依赖之间的冲突。这就像你去餐厅点菜,写了一张单子说"我要A菜、B菜、C菜",但A菜的原料和B菜冲突了——而服务员(pip)只会告诉你"做不了",不会帮你换搭配。

下面这张图展示了整个依赖冲突的核心矛盾:

pillow==10.0.0

mineru[core]==2.1.4

requires pillow>=11.0.0

❌ 版本冲突!

requirements.txt

pillow 10.0.0

mineru 2.1.4

pillow ≥11.0.0

2.2 从源码阅读中发现的三类设计缺陷

在等依赖问题解决的间隙,我通读了一遍源码。工程经验告诉我:在动手改之前,先把地图画清楚。这一读,读出了三个典型缺陷。

缺陷一:Agent 服务的"三国演义"

文件 状态
app/api/v1/endpoints/agents.py 主 API 路由(正在使用
app/services/agent_service.py 独立服务层(未被主路由引用)
app/routes/agent.py 另一套独立路由(未注册到 FastAPI)

同一个"Agent 对话"的功能,居然存在三套独立实现,后两者用了不同的数据库接口,与主路由功能完全重叠。这是早期开发时"写了新的忘删旧的"经典症状,我管它叫代码考古学遗迹。它不会导致 Bug,但会极大地误导后来的维护者——“到底哪个才是真的?”

缺陷二:生产代码里的 print 炸弹

# app/utils/security.py 第 95 行
async def get_current_user(...) -> User:
    try:
        payload = jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"])
        print("用户::", payload)  # ← 每次请求都把 JWT payload 打到控制台
        username: str = payload.get("sub")

这行 print 会把含用户名、过期时间的 JWT payload 打到标准输出。在开发环境无所谓,但如果这段代码上了生产——恭喜,你获得了一个免费的信息泄露漏洞

缺陷三:config.py 的浅拷贝地雷——这个后面详述,它是整个实验一的"明星 Bug"。


三、穿上"工程化铠甲"

3.1 为什么是 Poetry?

既然 requirements.txt 是问题根源,那就换一个"聪明的服务员"。Poetry 的核心优势,用一张思维导图概括:

Poetry 的四大优势

版本锁定

poetry.lock 精确锁定每个包

包括传递依赖的版本

团队协作环境完全一致

依赖分组

--group dev 隔离开发依赖

pytest 等不会进生产环境

构建产物更干净

自动解析

内置 SAT 求解器

自动化解版本冲突

无需手动调版本号

虚拟环境管理

自动创建和管理 venv

无需手动 python -m venv

poetry run 一键执行

类比:如果说 requirements.txt 是手写购物清单,那 Poetry 就是一个智能采购系统——它不仅帮你列清单,还会自动检查库存兼容性、锁定供应商版本、并把开发用品和生产用品分开放。

3.2 迁移实战:两轮走

迁移过程分两轮,这个节奏本身就有工程价值——不要试图一步到位,先保证核心能跑,再逐步补全

# ===== 第一轮:核心依赖 =====
poetry init
poetry add fastapi "uvicorn[standard]" pydantic pydantic-settings \
    python-multipart sqlalchemy pymysql python-dotenv httpx jinja2 \
    sse-starlette email-validator loguru aiofiles
poetry add "python-jose[cryptography]" "passlib[bcrypt]" bcrypt \
    watchdog cachetools pyyaml minio
poetry add --group dev pytest pytest-asyncio pytest-cov pytest-mock

# ===== 第二轮:靠"启动报错"逐个补全 =====
# 每次 poetry run python run.py,看到缺什么就加什么
poetry add langchain-text-splitters
poetry add google-generativeai tavily-python
poetry add neo4j aiomysql asyncpg

第二轮的策略有点像"打地鼠"——启动一次、报错一个、补一个、再启动——但这恰恰体现了一种工程思维:让运行时错误指导你补全依赖,而不是凭猜测把所有东西都装上

3.3 可选重依赖的优雅处理:条件导入

迁移中遇到一个硬骨头:mineru[core](需要 pandas<3)和 modelscope(超 2GB 的本地推理框架)无法正常安装。暴力做法是硬装或者注释掉相关代码,但这两种都不够优雅。

正确的工程做法是条件导入——缺包时不崩溃,只在真正调用时才报功能不可用:

try:
    from magic_pdf.data.data_reader_writer import (
        FileBasedDataWriter, FileBasedDataReader
    )
    MAGIC_PDF_AVAILABLE = True
except ImportError:
    logger.warning("magic_pdf 未安装,PDF 高级解析功能不可用")
    MAGIC_PDF_AVAILABLE = False

这个模式在工业界非常常见——你不会因为用户没装可选功能的依赖,就让整个服务挂掉。服务的可用性,不应该被非核心依赖绑架。

3.4 三个"深水区"兼容性问题

以为装完依赖就能跑了?太天真了。这里有三个让我调了不少时间的深层兼容性问题:

(1)bcrypt 版本陷阱

bcrypt>=4.0 移除了 bcrypt.__about__.__version__ 属性,而 passlib 1.7.4 依赖这个属性做版本探测。结果就是所有密码哈希操作都会误报 ValueError: password cannot be longer than 72 bytes——实际上密码没超长,是版本探测失败后走错了代码路径。

解法:bcrypt>=3.2.0,<4.0.0,把 bcrypt 锁在 passlib 能认识的版本范围内。

这类问题的本质是传递依赖的版本契约被上游单方面打破。你的代码没问题,你依赖的库没问题,但你依赖的库所依赖的库变了——这就是依赖管理里最阴险的地方。

(2)ORM 外键解析失败

app/models/__init__.py 漏导出了 MCPServiceUserServiceConnectionGraphFileExtractionTask 四个模型类。SQLAlchemy 的 create_all() 处理外键时找不到这些表的定义,直接抛 NoReferencedTableError

(3)核心 Provider 缺依赖

openaianthropic 这两个 SDK 居然没在 pyproject.toml 里——Provider 模块导入即失败。

三个问题修完,poetry run python run.py——终端出现了久违的启动日志,Swagger UI 在 localhost:8080/docs 亮了。那一刻的成就感,不亚于 LeetCode 通过了一道 Hard。


四、构建测试安全网

4.1 测试策略设计

服务能跑了,但"能跑"和"跑得对"之间隔着一个测试安全网。我选择从 app/utils/ 下的五个核心工具模块入手,遵循三维度覆盖策略

三维度覆盖策略

Happy Path
正常路径

验证功能正确性

边界条件
跨日/跨年/空值/极值

验证鲁棒性

异常路径
None/依赖故障/非法输入

验证错误处理

测试目录与源码结构一一对齐:

tests/utils/
├── test_datetime_utils.py        # 时间工具(15 用例)
├── test_response.py              # 响应构建(22 用例)
├── test_provider_icon_mapper.py  # 图标映射(14 用例)
├── test_security.py              # 安全模块(46 用例,含 Mock)
└── test_config.py                # 配置模块(16 用例,含 Mock)

4.2 测试揪出的真实 Bug:浅拷贝的"明星 Bug"

写测试最大的价值,不是"绿了就完事",而是在写的过程中把潜藏的 Bug 逼出来。实验一最精彩的发现有两个。

Bug 1:provider_icon_mapper.py 的逻辑分支矛盾

# 第 59 行:允许 .svg 和 .png 通过
if not url.endswith('.svg') and not url.endswith('.png'):
    return get_icon_filename(provider_id)

# 第 71 行:只允许 .svg —— .png 在这里被踢出去了!
if not filename.endswith('.svg'):
    return get_icon_filename(provider_id)

一个 .png 的 URL,在第 59 行被放行,到第 71 行又被拦截,最终返回了默认图标。这是一个典型的**"逻辑分支不一致"Bug**——两道关卡的规则不统一,导致数据在通道里被来回踢皮球。

Bug 2:config.py 的浅拷贝污染——实验一的"明星 Bug"

这个 Bug 值得单独展开讲,因为它涉及 Python 里一个非常容易踩的坑:dict.copy()浅拷贝

# config.py 第 66 行
_config = DEFAULT_CONFIG.copy()  # 只拷贝了外层!

DEFAULT_CONFIG 是一个嵌套字典,类似这样的结构:

DEFAULT_CONFIG = {
    "neo4j": {"uri": "bolt://localhost:7687", "user": "neo4j"},
    "minio": {"endpoint": "localhost:9000"},
    # ...
}

dict.copy() 只复制了最外层,内层的 {"uri": "bolt://localhost:7687", ...} 仍然是同一个对象引用。这意味着——如果有人修改了 load_config() 的返回值里的嵌套字段,全局的 DEFAULT_CONFIG 也会被污染

我写了一个破坏性测试,一击命中:

def test_shallow_copy_pollutes_default_config(self, mock_exists):
    loaded = load_config()
    loaded["neo4j"]["uri"] = "bolt://hacked:9999"
    # 预期:DEFAULT_CONFIG 不应该被改变
    # 现实:DEFAULT_CONFIG["neo4j"]["uri"] 也变成了 "bolt://hacked:9999"!
    assert DEFAULT_CONFIG["neo4j"]["uri"] == "bolt://hacked:9999"  # 被污染了!

用一张图来理解浅拷贝 vs 深拷贝:

copy.deepcopy() 深拷贝

返回的 _config

'neo4j' 键

DEFAULT_CONFIG

'neo4j' 键

{'uri':'bolt://localhost:7687'}
✅ 独立副本

{'uri':'bolt://localhost:7687'}
✅ 独立副本

dict.copy() 浅拷贝

外层独立

外层独立

⚠️ 共享引用!

⚠️ 共享引用!

返回的 _config

'neo4j' 键

DEFAULT_CONFIG

'neo4j' 键

{'uri':'bolt://localhost:7687'}

修复方案很简单:

 import os
 import json
+import copy

-def load_config() -> Dict[str, Any]:
+def load_config(config_path: Optional[str] = None) -> Dict[str, Any]:
-    _config = DEFAULT_CONFIG.copy()
+    _config = copy.deepcopy(DEFAULT_CONFIG)   # 深拷贝,彻底切断引用

修复前跑测试套件:14 failed——浅拷贝污染和 bcrypt 兼容性 Bug 被同时逼出。这就是"测试即安全网"的价值:你以为代码在"正确地运行",其实只是"幸运地没出事"。测试做的事情,就是把运气变成证据。

4.3 最终测试结果

$ poetry run pytest tests/ -v
======================== 113 passed, 3 warnings in 8.17s ========================
模块 语句数 覆盖率 未覆盖行
app/utils/__init__.py 16 100%
app/utils/response.py 11 100%
app/utils/config.py 74 92% 90,92,94,169,171,173
app/utils/provider_icon_mapper.py 23 87% 75-77,84
app/utils/security.py 93 100%
合计 217 96%

5 个核心模块综合覆盖率 96%,每个模块单独 >80%。数字本身不是目的,但它给了我一个信号:这个项目的核心工具链,现在有了一张可信赖的安全网。


五、Mock 技术实战

写测试最大的技术难点,不是怎么断言——而是怎么隔离security.py 里的函数依赖 JWT 密钥、数据库会话、FastAPI 的 Depends 注入、甚至系统时间——如果不做隔离,你的"单元测试"就变成了"集成测试"。

我总结了四种 Mock 模式,可以覆盖绝大多数隔离需求:

Mock 四式

① @patch 替换全局依赖
Mock settings.SECRET_KEY
Mock get_cn_datetime()

② MagicMock 模拟链式调用
mock_db.query.return_value
.filter.return_value
.first.return_value = mock_user

③ mock_open + @patch.dict
隔离文件系统和环境变量
测试 load_config()

④ @pytest.mark.asyncio + @patch
测试 async 函数
关键:编解码用同一 Mock Key

这里有一个容易踩的坑值得展开说:测试 JWT 相关函数时,创建 token 和验证 token 必须使用同一个 Mock Secret Key。如果你 Mock 了创建侧的密钥但没 Mock 验证侧的,或者反过来——解码必然失败,而错误信息会让你以为是 token 格式有问题。

# ✅ 正确做法:创建和验证用同一把"假钥匙"
MOCK_SECRET = "test-secret-key-for-unit-test"

@patch("app.utils.security.settings")
def test_create_and_verify_token(self, mock_settings):
    mock_settings.SECRET_KEY = MOCK_SECRET
    # 创建和验证都走这个 Mock,逻辑自洽
    token = create_access_token(data={"sub": "testuser"})
    payload = jwt.decode(token, MOCK_SECRET, algorithms=["HS256"])
    assert payload["sub"] == "testuser"

凭借这套 Mock 体系,security.py 全部 11 个函数(含 5 个 async)实现了 100% 覆盖、46 个用例


六、测试驱动重构

在写 config.py 的测试时,我遇到了一个比 Bug 更深层的问题:这段代码的设计本身就是"不可测试"的。

CONFIG_PATH 在模块导入时就被硬编码了,load_config()save_config() 都直接读写这个固定路径。这意味着你没法在测试里指定一个临时文件——要么 Mock 整个文件系统,要么就只能忍受测试和真实配置互相干扰。

这不是打个补丁就能解决的事情——需要重构

重构前(不可测试) 重构后(可测试)
def load_config(): 固定读 CONFIG_PATH def load_config(config_path=None): 支持依赖注入
def save_config(config): 固定写 CONFIG_PATH def save_config(config, config_path=None): 同样可注入
_config = DEFAULT_CONFIG.copy() _config = copy.deepcopy(DEFAULT_CONFIG)

这是一次经典的测试驱动重构:不是因为功能有错才重构,而是因为要写测试,才发现设计有问题,进而主动改善设计

说一句感悟吧:很多人把"写测试"理解成"功能完成后的扫尾工作"。但实际上,测试是代码设计质量的照妖镜。如果一段代码很难测试,大概率不是测试工具的问题,而是代码本身耦合度太高、职责不清晰。测试的价值,不仅仅是"验证正确性",更是"倒逼设计演化"。


七、工程规范:像写文章一样写 Commit

最后聊聊版本管理。整个实验一的提交历史遵循 Conventional Commits 规范:

29e5a83 fix:      修复服务启动链中的多个兼容性问题
e420db8 fix:      补全 Poetry 缺失依赖 + 可选重依赖条件导入
80a844f test:     补齐 security.py 异步函数测试,覆盖率 100%
56e29b7 refactor: 修复 config.py 浅拷贝 Bug + 依赖注入改造
1b47da5 fix:      修复测试兼容性,94 用例通过
9cc7293 feat:     Poetry 迁移 + 首批单元测试
753ebd8 chore:    初始化项目原始代码

每一条提交都是原子化的——一个 commit 做一件事,类型标签(feat/fix/refactor/test)一目了然。这不是在装逼,而是在给未来的自己买保险。当你需要 git bisect 定位某个回归 Bug 的时候,你会感谢现在的自己把提交粒度控制得足够细。


八、全局回顾

做完实验一,我尝试把收获画成一张完整的知识脉络图:

实验一:工程化地基

依赖管理

理解"依赖地狱"的本质

Poetry SAT 求解器的优势

条件导入处理可选重依赖

传递依赖的版本契约风险

测试工程

三维度覆盖策略

测试是代码设计的照妖镜

破坏性测试逼出潜藏 Bug

覆盖率是手段不是目的

Mock 技术

四种隔离模式

异步函数的 Mock 注意事项

编解码侧密钥一致性

测试驱动重构

不可测试 → 依赖注入改造

浅拷贝 → 深拷贝

写测试倒逼设计演化

版本管理

Conventional Commits

原子化提交粒度

给未来的自己买保险

如果要用一句话总结实验一的核心启发,我想说的是:

“工程化"不是某个工具或框架——它是一种思维方式。它的本质是:把"它恰好能跑"变成"它必然能跑,出了问题我能证明、能定位、能回滚”。

Poetry 给了你环境的可复现性,pytest 给了你行为的可验证性,Conventional Commits 给了你历史的可追溯性。三者合力,构成了一个项目最基本的"工程化铠甲"。

没有这套铠甲,后面的 ADT 设计、OOP 扩展、架构重构——全是空中楼阁。


下一篇预告:实验二——ADT 与 OOP 设计。我将用 SessionContextKnowledgeChunk 两个自己设计的 ADT,亲手实践 AF/RI 与防御性编程,并在零侵入新增 Provider 的过程中,把开闭原则从"课本定义"变成"肌肉记忆"。

如果这篇文章对你有帮助,欢迎点赞收藏,你的反馈是我继续写下去的动力。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐