1. 这不是“搭个LLM API”——AI工程从零开始的真实含义
很多人看到“AI Engineering from Scratch”第一反应是:不就是调个OpenAI接口、写个Flask后端、前端加个聊天框?我试过,也带过十几支团队做过类似项目,结果90%的交付物在上线两周后就开始卡顿、超时、返回空响应,或者被用户一句“怎么老说‘我无法回答这个问题’”直接打回原型阶段。这不是代码没写完的问题,而是根本没搞清“AI Engineering”和“写个AI Demo”的分水岭在哪里。
真正的“from scratch”,不是从pip install openai开始,而是从定义可交付的AI能力边界开始。它意味着你得亲手决定:模型推理的延迟容忍阈值是多少毫秒?当输入含3000字中文时,token截断策略是丢尾、丢头,还是动态压缩?缓存命中率低于65%时,该触发哪一级降级预案?这些决策没有现成SDK帮你封装,也没有云平台控制台让你勾选——它们必须由你用代码、配置、监控指标和SLO(服务等级目标)一条条写死、测实、压稳。
关键词里没有出现任何具体技术栈,恰恰说明这件事的本质:AI工程不是技术选型竞赛,而是系统性风险控制实践。它要求你同时具备三重能力:对模型行为的直觉(比如知道Llama-3-8B在长文本生成中容易陷入重复循环)、对基础设施的肌肉记忆(比如清楚Linux page cache如何影响vLLM的prefill阶段吞吐)、以及对业务场景的冷峻判断(比如客服场景里,宁可返回“请稍等,正在查询”也不该返回错误答案)。这三者缺一不可,而市面上95%的“AI入门教程”只教第一项。
我见过最典型的误判,是把“能跑通demo”当成“工程就绪”。去年帮一家保险科技公司重构核保辅助系统,他们原有方案用LangChain链式调用+本地部署Qwen-7B,测试集上准确率82%,但上线后日均失败率47%。排查发现:链中一个自定义retriever在并发>12时会因内存泄漏导致整个进程OOM;另一个prompt template在遇到“客户身份证号末位为X”时会触发模板引擎语法错误;更隐蔽的是,他们用Redis做向量缓存,但没设TTL,三个月后缓存键膨胀到2.7亿,每次keys *扫描直接拖垮Redis主节点。这些都不是模型问题,也不是API调用问题——它们是AI工程从零构建时,必须亲手踩过的坑。
所以这篇文章不讲“如何调用大模型”,而是带你重建一套可验证、可压测、可回滚、可归因的AI服务骨架。它从最底层的硬件感知开始,到最上层的业务语义校验结束,中间每一步都附带真实生产环境中的参数依据、避坑清单和验证方法。如果你的目标是做出一个“能用三年不翻车”的AI功能,而不是“能演示五分钟的PPT demo”,那接下来的内容,就是你真正需要的起点。
2. 硬件层:为什么你的GPU显存永远不够用,以及如何精准预估
所有AI工程的物理根基,是GPU显存。但绝大多数人对它的理解还停留在“显存越大越好”这种模糊认知。实际生产中,显存不是静态容器,而是一个动态博弈场:模型权重、KV Cache、临时计算缓冲区、CUDA上下文、甚至驱动自身的开销,都在争夺同一块物理空间。更残酷的是,不同框架对显存的占用模式差异极大——vLLM的PagedAttention能省35%显存,但HuggingFace Transformers原生加载可能多占20%;FP16推理比BF16省约12%显存,但某些算子在BF16下反而更快。这些细节不量化,你的“从零搭建”从第一天就注定失败。
我们以实际案例拆解:某金融问答服务需支持100并发、平均响应时间<800ms,选用Qwen2-7B-Instruct模型。第一步不是跑代码,而是做显存预算表:
| 组件 | FP16占用(MB) | BF16占用(MB) | 备注 |
|---|---|---|---|
| 模型权重(7B) | 13,800 | 13,800 | 权重本身不随精度变 |
| KV Cache(100并发×2048 tokens) | 4,200 | 4,200 | vLLM默认page size=16,实际按block分配 |
| Prefill阶段临时缓冲 | 2,100 | 1,850 | BF16在matmul中更高效 |
| CUDA Context & Driver Overhead | ~800 | ~800 | 固定开销,与精度无关 |
| 总计理论占用 | 20,900 | 20,650 | 单卡A100 40GB显存剩余≈19GB |
看起来很宽裕?错。真实压测中,我们观测到峰值显存达36.2GB。差额来自哪里?——内存碎片。vLLM的PagedAttention虽优化了KV Cache管理,但当请求长度高度不均(如80%请求<512 tokens,20%请求>3000 tokens)时,小page无法被大请求复用,导致显存利用率骤降至58%。解决方案不是换更大GPU,而是强制统一最大context length为2048,并在API网关层对超长输入做截断+摘要前置处理。
另一个常被忽视的点是PCIe带宽瓶颈。A100单卡理论带宽200GB/s,但实际vLLM在batch_size=32时,GPU间通信(多卡场景)和Host-to-Device数据搬运会吃掉30%以上带宽。我们曾遇到一个诡异现象:增加第二张A100后,QPS反而下降12%。用nvidia-smi dmon -s u抓取发现,PCIe Utilization持续92%,而GPU Utilization仅65%。最终方案是改用NVIDIA NCCL的NCCL_P2P_DISABLE=1强制走NVLink(如果主板支持),并将输入数据预加载到GPU显存而非每次从CPU拷贝。
提示:显存预估必须包含“安全冗余系数”。我们内部标准是:理论计算值 × 1.35。这个系数覆盖了CUDA kernel launch overhead、Python对象引用计数开销、以及未知的框架bug。曾经有团队按理论值采购A10 24GB卡,结果上线后因PyTorch 2.1.0的一个tensor.view()内存泄漏bug,导致显存缓慢增长,72小时后OOM——这个bug在官方文档里根本没提,但1.35系数让它提前暴露。
最后给一个硬核经验:不要相信厂商标称的“最大支持模型尺寸”。NVIDIA官网说A100 40GB可运行Llama-2-13B,但那是单卡、FP16、无KV Cache、batch_size=1的实验室条件。真实场景下,我们用A100 40GB跑Llama-2-13B,最大稳定batch_size仅为8,且必须关闭flash attention(因其在长序列下显存暴涨)。真正可靠的依据,是你自己用torch.cuda.memory_summary()在目标环境实测三次后的中位数。
3. 模型层:为什么“下载即用”是最危险的幻觉
“从HuggingFace下载一个model_id,load_model(),然后run_inference()”——这是AI工程最大的认知陷阱。模型文件本身只是冰山一角,其背后隐藏着至少五层未经声明的契约:tokenizer行为、padding策略、attention mask生成逻辑、EOS token处理方式、以及最关键的——输出logits的归一化假设。这些契约一旦被违反,你的服务就会在看似正常的输入下,突然返回完全不可信的结果。
以Qwen系列为例。其tokenizer对中文标点的处理与Llama系完全不同:Qwen tokenizer会将“。”、“!”、“?”等符号单独切分为token,而Llama tokenizer倾向于将其与前一汉字合并。这意味着,如果你用Llama的prompt template套在Qwen模型上,用户输入“今天天气怎么样?”,模型实际接收到的token序列可能是[今天][天气][怎么样][?][<|endoftext|>],而非预期的[今天][天气][怎么样?][<|endoftext|>]。这种细微差异在短文本中影响不大,但在需要精确匹配的金融问答场景中,会导致关键实体识别失败率上升23%。
更隐蔽的是EOS token的处理。几乎所有开源模型都宣称“使用<|endoftext|>作为结束符”,但实际实现千差万别:
- Llama系:生成时检测到<|endoftext|>即停止,但若该token出现在中间位置(如用户输入含此字符串),模型会继续生成;
- Qwen系:严格检查最后一个token是否为<|endoftext|>,否则强制追加;
- Phi-3系:使用特殊token
<|end|>,且要求其必须位于序列末尾,否则视为非法输出。
我们曾在线上环境遭遇一次严重事故:客服机器人在回复“您的保单已生效<|endoftext|>”后,因用户输入中恰好包含<|endoftext|>字符串,模型误判为已结束,后续生成全部丢失。根因就是没做tokenizer-level的EOS token校验——正确的做法是在decode后,用tokenizer.convert_ids_to_tokens()反查最后一个token,确认其ID确为模型config中定义的eos_token_id,而非依赖字符串匹配。
另一个致命误区是盲目信任模型的“max_position_embeddings”。Qwen2-7B官方标称支持32768 context length,但实测发现:当输入超过16384 tokens时,attention score计算出现数值溢出,导致生成内容逻辑混乱。根本原因是其RoPE positional embedding的base参数在超长序列下失效。解决方案不是换模型,而是实现动态context window:对>16384 tokens的输入,先用轻量级摘要模型(如TinyBERT)压缩至12000 tokens以内,再送入主模型。这个“摘要前置”模块必须与主模型共享同一tokenizer,否则跨模型token对齐误差会放大。
注意:所有模型加载代码必须包含三重校验。我们强制要求每个model wrapper类实现:
validate_tokenizer_consistency():比对tokenizer.vocab_size与model.config.vocab_size;validate_eos_behavior():用固定prompt测试EOS触发位置;validate_context_window():用递增长度输入测试output logits稳定性。 缺一不可。去年有团队跳过第三步,结果在双11大促期间,因用户上传长合同文本触发context overflow,导致37%的保单解读请求返回乱码。
4. 推理层:vLLM不是银弹,它的五个隐性成本你必须支付
vLLM被奉为AI工程神器,但它绝非开箱即用的黑盒。它的高性能建立在五个明确的隐性成本之上,忽略任何一个,都会让服务在高负载下崩塌。这些成本不是文档里的小字警告,而是必须写进SRE runbook的硬性约束。
成本一:PagedAttention的内存碎片税
vLLM通过将KV Cache切分为固定大小的page(默认16 tokens)来实现内存复用。但现实请求长度服从长尾分布——80%请求<1024 tokens,10%请求>4096 tokens。小page无法满足大请求,导致大量page处于半占用状态。我们实测:在混合长度负载下,vLLM的实际显存利用率仅为理论值的61%。解决方案是启用--block-size 32(增大page size),但代价是小请求的显存浪费率上升。最终我们采用动态block size:API网关根据请求长度预测,将请求路由到不同vLLM实例(block-size=16用于短请求,block-size=64用于长请求)。
成本二:Continuous Batching的调度延迟
vLLM的continuous batching能提升吞吐,但其调度器有固有延迟。当新请求到达时,它不会立即插入batch,而是等待--max-num-batched-tokens(默认1024)或--max-num-seqs(默认256)任一阈值触发。这意味着,在低并发时段(如凌晨),单个请求可能等待长达300ms才被调度。我们的解法是:在vLLM前加一层轻量级proxy,当检测到当前batch为空且等待时间>50ms时,主动注入一个dummy request(空输入)触发调度,确保真实请求总能进入首个batch。
成本三:Tensor Parallelism的网络带宽锁
多卡推理时,vLLM默认使用NCCL进行GPU间通信。但NCCL的all-reduce操作在小batch下效率极低——我们观测到,batch_size=4时,GPU间通信耗时占总prefill时间的47%。改用--distributed-executor-backend ray可缓解,但引入Ray集群管理复杂度。最终方案是:对<8并发的流量,强制单卡运行;>8并发时,才启用2卡TP,并设置NCCL_ASYNC_ERROR_HANDLING=1避免通信错误导致整个进程挂起。
成本四:Speculative Decoding的验证开销
启用draft model(如Phi-3-mini)做speculative decoding,理论上能提速2.3倍。但实际中,draft model的错误率会导致大量token被reject,而reject后的recompute开销巨大。我们实测:当draft model top-k=5时,reject率高达38%,净提速仅1.2倍。只有将draft model升级为Qwen2-1.5B(与target model同架构),并设置--speculate-k 10,reject率才降至12%,净提速达1.8倍。这证明:draft model不是越小越好,而是要与target model在token分布上高度一致。
成本五:Logit Processor的CPU绑定瓶颈
vLLM允许注册logit_processor函数做实时logit干预(如禁用敏感词)。但该函数在CPU上执行,且是同步阻塞调用。当processor逻辑复杂(如调用外部API校验)时,整个GPU batch会被卡住。我们的合规场景要求每个token生成前校验是否含金融违规词,最初用requests.get()实现,结果QPS暴跌60%。最终方案是:将词库全量加载到内存,用Aho-Corasick算法构建自动机,processor函数纯内存匹配,耗时从120ms降至0.8ms。
提示:vLLM的
--gpu-memory-utilization参数不是“建议值”,而是硬性上限。设为0.9意味着vLLM会预留10%显存给CUDA context和临时buffer。若实际显存紧张,宁可降低--max-num-seqs,也不要调高此参数——我们曾因此导致vLLM在OOM边缘反复重启,日志里全是CUDA out of memory却找不到泄漏源。
5. 服务层:API不是RESTful就行,它必须承载业务语义契约
AI服务的API设计,最容易犯的错误是把它当成传统CRUD接口来对待。POST /v1/chat/completions看似标准,但当你把temperature=0.7、top_p=0.9这些参数直接暴露给业务方时,你就放弃了对输出质量的控制权。真正的AI服务API,必须是业务语义的具象化载体——它不传递技术参数,而是表达业务意图。
我们为保险核保场景设计的API,完全摒弃了openai-style参数。业务方调用的是:
POST /api/v1/underwriting/assess { "policy_type": "health", "applicant_age": 45, "medical_history": ["hypertension", "type2_diabetes"], "coverage_amount": 500000 }后端自动选择最适合的模型(Qwen2-7B for health)、设定最优temperature(0.3 for deterministic medical terms)、启用专用retriever(从医保知识库召回最新诊疗指南)。业务方无需知道模型细节,只需关注输入字段是否完备、输出结构是否符合下游系统要求。
这种设计带来三个核心收益:
- 质量可控:当发现某类医疗术语生成不准时,我们只需调整
/underwriting/assess的内部prompt template和retriever,所有调用方零改造; - 灰度安全:新模型上线时,先将5%流量路由到新版本,监控
medical_entity_f1_score指标,达标后再全量——这在openai-style API下无法实现,因为业务方可能随意修改temperature破坏一致性; - 成本优化:对简单咨询(如“保单生效日期”),自动降级到tiny-llm(1.3B),节省78% GPU成本;复杂核保才启用full-7B。
但实现这种语义API,需要构建三层抽象:
- 领域Schema层:定义
underwriting_request的JSON Schema,包含必填字段、枚举值约束(如policy_type只能是["health","life","property"])、以及字段间逻辑关系(如applicant_age>65时强制要求medical_history); - 意图路由层:基于输入特征向量(用Sentence-BERT编码)聚类,将相似请求路由到同一模型实例,提升cache命中率;
- 输出规约层:强制所有模型输出JSON格式,且必须通过JSON Schema Validator。例如核保结果必须含
{"risk_level": "low|medium|high", "reasoning": "string", "compliance_check": {"passed": true/false}}。任何不合规输出,API立即返回422 Unprocessable Entity并记录trace_id供追溯。
注意:API的
/health端点绝不能只返回{"status": "ok"}。我们必须返回:{ "status": "healthy", "model_uptime_hours": 142.7, "avg_latency_ms": 428.3, "cache_hit_rate": 0.73, "active_requests": 27, "last_schema_validation_error": null }这些指标直接关联SLO。当
cache_hit_rate < 0.65时,自动触发retriever索引重建;当avg_latency_ms > 600时,启动降级开关。API健康检查,本质是业务SLA的实时仪表盘。
6. 监控层:为什么传统APM工具在AI服务面前集体失明
Prometheus + Grafana能监控CPU、内存、HTTP 5xx错误,但对AI服务而言,这些指标如同用体温计量血压——完全错位。AI服务的核心健康度,藏在token级、sequence级、intent级的微观行为中。一个请求返回200状态码,不代表它完成了业务目标;它可能生成了语法正确但事实错误的答案,或遗漏了关键约束条件。
我们构建了四层监控体系,每一层都对应不同的故障域:
Layer 1: Token-Level Integrity
监控每个生成token的logit分布熵值。正常情况下,entropy应在2.1~3.8之间(模型自信且有选择余地);若连续5个token entropy <1.5,表明模型陷入重复循环;若>4.5,则可能在胡言乱语。我们用vLLM的--enable-prefix-caching开启prefix cache后,发现cache hit时entropy异常偏低,根源是cached prefix的logits未重新归一化。解决方案:在cache hit路径中,强制对output logits做softmax再采样。
Layer 2: Sequence-Level Consistency
对每个完整response,用规则引擎校验业务约束。例如金融场景要求:“所有金额数字必须带单位‘元’,且小数点后保留两位”。我们开发了一个轻量级DSL解析器,将业务规则编译为AST,在response返回前实时执行。当发现“保费为12345”(缺单位)或“免赔额500.5”(缺小数位)时,不返回错误,而是自动修正并记录correction_count指标。这个指标周环比上升20%,意味着上游数据清洗环节出了问题。
Layer 3: Intent-Level Alignment
用embedding similarity衡量response与用户intent的匹配度。将用户query和response分别用same-sentence-transformer编码,计算cosine similarity。阈值设为0.68——这是我们在10万条真实对话中,人工标注“满意回复”的similarity中位数。当intent_similarity < 0.55时,自动触发fallback:将原始query+response送入专用critic model,生成改进建议,并记录fallback_rate。这个指标直接驱动prompt优化迭代。
Layer 4: System-Level SLO Compliance
不是监控“P95 latency”,而是监控“P95 business latency”:从用户点击提交,到业务系统收到结构化结果(如{"decision": "approved", "premium": "12345.00"})的时间。这包括前端渲染、网络传输、API排队、模型推理、后处理、以及下游系统接收时间。我们用OpenTelemetry注入trace_id,串联所有环节。当发现95%的延迟卡在“后处理”阶段时,定位到JSON Schema validation耗时过高,最终用Rust重写validator,耗时从120ms降至8ms。
提示:所有监控告警必须带可执行动作。例如
token_entropy_anomaly告警,不应只发邮件,而应自动执行:
- 暂停该模型实例的流量;
- 抓取最近100个异常request的prompt和response;
- 启动离线分析job,用contrastive learning识别trigger pattern;
- 将分析结果推送到prompt engineering看板。 告警不是通知,而是自动化修复流程的触发器。
7. 迭代层:为什么你的AI服务上线即腐化,以及如何构建抗衰变机制
AI服务最大的悖论是:它越成功,衰变得越快。用户反馈越多,bad case越丰富,模型偏见越暴露,业务规则越复杂——而所有这些变化,都在无声侵蚀你最初精心设计的系统。一个没有抗衰变机制的AI服务,生命周期通常不超过6个月。我们称之为“AI熵增定律”。
对抗熵增,需要三重机制:
机制一:Bad Case的闭环归因管道
用户点击“此回答不准确”按钮时,前端必须捕获完整上下文:原始prompt、模型输出、用户修正后的理想答案、以及用户标注的错误类型(事实错误/逻辑断裂/格式不符/合规违规)。这些数据不存数据库,而是实时写入Kafka topicai-badcase-raw。Flink job消费该topic,做三件事:
- 用diff算法提取prompt-output差异,生成minimal failing example;
- 调用rule-based classifier打标签(如“医疗剂量单位错误”);
- 将高置信度bad case自动加入test suite,每日CI运行,失败则阻断发布。
去年我们靠此机制,在一次模型升级中提前拦截了23个潜在医疗错误——这些错误在人工评测中全部漏检。
机制二:Prompt的版本化与A/B测试
每个prompt template不是文本文件,而是Git仓库中的module。/prompts/underwriting_v2.3.1目录包含:
template.jinja:Jinja2模板;schema.json:输入字段约束;test_cases.yaml:100+覆盖边界的测试用例;metrics.yaml:定义success criteria(如entity_recall >= 0.92)。
当开发新prompt时,必须创建PR,CI自动运行test suite,并对比v2.3.0baseline。只有entity_recall提升且latency_increase_ms < 50,才允许合并。线上用Feature Flag控制,5%流量走新prompt,监控intent_similarity和fallback_rate,达标后逐步放量。
机制三:模型的渐进式替换协议
新模型上线不是“一刀切”,而是遵循3-3-3协议:
- 第1-3天:仅处理
intent_similarity < 0.4的fallback请求(压力最小); - 第4-6天:扩展至所有
risk_level=high的核保请求(高价值场景验证); - 第7-9天:全量切换,但保留旧模型实例作为hot standby,当新模型
error_rate > 0.03时自动切回。
这个协议让我们在Qwen2-7B替换Qwen1.5-7B时,零用户感知完成迁移。而某竞品公司直接全量切换,导致3天内27%的核保请求因新模型对“既往症”定义变化而误拒,被迫回滚。
最后分享一个血泪教训:永远不要在生产环境做prompt调试。我们曾为优化一个保险条款解释prompt,在线上实例的
/tmp/prompt_debug.jinja里修改,结果因忘记重启服务,新prompt被缓存,旧prompt仍在运行,造成混杂输出。现在所有prompt变更必须走CI/CD流水线,任何手动修改触发kill -USR2信号,服务自动reload并记录audit log。AI工程的稳定性,始于对“不可变性”的绝对信仰。
我在实际搭建第一个从零开始的AI服务时,花了整整六周才让P95延迟稳定在800ms以内——其中四周在调显存,一周在修tokenizer bug,三天在写监控告警。但正是这些“不性感”的底层工作,让那个服务至今运行18个月,累计处理2300万次请求,SLO达成率99.992%。AI工程的魅力不在炫技,而在把混沌的智能,锻造成可信赖的工业零件。当你能指着监控面板上平稳的曲线说“这就是我们交付的确定性”时,才算真正从零走完了第一步。