云辇103

Love Life Love Coding Love You

用 Rust 的类型系统,搭一个诊断平台

很多人第一次接触 Rust,会把注意力放在所有权、生命周期、Resulttrait 这些语言特性上。
但如果只停在这里,其实只学到了 Rust 的“语法层”。

Rust 真正厉害的地方,是你可以把很多业务约束直接编码进类型系统里,让一部分错误根本写不出来。

这篇文章想讨论一个非常实用的问题:

如果我们要写一个服务器诊断平台,怎么用 Rust 的类型系统,把认证、会话状态、命令响应、数据校验、寄存器宽度这些规则都放进编译期?

这正是微软那本 Type-Driven Correctness in Rust 在第 10 章做的事情。它把前面几章分散的模式组合起来,搭出了一个完整的诊断工作流:

  1. 认证拿到能力令牌
  2. 打开会话并进入激活状态
  3. 发送类型安全的命令
  4. 用一次性 token 做审计
  5. 返回带单位的结果
  6. 校验 FRU 边界数据
  7. 读取带 phantom type 的寄存器

看起来像很多技巧,但它们放在一起,目标其实只有一个:

让错误的使用方式在编译阶段就被排除。


先看目标:我们想防住哪些错误?

假设我们在做一个 IPMI 风格的诊断模块,最常见的问题大概有这些:

  • 没认证就去激活会话
  • 会话还没准备好就发命令
  • 温度和转速都用 f64,结果混着传
  • 收到的原始 FRU 数据还没校验就开始解析
  • 本该读 16 位寄存器,却当成 32 位来读
  • 一次性的审计 token 被重复使用

如果这些约束全靠注释、文档、或者 if 判断来维护,系统一大就会开始漏。

所以更好的办法是:把正确流程写进类型里。


1. 用 typed command 约束“请求决定响应”

很多协议代码会写成这样:

1
fn send_command(cmd: &[u8]) -> io::Result<Vec<u8>>

这种设计的问题是,命令和响应之间没有类型关联。
调用者必须“自己记住”某个命令返回的到底是温度、风扇转速,还是电压。

我们换一种方式,把命令建模成 trait:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
use std::io;

#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
pub struct Celsius(pub f64);

#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
pub struct Rpm(pub f64);

pub trait IpmiCmd {
type Response;

fn net_fn(&self) -> u8;
fn cmd_byte(&self) -> u8;
fn payload(&self) -> Vec<u8>;
fn parse_response(&self, raw: &[u8]) -> io::Result<Self::Response>;
}

这里最关键的是这句:

1
type Response;

它把“这个命令对应什么返回值”直接绑定到了类型层。

比如读取温度:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
pub struct ReadTemp {
pub sensor_id: u8,
}

impl IpmiCmd for ReadTemp {
type Response = Celsius;

fn net_fn(&self) -> u8 { 0x04 }
fn cmd_byte(&self) -> u8 { 0x2D }
fn payload(&self) -> Vec<u8> { vec![self.sensor_id] }

fn parse_response(&self, raw: &[u8]) -> io::Result<Celsius> {
if raw.is_empty() {
return Err(io::Error::new(io::ErrorKind::InvalidData, "empty response"));
}
Ok(Celsius(raw[0] as f64))
}
}

再比如读取风扇转速:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
pub struct ReadFanSpeed {
pub fan_id: u8,
}

impl IpmiCmd for ReadFanSpeed {
type Response = Rpm;

fn net_fn(&self) -> u8 { 0x04 }
fn cmd_byte(&self) -> u8 { 0x2D }
fn payload(&self) -> Vec<u8> { vec![self.fan_id] }

fn parse_response(&self, raw: &[u8]) -> io::Result<Rpm> {
if raw.len() < 2 {
return Err(io::Error::new(io::ErrorKind::InvalidData, "need 2 bytes"));
}
Ok(Rpm(u16::from_le_bytes([raw[0], raw[1]]) as f64))
}
}

这样设计之后,调用方拿到的类型是编译器推出来的,而不是靠人脑记忆。


2. 用 capability token 表达“你有没有资格做这件事”

很多系统喜欢这么写权限控制:

1
fn activate_session(is_admin: bool) -> Result<(), Error>

问题是 bool 太弱了。
它没有任何上下文,也不能证明这个权限是怎么来的。

我们可以把“管理员权限”变成一个能力令牌

1
2
3
4
5
6
7
8
9
10
11
pub struct AdminToken {
_private: (),
}

pub fn authenticate(user: &str, pass: &str) -> Result<AdminToken, &'static str> {
if user == "admin" && pass == "secret" {
Ok(AdminToken { _private: () })
} else {
Err("authentication failed")
}
}

AdminToken 不能随便构造,只能通过 authenticate() 拿到。
这就意味着后续 API 可以直接要求它:

1
2
3
fn do_sensitive_thing(_admin: &AdminToken) {
// ...
}

如果手里没有 AdminToken,这段代码连调用资格都没有。


3. 用 typestate 限制“什么状态下能做什么事”

协议会话天然就是状态机。
比如一个 IPMI 会话至少会经历:

  • Idle
  • Active

如果你把所有操作都挂在一个 Session 上,然后运行时判断当前状态,代码很容易写出非法调用顺序。

更好的做法是:把状态放进类型参数。

1
2
3
4
5
6
7
8
9
use std::marker::PhantomData;

pub struct Idle;
pub struct Active;

pub struct Session<State> {
host: String,
_state: PhantomData<State>,
}

连接之后先得到 Session<Idle>

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
impl Session<Idle> {
pub fn connect(host: &str) -> Self {
Session {
host: host.to_string(),
_state: PhantomData,
}
}

pub fn activate(self, _admin: &AdminToken) -> Result<Session<Active>, String> {
println!("Session activated on {}", self.host);
Ok(Session {
host: self.host,
_state: PhantomData,
})
}
}

真正执行命令的方法,只存在于 Session<Active> 上:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
impl Session<Active> {
pub fn execute<C: IpmiCmd>(&mut self, cmd: &C) -> io::Result<C::Response> {
let raw = self.raw_send(cmd.net_fn(), cmd.cmd_byte(), &cmd.payload())?;
cmd.parse_response(&raw)
}

fn raw_send(&self, _nf: u8, _cmd: u8, _data: &[u8]) -> io::Result<Vec<u8>> {
Ok(vec![42, 0x1E])
}

pub fn close(self) {
println!("Session closed");
}
}

这意味着下面这种错误在编译期就会被挡住:

1
2
let mut session = Session::<Idle>::connect("192.168.1.100");
// session.execute(&ReadTemp { sensor_id: 0 }); // 编译不过

因为 execute() 根本不属于 Session<Idle>


4. 用 newtype 表达“单位不同就是不同类型”

很多监控/诊断系统里最危险的一类 bug,不是崩溃,而是数值语义混淆

比如:

  • 温度是摄氏度
  • 风扇是转速 RPM
  • 电压是 Volt

如果全都用 f64,编译器根本分不清。

我们用简单的 newtype 包起来:

1
2
3
4
5
6
7
8
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
pub struct Celsius(pub f64);

#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
pub struct Rpm(pub f64);

#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
pub struct Volts(pub f64);

这看起来只是多包了一层,但编译器会立刻帮你防住类型串线:

1
2
3
let temp: Celsius = session.execute(&ReadTemp { sensor_id: 0 })?;
// let wrong: Volts = session.execute(&ReadTemp { sensor_id: 0 })?;
// error: expected Volts, found Celsius

这就是“让单位进入类型系统”的价值。


5. 用单次消费类型保证审计 token 不能复用

有些资源天生就应该“一次性使用”。
比如一个审计事件 token,要求每次诊断只写入一次,不允许复用。

Rust 的 move 语义特别适合表达这种约束:

1
2
3
4
5
6
7
8
9
10
11
12
13
pub struct AuditToken {
run_id: u64,
}

impl AuditToken {
pub fn issue(run_id: u64) -> Self {
AuditToken { run_id }
}

pub fn log(self, message: &str) {
println!("[AUDIT run_id={}] {}", self.run_id, message);
}
}

注意 log(self, ...) 会消费掉 self
所以一旦调用完成,这个 token 就不能再用了:

1
2
3
let audit = AuditToken::issue(1001);
audit.log("diagnostic report written");
// audit.log("write again"); // 编译不过,value moved

这种“只能发生一次”的约束,不需要额外 runtime flag,所有权系统天然就能表达。


6. 用 validated boundary 把“不可信输入”拦在边界

FRU、网络包、磁盘块、用户输入,这些外部数据都不应该直接进业务逻辑。
更好的模式是:

原始数据先校验,校验通过后才变成领域对象。

比如一个简化版 FRU:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
pub struct ValidFru {
pub board_serial: String,
pub product_name: String,
}

impl ValidFru {
pub fn parse(raw: &[u8]) -> Result<Self, &'static str> {
if raw.len() < 8 {
return Err("FRU too short");
}

if raw[0] != 0x01 {
return Err("bad FRU version");
}

Ok(ValidFru {
board_serial: "SN12345".to_string(),
product_name: "ServerX".to_string(),
})
}
}

调用方式也很清楚:

1
2
let raw_fru = vec![0x01, 0x00, 0x00, 0x01, 0x01, 0x00, 0x00, 0xFD];
let fru = ValidFru::parse(&raw_fru)?;

重点不在于这个 demo 校验有多复杂,而在于:
后续代码拿到的是 ValidFru,不是随便一段 &[u8]

也就是说,“校验过没有”不再靠注释,而是靠类型表达。


7. 用 phantom type 表达“运行时没有,但语义上很重要”的信息

有些信息不会体现在结构体字段里,但对 API 正确性至关重要。
寄存器宽度就是一个很典型的例子。

