这些年做AI应用,我最大的感受是:很多人会训练模型、会写提示词,但一提到工程化就露怯。ai-engineering-from-scratch这个项目,就是把我自己从只会调用现成接口、到能独立交付一个可用Web服务的完整过程,沉淀成的一个实践记录。核心是尽量用最少的框架依赖,自己动手把模型加载、推理逻辑、接口封装、前端展示这一整条链路从零搭起来,而不是上来就套云平台或者成熟中间件。它能解决的问题,是让开发者真正理解AI应用背后那些“看不见的部分”;适合两类人看:一类是刚入门、想搞清楚AI应用到底怎么跑起来的,另一类是已经会调用API但总被工程细节卡住的人。
我动手之前也没想到会踩这么多坑。原以为最难的部分是用模型,真正做起来才发现,模型推理只是整条链路里最容易的一环。真正的工程难点,在于怎么把模型稳稳当当包进一个长期运行的服务里,怎么处理并发和超时,怎么让接口返回结构统一,以及怎么在前端把用户输入顺畅地送到后端再拿回来。这些内容,教科书上很少提,但每个上过线的AI项目都绕不开。下面按我实际推进的顺序,把整个项目的设计思路、核心模块、完整实操和踩坑记录拆开讲清楚。
1. 为什么非要从零开始:项目的设计初衷与整体思路
1.1 “from scratch”到底要避免什么
如果只看“from scratch”这几个字,很多人会以为是要把Transformer从零写一遍、把优化器从零实现一遍。那我得先泼一盆冷水:没必要。现在开源社区里模型、推理库、框架都已经很成熟,你非要自己从零写反向传播,那只适合教学实验,不适合解决实际问题。我这个项目的“from scratch”,是指在应用层不做黑盒化处理——不把整个AI链路直接交给一个一体化平台,不把数据输入输出交给一个私有协议,而是自己把流程给接起来、串起来、管起来。
具体来说,我用开源的模型权重和推理库作为底层支撑,但模型怎么加载、参数怎么传、输出怎么解析、请求怎么路由、错误怎么返回、前端怎么调用,全部由自己写代码明确实现。就像学开车,你可以先开自动挡,但总得知道油门、刹车、方向盘各自连着什么;不然车一旦出点小毛病,你连问题出在哪都不知道。AI工程也是同样的道理:先用最朴素的方式把链路跑通,再去用那些封装好的高级平台,你会更有底气,因为你知道底层在做什么。
这也避免了一个实际痛点:很多项目用别人的服务时跑得很顺,一旦需要迁移、换模型、加定制逻辑,就完全卡住,因为那些关键环节被框架锁死了。而“from scratch”方式写出来的代码,每个模块都是自己的,换模型、换框架、改流程都清晰可控。说得直白一点,这个项目练的不是“调API”,而是“造API”的能力。
1.2 整体技术栈选型:少用抽象,多用基石
选型这件事,我纠结了很长时间。最初想过用LangChain这类编排框架,又想过直接上dify等应用平台,但最后全部放弃了。我的选型原则是:每个组件,我都要能解释清楚它存在的理由,去掉它不心疼的那种才留下。
后端语言选了Python,这没什么悬念,AI生态最好的就是它。Web框架选了FastAPI,原因是它自带数据校验、异步支持和自动接口文档,而且足够轻——这几点对AI推理服务特别重要。模型加载方面,我还是用开源生态里的transformers库,但封装完全自己写,因为模型tokenizer、生成参数这些细节必须能自己控制。前端就用了HTML加原生JavaScript,不搞构建工具链,纯静态页面直接调用后端接口。
对照一下我当时列出的备选方案:
| 组件 | 备选方案 | 最终选择 | 理由 |
|---|---|---|---|
| 语言 | Python / Node.js | Python | AI生态成熟,模型推理和数据处理方便 |
| Web框架 | FastAPI / Flask / Django | FastAPI | 轻量、异步、自带pydantic校验和OpenAPI文档 |
| 编排框架 | LangChain / 自研 | 自研 | 编排框架抽象太重,出了问题很难定位 |
| 模型库 | transformers / 纯手动推理 | transformers | 用成熟库加载权重,保留自研封装控制细节 |
| 前端 | 原生JS / Vue / React | 原生JS | 场景简单,不引入构建工具,减少失败点 |
这里面最关键的决定是放弃LangChain。不是说它不好,而是对一个教学性质明显、追求理解深度的项目来说,它的抽象层级太高。你调一个接口,框架替你做了很多事,但一旦报错,你连是哪一环出错的都看不出。自己写一遍原生调用逻辑之后,你再看LangChain的实现,会瞬间明白它在替你做什么、背后每种模型又有哪些差异。这也是“from scratch”的最大价值所在。
2. 核心模块拆解与关键设计决策
2.1 工程目录怎么组织才不混乱
项目一开始我犯过一个典型错误:把所有代码塞进一个main.py里,模型加载、数据处理、路由逻辑全混在一起。结果改了十几天之后,自己看代码都头疼。后来按职责拆了目录,结构稳定多了,强烈推荐给你:
ai-engineering-from-scratch/ ├── app.py # 服务启动入口,只做初始化 ├── config/ │ └── settings.py # 全局配置:模型路径、超时、端口 ├── models/ │ └── llm_service.py # 模型加载与推理封装 ├── api/ │ ├── routes.py # API路由定义 │ └── schemas.py # 请求/响应数据模型 ├── services/ │ └── generate.py # 核心业务逻辑:参数处理、调用模型、结果后处理 ├── web/ │ ├── index.html # 演示页面 │ └── app.js # 前端调用逻辑 ├── tests/ │ └── test_api.py # 接口回归测试 └── logs/ └── app.log # 运行时日志这个目录的要点是“入口要瘦,模块要胖”。app.py只负责创建应用、载入配置、注册路由;模型加载放在models/llm_service.py里,不会因为请求进来就重复初始化;业务逻辑统一走services/generate.py,路由层只做参数接收和响应返回。这样分层之后,排查问题会变得很简单——接口报错了先去routes看参数,再查services看逻辑,模型问题去models层看,不用满项目翻文件找线索。
对了,config/settings.py里我会集中放一些关键参数,比如模型名称、设备类型、超时时间、默认生成参数。这样后续更换模型时只需要改一个文件,不需要去代码里翻找硬编码的字符串。
2.2 模型层:加载、推理与通用接口
模型层是整个项目的心脏,也是最容易写出烂代码的地方。核心设计目标只有一个:把模型的加载和调用封装成一个稳定的对象,其他模块完全不关心内部用的是哪个模型、什么结构。我实现的LLMService类大概长这样:
import torch from transformers import AutoModelForCausalLM, AutoTokenizer class LLMService: def __init__(self, model_name: str, device: str = "auto"): self.device = self._detect_device(device) self.tokenizer = AutoTokenizer.from_pretrained(model_name) self.model = AutoModelForCausalLM.from_pretrained( model_name, torch_dtype=torch.float16 if self.device == "cuda" else torch.float32 ).to(self.device) self.model.eval() def _detect_device(self, device): if device == "auto": return "cuda" if torch.cuda.is_available() else "cpu" return device def generate(self, prompt: str, max_new_tokens: int = 128, temperature: float = 0.7, top_p: float = 0.9) -> str: inputs = self.tokenizer(prompt, return_tensors="pt").to(self.device) with torch.no_grad(): outputs = self.model.generate( **inputs, max_new_tokens=max_new_tokens, temperature=temperature, top_p=top_p, do_sample=temperature > 0 ) return self.tokenizer.decode(outputs[0][inputs["input_ids"].shape[1]:], skip_special_tokens=True)几个关键决策解释一下。首先,设备检测用auto,有CUDA就用GPU,没有就退回CPU。对个人项目来说,CPU完全可以跑,只是速度慢,但你能正常调试和验证流程,这很重要。其次,模型初始化时用了model.eval(),因为推理模式下要关闭dropout等训练专用行为,忘记这一步会让输出出现随机波动。再者,decode时通过切片去掉输入部分,只保留新生成的token。刚开始我没做这一步,前端拿到的回复里混着整段原文,看起来非常奇怪。
还有一个容易踩的坑是do_sample参数。当temperature等于0时表示贪心解码,此时如果还设置do_sample=True,有些模型设置下会忽略温度值直接采样。所以我在代码里做了联动:do_sample=temperature > 0。这个问题在官方demo里经常被忽略,实际用的时候会困惑为什么输出不稳定,就是因为这个细节没处理好。
2.3 服务层:把推理变成可调用的API
模型层搞定之后,下一步是把生成能力变成“能被外部调用”的服务。这一层我设计了统一的请求、响应结构,所有接口都保持一致的格式。请求体我用Pydantic来定义,天然支持字段校验和默认值:
from pydantic import BaseModel, Field class GenerateRequest(BaseModel): prompt: str = Field(..., min_length=1, max_length=2048, description="用户输入的提示词") max_new_tokens: int = Field(128, ge=1, le=1024, description="最大生成长度") temperature: float = Field(0.7, ge=0.0, le=1.5, description="采样温度") top_p: float = Field(0.9, ge=0.1, le=1.0, description="核采样概率")响应也统一包装成{code, message, data}的结构。code为0表示成功,非0表示失败;message是给人看的说明;data里放真正的生成结果和耗时。可能有人觉得这样包装麻烦,但对生产环境很实用:前端只需要判断code就能决定是否展示正常内容,后端报错时能通过code快速定位类型,而不是靠解析一团混乱的报错文本。
FastAPI路由层很薄,只干三件事:接收请求、调用service、构造响应。核心逻辑抽到了services/generate.py里,这样做是有意的——路由层不写业务,避免随着功能扩展变成大泥球。举个实际例子,如果以后要给生成结果加敏感词过滤,只需要改services里的一处逻辑,前端和路由完全不用动。
一个我一开始没注意、后来补上的细节:请求体字段要设置合理边界。max_new_tokens限制在1024以内,是为了防止某次恶意或误操作传入10000,导致GPU资源被长时间占用,拖垮整个服务。在生产环境里,类似这种“防呆设计”非常必要。
2.4 前端层:极简演示也是一种工程能力
很多人做AI后端项目,会小看前端部分,觉得“能curl通就行了”。但实际上,一个能点开就用的页面,会在调试阶段帮你发现大量后端接口设计上的问题。比如token如何传、加载状态怎么展示、流式输出要不要支持、响应时间长了怎么处理,这些问题只有真实用页面调用时才会暴露。
我的前端页面非常简单,一个输入框、一个生成按钮、一个展示区域,用原生JavaScript的fetch调用后端接口:
async function generate() { const prompt = document.getElementById("prompt").value; const res = await fetch("/api/generate", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ prompt: prompt, max_new_tokens: 256 }) }); const data = await res.json(); if (data.code === 0) { document.getElementById("output").textContent = data.data.text; } else { document.getElementById("output").textContent = "出错:" + data.message; } }这个页面不需要打包工具,也不需要依赖任何前端框架,直接放进静态目录就能跑。之所以坚持原生,是因为这个项目的核心目标是理解链路,前端越简单,越能看清“输入怎么变输出”。另外在做接口调试时,我建议先在浏览器开发者工具的Network里看真实请求和响应格式,这比后端打印日志更能直观反映问题。
如果项目跑在本地,页面和后端同源,不需要处理跨域;但如果你把后端起在服务器上,前端页面放在本地通过file://打开,就会遇到CORS问题。这时可以在FastAPI里加CORSMiddleware,只允许你的前端域名访问。
3. 实操全过程:从零跑通一条AI推理链路
3.1 环境搭建与依赖锁定
实操第一步,先把环境搭好。建议所有依赖都锁定版本,不要只用“最新版”。我在项目初期吃过亏:某次升级了transformers库的小版本,结果模型加载方式发生了变化,生成的日志格式也变了,排查了半天才发现是“升级惹的祸”。
创建一个独立的虚拟环境:
python -m venv venv source venv/bin/activate # Windows是 venv\Scripts\activate pip install fastapi uvicorn transformers torch pip freeze > requirements.txt这里多说一句pip freeze的作用:把当前环境里所有已安装的包和版本号写入requirements.txt,下次换机器时一条pip install -r requirements.txt就能复现环境。如果你想让环境干净一点,可以先只手动安装核心依赖,最后再导出锁定文件。如果使用Linux服务器且机器有显卡,还要额外注意CUDA版本和torch版本的匹配关系,否则装好后运行时会直接报找不到CUDA设备。
环境搭建阶段最容易忽略的是Python和pip本身版本。如果你用的Python是3.12之前的版本,大概率会遇到依赖冲突;但3.12和3.13可能又有某个老旧库还没适配。保守起见,我建议直接用Python 3.11,现在的AI生态对这个版本支持最稳妥。这不是我拍脑袋说的,而是实测下来踩坑最少的选择。
3.2 最小可用样例:先让推理脚本跑起来
很多人做项目时喜欢一上来直接写Web服务,我强烈不建议这么干。正确顺序是:先写一个能跑的脚本,验证模型能加载、能生成、输出质量可接受,然后再谈封装成服务。因为如果模型都还没真正跑通,就去写API层,出了bug你会很难判断问题究竟在模型还是在接口。
我当时的第一个脚本就是导入LLMService,传入一句测试提示词,打印生成结果:
python -c " from models.llm_service import LLMService svc = LLMService('path/to/model') print(svc.generate('介绍一下杭州')) "第一次跑通的时候,输出的内容可能不太完善,但这是整个项目的“零点”。从这一刻起,你有了一个固定的测试基准。之后每次改代码,都要先用这段固定输入回归一遍,确认没有把已有的能力改坏。在我项目里,我把这些固定测试提示词集中放在一个tests/test_cases.py里,内容覆盖了短文本、长文本、特殊符号、空字符串等边界情况,每次改动后直接运行,省心很多。
在这个阶段,重点观察三件事:首token延迟是否正常、生成内容是否有明显乱码或重复、CPU或GPU占用是否符合预期。如果生成重复严重,多半是温度和top_p设置不合适;如果首次加载特别慢,可以考虑是否换成更小一点的模型。这些都是很直接的观察指标,越早发现越好改。
3.3 服务化改造与参数透传
脚本能跑之后,再进行服务化改造就是水到渠成的事。改造重点是把脚本里的固定调用改为可动态配置的HTTP接口。在FastAPI中定义一个路由,接收GenerateRequest,然后调用service返回结果:
@app.post("/api/generate") async def generate(req: GenerateRequest): try: text = generate_service.generate( req.prompt, max_new_tokens=req.max_new_tokens, temperature=req.temperature, top_p=req.top_p ) return {"code": 0, "message": "ok", "data": {"text": text}} except Exception as e: logger.error("推理失败: %s", e) return {"code": 500, "message": "推理服务异常", "data": None}这里面最值得留意的是“参数透传”设计。我没有把模型内部参数固定死,而是让调用方按需传入。这样做的好处是,前端可以动态调整生成风格:让用户选择“保守回答”还是“创意回答”,后端只需要修改temperature和top_p就行,不需要改代码。
关于参数的取值,我根据自己的使用经验给出一个参考范围:日常对话temperature设0.7、top_p设为0.9是不错的中点;代码生成希望确定性高一些,temperature可以降到0.2左右;写文案、头脑风暴等希望发散一些,temperature可以提高至0.9甚至1.2。需要留意的是,temperature过高会让输出变得很碎片或重复,不是越高越好。
3.4 可观测性:日志、监控与错误码设计
服务上线后,最怕的就是用户反馈“用不了”,而你又不清楚后端到底发生了什么。所以我专门设计了日志记录:每次请求来了,记录IP、prompt前100字、生成耗时、生成token数、返回码;每次报错,记录完整traceback到日志文件。这些信息不需要都能立刻定位问题,但作为事后分析数据极其宝贵。
我在日志里特意用截断的方式记录prompt,只保留前100个字符。原因是用户输入可能非常长,全部写入日志会造成文件膨胀,而且往往只需要看到大概输入内容就能判断问题。这个“只记关键信息”的思路,在很多工程场景里都适用。
错误码设计上,我分了三类:
| code | 场景 | 说明 |
|---|---|---|
| 0 | 成功 | 正常返回 |
| 400 | 参数错误 | 请求体不符合约束,比如prompt为空或超长 |
| 500 | 推理异常 | 模型调用失败、显存不足等情况 |
| 503 | 服务不可用 | 模型还在加载中或正在重载 |
把错误码统一管理后,前端和后端的沟通成本大幅下降。前端只需要根据code显示对应的友好提示,不需要解析后端返回的异常文本;运维也可以根据错误码统计接口健康度。这算是一个典型的“小事大收益”设计。
4. 踩坑记录与排查方式
4.1 高频问题速查表
下面这些问题,是我在这个项目实操中真实遇到过的,不是凭空列出来的。每个问题都附带了排查路径,照着做基本都能解决:
| 问题 | 现象 | 排查路径 | 解决方案 |
|---|---|---|---|
| 模型加载卡死 | 内存持续增长,进程长时间无响应 | 检查是CPU还是GPU环境;确认权重路径是否正确;看是否有其他进程占用显存 | 模型文件不要放网盘同步目录;加载前先确认nvidia-smi显存充足 |
| 生成速度很慢 | CPU推理超过数十秒 | 检查是否真的用了GPU;确认模型参数规模和设备匹配 | 有卡就用半精度float16;没卡就用小模型或量化版本 |
| 中文输出乱码 | 返回内容含[UNK]或英文替代 | 检查tokenizer是否正确加载;确认decode时没有丢失skip_special_tokens | 给tokenizer显式传入trust_remote_code=True;输出前统一decode |
| 接口超时 | 前端请求迟迟不返回 | 看服务端日志卡在哪个环节;确认模型单次推理耗时 | 设置HTTP超时时间;考虑队列和异步化改造 |
| 请求偶发失败 | 有时成功有时报错 | 看是否并发请求导致显存不足;确认生成参数是否包含非法组合 | 给服务加并发控制和排队机制;限制单请求最大token数 |
| 前端CORS报错 | 浏览器控制台提示跨域 | 确认请求方和响应方域名/端口是否一致 | 添加CORSMiddleware并只放行自己的域名 |
我印象最深的是一次“偶发失败”排查:用户发同样的请求,有时成功,有时直接报错。我猜了半天,最后发现是两个并发请求同时占了显存,导致其中一个分配不到足够的空间。这个问题在本地单用户调试时完全不会出现,所以强烈建议至少做一次简单的并发压测,哪怕只开两个并发请求也能暴露出不少问题。
4.2 性能优化的几个下限建议
做这个项目时,我一度陷入性能优化的焦虑,总想上流式输出、上多进程、上GPU集群。后来冷静下来,意识到一个道理:很多问题不是性能问题,是复杂度问题。项目初期,把链路跑通比把链路跑快重要得多。
如果你也想优化自己的AI服务性能,我的建议优先级是这样的:
- 先检查模型加载方式。同一个模型每次请求都加载一遍是最严重的浪费,应该做成单例,在服务启动时初始化一次。
- 再检查生成参数上限。把max_new_tokens设小一点,能直接减少单次请求的耗时时长,这是最便宜、见效最快的优化。
- 有条件的话,把模型切成半精度。在GPU上用float16通常能减少一半显存占用,推理速度也有提升,而输出质量基本无感。
- 最后再考虑服务拆分或并行。比如用gunicorn起多个worker进程,每个worker里加载一份模型,通过系统负载均衡分发请求。这时候要额外注意多份模型同时占用的显存总量。
个人建议,如果你的机器没有GPU,先用CPU小模型把这个工程链路跑通,再去追求性能。因为性能优化都是建立在正确性之上的——如果你链路本身是错的,再快的推理也没有意义。我在这个项目里,最开始就是用CPU跑一个很小的模型,验证完整个交互逻辑之后,才换到大模型和GPU上。
还有一个小细节:服务启动时做一次自检。所谓自检,就是启动后立刻调用一次generate,拿一个预设的短提示词生成一小段文本。如果自检通过,就说明模型加载和基本推理是好的,对外面的请求会有更高的成功率。这个习惯我一直保留着,它能把“模型坏了影响用户”提前变成“启动时就能发现”。
4.3 一些我保留至今的工程习惯
项目迭代到后面,有几个习惯让我受益很大,分享出来供参考。第一,每次改动代码后都跑一遍固定的测试用例集合。这些测试用例不是严格的单元测试,而是覆盖了主要使用场景的简单断言。只要这些用例过了,我就基本敢提交代码。第二,写CHANGELOG,记录每个版本的改动点。哪怕只写一行“修复了温度参数为0时采样失效的问题”,回头看都很有价值。第三,用小函数代替大函数。所有核心逻辑都拆成职责单一的短函数,比如_build_inputs、_postprocess_output、_log_request,每个函数一眼能看懂在做什么。
说到测试,我后来把测试脚本简单写成了pytest风格,放在tests目录里,每次修改后运行pytest -v即可。不过说句实话,对于个人项目来说,最关键的测试是“固定输入能出固定预期”。你不需要追求测试覆盖率,关键是保证核心链路不因改动而退化。
这个项目做到现在,最珍贵的其实是那几版又脏又乱的v0脚本。每次翻看它们,都能想起最初踩到的那些坑,也能看到自己从“只会调库”到“能独立搭建AI工程链路”的转变轨迹。如果你也想走一遍from scratch的路线,我只有一个建议:先别想太多,咬咬牙把最小链路跑通,再回头看,你会感谢自己当初没被那些看似高深的名词吓退。我现在偶尔还会拿最初的demo页面出来做回归测试,因为它能暴露很多看似无关紧要、但其实很致命的问题——这些东西,恰恰是工程经验里最值钱的部分。