AI工程从零实践:手写Transformer核心与训练推理全链路

发布时间:2026/10/4 10:01:35
AI工程从零实践:手写Transformer核心与训练推理全链路
很多人对AI工程的理解是调一个现成的大模型API套一层FastAPI写几个Prompt模板然后部署出去。这没错但只能算AI应用开发离真正的AI工程还差着一大截。我自己从去年开始给自己立了一个项目就叫ai-engineering-from-scratch——不是从现成SDK开始而是从token化、注意力机制、训练循环、推理优化、Agent编排这一整条链路亲手把每个环节搭出来。这篇博文就是从这条路上沉淀下来的完整路线、原理拆解和实操细节适合那些不想只停留在调库调参层面、想真正吃透AI系统底层逻辑的开发者。我写这篇东西的初衷是给自己做学习复盘也顺便帮后来人少走点弯路。如果你已经有过一点深度学习的底子比如知道什么是张量、什么是反向传播那读起来会非常顺如果你完全零基础也不用慌我会把每个关键概念都尽量讲成人话保证你照着做能跑通。下面直接进入正题。1. 别急着上模型先搞清楚从零开始到底从哪开始市面上很多教程说的从零开始其实是从零开始调API。它们默认你已经有了一颗别人训练好的大模型你只需要学会怎么发请求、怎么解析返回结果、怎么处理计费和限流。但真正的ai-engineering-from-scratch要回答的是另一个问题如果地球上没有任何现成模型可用你能不能自己造一个能用的出来要回答这个问题你得对整条流水线有完整的认知。我用一张表把这几年摸爬滚打总结出来的全链路画出来每条线都标清楚输入、输出和核心问题环节核心输入核心输出要解决的核心问题数据工程原始语料整洁、多样、配比合理的token序列模型吃进去的到底是什么样的数据Token化文本整数索引序列怎么把语言切成模型能高效处理的单位模型设计Token序列概率分布注意力机制怎么捕捉长距离依赖训练循环模型数据更新后的权重损失函数怎么定义、学习率怎么调推理部署训练好的权重逐token生成的文本怎么让生成速度足够快、成本足够低评估监控模型评测集量化指标模型是真的可靠还是只是看起来聪明Agent编排模型工具任务完成怎么让模型逐步拆解任务、调用工具、自主纠错我在最开始犯过一个大错一上来就啃《Attention Is All You Need》原文死磕多头注意力公式结果连数据怎么进模型都没搞清楚。后来才明白从零开始的正确姿势不是一条路走到黑而是先纵向跑通再横向抠深。也就是说第一遍先不管细节用最小的模型、最小的语料把数据-训练-推理整条线跑通第二遍再回到每个环节问自己为什么这里要这么做把底层的原理一个个补齐。这个路子我后面会反复提到。现在你只需要记住一个结论真正的从零开始是一条全链路不是一个模型。后面几个章节我会按照我自己实践过的顺序一条一条展开。另外一个重要的认知是从零开始不等于非得造出一个GPT-4。不要想着自己训一个大模型那是烧钱的事情。我们的目标是把大模型技术栈里的每一个核心组件亲手实现一遍用最小的规模验证理解。比如我后面的章节里会带你用一个几百万参数的微型Transformer在单张消费级显卡上训练一个能造句的小模型。这个规模下你仍然能完整体验到时间换精度、显存换性能那一整套工程权衡这才是从零开始最宝贵的收获。2. Token化与数据准备模型看到的世界和你不一样很多人会把Token化当成一个无关紧要的预处理步骤觉得不就是调一个tokenizer.encode()吗这种心态后面一定会吃苦头。Token化决定了模型认识世界的最小单位它直接决定了序列长度、训练效率、推理成本甚至影响模型的语言能力边界。2.1 从字符到整数BPE分词是怎么工作的现代大模型用的分词方案主流是BPEByte Pair Encoding。它的核心逻辑特别朴素先把文本拆成一个个字符然后反复统计相邻字符对的出现频率把最高频的字符对合并成一个新的token直到词表达到预设大小。我举个极简的例子。假设语料里频繁出现ing这个组合最开始文本被拆成了i、n、g三个字符统计时发现i n这对组合出现次数非常多于是把它们合并成一个token标记为in再统计in和g的组合又很频繁于是合并成ing。如此往复最终高频子词成为词表里的一组索引。我自己实现BPE时发现一个重要的工程细节合并优先级不能只用出现次数还要看合并之后能节省多少个token。举个例子如果t和h在语料中出现了10万次但分别出现也都接近10万次合并它们只能节省几千个token但如果某个生僻组合出现了1万次且各自出现频率都很低合并一次的收益反而更大。实际的开源分词器比如GPT系列的tokenizer用的是word级别的词频统计加BPE合并跑一遍需要几小时到几天不等。对于学习阶段的你直接用tiktoken或huggingface/tokenizers库分析词表就够了没必要自己从头写一个生产级的BPE。2.2 中文场景下最容易踩的Token化陷阱如果只是处理英文数据BPE基本够用但换成中文问题马上就来了。中文的字天然是语义的最小单位而BPE的初始词汇表是字符级加字节级的混合中文字显然不会作为初始token存在所以它会把常用字逐步合并成词。实测下来一个质量不高的tokenizer处理中文时token数会比高质量tokenizer多出30%-50%。这意味着什么序列长度变长注意力计算量按平方增长训练和推理成本跟着飙升。我有一次在微调一个开源模型时发现同样的中文段落有的tokenizer编码出300个token有的只要180个。相同显存下后者能塞进去的上下文长度几乎翻倍。后来我养成了一个习惯任何模型在接入中文项目之前先拿几段真实业务文本跑一遍token计数用tokenizer.encode(text)统计平均token长度低于行业基准的再考虑是否换词表或者加几个中文专用token。2.3 训练数据的干净不是指没有错别字把token化搞定之后真正的数据工程才刚开始。我见过很多人拿网上的语料直接开训结果模型训练loss怎么都降不下去或者生成内容前后矛盾。问题往往出在数据质量而不是模型结构。我自己的数据处理管线分成五道关卡每一步都可能筛掉大量数据去重用MinHash或者简单的字符级哈希去掉完全重复和高度相似的文档。重复数据会让模型反复背同一段内容破坏泛化能力。格式净化去除HTML标签、Markdown标记、无意义的空行。这一步别用正则硬刚我踩过坑最后改用trafilatura这类专门做正文提取的工具。语言过滤用fastText的语种识别模型区分中英文或者按项目需要保留多语种。注意混合语种的数据会让tokenizer的效率进一步恶化。质量过滤按perplexity困惑度打分保留那些一个通用语言模型觉得合理的文本。低质量语料的perplexity通常明显偏高。指令与代码配比如果你打算做指令微调还需要单独构造或收集高质量的指令数据并控制通用语料、代码、数学、指令数据之间的比例。在配比上我给一个参考值假设数据总量是100份通用文本占60份代码占25份数学和逻辑推理占10份指令对话占5份。这只是起点具体配比要看任务目标——如果做垂直领域问答指令数据的比例要大幅上调如果需要模型有强大的代码能力代码比例甚至能到40%。这一整套做完之后你打交道的就不再是文本而是一串定长的整数序列。此时才真正有资格进入下一章怎么让模型处理这些序列。3. 从零构建可训练的Transformer核心注意力、多头与残差连接这一章是整个项目里最硬核的部分也是从会用到会造的分水岭。我带着你把Transformer的核心组件手写一遍不用任何高级封装只用PyTorch的基础张量操作。3.1 为什么非得是注意力机制在Transformer之前循环神经网络按时间步处理序列前后信息传递存在远距离衰减问题句子一长开头的信息基本就丢了。Transformer的注意力机制则完全不同它在每一步生成时直接对整个序列的所有位置做加权求和权重由当前位置与目标位置的相关性决定。可以理解成读一句话的时候模型不是顺着读而是先扫全文然后自己决定当前这个词应该重点参考哪个词。这个机制让长距离依赖不再是问题但也引入了一个新的计算瓶颈注意力分数矩阵的大小是序列长度 × 序列长度序列一长显存和时间都成平方级爆炸。这是后面推理优化章节的重要伏笔。3.2 多头自注意力的PyTorch实现我直接给出一个能跑的最小实现关键步骤都写清楚注释import torch import torch.nn as nn import torch.nn.functional as F import math class MultiHeadSelfAttention(nn.Module): def __init__(self, d_model, n_head, dropout0.1): super().__init__() assert d_model % n_head 0 self.d_model d_model self.n_head n_head self.head_dim d_model // n_head # 三个线性投影Q、K、V。 # 这里不分别写三个线性层而是合并成一个大的权重矩阵 # 好处是能利用GPU矩阵乘法的并行性一次性完成投影。 self.w_qkv nn.Linear(d_model, 3 * d_model) self.w_out nn.Linear(d_model, d_model) self.dropout nn.Dropout(dropout) def forward(self, x, maskNone): B, T, C x.shape # batch, 序列长度, 特征维度 qkv self.w_qkv(x) # 形状: (B, T, 3 * d_model) q, k, v torch.chunk(qkv, 3, dim-1) # 拆成多头从 (B, T, d_model) 变成 (B, n_head, T, head_dim) q q.view(B, T, self.n_head, self.head_dim).transpose(1, 2) k k.view(B, T, self.n_head, self.head_dim).transpose(1, 2) v v.view(B, T, self.n_head, self.head_dim).transpose(1, 2) # 注意力分数q k^T再除以 sqrt(head_dim) # 除以 sqrt(head_dim) 的原因让分数方差保持稳定。 # 如果不缩放当 head_dim 很大时点积结果方差过大 # softmax 之后会退化成近乎 one-hot梯度几乎为零。 attn_scores (q k.transpose(-2, -1)) / math.sqrt(self.head_dim) if mask is not None: attn_scores attn_scores.masked_fill(mask 0, float(-inf)) attn_probs F.softmax(attn_scores, dim-1) attn_probs self.dropout(attn_probs) # 加权聚合 out attn_probs v # (B, n_head, T, head_dim) # 合并回多头 out out.transpose(1, 2).contiguous().view(B, T, C) return self.w_out(out)这段代码里有几个为什么特别值得展开讲一下。为什么Q、K、V要拆成多头多头可以理解为让模型同时关注不同的关系类型。一个头可能专门关注语法邻近的词另一个头可能关注跨句子的指代关系。多个头各自独立学习最后拼在一起相当于一个模型内部运行了多套阅读理解策略表达能力大幅增强。为什么缩放因子是1/sqrt(head_dim)这是一个非常经典的经验设计。两个随机向量的点积方差近似等于向量维度d。如果不缩放d越大点积分数的绝对值就越大进入softmax后越接近极端值梯度越小训练越不稳定。除以sqrt(d)本质上是把方差拉回1附近让softmax的输入保持在一个合理区间。残差连接和层归一化我习惯单独处理。Transformer block的标准结构是先做注意力加上残差再LayerNorm然后做前馈网络加上残差再LayerNorm。残差连接保证深层网络里梯度能顺畅回流LayerNorm让每层输入分布稳定。后来不少模型偏好用RMSNorm替代LayerNorm——少算均值速度快一截效果基本持平。前馈网络那块就不贴完整代码了核心就是在注意力输出之后接一个Linear(d_model, 4*d_model)过激活函数再Linear(4*d_model, d_model)。这里的4倍扩展是经验值太大增加参数和过拟合风险太小表达能力不足。我实测时发现激活函数选GELU或者SiLU比ReLU更好主要是ReLU在负半轴硬截断训练后期容易出现死神经元。3.3 用一个迷你配置验证前向计算代码写完先别急着训练用一个小配置跑一遍前向确认输出形状符合预期torch.manual_seed(42) device cuda if torch.cuda.is_available() else cpu x torch.randint(0, 100, (2, 32)).to(device) # 2个样本32个token emb nn.Embedding(100, 128) attn MultiHeadSelfAttention(d_model128, n_head4) x_emb emb(x) out attn(x_emb, maskNone) print(out.shape) # 期望输出: torch.Size([2, 32, 128])如果这里输出的形状不对通常是view和transpose的维度写岔了。我当时排查了整整一个晚上才发现是contiguous()没调导致后面view报错。这种细节只有亲手写一遍的人才有体会。到这里你已经有了一个能跑前向的Transformer核心块。但要让模型真正开口说话还得把它接到完整的模型骨架里加上位置编码、输出层然后进入最磨人的训练环节。接下来这章就专门讲训练循环。4. 训练循环实战Loss、学习率与从零跑出的第一个能说话的模型模型结构搭好之后我突出感觉到一件事结构决定上限训练决定能不能够到上限。很多人调开源模型微调时总觉得效果不理想其实一半的原因在训练细节另一半在数据模型结构反而是最不容易出问题的地方。4.1 自回归语言模型的标准答案是什么一个大模型在预训练阶段做的事情非常单纯给它一段文本让它在每个位置预测下一个token是谁。比如文本是我喜欢吃苹果模型看到我喜欢吃这四个token要预测第五个token是苹的概率最高。所以训练数据构造也很直接把token序列切成长度为T的输入窗口窗口右移一位作为标签。举个例子输入是[1, 2, 3, 4, 5]标签就是[2, 3, 4, 5, 6]——每个位置都去预测下一位。损失函数就是标准的交叉熵只计算真实token位置上的概率负对数。这里有个细节padding位置的token不应该参与损失计算。如果数据集里的序列长短不一需要填充到统一长度计算loss时必须用ignore_index-100把padding位置屏蔽掉否则模型会花力气去学如何预测padding符纯属浪费。4.2 一个能完整跑起来的训练循环下面这个训练循环我用在好几个小项目里综合了warmup、余弦退火、梯度裁剪、梯度累积算是比较实用的模板import torch.nn.functional as F def train_one_epoch(model, dataloader, optimizer, scheduler, device, grad_clip1.0, accum_steps4): model.train() total_loss 0 optimizer.zero_grad() for step, (batch_x, batch_y) in enumerate(dataloader): batch_x batch_x.to(device) batch_y batch_y.to(device) logits model(batch_x) # (B, T, vocab_size) loss F.cross_entropy( logits.view(-1, logits.size(-1)), batch_y.view(-1), ignore_index-100 ) # 梯度累积当显存小、batchsize上不去时很有用 loss loss / accum_steps loss.backward() if (step 1) % accum_steps 0: # 梯度裁剪防止梯度爆炸 torch.nn.utils.clip_grad_norm_(model.parameters(), grad_clip) optimizer.step() scheduler.step() # 每个optimizer step更新一次学习率 optimizer.zero_grad() total_loss loss.item() * accum_steps if step % 100 0: print(fstep {step}, loss {loss.item() * accum_steps:.4f}, lr {scheduler.get_last_lr()[0]:.6f}) return total_loss / len(dataloader)我在这个模板里吃过不少亏挑几个影响最大的说。学习率策略不是锦上添花而是生死线。如果一开始就用一个固定较大的学习率比如5e-4模型前期会出现loss剧烈震荡甚至变成NaN。原因是训练初期权重还没成型梯度的方向很不稳定大步长直接冲飞。warmup就是在最开始的一两千步里把学习率从0线性升到目标值让模型小步试探进入平稳区然后再用余弦退火慢慢降下来收敛得更平滑。混合精度不是白捡的加速。用torch.autocast配合GradScaler显存占用能少一半训练速度提升明显。但FP16有个经典坑loss突然变成NaN通常发生在数据里出现极端值梯度动态范围太大16位浮点数表示不了。解决办法有两个一是开启动态loss scaling二是实在不行退回FP32训练那一段。我自己的经验是小模型完全用FP32训练也没什么问题省心优先。梯度裁剪到底剪什么。通俗解释就是每次反传之后把所有参数的梯度算一个总范数如果这个范数超过阈值就整体等比缩小。这防止了某一个batch的数据特别刁钻把梯度推得太远导致训练直接飞出收敛盆地。阈值我习惯设1.0不算激进也不算保守。4.3 我亲测的小规模训练实验数据用单张RTX 4090我训过一个大约3000万参数30M的微型GPT模型配置项我的设置备注模型结构8层Transformerd_model5128个头参数约30M训练数据约5亿token的中英文混合语料从公开数据集筛选批大小动态batch每个batch 512个序列配合梯度累积学习率峰值6e-4warmup 2000步余弦退火总步数约15万步训练时间约40小时单卡4090混合精度最终loss约3.1交叉熵已经能生成语法通顺的短句训练大约5万步的时候loss降到4.0左右模型只能输出乱序单词到10万步开始出现主谓宾齐全的简单句子到15万步能稳定生成20个词以内、无明显语法错误的片段。这种看着模型一点点学会说话的过程是直接调用API永远体会不到的成就感。如果你手头显存只有12G或更小也不用担心。把模型压到10M参数级别即4层、d_model256、4个头训练语料缩到1亿token照样能把整条链路跑通。这个规模的模型单人单卡一晚上就能完成一次完整的实验迭代。5. 训练完只是开始推理服务里的速度、量化与并发训练好的模型只是起点。当你试图把模型接入真实服务面对用户的并发请求时会遇到一批训练时根本感觉不到的问题生成速度慢得让人抓狂、显存不够放不下、并发稍高就OOM。这一节讲的都是部署侧的血泪教训。5.1 自回归生成为什么天生就慢大模型生成文本是一个token一个token地来。生成第10个token的时候需要用前面9个token做输入做一次前向计算生成第11个token又需要重新处理前面10个token。如果你不做任何优化生成100个token就要跑100次前向而且每次前向计算的注意力都会把前文所有token重新算一遍。典型情况下首token延迟可能在几十到几百毫秒后续每秒只能生成不到50个token在云服务器上如果响应慢用户早就等得不耐烦了。5.2 KV Cache一次计算反复复用优化的核心思路叫KV Cache。注意力计算可以拆成两部分对每个新token需要做的是拿它的Query去和前面所有token的Key做匹配然后用匹配权重对Value做加权。前面的token都生成完了不会再变了那它们的Key和Value其实可以缓存下来不用每次重新算。这个优化对算力的节省是数量级的。我用一张表直观对比一下指标无KV Cache有KV Cache计算量生成第n个tokenO(n²·d)O(n·d)生成N个token的总计算量O(N³·d)O(N²·d)实际观感越往后越卡生成速度全程平稳内存代价无额外存储需要存K/V矩阵约2×层数×头数×维度代价是显存占用变高这也引出了下一节的内容。5.3 量化用精度换显存和速度我最初在16G显存的卡上部署一个7B模型FP16权重占掉14G再加上KV Cache和中间激活值根本跑不动。后来改用INT8量化模型显存直接砍一半基本能跑了再狠一点做INT4量化模型只剩下不到4G甚至能在8G显存的卡上跑。量化原理不复杂把原本用16位浮点数表示的权重压缩成8位或4位整数同时记录每个张量的缩放系数和零点用的时候再还原。代价是精度损失。实测下来INT8对生成质量的影响很小回答逻辑基本没有明显变化INT4在一些常识推理任务上会掉几个百分点但换来的是部署门槛大幅下降。如果你做部署我建议按这个顺序选型先用FP16跑通再看显存余量决定量化档位。不要一上来就上INT4因为有些模型对量化非常敏感尤其在没有量化感知训练的情况下可能生成质量断崖式下跌。需要快速跑通就用bitsandbytes的8位加载需要进一步压缩就用GPTQ或AWQ它们都支持离线量化后保存权重部署时不增加加载时间。5.4 并发请求与流式输出一个能用的服务骨架部署服务的核心挑战是并发控制。大模型推理不像普通HTTP接口那样来一个请求算一个显存是一块硬约束。你开一个模型实例同时并发处理多个请求时每个请求都要一份独立的KV Cache并发数一高显存立刻爆掉。这也是为什么生产环境会用到vLLM这类带PagedAttention的推理框架——它把KV Cache像操作系统管理内存一样分页管理显存利用率能翻好几倍。如果只是自己搭个服务做测试FastAPI加流式输出就够用了。我给出一个最小的流式响应骨架from fastapi import FastAPI from fastapi.responses import StreamingResponse from transformers import AutoModelForCausalLM, AutoTokenizer import torch app FastAPI() model_name your_model_path tokenizer AutoTokenizer.from_pretrained(model_name) model AutoModelForCausalLM.from_pretrained(model_name, torch_dtypetorch.float16).cuda() app.post(/generate) async def generate(prompt: str, max_new_tokens: int 100): inputs tokenizer(prompt, return_tensorspt).to(cuda) async def stream(): with torch.inference_mode(): for _ in range(max_new_tokens): outputs model.generate(**inputs, max_new_tokens1, do_sampleTrue) new_token outputs[:, -1:] inputs {k: torch.cat([v, new_token], dim1) for k, v in inputs.items()} text tokenizer.decode(new_token[0], skip_special_tokensTrue) yield text # 每生成一个token就吐给前端 return StreamingResponse(stream(), media_typetext/plain)这个版本不做任何优化单机自测没问题但并发一高就抓瞎。生产环境请直接上vLLM它能做到几百并发吞吐而不崩。我自己的经验是测试用FastAPI手写生产一律用专业推理框架省下的精力能用来干更多有价值的事。6. 让模型会干活工具调用循环与Harness Engineering实践到这里为止模型都还只是文本生成器。你问它一个问题它写一段话但你没法让它真正去查数据库、调API、执行代码、根据报错修正自己。这也是为什么现在大家都在讨论Agent、AI Agent和Harness Engineering——让模型从会说变成会做工程上完全是另一套玩法。6.1 Harness Engineering到底在工程化什么Harness这个词原意是马具、安全带在AI工程里指的是给模型搭一套行为约束和执行脚手架。模型本身不知道如何调用工具不知道什么时候该终止循环也不能保证输出永远合法。Harness Engineering做的就是在模型外面包一层系统负责把模型的输出解析成结构化的工具调用指令执行工具把结果转化为模型可读的文本塞回上下文 -控制循环次数、超时、错误恢复记录每一步过程方便回放和评估打个比方模型是一个实习生它聪明但没经验经常胡言乱语。Harness就是带教导师给实习生一张明确的工单流程表规定每一步做什么、做完之后把结果写到哪个位置、出错之后该怎么办。实习生还是那个实习生但在流程约束下产出的结果远比自由发挥稳定。6.2 最小可用的工具调用循环我自己写过一个极简的工具调用循环核心逻辑不超过50行。大致流程是给模型一个系统提示里面描述了有哪些工具、每个工具的入参格式是什么模型输出一段JSON格式的调用意图系统解析JSON执行工具拿到结果然后把结果作为观察结果追加回上下文让模型决定下一步是继续调用还是给出最终回答。def agent_loop(prompt, max_iters5): messages [{role: system, content: SYSTEM_PROMPT}, {role: user, content: prompt}] for i in range(max_iters): response llm(messages) action parse_action(response) # 尝试解析JSON动作 if action is None: return response # 模型给出了最终回答循环结束 tool_name action[tool] tool_input action[input] result execute_tool(tool_name, tool_input) # 执行工具 messages.append({role: assistant, content: response}) messages.append({role: tool, content: f观测结果: {result}}) return 达到最大迭代次数任务终止这个实现看起来简单但生产级的Agent循环远不止这样。最大的坑是上下文爆炸。每一次工具调用都要把结果塞回去结果越积越多几个循环之后上下文窗口就满了模型开始失忆。我的处理方案有两种一是给每个工具结果做摘要只把摘要放回上下文二是用一个独立的记忆窗口只保留最近N轮的关键信息更早的压缩成一条历史摘要。另一个坑是死循环。模型在某个问题上反复调用同一个工具得到同样的结果然后继续调用。必须加最大迭代次数我一般设5-10轮并且允许在连续两次调用结果相同的情况下强制终止。6.3 工具Schema设计模型的说明书要怎么写工具定义写得清不清楚直接决定调用成功率。我总结出来的经验是每个工具的描述要交代清楚这个工具是什么什么情况下用它入参每个字段的含义而且最好附上一个调用示例。模型是在做模式匹配给它一个示例它照葫芦画瓢的成功率远超只给字段说明。我用JSON Schema来定义工具一个实际的例子长这样{ name: search_products, description: 根据关键词搜索商品列表返回商品名称、价格和销量。适合用户询问有没有某商品或推荐商品时使用。, parameters: { type: object, properties: { keyword: { type: string, description: 搜索关键词必须是用户问题中的核心名词 }, limit: { type: integer, description: 最多返回结果条数默认5, default: 5 } }, required: [keyword] }, example: {\keyword\: \无线耳机\, \limit\: 3} }这里最容易出错的地方是模型经常会把用户原话里的无用信息填进关键字段比如用户问帮我找一下那种便宜一点的无线耳机关键词可能被抽成便宜一点的无线耳机导致搜索没有结果。我的缓解办法是在描述里加一句只提取核心名词不要包含修饰词同时给一个正反例。在Harness层也可以做一步轻量清洗匹配预设的修饰词列表并剔除。6.4 多Agent协作架构比单Agent复杂一个量级单个Agent能力有限时可以考虑多Agent协作。我自己试过一种简单的路由-执行-审查架构一个Router Agent负责理解用户意图决定派发给哪个专业WorkerWorker负责实际执行任务Critic Agent负责检查Worker的输出质量不合格就退回重做。这种架构效果不错但工程复杂度也上来了。最头疼的是Agent之间的通信协议如果定义得不严格经常出现A输出的格式B解析不了。我的建议是通信消息用严格的结构化格式不要用自然语言自由发挥。比如统一用JSON字典传递{task_type: ..., input: ..., output: ...}。只要有一个字段对不上整个链路就断了。后来我在社区里看到有人提到Loop Engineering这个概念本质上就是把Agent的工作循环本身当作一个需要单独设计的工程对象循环的入口条件是什么、退出条件是什么、每一步的输入输出契约是什么、失败了怎么重试。这跟上面说的Harness Engineering是一体两面一个侧重约束环境一个侧重循环控制。不管叫法怎么变底层要解决的都是同一个问题如何让模型的自主行为变得可控、可预测、可评估。7. 不是能用而是可靠评估体系与可观测性建设模型能跑通、能调用工具之后我一度觉得自己已经毕业了。直到我把它放到真实业务场景里才发现最大的问题不是功能缺失而是行为不可预测。同一个问题昨天答得好好的今天换了个Prompt模板后质量直接崩了。你很难说清楚是哪次改动导致的行为回归因为整个链路是黑盒。这时候评估体系和可观测性就成了AI工程的质检部门和黑匣子。7.1 分层评估别用一个指标评价整个系统我一开始习惯只用一个指标——比如pass1、BLEU分数、ROUGE分数——来评价整个Agent系统结果发现根本没代表性。后来我把评估拆成三个层次任务级评估针对每一个单次调用看它是否正确理解了输入输出是否合法。这个层面可以用规则和分类器来做。比如工具调用是否输出了合法JSON搜索接口是否返回了200。组件级评估对模型本身做评测。直接用现成的评测集或者在业务数据上构造一批高质量的黄金测试集每次改模型版本、改Prompt模板时跑一遍。这里的核心是测试集要小但精我习惯维护200到500条覆盖不同场景的输入输出对每条都人工标注了标准答案和质量标准。系统级评估把整个Agent链路当成一个整体模拟真实用户的任务发起端到端请求检查最终任务的完成率。这个级别的测试数据要贴近生产最好是从真实日志里抽出来的脱敏样本。7.2 LLM-as-judge让模型当裁判有哪些坑在系统级评估里很多任务没有标准答案比如开放性问答、文本摘要、代码解释。我采取的方式是让一个更强的模型当裁判对输出打分。这叫LLM-as-judge。做法不复杂把待评文本和一套打分标准一起发给裁判模型让它按1到5分给分。这个方案省了大量人工但坑也不少。最典型的坑是位置偏差把答案A放前面答案B放后面裁判可能偏好前面那个换个顺序结果可能反过来了。我的绕坑办法是让裁判从不同顺序分别评两次取平均分。另一个坑是裁判模型的自我偏好它倾向于给自己的答案打高分跟自己的风格越像的得分越高。缓解办法是在评分标准里强制要求先列证据再给分数降低一眼定生死的概率。7.3 可观测性AI系统必须留下黑匣子线上系统一旦出问题如果你只有最终返回结果排错会非常痛苦。Agent系统的排错难度远高于传统后端因为中间夹着模型输出、工具调用、上下文拼接这一大堆环节。所以从第一天起我就要求自己的系统记录完整的trace日志。我的结构化日志格式大致长这样{ trace_id: 8f3a2c1e, timestamp: 2025-04-10T14:32:01Z, task: 查找某产品的售后电话, prompt_used: 系统提示词v3.4, steps: [ {role: user, content: ...}, {role: assistant, content: ..., tool_call: search_products}, {role: tool, content: 返回结果...} ], latency_ms: 2310, total_tokens: 1024, cost_usd: 0.003 }有一个重要指标是每次会话的成本。模型调用是按量计费的Agent的多轮循环会把成本放大好几倍。当初我设计的Agent平均每任务要调用8次模型单次成本不高累积起来一天下来数量相当可观。记录成本之后你再做优化就有了数据依据——比如减少不必要的工具调用、给结果做摘要缩短上下文都能在日志里直接看到收益。另一个容易被忽略的事情是回归测试。每改一次Prompt、每升级一次模型都要把那条黄金测试集跑一遍对比指标有没有下降。我吃过一次大亏优化了Agent的提示词看起来单次任务的效果变好了但整体流程的失败率反而上升了原因是新提示词在某个中间环节引入了歧义。如果没有及时的回归测试这种问题要等到线上用户投诉才知道。8. 我个人亲测有效的八周推进路线和避坑清单最后这部分我把前面所有内容压缩成一张可以照着执行的路线图。这不是什么通用的标准答案而是我自己踩过无数坑之后总结出来的、被验证过能走通的路径。如果你决定自己从零开始做一遍可以参考这个节奏。8.1 八周推进路线阶段周期核心任务验收标准第1周环境与数据搭好Python/PyTorch环境准备好数据清洗管线能对1GB原始语料完成清洗、分词、生成训练样本第2周模型结构手写Transformer核心组件并跑通前向能正确处理不同batch和序列长度的输入第3-4周训练循环训一个10M-30M参数的微型GPT模型能生成语法通顺的短句loss持续下降第5周推理优化实现KV Cache接入流式输出生成速度至少提升一半服务能处理并发请求第6周量化部署完成量化部署到目标显卡显存占用降低到目标范围生成质量可接受第7周Agent工具调用实现最小工具调用循环模型能调用至少3个工具并完成任务第8周评估监控建立黄金测试集和trace日志每次修改后能跑回归测试并输出对比报告这个节奏适合每天能拿出2到4小时的人。如果你连着学往前赶两周也不是不可能但我还是建议每阶段都留出缓冲时间因为AI工程经常出现看起来简单一动手全是意外的情况。8.2 我从坑里爬出来的几条关键心得第一不要一上来就追求最好的模型架构。我见过有人花一个月时间研究最新的MoE、稀疏注意力、各种新奇的归一化方式结果模型还没跑起来过。先把最基础的Transformer吃透后面所有变体都是在这个骨架上做文章。第二实验记录一定要做而且要从第一天开始。不用上什么花哨的框架一个Markdown文件或者电子表格就够了。我自己的习惯是每次实验记录五件事模型配置、数据来源与配比、训练超参数、最终指标、以及这次学到了什么。第三改一行代码前先想清楚怎么验证。很多新手习惯性改了Prompt或者模型配置然后肉眼看一下几个case就下结论效果变好了。正确的做法是跑一遍黄金测试集拿数据说话。在没有评估体系的情况下优化系统约等于闭着眼睛开车。第四如果卡住了把问题拆小。我从零复现Transformer时卡过很多次最难的一次是训练loss完全不下降。后来我花了整整一个晚上把问题缩小到输入输出维度是否匹配才终于找出原因标签序列偏移了一位。调试AI系统和调试传统代码没有本质区别都是不停地二分缩小问题范围。我写这篇东西的时候那个名为ai-engineering-from-scratch的项目还在持续推进中。不同之处在于我对自己正在做的事情有了完全不同的理解——从最开始面对一堆抽象概念的茫然到现在能看着模型自己调用工具跑完一个任务整个过程给我最大的收获不是技术本身而是建立了一种什么问题都能从第一性原理开始拆解的信心。这个思路你也能用从最小的模型开始把每个环节亲手做一遍一点一点补齐整个地图。如果你也在这条路上走着希望你从这篇分享里找到能用的东西。