AI工程化从零实践:从大模型接口到稳定系统的完整搭建指南
看到“ai-engineering”这个热搜词的时候我第一反应不是去看哪个新框架又火了而是想起自己从零折腾“AI工程化”的那几个月。说实话当时我也以为AI工程就是调通大模型接口、写几句提示词、把输出拼成JSON返回给前端。真把一个项目推到能稳定上线、可控成本、方便排查问题的状态才发现这里面藏着一整套工程方法。这个项目我命名为“ai-engineering-from-scratch”意思很直白不靠现成全家桶把所有关键环节从最底层亲手搭一遍把黑盒变成白盒。这篇内容适合三类人想从后端转向AI应用开发的工程师被LangChain这类框架绕晕的新手还有已经在用大模型接口做Demo、但一上线就问题不断的产品团队。我会按自己实践的顺序把项目里的分层设计、上下文管理、RAG、Agent、质量评估和止损经验拆开讲保证你看完至少能搭出一个有工程骨架的AI应用而不是几个脚本拼起来的玩具。1. 先把“AI工程”这顶帽子想清楚它到底在工程化什么很多人对AI工程的误解是把它等同于“写提示词”或者“封装大模型API”。我一开始也这么认为直到被线上问题教育了几次才明白AI工程真正要解决的是如何让一个“天生带随机性”的系统稳定地跑在业务环境里。1.1 AI工程师到底在解决什么新问题传统后端工程面对的是确定性逻辑输入固定输出固定最多是算法复杂度问题。你写一个订单查询接口传进去订单号返回订单状态结果是可以预期的。但把大模型放进链路后情况变了同一个输入两次调用可能给出不同措辞、不同结构、甚至不同结论。它不是运行崩溃那种“非黑即白”的故障而是“能返回结果但质量跑偏”的灰色故障。我做过一个智能客服的Demo后端逻辑很简单用户提问拼进提示词调用模型把回答返回。本地测试一切正常一到生产就冒出来各种奇怪问题模型把两个字段名搞混了、答案里掺了系统提示词里没有的规则、遇到一个长问题直接截断成半句话。这时候你才意识到AI工程和普通后端最大的差别是要把“输出质量”纳入系统设计范围。你不再只是传参和接返回值还得对模型输出做校验、兜底、重试和人工介入。所以AI工程师的工作可以概括成一句话在不稳定模型之上搭建稳定的产品体验。提示词只是中间一环背后还牵扯到上下文预算、检索召回、输出规范、日志追踪和成本控制。这些才是真正值得花时间去设计和打磨的部分。注意如果只把模型调用当成一个远程函数来包而不考虑它可能产生的异常行为那这个系统充其量是个能跑的脚本离“工程”还差得很远。1.2 一份从零开始的AI工程能力地图既然要“从零开始”第一件事不是写代码而是把需要的能力列清楚。我给自己画过一张能力地图按依赖关系分成五层层级核心内容落地产物基础层API调用方式、模型参数含义、上下文窗口、令牌计算能调通接口并理解返回结构数据层文档解析、文本切分、向量化、检索、重排能构建一个可查询的知识库模型层模型选型、统一接入、结构化输出、提示词版本管理能切换模型并稳定输出JSON引擎层RAG、Agent、工具调用、工作流编排能做多步推理和工具协同工程层日志、评估、限流、缓存、成本配额能支撑小规模线上流量这张表的每一行都有对应的学习路径。基础层的标志是你不再靠“试错”理解temperature和top_p而是知道它们各自影响输出的哪个维度。数据层的标志是你能够解释为什么某个碎片检索不到、为什么两个片段内容重复。引擎层的标志是你能判断一个问题该用RAG解决还是靠Agent干活而不是所有请求都一股脑丢给模型。我当时把这张表打印出来贴在显示器旁边每完成一层就打一个勾。最大的感受是越往后越偏传统工程。到了工程层你干的事和普通后端高度重合只是监控对象从接口耗时变成了“Token消耗”和“回答准确率”。这给转行的后端工程师提了个醒你的并发、缓存、日志能力完全没有浪费只是在AI场景里换了一种用法。1.3 工具选型为什么我坚持“少而够用”这个项目刚起步那几天我差点掉进框架焦虑里。今天的AI生态有个毛病框架比需求多。你还没搞清楚自己的调用链路先被LangChain Chain、Agent、Memory这些概念塞了一嘴。我最后的选择很收敛项目核心依赖只有几个组件我用的方案选择理由编程语言Python生态最全模型SDK基本都是Python优先网络请求httpx支持同步异步超时和重试控制灵活数据校验Pydantic结构化输出校验的标配模型接入LiteLLM统一入口同一套代码能切不同模型服务向量存储LanceDB本地嵌入式文件库零运维负担编排框架不采用前期手写链路把原理吃透后再按需引入选择“少而够用”的理由很实际框架会把很多细节藏起来出了问题你只能对着黑盒猜。LiteLLM我留着的唯一原因是它能兼容多家平台的调用方式切换模型只改环境变量但它的定位只是“客户端”不负责业务编排。向量库选LanceDB是图省事一个文件搞定省去搭建专用服务的麻烦。核心判断标准是每一个被引入的依赖都必须能回答“没有它我会多花多少成本”。如果答案是“只是少写几行样板代码”那我会选择自己写因为那几行代码恰恰是理解原理的最好教材。等以后项目复杂度真上来了再考虑LangGraph这类专门的工作流引擎也不迟。2. 从空目录开始搭出一个能跑起来的AI工程骨架确定工具后我没有立刻写调用代码而是先画了系统边界设计目录。这个阶段看似慢实际上决定了后面调试的效率。2.1 动手之前先画边界四类边界问题AI工程里最容易失控的是“边界模糊”。模型不知道哪些事能做、哪些事不能做程序也不知道哪些输出该信、哪些该弃。我在项目里定义了四类边界第一是模型边界。任何能用规则代码完成的事都不要交给模型。比如订单状态的合法性校验用一行枚举判断就够了硬塞进提示词只会引入错误空间。我的原则是“模型只做需要语言理解的事能算的用代码算能查的用数据库查”。第二是数据边界。不是所有数据都适合放进上下文。内部文档、用户隐私、动态详情要区分哪些通过向量检索注入哪些通过代码查询后拼入框架哪些绝不能进模型。第三是用户边界。输入长度上限、身份权限、单用户调用频率必须在网关处做限制不能全指望模型自觉。第四是输出边界。你希望模型返回自然语言还是严格JSON需要在请求端就约束清楚并且在后端做格式校验。画完这四条边界再去写代码很多问题会自动消失。比如之前遇到的“模型把两个字段搞混”本质是输出边界没设好。后来我在提示词里给了JSON样例同时在后端用Pydantic做校验不合格就重试一次问题立刻缓解。2.2 一个可以直接抄的目录结构这个项目的目录结构是迭代了几版才定下的我直接贴出来你可以按需删减ai-engineering-from-scratch/ ├── config/ │ ├── settings.py │ └── .env.example ├── data/ │ ├── documents/ │ └── vector_store/ ├── core/ │ ├── llm/ │ │ ├── client.py │ │ ├── schema.py │ │ └── prompts.py │ ├── rag/ │ │ ├── loader.py │ │ ├── splitter.py │ │ ├── embedder.py │ │ └── retriever.py │ ├── agent/ │ │ ├── tools.py │ │ └── executor.py │ └── evaluator/ ├── services/ │ ├── chat_service.py │ └── search_service.py ├── app/ │ ├── api.py │ └── main.py └── tests/这个结构的核心思想是“分层隔离”。core目录放纯逻辑所有模型调用、检索、工具执行都在这里services目录做业务编排把core里的能力组合成具体服务app目录只负责接收HTTP请求和返回响应里面不写任何业务代码。这样分层以后把FastAPI换成其他框架或者把同步代码改成异步都不需要动core里的核心逻辑。还有一个容易被忽略的细节config目录必须有。AI应用要用的配置项远比普通后端多模型名称、温度、超时、最大Token、向量库路径、提示词版本号全部集中管理。我第一次偷懒把模型名硬编码在代码里换模型时翻了七八个文件才改完从此老实了。2.3 统一模型接入层切换模型只改一行配置AI工程里最不确定的东西就是模型本身。今天用的这个模型效果不错明天换个参数量更大的可能更好这家平台收费贵了就要考虑换一家。为了不让这些变化侵入业务代码必须做统一接入层。我当时写了一个很薄的客户端核心接口就一个chat方法# core/llm/client.py from typing import Optional from dataclasses import dataclass dataclass class LLMConfig: provider: str model: str api_base: Optional[str] None temperature: float 0.2 max_tokens: int 1024 class LLMClient: def __init__(self, config: LLMConfig): self.config config def chat(self, messages: list[dict], **kwargs) - str: if self.config.provider litellm: from litellm import completion params { model: self.config.model, messages: messages, temperature: kwargs.get(temperature, self.config.temperature), max_tokens: kwargs.get(max_tokens, self.config.max_tokens), } resp completion(**params) return resp[choices][0][message][content] # 其他provider可以继续扩展 raise ValueError(funsupported provider: {self.config.provider})这个层不是为了炫技而是要解决三个问题第一统一返回结构。不管底层是哪个平台业务代码拿到的永远是一个字符串或对象换模型不影响上层逻辑。第二统一超时和重试。真正生产环境里超时重试必须在一个地方管理而不是散落在各处try except。第三统一成本日志。每次请求的Token消耗、模型名、耗时都在这一层记录后面的成本分析才有数据源。实际项目中我还会在chat方法里加一个“响应空值检测”如果返回内容为空或格式不对触发一次自动重试重试时把temperature调低。这一步看似简单却拦截了大量线上偶发问题。3. 工程化的灵魂上下文、RAG和Agent的落地细节如果说第一版项目靠目录结构立住了骨架那让这个项目真正发挥价值的就是上下文工程、RAG和Agent这三个核心模块。这一章我按实践顺序逐个拆解每一步都标注了为什么这么做以及我在真实项目中踩到的坑。3.1 上下文工程提示词模板要“版本化”提示词不是写一次就完事的东西它会随着产品需求变化、用户反馈和模型演进不断修改。如果直接把提示词硬编码在代码里每次改动都可能引发布满问题。我把提示词模板放到了独立的py文件里用字符串模板组织做到“提示词和业务代码分离”。实际落地时我推荐把提示词拆成四个部分系统角色、背景资料、用户问题、输出约束。这样每部分都能独立调整。以下是我在项目里实际用过的模板结构# core/llm/prompts.py SYSTEM_TEMPLATE 你是一个{role}。 请基于以下背景资料回答问题 {context} 输出要求 {rules} QUERY_TEMPLATE 用户的问题是 {query} 组装过程很简单从配置读取role从检索模块拿context从用户输入拿query再拼上rules。为什么这样拆原因是我发现实际调优过程中90%的修改都集中在“rules”部分其余部分很少动。把rules单独拆出来改起来定位快也方便做A/B测试。上下文窗口是有限的所以我把提示词组装封装成一个“预算函数”先算固定部分占了多少Token再决定能放多少检索片段、多少历史消息。优先级是系统指令最高用户当前问题次之检索片段再次之历史消息最后。一旦超限先砍历史消息再砍检索片段确保最核心的指令和问题不被挤掉。实操心得温度参数不要一直用默认值。事实型任务客服答疑、信息抽取我通常设0.2以下创意型任务可以到0.8。温度高了回答确实更“有人味”但JSON格式错误率也会成倍上涨。3.2 知识库RAG从文档切分到混合检索RAG是我这个项目里投入时间最多的模块也是大家最容易做成一锅粥的地方。RAG的逻辑不复杂把知识文档切成小段向量化后存进向量库用户提问时先检索相关片段再把这些片段塞进上下文给模型参考。看起来简单实际有一堆细节决定效果。切分这一步我试过几种策略按固定字符数切、按标题段落切、按语义分割。最后保留的方案是“段落结构优先固定窗口兜底”。先把文档按Markdown标题层级切成一棵树每个节点保持语义完整如果某段超过长度上限再按固定窗口切并让相邻窗口重叠50到100个Token避免语义被拦腰斩断。切分之后是向量化和检索。我用本地向量库LanceDB免运维。但只靠向量相似度检索并不够会出现关键词精确匹配失效的问题。所以我采用的是混合检索向量检索覆盖语义相近但措辞不同的情况BM25关键词检索覆盖精确术语的情况然后用倒数排名融合算法把两路结果合并。# core/rag/retriever.py def reciprocal_rank_fusion(vector_results, keyword_results, top_k5): scores {} for rank, doc_id in enumerate(vector_results): scores[doc_id] scores.get(doc_id, 0) 1 / (rank 1) for rank, doc_id in enumerate(keyword_results): scores[doc_id] scores.get(doc_id, 0) 1 / (rank 1) return sorted(scores, keyscores.get, reverseTrue)[:top_k]我当时第一个版本只用了向量检索结果发现产品文档里“发票抬头”这种精确术语经常匹配不到因为语义向量把它和“报销单据”混在一起。加上BM25之后这类问题基本消失。如果你的检索精度要求更高还可以在检索后加一个重排序环节用交叉编码器对候选结果逐条打分代价是延迟增加但准确率提升明显。RAG最容易犯的错是“不检索也硬塞”。有些人图方便把所有知识文档全部塞进提示词以为模型能自己找答案。这会带来两个后果Token超限和答案被无关信息干扰。正确做法是先检索再组装最后放入上下文。这一步的成本差距巨大一次全库塞入可能用掉几万Token而一次精准检索可能只需要1500Token。3.3 Agent从规则流程到自主规划的三步走Agent是AI工程里最“诱人”但也最容易翻车的部分。我建议不要一上来就做完全自主的Agent而是沿着三步走。第一步是固定流程编排。输入进来以后代码按照写死的顺序调用工具先查订单再查物流最后让模型综合回答。这不算真正的Agent但作为起点可以让系统先跑通。第二步是函数调用。模型被允许在回答里“表达”它想调用哪个工具、传什么参数后端解析后执行工具再把结果返回给模型。这是目前最落地的方式因为它把决策交给模型但执行权仍在代码手里可控性很强。以下是我定义的订单查询工具Schema# core/agent/tools.py TOOL_QUERY_ORDERS { name: query_orders, description: 根据订单ID查询订单状态和物流信息, parameters: { type: object, properties: { order_id: {type: string} }, required: [order_id] } }第三步是规划加执行。模型先把大任务拆成小步骤再逐步调用工具每步结束还可以反思是否需要调整计划。这种形态灵活度高但同样意味着不可控因素多必须有最大轮次限制、中断开关和人工确认节点。在这三步里我踩过最大的坑是工具描述写得太模糊。开始我在description里只写了“查询订单”模型经常把“查询订单”和“查询物流”混用。后来把描述改成“根据订单ID查询订单状态和物流信息”并补充参数格式示例准确率立刻上来。记住工具名称和描述就是模型的“认知索引”写清楚比写花哨有用得多。3.4 质量和成本日志、评估、限流一起上一个AI工程能不能真上线拼的不是单个Prompt写得有多好而是你能否看清系统哪里在漏钱、哪里在出错。我在项目里上了三件套结构化日志、自动评估、成本控制。结构化日志的核心是把每次模型调用的关键信息记录成JSON包括时间、模型、输入Token数、输出Token数、耗时、温度、是否重试、返回是否通过校验。有了这份日志你可以回答三类问题哪个用户用了多少Token、哪个模型超时最多、哪个业务场景格式错误率最高。自动评估我采用“黄金集加规则检查”的组合。黄金集是人工挑选的几十条典型问题和期望答案每次提示词或检索策略改动后用同一批问题跑一遍统计准确率波动防止优化一个场景破坏另一个场景。规则检查则是硬性校验比如是否包含禁用词、JSON是否可解析、答案长度是否合理。成本控制方面我给每个业务接口设了Token预算。先算模型输入输出的平均Token数再乘以单价得到单次请求成本最后乘以预估调用量就是月度预算。一旦某个接口超预算我优先考虑削输入减少检索片段、缩短历史消息、压缩系统提示词而不是直接换更便宜的模型因为换模型往往伴随质量下降。4. 实战中的坑从调试到上线我踩过的雷和排查路径这一章是我最想写的部分因为AI项目的坑和传统项目很不一样。传统项目报错多半有明确堆栈AI项目更多是“能跑但不对劲”排查起来全靠方法。4.1 先固定变量再谈调优最开始调试的时候我犯过一个相当低级的错误同时改了提示词、检索TopK、温度三个东西结果效果变差完全不知道是哪一环导致的。那是AI调试里最大的禁忌——同时动多个变量。后来我给自己定了一条铁律每次只改一个变量。先把temperature固定到0.1跑通基线然后单独调检索片段数量再单独调提示词结构。每调一个变量跑同一组测试用例记录通过率。听起来慢实际是唯一不让自己陷入玄学的方法。排疑顺序也有讲究我习惯按“检索内容、上下文组装、模型参数、提示词细节”这个顺序查因为检索问题通常影响面最大。举个例子有次用户问“退款多久到账”模型回答得总是不够准确。我第一反应是提示词不行结果排查后发现检索出来的片段里根本没有退款政策文档因为那批新文档忘了切分入库。这是数据层问题不是提示词问题调提示词调多久都没用。4.2 Token超限与成本预算的现场计算Token超限是每个AI应用都会碰到的墙。我的一个知识库问答功能最初把所有相关文档片段都放进Prompt结果用户问题一长就直接报错。后来我引入了一个简单的预算公式预估输入Token 系统提示词Token 用户问题Token 检索片段Token 历史记录Token算下来固定消耗大概是1800Token检索片段平均消耗1600Token历史记录平均消耗1200Token总输入就接近4600Token。如果把模型的max_tokens设为1024单次调用消耗就是5600多Token。按照当时平台的计费单价单个请求成本约几分钱看起来不贵但一旦每天有上万请求成本立刻变成大头。为了让预算可控我在代码里加了Token截断策略给每个部分设定上限超出时按优先级砍掉最不重要的内容。检索片段数量从5降到4历史记录从10条砍到6条整体成本下降约20%回答质量没有明显变化。这个案例想说明的是Token预算不能靠感觉你得算给老板看算清楚才能有理有据地做取舍。4.3 接口超时、并发抖动和异常兜底大模型接口和传统接口的响应速度完全不是一个量级。我的接口平均响应在3到8秒之间高峰能达到15秒。这个现实带来两个直接问题超时配置必须放宽而且不能把大模型调用直接暴露给前端。我在客户端设置了连接超时10秒、读取超时60秒。重试策略采用指数退避第一次重试等待2秒第二次等待4秒最多重试两次。重试还有一个前提只重试那些可安全重复的请求比如纯文本生成如果是创建订单这种有副作用的操作绝不盲目重试。并发控制也很重要。模型服务端通常有每分钟请求数限制直接高并发调用必被拒。我在调用层加了一个信号量来限制并发数比如最多同时5个请求其余的排队等待。同时给相同问题加了一层文本缓存短时间内完全相同的查询直接命中缓存不消耗模型调用。这个措施让我的模型调用量下降了约三成效果立竿见影。异常类型典型表现我的处理方式超时请求长时间无响应指数退避重试2次为上限限流返回限流错误码增加并发等待拉长重试间隔解析失败返回内容不是合法JSON校验失败自动重试温度降到0空响应返回空白或截断记录日志返回预设兜底话术内容安全拦截返回空白或模糊提示分类处理非敏感误判则提示用户换措辞4.4 提示词调整如何摆脱“玄学”聊到提示词很多人会有一种“调参靠玄学”的错觉。实际上提示词工程可以很工程化。我做的第一件事是把所有提示词放进Git仓库管理每次改动都有diff记录配合黄金集测试结果形成“改动前测试、改动后测试”的对照流程。第二件事是让提示词全部参数化除了模板变量还包括输出格式示例尽量少用自由发挥式描述。第三件事是用户输入归一化把多余空格、特殊符号、异常编码清洗掉这一步能减少很多模型理解偏差。提示词迭代还有一个技巧先做减法再做加法。当效果不好时先删掉那些“看起来安全”的冗余描述比如“请你务必”这类废话因为它们会稀释指令权重。删完以后还不行再加结构化示例。实际证明一个紧凑、结构清晰、带具体示例的提示词通常比一个冗长、充满强调词的长提示词更稳定。特别提醒不要迷信“万能提示词模板”。网上流传的模板往往是为特定场景设计的直接抄到自己的业务里大概率水土不服。真正好用的提示词是从你的错误案例里长出来的。最后说点我个人的体会。做了这个“ai-engineering-from-scratch”项目以后我最深的感受是AI工程不是某一天的灵感爆发而是大量细小决策累积出来的结果。统一接入层、混合检索、结构化日志、黄金集测试每一项单独看都不酷但组合在一起才让系统从“偶尔聪明”变成“稳定可用”。如果你也在从零开始我建议别急着追逐新框架先把最底层的东西亲手搭一遍。踩坑的过程确实慢但它给你的判断力比任何现成工具都值钱。