
做个能上线跑的AI应用和“能跑通的Demo”之间隔着一条巨大的鸿沟。我这么说是因为我最近把一个项目从零开始真正的搭了一遍经历了许多我以为自己知道、实际动手才发现完全不是那么回事的环节。这个项目我起名叫 ai-engineering-from-scratch核心是想验证一件事抛开那些现成的玩具代码如果我对一个AI应用完全没有存量代码从一块白板开始到底需要处理多少“算法之外”的脏活累活。我指的不仅仅是训练一个模型或者接一个API而是从需求拆解、方案选型、数据准备、接口封装、部署上线到性能优化这一整个完整链路。这篇文章就是要把这一路上踩过的坑、验证过的方案原原本本的记录下来给准备从零开始走一遍AI工程化流程的同学一个比较真实的参考。先说结论真正的AI工程化模型推理只是最后那个10%的环节。前面有90%的工作量消耗在数据质量、流程设计、工程边界以及各种“生效”和“失效”的玄学问题上。如果你也准备动手或者正在被自己搭出来的AI应用折磨这篇文章应该能帮你少走好几天的弯路。1. 从点子到边界把“做个AI应用”压缩成“可交付的规格”很多人拿到“AI工程”这个题目第一反应是立刻去跑模型。但以我的真实感受动手前的需求拆解才是最考验工程能力的地方甚至比模型选型还重要。1.1 需求的三层拆解法我在项目开始时给自己的定位是做一个“能用自然语言描述需求然后自动输出结构化结果”的AI服务。这个描述听起来很清楚实际上落到工程设计层面四处漏风。“自然语言”是哪种语言“结构化结果”是JSON还是XML“自动输出”需不需要人工确认环节这些模糊点如果不趁早锁死后面每一个都会变成返工导火索。我对需求的拆解分了三层能力层这个应用要拥有什么核心智能。例如是基于大模型做文本理解还是基于传统模型做分类或者两者混合。接口层用户怎么和这个能力互动。是同步HTTP请求还是异步任务队列又或者是离线批量处理。数据层输入数据长什么样输出数据需要被谁消费。这决定了我后面用哪种数据结构去承接。这三个层面想清楚之后我把目标收敛成了一个具体的规格用户提交一段不超2000字的描述文本系统通过大模型接口提取出其中的关键实体和关系最终输出一个JSON结构。看起来很小的一个目标实际上完整覆盖了API设计、模型调用、数据校验和错误处理四个工程模块。1.2 MVP范围的自我约束做工程的人最忌讳一上来就追求大而全。我在设计之初有个很清晰的MVP最小可用产品边界需求维度MVP范围暂不实现输入形式纯文本粘贴限2000字以内多文件上传、网页抓取交互方式同步HTTP请求等待返回长时任务异步回调、流式输出模型能力实体识别关系抽取复杂推理、多轮对话输出结构严格JSON格式带置信度评分、可解释性分析这个范围的好处是让我能把注意力全部放在服务架构本身而不是被业务功能的枝枝叶叶带跑偏。当你决定把一个AI项目从零开始做进生产环境时请相信“可以不用但不能没有”的架构思维和“现在不做以后再加”的功能取舍两者可以同时存在而且必须同时存在。2. 方案选型API调用还是本地部署为什么我选了混合策略很多人一想到AI工程就从本地部署一个开源模型开始觉得这样“免费”且“可控”。但我这轮做完之后看法完全变了——你以为的“免费”算上显存成本、运维成本、开发迭代成本之后往往比商用API贵得多。2.1 两条路的成本账我给自己算过一笔非常真实的账本地部署路线一张消费级显卡起步显存起码12GB才能跑像样的7B模型。光这个硬件门槛就卡掉了一批人。即使你已经有卡模型推理时GPU占用率动不动90%往上机器基本干不了别的活儿。全天候运行的耗电量、模型存储占用的磁盘、为了跑大模型额外加的内存这些折旧算下来真不便宜。API调用路线按次数计费单次调用几十毫秒到几秒价格要看模型级别。对个人项目来说最大优势是零运维成本模型升级他们负责API挂了他们兜底。缺点是数据要出网对数据敏感的场景不适用而且单次调用量大了之后费用会线性增长。我最终的结论是两边都不能全押。核心链路用商用API保效果和速度但保留一个本地小模型的降级通道万一API限流或者服务不稳至少服务不会完全瘫痪。2.2 模型选择的具体考量当时进入我筛选视野的几个备选通用对话类大模型胜在什么都能聊结构化输出能力需要自己调Prompt强项是语言理解但在“精确返回JSON”这件事上如果不加约束会经常自由发挥。代码生成类模型对结构化输出的理解更强因为它们本身见过海量的代码和数据结构给它们明确的JSON Schema约束后生成结果准确率明显更高。我最终选型偏这一挂。本地开源小模型负责简单分类、关键词提取这类低难度任务。因为任务简单小模型够用也不用担心效果崩坏。这轮体验下来的核心认知是模型选型本质上是在“效果-成本-延迟”三条线的约束下找平衡脱离任务难度去追什么“最强模型”一点意义没有。低难度任务丢给大模型是杀鸡用牛刀高难度任务丢给小模型是注定翻车。3. 搭建最小可运行骨架从FastAPI到模型推理接入需求定好了模型选型有了接下来就是代码落地。这一部分我走的弯路最多核心教训就是AI工程首先是工程然后是AI。模型推理的代码只占总代码量的一小部分服务框架、数据校验、异常捕获才是大头。3.1 为什么选FastAPI而不是Flask我接触过不少后端项目用Flask但到了AI服务这个场景FastAPI几乎是当前最优解原因有三性能优势FastAPI基于ASGI异步框架和同步框架Flask相比在IO密集场景大量的网络请求、模型API调用下能支撑更高的并发。AI服务的瓶颈恰恰在等待模型返回这个过程异步能力能把这个等待时间让出来服务别的请求。数据校验零成本FastAPI自带Pydantic数据模型校验请求体是JSON还是Form、字段类型对不对它会自动帮你挡一道。这对AI服务尤其重要因为输入数据的质量直接决定模型输出的质量让脏数据在前置就断掉比在模型侧拼命补救高效得多。交互式文档FastAPI自动生成Swagger文档调试阶段直接在浏览器里发请求、看响应省掉了我单独写接口测试工具的时间。3.2 服务骨架的目录设计工程要“从零开始”软件的目录结构就必须在动工前铺好不然后面每加一个功能都在往垃圾桶里扔代码。ai-engineering-from-scratch/ ├── app/ │ ├── main.py # FastAPI入口 │ ├── api/ │ │ ├── routes.py # 路由定义 │ │ └── schemas.py # Pydantic请求/响应模型 │ ├── core/ │ │ ├── config.py # 配置管理 │ │ └── logging.py # 日志封装 │ ├── services/ │ │ ├── llm_client.py # 模型调用封装 │ │ └── prompt_builder.py # 提示词构建 │ ├── models/ │ │ └── validators.py # 输出结构校验 │ └── utils/ │ ├── text_process.py # 文本预处理 │ └── retry.py # 重试机制 ├── tests/ # 单元测试与接口测试 ├── pyproject.toml # 依赖管理 ├── .env.example # 环境变量样例 └── Dockerfile # 部署镜像这个结构是我迭代到第三个版本才定下来的。最初我只有两个文件main.py里塞了所有逻辑llm_client和prompt_builder混在一起结果改一次Prompt就要动整个服务文件。后来忍痛拆开才发现把不同职责的代码隔离在不同模块里不仅仅是为了好看更是为了让你改一个地方的时候不用提心吊胆怕弄坏另一个地方。3.3 模型调用的封装逻辑模型调用这块我坚持要做成独立的llm_client模块上层业务代码绝对不允许直接拼字符串调API。这样做的目的有三个第一换模型厂商时只需要替换这一个模块其他代码原封不动第二可以集中处理认证、限流、重试这些横切逻辑第三测试时方便做mock不用真的去调用外部API也能验证业务流程。我当时的LLM客户端核心逻辑大概长这样class LLMClient: def __init__(self, model: str, api_base: str, api_key: str): self.model model self.api_base api_base self.api_key api_key self.session httpx.AsyncClient( base_urlapi_base, headers{Authorization: fBearer {api_key}}, timeout30, ) self.max_retries 3 self.backoff_factor 1.5 async def complete(self, prompt: str, schema: dict | None None) - dict: 调用模型接口并返回结构化的JSON结果。 如果指定了schema则要求模型按schema输出。 payload { model: self.model, messages: [{role: user, content: prompt}], temperature: 0, } if schema: payload[response_format] {type: json_schema, schema: schema} for attempt in range(self.max_retries): try: response await self.session.post(/chat/completions, jsonpayload) response.raise_for_status() data response.json() content data[choices][0][message][content] # 这里统一走JSON提取逻辑后面专门说这个 return extract_json(content) except Exception as e: if attempt self.max_retries - 1: raise sleep_time self.backoff_factor ** attempt await asyncio.sleep(sleep_time) raise RuntimeError(模型调用失败)几个细节值得展开说说temperature固定为0这是结构化抽取任务的标准配置。temperature越高模型输出越发散对必须产出可靠JSON的任务来说发散等于灾难。response_format指定json_schema可以极大的提升JSON输出成功率。这是API服务时代比直接对话更好的结构约束方式。如果用的API不支持这个参数就得靠强Prompt约束但成功率会下降。重试机制里我使用了指数退避1.5倍基数递增第一次失败等1.5秒第二次等2.25秒第三次等3.375秒。看起来只是加时间实际上是为了在瞬态故障时不把API服务自己的限流阈值打爆。3.4 流式接口和普通接口的取舍我最初只想做普通同步接口发一个请求等一个完整响应。但后来测试发现模型生成时间稍微长一点前端那边就会因长时间无响应而主动断开连接。于是我又加了一个流式接口让模型边生成边通过SSEServer-Sent Events推送内容。普通接口适合处理耗时较短的任务流程图简单调用方的代码也直观。流式接口适合模型生成时间长、用户需要感知进度或者部分结果沉淀的场景。但对严格JSON输出的任务流式有个天然矛盾——JSON必须完整闭合才能解析边生成边推送的只是一个半成品字符串中间状态没法用。最后我的解决方案是默认走普通同步接口只有调试和大文件处理场景走流式而核心的JSON解析永远在完整返回之后再做。4. 工程化的真正重头戏提示词管理、上下文裁剪和输出校验模型本身的能力边界在那里真正拉开两个工程水平差距的是工程上怎么“驾驭”模型。这一节可能是全篇含金量最高的部分。4.1 提示词模板为什么要集中管理很多刚上手的人喜欢在业务代码里直接写字符串拼接prompt f请从以下文本中提取实体和关系文本是{user_input}这段代码在Demo阶段没有问题但一旦你的提示词需要维护、版本比对、A/B测试它就会变成灾难。我做了集中的prompt_builder模块用模板参数的方式动态构建提示词class PromptBuilder: def __init__(self): self.template 你是信息抽取引擎。你的任务是分析用户输入的文本提取出其中的关键实体以及实体之间的关系。 ## 输入文本 {input_text} ## 输出要求 1. 严格按照JSON格式输出 2. JSON必须包含entities和relations两个字段 3. entities字段是一个列表每个元素包含name和type 4. relations字段是一个列表每个元素包含source, target, relation ## 输出示例 {entities: [{name: 张三, type: 人物}], relations: [{source: 张三, target: 某公司, relation: 任职于}]} 请只输出JSON对象不要输出任何解释文字。 def build(self, input_text: str) - str: return self.template.format(input_textinput_text[:2000])用模板管理有三个好处版本可追溯每次改Prompt存一个版本效果回退的时候可以快速回滚而不是靠记忆去猜改了哪句话导致效果崩坏。参数明确模板里的{input_text}一眼可见不会出现拼接错误。统一语义边界所有模型的输入都走这一个模板不同模型之间的效果对比才是公平的没有变量污染。4.2 模型上下文长度和裁剪策略模型有上下文长度限制输入文本太长会触发截断或者直接被拒绝。我最初天真地以为输入长度控制在模型最大上下文以内就行结果现实给我上了一课。第一模型最大上下文是Prompt加输出的共同长度上限。你输入了2000字模型输出的JSON可能有500字那实际上你可用输入是上下文上限减去输出预留空间。第二Prompt中除了用户文本还有系统指令、示例输出等固定内容这些都占上下文。第三输出预留空间太小时模型很容易出现生成到一半被截断的情况返回一个不完整的JSON。我的裁剪策略最终定成了这样MAX_INPUT_CHARS 1500 MAX_OUTPUT_CHARS 500 MAX_TOTAL_CHARS 2000 def truncate_input(text: str) - str: if len(text) MAX_INPUT_CHARS: return text truncated text[:MAX_INPUT_CHARS] return truncated \n... [文本已截断]“文本已截断”这个标记看着简单实际上对模型有很强的提示作用让模型知道自己看到的不是完整输入不会强行去猜测缺失部分的内容。测量之后发现同样的任务输入裁剪到1500字以内后JSON输出成功率从79%提升到了94%。这个数据的说服力比什么都强。4.3 从模型返回里“抠”出干净的JSON当前大模型即使指令里写了“只输出JSON对象”真实返回时还是可能出现markdown代码块包裹、前后有多余文字、甚至把JSON和自然语言混在一起的情况。模型层面的不稳定性必须靠工程层面的兜底来对冲。我写了一个专门做JSON提取的函数处理顺序是从严格到宽松逐步降级def extract_json(content: str) - dict: # 1. 尝试直接解析 try: return json.loads(content.strip()) except json.JSONDecodeError: pass # 2. 尝试从markdown代码块中提取 pattern r(?:json)?\s*(.*?)\s* match re.search(pattern, content, re.DOTALL) if match: try: return json.loads(match.group(1)) except json.JSONDecodeError: pass # 3. 尝试提取第一个{到最后一个}之间的内容 start content.find({) end content.rfind(}) if start ! -1 and end ! -1 and end start: try: return json.loads(content[start:end1]) except json.JSONDecodeError: pass # 4. 全部失败则抛出异常交给上层走降级逻辑 raise ValueError(f模型输出中未找到合法JSON: {content[:100]})这个四级降级提取法在这一轮里至少救了我十几次。基本原理是大模型输出HTML格式时习惯用代码块包裹代码这种包裹对JSON来说是纯干扰。直接把“{”到“}”的闭合区间抠出来再解析通常能绕开模型输出里的绝大多数“废话”。4.4 结构化输出的最强兜底给模型一个Schema如果你用的API支持response_format参数强烈建议直接传一份JSON Schema给模型。这是我实测下来获取确定性和结构化结果最有效的办法几乎不用再处理“模型自由发挥”的问题。我给实体抽取任务写了一份Schemaschema { type: object, properties: { entities: { type: array, items: { type: object, properties: { name: {type: string}, type: {type: string} }, required: [name, type], additionalProperties: False } }, relations: { type: array, items: { type: object, properties: { source: {type: string}, target: {type: string}, relation: {type: string} }, required: [source, target, relation], additionalProperties: False } } }, required: [entities, relations], additionalProperties: False }定义Schema之后模型返回的内容会被强制约束在这个结构里乱加字段、漏字段的概率大幅下降。配合上面的extract_json降级提取法我最后实测的JSON解析成功率稳定在了98%以上。这个数字对工程落地是一个比较有参考价值的基准。5. 部署与实测API评测脚本告诉我的性能真相代码写完只代表“能用”离“好用”还差一次完整的性能测试。这轮我给自己做了一套轻量评测工具没有用复杂的压测框架就是用Python脚本手工模拟真实调用场景记录下来每个环节的实际耗时和成功率。5.1 评测脚本的核心指标我自己关注的指标有五项调用成功率成功率是生命线、单次调用延迟P50、P95、P99三个分位数、JSON解析失败率模型返回了一堆内容但抠不出合法JSON、上下文超限触发率输入被裁剪的比例、以及并发请求下的延迟分布看看服务扛不扛得住。我的脚本核心逻辑是这样的import asyncio import time import json import statistics async def run_evaluation(client, sample_inputs, concurrency5, iterations20): semaphore asyncio.Semaphore(concurrency) latencies [] errors [] parse_failures [] async def single_call(sample): async with semaphore: start time.perf_counter() try: result await client.complete(sample) latencies.append(time.perf_counter() - start) except Exception as e: errors.append(str(e)) latencies.append(time.perf_counter() - start) tasks [single_call(s) for s in sample_inputs * iterations] await asyncio.gather(*tasks) if latencies: latencies.sort() p50 latencies[len(latencies) // 2] p95 latencies[int(len(latencies) * 0.95)] p99 latencies[int(len(latencies) * 0.99)] success_rate (len(latencies) - len(errors)) / len(latencies) * 100 avg_latency statistics.mean(latencies) return { success_rate: success_rate, avg_latency_ms: avg_latency * 1000, p50_ms: p50 * 1000, p95_ms: p95 * 1000, p99_ms: p99 * 1000, error_count: len(errors), } return {success_rate: 0.0}用这个脚本跑出来的数据让我对API服务和本地模型的真实差距有了量化感知。5.2 真实数据里那些出乎意料的点单次调用的P50在1.2秒到2.8秒之间波动不同的模型差别很大。这个延迟范围对同步接口来说用户体验并不算好前端加载转圈太长时间用户大概率关页面。并发从1增加到5时P99延迟从2.1秒飙升到7.6秒。这个上升说明模型API服务端对并发是有限流的不是你请求并发越高吞吐就越高乱提高并发反而可能触发限流报错。输入文本清理程度对输出影响非常大。我试过把同样的文本带一堆HTML标签、特殊符号传给模型和清洗干净后再传输出质量差距肉眼可见。最后我加了一层文本预处理去掉乱码、压缩空白、统一全半角标点。这步在前端做要比在后端做省很多模型Token成本。性能测试的真实价值不是告诉我“服务有多快”而是告诉我“服务到底什么时候会挂”以及“挂了之后能不能快速定位”。带着这份测试报告去调优每一步都有数据支撑而不是凭感觉优化。6. 从跑通到稳定我踩过的坑和处理方案这一部分是我觉得含金量最高的内容每个坑都是真金白银买来的教训。我把它总结成了七个具体问题每个问题都附上了最终的处理办法。6.1 模型的“幻觉”问题明明文本里没有的信息它一本正经编出来了实体抽取这种任务模型很爱“发挥创造力”把输入文本里根本没有的人名、地名脑补出来塞进输出。原因在于大模型的本质是概率生成它会根据上下文推测最“合理”的内容而不是严格做信息检索。我最终的应对方案是双管齐下先在Prompt里显式声明“只抽取输入文本中明确出现的信息禁止推断和补充”把规则前置然后在后端加一个“二次核对”环节拿着模型输出的每个实体名去原文本里做字符串匹配匹配不上的直接丢弃。字符串匹配虽然土但在这种场景下的效果却非常可靠那才是不折不扣的工程兜底。6.2 大括号陷阱f-string格式化Prompt时的隐性灾难如果你用f-string拼JSON样例得格外小心JSON的键值对全是花括号而f-string把单个花括号当作文本、双花括号才是转义后的字面花括号。换句话说你原封不动写一个{name: 张三}到f-string里Python解释器会直接报错。就算你绕开了报错输出的样例JSON里花括号可能莫名其妙多了一倍。我强烈推荐用str.format()模板配合format_map来构建提示词或者直接用.replace(__INPUT__, text)这种方式代替f-string拼接这样JSON里的花括号不参与任何解释器转义踩坑的概率直接归零。6.3 长文本截断后语义断裂前文不搭后语把超长文本硬截断传给模型模型的输出质量会断崖式下跌。因为截断处往往在一个句子的中间模型看到的输入是残缺句子它只能去猜前后文猜的过程就是在给幻觉创造机会。我最后的策略是优先按段落切分而不是按字符硬截。先保留文档前几个段落的重要内容再补充文档末段的总结性内容中段内容能省则省。对实体抽取任务来说开头、结尾的信息密度通常高于中段这种“首尾保留中段压缩”的策略能最大化信息密度。6.4 定时任务API限流突然被限流之后是全链路失败我的服务里有一个定时清理任务每小时跑一次。跑起来的时候一次性发了几十个并发请求出去结果直接把API限流阈值冲爆了之后的正常用户请求也一起铺盖。AI工程的限流不光是用户侧要限服务对外部API的调用更要限。客户端限流虽然牺牲了定时任务的速度但换回来的是整个服务的稳定性。6.5 重试逻辑的副作用接口幂等性成了隐藏需求给模型调用加重试机制时我一开始忘了考虑一个问题如果第一次调用超时但模型服务端其实已经成功处理了重试会生成第二条重复记录。对于纯生成任务问题不大但对于有副作用的任务比如同时写入数据库就会产生脏数据。我最后用了一个简单粗暴的方案给每个请求生成一个request_id服务端通过它做幂等判断重试时携带同一个request_id服务端识别到相同ID就不再重复执行。这个方案让重试从“副作用风险源”变成了“可靠性的保障”很值得在AI服务里落地成标准配置。6.6 环境变量的坑开发环境好好的上线就报错代码在本地跑得正常一到服务器上就各种莫名其妙的问题最后定位到是环境变量缺失。.env文件没有同步到服务器配置在本地靠dotenv加载服务器上跑Docker容器时没有把对应的环境变量注入进去。这个坑是所有后端工程师都踩过几轮的经典坑应对方式很简单统一用secrets管理环境变量并且写一份部署前的环境检查脚本启动服务前自动检查必需的变量是否存在。6.7 模型输出中的Unicode问题转义字符引发的解析失败模型在返回JSON时偶尔会把特殊符号转义成Unicode形式比如把引号转成\u201c。这个情况在正常的json.loads里是可以正确解析的但如果中途经过了某种字符串处理函数转义就可能会破裂最终得到的是乱码而不是正确字符。最后的处理方式是统一用ensure_asciiFalse来保证存到数据库的是可读文本而不是一坨转义序列在前端展示时再统一做一次解码。7. 稳定之后的额外思考从单机脚本到可持续维护的小系统等核心链路稳定以后我开始想下一个问题这个项目还需要怎么演进才算对得起“from scratch”这几个字。一个从零开始的项目很容易止步于“能跑”但能不能扛得住真实流量才真正决定工程化做完没有。7.1 观测性日志、追踪和指标三件套我在最初阶段几乎没加日志出了错只能靠print输出来猜。后来被一个线上异常折腾了两小时才定位到问题忍无可忍才给服务加上了全套的观测体系。AI服务和普通后端服务最不一样的地方在于它的输入和输出都是非确定性的你没法拿固定参数去复现问题。所以日志里必须记录三个东西完整的请求体、模型返回的原始内容、最终的解析结果。有了这三层记录排查问题的时候才能准确知道到底是用户输入的问题、模型输出的问题还是解析逻辑的问题。7.2 降级策略当模型API不可用时服务不能跟着一起死商用API难免有故障窗口一旦上游挂了你唯一的选择就是优雅降级。我的方案是给服务设计了两级降级路径第一级检测到API连续失败超过阈值时自动切换到本地的小模型服务第二级连本地模型也异常时直接返回一个明确的错误码加提示信息而不是让请求一直挂起超时。用户体验上提示“暂时繁忙”和请求一直转圈是两回事前者可接受后者不可接受。7.3 数据反馈闭环每次调用都在为下一次优化蓄力做AI工程和做传统工程的另一个关键差异是性能优化的核心不在代码层面而在数据层面。我给自己做了一个自动收集“失败样本”的机制每次解析失败、校验失败、用户手动修改过的输出全部落库。每周去看一眼这些样本基本就能知道下一步该调Prompt、该加正则还是该换更合适的模型。模型的效果不是买的是慢慢喂出来的。这个反馈闭环从项目上线第一天就开始跑到现在已经攒了不少有用的调优素材。8. 写在最后AI工程的核心能力本质上是“选择和兜底”如果让我用一句话总结这次从零搭建AI工程项目的体会那就是AI工程的核心从来不是“让模型跑起来”而是“让模型稳定地跑下去”。模型的能力上限是厂商决定的但效果的下限是工程兜底决定的。从需求的三层拆解、模型选型的成本核算、服务骨架的模块划分、提示词集中管理、JSON降级提取、到评测体系、限流策略、降级方案和观测能力每一步单独拿出来看起来都不复杂但把它们串成一个完整闭环的时候你会感受到工程化的真正威力——它不是魔法它只是把不确定性一点点吸附到可控范围内。这一轮做下来我个人的一个明显变化是再去评估一个AI项目的时候我不太关心Demo跑得有多惊艳我更想看它的日志体系、重试策略、失败样本反馈这才是决定一个AI应用能不能真正活下去的东西。希望这篇项目记录能给你一些参考也欢迎你分享自己在AI工程化路上遇过的那些坑。