我们可以这样建模:

1
2
3
4
5
6
7
8
9
10
11
12
pub struct Width16;

pub struct Reg<W> {
offset: u16,
_w: PhantomData<W>,
}

impl Reg<Width16> {
pub fn read(&self) -> u16 {
0x8086
}
}

Width16 不占运行时空间,但它告诉编译器:
这个寄存器就是 16 位的,所以 read() 的返回值应该是 u16

把它放进设备模型里:

1
2
3
4
5
6
7
8
9
10
11
12
13
pub struct PcieDev {
pub vendor_id: Reg<Width16>,
pub device_id: Reg<Width16>,
}

impl PcieDev {
pub fn new() -> Self {
PcieDev {
vendor_id: Reg { offset: 0x00, _w: PhantomData },
device_id: Reg { offset: 0x02, _w: PhantomData },
}
}
}

这样读取时就不会“忘了这个寄存器本来多宽”。


8. 把这些模式拼起来:一个完整的诊断流程

前面每个点单独看都不算复杂,真正有意思的是它们组合起来之后会变成什么样。

下面是一个简化后的完整流程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
use std::io;
use std::marker::PhantomData;

#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
pub struct Celsius(pub f64);

#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
pub struct Rpm(pub f64);

pub trait IpmiCmd {
type Response;
fn net_fn(&self) -> u8;
fn cmd_byte(&self) -> u8;
fn payload(&self) -> Vec<u8>;
fn parse_response(&self, raw: &[u8]) -> io::Result<Self::Response>;
}

pub struct ReadTemp {
pub sensor_id: u8,
}

impl IpmiCmd for ReadTemp {
type Response = Celsius;

fn net_fn(&self) -> u8 { 0x04 }
fn cmd_byte(&self) -> u8 { 0x2D }
fn payload(&self) -> Vec<u8> { vec![self.sensor_id] }

fn parse_response(&self, raw: &[u8]) -> io::Result<Celsius> {
if raw.is_empty() {
return Err(io::Error::new(io::ErrorKind::InvalidData, "empty"));
}
Ok(Celsius(raw[0] as f64))
}
}

pub struct ReadFanSpeed {
pub fan_id: u8,
}

impl IpmiCmd for ReadFanSpeed {
type Response = Rpm;

fn net_fn(&self) -> u8 { 0x04 }
fn cmd_byte(&self) -> u8 { 0x2D }
fn payload(&self) -> Vec<u8> { vec![self.fan_id] }

fn parse_response(&self, raw: &[u8]) -> io::Result<Rpm> {
if raw.len() < 2 {
return Err(io::Error::new(io::ErrorKind::InvalidData, "need 2 bytes"));
}
Ok(Rpm(u16::from_le_bytes([raw[0], raw[1]]) as f64))
}
}

pub struct AdminToken {
_private: (),
}

pub fn authenticate(user: &str, pass: &str) -> Result<AdminToken, &'static str> {
if user == "admin" && pass == "secret" {
Ok(AdminToken { _private: () })
} else {
Err("authentication failed")
}
}

pub struct Idle;
pub struct Active;

pub struct Session<State> {
host: String,
_state: PhantomData<State>,
}

impl Session<Idle> {
pub fn connect(host: &str) -> Self {
Session {
host: host.to_string(),
_state: PhantomData,
}
}

pub fn activate(self, _admin: &AdminToken) -> Result<Session<Active>, String> {
Ok(Session {
host: self.host,
_state: PhantomData,
})
}
}

impl Session<Active> {
pub fn execute<C: IpmiCmd>(&mut self, cmd: &C) -> io::Result<C::Response> {
let raw = self.raw_send(cmd.net_fn(), cmd.cmd_byte(), &cmd.payload())?;
cmd.parse_response(&raw)
}

fn raw_send(&self, _nf: u8, _cmd: u8, _data: &[u8]) -> io::Result<Vec<u8>> {
Ok(vec![42, 0x1E])
}
}

pub struct AuditToken {
run_id: u64,
}

impl AuditToken {
pub fn issue(run_id: u64) -> Self {
AuditToken { run_id }
}

pub fn log(self, message: &str) {
println!("[AUDIT run_id={}] {}", self.run_id, message);
}
}

pub struct ValidFru {
pub board_serial: String,
pub product_name: String,
}

impl ValidFru {
pub fn parse(raw: &[u8]) -> Result<Self, &'static str> {
if raw.len() < 8 {
return Err("FRU too short");
}
if raw[0] != 0x01 {
return Err("bad FRU version");
}

Ok(ValidFru {
board_serial: "SN12345".to_string(),
product_name: "ServerX".to_string(),
})
}
}

pub struct Width16;

pub struct Reg<W> {
offset: u16,
_w: PhantomData<W>,
}

impl Reg<Width16> {
pub fn read(&self) -> u16 {
0x8086
}
}

pub struct PcieDev {
pub vendor_id: Reg<Width16>,
pub device_id: Reg<Width16>,
}

impl PcieDev {
pub fn new() -> Self {
PcieDev {
vendor_id: Reg { offset: 0x00, _w: PhantomData },
device_id: Reg { offset: 0x02, _w: PhantomData },
}
}
}

fn full_diagnostic() -> Result<(), String> {
let admin = authenticate("admin", "secret").map_err(|e| e.to_string())?;

let session = Session::<Idle>::connect("192.168.1.100");
let mut session = session.activate(&admin)?;

let temp: Celsius = session.execute(&ReadTemp { sensor_id: 0 })
.map_err(|e| e.to_string())?;

let fan: Rpm = session.execute(&ReadFanSpeed { fan_id: 1 })
.map_err(|e| e.to_string())?;

let pcie = PcieDev::new();
let vendor_id = pcie.vendor_id.read();

let raw_fru = vec![0x01, 0x00, 0x00, 0x01, 0x01, 0x00, 0x00, 0xFD];
let fru = ValidFru::parse(&raw_fru).map_err(|e| e.to_string())?;

let audit = AuditToken::issue(1001);

let report = format!(
"Server: {} (SN: {}), VID: 0x{:04X}, Temp: {:?}, Fan: {:?}",
fru.product_name,
fru.board_serial,
vendor_id,
temp,
fan,
);

audit.log(&report);

Ok(())
}

这段代码最有意思的地方,不是“它能跑”,而是它在编译期已经帮我们排除了很多非法情况。


9. 编译器到底替我们证明了什么?

把上面的设计拆开看,编译器至少能帮我们兜住这几类错误:

错误类型 怎么被防住 对应模式
未认证就激活会话 activate() 必须拿 &AdminToken capability token
会话状态不对就发命令 execute() 只存在于 Session<Active> typestate
命令返回值写错类型 IpmiCmd::Response 绑定响应类型 typed command
温度/转速/电压混用 CelsiusRpmVolts 是不同类型 dimensional type
寄存器宽度读错 Reg<Width16> 只能读出 u16 phantom type
未校验原始数据直接使用 parse() 才能得到 ValidFru validated boundary
审计 token 被复用 log(self, ...) 消费所有权 single-use type

这也是这类设计最打动人的地方:

不是“多写了几层抽象”,而是把一堆原本只能靠测试和经验发现的问题,前移到了编译期。


10. 这类设计的成本和收益

这种写法当然不是零成本的。
你需要多定义一些类型,多思考状态边界,也要接受一开始 API 看起来会比“直接传 u8Vec<u8>”更啰嗦。

但它带来的收益非常适合基础设施和协议类系统:

  • API 更难被误用
  • 重构时更有信心
  • 单元测试压力下降
  • 文档和代码更一致
  • 很多 bug 根本进入不了运行时

如果你的系统有这些特点,这种设计会特别值:

  • 协议有明确状态流转
  • 外部输入不可信
  • 数值单位很多
  • 权限控制严格
  • 某些资源只能单次使用
  • 错误代价很高

结语

很多文章会把 Rust 的优势概括成“内存安全”。
但对于工程系统来说,更实用的一层是:

Rust 可以让你把业务规则、协议约束、单位语义和权限边界,一起编码进类型系统。

从零构建大语言模型:沿着 500 行代码看懂 GPT 的工作原理

大语言模型常常给人一种“高深且遥远”的印象:Transformer、注意力机制、交叉熵、采样策略,这些名词堆叠在一起,很容易让人望而却步。但如果换一种视角,不从抽象概念出发,而是沿着一份可以运行的代码,一层一层往下拆,事情就会清晰很多。

这篇文章的目标,就是用工程化的方式回答一个问题:一个 GPT 风格的大语言模型,到底是怎么从零搭起来的?

这里讨论的不是工业级超大模型的分布式训练,而是一套约 500 行代码的最小可行实现。它足够小,能让我们看清每个模块的职责;也足够完整,涵盖了数据准备、模型结构、训练过程和文本生成这四个关键环节。通过这条主线,我们不仅能理解“大模型由哪些部分组成”,更能明白“这些部分为什么必须存在”。

一、从原始文本到训练样本:模型先要吃对“数据”

任何语言模型的起点都不是神经网络,而是文本数据。模型并不能直接理解字符串,它首先需要把自然语言变成可计算的数值形式。

1. 训练集和验证集为什么要分开

从工程角度看,数据集通常会先划分为训练集和验证集。训练集用于更新模型参数,验证集用于检查模型的泛化能力。

这背后的逻辑并不复杂。训练集相当于“练习题”,模型会不断根据它调整自己;验证集相当于“模拟考试”,用于判断模型是否真的学到了规律,而不是仅仅把训练数据背了下来。如果训练损失一直下降,但验证损失开始上升,通常就意味着模型正在过拟合。

在实现上,这一步往往只是把原始文本按比例切开,例如 90% 用于训练,10% 用于验证。虽然简单,但它决定了后续训练是否具有可解释性。

