年初给自己定了一个有点"自虐"的目标:不借助任何现成的AI脚手架,也不套模板,把一个真实的AI工程从零开始完整跑通。当时身边不少人觉得这是在绕远路——现在API这么成熟,调个接口、拼个Prompt不就叫AI工程了吗?但真把一个AI功能放进生产环境,让它稳定、可控、能评估、能迭代,你会发现中间隔着大量教科书不会写的东西。
这篇文章就是"ai-engineering-from-scratch"项目的过程复盘。我会从项目拆解、模型选型、提示词工程、服务封装、评测体系到踩坑实录,完整走一遍。适合想系统理解AI工程全链路的人,尤其是那些已经能跑通Demo、但一上生产就手足无措的开发者与算法工程师。
1. 项目全景拆解:AI工程和算法Demo到底差在哪
1.1 先搞清楚"从零开始"的边界
开始动手之前,我花了很长时间想一个问题:什么才叫"从零开始"?是把Transformer从论文里复现一遍?是用NumPy手写反向传播?还是完全不使用任何第三方库?如果标准定得这么极端,这个项目就变成学术练习,而不是"工程"了。
我的落点是:不依赖任何端到端的AI应用框架,不做那种"填个key、配个模型名就能跑"的胶水代码;但从模型权重、推理框架到基础库(PyTorch、Transformers这类)可以正常使用。换句话说,别人给你的是半成品零件,我要做的是从这些零件里设计并组装出一台能稳定运转的机器。这就好比做菜:我不去种水稻,但我也不能直接买速食料理包——洗菜、切菜、调味、掌握火候都得自己来。
边界定清楚之后,"工程"二字才有抓手。真正的AI工程,是围绕模型构建的一整套体系:数据怎么管、模型怎么接、推理怎么加速、接口怎么封装、效果怎么评价、线上怎么监控。每一步都不是独立的,任何一环做得粗糙,整个系统都会在某个意想不到的时刻给你脸色看。
1.2 整体技术栈与模块划分
我将整个项目划分成了五个核心模块,它们也是我认为任何AI工程都绕不开的基础骨架:
| 模块 | 职责 | 关键问题 |
|---|---|---|
| 数据层 | 语料采集、清洗、结构化 | 数据质量如何保证?格式如何统一? |
| 模型层 | 基座模型选型、微调策略 | 开源还是闭源?底座选多大的? |
| 推理层 | 前向推理加速、并发控制 | 延迟怎么压?显存怎么省? |
| 服务层 | API封装、流式返回、状态管理 | 接口怎么设计?异常怎么兜底? |
| 评测层 | 离线评估、回归测试、线上监控 | 效果怎么量化?怎么防止越改越差? |
技术选型上我有几个明确偏好。底座模型用了可私有化部署的开源模型(Qwen系列),推理框架用vLLM做服务化,底层接口开发用FastAPI。为什么选这套组合?三个理由:第一,开源模型让我能控制整个链路,方便做后续的量化、蒸馏和定制,闭源API像黑盒,一旦对方调整模型行为,你的系统行为也会跟着漂移,这在工程上是非常难受的;第二,vLLM在吞吐量和显存管理上都比较成熟,PagedAttention机制让长上下文的处理效率高出不少;第三,FastAPI的异步支持让SSE流式输出写起来很自然,而流式输出是AI应用的用户体验基石。
模块划分完之后,我给每个模块都设了最小可用标准:数据层能稳定输出干净的结构化JSON,模型层能在单卡上稳定推理,服务层能支撑至少30路并发不崩溃,评测层能对每一次模型改动给出可对比的分数。没有这些量化标准,后面所有的工作都会变成"感觉还行"——而"感觉还行"是工程事故的温床。
2. 从零构建AI能力的完整链路:数据、模型与提示词工程
2.1 数据准备:质量比数量重要得多
任何AI工程的第一步都是数据。很多人以为数据工作就是"找一堆文本丢给模型",但实际做下来,数据管道的设计比模型本身更能决定项目成败。我在项目里实现了一套完整的数据处理管线:采集、清洗、去重、格式化、版本管理五步。
先说采集。我构建的是一个知识问答与文档分析系统,领域集中在技术文档和产品说明书上。采集的原始资料格式五花八门:PDF里扫描件占多数,有些表格是图片,有些代码块混着渲染错误。这里我踩了一个印象很深的坑:用OCR提取PDF时,经常把"OO"识别成"00",把"l"识别成"1",代码里的变量名被错改得面目全非。所以我在清洗环节必须加一道"代码块保护":先识别文档中的代码区和数据表,用占位符替换,等OCR完成后再还原,而不是整个文档一视同仁地做OCR。
去重也远不止"删掉完全一样的句子"这么简单。技术文档经常有语义重复但字面不同的内容,比如两个版本的手册对同一个函数的描述略有差异。我用了文本向量的近似去重,把语义相似度超过0.85的段落做合并标记,人工抽查决定保留哪一份。还有格式统一:所有数据最后都转成统一的JSON形式,包含id、text、metadata三个字段。metadata里记来源、时间、类型,方便后面做数据筛选和分析。
数据版本管理是我特别想强调的。训练集、测试集、验证集一旦出了问题,整个项目的可信度就塌了。我用DVC做数据版本控制,每一条数据都对应一个哈希记录。后面发现评测结果异常波动时,第一件事永远是回查数据版本——是数据变了,还是模型变了,还是推理代码变了?这个排查能力在AI工程里属于基本功。
2.2 模型选型与微调的实际取舍
模型选型上,我考察了三个维度:任务匹配度、硬件约束、生态成熟度。任务以中文技术文档问答为主,需要一个擅长中文、具备一定代码理解能力和长上下文处理能力的底座。显存方面我手上只有一张24G的消费级卡,所以模型参数规模控制在7B到14B之间,再大就只能做量化了。
这里想展开说一下为什么要优先选开源模型。API调用在原型阶段确实省事,但工程化之后三个痛点会越来越明显:一是行为不可控,同一个Prompt今天和明天的返回可能就有差异;二是数据隐私,内部文档要过第三方服务,在很多场景里合规上过不去;三是成本,高频调用下API费用会膨胀得很厉害。自己做私有化部署虽然前期工程量大,但后期边际成本几乎为零,而且可以针对自己的数据反复迭代。
微调这件事,我的建议是:能不做就不做,先试试RAG和提示词工程能不能解决问题。我一开始也给自己画了大饼,觉得非微调不可。但后来评估了一下,大部分问答任务靠检索增强就能覆盖,只有涉及特定领域的术语和格式规范时才需要微调。最后我只做了一个低成本的全参数微调(用LoRA),语料大概两万条问答对,效果提升确实明显,但也没到那种"质变"的程度。如果你预算和算力都有限,一定要先搭建评测基线,再用基线判断微调值不值。
2.3 Prompt Engineering在工程中的真实权重
很多人对Prompt Engineering有误解,要么觉得它是"玄学咒语",要么觉得它是AI的全部。我的切身体会是:Prompt Engineering是工程体系的一部分,它重要,因为它成本最低、反馈最快;但它撑不起整个工程的根基。
我在项目里建立了一套Prompt的"工程化管理"方式,而不是每次随手写一段。首先,所有Prompt都放在统一的配置文件里,用模板语法管理变量,不硬编码在代码中。其次,每个Prompt都有版本号,跟代码一样走Git管理,方便回溯"上一次效果还好,某次改完变差了"这类问题。最重要的是,我给Prompt设计了评测用例:针对每个系统提示词,至少准备30条测试问题,任何Prompt改动都必须跑一轮回归。
具体到Prompt的结构,我通常包含五个部分:角色设定、任务描述、输入输出格式、约束条件、示例。约束条件这块特别容易被忽视,但它决定了系统的安全边界。比如在问答系统里,我会明确要求模型在无法从上下文中找到答案时直接说"文档中没有相关信息",而不是让模型自行编造。这一条约束的直接效果是,幻觉率下降了大概六成。
还有一个实际操作中的心得:Few-shot示例的选取比数量重要。我在早期放了十几个不同类型的示例,结果模型在风格上变得僵硬,输出中频繁出现示例里的词汇结构。后来精减成三个高质量示例,一个讲格式规范,一个讲边界拒绝,一个讲复杂推理,效果反而更好。模型是个模仿者,你给它什么它就会放大什么。
3. 把模型变成产品:工程化落地的四个关键环节
3.1 服务封装与接口设计
模型本身不是产品,API才是。在这层,我踩过的坑和解决方式值得写给你参考。
首先是接口设计。我强烈建议AI服务的接口设计要考虑三个核心场景:普通请求、流式请求、带会话上下文的请求。普通请求就是同步调用,适合快速验证;流式请求用SSE(Server-Sent Events)实现,让用户看到逐字输出,这直接改善用户体验;会话上下文则是多轮对话的基础。我一开始只做了同步返回,结果前端反馈"每次要等十秒才有反应,体验太差",后来补了SSE流式,效果天差地别。
代码层面,FastAPI + asyncio + vLLM的异步引擎,组合起来比较顺手。核心是别在异步接口里同步调用模型,否则事件循环会被阻塞,所有并发请求都卡住。我的做法是:请求进来后,把消息放到队列里,由后台的推理worker池统一调度,结果再通过回调或异步迭代器返回。这样接口层和推理层彻底解耦,扩展并发时只需要加worker,不用动接口代码。
键性的工程决策是"预设降级路径"。模型服务崩了怎么办?缓存命不中的新问题怎么办?我的方案是分层降级:第一层是本地缓存,命中了就直接返回;第二层是模型推理;第三层是规则兜底,实在答不上来就返回预设话术,并记录日志方便追踪。这套降级机制在服务出问题时拯救了我不止一次。
3.2 推理优化与成本控制
模型推理性能决定了一个AI工程能不能真正落地。我用vLLM做推理服务化,因为在相同硬件条件下,它的吞吐量能比原版Transformers推理高出不少。vLLM的PagedAttention把KV Cache按页管理,显存利用率提升明显,连续批处理也让多个请求可以在同一个推理循环里处理,大幅减少GPU空闲等待。
部署参数上我做了几轮的调优。max_model_len决定了单次请求最多能吃多少token,这个值不是越大越好——开得太大,显存被预占,并发就上不去;开得太小,长文档处理就截断。我的做法是根据业务统计决定:分析数据发现90%的查询在2000 token内,最终把max_model_len设为4096,给了足够余量又没浪费资源。
还有一个被很多人忽略的点:prompt缓存的命中率。在文档问答场景里,系统提示词和检索到的文档片段占了输入的大部分,用户真正的话只占一小段。vLLM支持prefix caching,也就是相同前缀的请求可以复用KV Cache,命中后首token延迟能下降一半以上。我做了个日志分析,发现缓存命中率能到40%以上,这是一笔不容小觑的成本节省。
推理层面的降本增效远没有结束。我后来还做了量化压缩测试,把模型从FP16压到INT8,用评测集打分对比,发现分数波动不到1个百分点,但显存占用下降了近一半。这也意味着同样一张24G的卡,可以承载更大的并发。量化不是洪水猛兽,前提是你有评测体系告诉你损失了多少。
3.3 评测体系与回归测试
如果一个AI项目没有评测体系,那它就是一个永远停不下来的调参游戏。我在这个项目里的一个重要心得是:评测体系越早搭,后面越省心。我见过太多项目在Demo阶段效果惊艳,一上线就原形毕露——原因就是没有建立可靠的评测循环。
我的评测体系分三层。第一层是自动化指标层:对每个问答任务算BLEU、ROUGE-L、回答包含率等指标,快速发现明显退化。第二层是规则校验层:检查输出是否包含指定格式(比如JSON是否合法、代码块是否完整)、是否遵守了约束条件(比如"找不到答案时是否明确说明")。第三层是人工评测层:抽样打分,按相关性、完整性、准确性、安全性四个维度,采用0到4分的量表。
让人工评测真正有用的关键是建立评分指南。最开始我找几个朋友帮忙评测,每人凭感觉打分,结果同一份回答有人给4分有人给2分,数据完全没法用。后来我写了一份详细的评分指南,每个分数档位配两个正反例子,又跑了几轮校准,评分一致性才上来了。这个细节如果没人提醒,你大概率会踩一遍。
评测集本身也要持续迭代。我维护了一个"失败用例库",每天都运行一遍,覆盖率要以肉眼可见的速度提升——凡是线上被用户反馈的问题,我一律把它转成评测用例放进库里。这样每一次模型修改,都能拿这套用例跑一遍回归,确保不会修了旧bug又引发新bug。
3.4 Agent能力的场景化集成
看到项目名里有"AI Engineering",就不能不提Agent。当前Agent已经从概念过渡到工程实践阶段,但它很容易吸引人去做大而全的设计,这反而会带来不必要的复杂度。我的做法是:只在明确的场景里引入Agent,并设定好边界。
具体到我的文档问答系统,Agent的适用场景是"多步工具调用"。比如用户问"对比A产品和B产品在功耗和价格上的差异",模型需要先去检索A的信息,再去检索B的信息,最后组装对比回答。传统的单轮RAG做不好这件事,因为一次检索很难同时覆盖两个对象。我设计了工具调用的框架:模型可以选择调用search()、compare()、summarize()这几个预设工具,每次调用都有独立的输入输出格式和校验逻辑。
Agent设计里有一个重要的工程概念叫"状态机"。每个Agent会话都有明确的状态:初始、检索中、生成中、完成、异常。状态之间只有固定的转移路径,任何不合法的转移都会被拦截。有了状态机,并发控制、超时处理、会话恢复都变得清晰起来。别让Agent自由飘荡在无结构的世界里,工程化的第一步就是给它画好轨道。
工具定义方面,我给每个工具写清了"何时调用"和"何时不该调用"的描述,而不是只写"这个工具是干嘛的"。区别很关键:模型靠描述决定走哪条路,描述越精确,路径越正确。比如search工具我会写"当问题涉及明确命名实体(产品名、型号)时使用;当问题只是泛泛询问概念时,不要使用,直接基于模型知识回答"。
4. 实操过程:从0到1跑通一个AI问答系统
4.1 环境搭建与项目骨架
理论部分讲了不少,现在进入实操。我以"技术文档问答系统"为例,完整重现从零到一的过程。
环境这块最大的痛点是依赖管理。AI项目的依赖矩阵非常复杂:CUDA版本、PyTorch版本、Transformers版本、vLLM版本,环环相扣,稍有不慎就是"版本地狱"。我用conda建了独立环境,Python锁定3.10版本,然后按官方文档推荐的组合来装。强烈建议用requirements.txt加锁版本号,不要用">=某版本"这种宽松写法——保证可复现性,省得哪天莫名其妙跑不起来了。
项目目录结构我是这样规划的:
ai-engineering-from-scratch/ ├── config/ # 全局配置与Prompt模板 ├── data/ # 原始数据与处理后数据 ├── models/ # 模型权重与微调脚本 ├── serving/ # 推理服务与API封装 ├── engine/ # Agent逻辑与工具定义 ├── eval/ # 评测集与评测脚本 └── tests/ # 单元测试与回归测试这个结构的好处是每个模块的边界清晰,符合前面讲的"五层架构"。我一度把评测脚本放在任意地方,后面要找的时候浪费了半小时,后来立刻归位到eval目录。别小看目录规划,它是代码整洁的物理基础。
4.2 推理服务与API的核心代码骨架
下面给出核心代码段,关键部分我会加注释,方便你直接抄作业。
首先是vLLM推理服务的初始化。这个阶段最容易犯的错误是"通过代码传参时把GPU内存全占了"。用vLLM时我推荐显式设置gpu_memory_utilization参数,比如0.85,留出15%给运行时其他开销。
# serving/vllm_server.py from vllm import LLM, SamplingParams llm = LLM( model="Qwen/Qwen2.5-7B-Instruct", tensor_parallel_size=1, gpu_memory_utilization=0.85, max_model_len=4096, trust_remote_code=True, ) sampling_params = SamplingParams( temperature=0.3, # 文档问答场景,低温度保证确定性 top_p=0.9, max_tokens=2048, stop=["<|im_end|>"], # Qwen系列用这个token做结束符 )注意temperature设置。很多人喜欢把温度调高让回答"更有创造性",但在文档问答场景里,创造性的另一面是幻觉。我实测下来,温度在0.2到0.4之间,准确性和稳定性最好;到了0.8以上,回答会明显开始飘。
然后是FastAPI的流式接口。这里用了StreamingResponse来实现SSE,确保前端可以逐字拿到结果。
# serving/api_server.py import json from fastapi import FastAPI from fastapi.responses import StreamingResponse from pydantic import BaseModel app = FastAPI() class QueryRequest(BaseModel): question: str session_id: str = "" stream: bool = True @app.post("/v1/chat") async def chat(request: QueryRequest): if request.stream: return StreamingResponse( stream_chat(request.question, request.session_id), media_type="text/event-stream" ) result = await sync_chat(request.question) return result async def stream_chat(question, session_id): # 实际实现会走推理队列,这里展示核心协议 for token in generate_tokens(question): yield f"data: {json.dumps({'token': token}, ensure_ascii=False)}\n\n" yield "data: [DONE]\n\n"流式接口有个容易被忽略的细节:SSE协议要求每条消息以data:开头、以\n\n结尾。前端如果发现只收到最后一条完整消息,大概率是换行符格式不对。另外,异步迭代器内部如果有阻塞操作,要用await asyncio.to_thread(...)包装,防止阻塞事件循环。
4.3 从检索增强到多轮对话的完整链路
有了基础服务,接下来要做的是让回答真正"有依据"。我实现了一个最朴素的RAG链路:检索 → 组装Prompt → 推理 → 校验输出。检索用的是向量数据库加BM25混合召回,两路结果做融合排序。为什么要混合?向量检索擅长语义匹配但容易忽略精确关键词,BM25在处理型号、报错码这种精确匹配上更可靠。两者融合后,召回准确率提升非常明显。
# engine/rag.py def build_prompt(question, retrieved_docs, history): context = "\n\n".join( f"[文档{idx+1}] {doc}" for idx, doc in enumerate(retrieved_docs) ) history_text = "\n".join( f"{role}: {msg}" for role, msg in history[-4:] # 只保留最近两轮 ) return f""" 你是技术文档问答助手。请严格依据下面给出的文档内容回答问题。 如果文档中没有答案,请直接回答"文档中未找到相关信息",不要编造。 历史对话: {history_text} 参考文档: {context} 问题:{question} 回答: """多轮对话的上下文管理有个工程细节:不能无限塞历史,否则输入长度会膨胀,推理延迟也会上升。我设定了最多保留最近两轮对话的规则,超出就丢弃。为什么是两轮不是五轮?因为针对当前问题,两轮内能覆盖绝大部分连续追问场景,五轮以上带来的信息增益很小,但token开销线性增长。这个参数需要根据你的场景实测调整,别照搬别人的。
输出校验环节也很有必要。模型返回结果后,我会做三层检查:JSON合法性、必须字段是否存在、是否包含"编造"痕迹(比如明明上下文没有却引用了不存在的引用编号)。校验不通过就触发一次重生成,重生成还不行就走降级兜底。这层校验看似麻烦,但线上很多低级错误都是在这拦下来的。
5. 调试日记:我在这个项目里踩过的坑
5.1 最浪费时间的五个Bug
这个项目踩过的坑,我挑五个最能给你提个醒的记录下来。
第一个是CUDA显存泄漏。表现是请求跑了几百次之后,显存占用缓慢爬升,最终OOM。排查过程像查案:先给代码加显存监控日志,发现每个请求结束后有几个MB的显存没释放。问题出在我手动做KV Cache时,张量被Python的引用环卡住了。解决方案是用torch.no_grad()包住推理过程,同时避免在结果张量上做会延长生命周期的操作。
第二个是并发线程安全问题。我的检索链路里用了同一个向量模型实例做编码,同时开多个线程调用时偶尔会出现结果错乱。独热编码模型的状态不是线程安全的,每个线程拿到的是上次调用残留的中间状态。解决方案是为编码模型加一个线程锁,或者直接用进程池隔离。这个Bug排查了很久,因为问题不是稳定复现,而是偶发的——如果你看到并发时结果时而正确时而错误,第一反应应该怀疑共享状态。
第三个是Prompt里的隐蔽换行符。有一次模型输出突然出现大量空行,排查半天找不到原因。后来发现在Prompt模板里,我用的是文本编辑器自动转换的软换行,看起来一致,实际混入了不同字符。模型对换行符的解析很敏感,这种玄学问题让人头疼。解决方案是统一用配置文件里的转义\n定义Prompt,不在代码字符串里手写多行文本。
第四个是流式输出的编码问题。SSE流式返回时,中文token被编码成UTF-8字节分段发送,前端按块解析时经常把一个中文字符拆成两半,导致乱码。解决方案是在服务端做按字符边界的缓冲,确保编码完整后再发送。这个坑想提醒的是:流式传输不是简单的逐token发送,还需要处理编码边界。
第五个是评测指标与用户感知脱节。最开始的评估指标显示BLEU分数挺高,但人工反馈说回答质量明显不行。原因是BLEU偏向于参考文本的词汇重叠,而用户在文档问答里更看重"是否回答了问题核心"。后来我把指标换成了基于LLM的判别式评估,让大模型当裁判,指标与人工评分的相关性才上去。评估指标如果是自嗨,那整个评测体系就是在给错误背书。
5.2 问题排查速查表
为了方便你以后参考,我把常见问题整理成一张排查表:
| 症状 | 可能原因 | 排查方向 |
|---|---|---|
| 显存OOM | KV Cache泄漏 | 检查推理代码是否包了no_grad,查显存监控日志 |
| 并发下结果错乱 | 共享模型实例的线程安全问题 | 给模型加锁或用进程隔离 |
| 输出突然多空行 | Prompt模板混入异常字符 | 检查模板文件的实际字节内容 |
| 流式输出中文乱码 | 多字节字符被拆散 | 服务端做字符边界缓冲 |
| 指标高但用户觉得差 | 评测指标与任务不匹配 | 增加基于LLM的判别式评估 |
| 同一请求结果不一致 | 采样参数温度过高 | 降低temperature到0.3以下 |
| 模型回答编造内容 | 上下文信息不足 | 加强RAG检索质量,约束Prompt边界 |
排查问题有个通用原则:一次只改一个变量。很多人出问题时同时调整了Prompt、模型参数、检索阈值,结果问题解决了也不知道是哪个改动起的作用,问题复现了也不知道是哪个改动导致的。我后来的做法是:所有配置改动都走配置文件加版本号,每次只动一个参数,跑完评测再动下一个。看似慢,实则是最快的。
5.3 成本控制与资源规划心得
个人项目的资源永远有限,所以成本控制不是"大厂优化",而是"生存策略"。三个方向我觉得最有用:第一,缓存优先。高频问题做一个问题-答案缓存表,命中缓存就直接返回,不再走模型推理。实测命中率高的时候,推理成本能降三分之一。第二,动态量化。非高峰时段自动切换成INT8精度,高峰时段切回FP16,在成本和体验之间找平衡。第三,离线批处理。对于不需要实时的任务(比如批量文档总结),攒一批再处理,vLLM的连续批处理吞吐量远高于逐条处理。
如果你预算真的非常紧张,更极端一点的做法是:模型从14B缩到7B,同时强化检索质量。我用7B模型加优质检索,在很多任务上的表现比14B模型加粗糙检索要好。这验证了一个朴素的工程思路——系统的上限由最弱的环节决定,补强短板永远比堆高长板更划算。
6. 一些个人体会
整个项目做下来,我最深的体会是:AI工程的核心不是模型,而是围绕模型建立的可控性体系。模型的效果天花板上限固然重要,但下限才是工程真正守的东西。可控性来自数据的规范化、Prompt的版本化、评测的标准化、服务的分层降级,这些东西每一个都平淡无奇,合在一起就是一个能打的生产系统。
最后分享一个小技巧:所有给模型看的文本,都要经过一次"肉眼人类测试"。在把Prompt、工具描述、系统提示词正式上线之前,先打印出来,用正常人的视角读一遍,问自己:这句话会不会有歧义?这个格式会不会让人误解?模型虽然比大多数人的阅读能力强,但它对模糊指令的放大效应也比人类严重得多。你写的时候觉得没问题,不代表模型读起来没问题。
AI工程从零开始,技术上并没有想象中那么不可逾越,难的是耐着性子把每个环节磨到及格线以上。如果你也正在折腾类似的项目,希望这篇复盘能帮你少踩几个坑。