图解计算机基础系列


























很多人第一次接触 Rust,会把注意力放在所有权、生命周期、Result、trait 这些语言特性上。
但如果只停在这里,其实只学到了 Rust 的“语法层”。
Rust 真正厉害的地方,是你可以把很多业务约束直接编码进类型系统里,让一部分错误根本写不出来。
这篇文章想讨论一个非常实用的问题:
如果我们要写一个服务器诊断平台,怎么用 Rust 的类型系统,把认证、会话状态、命令响应、数据校验、寄存器宽度这些规则都放进编译期?
这正是微软那本 Type-Driven Correctness in Rust 在第 10 章做的事情。它把前面几章分散的模式组合起来,搭出了一个完整的诊断工作流:
看起来像很多技巧,但它们放在一起,目标其实只有一个:
让错误的使用方式在编译阶段就被排除。
假设我们在做一个 IPMI 风格的诊断模块,最常见的问题大概有这些:
f64,结果混着传如果这些约束全靠注释、文档、或者 if 判断来维护,系统一大就会开始漏。
所以更好的办法是:把正确流程写进类型里。
很多协议代码会写成这样:
1 | fn send_command(cmd: &[u8]) -> io::Result<Vec<u8>> |
这种设计的问题是,命令和响应之间没有类型关联。
调用者必须“自己记住”某个命令返回的到底是温度、风扇转速,还是电压。
我们换一种方式,把命令建模成 trait:
1 | use std::io; |
这里最关键的是这句:
1 | type Response; |
它把“这个命令对应什么返回值”直接绑定到了类型层。
比如读取温度:
1 | pub struct ReadTemp { |
再比如读取风扇转速:
1 | pub struct ReadFanSpeed { |
这样设计之后,调用方拿到的类型是编译器推出来的,而不是靠人脑记忆。
很多系统喜欢这么写权限控制:
1 | fn activate_session(is_admin: bool) -> Result<(), Error> |
问题是 bool 太弱了。
它没有任何上下文,也不能证明这个权限是怎么来的。
我们可以把“管理员权限”变成一个能力令牌:
1 | pub struct AdminToken { |
AdminToken 不能随便构造,只能通过 authenticate() 拿到。
这就意味着后续 API 可以直接要求它:
1 | fn do_sensitive_thing(_admin: &AdminToken) { |
如果手里没有 AdminToken,这段代码连调用资格都没有。
协议会话天然就是状态机。
比如一个 IPMI 会话至少会经历:
IdleActive如果你把所有操作都挂在一个 Session 上,然后运行时判断当前状态,代码很容易写出非法调用顺序。
更好的做法是:把状态放进类型参数。
1 | use std::marker::PhantomData; |
连接之后先得到 Session<Idle>:
1 | impl Session<Idle> { |
真正执行命令的方法,只存在于 Session<Active> 上:
1 | impl Session<Active> { |
这意味着下面这种错误在编译期就会被挡住:
1 | let mut session = Session::<Idle>::connect("192.168.1.100"); |
因为 execute() 根本不属于 Session<Idle>。
很多监控/诊断系统里最危险的一类 bug,不是崩溃,而是数值语义混淆。
比如:
如果全都用 f64,编译器根本分不清。
我们用简单的 newtype 包起来:
1 | #[derive(Debug, Clone, Copy, PartialEq, PartialOrd)] |
这看起来只是多包了一层,但编译器会立刻帮你防住类型串线:
1 | let temp: Celsius = session.execute(&ReadTemp { sensor_id: 0 })?; |
这就是“让单位进入类型系统”的价值。
有些资源天生就应该“一次性使用”。
比如一个审计事件 token,要求每次诊断只写入一次,不允许复用。
Rust 的 move 语义特别适合表达这种约束:
1 | pub struct AuditToken { |
注意 log(self, ...) 会消费掉 self。
所以一旦调用完成,这个 token 就不能再用了:
1 | let audit = AuditToken::issue(1001); |
这种“只能发生一次”的约束,不需要额外 runtime flag,所有权系统天然就能表达。
FRU、网络包、磁盘块、用户输入,这些外部数据都不应该直接进业务逻辑。
更好的模式是:
原始数据先校验,校验通过后才变成领域对象。
比如一个简化版 FRU:
1 | pub struct ValidFru { |
调用方式也很清楚:
1 | let raw_fru = vec![0x01, 0x00, 0x00, 0x01, 0x01, 0x00, 0x00, 0xFD]; |
重点不在于这个 demo 校验有多复杂,而在于:
后续代码拿到的是 ValidFru,不是随便一段 &[u8]。
也就是说,“校验过没有”不再靠注释,而是靠类型表达。
有些信息不会体现在结构体字段里,但对 API 正确性至关重要。
寄存器宽度就是一个很典型的例子。
我们可以这样建模:
1 | pub struct Width16; |
Width16 不占运行时空间,但它告诉编译器:
这个寄存器就是 16 位的,所以 read() 的返回值应该是 u16。
把它放进设备模型里:
1 | pub struct PcieDev { |
这样读取时就不会“忘了这个寄存器本来多宽”。
前面每个点单独看都不算复杂,真正有意思的是它们组合起来之后会变成什么样。
下面是一个简化后的完整流程:
1 | use std::io; |
这段代码最有意思的地方,不是“它能跑”,而是它在编译期已经帮我们排除了很多非法情况。
把上面的设计拆开看,编译器至少能帮我们兜住这几类错误:
| 错误类型 | 怎么被防住 | 对应模式 |
|---|---|---|
| 未认证就激活会话 | activate() 必须拿 &AdminToken |
capability token |
| 会话状态不对就发命令 | execute() 只存在于 Session<Active> |
typestate |
| 命令返回值写错类型 | IpmiCmd::Response 绑定响应类型 |
typed command |
| 温度/转速/电压混用 | Celsius、Rpm、Volts 是不同类型 |
dimensional type |
| 寄存器宽度读错 | Reg<Width16> 只能读出 u16 |
phantom type |
| 未校验原始数据直接使用 | 先 parse() 才能得到 ValidFru |
validated boundary |
| 审计 token 被复用 | log(self, ...) 消费所有权 |
single-use type |
这也是这类设计最打动人的地方:
不是“多写了几层抽象”,而是把一堆原本只能靠测试和经验发现的问题,前移到了编译期。
这种写法当然不是零成本的。
你需要多定义一些类型,多思考状态边界,也要接受一开始 API 看起来会比“直接传 u8 和 Vec<u8>”更啰嗦。
但它带来的收益非常适合基础设施和协议类系统:
如果你的系统有这些特点,这种设计会特别值:
很多文章会把 Rust 的优势概括成“内存安全”。
但对于工程系统来说,更实用的一层是:
Rust 可以让你把业务规则、协议约束、单位语义和权限边界,一起编码进类型系统。

大语言模型常常给人一种“高深且遥远”的印象:Transformer、注意力机制、交叉熵、采样策略,这些名词堆叠在一起,很容易让人望而却步。但如果换一种视角,不从抽象概念出发,而是沿着一份可以运行的代码,一层一层往下拆,事情就会清晰很多。
这篇文章的目标,就是用工程化的方式回答一个问题:一个 GPT 风格的大语言模型,到底是怎么从零搭起来的?
这里讨论的不是工业级超大模型的分布式训练,而是一套约 500 行代码的最小可行实现。它足够小,能让我们看清每个模块的职责;也足够完整,涵盖了数据准备、模型结构、训练过程和文本生成这四个关键环节。通过这条主线,我们不仅能理解“大模型由哪些部分组成”,更能明白“这些部分为什么必须存在”。
任何语言模型的起点都不是神经网络,而是文本数据。模型并不能直接理解字符串,它首先需要把自然语言变成可计算的数值形式。
从工程角度看,数据集通常会先划分为训练集和验证集。训练集用于更新模型参数,验证集用于检查模型的泛化能力。
这背后的逻辑并不复杂。训练集相当于“练习题”,模型会不断根据它调整自己;验证集相当于“模拟考试”,用于判断模型是否真的学到了规律,而不是仅仅把训练数据背了下来。如果训练损失一直下降,但验证损失开始上升,通常就意味着模型正在过拟合。
在实现上,这一步往往只是把原始文本按比例切开,例如 90% 用于训练,10% 用于验证。虽然简单,但它决定了后续训练是否具有可解释性。
在 PyTorch 里,数据处理通常由 Dataset 和 DataLoader 配合完成。
Dataset 回答的是“数据是什么”。它负责定义一条样本怎么取、数据集有多大。DataLoader 回答的是“数据怎么喂给模型”。它负责批量化、打乱顺序、多进程加载等工程问题。
这套设计很重要,因为模型训练并不是一次吃下一整本书,而是一批一批地读。训练时的数据顺序通常会被打乱,以避免模型记住样本顺序本身;而验证时则保持顺序稳定,确保评估结果可比较、可复现。
模型无法直接处理句子,必须先把文本切成 token,再把 token 映射为整数 ID。这个过程就是词元化(tokenization)。
其本质是一次“语言到数字”的映射。比如一句文本,会先被拆成词、子词、标点等更小单元,然后每个单元对应词表中的一个整数。后续模型看到的,其实只是一个 ID 序列。
这里一个关键点在于:现代 LLM 通常不会直接以“整词”为最小单位,而是采用 BPE 之类的子词方法。这样做的好处是,即便遇到词表中从未出现过的新词,分词器也能把它拆成更小的已知片段,从而避免真正意义上的“未知词”。
语言模型的核心任务只有一句话:根据上文预测下一个 token。
如果我们已经把整段文本变成了 token 序列,那么训练样本就可以通过滑动窗口自动构造出来:
例如输入是 [t1, t2, t3, t4],目标就是 [t2, t3, t4, t5]。
这样模型在每个位置上都在做同一件事:根据前面的 token,猜当前正确答案是什么。
这一步非常关键,因为它把“阅读原始文本”转化成了一个标准的监督学习问题。
当数据准备好之后,真正的模型部分才开始登场。一个 GPT 风格模型可以粗略分成三层:
输入给模型的虽然是 token ID,但 ID 本身没有语义,也不携带顺序信息。因此,第一步必须做嵌入。
词嵌入(token embedding)负责把每个离散 ID 映射成一个连续向量。这个向量是可训练的,在训练过程中,模型会逐步把语义相近的 token 拉近,把语义不同的 token 拉远。
但只有词嵌入还不够,因为注意力机制天然不理解顺序。对于模型来说,“人咬狗”和“狗咬人”如果只看词集合,几乎没有区别。因此还必须引入位置嵌入(positional embedding),把“这个 token 出现在第几个位置”也编码进去。
最终,模型真正接收到的输入,是词嵌入和位置嵌入的相加结果。这样每个位置上的向量同时带有“它是谁”和“它在哪”的信息。
GPT 的主体是一堆 Transformer Block 的堆叠。每个 Block 内部通常包含三类关键组件:
这几个部分共同决定了模型是否既能理解上下文,又能稳定训练。
自注意力机制是 LLM 的灵魂。它解决的核心问题是:当前这个 token,到底应该关注上下文里的哪些 token?
可以把自注意力理解成三步:
最后得到的新向量,不再只是“当前词自身的表示”,而是“当前词在整个上下文中的表示”。
这一步的意义极大。因为模型终于不再是线性读句子,而是可以在任意位置直接回看整段上下文,动态决定哪里更重要。
如果直接拿原始嵌入向量做相似度计算,会有一个问题:同一个向量既要代表词义,又要参与注意力打分,职责混在一起,不够灵活。
于是 Transformer 引入了三组可训练变换:
这样注意力就从“直接比向量”升级成了“学习如何比、学习该关注谁”。Q、K、V 的存在,让注意力机制本身也变成了可训练模块,而不只是固定公式。
标准自注意力会看到整个序列,但 GPT 是自回归模型,在预测下一个词时不能看到未来词,否则就等于作弊。
解决办法是加入因果掩码。做法通常是在注意力分数矩阵上盖一个上三角 mask,把当前位置之后的分数全部变成极小值。经过 softmax 后,这些位置的概率就会接近 0。
这样一来,模型在第 i 个位置上,只能利用 1...i 的信息,不能利用 i+1 之后的内容。这正是 GPT 能用于文本生成的根本前提。
单头注意力只有一种“看问题的方式”。但语言是复杂的,有时要关注语法关系,有时要关注语义关联,有时要关注长距离依赖。
多头注意力的思路,就是并行跑多套注意力计算。每个头都有独立的 Q、K、V 变换,它们会在不同表示子空间里学习不同的关注模式。最后再把多个头的结果拼接起来,统一投影回模型维度。
这相当于让模型拥有多组“观察员”,从不同角度同时审视一句话,再把各自的发现汇总起来。
注意力机制很强,但一个 Transformer Block 并不只有注意力。
深度网络一旦堆得很深,就容易出现数值分布不断漂移的问题。某些层输出过大,某些层输出过小,最终导致训练不稳定,甚至梯度爆炸或梯度消失。
LayerNorm 的作用,就是在特征维度上对每个样本做标准化,让输入保持在一个更稳定的分布上。这样后续模块看到的数据范围更可控,训练也更容易收敛。
更重要的是,LayerNorm 后面通常还会带上可学习的缩放和偏置参数。这意味着它不是死板地“强制统一”,而是在稳定数值的基础上,仍允许模型学回自己想要的分布。
注意力更像是“信息整合器”,负责从上下文中取回相关内容;前馈网络则更像“局部加工器”,负责对已经整合好的信息做更复杂的非线性变换。
典型实现是两层线性层加一个 GELU 激活,中间先升维,再降维。这个结构为模型提供了更强的表达能力,使它不只是会“找关系”,还能对关系进行深度加工。
如果每经过一层都完全重写表示,原始信息很容易在深层传播中被冲淡;同时,反向传播时梯度也容易逐层衰减。
残差连接的做法非常直接:把输入直接绕过当前模块,加回输出上。
这相当于给网络修了一条高速公路:
正因为有残差连接,Transformer 才能稳定堆叠很多层,而不至于“越深越学不动”。
经过多层 Transformer Block 处理后,模型得到的是一串上下文化的隐藏向量。但这些向量仍然只是内部表示,不能直接变成文字。
所以最后还需要一个输出投影层,把每个位置上的隐藏向量映射回词表大小的维度。
如果词表大小是 50,257,那么每个位置最终都会得到一个长度为 50,257 的向量,里面每个数都对应一个 token 的原始置信度分数,也就是 logits。
再对 logits 做 softmax,就能得到“下一个 token 是谁”的概率分布。
至此,模型才真正完成了一次“预测”。
刚初始化出来的 GPT 结构虽然完整,但参数全是随机的,输出自然也是随机噪声。模型之所以能学会语言,靠的是训练循环。
每一个 batch 的训练,本质上都在重复以下过程:
这个过程反复执行,模型就会逐渐学会:在什么样的上下文后面,什么 token 更可能出现。
语言模型的每个位置,其实都可以看成一个多分类任务:
在整个词表中,正确答案只有一个,模型需要给它尽量高的概率。
交叉熵损失正适合做这件事。它本质上是在惩罚模型“没有把正确答案的概率抬高”。如果模型对正确 token 非常自信,损失就小;如果模型把概率分配给了错误 token,损失就大。
因此,最小化交叉熵的过程,实际上就是在逼迫模型学会构造更接近真实语言分布的预测。
训练时不能只盯着一个数字看。训练损失下降,只说明模型越来越会拟合训练数据;验证损失下降,才说明模型学到的是可迁移的规律。
一个健康的训练过程通常表现为:
如果后者停止下降甚至反弹,就要警惕过拟合。这也是为什么训练集和验证集的划分不是形式主义,而是整个训练监控体系的一部分。
模型学会预测下一个 token 后,就可以进入生成阶段了。生成的基本流程很简单:
但“怎么选下一个 token”其实很讲究。
最简单的方法是每次都选概率最大的 token,也就是贪婪解码。
它的问题也很明显:
对于开放式文本生成任务,这种策略通常会让输出显得机械又无聊。
温度本质上是在 softmax 前调整 logits 的尖锐程度。
温度不是决定“对错”,而是决定“敢不敢冒险”。它直接影响生成文本的稳定性与创造性。
即便做了温度调整,词表仍然太大,很多低概率词虽然没被排除,但其实并不合理。Top-k 采样的思路就是:只保留概率最高的 k 个 token,然后在这 k 个里面按概率随机抽样。
这样既避免了完全放飞,也避免了过度死板,通常能在连贯性和多样性之间取得更平衡的效果。
实际系统中还常结合 Top-p 等更动态的采样策略,本质上都是在回答同一个问题:如何既让模型说得通,又让它别太无聊。
从工程视角回看,一个 GPT 风格的大语言模型其实可以拆解成一套非常清晰的流水线:
真正有价值的,不是“会背 Transformer 结构图”,而是理解这条链路上每个模块分别解决了什么问题。
为什么必须先做 tokenization?
为什么位置编码不可少?
为什么 GPT 一定要做 causal mask?
为什么没有残差连接,深层网络会训练困难?
为什么生成阶段不能只靠 argmax?
当这些问题都能从实现角度讲清楚时,我们对大语言模型的理解,才算真正从“知道名词”进入“理解机制”。
大语言模型并不是一个无法触碰的黑盒。把它还原成代码之后,你会发现它本质上是很多经典工程思想的组合:数据管道、矩阵运算、可微优化、模块堆叠、概率采样。
所谓“从零构建 LLM”的意义,也不在于真的用 500 行代码复刻工业级模型,而在于通过一个最小实现,把复杂系统拆成一组可以被逐个理解、逐个验证的子问题。理解了这条主线,再去看更大的模型、更复杂的训练框架,很多东西都会顺理成章。
分治并不天然正确。
很多时候,我们一提到“拆分”,就会本能地觉得这是在优化架构、降低复杂度。但事实恰恰相反:分治最大的风险,不是没有拆,而是拆错了边界。一个错误的拆分,会把原本内聚的整体强行打散,结果不是降低复杂度,而是引入更多耦合、更多协调成本、更多隐式依赖,最终让系统变得更难理解、更难修改、更难测试。
所以,真正重要的问题从来不是“要不要分”,而是:
应该按什么边界来分?
我个人目前比较认可,可以从两个层级来理解这个问题:
这两个概念看起来属于不同尺度,但它们背后的思想其实非常一致:分,不是为了把东西切碎;分,是为了把复杂度约束在局部。
在代码层面,我们面对的最大问题是什么?
不是“代码长不长”,也不是“函数优不优雅”,而是变更。
一个软件生命周期里最大的成本,往往不是第一次把功能写出来,而是后续不断地响应需求变化、修 bug、扩展逻辑、兼容新场景。维护成本的本质,就是应对变化的成本。
Robert C. Martin 对 SRP 的经典表述是:
A class should have only one reason to change.
也就是:
一个类,应该只有一个变化的理由。
很多人第一次接触 SRP 时,会把它理解成“一个类只做一件事”。这句话不能说错,但太模糊。真正更有操作性的判断方式其实是:
如果两个逻辑会因为不同的人、不同部门、不同规则变化而分别修改,那它们就不该耦合在一起。
举个经典例子。假设我们有一个 Employee 类型,它同时负责:
那么这三个职责其实分别对应三种完全不同的变化来源:
如果它们被塞进同一个类里,会发生什么?
这就是复杂度滋生的起点。
在 Rust 里,我们通常不会把所有行为都塞进一个“大对象”里,而是更倾向于把能力拆成清晰的结构体和 trait。
1 | #[derive(Debug, Clone)] |
这里的拆分重点不在于“代码看起来更工整”,而在于:
PayrollCalculator 只会因为薪酬规则变化而变化SqlEmployeeRepository 只会因为存储策略变化而变化EmployeeReporter 只会因为报表格式变化而变化也就是说,SRP 的边界,不是功能名词的边界,而是变化来源的边界。
到了系统层面,尤其是大型业务系统里,复杂度的来源就不只是“代码如何组织”了。
这时候更大的问题是:
同一个词,在不同业务里,到底是不是同一个东西?
比如“客户(Customer)”这个词:
如果我们强行建立一个“统一 Customer 大模型”,试图让一个模型覆盖所有场景,结果通常会是:
这时候问题已经不是代码优不优雅,而是语义冲突。
DDD 里的限界上下文(Bounded Context)就是为了解决这个问题。
在不同上下文里,可以允许同一个概念拥有不同模型。
比如:
SalesCustomerSupportCustomerBillingCustomer它们名字都带着“Customer”,但语义不同、关注点不同、规则不同,所以本来就不该硬揉成一个模型。
如果用 Rust 表达这个思想,可以很自然地把它们设计成不同类型:
1 | #[derive(Debug)] |
这三个结构体看起来“重复”,但这种重复并不是坏事。
因为它们表达的是:
限界上下文的价值,不是复用模型,而是避免错误复用模型。
这是很多系统设计里最容易被忽视的一点。
我过去在游戏业务里接触过一种非常典型的“巨型结算接口”。
这个接口要处理很多事情:
如果你把这些逻辑全部塞在一个接口里,代码通常会长成这样:
1 | pub fn settle(req: SettleRequest) -> Result<SettleResponse, String> { |
这种代码的问题,不只是“长”,而是它混杂了多个抽象层级:
很多人会进一步做一个“表面拆分”:
1 | pub fn process_game_mode_1(ctx: &SettlementContext) -> Result<SettlementResult, String> { |
这看起来好像变好了,但实际上并没有真正治理复杂度。
因为这些 step1/step2/step3 往往只是把原来的大段代码搬到了不同函数里,而没有改变依赖关系和职责结构。它依然存在几个根本问题:
换句话说,这种拆分实现了“分”,但没有实现“治”。
我现在越来越倾向于用下面四个维度来判断一个拆分到底有没有价值:
好的架构不是消灭复杂度,因为业务本来就复杂。
好的架构只是做到一件事:
让复杂度被约束在局部,每个局部都能被人稳定地理解、修改、测试和复用。
下面用 Rust 的方式来表达这个思路。
我们先把流程的输入和输出抽出来,避免每个处理器都各自操作一堆零散参数。
1 | use std::collections::HashMap; |
这一层的意义在于:
SettlementContext 表达流程输入SettlementResult 表达流程累积产物SettleResponse 表达最终对外返回结果这样,业务处理单元之间共享的是明确的流程模型,而不是散乱参数。
接下来定义统一的处理器接口。
1 | pub trait Processor { |
这个 trait 很关键。它表达的是:
接着我们定义计分职责。
1 | pub trait ScoreCalculator { |
这段代码体现的是认知治理。
当你阅读 ScoreCalculationProcessor 时,不需要理解:
你只需要理解一件事:
输入玩家分数,输出基础奖励。
这就是“可独立理解”。
1 | pub trait MasterApprenticeService { |
这段代码体现的是演化治理。
如果未来产品说:
那么你修改的边界非常明确:
MasterApprenticeServiceMasterApprenticeProcessor而不需要在一大段结算总流程里艰难定位一段逻辑。
这就是“可独立修改”。
1 | pub struct SettlementPipeline { |
SettlementPipeline 的职责非常纯粹:
它并不关心每个处理器内部细节。
这种设计带来的好处是,流程控制和业务实现分离了。编排归编排,规则归规则。
这就是“组合治理”的基础。
1 | pub struct SettlementPipelineFactory; |
这里体现的是很关键的一点:
新增模式,不一定意味着修改原有主流程;更理想的方式是新增一种组合。
比如:
Mode1 有基础计分 + 师徒加成Mode2 只有基础计分Mode3 可以是基础计分 + 活动奖励 + 排行榜结算你做的是装配不同 pipeline,而不是不断在主流程里堆 if else。
1 | pub struct Api { |
这一层就非常清晰了:
此时 Api::settle 已经不是一个“巨石方法”,而是一个真正的入口编排器。
因为它不只是把代码切开了,而是让每一部分都具备独立治理能力。
你看 ScoreCalculationProcessor,知道它只负责算基础奖励。
你看 MasterApprenticeProcessor,知道它只负责师徒加成。
你不需要读完整个结算流程,才能理解局部逻辑。
改师徒系统,不需要冒险碰计分逻辑。
改活动奖励,不需要去翻数据库落库逻辑。
每种变化都有明确落点。
测试也会变得非常自然。
比如测试计分逻辑时,只关心输入分数和输出奖励:
1 | #[cfg(test)] |
你会发现,这种测试的特点是:
这就是“可独立验证”。
新增一个模式时,你大概率只需要:
而不是冲进那个几百行主函数里再追加一个大分支。
这就是“可灵活组合”。
看到这里其实会发现,SRP 和限界上下文虽然一个偏代码、一个偏系统,但它们都在回答同一个根本问题:
边界该怎么画,才能让复杂度停留在局部,而不向全局扩散?
它们的区别只是作用层级不同。
SRP 关注的是:
限界上下文关注的是:
所以可以这样理解:
两者本质上都是在做一件事:
把复杂系统切成高内聚、低耦合、局部可治理的单元。
我现在越来越觉得,架构设计里最重要的能力,不是“会不会拆”,而是“知不知道什么不该拆”。
因为错误的拆分,往往比不拆更糟。
所以,真正有价值的分治,不是为了让代码看起来更模块化,也不是为了迎合某种架构风格,而是为了让复杂度在局部被稳定控制住。
如果一个拆分最终没有带来这些收益:
那它很可能只是“看起来分了”,其实并没有真正治理复杂度。
而我理解中的“真正的分治”,恰恰就在这里:
不是把大问题切碎,而是把每一块都切成可以独立治理的单元。

这个阶段是离线准备的,就像考试前你需要先整理好复习资料。
这个阶段是实时发生的,用户开始提问了。
资料找到了,现在要让大模型开始写作业了。
为了加深记忆,你可以用这句顺口溜记住整张图:
“切碎资料变向量(索引),问题比对找前K(检索),拼进Prompt喂模型(生成)。”

1 | use std::marker::PhantomData; |
这段代码展示了 Rust 中一种非常高级且强大的设计模式,叫做 Typestate Pattern(类型状态模式)。
它的核心意思是:利用 Rust 的编译检查,确保你永远不会在错误的状态下调用错误的函数。
在普通的编程语言中(比如 C++ 或 Java),你可能会写这样的代码:
1 | struct Socket { |
但在 Rust 代码里,如果你试图在断开连接时调用 send(),程序根本编译不通过。 编译器会直接告诉你:”对不起,Socket<Disconnected> 这个类型没有 send 方法”。
1 | struct Disconnected; // 状态 A:断开 |
这两个结构体没有任何字段,它们只是”标签”。
1 | struct Socket<State> { |
PhantomData 就像一个占位符,它不占内存,只在编译阶段起作用,标记当前的 State 是什么。
这是最关键的地方:方法是分家写的。
只有 Socket<Disconnected> 才有 connect 方法:
1 | impl Socket<Disconnected> { |
注意:connect 会消耗掉原来的自己(self),返回一个全新的类型 Socket<Connected>。
只有 Socket<Connected> 才有 send 和 disconnect 方法:
1 | impl Socket<Connected> { |
如果你这么写:
1 | let socket = Socket { fd: 1, _state: PhantomData }; // 默认是 Disconnected |
PhantomData 和泛型状态只存在于编译期,编译出来的机器码里没有任何多余的 if-else 判断,性能极高。send 方法。一句话总结:它把”逻辑错误”变成了”语法错误”。
1 | use std::io; |
这段代码展示了 Rust 中一个非常核心的设计哲学:通过类型系统确保协议正确性(Protocol Correctness),即所谓的”使非法状态不可表达“(Make invalid interactions unrepresentable)。
在底层协议(如 IPMI、NVMe)中,你发送一个特定的命令(Request),硬件会返回一段二进制数据。传统做法通常是手动解析这些字节,但这容易出错:你可能会不小心用解析”转速”的代码去解析”温度”数据。
这段代码通过 Rust 的 关联类型(Associated Types) 在编译阶段解决了这个问题。
1 | trait IpmiCmd { |
type Response; 是关键。它告诉编译器:任何实现了 IpmiCmd 的类型,都必须明确指定它返回什么类型的数据。1 | struct ReadTemp { sensor_id: u8 } |
ReadTemp 结构体与 Celsius(摄氏度)类型硬绑定。parse_response 里返回一个 Rpm(转速)类型,编译器会直接报错。1 | fn execute<C: IpmiCmd>(cmd: &C, raw: &[u8]) -> io::Result<C::Response> { |
C::Response。ReadTemp,函数返回值的类型在编译时就被确定为 Celsius。“ReadTemp always returns Celsius — can’t accidentally get Rpm”
(ReadTemp 总是返回 Celsius —— 不会意外地得到 Rpm)
data = execute(READ_TEMP_CMD); rpm = parse_as_rpm(data); 这样的代码。这在逻辑上是错的,但编译器不会阻止你。let res: Rpm = execute(&read_temp_cmd, raw);,代码根本无法通过编译。因为 ReadTemp 的 Response 类型是 Celsius,它与 Rpm 类型不匹配。例子中提到的 IPMI, Redfish, NVMe Admin commands 都是复杂的硬件管理协议。
这段代码演示了如何利用 Rust 的 Trait 和 Associated Types 来建模复杂的交互协议。它将”运行时的协议逻辑错误”转换成了”编译时的类型错误”,从而保证了程序的绝对健壮性。
核心思想:把”逻辑错误”变成”语法错误”。

在 Rust 中实现”依赖倒置”(Dependency Inversion)非常自然。Rust 使用 Trait 来定义契约,并利用 泛型(Generics) 或 Trait 对象(Trait Objects) 来注入依赖。
首先定义业务对象和它需要的操作接口。
1 | #[derive(Debug, Clone)] |
在 Rust 中,我们通常使用泛型来实现这种注入。这在编译时就会确定类型,性能极高。
1 | // 2. "不变" 的业务逻辑 |
有了抽象,测试业务逻辑就变得非常简单,不需要启动任何数据库。
1 | #[cfg(test)] |
在生产代码中,你才去实现真正的数据库逻辑:
1 | struct SqliteRepository { |
我们延续之前的例子,展示如何在不改动 OrderService 的情况下,像”套娃”一样把 Redis 功能套上去。
1 | use async_trait::async_trait; |
1 | pub struct SqliteRepo; |
重点: 它也实现 OrderRepository,但它内部持有另一个实现了该 Trait 的类型。
1 | pub struct CachedOrderRepo<T: OrderRepository> { |
注意: 这里的代码和加缓存之前完全一模一样。
1 | pub struct OrderService<R: OrderRepository> { |
这是见证奇迹的时刻:你通过改变对象的组合方式,改变了系统的行为,而没有修改业务逻辑代码。
1 | #[tokio::main] |
零成本抽象 (Zero-cost Abstraction):
如果你使用泛型,Rust 编译器在编译时会进行”单态化”(Monomorphization)。它会直接生成一套专门调用 Sqlite 后接 Redis 的机器码。没有运行时的虚函数表查找开销,性能等同于你手写的硬编码代码。
职责单一 (SRP):
OrderService: 只管业务逻辑(价格对不对)。SqliteRepo: 只管 SQL 怎么写。CachedOrderRepo: 只管 Redis 怎么存。KafkaOrderRepo 并在 main 函数里再套一层。极度好写测试:
OrderService?给它塞一个 MockRepo(不连 DB,不连 Redis)。CachedOrderRepo 的缓存逻辑?给它塞一个 MockRepo 作为 inner。符合开闭原则 (OCP):
软件实体应对扩展开放,对修改关闭。你通过增加新类实现了新功能,而不是通过修改旧代码实现新功能。
这就是装饰器模式最迷人的地方。我们可以非常轻松地再加一层”重试”逻辑,而无需触碰现有的 OrderService、SqliteRepo 或 CachedOrderRepo 的任何代码。
1 | pub struct RetryOrderRepo<T: OrderRepository> { |
现在的组装顺序是:Service -> Retry (重试) -> Cache (缓存) -> Sqlite (数据库)
1 | #[tokio::main] |
Retry 包裹 Cache (Retry(Cache(DB))):
Cache 包裹 Retry (Cache(Retry(DB))):
Logging 包裹一切 (Log(Retry(Cache(DB)))):
这个例子展示了架构设计中极其重要的 “防腐层”(Anti-Corruption Layer, ACL) 概念。
1 | use async_trait::async_trait; |
1 | // 实现 A:腾讯云 (生产环境) |
1 | #[tokio::main] |
如果没有这个 Trait,你写单元测试时必须处理腾讯云 SDK 的初始化、网络连接等。
有了 Trait,你在测试文件里写一个 struct TestSms,在 send 方法里直接 return Ok(())。你的测试运行速度会从秒级降至毫秒级,且不消耗任何短信资费。
腾讯云 SDK 的 API 设计可能很古怪。如果你直接在 UserService 里用,你的业务逻辑就被腾讯的 API 风格”污染”了。
phone, code),墙外是肮脏的第三方 SDK 细节。AliyunSms 实现类,业务代码一行都不用动。你可以利用 Rust 的 dyn SmsService 实现一个”智能切换器”:
1 | pub struct FailoverSms { |
这种强大的容灾能力,对于业务层 UserService 来说是完全透明的! 业务层只管调 send,它根本不知道背后发生了惊心动魄的线路切换。