2. Dataset 和 DataLoader 分别解决什么问题

在 PyTorch 里,数据处理通常由 DatasetDataLoader 配合完成。

Dataset 回答的是“数据是什么”。它负责定义一条样本怎么取、数据集有多大。
DataLoader 回答的是“数据怎么喂给模型”。它负责批量化、打乱顺序、多进程加载等工程问题。

这套设计很重要,因为模型训练并不是一次吃下一整本书,而是一批一批地读。训练时的数据顺序通常会被打乱,以避免模型记住样本顺序本身;而验证时则保持顺序稳定,确保评估结果可比较、可复现。

3. 分词:从字符串到 token id

模型无法直接处理句子,必须先把文本切成 token,再把 token 映射为整数 ID。这个过程就是词元化(tokenization)。

其本质是一次“语言到数字”的映射。比如一句文本,会先被拆成词、子词、标点等更小单元,然后每个单元对应词表中的一个整数。后续模型看到的,其实只是一个 ID 序列。

这里一个关键点在于:现代 LLM 通常不会直接以“整词”为最小单位,而是采用 BPE 之类的子词方法。这样做的好处是,即便遇到词表中从未出现过的新词,分词器也能把它拆成更小的已知片段,从而避免真正意义上的“未知词”。

4. 滑动窗口:把连续文本变成监督学习样本

语言模型的核心任务只有一句话:根据上文预测下一个 token。

如果我们已经把整段文本变成了 token 序列,那么训练样本就可以通过滑动窗口自动构造出来:

  • 输入:当前位置开始的一段上下文
  • 目标:这段上下文整体向右偏移一位后的结果

例如输入是 [t1, t2, t3, t4],目标就是 [t2, t3, t4, t5]
这样模型在每个位置上都在做同一件事:根据前面的 token,猜当前正确答案是什么。

这一步非常关键,因为它把“阅读原始文本”转化成了一个标准的监督学习问题。

二、模型结构:GPT 到底由哪些模块构成

当数据准备好之后,真正的模型部分才开始登场。一个 GPT 风格模型可以粗略分成三层:

  1. 输入表示层
  2. Transformer 核心处理层
  3. 输出投影层

1. 输入表示层:语义和位置缺一不可

输入给模型的虽然是 token ID,但 ID 本身没有语义,也不携带顺序信息。因此,第一步必须做嵌入。

词嵌入(token embedding)负责把每个离散 ID 映射成一个连续向量。这个向量是可训练的,在训练过程中,模型会逐步把语义相近的 token 拉近,把语义不同的 token 拉远。

但只有词嵌入还不够,因为注意力机制天然不理解顺序。对于模型来说,“人咬狗”和“狗咬人”如果只看词集合,几乎没有区别。因此还必须引入位置嵌入(positional embedding),把“这个 token 出现在第几个位置”也编码进去。

最终,模型真正接收到的输入,是词嵌入和位置嵌入的相加结果。这样每个位置上的向量同时带有“它是谁”和“它在哪”的信息。

2. Transformer Block:GPT 的计算核心

GPT 的主体是一堆 Transformer Block 的堆叠。每个 Block 内部通常包含三类关键组件:

  • 多头因果自注意力
  • 前馈神经网络
  • 层归一化与残差连接

这几个部分共同决定了模型是否既能理解上下文,又能稳定训练。

三、自注意力:模型是如何“看上下文”的

自注意力机制是 LLM 的灵魂。它解决的核心问题是:当前这个 token,到底应该关注上下文里的哪些 token?

1. 最朴素的自注意力:加权汇总上下文

可以把自注意力理解成三步:

  1. 计算当前 token 与其他 token 的相关性分数
  2. 用 softmax 把分数归一化成权重
  3. 按权重对所有 token 的信息做加权求和

最后得到的新向量,不再只是“当前词自身的表示”,而是“当前词在整个上下文中的表示”。

这一步的意义极大。因为模型终于不再是线性读句子,而是可以在任意位置直接回看整段上下文,动态决定哪里更重要。

2. 为什么要引入 Q、K、V

如果直接拿原始嵌入向量做相似度计算,会有一个问题:同一个向量既要代表词义,又要参与注意力打分,职责混在一起,不够灵活。

于是 Transformer 引入了三组可训练变换:

  • Query:当前 token 想查询什么
  • Key:其他 token 提供什么索引信息
  • Value:其他 token 真正携带什么内容

这样注意力就从“直接比向量”升级成了“学习如何比、学习该关注谁”。Q、K、V 的存在,让注意力机制本身也变成了可训练模块,而不只是固定公式。

3. 因果掩码:生成模型不能偷看未来

标准自注意力会看到整个序列,但 GPT 是自回归模型,在预测下一个词时不能看到未来词,否则就等于作弊。

解决办法是加入因果掩码。做法通常是在注意力分数矩阵上盖一个上三角 mask,把当前位置之后的分数全部变成极小值。经过 softmax 后,这些位置的概率就会接近 0。

这样一来,模型在第 i 个位置上,只能利用 1...i 的信息,不能利用 i+1 之后的内容。这正是 GPT 能用于文本生成的根本前提。

4. 多头注意力:让模型从多个角度理解同一句话

单头注意力只有一种“看问题的方式”。但语言是复杂的,有时要关注语法关系,有时要关注语义关联,有时要关注长距离依赖。

多头注意力的思路,就是并行跑多套注意力计算。每个头都有独立的 Q、K、V 变换,它们会在不同表示子空间里学习不同的关注模式。最后再把多个头的结果拼接起来,统一投影回模型维度。

这相当于让模型拥有多组“观察员”,从不同角度同时审视一句话,再把各自的发现汇总起来。

四、除了注意力,为什么还需要 LayerNorm、FFN 和残差连接

注意力机制很强,但一个 Transformer Block 并不只有注意力。

1. LayerNorm:解决深层网络训练不稳定

深度网络一旦堆得很深,就容易出现数值分布不断漂移的问题。某些层输出过大,某些层输出过小,最终导致训练不稳定,甚至梯度爆炸或梯度消失。

LayerNorm 的作用,就是在特征维度上对每个样本做标准化,让输入保持在一个更稳定的分布上。这样后续模块看到的数据范围更可控,训练也更容易收敛。

更重要的是,LayerNorm 后面通常还会带上可学习的缩放和偏置参数。这意味着它不是死板地“强制统一”,而是在稳定数值的基础上,仍允许模型学回自己想要的分布。

2. 前馈网络:负责“进一步思考”

注意力更像是“信息整合器”,负责从上下文中取回相关内容;前馈网络则更像“局部加工器”,负责对已经整合好的信息做更复杂的非线性变换。

典型实现是两层线性层加一个 GELU 激活,中间先升维,再降维。这个结构为模型提供了更强的表达能力,使它不只是会“找关系”,还能对关系进行深度加工。

3. 残差连接:给信息和梯度修一条高速公路

如果每经过一层都完全重写表示,原始信息很容易在深层传播中被冲淡;同时,反向传播时梯度也容易逐层衰减。

残差连接的做法非常直接:把输入直接绕过当前模块,加回输出上。
这相当于给网络修了一条高速公路:

  • 前向时,原始信息可以直接传递
  • 反向时,梯度也可以更顺畅地流回浅层

正因为有残差连接,Transformer 才能稳定堆叠很多层,而不至于“越深越学不动”。

五、输出层:从隐藏状态回到词表概率

经过多层 Transformer Block 处理后,模型得到的是一串上下文化的隐藏向量。但这些向量仍然只是内部表示,不能直接变成文字。

所以最后还需要一个输出投影层,把每个位置上的隐藏向量映射回词表大小的维度。
如果词表大小是 50,257,那么每个位置最终都会得到一个长度为 50,257 的向量,里面每个数都对应一个 token 的原始置信度分数,也就是 logits。

再对 logits 做 softmax,就能得到“下一个 token 是谁”的概率分布。

至此,模型才真正完成了一次“预测”。

六、训练过程:模型如何从随机参数变得会说话

刚初始化出来的 GPT 结构虽然完整,但参数全是随机的,输出自然也是随机噪声。模型之所以能学会语言,靠的是训练循环。

1. 训练的四步循环

每一个 batch 的训练,本质上都在重复以下过程:

  1. 前向传播,得到预测 logits
  2. 计算损失,衡量预测与真实答案的差距
  3. 反向传播,计算每个参数该往哪个方向改
  4. 优化器更新参数

这个过程反复执行,模型就会逐渐学会:在什么样的上下文后面,什么 token 更可能出现。

2. 交叉熵损失为什么适合语言模型

语言模型的每个位置,其实都可以看成一个多分类任务:
在整个词表中,正确答案只有一个,模型需要给它尽量高的概率。

交叉熵损失正适合做这件事。它本质上是在惩罚模型“没有把正确答案的概率抬高”。如果模型对正确 token 非常自信,损失就小;如果模型把概率分配给了错误 token,损失就大。

因此,最小化交叉熵的过程,实际上就是在逼迫模型学会构造更接近真实语言分布的预测。

3. 为什么既要看训练损失,也要看验证损失

训练时不能只盯着一个数字看。训练损失下降,只说明模型越来越会拟合训练数据;验证损失下降,才说明模型学到的是可迁移的规律。

一个健康的训练过程通常表现为:

  • 训练损失下降
  • 验证损失也同步下降

如果后者停止下降甚至反弹,就要警惕过拟合。这也是为什么训练集和验证集的划分不是形式主义,而是整个训练监控体系的一部分。

七、文本生成:训练完以后,模型怎么把字一个个“写出来”

