1. 为什么我把这个项目命名为 ai-engineering-from-scratch
先说个身边经常看到的场景:模型能力越来越强,开源社区每天都有新模型发布,各种Demo视频刷屏朋友圈,但真正能把一个AI应用稳定跑进业务里的人依然不多。我在过去半年里被问得最多的一句话是“为什么我照着教程把代码跑通了,真要用起来还是不行”。大多数情况下,问题并不出在模型本身,而是出在被教程跳过的那些环节上——数据怎么准备、怎么评测、怎么部署、怎么监控。这些环节拼在一起,才叫AI工程。这也是我整理“ai-engineering-from-scratch”这套内容的初衷。
我不打算从概念讲起。网上讲AI工程定义和趋势的文章已经够多了,真正缺的是顺着一条可以落地的路径,把每一步怎么选型、怎么写、怎么避坑讲清楚。本文就是以“从零开始做一个企业文档智能问答系统”为线索,带你走完整条链路:数据准备、模型选型、提示词工程化、服务部署、线上监控。你如果是一名开发工程师,或者正在做AI相关项目的技术负责人,这篇文章能帮你省掉不少“摸索完发现方向错了”的时间。
项目核心关键词是“ai-engineering”,整体思路是这样一句话:AI工程不是写一段调用大模型接口的代码,而是让这端代码在一个真实运行环境里稳定、可控、可维护地产生价值。所以本文会非常偏实践,很多配置、代码片段、评测方法都是我实测后留下的,你可以直接抄作业。
我还想表达一个反直觉的结论:从零开始做AI工程,最大的成本往往不是模型推理费用,也不是开发时间,而是你对自己系统的失控感。当你不知道数据从哪里来、不知道模型为什么输出这个结果、不知道线上用户为什么会触发某个badcase时,整个项目会变成一团迷雾。而工程化的过程,就是把这团迷雾一层层拨开。下面每一章都在做这件事。
2. 目录结构设计:把整条工程链路拆成六个可执行的阶段
在进入具体实现之前,先看看这套内容对应的项目结构。我采用模块化思路,每个阶段都能独立启动、独立验证,彼此之间通过标准格式的数据和接口衔接,这样在调试时可以快速定位问题出在哪一环。
ai-engineering-from-scratch/ ├── data/ # 数据层:存放原始资料、清洗脚本、生成结果 │ ├── raw_docs/ # 原始PDF/Word/网页文档 │ ├── cleaned/ # 清洗后的纯文本 │ └── qa_pairs/ # 用于评测的问题答案对 ├── pipeline/ # 数据处理管线 │ ├── extract.py # 从不同格式中抽取文本 │ ├── clean.py # 清洗、分段、去重 │ └── chunk.py # 文本切分与向量化 ├── models/ # 模型相关代码 │ ├── embedder.py # 向量化模型封装 │ ├── llm_client.py # 大模型接口封装 │ └── prompt_templates/ # 提示词模板目录 ├── eval/ # 评测模块 │ ├── build_qa_set.py # 构建评测集 │ ├── run_eval.py # 跑评测 │ └── metrics.py # 指标计算 └── serve/ # 服务部署 ├── api.py # FastAPI服务 ├── cache.py # 缓存层 └── monitor.py # 监控与日志这个结构对应了AI工程里最核心的六件事:数据准备、模型封装、检索增强、评测体系、服务部署、监控反馈。我把它作为贯穿全篇的线索。下面每一章会深入一个阶段,把其中隐蔵的坑和取舍逻辑说清楚。
3. 数据层:AI工程质量的上限,从源头上就被决定了
很多从零开始做AI工程的人会犯同一个错误:拿到模型后先急着写提示词,把数据问题扔到一边。结果模型输出经常“一本正经地胡说八道”,或者在某个知识领域上的回答质量飘忽不定。我个人的经验非常明确:数据准备和清洗的时间,至少要占到项目总工期的一半以上。模型能力再强,喂进去的是垃圾,产出的也只能是高级垃圾。
3.1 原始资料的采集格式:你以为的“半结构化”其实是非结构化
我接触的企业文档场景里,原始资料形态五花八门:扫描版PDF、Word合同、Excel报表、网页介绍、PPT方案。第一步工作不是“想清楚怎么解析”,而是先把它们统一“降级”成纯文本。
拿PDF举例。数字原生的PDF可以用pypdf抽取文本,老式扫描件则需要OCR。最简单粗暴的判断方法:新建一个文本文件,把PDF里的文字复制进去,如果乱码或缺字,说明这个PDF需要OCR。我用过的OCR方案里,开源的有PaddleOCR,本地私有化效果好,识别中文准确率比Tesseract高出一大截。虽然部署显得重,但实测之后我认为是值得的。
这个阶段有一个坑,如果你不提前想清楚会非常痛苦:文档编码和格式混乱问题。有些Word文档是GBK编码,有些是UTF-8,处理不好会出现大量乱码。清洗脚本里的第一行就应该把编码统一掉:
# pipeline/clean.py 摘录 def read_text_file(path: str) -> str: for enc in ["utf-8", "gbk", "gb18030", "latin-1"]: try: with open(path, "r", encoding=enc) as f: return f.read() except UnicodeDecodeError: continue raise ValueError(f"无法识别文件编码: {path}")这样虽然看起来笨,但在真实数据面前,笨办法往往才是靠得住的。
注意:不要把原始文档直接作为模型输入。大模型的上下文窗口虽然越来越长,但文档里的页眉、页脚、目录、空行、重复声明,都会干扰生成质量,还白消耗Token。
3.2 文本清洗的落地细节:不只是去空格那么简单
文本清洗的核心目标有三个:去掉噪音、保留语义、降低切块的难度。我给清洗环节定了这么几条规则,在实践中反复验证过:
- 删除页眉页脚,规则上就是看每页开头和结尾重复出现的文字;
- 删除目录页,尤其是页码指向的内容,对检索系统帮助为零;
- 统一中英文标点和全角半角;
- 折叠连续空行为一个换行;
- 按章节标题保留结构信息,用 \n\n\n 作为大段分隔标记;
- 清理无意义的换行符——很多PDF转出来的文本会在一行没写满时强行断行,这会导致段落碎成一段一段,检索时语义不连续。
清洗后,建议做一次抽样肉眼检查:随机抽10段文本去读,确认没有乱码、没有结构错乱,再进入下一步。
3.3 段落切分:不能只看字符数,要顺应文本的自然边界
RAG方向的项目里,切块方式是决定检索效果的第一因素。这里有一个常见的矛盾:块太小,上下文不完整,召回信息碎片化;块太大,直接塞给模型做答案生成时,又会混入大量不相关内容。
我的做法是结构感知切块(structure-aware chunking):优先按标题和段落边界切分,在段落过长时再按句子边界二次切分,同时保留段落的来源信息(比如“来自于XX文档的XX章节”),供后续引用追溯用。如果文档有标题层级,可以用正则把标题识别出来,让每个切块自带一个“小标题”,这样检索命中时模型更容易定位。
切块后建议写一个统计脚本,看一眼每个块的长度分布。如果大量块的字符数在几百到一千之间且分布在自然标题边界上,那大概率是可用的状态。如果切出来的块全部集中在固定长度上,很可能你的切块算法是“硬按数字截断”,后续检索效果会打折扣。
4. 模型选型与封装:不追最强模型,只追最稳的组合
模型选型是所有环节里最容易被带节奏的。今天很多团队在立项时直接说“用当下最强的模型”,但在真实落地中,工程系统要的是平衡:成本可预估、延迟可接受、可私有化部署或数据合规允许。我建议从三个约束出发做选型。
4.1 三个约束:成本、延迟、数据边界
数据边界:企业文档可能包含内部机密,如果无法接受数据出网,你要么用私有化部署的开源模型,要么买云厂商的专属实例。这一步直接决定了后面所有方案。别在项目做到一半时发现数据合规不通过,再回头换模型,那种重构代价相当大。
成本:一个简单换算关系是“每千Token的价格乘以日均请求量”。我见过不少项目,用大模型做文本分类和实体抽取,杀鸡用牛刀,成本一个月下来高得离谱。实际上很多结构化任务,用小参数模型就能达到95%以上的效果。
延迟:如果应用面向用户在线交互,要求1到2秒内的响应,那需要控制模型的推理链路。开源模型如果部署在没有GPU的生产环境,速度会很尴尬;如果调用API,链路尽量只保留一次大模型请求,不要做“模型A判断要不要调用模型B,然后B再调用C”的串联。
4.2 我常用的一个组合:分层设计
我的惯用组合是“小模型做子任务,大模型做生成”。具体来说:
- 文本向量化:用开源的 embedding 模型,比如
bge-large-zh或m3e-base,这种模型参数量不大,跨机部署成本低,中文语义效果够用; - 路由或分类:如果任务是判断“这个问题是否需要走知识库检索”,我用一个较小的分类模型解决,不走大模型判断,省时省钱;
- 最终答案生成:这里才上大模型,把检索到的相关内容作为上下文,生成最终答案。
这样一个分层设计不仅省钱,还让每个环节都更容易debug:检索不对就查embedding模型,分类不对就查路由模型,只有答案质量问题时才需要去调整大模型的提示词和参数。
4.3 封装接口时预留好扩展位
模型封装模块不要写“死”,建议把嵌入模型、生成模型都设计成可配置项。我的做法是写一个model_config.yaml,把模型名称、API地址、Key、温度、最大Token数、超时时间都放进去,运行时动态加载。这样以后模型升级或更换,直接改配置,不用动业务代码。
# models/model_config.yaml 摘录 embedding: type: "bge-large-zh" dim: 1024 device: "cuda:0" llm: provider: "openai-compatible" base_url: "http://localhost:8000/v1" model: "Qwen2.5-14B-Instruct" temperature: 0.2 max_tokens: 1024 timeout: 30另外,所有大模型调用必须做异常兜底和重试。网络抖动、上游服务不稳定、返回err这些都是线上常态,不做兜底的话,用户端表现就是偶发性的错误或空白页。
5. 检索增强生成:让“问什么答什么”变成“问到点子上”
既然做的核心是文档问答系统,RAG(检索增强生成)就是重头戏。很多人以为RAG就是把文档切块、向量化、建个向量库、查相似度然后塞给模型,看完下面的内容你会发现,真正的工程问题都在细节里。
5.1 向量检索只是起点,不是终点
向量检索的缺点非常明显:它对关键词敏感度低,对同义改写敏感,但在精确匹配场景上反而表现不佳。举个例子,问一句“公司年假制度怎么规定的”,如果知识库里原文写的是“休假管理办法”,部分embedding模型可能会漏掉最相关的文档。
所以我建议至少用两层召回:
- 第一层:BM25关键词召回。用
rank_bm25或者ES的bm25相关性算法,把和问题有直接词汇重叠的段落捞回来; - 第二层:向量语义召回。把问题向量化,召回语义相关的段落。
两层召回各自返回TopK,然后合并去重,再按自定义的分数让重排器去挑最优的若干段。这样既保住了精确匹配的文档,又不错过语义相关的上下文。
5.2 “重排”是RAG最容易拉差距的地方
你以为重排是把两层召回的结果直接合并给大模型?那还不够。我给重排定的规则是:
- 把召回结果按“来源文档分布”做一次去重——同一个文档最多保留3段,避免模型被同一份资料的相似表述反复灌输;
- 相关性打分用交叉注意力重排模型(如
bge-reranker-base),它的效果比纯向量相似度更好,因为它是把“问题+段落”拼在一起做精细语义匹配; - 设定一个相关性阈值,低于阈值的段落直接丢弃。宁可返回“知识库中暂未找到相关信息”,也不能让模型基于无关段落硬编。
这一步做和不做,线上效果天差地别。很多项目把RAG效果差归结为“模型不行”,其实八成是召回和重排没调好。
5.3 提示词模板里要把“检索证据”和“生成限制”写清楚
生成层的提示词模板我调整过不下十个版本,目前稳定使用的策略包含三个要素:
- 给模型限定“只能基于提供的上下文回答”,并声明“如果上下文里没有相关信息,请明确告知”;
- 把检索到段落按编号列出,要求模型在回答时引用编号,方便人工追溯;
- 明确要求答案不要重复上下文原文,而是做概括和转述,这样回答更自然,也不会让用户看到一个“原文片段堆砌”。
还有一个细节:不要把检索到的所有段落全部填入Prompt。上下文塞得越多,模型越容易在其中“挑”出一些不相关信息来编造。精挑细选3到5段有质量的上下文,比堆10段效果要好得多。
6. 评测机制:没有评测的AI工程,就像没有测试的软件工程
这一章是对很多人的“点睛之笔”。我在与同行交流时发现,他们做AI工程最迷茫的是“不知道线上效果算好还是算差”。没有量化指标,就只能靠“感觉”。这种状态下,后续优化根本无从谈起,因为你不知道改动到底产生了什么影响。
6.1 怎么快速构建一个小而有效的评测集
最轻量的做法:从业务对话记录里收集60到100个真实问题,并人工写好标准答案。这些问题要覆盖:简单检索类、推理类、对比类、略复杂问答类。不要用模型生成答案再喂给模型,环环相套容易掩盖问题。
评测集字段可以设计为:
{ "question": "公司年假最长可以休多少天?", "expected_keywords": ["20天", "累计工作年限"], "expected_source_doc": "休假管理办法.pdf", "category": "policy" }跑评测时,通过“答案里是否包含预期关键词”“是否召回预期来源文档”两个指标来判断基础效果。更精细的可以加“答案相关性”“忠实性”这类模型评价指标,但那套体系比较复杂,从入门角度先把关键词和召回率两项做扎实,已经能发现绝大多数问题。
6.2 三个关键指标:能够具体操作的那种
我最看重的三个指标分别是:
- 检索召回率(Recall@K):正确答案对应的文档有没有出现在召回的TopK里。这个指标不对,后面生成质量再高也救不回来;
- 答案忠实度(Faithfulness):生成的回答是否严格基于检索到的上下文,有没有加入模型自己的幻觉内容。做法是把“上下文”和“生成的答案”再交给一枚评判模型打分;
- 端到端准确率(Accuracy):基于人工标注的标准答案做比对,看整体回答正误率。
我在跑评测时用到的套路是“先恶化后优化”:故意把一个环节调到明显不好的状态(比如关掉重排器),确认后台指标能反映效果变化,再做优化。这能验证评测体系是否灵敏,不然你改动后指标纹丝不动,评测就形同虚设。
6.3 评测要能回归
每改一次提示词、每换一次模型版本,都用同一套评测集跑一遍完整回归。这个流程看起来耗时间,但在项目迭代中非常值得。没有回归机制的话,今天优化了A问题,明天冒出来B问题,项目状态会一直处于“修修补补”的混沌里。
7. 服务设计与部署:从脚本到生产环境,还差了很多细节
模型效果可以接受之后,进入部署阶段。很多开发者在“脚本能跑”和“线上可用”之间摔得鼻青脸肿,下面这些点是我实测之后认为最容易踩且后果严重的。
7.1 用FastAPI搭一个最简单但完整的服务
服务层我习惯用FastAPI,异步支持和OpenAPI文档天然适合做AI应用。一个最小实现就两件事,一个对外接口,一个内部调用链路。
# serve/api.py 摘录 from fastapi import FastAPI from pydantic import BaseModel app = FastAPI(title="doc-qa-service") class QARequest(BaseModel): question: str user_id: str = "anonymous" class QAResponse(BaseModel): answer: str sources: list[str] @app.post("/v1/qa", response_model=QAResponse) async def answer_question(req: QARequest): # 1. 检索召回 contexts = retrieve(req.question) # 2. 生成回答 answer = generate(req.question, contexts) # 3. 返回答案与溯源 return QAResponse(answer=answer, sources=[c.doc_name for c in contexts])要注意一个关键点:接口传入的只应该是业务数据(问题和用户ID),不要在业务接口里去暴露“模型参数怎么设置”这类内部细节。控制参数放到配置模块统一管理,这样接口才会稳定,后续升级模型也不需要让调用方改代码。
7.2 缓存层:让重复问题少烧钱,少等待
真实业务里,大量用户会问相近甚至完全相同的问题。没有缓存,每次都去跑向量检索加大模型生成,成本和时间都会失控。加一个简单的缓存层,键为“标准化后的问题”,值为“答案+来源”。
这里的学问在“标准化”。用户问“年假能休几天”和“公司年假是多少天”,两者在缓存命中上会互相错过。我的做法是先用小模型做一次问题改写和归一化,再取哈希做缓存键。虽然多了一次小模型调用,但在高频重复场景下,节省的生成成本显著大于一次改写成本。
7.3 容器化部署时的内存与并发问题
部署时最容易被忽视的是embedding模型的显存占用。bge-large-zh这类模型加载后大约占1.3GB显存,但如果和生成模型同一张GPU,又没有留足余量,推理时会直接OOM。我的建议是:embedding模型可以放CPU跑,推理速度在批量场景下可以接受;生成模型才用GPU。如果并发上来了,CPU模式的embedding可能成为瓶颈,再用独立的GPU实例单独部署embedding服务,通过HTTP内部调用。
上线前的检查清单大致是这样的:
- 服务日志有没有分级(INFO/WARN/ERROR),能不能按请求ID串起整条链路;
- 超时时间有没有设置,上游慢请求会不会拖垮整个服务;
- 并发量往上压时,显存和内存会不会爆掉;
- 容器健康检查有没有配好,启动顺序是不是先加载模型再对外提供服务;
- 有没有做简单的限流,防止恶意刷接口。
8. 线上监控与持续迭代:模型上线不是终点
部署上线,只是进入了最漫长的阶段:持续维护和迭代。很多AI项目死在“上线后没人看护”这一步。模型不像传统软件那样是确定性的,它的效果会受数据漂移、用户提问分布变化、上游模型版本更新等因素影响。
8.1 日志里要记录哪些字段
线上日志必须包含:用户原始问题、标准化后的问题、检索到的TopK文档列表、模型最终回答、响应耗时、命中的缓存标讑、模型版本号。这些字段的作用有两个方面,一是用来复盘badcase,二是用来判断线上真实问题分布。
有了完整日志,评测集的更新就不再是拍脑袋了。每隔一两周从日志里抽一批新问题补进评测集,重新跑评测,观察效果的变化曲线,你就能清楚地知道系统是越来越稳还是需要干预。
8.2 最容易“翻车”的三类线上问题
根据我的观察,最容易出现的是这三类:
第一类:检索召回漂移。新上传的文档格式特殊,切块异常,或者文档主题和原有文档高度相似,导致检索到了错误来源。这类问题的发现靠“来源文档分布统计”,如果某个文档被反复召回但用户反馈不断,大概率这个文档的切块和metadata有问题。
第二类:模型幻觉集中在“信息缺失”场景。当知识库里没有答案时,模型倾向于编造一个看起来合理的回答。解决方案是前面提过的相关性阈值加严,以及提示词里强约束“没有信息就说没有”。评测集里必须包含一批“无答案”问题,用来专门测试模型会不会瞎编。
第三类:长尾问题处理不当。用户问得又多又杂,很多问题没有命中知识库。积累一段时间后,应该对无命中问题做聚类分析,如果某类问题持续高频,说明该主题的知识资料缺失,这时要补文档,而不是试图通过提示词解决问题。
8.3 持续的样本回流机制
最理想的迭代闭环是:线上问题日志 → 人工粗筛 → 补充到评测集 → 离线评测验证 → 优化检索或提示词 → 发布新版本 → 观察线上指标。这套机制跑通后,AI工程就进入一个正向循环。我在实际维护中最深的体感是:做一个AI应用并不难,难的是一直让它维持在靠谱状态里。
9. 回到标题:从零开始的AI工程,本质是在做“系统性”
“ai-engineering-from-scratch”这个项目,对我来说不只是写一套代码,更像是在建立一种思考方式。每一层的选型、每一个参数、每一次评测,都是在为目标服务:让一个AI系统在真实环境里可以被理解、被控制、被改进。
如果你现在正要从零开始一个AI项目,我想给出的建议很简单:不要先写代码,先写清楚数据和评测方案。把数据从哪里来、怎么清洗、是否过期、评测集怎么建、怎么判断好坏这些问题回答完,再动手开发,你的工程进度反而会快得多。很多项目碰到的“调不通”“效果不稳定”,根源都在前面这些环节欠了债。
这个过程不会一帆风顺,保留着一点好奇心和较真劲就好。每遇到一个奇怪的结果,就去翻日志、找原因,追得多了,你会慢慢感觉到自己不再是“调API的人”,而是真正在“做AI工程”了。