1. 这个项目到底在解决什么问题
第一次看到ai-engineering-from-scratch这个标题,我脑子里蹦出来的第一个念头是:终于有人把这件事挑明了。市面上讲AI工程化的内容,要么是调包侠式的“三行代码跑通大模型”,要么是论文复现式的“从注意力机制推导到反向传播”,中间那一大段真正决定项目能不能落地的工程地带,几乎是空白的。这个项目标题里的“from scratch”不是让你从零手写矩阵乘法,而是让你从零搭建一套能跑、能维护、能扩展的AI工程体系。
说白了,它瞄准的是这样一个群体:你会写Python,能看懂Transformer的大致结构,但真让你从零搭一个RAG系统、配一套向量检索、做一轮模型评测、再把它部署成能扛住并发请求的服务,你就开始心里发虚。这个项目就是冲着这个“心虚地带”来的。它要解决的核心问题是:把AI应用从“demo能跑”推进到“生产可用”之间那层窗户纸捅破。
我之所以对这个方向特别有感触,是因为过去两年我参与过好几个AI项目的从零搭建,踩过的坑几乎都能在这个标题的射程范围内找到对应。比如向量库选型时被“开箱即用”的宣传忽悠,上线后才发现召回率惨不忍睹;比如prompt版本管理混乱,改了一版效果变差却回滚不回去;再比如评测集构建偷懒,导致模型迭代完全靠感觉走。这些问题不是算法问题,是工程问题,而工程问题的解法往往比算法问题更琐碎、更依赖经验。
这个项目适合谁来啃?我认为有三类人收益最大。第一类是后端或全栈工程师,想转AI应用方向,但缺一套系统的工程化认知;第二类是算法工程师,模型调得动但服务搭不利索,需要补上部署、监控、评测这一环;第三类是技术负责人,需要一套可参考的架构决策清单,避免团队在选型和流程上反复交学费。不管你属于哪一类,接下来的内容我会把每个环节的“为什么这么选”和“我当时怎么踩的坑”都摊开讲。
2. 整体架构设计与技术选型思路
2.1 为什么不做“全家桶”,而是分层解耦
很多从零搭建AI工程的教程喜欢一上来就甩一个docker-compose,把向量库、模型服务、前端、数据库全塞进去,看起来一键启动很爽,但真到要替换某个组件的时候,你会发现牵一发动全身。我在实际项目里吃过这个亏:早期图省事,把embedding模型和向量检索逻辑硬编码在一起,后来想换一个更适配中文的embedding模型,结果发现检索层、缓存层、评测脚本全得跟着改,整整花了一周做迁移。
所以这个项目的架构思路我高度认同:分层解耦,每一层只通过明确定义的接口通信。具体来说,我会把它拆成五层。最底层是模型接入层,负责统一不同模型提供方的调用方式,不管是本地推理还是API调用,上层拿到的都是一致的请求响应格式。往上是数据处理层,管文档解析、分块、清洗、embedding生成。再往上是检索与编排层,这是RAG的核心,管向量检索、关键词检索、重排序、上下文组装。然后是应用逻辑层,管prompt模板、对话状态、业务规则。最上面是服务与观测层,管API暴露、限流、日志、评测、监控。
这么分的好处是,每一层都可以独立替换和测试。比如你今天用A模型做embedding,明天想换B模型,只需要在模型接入层加一个适配器,数据处理层重新跑一遍embedding任务就行,检索层和应用层完全不用动。这种解耦带来的维护成本降低,在项目进入第二个月迭代期时会体现得淋漓尽致。
2.2 向量库选型:别被“开箱即用”带偏
向量库的选型是AI工程里最容易让人纠结的环节之一。我见过太多团队在这个决策上反复横跳,最后选了一个“功能最全”的,结果发现运维成本高得离谱。我的经验是,选型要回到三个硬指标:数据规模、查询延迟要求、过滤条件复杂度。
如果数据量在百万级以下,过滤条件简单,我强烈建议从轻量方案起步。比如用FAISS做本地索引,配合一个关系型数据库存元数据。这样做的好处是零运维、调试透明,你能清楚地知道每一次检索到底发生了什么。等数据量涨到千万级,或者需要复杂的元数据过滤加实时更新,再考虑上专业的向量数据库。这个迁移时机很重要,太早迁移是过度设计,太晚迁移是技术债。
下面这张表是我在实际项目中总结的选型对照,供你参考:
| 方案类型 | 适用数据规模 | 过滤能力 | 运维成本 | 典型场景 |
|---|---|---|---|---|
| 本地索引方案 | 十万到百万 | 弱,需自行实现 | 极低 | 原型验证、小规模知识库 |
| 轻量向量库 | 百万到千万 | 中等 | 低 | 中等规模RAG应用 |
| 分布式向量库 | 千万以上 | 强 | 高 | 大规模生产系统 |
选型时还有一个容易被忽略的点:索引更新频率。如果你的知识库每天都有大量新增文档,那就要重点考察增量索引的能力,而不是只看查询性能。我踩过的坑是选了一个查询很快但增量更新要重建全量索引的方案,结果每天凌晨重建索引成了运维噩梦。
2.3 分块策略:RAG效果的分水岭
如果说向量库选型决定了下限,那分块策略就决定了上限。我敢说,大部分RAG效果不好的案例,根因都在分块上。常见的错误做法是按固定字符数切分,比如每500字一刀切。这种做法在技术文档上尤其致命,因为它会把一个完整的代码示例或者一个逻辑段落拦腰截断,检索出来的上下文缺头少尾,模型自然答不好。
我的做法是语义分块加重叠窗口。具体来说,先按文档的自然结构分(标题、段落、列表项),如果单个语义单元超过阈值再按句子边界切分,同时相邻块之间保留10%到20%的重叠内容。重叠的作用是防止关键信息刚好落在切分边界上被割裂。这个比例不是拍脑袋定的,我做过对比实验,重叠比例从0到30%逐档测试,发现15%左右在召回率和存储成本之间平衡最好。
还有一个细节是块大小的动态调整。技术文档的块可以小一些,因为信息密度高;叙述性文档的块可以大一些,因为需要更多上下文才能理解。我在项目里会为不同类型的文档配置不同的分块参数,而不是全局一刀切。这个配置化思路在后期维护时省了大量返工时间。
3. 核心环节的实操要点与避坑指南
3.1 模型接入层的统一抽象怎么做
模型接入层看起来简单,不就是包一层HTTP请求吗?但真做起来,坑比想象的多。第一个坑是不同提供方的响应格式差异。有的返回JSON里嵌JSON,有的流式返回的chunk格式各不相同,如果不做统一抽象,上层代码里会散落大量if-else判断,维护起来极其痛苦。
我的做法是定义一个统一的请求响应模型,所有适配器负责把各自格式转换成这个统一模型。请求侧统一成messages列表加参数对象,响应侧统一成content加usage加finish_reason。流式响应则统一成生成器,每次yield一个增量文本片段。这样上层应用完全不需要关心底层用的是哪家模型。
第二个坑是错误处理和重试策略。模型调用失败的原因五花八门:网络超时、限流、内容审核拦截、模型过载。如果不做区分统一重试,可能会把内容审核拦截的请求反复重试,既浪费配额又解决不了问题。我的策略是分类处理:网络类错误指数退避重试,限流类错误等待后重试,内容类错误直接返回不重试,模型过载类错误降级到备用模型。
class ModelAdapter: def __init__(self, config): self.max_retries = config.get("max_retries", 3) self.backoff_base = config.get("backoff_base", 1.5) def call(self, messages, **kwargs): for attempt in range(self.max_retries): try: response = self._do_call(messages, **kwargs) return self._normalize(response) except RateLimitError: time.sleep(self.backoff_base ** attempt) except NetworkError: time.sleep(self.backoff_base ** attempt) except ContentFilterError: raise raise MaxRetriesExceeded()这段代码的关键在于异常分类,不同异常走不同分支。实际项目中我还加了一个熔断机制,当某个模型连续失败超过阈值时,自动切换到备用模型并告警,避免雪崩。
3.2 检索环节的混合策略与重排序
纯向量检索有个天然缺陷:它对精确匹配不敏感。比如用户问“第三章第二节讲了什么”,向量检索可能召回一堆语义相关但章节不对的内容。这时候就需要关键词检索来补位。我的做法是向量检索和关键词检索并行执行,各取TopK,然后合并去重,再用重排序模型精排。
重排序这一步很多人省掉了,觉得向量相似度够用了。但实测下来,加一层重排序对最终答案质量的提升非常明显,尤其是在候选集较大的时候。重排序模型可以选择轻量级的交叉编码器,它对query和document做联合编码,能捕捉到向量检索漏掉的细粒度相关性。
这里有个参数需要仔细调:向量检索和关键词检索的召回数量比例。我的经验是向量检索召回稍多一些,因为它的语义泛化能力更强,关键词检索召回少一些作为精确补充。具体比例要根据你的数据特点做A/B测试,没有万能值。
还有一个实操细节是元数据过滤的前置还是后置。如果过滤条件能大幅缩小候选集,应该前置到检索阶段,减少计算量;如果过滤条件本身依赖检索结果,就只能后置。我在项目里会把过滤条件分成硬过滤和软过滤,硬过滤前置,软过滤后置参与重排序打分。
3.3 Prompt模板的版本管理与回归测试
Prompt是AI应用里最像“代码”但又最不像“代码”的东西。它没有编译期检查,改一个词可能效果天差地别,而且很难做单元测试。我见过团队把prompt直接写在业务代码的字符串里,改一版上线一版,出了问题连回滚都不知道回滚到哪个版本。
我的做法是把prompt当成一等公民来管理。每个prompt模板独立成文件,带版本号和变更说明,用Git管理。每次修改prompt必须附带一组回归测试用例,跑完对比新旧版本的输出差异。这个流程一开始觉得繁琐,但当你经历过一次“改了个标点导致线上答案全乱”的事故后,就会觉得这点繁琐完全值得。
回归测试的用例设计也有讲究。不能只测“正常问题”,要覆盖边界情况:空输入、超长输入、多语言混合、包含特殊字符、意图模糊的问题。我通常会维护一个50到100条的黄金测试集,每次prompt变更都跑一遍,人工抽检关键case的输出。
提示:prompt版本号建议和模型版本号绑定记录,因为同一个prompt在不同模型上的表现可能完全不同,分开记录会导致排查问题时无法复现。
3.4 评测体系的搭建:别让迭代靠感觉
没有评测体系的AI项目,迭代就是盲人摸象。我今天改了个检索参数,感觉答案变好了,但到底是真变好还是我看了几个case的错觉?没有量化指标,这个问题永远说不清。
我的评测体系分三层。第一层是检索层指标,包括召回率、精确率、MRR,衡量检索环节有没有把正确内容找出来。第二层是生成层指标,包括答案相关性、忠实度、完整性,可以用模型辅助打分加人工抽检结合。第三层是端到端指标,包括任务完成率、用户满意度,这个需要真实用户反馈或者模拟用户测试。
搭建评测体系最大的难点是标注数据的获取。我的经验是不要一开始就追求大规模标注,先从几十条高质量标注起步,覆盖核心场景,随着项目迭代逐步扩充。标注质量比数量重要得多,宁要50条精标,不要500条粗标。
评测频率上,我建议检索层指标每次代码变更都跑,生成层指标每天跑一次,端到端指标每周跑一次。这个频率是根据变更影响范围定的,检索层改动最频繁所以跑得最勤。
4. 部署、监控与持续迭代的实战经验
4.1 服务化部署的关键决策
把AI应用部署成服务,和部署普通Web服务有本质区别。最大的区别是延迟波动大。模型推理时间受输入长度、并发数、硬件负载影响,可能从几百毫秒到几十秒不等。如果不做特殊处理,用户体验会非常糟糕。
我的做法是异步加流式。对于长文本生成任务,同步等待完整结果再返回是不可接受的,必须用流式返回,让用户先看到部分内容。对于检索加生成的组合任务,检索可以同步快速返回,生成部分流式返回。这个架构调整对用户体验的提升是立竿见影的。
另一个关键决策是批处理还是单条处理。批处理能提高吞吐量,但会增加单条延迟。我的策略是设置一个短窗口,比如50毫秒,窗口内的请求合并成一批处理。这样在并发高的时候能显著提升吞吐,并发低的时候延迟增加可忽略。
资源分配上,GPU和CPU的配比要根据实际负载调。embedding计算和重排序适合GPU,文档解析和业务逻辑适合CPU。我见过把所有环节都塞到GPU上的方案,结果GPU利用率上不去,成本还高得吓人。
4.2 监控指标:盯住这几个就够了
AI服务的监控不能只看CPU和内存,那只能告诉你服务活着,不能告诉你服务好不好。我重点盯三类指标。
第一类是质量指标,包括检索命中率、答案拒答率、用户追问率。追问率是个特别灵敏的指标,用户追问往往意味着上一轮答案没解决问题,这个指标突然上升通常预示着某个环节出了问题。
第二类是性能指标,包括首token延迟、完整响应延迟、每秒token数。首token延迟直接影响用户感知,我一般要求控制在1秒以内。完整响应延迟根据任务类型定,但要有P95和P99的分位数监控,平均值会掩盖长尾问题。
第三类是成本指标,包括每千次请求的token消耗、GPU小时数、API调用费用。AI应用的成本很容易失控,尤其是prompt设计不合理导致token浪费的情况。我会定期分析token消耗分布,找出那些消耗高但价值低的请求做优化。
下面这张表是我实际使用的告警阈值配置,供参考:
| 指标 | 警告阈值 | 严重阈值 | 处理动作 |
|---|---|---|---|
| 首token延迟P95 | 1.5秒 | 3秒 | 检查模型负载 |
| 答案拒答率 | 10% | 20% | 检查检索召回 |
| 用户追问率 | 15% | 25% | 全链路排查 |
| 单请求token消耗 | 基线1.5倍 | 基线2倍 | 检查prompt |
4.3 持续迭代的节奏把控
AI项目的迭代节奏和传统软件不同。传统软件可以按固定周期发版,AI项目因为依赖模型和数据的动态变化,需要更灵活的迭代策略。我的经验是小步快跑加灰度验证。
每次迭代只改一个变量,比如只调检索参数,或者只改prompt,不要同时改多个东西,否则出了问题无法归因。改完之后先在灰度流量上验证,对比核心指标有没有显著变化,确认正向再全量。
迭代频率上,我建议检索和prompt层面可以高频迭代,每周甚至每天都可以调;模型层面要谨慎,换模型是大动作,需要充分的离线评测和灰度验证。数据层面则要持续做,文档更新、标注补充、badcase收集,这些是长期功夫。
还有一个容易被忽略的点是回滚预案。每次变更前都要想清楚,如果效果变差怎么快速回滚。prompt和参数类的变更回滚简单,模型和数据类的变更回滚成本高,所以后者变更前要做更充分的准备。
注意:灰度验证的流量比例不要设得太低,否则统计显著性不够,容易得出错误结论。我的经验是至少5%的流量,且观察周期不少于24小时。
5. 常见问题排查与独家避坑技巧
5.1 检索召回不准的排查路径
检索召回不准是最常见的问题,但排查起来需要系统性地逐层排除。我的排查顺序是这样的:先看query本身有没有问题,比如用户输入太短、有错别字、意图模糊;再看分块有没有问题,比如关键信息被切散、块太大导致噪声多;然后看embedding模型适不适合当前语言和领域;最后看检索参数和过滤条件有没有配错。
这个顺序的逻辑是从便宜到贵。query改写成本最低,分块调整次之,换embedding模型成本最高。我见过一上来就怀疑模型不行要换模型的,结果查了半天发现是分块把答案切成了两半。
具体排查时我会做一个检索诊断工具,输入一个query,输出检索到的TopK内容及其相似度分数,同时展示这些内容在原文中的位置。这样能直观看到检索到底召回了什么,是召回错了还是排序错了。这个工具开发成本不高,但排查效率提升巨大。
5.2 生成答案不忠实于原文的解法
答案不忠实,也就是模型“胡说八道”,是RAG的另一个高频问题。根因通常有三个:检索到的上下文本身就不包含答案,模型只能编;上下文包含答案但被噪声干扰,模型没抓住重点;prompt没有明确约束模型必须基于上下文回答。
对应的解法分别是:提高检索召回质量,这个回到上一节的排查路径;优化上下文组装,把最相关的内容放在前面,去掉低相关噪声;强化prompt约束,明确要求“如果上下文中没有答案,直接说不知道”。
我特别想强调第三点。很多prompt写得太温和,比如“请参考以下内容回答”,模型就会自由发挥。改成“只能基于以下内容回答,内容中没有的信息不要编造”,忠实度会明显提升。这个改动成本极低,但效果立竿见影。
还有一个技巧是引用标注。让模型在生成答案时标注每个信息点来自哪个文档片段,这样既能约束模型基于原文,又方便用户核查。实现上可以在prompt里要求模型输出引用编号,后处理时再映射回原文。
5.3 性能瓶颈的定位方法
性能问题排查需要分段计时。我会在请求处理链路的每个环节打点:query预处理耗时、检索耗时、重排序耗时、模型生成耗时、后处理耗时。这样一眼就能看出瓶颈在哪。
常见的瓶颈和对应解法我整理成了一张速查表:
| 瓶颈环节 | 典型表现 | 优化方向 |
|---|---|---|
| 检索 | 检索耗时占比高 | 加缓存、减候选集、换索引类型 |
| 重排序 | 重排序耗时占比高 | 减候选数、换轻量模型、批处理 |
| 模型生成 | 首token慢或总时长长 | 流式返回、换推理框架、量化 |
| 后处理 | 后处理耗时异常 | 检查正则、减少字符串操作 |
我踩过的一个坑是缓存失效策略没设计好。早期给检索结果加了缓存,但文档更新后缓存没及时失效,导致用户拿到过期答案。后来改成文档更新时主动清除相关缓存,问题才解决。缓存是好东西,但失效策略必须和更新流程绑定。
5.4 成本失控的预防措施
AI应用的成本很容易在不知不觉中涨上去。我见过一个项目,上线三个月成本翻了五倍,排查发现是prompt里塞了太多无关上下文,token消耗巨大但效果没提升。
预防成本失控,我的做法是建立成本基线并持续监控。每个功能模块设定单次请求的token消耗基线,超过基线1.5倍就告警。同时定期做成本审计,分析哪些请求消耗高,这些高消耗请求是否带来了相应的价值提升。
优化成本的手段有几个:精简prompt,去掉冗余指令和示例;压缩上下文,只保留最相关的片段;缓存高频请求的结果;对小任务用更小的模型。这些手段可以组合使用,但要注意每次只改一个,观察效果和成本的平衡点。
提示:成本优化不要牺牲质量。我见过为了省token把上下文砍太狠导致答案质量暴跌的案例,最后用户流失的成本远高于省下的token费用。
6. 从能跑到好用还差哪些工程细节
6.1 日志与可观测性的最小可用配置
AI应用的日志不能只记请求和响应,那样出了问题根本没法排查。我的最小可用配置包括:完整请求参数、检索到的文档ID和分数、最终prompt全文、模型原始响应、各环节耗时、token消耗。这些信息缺一不可。
日志存储上,我建议结构化存储,方便后续查询和分析。每次请求生成一个trace_id,所有环节的日志都带上这个ID,排查时按ID串联即可。这个trace_id还要透传到前端,方便用户报障时快速定位。
可观测性方面,除了前面说的监控指标,我还建议加一个答案质量抽样检查。每天随机抽取一定比例的请求,人工或模型辅助评估答案质量,及时发现质量下滑趋势。这个机制能捕捉到监控指标覆盖不到的隐性问题。
6.2 数据更新与索引重建的工程化
知识库不是一成不变的,文档会新增、修改、删除。如果索引更新流程没做好,会出现新文档搜不到、旧文档已删除还能搜到的问题。我的做法是增量更新加定期全量重建。
增量更新负责处理日常的文档变更,新文档生成embedding后插入索引,删除的文档从索引移除。但增量更新久了索引会碎片化,所以每隔一段时间做一次全量重建,保证索引质量。重建期间用双索引切换,避免服务中断。
文档变更的检测我用的是内容哈希。每次文档更新计算哈希值,和上次对比,变了才触发重新处理。这样避免了无变更文档的重复计算,节省大量资源。
6.3 多轮对话的上下文管理
单轮问答做好已经不容易,多轮对话的复杂度又上一个台阶。核心难点是上下文窗口有限但对话历史可能很长。全塞进去会超限且引入噪声,截断又可能丢失关键信息。
我的策略是分层管理。最近几轮对话完整保留,较早的对话做摘要压缩,更早的只保留关键实体和意图。摘要用模型生成,压缩比控制在合理范围。同时维护一个对话状态对象,记录用户已经确认的信息、当前任务进度等结构化数据,这些不依赖原始对话文本。
还有一个细节是指代消解。多轮对话里用户经常说“它”“那个”“上面说的”,需要结合历史把指代还原成具体内容再检索。这个环节做不好,检索会完全跑偏。我的做法是在query改写阶段就把指代消解掉,用模型把当前query改写成自包含的完整query再送去检索。
6.4 安全与合规的工程落地
AI应用的安全合规不是加个敏感词过滤就完事了。我的做法是多层防护。输入侧做敏感内容检测和注入攻击防护,检索侧做权限过滤确保用户只能检索到有权限的文档,生成侧做输出审核防止不当内容,日志侧做脱敏处理保护隐私。
权限过滤这块特别重要。如果知识库里有不同权限级别的文档,检索时必须带上用户权限过滤条件,否则会出现越权访问。这个过滤要在检索阶段就生效,不能等检索完再过滤,否则既浪费计算又可能通过相似度分数泄露信息。
输出审核我建议用规则加模型结合的方式。规则处理明确的敏感词和格式要求,模型处理隐晦的不当内容。两者互补,覆盖面更全。审核不通过的输出要有兜底话术,不能直接报错给用户。
7. 我在这条路上踩过的几个真实坑
第一个坑是过早优化。项目初期数据量才几万条,我就花了两周搭分布式向量库,结果运维复杂度上去了,性能提升却微乎其微。后来退回到轻量方案,开发效率反而高了。这个教训是:架构要匹配当前规模,留好扩展接口就行,不要提前为想象中的规模买单。
第二个坑是评测集污染。我早期图省事,用训练数据里的问题做评测集,结果指标虚高,上线后真实表现差很多。后来重新构建了独立的评测集,指标虽然降了,但和线上表现对齐了,迭代方向才正确。评测集必须和开发数据严格隔离,这个纪律不能破。
第三个坑是忽视冷启动。新用户第一次使用时,没有历史对话,检索也没有用户画像辅助,效果往往最差。但第一印象恰恰最重要。后来我针对冷启动场景做了专门优化,比如用更宽松的检索策略、更详细的prompt引导,效果改善明显。
第四个坑是prompt里的示例过时。prompt里放的few-shot示例是早期写的,后来业务变了但示例没更新,导致模型输出风格和当前业务不匹配。这个坑很隐蔽,因为prompt能跑通,只是效果慢慢变差。后来我把示例也纳入版本管理,和业务变更同步更新。
这些坑说到底都指向一个道理:AI工程化没有一劳永逸的方案,它是一个持续调优、持续对齐的过程。工具和框架会变,但分层解耦、量化评测、灰度迭代这些工程原则是稳定的。把原则吃透,具体技术选型反而没那么纠结了。
最后分享一个我一直在用的小技巧:每次遇到badcase,不要只修这一个case,要问自己“这类问题还有多少”。把单个case抽象成一类问题,然后从工程层面系统性解决,这样迭代效率会高很多。单个case修起来快,但同类问题会反复出现,系统性解决虽然前期投入大,但长期收益更高。