
1. 从脚本到系统AI 项目工程化到底在解决什么问题写了四五十课的 Python语法、爬虫、数据分析、可视化基本都摸过一遍了这时候很多人会卡在同一个坎上单个.py文件跑得挺欢一旦要把一个 AI 能力做成能给别人用、能长期维护、能反复迭代的东西就立刻乱成一锅粥。模型权重放哪、提示词写死在代码里、依赖版本对不上、换台机器就跑不起来、接口一改下游全崩——这些都不是算法问题而是工程问题。AI 项目工程化说白了就是把“我本地能跑”变成“谁都能跑、天天都能跑、改了还能跑”。它跟传统软件工程有重叠但多了几块特有的麻烦模型和数据是有体积的、提示词和参数是需要版本管理的、推理结果是带随机性的、成本是按 token 或算力计费的。这一课我打算把这几块拆开讲透从目录结构、依赖管理、配置分离到模型封装、服务化、测试和部署给一套可以直接抄的骨架。适合已经会写 Python、但项目一超过三个文件就头疼的人也适合做 AI Agent、大模型应用、量化策略这类需要长期维护的开发者。我踩过的最大一个坑就是早期把所有逻辑塞进一个main.py提示词直接写成字符串常量模型路径写死成绝对路径。结果换台机器路径全废想调提示词得翻几百行代码想对比两个版本效果只能靠手动改完再跑一遍改回去还容易漏。工程化要解决的就是这种“改一处、崩一片”的连锁反应。2. 项目骨架设计目录结构决定你能走多远2.1 为什么目录结构是工程化的第一道门槛很多人觉得目录结构是形式主义能跑就行。但目录结构本质上是职责边界的物理体现。你把配置、数据、模型、业务逻辑、测试混在一起代码耦合就是必然的因为物理上它们都挨着随手 import 一下就跨层调用了。等到项目膨胀到几千行你会发现改一个提示词模板居然要动到数据加载模块这就是边界没划清的代价。一个能扛住长期迭代的 AI 项目我一般会按下面这个骨架来搭。它不是唯一解但每一层都有明确理由ai-project/ ├── configs/ # 配置层环境、模型、提示词 │ ├── base.yaml │ ├── dev.yaml │ └── prod.yaml ├── src/ │ ├── __init__.py │ ├── data/ # 数据加载与预处理 │ ├── models/ # 模型封装与推理 │ ├── prompts/ # 提示词模板集中管理 │ ├── services/ # 业务编排层 │ └── utils/ # 通用工具 ├── tests/ # 测试 ├── scripts/ # 一次性脚本、数据迁移 ├── notebooks/ # 探索性分析不进生产 ├── requirements.txt ├── pyproject.toml └── README.md关键点在于configs和src的分离。配置是“会变的东西”代码是“相对稳定的东西”把易变的抽出去代码就不用频繁改。notebooks单独放是因为探索性代码天生脏乱绝不能让它污染生产路径——我见过太多项目最后生产代码里 import 了一个 notebook 里的函数那个 notebook 还带着一堆调试输出。2.2 配置分离把提示词和参数从代码里赶出去AI 项目跟普通项目最大的区别就是提示词也是代码资产。它需要版本管理、需要 A/B 对比、需要按环境切换。把提示词写死在 Python 字符串里等于放弃了这一切。我的做法是统一放prompts/目录用模板文件管理prompts/ ├── summarize_v1.txt ├── summarize_v2.txt └── classify.txt然后在配置里指定用哪个版本。这样调提示词就是改文件不用碰代码还能用 git 直接 diff 两个版本的差异。参数同理温度、最大长度、重试次数这些全部进 YAML# configs/base.yaml model: name: your-model-name temperature: 0.7 max_tokens: 1024 timeout: 30 retry: 3 prompt: summarize: summarize_v2 classify: classify paths: data_dir: ./data output_dir: ./outputs加载配置我推荐用pydantic配合pyyaml因为 pydantic 能在加载时做类型校验配置写错了立刻报错而不是跑到一半才崩。这一点在 AI 项目里特别重要因为很多错误比如温度写成字符串在运行时才暴露排查成本极高。注意配置文件里绝对不要放密钥。密钥走环境变量配置里只放“从哪个环境变量读”的引用。这是安全底线也是团队协作的基本规矩。2.3 依赖管理为什么 requirements.txt 不够用pip install -r requirements.txt是入门做法但它有个致命问题不锁定传递依赖。你写torch2.0今天装的是 2.0.1明天可能是 2.1.0行为可能就变了。AI 项目对版本极其敏感一个 CUDA 版本对不上整个推理就废了。我的建议是分两层requirements.in写你直接依赖的顶层包宽松版本用pip-tools编译出锁定的requirements.txt精确版本。这样既方便升级又能保证复现pip install pip-tools pip-compile requirements.in -o requirements.txt pip-sync requirements.txtpip-sync比pip install更狠它会把你环境里多余的包删掉保证环境和锁定文件完全一致。这在 CI 和部署时特别有用能避免“本地能跑、服务器不行”的经典问题。如果项目更复杂直接上poetry或uv它们把依赖、虚拟环境、打包一体化管理省心不少。3. 模型与推理封装让 AI 能力变成可调用的积木3.1 封装的意义隔离变化统一接口AI 项目里模型是最容易变的部分。今天用这个 API明天换本地部署后天加个缓存层。如果业务代码直接调用模型 SDK那每次换模型都要改遍全项目。正确做法是定义一个抽象接口把模型调用包在models/层里# src/models/base.py from abc import ABC, abstractmethod class BaseLLM(ABC): abstractmethod def generate(self, prompt: str, **kwargs) - str: ... abstractmethod def batch_generate(self, prompts: list[str], **kwargs) - list[str]: ...然后针对不同后端实现这个接口。业务层只依赖BaseLLM不关心底层是哪个模型。这样换模型就是加一个实现类改一行配置业务代码纹丝不动。这就是依赖倒置在 AI 项目里的实际价值。3.2 重试、超时与降级AI 调用必须假设它会失败AI 调用跟普通函数调用最大的不同是它天然不稳定。网络会抖、服务会限流、模型会返回空、会超时。如果你不处理这些线上就是随机崩。我的封装里必带三件套超时、重试、降级。import time from tenacity import retry, stop_after_attempt, wait_exponential class RemoteLLM(BaseLLM): def __init__(self, config): self.config config retry( stopstop_after_attempt(3), waitwait_exponential(multiplier1, min1, max10), ) def generate(self, prompt: str, **kwargs) - str: try: resp self._call_api(prompt, timeoutself.config.timeout, **kwargs) if not resp or not resp.strip(): raise ValueError(empty response) return resp except Exception as e: # 记录日志便于排查 logger.warning(fgenerate failed: {e}) raisetenacity的指数退避很关键第一次等 1 秒第二次 2 秒第三次 4 秒避免瞬间打爆下游。降级策略则看业务可以返回缓存结果、返回兜底文案、或者抛给上层决定。关键是不能让一次失败直接冒泡到用户。实操心得重试次数不是越多越好。我一般设 3 次因为超过 3 次还失败大概率是服务真挂了再重试只是浪费时间和配额。同时一定要给重试加日志否则线上出问题你根本不知道重试了多少次。3.3 缓存省钱又提速的隐形功臣AI 调用又慢又贵缓存是性价比最高的优化。最简单的做法是用functools.lru_cache但它只对纯函数有效且进程重启就没了。生产环境我推荐用文件缓存或 Rediskey 用“模型名 提示词哈希 参数哈希”import hashlib, json, os def cache_key(model: str, prompt: str, params: dict) - str: raw json.dumps({m: model, p: prompt, k: params}, sort_keysTrue) return hashlib.sha256(raw.encode()).hexdigest()这里有个坑参数里如果有随机种子或时间戳缓存永远命中不了。所以缓存 key 要只包含影响输出的确定性参数。另外缓存要设过期时间因为模型可能更新旧结果未必还适用。我一般设 7 天兼顾命中率和新鲜度。4. 服务化与接口设计把能力交出去4.1 从函数到服务什么时候该上 Web 框架当你的 AI 能力需要被别的系统调用、被前端调用、或者被多个用户同时用时就该服务化了。Python 里最轻的选择是 FastAPI它自带类型校验和自动文档对 AI 项目特别友好因为 AI 接口的参数往往很多类型校验能挡掉大量脏输入。from fastapi import FastAPI, HTTPException from pydantic import BaseModel app FastAPI() class SummarizeRequest(BaseModel): text: str max_length: int 200 class SummarizeResponse(BaseModel): summary: str model: str app.post(/summarize, response_modelSummarizeResponse) def summarize(req: SummarizeRequest): if len(req.text) 10000: raise HTTPException(status_code400, detailtext too long) result service.summarize(req.text, max_lengthreq.max_length) return SummarizeResponse(summaryresult, modelconfig.model.name)用 pydantic 定义请求和响应模型好处是接口契约显式化。前端一看文档就知道要传什么、会收到什么减少沟通成本。而且 FastAPI 会自动生成/docs页面联调时特别省事。4.2 同步还是异步AI 接口的性能关键AI 调用是 IO 密集型等网络、等模型如果用同步接口一个请求卡住整个线程就堵着。FastAPI 支持async def配合异步 HTTP 客户端能大幅提升并发。但要注意如果你的模型调用是同步阻塞的比如本地推理放进 async 函数里反而会阻塞事件循环。这时候要么用线程池要么老老实实写同步接口让框架自己调度。import asyncio from concurrent.futures import ThreadPoolExecutor executor ThreadPoolExecutor(max_workers4) app.post(/summarize) async def summarize(req: SummarizeRequest): loop asyncio.get_event_loop() result await loop.run_in_executor( executor, service.summarize, req.text ) return {summary: result}这个模式我用了很多次把阻塞的推理丢进线程池事件循环继续处理其他请求并发能力立刻上一个台阶。max_workers要根据模型的实际并发能力设设太大反而会拖垮模型服务。4.3 限流与鉴权别让服务被薅秃服务一旦暴露就会有人或脚本疯狂调用。限流是必须的最简单的做法是按 IP 或 API Key 计数。FastAPI 可以用中间件实现from collections import defaultdict import time rate_limit defaultdict(list) app.middleware(http) async def limit_middleware(request, call_next): ip request.client.host now time.time() rate_limit[ip] [t for t in rate_limit[ip] if now - t 60] if len(rate_limit[ip]) 60: return JSONResponse(status_code429, content{detail: too many requests}) rate_limit[ip].append(now) return await call_next(request)这段代码实现的是“每分钟 60 次”的滑动窗口限流。生产环境建议用 Redis 存计数因为多进程部署时内存计数不共享。鉴权则用 API Key 或 Token放在请求头里校验别放 URL 参数里否则会进日志泄露。5. 测试与可观测性让问题在爆发前被发现5.1 AI 项目怎么测确定性测试与不确定性测试分开AI 项目的测试比普通项目难因为输出不确定。我的策略是分层测试确定性逻辑数据清洗、参数校验、缓存 key 生成用传统单元测试断言精确值不确定性逻辑模型输出用“属性测试”只断言输出满足某些性质比如非空、长度在范围内、包含关键字段。def test_cache_key_stable(): k1 cache_key(m, hello, {t: 0.7}) k2 cache_key(m, hello, {t: 0.7}) assert k1 k2 # 确定性 def test_summarize_not_empty(): result service.summarize(一段测试文本) assert isinstance(result, str) assert len(result) 0 # 属性断言不比对具体内容对于提示词效果硬断言没用得靠评测集。我会维护一个小规模的人工标注集每次改提示词就跑一遍看准确率、召回率有没有退化。这套评测脚本放scripts/里不进生产但每次发版前必跑。5.2 日志与追踪线上出问题靠什么定位AI 项目的日志要记三样东西输入、输出、耗时。输入用于复现输出用于分析耗时用于性能优化。但要注意脱敏用户输入里可能有敏感信息日志里要过滤。我一般用结构化日志import logging, json logger logging.getLogger(ai) def log_call(prompt, output, elapsed, model): logger.info(json.dumps({ event: llm_call, model: model, prompt_len: len(prompt), output_len: len(output), elapsed: round(elapsed, 3), }, ensure_asciiFalse))用 JSON 格式是为了方便后续接入日志系统做检索和统计。prompt_len而不是完整 prompt是出于隐私和体积考虑。如果确实需要完整记录单独存到受控的存储里并设访问权限。常见问题很多人日志只记“调用成功”不记失败原因。结果线上报错率上升却不知道是超时、限流还是模型返回异常。我的做法是失败日志里带上异常类型和重试次数这样一眼就能看出问题分布。5.3 成本监控AI 项目独有的账本AI 调用是要花钱的token 用量、GPU 时长都是成本。工程化项目必须能回答“这个月花了多少、哪个功能最费钱”。做法是在封装层记录每次调用的 token 数或估算值汇总到监控系统。我一般按“功能模块 模型”两个维度统计这样能快速定位成本大头。如果某个功能成本异常高要么优化提示词要么换更便宜的模型要么加缓存。6. 部署与持续迭代让项目活得久6.1 容器化一次构建到处运行AI 项目部署最大的痛点是环境。CUDA 版本、Python 版本、系统库任何一处不一致都可能跑不起来。Docker 是标准解法。一个精简的 DockerfileFROM python:3.11-slim WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY src/ ./src/ COPY configs/ ./configs/ COPY prompts/ ./prompts/ ENV PYTHONPATH/app EXPOSE 8000 CMD [uvicorn, src.services.api:app, --host, 0.0.0.0, --port, 8000]关键点是先复制依赖文件再复制代码这样改代码不会触发依赖重装构建快很多。--no-cache-dir减小镜像体积。如果模型文件很大不要打进镜像用挂载卷或对象存储镜像只放代码。6.2 配置注入同一份镜像跑多环境镜像应该只有一份环境差异靠配置注入。用环境变量指定加载哪个配置文件import os, yaml def load_config(): env os.getenv(APP_ENV, dev) with open(fconfigs/{env}.yaml) as f: return yaml.safe_load(f)这样 dev、prod 用同一个镜像只是APP_ENV不同。密钥也走环境变量绝不进镜像。这是十二要素应用的基本原则AI 项目同样适用。6.3 灰度与回滚改提示词也要能撤回AI 项目迭代频繁提示词、模型、参数都可能变。每次变更都应该能灰度、能回滚。最简单的做法是配置里加一个“流量比例”新版本先接 10% 流量观察指标没问题再全量。回滚就是把配置改回去重启服务。因为提示词和参数都在配置里回滚不需要重新构建镜像几秒钟就能完成。我个人的经验是任何影响输出的变更都要有回滚预案。有一次我改了个提示词本地测着挺好上线后某个边界场景输出全乱了幸好配置能秒回滚没造成大影响。从那以后我坚持所有提示词变更都走配置绝不硬编码。7. 常见问题与排查速查实际做工程化的过程中问题往往集中在几个地方。我把踩过的坑整理成表方便对照排查现象可能原因排查方向本地能跑服务器报错依赖版本不一致用锁定文件检查 Python 版本模型输出为空提示词问题或超时看日志里的原始响应和耗时并发上不去同步阻塞事件循环检查是否用了线程池缓存命中率低key 含随机参数检查 key 生成逻辑成本突然飙升某功能调用量激增按模块统计 token 用量配置改了不生效缓存了配置对象检查配置加载时机排查的核心思路是先定位层次是配置问题、依赖问题、还是代码逻辑问题。我一般从日志入手看最后一次成功和第一次失败的差异往往能快速缩小范围。另外本地复现是王道如果本地复现不了说明环境差异是主因优先查依赖和配置。避坑技巧给项目加一个scripts/healthcheck.py启动时自检配置、依赖、模型连通性。部署后先跑一遍能挡掉大部分低级问题。8. 我在这条路上的一些真实体会工程化这东西刚开始做会觉得繁琐觉得不如直接写脚本痛快。但项目一旦要活过三个月、要交给别人维护、要天天稳定跑这些“繁琐”就是救命稻草。我最大的转变是从“能跑就行”变成“改了还能跑”。这个转变的代价是前期多花时间搭骨架收益是后期改需求时不用推倒重来。还有一点工程化不是一次性任务是持续习惯。每次加新功能都问自己配置抽出去了吗有测试吗失败会怎样日志够定位吗养成这个习惯后项目自然就稳了。至于工具选型别追新选团队熟悉的、社区活跃的能省很多事。FastAPI、pydantic、tenacity、Docker 这套组合我用了几年没出过大问题推荐给刚起步的人。最后分享一个小技巧把项目的“运行手册”写进 README包括怎么装、怎么跑、怎么测、怎么部署、出问题找谁。这份文档的价值在你休假时别人要接手的那一刻会体现得淋漓尽致。