模型学会预测下一个 token 后,就可以进入生成阶段了。生成的基本流程很简单:

  1. 给模型一个起始文本
  2. 模型预测下一个 token 的概率分布
  3. 选择一个 token 作为输出
  4. 把这个 token 拼回输入
  5. 重复以上过程

但“怎么选下一个 token”其实很讲究。

1. 贪婪解码的问题

最简单的方法是每次都选概率最大的 token,也就是贪婪解码。
它的问题也很明显:

  • 容易重复
  • 缺乏多样性
  • 经常陷入局部最优

对于开放式文本生成任务,这种策略通常会让输出显得机械又无聊。

2. 温度缩放:控制模型的保守与发散

温度本质上是在 softmax 前调整 logits 的尖锐程度。

  • 温度小于 1:分布更尖锐,模型更保守
  • 温度大于 1:分布更平缓,模型更发散

温度不是决定“对错”,而是决定“敢不敢冒险”。它直接影响生成文本的稳定性与创造性。

3. Top-k 采样:只在靠谱候选里随机

即便做了温度调整,词表仍然太大,很多低概率词虽然没被排除,但其实并不合理。Top-k 采样的思路就是:只保留概率最高的 k 个 token,然后在这 k 个里面按概率随机抽样。

这样既避免了完全放飞,也避免了过度死板,通常能在连贯性和多样性之间取得更平衡的效果。

实际系统中还常结合 Top-p 等更动态的采样策略,本质上都是在回答同一个问题:如何既让模型说得通,又让它别太无聊。

八、从零实现 GPT,真正能学到什么

从工程视角回看,一个 GPT 风格的大语言模型其实可以拆解成一套非常清晰的流水线:

  • 文本被分词并数值化
  • 连续文本被切成输入-目标训练样本
  • 输入经过嵌入和位置编码进入模型
  • Transformer 用注意力机制融合上下文
  • 前馈网络、归一化和残差连接保证表达能力与训练稳定
  • 输出层把隐藏表示映射回词表概率
  • 训练循环通过交叉熵和反向传播持续更新参数
  • 推理阶段通过采样策略逐 token 生成文本

真正有价值的,不是“会背 Transformer 结构图”,而是理解这条链路上每个模块分别解决了什么问题。

为什么必须先做 tokenization?
为什么位置编码不可少?
为什么 GPT 一定要做 causal mask?
为什么没有残差连接,深层网络会训练困难?
为什么生成阶段不能只靠 argmax?

当这些问题都能从实现角度讲清楚时,我们对大语言模型的理解,才算真正从“知道名词”进入“理解机制”。

结语

大语言模型并不是一个无法触碰的黑盒。把它还原成代码之后,你会发现它本质上是很多经典工程思想的组合:数据管道、矩阵运算、可微优化、模块堆叠、概率采样。

所谓“从零构建 LLM”的意义,也不在于真的用 500 行代码复刻工业级模型,而在于通过一个最小实现,把复杂系统拆成一组可以被逐个理解、逐个验证的子问题。理解了这条主线,再去看更大的模型、更复杂的训练框架,很多东西都会顺理成章。

分治的边界:从 SRP 到限界上下文,再到 Rust 中真正可治理的设计

分治并不天然正确。

很多时候,我们一提到“拆分”,就会本能地觉得这是在优化架构、降低复杂度。但事实恰恰相反:分治最大的风险,不是没有拆,而是拆错了边界。一个错误的拆分,会把原本内聚的整体强行打散,结果不是降低复杂度,而是引入更多耦合、更多协调成本、更多隐式依赖,最终让系统变得更难理解、更难修改、更难测试。

所以,真正重要的问题从来不是“要不要分”,而是:

应该按什么边界来分?

我个人目前比较认可,可以从两个层级来理解这个问题:

  1. 代码层级:单一职责原则(SRP)
  2. 系统层级:限界上下文(Bounded Context)

这两个概念看起来属于不同尺度,但它们背后的思想其实非常一致:分,不是为了把东西切碎;分,是为了把复杂度约束在局部。


一、代码层级的边界:SRP 不是“一个类只做一件事”,而是“只有一个变化理由”

在代码层面,我们面对的最大问题是什么?

不是“代码长不长”,也不是“函数优不优雅”,而是变更

一个软件生命周期里最大的成本,往往不是第一次把功能写出来,而是后续不断地响应需求变化、修 bug、扩展逻辑、兼容新场景。维护成本的本质,就是应对变化的成本。

Robert C. Martin 对 SRP 的经典表述是:

A class should have only one reason to change.

也就是:

一个类,应该只有一个变化的理由。

很多人第一次接触 SRP 时,会把它理解成“一个类只做一件事”。这句话不能说错,但太模糊。真正更有操作性的判断方式其实是:

如果两个逻辑会因为不同的人、不同部门、不同规则变化而分别修改,那它们就不该耦合在一起。

举个经典例子。假设我们有一个 Employee 类型,它同时负责:

  • 薪酬计算
  • 数据库存储
  • 报表生成

那么这三个职责其实分别对应三种完全不同的变化来源:

  • 财务规则变了,薪酬计算要改
  • DBA 调整表结构了,持久化逻辑要改
  • HR 改报表格式了,报表生成要改

如果它们被塞进同一个类里,会发生什么?

  • 改薪酬逻辑,可能误伤数据库逻辑
  • 改存储逻辑,可能影响报表
  • 一个类被多个团队从不同方向同时修改
  • 测试范围越来越大,改动影响面越来越不清晰

这就是复杂度滋生的起点。

Rust 风格的 SRP 拆分

在 Rust 里,我们通常不会把所有行为都塞进一个“大对象”里,而是更倾向于把能力拆成清晰的结构体和 trait。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
#[derive(Debug, Clone)]
pub struct Employee {
pub id: u64,
pub name: String,
pub base_salary: i64,
pub bonus: i64,
}

pub struct PayrollCalculator;

impl PayrollCalculator {
pub fn calculate_salary(&self, employee: &Employee) -> i64 {
employee.base_salary + employee.bonus
}
}

pub trait EmployeeRepository {
fn save(&self, employee: &Employee) -> Result<(), String>;
}

pub struct SqlEmployeeRepository;

impl EmployeeRepository for SqlEmployeeRepository {
fn save(&self, employee: &Employee) -> Result<(), String> {
println!("save employee {} to database", employee.id);
Ok(())
}
}

pub struct EmployeeReporter;

impl EmployeeReporter {
pub fn generate_report(&self, employee: &Employee) -> String {
format!(
"Employee Report => id: {}, name: {}, base_salary: {}, bonus: {}",
employee.id, employee.name, employee.base_salary, employee.bonus
)
}
}

这里的拆分重点不在于“代码看起来更工整”,而在于:

  • PayrollCalculator 只会因为薪酬规则变化而变化
  • SqlEmployeeRepository 只会因为存储策略变化而变化
  • EmployeeReporter 只会因为报表格式变化而变化

也就是说,SRP 的边界,不是功能名词的边界,而是变化来源的边界。


二、系统层级的边界:限界上下文解决的不是代码问题,而是语义问题

到了系统层面,尤其是大型业务系统里,复杂度的来源就不只是“代码如何组织”了。

这时候更大的问题是:

同一个词,在不同业务里,到底是不是同一个东西?

比如“客户(Customer)”这个词:

  • 在销售团队眼里,客户是潜在购买者
  • 在客服团队眼里,客户是有服务关系的注册用户
  • 在财务团队眼里,客户是有付款行为的法律实体

如果我们强行建立一个“统一 Customer 大模型”,试图让一个模型覆盖所有场景,结果通常会是:

  • 字段越来越多
  • 各种条件分支越来越多
  • 不同团队对模型含义理解不一致
  • 每个场景都觉得这个模型“不太对”

这时候问题已经不是代码优不优雅,而是语义冲突

DDD 里的限界上下文(Bounded Context)就是为了解决这个问题。

限界上下文的核心不是“拆服务”,而是“定义语义边界”

在不同上下文里,可以允许同一个概念拥有不同模型。

比如:

  • SalesCustomer
  • SupportCustomer
  • BillingCustomer

它们名字都带着“Customer”,但语义不同、关注点不同、规则不同,所以本来就不该硬揉成一个模型。

如果用 Rust 表达这个思想,可以很自然地把它们设计成不同类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#[derive(Debug)]
pub struct SalesCustomer {
pub lead_id: u64,
pub name: String,
pub contact: String,
pub intention_level: u8,
}

#[derive(Debug)]
pub struct SupportCustomer {
pub user_id: u64,
pub nickname: String,
pub active_ticket_count: u32,
}

#[derive(Debug)]
pub struct BillingCustomer {
pub legal_entity_id: u64,
pub company_name: String,
pub tax_number: String,
pub paid_amount: i64,
}

这三个结构体看起来“重复”,但这种重复并不是坏事。

因为它们表达的是:

  • 销售上下文里的客户
  • 客服上下文里的客户
  • 财务上下文里的客户

限界上下文的价值,不是复用模型,而是避免错误复用模型。

这是很多系统设计里最容易被忽视的一点。


三、真正的分治,不是把 500 行函数拆成 5 个 100 行函数

我过去在游戏业务里接触过一种非常典型的“巨型结算接口”。

这个接口要处理很多事情:

  • 多种游戏模式的结算逻辑
  • 成绩计算
  • 奖励发放
  • 历史荣誉更新
  • 师徒系统加成
  • 任务系统进度推进
  • 活动奖励发放
  • 而且部分奖励必须同步返回给客户端,不能纯异步

如果你把这些逻辑全部塞在一个接口里,代码通常会长成这样:

1
2
3
4
5
6
7
8
9
10
11
pub fn settle(req: SettleRequest) -> Result<SettleResponse, String> {
// 1. 参数解析
// 2. 成绩格式化
// 3. 按模式分支
// 4. 计算奖励
// 5. 更新任务
// 6. 发布事件
// 7. 持久化
// 8. 构建响应
todo!()
}

