1. 这不是“搭个LLM API”——AI工程从零开始的真实含义
很多人看到“AI Engineering from Scratch”第一反应是:哦,写个Python脚本调用OpenAI接口,再套个Streamlit前端,发个GitHub仓库,标题就叫《从零构建AI应用》。我去年也这么干过——项目上线当天,用户并发刚到87,API响应延迟飙到12秒,错误日志里全是RateLimitError和ContextLengthExceeded,后台服务内存泄漏,三天后整个容器OOM崩溃。后来翻看监控曲线才发现,所谓“from scratch”,根本不是从调用API开始,而是从你亲手编译的第一个CUDA kernel、手动配置的第一块GPU显存分片、在裸金属上部署的第一个模型推理服务才算真正起步。
AI工程不是AI应用开发,它不关心你用了哪个大模型,而只问三个问题:你的模型推理链路里,哪一环的延迟不可控?哪一块显存分配策略导致了batch size卡死在32?当用户上传一张4K医学影像时,预处理pipeline里哪个算子把CPU打满却没触发GPU卸载?这些细节,没有现成SDK能帮你兜底。关键词“ai-engineering”和“from-scratch”背后,是一整套脱离黑盒API的底层掌控力:你要知道TensorRT如何把ONNX图拆解成kernel fusion序列,要理解vLLM的PagedAttention为什么比HuggingFace原生generate快3.7倍,更要清楚在Kubernetes里给一个7B模型分配多少GPU memory limit才既不浪费又不OOM——这些都不是文档里抄来的参数,而是你在37次OOM crash、19次CUDA out of memory报错、6次模型量化精度崩塌后亲手校准出来的数字。
这篇文章不教你怎么用LangChain搭RAG,也不讲LlamaIndex怎么自动切chunk。它记录的是我用4个月时间,从一台闲置的Dell R740服务器(双路Xeon + 4×A10)开始,零依赖、零云服务、零托管平台,纯手工构建端到端AI推理栈的全过程。所有代码跑在物理机上,所有模型权重自己下载校验,所有CUDA版本自己编译适配,所有服务发现靠etcd手写健康检查。如果你正打算跳过“调用API”阶段,真正踏入AI基础设施层,这篇就是为你写的实操手记——它不承诺速成,但保证每一步都踩在真实世界的坑里,每个参数都有血泪依据。
2. 物理机不是玩具——硬件选型与CUDA生态的硬约束
很多人以为AI工程从零开始,第一步是选框架。错。第一步是摸清你手边那台机器的物理边界。我用的Dell R740不是为AI设计的——它出厂配的是Tesla P4(8GB显存),但P4的FP16吞吐只有1.5 TFLOPS,连7B模型的单卡推理都卡顿。换卡不是插拔那么简单:R740的PCIe插槽是Gen3 x16,但A10需要PCIe Gen4带宽;主板BIOS默认关闭Above 4G Decoding,不打开的话系统根本识别不到第二块GPU;更致命的是电源——原装1100W白金电源带不动4块A10(单卡250W TDP),必须换成2000W钛金模组电源,且要重新走线避开机箱风道死角。
提示:别信“支持多卡”的宣传页。实测发现,R740在4卡A10配置下,第三、四卡的PCIe带宽会降级到x8模式,导致vLLM的tensor parallel通信延迟增加40%。最终方案是物理屏蔽第三、四卡插槽,只用前两卡,但通过NVLink桥接器(需额外购买)实现P2P direct access——这直接让跨卡KV cache同步速度提升2.3倍。
CUDA版本选择更是生死线。A10基于Ampere架构,官方支持CUDA 11.0+,但PyTorch 2.1要求CUDA 11.8,而TensorRT 8.6.1又只兼容CUDA 11.8的特定patch版本(11.8.0_520.61.05)。我试过直接装CUDA 12.1——结果nvcc编译成功,但torch.cuda.is_available()返回False,因为驱动版本(525.85.02)和CUDA runtime不匹配。最终锁定组合:NVIDIA Driver 525.85.02 + CUDA Toolkit 11.8.0_520.61.05 + cuDNN 8.6.0 + TensorRT 8.6.1.6。这个组合不是查文档得来的,是我在nvidia-container-toolkit的issue区翻了73页,逐条验证用户反馈后确认的唯一稳定栈。
显存管理是另一个隐形杀手。A10单卡24GB,但Linux内核默认的cgroup v1对GPU memory限制支持极差。用docker run --gpus all -m 16g启动容器,实际显存仍被其他进程抢占。解决方案是启用cgroup v2 + nvidia-container-runtime的device plugin模式,并在/etc/nvidia-container-runtime/config.toml中强制设置:
[nvidia-container-cli] no-cgroups = false同时在Kubernetes DaemonSet里注入initContainer,执行:
nvidia-smi -i 0 -r && sleep 2 && nvidia-smi -i 0 --gpu-reset——这是为了清除GPU BIOS残留状态,否则长时间运行后显存碎片率会超过65%,导致新模型加载失败。
注意:不要迷信“自动显存优化”。我曾用HuggingFace的accelerate库,在A10上加载Qwen-7B-Chat,设device_map="auto",结果它把embedding层分到CPU,decoder层塞进GPU,导致每次forward都要做12MB tensor搬运,延迟暴涨300ms。最后改用手动device_map:{"model.embed_tokens": 0, "model.layers.0": 0, ..., "lm_head": 0},强制全模型驻留GPU,显存占用从18.2GB升到22.1GB,但P99延迟从1.8s降到320ms。
3. 模型不是文件——权重校验、格式转换与量化实操
下载一个qwen2-7b-instruct.Q4_K_M.gguf文件,双击就能跑?在AI工程里,这等于把未消毒的手术刀直接递进无菌室。真正的from scratch,始于对每个字节的怀疑。我第一次加载Qwen2-7B时,用llama.cpp跑出的结果和HuggingFace demo页面完全不一致——token概率分布偏差超过0.3。排查三天后发现,原始GGUF文件里的rope.freq_base参数被错误地设为10000.0(应为1000000.0),导致位置编码偏移。这不是模型bug,而是GGUF打包工具的一个已知issue(llama.cpp#4211)。
所以我的标准流程是:
- SHA256校验:从HuggingFace Hub下载原始
.safetensors文件,用sha256sum比对官方发布的checksum; - 结构解析:用
safetensors-cli dump qwen2-7b.safetensors | head -20查看tensor命名规范,确认model.layers.0.self_attn.q_proj.weight等关键层存在; - dtype验证:用Python加载后检查
model.lm_head.weight.dtype == torch.float16,避免意外混入float32权重拖慢推理; - 量化重铸:绝不直接用第三方GGUF,而是用llama.cpp自带的
quantize工具,指定--allow-requantize和--quant_method q4_k,并强制--ctx_size 4096(防止context长度截断)。
量化不是“越小越好”。Q4_K_M在A10上实测:
- Q4_K_S(3.5GB):P99延迟210ms,但数学题准确率下降12%(因weight quantization误差放大);
- Q5_K_M(4.2GB):延迟245ms,准确率恢复至原始模型98.7%;
- Q6_K(5.1GB):延迟280ms,准确率持平,但显存余量仅剩1.2GB,无法加载额外LoRA adapter。
最终选择Q5_K_M——它用2.3%的延迟代价,换回了业务可接受的精度底线。这个决策不是查表格得来的,而是我写了自动化测试脚本,用MMLU子集(57个STEM题目)跑100轮,统计accuracy std dev < 0.5%才敢上线。
模型格式转换更是暗坑密布。HuggingFace的transformers库导出ONNX时,默认用torch.onnx.export(..., opset_version=14),但TensorRT 8.6只支持opset 17。强行加载会报错Unsupported ONNX data type: INT64。解决方案是:
- 先用
torch.onnx.export(..., opset_version=17, do_constant_folding=True); - 再用
onnxsim简化图结构(python -m onnxsim input.onnx output_sim.onnx); - 最后用
trtexec --onnx=output_sim.onnx --saveEngine=qwen2-7b.trt --fp16 --workspace=4096生成引擎。
提示:
trtexec的--workspace参数不是显存预留量,而是CUDA stream的临时buffer大小。设太小(如1024MB)会导致large context推理失败;设太大(如8192MB)则挤占模型显存。实测A10最佳值是4096MB——它刚好等于单卡显存的1/6,既满足KV cache expansion需求,又留出16GB给模型权重。
4. 推理服务不是Flask——低延迟Pipeline的七层拆解
用Flask写个/v1/chat/completions接口,return jsonify({"choices": [...]}),这叫API服务,不叫AI工程服务。真正的推理服务,是把一次请求拆解成7个原子阶段,每个阶段都可独立压测、限流、熔断:
| 阶段 | 耗时占比(A10实测) | 关键瓶颈 | 我的解决方案 |
|---|---|---|---|
| 1. HTTP解析 & JWT鉴权 | 8% | OpenSSL RSA验签 | 改用Ed25519公钥算法,耗时降至1.2ms |
| 2. 请求反序列化 | 5% | JSON decode深度嵌套 | 预编译jsonschema,用pydantic-core替代pydantic |
| 3. Prompt组装 | 12% | Python字符串拼接 | 提前编译Jinja2模板,缓存render函数指针 |
| 4. Tokenizer | 18% | 字节级BPE查表 | 用tokenizers库的TokenizerFast,禁用正则回溯 |
| 5. Model Forward | 41% | GPU kernel launch overhead | 启用CUDA graph,batch内复用graph instance |
| 6. Sampling & Decode | 10% | Python random采样 | 用torch.multinomial替代numpy.random.choice |
| 7. 响应序列化 | 6% | JSON encode浮点数 | 用ujson替代json,精度截断到6位 |
其中第5阶段的CUDA graph优化最值得细说。默认情况下,每次model.generate()都会触发全新kernel launch,包括memory allocation、stream sync、grid config计算。启用graph后,我做了三件事:
- 预热:用
torch.cuda.graph(model.forward)捕获一次完整forward的graph; - 复用:对相同input_length的请求,直接
graph.replay(),省去90% kernel launch开销; - 动态fallback:当input_length变化时,自动切换到新graph,旧graph缓存不释放(显存占用+1.2GB,但P99延迟降低37%)。
代码层面,不是简单加个装饰器。必须手动管理graph生命周期:
# 初始化graph pool self.graph_pool = {} for seqlen in [128, 256, 512, 1024]: g = torch.cuda.CUDAGraph() with torch.cuda.graph(g): self.model(input_ids=torch.ones(1, seqlen).long().cuda()) self.graph_pool[seqlen] = g # 推理时 if input_len in self.graph_pool: self.graph_pool[input_len].replay() else: self.model.generate(...) # fallback to eager mode第4阶段的tokenizer优化常被忽视。HuggingFace默认tokenizer用Python实现BPE,对长文本(>2048 tokens)做subword切分时,会触发大量Python对象创建。换成tokenizers的Rust backend后,1024-token文本的encode耗时从83ms降到12ms。但要注意:Rust tokenizer不支持add_special_tokens=False的动态控制,必须在preprocessing阶段硬编码<|startoftext|>等特殊token。
注意:不要在服务里做“智能batching”。vLLM的continuous batching确实优秀,但它假设所有请求的max_new_tokens相同。而真实业务中,用户提问可能是“你好”(需5 token)或“请用Latex推导麦克斯韦方程组”(需256 token)。我的方案是:用Redis Sorted Set按request_time排序,取top-K请求中min(max_new_tokens)作为batch size上限,剩余请求放入delay queue——实测P95延迟波动从±120ms降到±18ms。
5. 监控不是看Dashboard——AI服务的五维可观测性
Prometheus + Grafana看个gpu_utilization{job="vllm"}就叫可观测?那是运维视角,不是AI工程视角。真正的AI服务监控必须覆盖五个维度,缺一不可:
1. 硬件层:不只是GPU利用率,要看nvmlDeviceGetMemoryInfo().usedvsnvmlDeviceGetMemoryInfo().total的绝对值(不是百分比)。A10在显存占用>21.5GB时,ECC error rate会指数上升,必须在20.8GB触发告警。
2. 框架层:vLLM的vllm:gpu_cache_usage_ratio指标只能告诉你cache用了多少,但不知道为什么用得多——是prefill阶段KV cache未及时释放?还是decode阶段block数量膨胀?我打了patch,在core/llm_engine.py里加了self.kv_cache_blocks_used计数器,暴露为vllm:kv_cache_blocks_used{model="qwen2-7b"}。
3. 模型层:每个token的logprobs标准差。正常推理时,top-k logprobs std应<0.8;若某次请求std>2.5,大概率是prompt injection或模型崩溃。我在response middleware里实时计算:
import numpy as np logprobs = np.array([p.logprob for p in response.choices[0].logprobs]) if np.std(logprobs) > 2.5: logger.warning(f"High logprobs variance: {np.std(logprobs):.3f}")4. 业务层:不是“请求成功率”,而是“语义成功率”。用Sentence-BERT计算用户query和模型response的cosine similarity,阈值设0.65。低于此值视为无效响应(如答非所问、胡言乱语),即使HTTP status=200也要告警。
5. 成本层:单token推理成本。公式:(gpu_hours * $0.85/hr) / total_tokens_served。A10实测Q5_K_M模型为$0.00012/token,但当batch_size从8降到4时,成本飙升至$0.00021——因为GPU空闲周期增加。这个数据直接决定是否该扩容。
所有监控指标必须落地为SLO。我定义的核心SLO:
- P95延迟 ≤ 400ms(含网络传输)
- 语义成功率 ≥ 92.5%
- 单token成本 ≤ $0.00015
- 显存泄漏率 ≤ 0.3MB/hour
达成SLO的关键不是堆监控工具,而是把SLO编译进服务代码。例如,当vllm:kv_cache_blocks_used连续3分钟>95%,自动触发vLLMEngine.clear_cache();当semantic_success_rate<90%,立即降级到Q4_K_S模型并发送PagerDuty告警。
提示:不要用“平均延迟”做告警。我吃过亏——某次GPU风扇故障,前90%请求延迟正常(200ms),后10%因thermal throttling飙到3.2s,但avg latency仍显示310ms,SLO看似达标。现在所有告警都基于P95/P99分位数,且用Prometheus的
histogram_quantile(0.95, rate(...))实时计算,延迟毛刺1秒内即可捕获。
6. 部署不是kubectl apply——裸金属上的Kubernetes最小可行栈
用k3s在单机跑K8s?那是玩具。真正的AI工程部署,必须直面裸金属的残酷现实:没有云厂商的弹性IP,没有托管LB,没有自动证书轮换。我的方案是——只用4个组件构建最小可行栈:
- CRI:
containerd而非Docker。Docker的overlayfs在高IO场景下有inode泄漏风险,containerd的io.containerd.snapshotter.v1.devmapper更稳定; - CNI:
cilium而非flannel。cilium的eBPF dataplane能直接hook到socket level,把HTTP/2 header parsing延迟从18ms降到3ms; - Ingress:
envoy而非nginx。envoy的HTTP/2 stream multiplexing对LLM streaming响应天然友好,且支持gRPC-Web转换; - Service Mesh:不用istio。istio的sidecar注入会让pod启动延迟增加1.2s,改用
linkerd的轻量proxy,只注入linkerd-proxy容器,启动延迟<200ms。
关键配置细节:
- containerd的
/etc/containerd/config.toml必须设置:[plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runc.options] SystemdCgroup = true # 否则GPU memory cgroup失效 - cilium的
helm install cilium cilium/cilium --set ipam.mode=cluster-pool,禁用--set tunnel=disabled,否则跨节点通信走iptables,延迟增加40ms; - envoy的
envoy.yaml里,必须开启http2_protocol_options和stream_idle_timeout:http_filters: - name: envoy.filters.http.router typed_config: "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router stream_idle_timeout: 300s # 防止LLM长响应被断连
最棘手的是证书管理。Let's Encrypt的ACME协议要求80/443端口可访问,但裸金属没有公网IP。我的解法是:
- 用
cert-manager的DNS01挑战,对接Cloudflare API; - 在
ClusterIssuer里配置solvers.dns01.cloudflare.apiTokenSecretRef.name: cloudflare-api-token; - 所有Ingress的
tls.secretName指向tls-secret,由cert-manager自动续期。
注意:不要用
self-signed证书。浏览器会拦截LLM streaming响应(因mixed-content policy),导致前端JS的EventSource连接失败。必须用可信CA签发的证书,哪怕只是Cloudflare的免费证书。
服务发现也得重写。K8s默认的kube-dns在高并发下解析延迟>150ms。我用coredns替换,并在Corefile里加:
.:53 { forward . 1.1.1.1 8.8.8.8 cache 30 reload }同时在deployment里设置dnsPolicy: ClusterFirstWithHostNet,绕过iptables DNAT,解析延迟压到8ms以内。
7. 迭代不是git push——模型热更新的原子性保障
“更新模型只需改一行config,kubectl rollout restart”?那是理想国。真实世界里,模型热更新必须满足三个原子性:
- 加载原子性:新模型权重加载完成前,旧模型服务不能中断;
- 路由原子性:流量切换瞬间,不能有请求被路由到半加载状态的模型;
- 清理原子性:旧模型显存释放必须在新模型ready后触发,否则OOM。
我的方案是双模型实例+etcd协调:
- 启动两个vLLM实例:
qwen2-7b-v1(旧)和qwen2-7b-v2(新),监听不同port; - 在etcd里创建key
/models/qwen2-7b/active,value为v1; - Envoy的route config监听etcd key变更,当value从
v1→v2时,先health check:8001/health(新实例),成功后再原子更新路由表; - 旧实例收到SIGUSR2信号后,执行
vLLMEngine.unload_model(),但只释放显存,不退出进程——等待10秒确认无请求后,再os._exit(0)。
关键代码在Envoy的Lua filter里:
function envoy_on_request(request_handle) local active = etcd:get("/models/qwen2-7b/active") if active == "v1" then request_handle:headers():replace("x-backend", "qwen2-7b-v1:8000") else request_handle:headers():replace("x-backend", "qwen2-7b-v2:8001") end end这个方案解决了三个痛点:
- 零停机:etcd watch事件延迟<50ms,路由切换在100ms内完成;
- 无脏读:新模型health check失败时,etcd key不更新,流量永不切换;
- 显存安全:旧实例的
unload_model()在新实例ready后才触发,避免显存竞争。
提示:不要用K8s readiness probe做健康检查。probe默认超时1秒,而vLLM加载7B模型需2.3秒。我的方案是:在vLLM启动脚本里,先
touch /tmp/model_loading,加载完成再touch /tmp/model_ready,probe脚本只检查/tmp/model_ready是否存在——把健康检查从“模型是否启动”细化为“模型是否就绪”。
最后说个血泪教训:模型版本号不能用git commit hash。某次我用qwen2-7b-abc123作tag,结果CI pipeline里git rev-parse HEAD在不同节点返回不同hash(因submodule更新顺序差异),导致生产环境一半节点跑v1,一半跑v2。现在所有模型版本号强制用YYYYMMDD-HHMMSS时间戳,配合SHA256校验,确保bit-for-bit一致性。
我在R740上跑满4个月,累计处理237万次推理请求,P95延迟标准差<15ms,单token成本稳定在$0.000132。这数字背后,是37次CUDA驱动重装、19次模型量化重试、6次etcd集群脑裂修复。AI工程从零开始,从来不是浪漫的技术冒险,而是用物理世界的铜臭味、硅晶体的发热声、和无数个凌晨的error log,一寸寸凿出来的确定性。当你终于能在裸金属上,让7B模型以320ms P99延迟稳定输出时,那种掌控感,远胜于调用任何云API——因为你知道,每一毫秒延迟,都源于你亲手写的那一行CUDA kernel。