1. 项目概述这不是“从零开始学AI”而是亲手锻造AI工程的锤子与砧板“AI Engineering from Scratch”——这个标题里藏着一个被严重误读的真相。它不是教你怎么用PyTorch搭个MNIST分类器也不是带你抄几行Hugging Face代码跑通一个LLM demo。它指的是亲手构建一套可生产、可维护、可演进的AI系统基础设施其核心组件不依赖现成黑盒框架而是由你逐层定义接口、实现逻辑、验证边界、压测性能。我带过三届AI工程团队每年都会让新人做一次“Scratch Week”关掉pip install删掉conda env只留一个干净的VS Code和系统自带的Python解释器从零写出能完成tokenization、autoregressive decoding、gradient accumulation、distributed checkpointing这四件事的最小可行模块。结果90%的人卡在第三天——不是因为不会写softmax而是根本没想清楚“batch size8时GPU显存里到底该存几个tensor每个tensor的shape和dtype怎么推导梯度反传路径上哪些tensor要retain_grad哪些必须detach”这些事TensorFlow和PyTorch替你扛了十年但一旦你要定制推理引擎、做模型编译优化、或者把LLM塞进边缘设备那些被封装掉的内存布局、计算图调度、序列化协议就会变成你无法绕过的墙。关键词里反复出现的Python、TypeScript、Rust恰恰揭示了这个项目的三维结构Python是算法实验的“手”TypeScript是服务编排的“眼”Rust是底层算力的“骨”。Scratch不是拒绝工具而是拒绝盲从——就像木匠不会因为有了电钻就放弃理解榫卯受力AI工程师也不能因为有了transformers库就放弃追问attention矩阵乘法在GPU上的memory coalescing如何影响吞吐。最近帮一家医疗影像公司做模型部署优化他们用官方Hugging Face pipeline跑ResNet-50 inferenceP99延迟230ms我们用Rust重写了tensor加载预处理推理调度三层延迟压到67ms关键不是Rust快而是我们亲手控制了每个memcpy的起始地址对齐、每个CUDA kernel的shared memory bank conflict、每个batch内图像尺寸的padding策略——这些细节任何高级API都不会告诉你该在哪改。适合谁来啃这块硬骨头不是刚学完吴恩达课程的初学者而是已经用过至少两种框架比如PyTorch训过CV模型JAX写过RL agent、遭遇过真实生产瓶颈OOM、冷启慢、版本升级崩pipeline、多模态数据对齐错乱的中级以上工程师。如果你的日常是调参、改config、等CI跑通那这个项目会颠覆你对“AI工程”的认知——它不产出模型它产出的是让你的模型真正活下来的土壤。2. 整体架构设计为什么必须用三种语言分层而不是“All-in-One”2.1 分层逻辑从“能跑通”到“可交付”的鸿沟很多团队尝试“All-in-Python”做AI工程基建结果三年后陷入泥潭训练脚本用PyTorch Lightning推理服务用FastAPIONNX Runtime监控用PrometheusGrafana但三者之间靠JSON串传递数据类型校验全靠文档约定某次PyTorch升级导致tensor dtype隐式转换推理服务突然返回NaN排查三天才发现是float32→bfloat16的隐式cast没做显式声明。这就是“能跑通”和“可交付”的本质区别前者关注功能正确性后者关注契约可靠性、演化可预测性、故障可追溯性。我们采用三语言分层根本原因是每种语言在特定维度上具有不可替代的“物理优势”Python层算法实验与数据流核心价值不是执行快而是生态粘性与表达密度。NumPy的ufunc、SciPy的稀疏矩阵、Pandas的groupby、Matplotlib的绘图原语这些不是“语法糖”而是数学家和统计学家三十年沉淀的领域专用语言DSL。强行用Rust重写pandas.DataFrame的rolling窗口计算不是技术胜利而是对人类知识积累的背叛。但Python的GIL和动态类型决定了它绝不能成为服务入口或高频计算内核。TypeScript层服务编排与契约定义TypeScript的杀手锏不是“加了类型”而是类型即文档、类型即契约、类型即测试。当你的AI服务要对接前端、移动端、IoT设备时OpenAPI spec只是纸面协议而TypeScript interface是运行时强制契约。我们定义InferenceRequest时不仅声明input_ids: number[]更通过zod库约束input_ids.length 0 input_ids.length 2048这个约束会自动生成HTTP 400错误提示也会在前端表单提交前触发校验。更重要的是TypeScript的泛型和条件类型能精准描述“当model_typevision时input_data必须是base64字符串当model_typetext时input_data必须是UTF-8文本”这种业务逻辑这种表达力Python的typing或Rust的enum都无法优雅覆盖。Rust层计算内核与系统集成Rust解决的不是“比C快多少”而是内存安全与并发确定性的工程负债归零。AI推理中常见的“batch内不同序列长度导致padding浪费显存”问题在Python里你得靠hacky的collate_fn和custom DataLoader在Rust里你可以用VecOptionT配合std::mem::align_of::T()精确控制内存布局用ArcMutex替代全局变量管理GPU context用tokio::sync::Semaphore控制并发请求数——所有这些编译期就能捕获use-after-free、data race、stack overflow。我们曾用Rust重写一个BERT tokenization C extension代码行数减少37%但CI通过率从82%提升到100%因为所有边界条件空字符串、超长文本、非法Unicode都在编译期被match穷举覆盖。提示不要陷入“哪种语言最好”的争论。真正的工程判断是“在这个模块里哪种语言能让我用最少的代码表达最多的约束并在最早的时间点编辑期/编译期/运行期暴露最多的问题”2.2 模块划分拒绝“大单体”拥抱“契约驱动微服务”整个系统拆解为六个核心模块每个模块有明确的输入/输出契约、独立的CI/CD流水线、以及跨语言的IDLInterface Definition Language模块名称主语言核心职责关键契约IDL片段为什么不用其他语言tokenizer-coreRust字节级tokenize、vocab lookup、padding策略struct TokenizeRequest { text: String, max_len: u32 }Python的正则回溯可能导致OOMTS无法直接操作字节流model-runnerRustGPU tensor计算、kernel dispatch、memory pool管理struct InferenceBatch { input_ids: Vecu32, attention_mask: Vecu8 }Python GIL阻塞多streamTS无GPU API>// src/tokenizer.rs use std::collections::HashMap; use std::sync::Arc; pub struct BpeTokenizer { // vocab存储在Arc中避免重复clone vocab: ArcHashMapString, u32, // merges存储为Vec(String, String)而非嵌套HashMap // 因为merges查找是顺序扫描Vec比HashMap cache-friendly merges: Vec(String, String), // 缓存已分词的常见词避免重复计算 cache: dashmap::DashMapString, Vecu32, } impl BpeTokenizer { pub fn new(vocab_path: str, merges_path: str) - ResultSelf, Boxdyn std::error::Error { let vocab Arc::new(load_vocab(vocab_path)?); let merges load_merges(merges_path)?; Ok(Self { vocab, merges, cache: dashmap::DashMap::new(), }) } pub fn encode(self, text: str) - Vecu32 { // Step 1: 字符串预处理Unicode normalization let normalized unicode_normalization::UnicodeNormalization::nfc() .normalize(text) .collect::String(); // Step 2: 缓存命中检查锁粒度控制到key级别 if let Some(cached) self.cache.get(normalized) { return cached.clone(); } // Step 3: BPE分词核心逻辑 let mut tokens self.split_into_bytes(normalized); let mut i 0; while i tokens.len() - 1 { let pair format!({}{}, tokens[i], tokens[i 1]); if let Some(id) self.vocab.get(pair) { // 安全替换用切片操作避免索引越界 tokens.splice(i..i 2, std::iter::once(id.to_string())); i i.saturating_sub(1); // 重置i因为新token可能参与下次merge } else { i 1; } } // Step 4: 转换为u32并缓存 let result: Vecu32 tokens .into_iter() .filter_map(|s| self.vocab.get(s).copied()) .collect(); self.cache.insert(normalized, result.clone()); result } }关键细节解析ArcHashMapvsRcHashMapArc支持多线程共享因为tokenizer会被多个HTTP worker并发调用Rc只适用于单线程用在这里会导致编译失败。dashmap::DashMap替代std::collections::HashMap标准HashMap在并发写入时需全局锁而DashMap将哈希桶分片锁粒度降到单个bucket实测100并发下cache命中率提升4.3倍。saturating_sub(1)的安全性当i0时i-1会underflow为usize::MAX导致panic。saturating_sub确保0-10这是Rust对整数溢出的主动防御。unicode_normalization::nfc()的必要性中文用户输入“你好”和“你好”不同Unicode组合形式应视为同一tokenNFC标准化确保一致性。实操心得不要迷信“Rust自动内存安全”。我们曾因忘记在encode函数末尾drop(tokens)导致大量临时String对象堆积GC压力暴增。Rust的ownership是编译期保证但算法复杂度仍需人工分析——建议用cargo instrument --gc定期检测内存分配热点。3.2 TypeScript层Orchestrator的类型驱动工作流TypeScript的类型系统在此处发挥极致威力。我们定义了一个WorkflowDefinition它不仅是配置更是编译期可验证的业务规则// src/orchestrator/workflow.ts import { z } from zod; // 定义step类型族确保每个step有唯一type标识 const StepBase z.object({ id: z.string(), type: z.string(), }); const PreprocessStep StepBase.extend({ type: z.literal(preprocess), config: z.object({ resize: z.object({ width: z.number(), height: z.number() }).optional(), normalize: z.boolean().default(true), }), }); const InferenceStep StepBase.extend({ type: z.literal(inference), config: z.object({ model_id: z.string(), batch_size: z.number().min(1).max(64), }), }); const PostprocessStep StepBase.extend({ type: z.literal(postprocess), config: z.object({ threshold: z.number().min(0).max(1), }), }); // 使用Zod的discriminated union自动推导type守卫 export const WorkflowStep z.discriminatedUnion(type, [ PreprocessStep, InferenceStep, PostprocessStep, ]); export type WorkflowStep z.infertypeof WorkflowStep; // 工作流定义强制step间依赖关系 export const WorkflowDefinition z.object({ name: z.string(), steps: z.array(WorkflowStep).min(1), // 编译期检查inference step必须存在且只能有一个 _inferenceStepCount: z .number() .refine((n) n 1, { message: Exactly one inference step required }) .transform(() 1), }).refine( (wf) { const inferenceSteps wf.steps.filter(s s.type inference); return inferenceSteps.length 1; }, { message: Workflow must contain exactly one inference step } ); export type WorkflowDefinition z.infertypeof WorkflowDefinition;这个定义带来的实际收益IDE智能提示当开发者输入workflow.steps[0].时TypeScript自动提示id、type、config且config的字段根据type动态变化——typepreprocess时显示resizetypeinference时显示model_id。配置校验前置CI流水线中zod会在加载workflow.yaml时立即报错“batch_sizemust be 1 and 64”而不是等到服务启动后因batch_size128导致OOM崩溃。类型安全的step遍历传统switch-case容易漏case而Zod的discriminatedUnion配合TypeScript的exhaustive检查确保所有type都被处理function executeStep(step: WorkflowStep): Promiseany { switch (step.type) { case preprocess: return runPreprocess(step.config); case inference: return runInference(step.config); case postprocess: return runPostprocess(step.config); // 如果新增typererank此处编译失败强制开发者补充逻辑 default: throw new Error(Unhandled step type: ${(step as any).type}); } }注意Zod的.refine()方法在运行时校验但它的类型定义z.infer会生成精确的TypeScript类型。这意味着你在代码里写的workflow.steps[0].config.resize.widthTypeScript能100%保证它存在且是number——这种“类型即契约”的力量是Python typing永远达不到的。3.3 Python层Experiment Tracker的轻量级可观测性Python层不追求性能而追求开发者体验与数据可信度。我们摒弃了MLflow、Weights Biases等重型方案用200行代码实现核心功能# src/tracker/experiment.py import json import time import hashlib from pathlib import Path from dataclasses import dataclass, asdict from typing import Dict, Any, Optional dataclass class ExperimentLog: # 必填字段强制校验 experiment_id: str timestamp: float metrics: Dict[str, float] params: Dict[str, Any] # 可选字段但类型明确 artifacts: Optional[Dict[str, str]] None # {name: file_path} tags: Optional[Dict[str, str]] None def __post_init__(self): # 编译期无法检查但运行期强制 if not isinstance(self.metrics, dict): raise TypeError(metrics must be a dict) if not isinstance(self.params, dict): raise TypeError(params must be a dict) class ExperimentTracker: def __init__(self, log_dir: str): self.log_dir Path(log_dir) self.log_dir.mkdir(exist_okTrue) def log(self, log: ExperimentLog): # 生成唯一IDhash(experiment_id timestamp params) id_hash hashlib.md5( f{log.experiment_id}{log.timestamp}{json.dumps(log.params, sort_keysTrue)}.encode() ).hexdigest()[:8] log_file self.log_dir / f{log.experiment_id}_{id_hash}.json # 原子写入先写临时文件再rename temp_file log_file.with_suffix(.tmp) with open(temp_file, w) as f: json.dump(asdict(log), f, indent2) temp_file.rename(log_file) print(f✅ Logged to {log_file}) # 使用示例 if __name__ __main__: tracker ExperimentTracker(./logs) tracker.log(ExperimentLog( experiment_idbert-base-uncased, timestamptime.time(), metrics{accuracy: 0.87, f1: 0.82}, params{lr: 2e-5, batch_size: 16, max_len: 512}, artifacts{model: ./models/bert.pt}, tags{env: dev, dataset: glue-mnli} ))这个设计的精妙之处在于dataclass__post_init__既提供简洁的初始化语法又能在实例化时做运行时类型检查比纯typing.NamedTuple更灵活。hashlib.md5生成短ID避免长UUID降低可读性同时保证相同参数组合生成相同ID便于diff实验。原子写入temp_file.rename()在Linux/macOS上是原子操作避免并发写入时文件损坏。Windows需用os.replace()替代。asdict()序列化自动处理dataclass嵌套比手动vars()更可靠。实操心得不要低估日志格式的威力。我们曾因metrics字段允许None值导致下游Pandas读取时pd.read_json()报错。现在强制Dict[str, float]并在__post_init__中isinstance(metrics, dict)校验问题彻底消失。可观测性的第一道防线永远是数据格式的刚性约束。4. 开发环境搭建VS Code Rust TypeScript Python的零摩擦配置4.1 VS Code工作区配置一个配置文件搞定三语言.vscode/settings.json是整个开发体验的基石我们拒绝插件堆砌用原生配置达成最佳体验{ files.exclude: { **/target: true, **/node_modules: true, **/__pycache__: true }, search.exclude: { **/target: true, **/node_modules: true }, // Rust配置rust-analyzer是唯一必需插件 rust-analyzer.cargo.loadOutDirsFromCheck: true, rust-analyzer.procMacro.enable: true, rust-analyzer.checkOnSave.command: check, // TypeScript配置TypeScript内置支持足够强大 typescript.preferences.importModuleSpecifier: relative, typescript.preferences.includePackageJsonAutoImports: auto, // Python配置Pylance Ruff双引擎 python.defaultInterpreterPath: ./venv/bin/python, python.linting.enabled: true, python.linting.pylintEnabled: false, python.linting.ruffEnabled: true, python.formatting.provider: ruff, // 全局快捷键一键运行当前文件 keybindings: [ { key: ctrlaltr, command: workbench.action.terminal.runActiveFile, when: editorTextFocus !terminalFocus } ] }关键配置说明rust-analyzer.procMacro.enable启用过程宏展开让#[derive(Debug)]等宏的调试信息可点击跳转否则你会看到一堆macro占位符。typescript.preferences.importModuleSpecifier设为relative避免../../../../utils这种反人类路径自动生成../../utils。python.linting.ruffEnabledRuff比Pylint快10倍且规则集更现代如强制from __future__ import annotations。ctrlaltr快捷键在Python文件按此键自动在终端执行python current_file.py在TS文件执行tsc --noEmit node current_file.js在Rust文件执行cargo run --bin current_bin——无需记忆不同命令。注意不要安装“Rust Test Explorer”、“TypeScript Hero”等花哨插件。rust-analyzer和TypeScript内置语言服务已覆盖95%需求额外插件只会拖慢VS Code启动速度。我们实测禁用所有非必要插件后VS Code冷启动从8.2秒降至2.1秒。4.2 Rust开发环境绕过国内镜像源的稳定方案Rust官方源在国内访问不稳定但盲目换镜像源有风险。我们采用“双源策略”# ~/.cargo/config.toml [source.crates-io] replace-with ustc [source.ustc] registry https://mirrors.ustc.edu.cn/crates.io-index # 同时配置git依赖的代理针对github.com [net] git-fetch-with-cli true # 在CI中用环境变量覆盖 # export CARGO_REGISTRIES_CRATES_IO_PROTOCOLhttps # export CARGO_NET_GIT_FETCH_WITH_CLItrue但更关键的是依赖锁定策略Cargo.lock必须提交到Git禁止cargo update随意升级。对tokio、axum等关键crate用[patch.crates-io]锁定小版本[patch.crates-io] tokio { git https://github.com/tokio-rs/tokio, tag tokio-1.32.0 }这样即使上游发布tokio-1.32.1修复bug你的服务也不会自动升级避免“修复一个bug引入三个新bug”。实操心得Rust的cargo tree是你的最佳朋友。当遇到编译错误说“conflicting versions ofbytes”运行cargo tree -p bytes立刻看到哪个依赖引入了旧版bytes然后用cargo update -p bytes精准升级而不是cargo update全量更新。4.3 Python虚拟环境为什么坚持venv而非conda尽管conda在数据科学领域流行但我们坚持用venv原因直击痛点Docker镜像体积conda环境打包后常超1.2GB而venvpip install --no-cache-dir可压至320MBCI构建时间缩短63%。依赖冲突可视化pipdeptree能清晰显示transformers依赖tokenizers0.13.0而tokenizers又依赖pydantic1.10这种链式依赖在conda里难以追踪。CI缓存友好pip install -r requirements.txt --no-depspip install -e .的组合让CI能精准缓存requirements.txt未变时的wheel包而conda的environment.yml每次都会全量重建。标准流程# 创建隔离环境 python -m venv venv source venv/bin/activate # Linux/macOS # venv\Scripts\activate.bat # Windows # 升级pip避免旧版pip安装wheel失败 pip install --upgrade pip # 安装生产依赖--no-cache-dir避免CI磁盘爆满 pip install -r requirements.txt --no-cache-dir # 安装开发依赖-e模式修改代码即时生效 pip install -e .[dev]requirements.txt采用pinning策略numpy1.24.3 pandas2.0.3 torch2.0.1cu118; platform_systemLinux torch2.0.1; platform_system!Linux注意platform_system的条件标记确保Mac/Windows用户安装CPU版本Linux用户安装CUDA版本。5. 常见问题与实战排查那些文档里永远不会写的坑5.1 Rust编译失败proc-macro无法解析syn语法树现象cargo build报错error[E0658]: procedural macros cannot be expanded to expressions且指向#[derive(Serialize)]。根因syncrate的版本与rustc版本不匹配。syn2.x要求rustc 1.65但你的rustc --version显示1.63.0。排查步骤运行rustup show确认当前toolchain查看Cargo.lock中syn的版本号访问https://crates.io/crates/syn查该版本的rust-version字段解决方案# 升级rustc到匹配版本 rustup update stable # 或降级syn不推荐可能引发其他依赖问题 # 在Cargo.toml中指定 # syn 1.0.109 # 适配rustc 1.63独家技巧在Cargo.toml顶部添加rust-version 1.65rustc会在编译前检查版本提前报错而非编译中途失败。5.2 TypeScript类型错误Property xxx does not exist on type yyy现象VS Code报红但npm run build却通过。根因TypeScript的skipLibCheck: true默认开启跳过.d.ts声明文件检查但VS Code的IntelliSense仍会校验。排查步骤运行tsc --noEmit --watch观察实时编译错误检查node_modules/types/xxx/package.json中的types字段是否指向有效文件运行npm list types/node确认版本兼容性Node 18需要types/node18.x解决方案// tsconfig.json { compilerOptions: { skipLibCheck: false, // 关闭跳过检查 types: [node, jest] // 显式声明需要的types } }实操心得永远不要相信“VS Code报错而tsc不报”。我们曾因此上线一个req.body.user.id访问实际user是undefined因为types/express的类型定义不准确。tsc是真理VS Code只是它的影子。5.3 Python导入失败ModuleNotFoundError: No module named xxx现象python main.py成功但pytest tests/失败报找不到模块。根因Python的sys.path在不同启动方式下不同。python main.py会把main.py所在目录加入sys.path而pytest会把当前工作目录通常是项目根目录加入sys.path。排查步骤在报错文件开头添加import sys print(sys.path:, sys.path)对比python main.py和pytest输出的sys.path差异解决方案# 方案1用-p参数指定pythonpath python -m pytest -p no:warnings tests/ # 方案2在pytest.ini中配置 # pytest.ini [tool:pytest] pythonpath .注意PYTHONPATH环境变量是万能钥匙但也是定时炸弹。我们禁止在CI中设置PYTHONPATH强制所有导入走相对路径或src布局。5.4 性能瓶颈定位如何找到真正的“慢点”现象端到端延迟高但timeit测单个函数很快。根因AI工程的慢点往往在IO、序列化、内存拷贝而非CPU计算。排查工具链Rustcargo flamegraph生成火焰图重点关注std::io::BufReader::read、serde_json::from_slice。TypeScriptnode --prof app.jsnode --prof-process查看Tick分布。Pythonpy-spy record -o profile.svg --pid $(pgrep -f main.py)。经典案例我们曾发现tokenizer.encode()耗时80mstimeit测函数本身只要2ms。py-spy显示95%时间在json.loads()——因为Python层把token ids转成JSON再传给TS层。解决方案Rust tokenizer直接写二进制文件TS层用fs.readFileSync().buffer读取性能提升40倍。独家技巧在Rust中用std::hint::black_box()防止编译器优化掉benchmark代码use std::hint::black_box; let start std::time::Instant::now(); let _ black_box(tokenizer.encode(hello world)); println!(Time: {:?}, start.elapsed());6. 项目演进路线从Scratch到Production的三个阶段6.1 Stage 1验证核心契约1-2周目标不是功能完整而是验证三层之间的数据契约是否坚不可摧。Python生成{text:hello,max_len:512}→ Rust tokenizer → 返回[123,456,789]→ TS服务接收并返回{tokens:[123,456,789]}关键检查点Rust返回的Vecu32能否被TS的JSON.parse()无损还原验证u32范围≤2^31-1Python的json.dumps()是否开启ensure_asciiFalse避免中文转\u4f60\u597dTS的fetch()是否设置headers: {Content-Type: application/json}否则Rust服务收到text/plain这个阶段要写出契约测试Contract Test而非单元测试// contract.test.ts it(should handle Chinese text correctly, async () { const res await fetch(http://localhost:3000/v1/tokenize, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ text: 你好世界, max_len: 512 }), }); const data await res.json(); expect(data.tokens).toEqual([123, 456, 789]); // 确保中文token id一致 });6.2 Stage 2注入生产约束
阅读完成 · 觉得有帮助?