这种代码的问题,不只是“长”,而是它混杂了多个抽象层级:

  • HTTP / 接口层职责
  • 业务编排职责
  • 业务执行职责
  • 副作用管理职责
  • 响应构建职责

很多人会进一步做一个“表面拆分”:

1
2
3
4
5
6
7
8
pub fn process_game_mode_1(ctx: &SettlementContext) -> Result<SettlementResult, String> {
let r1 = step1(ctx)?;
let r2 = step2(ctx, r1)?;
let r3 = step3(ctx, r2)?;
let r4 = step4(ctx, r3)?;
let r5 = step5(ctx, r4)?;
Ok(r5)
}

这看起来好像变好了,但实际上并没有真正治理复杂度。

因为这些 step1/step2/step3 往往只是把原来的大段代码搬到了不同函数里,而没有改变依赖关系和职责结构。它依然存在几个根本问题:

  • 不能独立理解
  • 不能独立修改
  • 不能独立测试
  • 不能灵活组合

换句话说,这种拆分实现了“分”,但没有实现“治”。


四、什么叫“真正的治理”?

我现在越来越倾向于用下面四个维度来判断一个拆分到底有没有价值:

  1. 可独立理解
  2. 可独立修改
  3. 可独立验证
  4. 可灵活组合

好的架构不是消灭复杂度,因为业务本来就复杂。

好的架构只是做到一件事:

让复杂度被约束在局部,每个局部都能被人稳定地理解、修改、测试和复用。

下面用 Rust 的方式来表达这个思路。


五、用 Rust 实现一个可治理的结算 Pipeline

1. 先定义上下文与结果

我们先把流程的输入和输出抽出来,避免每个处理器都各自操作一堆零散参数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
use std::collections::HashMap;

#[derive(Debug, Clone, Copy)]
pub enum GameMode {
Mode1,
Mode2,
}

#[derive(Debug, Clone)]
pub struct Player {
pub id: u64,
pub base_score: i32,
}

#[derive(Debug, Clone)]
pub struct SettleRequest {
pub mode: GameMode,
pub players: Vec<Player>,
}

#[derive(Debug, Clone)]
pub struct SettlementContext {
pub mode: GameMode,
pub players: Vec<Player>,
}

impl SettlementContext {
pub fn from_request(req: SettleRequest) -> Result<Self, String> {
if req.players.is_empty() {
return Err("players cannot be empty".to_string());
}

Ok(Self {
mode: req.mode,
players: req.players,
})
}
}

#[derive(Debug, Clone, Default)]
pub struct Reward {
pub coins: i32,
}

#[derive(Debug, Clone)]
pub struct PlayerReward {
pub player_id: u64,
pub base_reward: Reward,
pub total_reward: Reward,
}

#[derive(Debug, Default)]
pub struct SettlementResult {
pub player_rewards: HashMap<u64, PlayerReward>,
pub events: Vec<String>,
}

#[derive(Debug)]
pub struct SettleResponse {
pub rewards: HashMap<u64, PlayerReward>,
pub events: Vec<String>,
}

impl From<SettlementResult> for SettleResponse {
fn from(result: SettlementResult) -> Self {
Self {
rewards: result.player_rewards,
events: result.events,
}
}
}

这一层的意义在于:

  • SettlementContext 表达流程输入
  • SettlementResult 表达流程累积产物
  • SettleResponse 表达最终对外返回结果

这样,业务处理单元之间共享的是明确的流程模型,而不是散乱参数。


2. 定义 Processor:让每个处理单元只关心一个职责

接下来定义统一的处理器接口。

1
2
3
4
5
6
7
pub trait Processor {
fn process(
&self,
ctx: &SettlementContext,
result: &mut SettlementResult,
) -> Result<(), String>;
}

这个 trait 很关键。它表达的是:

  • 处理器读取上下文
  • 处理器修改结果
  • 处理器只负责自己那一段逻辑

接着我们定义计分职责。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
pub trait ScoreCalculator {
fn calculate(&self, player: &Player, ctx: &SettlementContext) -> i32;
fn convert_to_reward(&self, score: i32) -> Reward;
}

pub struct DefaultScoreCalculator;

impl ScoreCalculator for DefaultScoreCalculator {
fn calculate(&self, player: &Player, _ctx: &SettlementContext) -> i32 {
player.base_score
}

fn convert_to_reward(&self, score: i32) -> Reward {
Reward { coins: score * 10 }
}
}

pub struct ScoreCalculationProcessor<C: ScoreCalculator> {
pub calculator: C,
}

impl<C: ScoreCalculator> Processor for ScoreCalculationProcessor<C> {
fn process(
&self,
ctx: &SettlementContext,
result: &mut SettlementResult,
) -> Result<(), String> {
for player in &ctx.players {
let score = self.calculator.calculate(player, ctx);
let reward = self.calculator.convert_to_reward(score);

result.player_rewards.insert(
player.id,
PlayerReward {
player_id: player.id,
base_reward: reward.clone(),
total_reward: reward,
},
);
}

Ok(())
}
}

这段代码体现的是认知治理

当你阅读 ScoreCalculationProcessor 时,不需要理解:

  • HTTP 是怎么进来的
  • 数据库怎么存
  • 活动系统怎么发奖
  • 师徒系统怎么计算

你只需要理解一件事:

输入玩家分数,输出基础奖励。

这就是“可独立理解”。


3. 再把师徒加成拆成独立单元

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
pub trait MasterApprenticeService {
fn bonus_rate(&self, player_id: u64) -> f32;
}

pub struct DefaultMasterApprenticeService;

impl MasterApprenticeService for DefaultMasterApprenticeService {
fn bonus_rate(&self, player_id: u64) -> f32 {
if player_id % 2 == 0 { 0.2 } else { 0.0 }
}
}

pub struct MasterApprenticeProcessor<S: MasterApprenticeService> {
pub service: S,
}

impl<S: MasterApprenticeService> Processor for MasterApprenticeProcessor<S> {
fn process(
&self,
_ctx: &SettlementContext,
result: &mut SettlementResult,
) -> Result<(), String> {
for reward in result.player_rewards.values_mut() {
let rate = self.service.bonus_rate(reward.player_id);
let bonus = (reward.base_reward.coins as f32 * rate) as i32;
reward.total_reward.coins += bonus;
}

Ok(())
}
}

这段代码体现的是演化治理

如果未来产品说:

  • 师徒加成从 20% 改成 15%
  • 某些模式不参与师徒加成
  • 某类玩家有特殊规则

那么你修改的边界非常明确:

  • MasterApprenticeService
  • 或改 MasterApprenticeProcessor

而不需要在一大段结算总流程里艰难定位一段逻辑。

这就是“可独立修改”。


4. Pipeline 负责组合,而不是负责业务细节

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
pub struct SettlementPipeline {
processors: Vec<Box<dyn Processor>>,
}

impl SettlementPipeline {
pub fn new(processors: Vec<Box<dyn Processor>>) -> Self {
Self { processors }
}

pub fn execute(&self, ctx: &SettlementContext) -> Result<SettleResponse, String> {
let mut result = SettlementResult::default();

for processor in &self.processors {
processor.process(ctx, &mut result)?;
}

Ok(result.into())
}
}

SettlementPipeline 的职责非常纯粹:

  • 维护处理顺序
  • 驱动流程执行
  • 聚合最终结果

它并不关心每个处理器内部细节。

这种设计带来的好处是,流程控制和业务实现分离了。编排归编排,规则归规则。

这就是“组合治理”的基础。


5. Factory 决定不同模式如何装配

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
pub struct SettlementPipelineFactory;

impl SettlementPipelineFactory {
pub fn create_mode1_pipeline(&self) -> SettlementPipeline {
SettlementPipeline::new(vec![
Box::new(ScoreCalculationProcessor {
calculator: DefaultScoreCalculator,
}),
Box::new(MasterApprenticeProcessor {
service: DefaultMasterApprenticeService,
}),
])
}

pub fn create_mode2_pipeline(&self) -> SettlementPipeline {
SettlementPipeline::new(vec![
Box::new(ScoreCalculationProcessor {
calculator: DefaultScoreCalculator,
}),
])
}
}

这里体现的是很关键的一点:

新增模式,不一定意味着修改原有主流程;更理想的方式是新增一种组合。

比如:

  • Mode1 有基础计分 + 师徒加成
  • Mode2 只有基础计分
  • Mode3 可以是基础计分 + 活动奖励 + 排行榜结算

你做的是装配不同 pipeline,而不是不断在主流程里堆 if else


6. 最后让 API 只负责入口职责

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
pub struct Api {
pipeline_factory: SettlementPipelineFactory,
}

impl Api {
pub fn new() -> Self {
Self {
pipeline_factory: SettlementPipelineFactory,
}
}

pub fn settle(&self, req: SettleRequest) -> Result<SettleResponse, String> {
let ctx = SettlementContext::from_request(req)?;

let pipeline = match ctx.mode {
GameMode::Mode1 => self.pipeline_factory.create_mode1_pipeline(),
GameMode::Mode2 => self.pipeline_factory.create_mode2_pipeline(),
};

pipeline.execute(&ctx)
}
}

这一层就非常清晰了:

  • 请求进来
  • 转成上下文
  • 根据模式选择流程
  • 执行流程
  • 返回结果

此时 Api::settle 已经不是一个“巨石方法”,而是一个真正的入口编排器。


六、为什么这种拆分才算“真正的分治”?

因为它不只是把代码切开了,而是让每一部分都具备独立治理能力。

1. 可独立理解

你看 ScoreCalculationProcessor,知道它只负责算基础奖励。

你看 MasterApprenticeProcessor,知道它只负责师徒加成。

你不需要读完整个结算流程,才能理解局部逻辑。

2. 可独立修改

改师徒系统,不需要冒险碰计分逻辑。

改活动奖励,不需要去翻数据库落库逻辑。

每种变化都有明确落点。

3. 可独立验证

测试也会变得非常自然。

比如测试计分逻辑时,只关心输入分数和输出奖励:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
#[cfg(test)]
mod tests {
use super::*;

#[test]
fn score_processor_should_convert_score_to_reward() {
let ctx = SettlementContext {
mode: GameMode::Mode1,
players: vec![Player { id: 1, base_score: 10 }],
};

let processor = ScoreCalculationProcessor {
calculator: DefaultScoreCalculator,
};

let mut result = SettlementResult::default();
processor.process(&ctx, &mut result).unwrap();

let reward = result.player_rewards.get(&1).unwrap();
assert_eq!(reward.base_reward.coins, 100);
assert_eq!(reward.total_reward.coins, 100);
}

#[test]
fn master_apprentice_processor_should_apply_bonus() {
let mut result = SettlementResult::default();
result.player_rewards.insert(
2,
PlayerReward {
player_id: 2,
base_reward: Reward { coins: 100 },
total_reward: Reward { coins: 100 },
},
);

let processor = MasterApprenticeProcessor {
service: DefaultMasterApprenticeService,
};

let ctx = SettlementContext {
mode: GameMode::Mode1,
players: vec![],
};

processor.process(&ctx, &mut result).unwrap();

let reward = result.player_rewards.get(&2).unwrap();
assert_eq!(reward.total_reward.coins, 120);
}
}

你会发现,这种测试的特点是:

  • 输入很小
  • 依赖很少
  • 断言很精确
  • 执行很快

这就是“可独立验证”。

4. 可灵活组合

新增一个模式时,你大概率只需要:

  • 新增一个 Processor
  • 或新增一个新的 Pipeline 装配方案

而不是冲进那个几百行主函数里再追加一个大分支。

这就是“可灵活组合”。


七、SRP 和限界上下文,本质上解决的是同一个问题

看到这里其实会发现,SRP 和限界上下文虽然一个偏代码、一个偏系统,但它们都在回答同一个根本问题:

边界该怎么画,才能让复杂度停留在局部,而不向全局扩散?

它们的区别只是作用层级不同。

SRP 关注的是:

  • 代码里的变化理由
  • 模块职责是否混杂
  • 修改边界是否清晰

限界上下文关注的是:

  • 业务语义是否一致
  • 模型是否被错误共享
  • 团队协作边界是否清晰

所以可以这样理解:

  • 在代码层级,SRP 是按“变化理由”划边界
  • 在系统层级,Bounded Context 是按“语义一致性”划边界

两者本质上都是在做一件事:

把复杂系统切成高内聚、低耦合、局部可治理的单元。


八、结语:真正好的拆分,不是更碎,而是更稳

我现在越来越觉得,架构设计里最重要的能力,不是“会不会拆”,而是“知不知道什么不该拆”。

因为错误的拆分,往往比不拆更糟。

  • 把本应内聚的逻辑拆散,会带来高耦合
  • 把强语义关联的概念硬拆开,会带来额外通信成本
  • 把本该独立演化的职责揉在一起,会让每次变更都像拆雷

所以,真正有价值的分治,不是为了让代码看起来更模块化,也不是为了迎合某种架构风格,而是为了让复杂度在局部被稳定控制住。

如果一个拆分最终没有带来这些收益:

  • 更容易理解
  • 更容易修改
  • 更容易测试
  • 更容易组合

那它很可能只是“看起来分了”,其实并没有真正治理复杂度。

而我理解中的“真正的分治”,恰恰就在这里:

不是把大问题切碎,而是把每一块都切成可以独立治理的单元。

img

📖 阶段一:索引 (Indexing) —— “把书买回来,做好标签放进图书馆”

这个阶段是离线准备的,就像考试前你需要先整理好复习资料。

  1. 文档加载:你要查资料,首先得有书。这一步就是把你公司里的 PDF、Word、TXT、网页等各种格式的文档全部导进系统里。(进货买书
  2. 文档分块:一整本书太厚了,大模型一口气读不完(受限于上下文窗口)。所以要把长文档切成一小段一小段的(Chunk)。比如按段落切,每段 500 字。(把书撕成一页页的“知识卡片”
  3. 向量嵌入:计算机看不懂汉字,只懂数字。所以要用一个特殊的模型(Embedding 模型),把每一张“知识卡片”变成一串数学坐标(向量)。意思相近的句子,坐标就离得近。(给每张卡片打上“数学条形码”,按语义分类
    • 图中的箭头向下:这些打了条形码的卡片,最终都被存进了下一层楼的向量数据库中,随时备查。

🔍 阶段二:检索 (Retrieval) —— “遇到考试题,去图书馆找相关的书页”

这个阶段是实时发生的,用户开始提问了。

  1. 问题输入:用户问了一个问题,比如:“公司的报销流程是什么?”(看到考试卷上的题目
  2. 向量嵌入:为了能在图书馆里找到答案,必须把用户的“问题”也变成同样的数学坐标(向量)。(把考试题目也翻译成“数学条形码”
  3. 相似匹配 向量数据库:拿着问题的条形码,去向量数据库里扫一圈,计算数学距离(比如余弦相似度)。距离越近,说明卡片上的内容跟问题越相关。(图书管理员拿着你的题目,去书架上比对,找出最相关的卡片

✍️ 阶段三:生成 (Generation) —— “照着找出来的资料,组织语言写出答案”

资料找到了,现在要让大模型开始写作业了。

  1. top_k:数据库里可能匹配出成百上千相关的段落,但我们只要最相关的前几个(K一般设为3或5)。(挑出最最相关的 3 张知识卡片放在桌上
  2. 提示词 Prompt:这是最关键的“拼接”环节。系统会在后台把“用户的原始问题”和“找出来的 top_k 知识卡片”缝合在一起,形成一段固定格式的话。
    • (画外音提示词类似于:“LLM你好,请根据以下参考资料【卡片1、卡片2、卡片3】,回答用户的问题【报销流程是什么?】”)
  3. LLM:大语言模型(像 ChatGPT)接收到了上面拼好的 Prompt。这时候,它不仅知道问题是什么,而且手边就有了正确答案的参考资料。(学生看着考卷题目,同时照着桌上的复习卡片开始作答
  4. 答案输出:LLM 消化了参考资料的内容,用自然人类语言润色后输出给用户。完美避免了幻觉!(交出满分答卷

🧠 一句话记忆口诀:

为了加深记忆,你可以用这句顺口溜记住整张图:

“切碎资料变向量(索引),问题比对找前K(检索),拼进Prompt喂模型(生成)。”

alt text

一、类型状态模式(Typestate Pattern)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
use std::marker::PhantomData;

struct Disconnected;
struct Connected;

struct Socket<State> {
fd: i32,
_state: PhantomData<State>,
}

impl Socket<Disconnected> {
fn connect(self, addr: &str) -> Socket<Connected> {
// ... connect logic ...
Socket { fd: self.fd, _state: PhantomData }
}
}

impl Socket<Connected> {
fn send(&mut self, data: &[u8]) { /* ... */ }
fn disconnect(self) -> Socket<Disconnected> {
Socket { fd: self.fd, _state: PhantomData }
}
}

// Socket<Disconnected> has no send() method — compile error if you try

这段代码展示了 Rust 中一种非常高级且强大的设计模式,叫做 Typestate Pattern(类型状态模式)

它的核心意思是:利用 Rust 的编译检查,确保你永远不会在错误的状态下调用错误的函数。

1.1 直白解释

在普通的编程语言中(比如 C++ 或 Java),你可能会写这样的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
struct Socket {
is_connected: bool,
fd: i32,
}

impl Socket {
fn send(&self, data: &[u8]) {
if !self.is_connected {
panic!("还没连接呢!不能发数据!"); // 这种错误只有在程序运行时才会发现
}
// ...
}
}

但在 Rust 代码里,如果你试图在断开连接时调用 send(),程序根本编译不通过。 编译器会直接告诉你:”对不起,Socket<Disconnected> 这个类型没有 send 方法”。

1.2 实现原理

1.2.1 定义不同的状态(标签)

1
2
struct Disconnected; // 状态 A:断开
struct Connected; // 状态 B:连接

这两个结构体没有任何字段,它们只是”标签”。

1.2.2 让 Socket 携带状态

1
2
3
4
struct Socket<State> {
fd: i32,
_state: PhantomData<State>, // 告诉编译器:这个 Socket 现在处于 State 状态
}

PhantomData 就像一个占位符,它不占内存,只在编译阶段起作用,标记当前的 State 是什么。

1.2.3 为不同状态实现不同的方法

这是最关键的地方:方法是分家写的。

  • 只有 Socket<Disconnected> 才有 connect 方法:

    1
    2
    3
    impl Socket<Disconnected> {
    fn connect(self, ...) -> Socket<Connected> { ... }
    }

    注意:connect 会消耗掉原来的自己(self),返回一个全新的类型 Socket<Connected>

  • 只有 Socket<Connected> 才有 senddisconnect 方法:

    1
    2
    3
    4
    impl Socket<Connected> {
    fn send(&mut self, data: &[u8]) { ... }
    fn disconnect(self) -> Socket<Disconnected> { ... }
    }

1.3 代码示例对比

如果你这么写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
let socket = Socket { fd: 1, _state: PhantomData }; // 默认是 Disconnected

// ❌ 编译报错!
// 编译器会说:Socket<Disconnected> 没定义 send 方法
// 你在写代码阶段就能发现错误,而不是等程序跑起来崩溃
socket.send(b"hello");

// ✅ 必须先 connect
let mut connected_socket = socket.connect("127.0.0.1");

// ✅ 现在可以 send 了,因为类型变成了 Socket<Connected>
connected_socket.send(b"hello");

// ❌ 再次报错!
// 一旦调用 disconnect,变量会被消耗,不能再调用 send
let closed_socket = connected_socket.disconnect();
connected_socket.send(b"bye"); // 编译报错:Value used after move

1.4 优势总结

  1. 状态安全:你不可能忘记检查连接状态,因为编译器替你检查了。
  2. 零成本抽象:这些 PhantomData 和泛型状态只存在于编译期,编译出来的机器码里没有任何多余的 if-else 判断,性能极高。
  3. API 指引:开发者在使用你的库时,编辑器只弹出会展示当前状态下可用的方法。如果你没连接,编辑器压根不会提示你 send 方法。

一句话总结:它把”逻辑错误”变成了”语法错误”。


二、通过关联类型确保协议正确性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
use std::io;

trait IpmiCmd {
type Response;
fn parse_response(&self, raw: &[u8]) -> io::Result<Self::Response>;
}

// Simplified for illustration — see ch02 for the full trait with
// net_fn(), cmd_byte(), payload(), and parse_response().

struct ReadTemp { sensor_id: u8 }
impl IpmiCmd for ReadTemp {
type Response = Celsius;
fn parse_response(&self, raw: &[u8]) -> io::Result<Celsius> {
Ok(Celsius(raw[0] as i8 as f64))
}
}


fn execute<C: IpmiCmd>(cmd: &C, raw: &[u8]) -> io::Result<C::Response> {
cmd.parse_response(raw)
}
// ReadTemp always returns Celsius — can't accidentally get Rpm

这段代码展示了 Rust 中一个非常核心的设计哲学:通过类型系统确保协议正确性(Protocol Correctness),即所谓的”使非法状态不可表达“(Make invalid interactions unrepresentable)。

2.1 核心目标:绑定”请求”与”响应”

在底层协议(如 IPMI、NVMe)中,你发送一个特定的命令(Request),硬件会返回一段二进制数据。传统做法通常是手动解析这些字节,但这容易出错:你可能会不小心用解析”转速”的代码去解析”温度”数据。

这段代码通过 Rust 的 关联类型(Associated Types) 在编译阶段解决了这个问题。

2.2 代码逐段解析

2.2.1 定义 Trait(协议骨架)

1
2
3
4
trait IpmiCmd {
type Response; // 关联类型:每个命令必须定义它对应的返回类型
fn parse_response(&self, raw: &[u8]) -> io::Result<Self::Response>;
}
  • type Response; 是关键。它告诉编译器:任何实现了 IpmiCmd 的类型,都必须明确指定它返回什么类型的数据。

2.2.2 具体命令实现

1
2
3
4
5
6
7
8
9
10
struct ReadTemp { sensor_id: u8 }

impl IpmiCmd for ReadTemp {
type Response = Celsius; // 绑定:ReadTemp 命令只能返回 Celsius 类型

fn parse_response(&self, raw: &[u8]) -> io::Result<Celsius> {
// 将原始字节解析为 Celsius 结构体
Ok(Celsius(raw[0] as i8 as f64))
}
}
  • 这里将 ReadTemp 结构体与 Celsius(摄氏度)类型硬绑定。
  • 如果你尝试在 parse_response 里返回一个 Rpm(转速)类型,编译器会直接报错。

2.2.3 泛型执行函数

1
2
3
fn execute<C: IpmiCmd>(cmd: &C, raw: &[u8]) -> io::Result<C::Response> {
cmd.parse_response(raw)
}
  • 这是一个通用的执行函数。它的返回类型是 C::Response
  • 这意味着:如果你传入的是 ReadTemp,函数返回值的类型在编译时就被确定为 Celsius

2.3 为什么说这实现了”协议正确性”?

“ReadTemp always returns Celsius — can’t accidentally get Rpm”
(ReadTemp 总是返回 Celsius —— 不会意外地得到 Rpm)

  • 传统方式的风险: 在 C 语言或动态语言中,你可能写出 data = execute(READ_TEMP_CMD); rpm = parse_as_rpm(data); 这样的代码。这在逻辑上是错的,但编译器不会阻止你。
  • Rust 的安全性: 在这段代码的设计下,如果你写 let res: Rpm = execute(&read_temp_cmd, raw);代码根本无法通过编译。因为 ReadTempResponse 类型是 Celsius,它与 Rpm 类型不匹配。

2.4 硬件层面的实际应用

例子中提到的 IPMI, Redfish, NVMe Admin commands 都是复杂的硬件管理协议。

  • 这些协议有成百上千个命令。
  • 每个命令对应的二进制响应格式都不同。
  • 使用这种模式,可以为每个命令编写一个结构体,并绑定其特有的响应结构体。这样开发者在调用 API 时,类型系统会自动引导他们写出正确的解析逻辑,极大地减少了底层驱动开发中的 Bug。

2.5 总结

这段代码演示了如何利用 Rust 的 TraitAssociated Types 来建模复杂的交互协议。它将”运行时的协议逻辑错误”转换成了”编译时的类型错误”,从而保证了程序的绝对健壮性。

核心思想:把”逻辑错误”变成”语法错误”。

概览图
在 Rust 中实现”依赖倒置”(Dependency Inversion)非常自然。Rust 使用 Trait 来定义契约,并利用 泛型(Generics)Trait 对象(Trait Objects) 来注入依赖。

一、依赖倒置与订单服务

1.1 定义数据和契约

首先定义业务对象和它需要的操作接口。

1
2
3
4
5
6
7
8
9
10
#[derive(Debug, Clone)]
pub struct Order {
pub id: u64,
pub price: f64,
}

// 1. 定义 "契约" (What) —— 相当于 Go 的 Interface
pub trait OrderRepository: Send + Sync {
fn save(&self, order: &Order) -> Result<(), String>;
}

1.2 实现业务逻辑

在 Rust 中,我们通常使用泛型来实现这种注入。这在编译时就会确定类型,性能极高。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 2. "不变" 的业务逻辑
pub struct OrderService<R: OrderRepository> {
repo: R, // 依赖于契约 R
}

impl<R: OrderRepository> OrderService<R> {
// 构造函数,注入具体的实现
pub fn new(repo: R) -> Self {
Self { repo }
}

pub fn create_order(&self, order: Order) -> Result<(), String> {
// 1. 核心业务逻辑 (100% 纯粹)
if order.price < 0.0 {
return Err("价格错误".to_string());
}

// 2. 调用 "契约",不关心底层是 MySQL, PostgreSQL 还是内存
self.repo.save(&order)
}
}

1.3 单元测试

有了抽象,测试业务逻辑就变得非常简单,不需要启动任何数据库。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;

// 3. 定义一个 Mock 实现
struct MockRepo {
saved_orders: Mutex<Vec<Order>>,
}

impl OrderRepository for MockRepo {
fn save(&self, order: &Order) -> Result<(), String> {
let mut orders = self.saved_orders.lock().unwrap();
orders.push(order.clone());
Ok(())
}
}

#[test]
fn test_create_order_logic() {
// 准备 Mock 环境
let mock_repo = MockRepo { saved_orders: Mutex::new(vec![]) };
let service = OrderService::new(mock_repo);

// 测试业务逻辑:价格错误
let bad_order = Order { id: 1, price: -10.0 };
assert!(service.create_order(bad_order).is_err());

// 测试业务逻辑:成功保存
let good_order = Order { id: 2, price: 100.0 };
assert!(service.create_order(good_order).is_ok());
}
}

1.4 生产环境的真实实现

在生产代码中,你才去实现真正的数据库逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
struct SqliteRepository {
// db: SqlitePool,
}

impl OrderRepository for SqliteRepository {
fn save(&self, order: &Order) -> Result<(), String> {
println!("正在将订单 {} 写入 SQLite 数据库...", order.id);
// 这里写真正的 SQL 逻辑
Ok(())
}
}

fn main() {
// 在程序入口处(组合根)完成注入
let repo = SqliteRepository {};
let service = OrderService::new(repo);

let _ = service.create_order(Order { id: 101, price: 50.0 });
}

二、装饰器模式:缓存封装

我们延续之前的例子,展示如何在不改动 OrderService 的情况下,像”套娃”一样把 Redis 功能套上去。

2.1 定义核心契约

1
2
3
4
5
6
7
8
9
use async_trait::async_trait;

#[derive(Debug, Clone)]
pub struct Order { pub id: u64, pub price: f64 }

#[async_trait]
pub trait OrderRepository: Send + Sync {
async fn save(&self, order: &Order) -> Result<(), String>;
}

2.2 数据库实现

1
2
3
4
5
6
7
8
9
pub struct SqliteRepo;

#[async_trait]
impl OrderRepository for SqliteRepo {
async fn save(&self, order: &Order) -> Result<(), String> {
println!(" [DB层] 正在将订单 {} 存入 Sqlite...", order.id);
Ok(())
}
}

2.3 缓存装饰器

重点: 它也实现 OrderRepository,但它内部持有另一个实现了该 Trait 的类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
pub struct CachedOrderRepo<T: OrderRepository> {
inner: T, // "下一层"是谁?我不关心,只要它实现了 OrderRepository
redis_addr: String, // 模拟 Redis 连接
}

impl<T: OrderRepository> CachedOrderRepo<T> {
pub fn new(inner: T, addr: &str) -> Self {
Self { inner, redis_addr: addr.to_string() }
}
}

#[async_trait]
impl<T: OrderRepository> OrderRepository for CachedOrderRepo<T> {
async fn save(&self, order: &Order) -> Result<(), String> {
// 1. 调用下一层(底层的数据库写入)
self.inner.save(order).await?;

// 2. 增加"副作用":写入缓存
println!(" [缓存层] 正在将订单 {} 写入 Redis ({})", order.id, self.redis_addr);

Ok(())
}
}

2.4 业务逻辑层——保持不变

注意: 这里的代码和加缓存之前完全一模一样。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
pub struct OrderService<R: OrderRepository> {
repo: R,
}

impl<R: OrderRepository> OrderService<R> {
pub fn new(repo: R) -> Self { Self { repo } }

pub async fn create_order(&self, order: Order) -> Result<(), String> {
// 核心业务校验
if order.price < 0.0 { return Err("价格错误".to_string()); }

// 直接调用契约
self.repo.save(&order).await
}
}

2.5 程序入口处组装

这是见证奇迹的时刻:你通过改变对象的组合方式,改变了系统的行为,而没有修改业务逻辑代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#[tokio::main]
async fn main() {
// --- 方案 A: 原始版本 ---
// let repo = SqliteRepo;
// let service = OrderService::new(repo);

// --- 方案 B: 带缓存的版本 ---
let sqlite_repo = SqliteRepo;
// 像套娃一样,把 sqlite 塞进 cached repo
let cached_repo = CachedOrderRepo::new(sqlite_repo, "redis://127.0.0.1");
// 把装好的 cached_repo 塞进 service
let service = OrderService::new(cached_repo);

// 调用
let my_order = Order { id: 888, price: 99.0 };
service.create_order(my_order).await.unwrap();
}

2.6 这种写法的优势

  1. 零成本抽象 (Zero-cost Abstraction):
    如果你使用泛型,Rust 编译器在编译时会进行”单态化”(Monomorphization)。它会直接生成一套专门调用 Sqlite 后接 Redis 的机器码。没有运行时的虚函数表查找开销,性能等同于你手写的硬编码代码。

  2. 职责单一 (SRP):

    • OrderService: 只管业务逻辑(价格对不对)。
    • SqliteRepo: 只管 SQL 怎么写。
    • CachedOrderRepo: 只管 Redis 怎么存。
      如果你下周要加 Kafka,你只需要再写一个 KafkaOrderRepo 并在 main 函数里再套一层。
  3. 极度好写测试:

    • 你想测 OrderService?给它塞一个 MockRepo(不连 DB,不连 Redis)。
    • 你想测 CachedOrderRepo 的缓存逻辑?给它塞一个 MockRepo 作为 inner
  4. 符合开闭原则 (OCP):
    软件实体应对扩展开放,对修改关闭。你通过增加新类实现了新功能,而不是通过修改旧代码实现新功能。

三、重试机制

这就是装饰器模式最迷人的地方。我们可以非常轻松地再加一层”重试”逻辑,而无需触碰现有的 OrderServiceSqliteRepoCachedOrderRepo 的任何代码。

3.1 RetryOrderRepo 实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
pub struct RetryOrderRepo<T: OrderRepository> {
inner: T,
max_attempts: usize,
}

impl<T: OrderRepository> RetryOrderRepo<T> {
pub fn new(inner: T, max_attempts: usize) -> Self {
Self { inner, max_attempts }
}
}

#[async_trait]
impl<T: OrderRepository> OrderRepository for RetryOrderRepo<T> {
async fn save(&self, order: &Order) -> Result<(), String> {
let mut last_error = String::new();

for attempt in 1..=self.max_attempts {
println!(" [重试层] 正在进行第 {} 次尝试...", attempt);

match self.inner.save(order).await {
Ok(()) => return Ok(()), // 成功了,直接返回
Err(e) => {
last_error = e;
println!(" [重试层] 第 {} 次尝试失败: {}", attempt, last_error);
}
}
}

Err(format!("在 {} 次重试后仍然失败: {}", self.max_attempts, last_error))
}
}

3.2 套娃组装

现在的组装顺序是:
Service -> Retry (重试) -> Cache (缓存) -> Sqlite (数据库)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#[tokio::main]
async fn main() {
// 1. 最底层:数据库
let sqlite_repo = SqliteRepo;

// 2. 中间层:加个缓存
let cached_repo = CachedOrderRepo::new(sqlite_repo, "redis://127.0.0.1");

// 3. 最外层包装:加个重试(比如最多尝试 3 次)
let retry_repo = RetryOrderRepo::new(cached_repo, 3);

// 4. 注入业务层
let service = OrderService::new(retry_repo);

// 运行
let my_order = Order { id: 999, price: 150.0 };
if let Err(e) = service.create_order(my_order).await {
println!("最终执行失败: {}", e);
} else {
println!("最终执行成功!");
}
}

3.3 套娃顺序的讲究

  1. Retry 包裹 Cache (Retry(Cache(DB))):

    • 语义:如果”写DB+写缓存”整体失败了,就重试整个过程。
    • 适用场景:你希望保证缓存和数据库最终一致。
  2. Cache 包裹 Retry (Cache(Retry(DB))):

    • 语义:重试只针对 DB 写入。只有 DB 真的写成功了,才去写一次缓存。
    • 适用场景:你认为缓存写入通常不会失败,只有 DB 写入需要重试。
  3. Logging 包裹一切 (Log(Retry(Cache(DB)))):

    • 语义:记录最外层的请求和最终结果。

四、防腐层与短信服务

这个例子展示了架构设计中极其重要的 “防腐层”(Anti-Corruption Layer, ACL) 概念。

4.1 定义短信契约与业务层

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
use async_trait::async_trait;

// --- 1. 定义契约 (What) ---
#[async_trait]
pub trait SmsService: Send + Sync {
async fn send(&self, phone: &str, code: &str) -> Result<(), String>;
}

// --- 2. 业务逻辑只依赖契约 ---
pub struct UserService<S: SmsService> {
sms: S, // 也可以用 Box<dyn SmsService> 支持运行时切换
}

impl<S: SmsService> UserService<S> {
pub fn new(sms: S) -> Self { Self { sms } }

pub async fn register(&self, phone: &str) -> Result<(), String> {
println!("[业务逻辑] 开始注册用户: {}", phone);
let code = "123456"; // 模拟生成验证码

// 调用契约,不关心背后是哪家云厂商
self.sms.send(phone, code).await?;

println!("[业务逻辑] 注册成功!");
Ok(())
}
}

4.2 多种发送方式实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 实现 A:腾讯云 (生产环境)
pub struct TencentSms { api_key: String }
#[async_trait]
impl SmsService for TencentSms {
async fn send(&self, phone: &str, code: &str) -> Result<(), String> {
println!(" [腾讯云] 调用 SDK 发送短信至 {},验证码:{}", phone, code);
Ok(())
}
}

// 实现 B:阿里云 (备用环境)
pub struct AliyunSms { secret_id: String }
#[async_trait]
impl SmsService for AliyunSms {
async fn send(&self, phone: &str, code: &str) -> Result<(), String> {
println!(" [阿里云] 调用 SDK 发送短信至 {},验证码:{}", phone, code);
Ok(())
}
}

// 实现 C:日志短信 (本地开发/单元测试)
pub struct LogSms;
#[async_trait]
impl SmsService for LogSms {
async fn send(&self, phone: &str, code: &str) -> Result<(), String> {
// 不花钱,不连网,只打日志
println!(" [本地模拟] 模拟发送短信至 {},验证码:{}", phone, code);
Ok(())
}
}

4.3 不同场景的组装

1
2
3
4
5
6
7
8
9
10
11
12
#[tokio::main]
async fn main() {
// 场景 1:本地开发
// 只需要传入 LogSms,不需要配置任何云账号,不需要联网
let dev_service = UserService::new(LogSms);
dev_service.register("13800138000").await.unwrap();

// 场景 2:线上生产(使用腾讯云)
let prod_sms = TencentSms { api_key: "TX_123".to_string() };
let prod_service = UserService::new(prod_sms);
prod_service.register("13911112222").await.unwrap();
}

4.4 深度价值

4.4.1 彻底解决”测试地狱”

如果没有这个 Trait,你写单元测试时必须处理腾讯云 SDK 的初始化、网络连接等。
有了 Trait,你在测试文件里写一个 struct TestSms,在 send 方法里直接 return Ok(())你的测试运行速度会从秒级降至毫秒级,且不消耗任何短信资费。

4.4.2 实现防腐层 (Anti-Corruption)

腾讯云 SDK 的 API 设计可能很古怪。如果你直接在 UserService 里用,你的业务逻辑就被腾讯的 API 风格”污染”了。

  • Trait 就像一堵墙:墙内是纯粹的业务语言(phone, code),墙外是肮脏的第三方 SDK 细节。
  • 更换供应商:换阿里云时,你只需要写一个新的 AliyunSms 实现类,业务代码一行都不用动。

4.5 运行时容灾切换

你可以利用 Rust 的 dyn SmsService 实现一个”智能切换器”:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
pub struct FailoverSms {
primary: TencentSms,
secondary: AliyunSms,
}

#[async_trait]
impl SmsService for FailoverSms {
async fn send(&self, phone: &str, code: &str) -> Result<(), String> {
// 先尝试腾讯云
match self.primary.send(phone, code).await {
Ok(_) => Ok(()),
Err(_) => {
// 如果腾讯云挂了,自动切换到阿里云
println!("!!! 腾讯云异常,正在切换到阿里云备用通道...");
self.secondary.send(phone, code).await
}
}
}
}

这种强大的容灾能力,对于业务层 UserService 来说是完全透明的! 业务层只管调 send,它根本不知道背后发生了惊心动魄的线路切换。

0%