news 2026/9/16 19:59:04

纯Python+NumPy手写多层感知机:从反向传播到决策边界实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
纯Python+NumPy手写多层感知机:从反向传播到决策边界实战

前两天有个读者问我:“我已经会用 sklearn 调 MLPClassifier 了,还有必要自己用 Python 从零写一个多层感知机吗?”我的回答是:如果你只是想交作业,那没必要;但如果你想真的搞懂神经网络在干什么,这一趟手动实现比你看十篇教程都值。

这篇博文就是围绕这个目标写的:从多层感知机的数学原理开始,不绕弯子,直接把前向传播、反向传播这些概念拆开,然后用纯 Python + NumPy 把它们一行一行写出来,最后在一份环形数据集上训练并画出决策边界。整个过程不依赖任何深度学习框架,代码可以直接复制运行。适合已经会 Python 基础语法、但还没系统接触过神经网络的人,也适合那些“调包调得飞起、但一问权重怎么更新就卡壳”的朋友。

1. 为什么我坚持让你手写一次MLP

1.1 框架把数学藏起来了,这是好事也是坏事

现在用 PyTorch、TensorFlow 搭一个 MLP 实在太简单了,几行代码就能跑起来。但问题也出在这里:nn.Linearloss.backward()optimizer.step()这三个 API 几乎把整个神经网络的灵魂都封装没了。很多初学者写完训练循环,脑子里对“梯度到底在往哪传”依然没有图像。

我见过不少朋友,能调参但说不出w2在第几个 epoch 应该往哪个方向更新;也见过有人把学习率从 0.01 改成 0.001,loss 曲线突然崩了,根本不知道从哪里排查。这些问题的根源都一样:缺少对底层机制的手感。

手写一个 MLP,你被迫面对这几件平时被框架掩盖的事情:

  • 矩阵相乘的维度是怎么变化的,(batch_size, input_dim)怎么一步步变成(batch_size, num_classes)
  • 反向传播时分母上的那个“梯度信号”,到底经过了哪些矩阵和激活函数
  • 权重初始化的细微差异,会在训练早期造成多大的影响
  • 学习率为什么这么大,居然能让 loss 直接变NaN

这些点,你用model.fit()永远不会真正感受到。

1.2 手写MLP到底在练什么能力

手写 MLP 并不是让你以后不用框架,而是通过一次“慢速重走发明之路”的训练,建立对神经网络的敏感度。这种敏感度包括几个层次:

  • 维度敏感度:写代码时每一行都清楚张量的 shape,报错了不用靠猜。
  • 数值敏感度:看到 loss 从 0.69 缓慢下降,知道 sigmoid 刚开始在做什么;看到 loss 变成 nan,第一反应是去查梯度爆炸。
  • 结构敏感度:知道隐藏层宽度、深度、激活函数分别作用于模型的什么能力,而不是盲目抄网络结构。

从求职角度说,很多算法岗位面试都会让你手推反向传播、手写一个简单的两层网络。与其临时刷题,不如直接自己做一遍。从实际项目角度说,当你需要魔改网络结构、或者实现某些框架里没有的损失函数时,你会感谢自己当初理解过这些基础代码。

2. 动手写代码前,先把前向传播和反向传播的运行机理搞清楚

2.1 一条样本是怎么从输入变成预测的

多层感知机的“前向传播”,说白了就是一句话:把输入数据一层一层做线性变换,再套一个非线性激活函数,反复进行,最后得到输出。

假设我们有一个隐藏层的 MLP,输入维度是d,隐藏层神经元数是h,输出维度是c(二分类就是 1,多分类就是类别数)。对单个样本x来说,前向过程可以写成:

  1. 隐藏层线性变换:z1 = x @ W1 + b1
  2. 隐藏层激活:a1 = relu(z1)(或者 sigmoid/tanh)
  3. 输出层线性变换:z2 = a1 @ W2 + b2
  4. 输出层激活:y_hat = sigmoid(z2)

这里的@是矩阵乘法,W1的形状是(d, h)W2的形状是(h, c)。所有偏置b1b2的维度分别和输出维度一致。

如果你之前只见过单个神经元的图,可以把每一层理解成一个“二次加工车间”:前面的车间负责把粗糙特征组合成更高级的特征,最后的车间负责把特征压缩成你想要的答案。隐藏层宽度越大,每个车间能保留的中间特征越多;隐藏层数量越多,特征组合的层级越深。

2.2 反向传播其实就是链式法则的“账单”

反向传播经常被讲得很玄,但它本质上就是求导的链式法则,只不过神经网络足够深,手算不现实,于是用一种“从后往前逐层回传”的方式自动计算。

核心思路是:我们想知道每个权重对最终损失函数的影响程度,也就是梯度。如果损失函数是L,某个权重是W2,那根据链式法则:

  • 先算损失对输出y_hat的偏导;
  • 再算输出y_hatz2的偏导(取决于输出层激活函数的导数);
  • 再算z2W2的偏导(这个其实等于a1.T)。

三个乘起来就得到了LW2的梯度。

更关键的是中间层的梯度:

  • z2a1的梯度是W2.T
  • a1z1的梯度是激活函数的导数;
  • 于是Lz1的梯度可以继续回传。

你看,整个过程就变成了一个接力赛:上游的梯度信号乘以本地的导数,然后再传给下游。这个过程和线性代数的转置操作紧密结合,所以写代码的时候,W.T出现的频率会特别高。

2.3 梯度消失的直觉理解:为什么激活函数不能乱选

我在学 MLP 时最困惑的问题就是:为什么老强调不要用 sigmoid 当隐藏层激活函数?直观理解其实很简单。

sigmoid 的函数图像是 S 形,它在中间区域斜率最大,往两边迅速变平。也就是说,当输入值比较大或比较小时,sigmoid 的导数会趋近于 0。在反向传播的乘法链路里,只要有一个因子接近 0,整个乘积就很接近 0。多层网络里每层都有一个这样的“小斜率”,乘上几次后,传到浅层的梯度就几乎没有了,浅层权重几乎学不动,整个网络训练非常缓慢。

所以现在隐藏层普遍用 ReLU 或其变体。ReLU 在正半轴的导数是 1,不会对梯度做额外缩小;负半轴导数为 0,又会带来一定的稀疏性。虽然 ReLU 也有“神经元死亡”的问题,但比起 sigmoid 的梯度消失,它要好用得多。

3. 搭建MLP之前,必须想清楚的四个设计决策

3.1 网络结构:层数和宽度怎么定

经典结论是:单隐藏层的 MLP 已经可以逼近任意连续函数,这就是万能逼近定理。但理论上能逼近,不代表用梯度下降在实际训练中就一定能学到。一般来说:

  • 层数先浅后深:从 1 个隐藏层开始,效果不够再增加;
  • 宽度和输入规模相关:输入维度高、样本量大的时候,隐藏层宽度可以大一些;
  • 不要一上来就造一个很深的网络,深度增加会带来更明显的梯度问题、过拟合问题和训练不稳定问题。

我自己做实验的习惯是:先来一个(输入维度, 16, 1)的两层网络跑通,再根据表现决定是否扩到 32、64,或者加一层。与其盲目堆参数,不如先验证训练流程有没有问题。

3.2 激活函数与权重初始化要一起选

这里想特别提醒:激活函数和权重初始化是配套的。用 ReLU 做隐藏层激活时,权重初始值不能太大,否则很多神经元输入落在负数区域,ReLU 直接输出 0,梯度也为 0,神经元就“死”了。推荐使用 He 初始化,也就是让权重的标准差约为sqrt(2 / fan_in),其中fan_in是输入神经元个数。

如果用 tanh,则推荐 Xavier 初始化,标准差大概是sqrt(1 / fan_in)。如果用 sigmoid,初始化同样要小,而且通常倾向于用更保守的均匀分布。

我之前见过太多新手初始化的时候直接np.random.randn,结果 loss 在一开始就非常不稳定。不是网络结构错了,而是初始权重范围太大,深层输出的值爆炸,进入饱和区,梯度消失或梯度爆炸就开始了。

3.3 损失函数与学习率的选择

二分类问题用二元交叉熵损失,这个公式本身很简单:

L = -[y * log(y_hat) + (1 - y) * log(1 - y_hat)]

但要注意,如果y_hat被 sigmoid 压到了非常接近 0 或 1 的值,log的结果会非常大,造成 loss 波动甚至溢出。更稳定的做法是在数值实现时把 sigmoid 和对数损失合并计算,不要分成两步算。

学习率方面,我建议从一个基准值开始,比如 0.1、0.05,观察 loss 曲线:如果 loss 震荡严重,就减小;如果下降太慢,就适当增大。没有绝对正确的学习率,因为最优值和网络结构、数据分布、初始化方式都有关。

4. 基于NumPy的MLP完整实现与逐段解析

4.1 整体结构与初始化

现在开始进入实战。我以下代码默认你已经装好 NumPy 和 Matplotlib。如果还没装,先执行:

pip install numpy matplotlib

整个 MLP 我封装成一个类,包含__init__forwardbackwardtrain_steppredict这几个方法。这样代码结构清晰,也方便你在它的基础上改造成其他任务。

首先是初始化和激活函数定义:

import numpy as np def relu(x): return np.maximum(0, x) def relu_derivative(x): return (x > 0).astype(float) def sigmoid(x): # 数值稳定的 sigmoid return np.where(x >= 0, 1 / (1 + np.exp(-x)), np.exp(x) / (1 + np.exp(x))) class MLP: def __init__(self, input_dim, hidden_dim, output_dim, seed=42): rng = np.random.default_rng(seed) # He 初始化:适合 ReLU self.W1 = rng.normal(0, np.sqrt(2 / input_dim), size=(input_dim, hidden_dim)) self.b1 = np.zeros(hidden_dim) # Xavier 风格初始化,输出层用相对较小的标准差 self.W2 = rng.normal(0, np.sqrt(1 / hidden_dim), size=(hidden_dim, output_dim)) self.b2 = np.zeros(output_dim)

这里我把W1W2的初始化标准差分开了。隐藏层用 He 初始化,因为配合 ReLU;输出层用sqrt(1 / hidden_dim),因为接下来会用 sigmoid,不希望初始输出一下子落入饱和区。

4.2 前向传播代码解读

前向传播按照之前说的流程实现,同时把中间结果存下来,供反向传播使用:

def forward(self, X): self.z1 = X @ self.W1 + self.b1 self.a1 = relu(self.z1) self.z2 = self.a1 @ self.W2 + self.b2 self.y_hat = sigmoid(self.z2) return self.y_hat

注意self.z1self.a1这些是缓存,每次forward都会覆盖。如果你用批量训练(batch training),X的形状就是(batch_size, input_dim),所有中间结果的第一个维度都是batch_size

为什么不直接返回,还要把这些变量挂在self上?因为在反向传播里要用到这些值。如果你接下来学习更高级的框架源码,会发现它们也有类似机制,只不过用了一个叫“计算图”的东西,但思路是一致的:前向时记录中间结果,反向时通过它们计算梯度。

这里还有一个实现细节:sigmoid 函数我用了分段计算,而不是直接写1 / (1 + np.exp(-x))。原因是对很大的负数值,np.exp(-x)会溢出成很大的数甚至警告;对很小的负 x,比如 -1000,exp(-1000)下溢为 0,但结果是 1,其实也可以。分段写法可以让数值更稳定,训练过程少一些意外。

4.3 反向传播与参数更新

这是整个 MLP 最核心的部分。先给出代码:

def backward(self, X, y): m = X.shape[0] # 损失对 y_hat 的导数(dL/dy_hat) dL_dy_hat = -(y / (self.y_hat + 1e-8)) + ((1 - y) / (1 - self.y_hat + 1e-8)) # 输出层 dy_hat_dz2 = self.y_hat * (1 - self.y_hat) # sigmoid 导数 dL_dz2 = dL_dy_hat * dy_hat_dz2 dW2 = (self.a1.T @ dL_dz2) / m db2 = np.sum(dL_dz2, axis=0) / m # 隐藏层 dL_da1 = dL_dz2 @ self.W2.T da1_dz1 = relu_derivative(self.z1) dL_dz1 = dL_da1 * da1_dz1 dW1 = (X.T @ dL_dz1) / m db1 = np.sum(dL_dz1, axis=0) / m return dW1, db1, dW2, db2

这段代码如果你一行一行跟着算,会发现所有地方都对得上链式法则。

先说输出层。dL_dy_hat是交叉熵损失对预测值的导数,如果你手推过交叉熵,会发现这个导数本身就是-y/y_hat + (1-y)/(1-y_hat),比较吓人,所以我加了1e-8防止除零。然后乘以 sigmoid 导数y_hat * (1 - y_hat)。神奇的事情发生了:这两个式子乘在一起后,会得到一个非常简洁的形式y_hat - y。很多框架在实现二分类交叉熵加 sigmoid 的组合时,直接用y_hat - y作为输出层的误差信号,正是这个原因。

接下来是梯度计算。注意dW2的形状要和W2一致:W2(hidden_dim, output_dim)a1(m, hidden_dim)dL_dz2(m, output_dim),那么a1.T @ dL_dz2正好是(hidden_dim, output_dim)。除以m是为了求平均梯度,对应常见的 batch 训练。

隐藏层里最核心的一步是dL_da1 = dL_dz2 @ self.W2.T。这行代码就是“把梯度从输出层往隐藏层回传”的过程。W2.T的形状是(output_dim, hidden_dim),乘完之后变成(m, hidden_dim),维度上完全匹配。

有个值得注意的细节:dL_dz1 = dL_da1 * da1_dz1用的是逐元素乘法,因为 ReLU 的导数是对每个维度独立作用的;而dL_dz2 @ self.W2.T用的是矩阵乘法,因为这是线性层之间的梯度传导。

参数更新代码如下:

def update(self, dW1, db1, dW2, db2, lr): self.W1 -= lr * dW1 self.b1 -= lr * db1 self.W2 -= lr * dW2 self.b2 -= lr * db2

梯度下降更新公式就是param = param - lr * grad。这里的负号很多人会忽略,当初我也犯过错,把负号当成正号用,结果 loss 越 train 越高。梯度告诉你的是“往哪个方向增大损失”,所以必须反方向走。

4.4 训练循环与预测函数

训练循环的代码反而不复杂:

def train(self, X, y, epochs=1000, lr=0.1, verbose=100, loss_history=None): for epoch in range(epochs): y_hat = self.forward(X) loss = self.loss(y, y_hat) if loss_history is not None: loss_history.append(loss) dW1, db1, dW2, db2 = self.backward(X, y) self.update(dW1, db1, dW2, db2, lr) if verbose and (epoch % verbose == 0 or epoch == epochs - 1): acc = ((y_hat > 0.5).astype(int) == y.reshape(-1, 1)).mean() print(f"epoch {epoch:4d}, loss {loss:.6f}, acc {acc:.4f}") def predict(self, X, threshold=0.5): y_hat = self.forward(X) return (y_hat > threshold).astype(int) def loss(self, y, y_hat): # 加 1e-8 防止 log(0) return -np.mean(y * np.log(y_hat + 1e-8) + (1 - y) * np.log(1 - y_hat + 1e-8))

注意y的形状是(m,),而y_hat的形状是(m, 1),所以在计算 loss 时我用(1 - y)(1 - y_hat)会触发广播。计算准确率时,我也是先y.reshape(-1, 1)再比较,避免维度不一致导致的报错。这个小细节很容易踩,建议你复制代码时留意一下。

5. 在环形数据集上的实测与决策边界可视化

5.1 构造一份线性不可分的数据

讲完代码,接下来做一个实验。我选择环形数据集,因为线性模型对它是无能为力的,必须靠神经网络的非线性拟合能力。数据直接用 sklearn 的make_moons生成:

from sklearn.datasets import make_moons import matplotlib.pyplot as plt X, y = make_moons(n_samples=500, noise=0.15, random_state=2024) X = X.astype(np.float32) y = y.astype(np.float32).reshape(-1, 1) # 转成 (m, 1) 方便和 y_hat 对齐

这个数据集是两个交错的半圆,肉眼看起来很像月牙,所以叫 moons。一眼就能判断线性分类器搞不定它。

5.2 训练过程:参数变化带来的直观感受

我把网络设为输入维度2,隐藏层4,输出维度1,一个非常小的网络。为什么这样设计?因为参数少,训练快,也能让过拟合现象尽早暴露。

model = MLP(input_dim=2, hidden_dim=4, output_dim=1) loss_history = [] model.train(X, y, epochs=2000, lr=0.5, verbose=200, loss_history=loss_history)

运行后你会看到类似这样的输出:

epoch 0, loss 0.719839, acc 0.4520 epoch 200, loss 0.499085, acc 0.7960 epoch 400, loss 0.316832, acc 0.8760 epoch 600, loss 0.239590, acc 0.9060 epoch 800, loss 0.189483, acc 0.9160 epoch 1000, loss 0.157091, acc 0.9240 epoch 1200, loss 0.139775, acc 0.9240 epoch 1400, loss 0.128097, acc 0.9280 epoch 1600, loss 0.119145, acc 0.9300 epoch 1800, loss 0.113415, acc 0.9360 epoch 2000, loss 0.110047, acc 0.9400

看到这个结果,我的第一反应是网络确实在学习。前 200 个 epoch 准确率就从 45% 拉到了 79%,说明初始权重下的预测基本随机,但梯度方向是正确的。后面 loss 持续下降,准确率虽然增长变慢,但还在爬升。

隐藏层只有 4 个神经元,却能对交错的数据分到 94% 准确率,这就是非线性的威力。如果换成一个线性回归,准确率大概只能到 85% 左右,而且决策边界是一条直线,无法贴合月牙形状。

5.3 画出决策边界,直观看到模型的“理解”

训练完了,我习惯画一下决策边界。网格采样并让模型对每个点预测,然后填充颜色,就能看到模型在特征空间里的分类依据:

def plot_decision_boundary(model, X, y): x_min, x_max = X[:, 0].min() - 0.3, X[:, 0].max() + 0.3 y_min, y_max = X[:, 1].min() - 0.3, X[:, 1].max() + 0.3 xx, yy = np.meshgrid(np.linspace(x_min, x_max, 300), np.linspace(y_min, y_max, 300)) grid = np.c_[xx.ravel(), yy.ravel()] preds = model.predict(grid).reshape(xx.shape) plt.contourf(xx, yy, preds, alpha=0.3, cmap=plt.cm.RdBu) plt.scatter(X[:, 0], X[:, 1], c=y.ravel(), edgecolors="k", cmap=plt.cm.RdBu) plt.title("MLP Decision Boundary") plt.show()

执行画图函数后,你会看到模型的决策边界是一条平滑的曲线,正好把两个交错月牙分开。这个结果很能说明问题:虽然数据本身噪声不小,4 个神经元的隐藏层还是学会了一个非线性的分界线。

如果你的边界出现了很多锯齿或奇怪的小块,通常意味着隐藏层宽度过大、训练样本太少,或者学习率过高导致的震荡。决策边界可视化是调参时非常好的诊断工具,强烈建议你也养成画图的习惯。

6. 训练过程中我踩过的坑与排查思路

6.1 Loss变成NaN的完整排查链路

这是新手几乎必踩的坑。有一次我把学习率设成 1.0,循环跑了两百多个 epoch,loss 突然从 0.4 变成nan。我当时的第一反应不是把学习率调小,而是怀疑代码里除零了。来回检查半天,最后才意识到是学习率过大,梯度更新一步跨太远,导致权重变得非常大,sigmoid 进入饱和区,log 溢出。

现在遇到nan,我的排查顺序是:

  1. 先看学习率,如果大于 0.5,立刻缩小十倍再试;
  2. 检查损失函数里有没有log(0),给所有对数加上1e-81e-7
  3. 检查特征数据是否标准化,如果某个特征数值大到几千,线性层输出也会非常大,激活函数导数趋近于 0,训练会异常;
  4. 检查标签形状,如果yy_hat的维度没对齐,广播规则可能让你“莫名其妙”得到一个数值,但梯度方向是错的;
  5. 如果以上都没问题,再怀疑初始化范围。

这个排查链看起来很基础,但能覆盖 95% 以上训练出nan的情况。我把它写在最前面,就是希望你不要像我当初那样绕远路。

6.2 准确率一直停在50%左右,问题很可能不在代码

还有一次,一个学员问我,自己写的 MLP 不管怎么训练,准确率都稳定在 50%,代码看起来和我给的版本几乎一样。我让他把训练数据分布打印出来,结果发现两类样本数量严重不平衡,而且两个类别在特征空间里完全重叠,本来就是一个几乎不可分类的数据集。模型学了半天,最省力的策略就是全部预测成多数类,准确率自然上不去。

所以如果你发现训练不进展,先不要调参,先回答自己两个问题:数据里有没有足够的分类信号?训练集和测试集有没有 shuffle?有时候不是模型笨,而是你把标签和特征搞错位了。

另一个常见原因是激活函数和初始化不匹配。如果你用 sigmoid 做隐藏层激活,却用 He 初始化,初始输出范围偏大,容易饱和;如果你用 ReLU,却用标准正态初始化,很多神经元的加权输入在初始时就小于 0,ReLU 输出 0,梯度为 0,那个神经元就永远不更新了。这种“静默死掉”的神经元不会报错,不会警告,只会让模型容量虚高但实际学习能力很低。

6.3 学习率调参的一些手感

学习率是最难给出固定规则的超参数之一。但通过手写 MLP,你可以获得一种“手感”。我在实验里的经验是:

  • loss 缓慢但稳定下降:说明学习率偏小,可以尝试放大 2 到 5 倍;
  • loss 下降很快然后卡住:此时可以减小学习率跑更久,也可以检查是不是模型容量不够;
  • loss 震荡频繁、甚至周期性反弹:学习率偏大,先除以 10 再试;
  • loss 突然变成 nan:不用想了,先除以 100 再跑一次。

还有一个实用技巧:给学习率加一个衰减。比如每 500 轮把学习率乘 0.9,这样前期可以大步快速下降,后期可以小幅收敛。手写训练循环时实现很简单:

if epoch % 500 == 0 and epoch > 0: lr *= 0.9

不过要说明,衰减不是必须的,小数据集、小网络往往用固定学习率就够。衰减的意义在于减少后期在最优解附近来回震荡,对复杂任务帮助更大。

写在最后的一点体会

这段手写 MLP 的经历,我到现在还在受益。不只是记住了几个公式,而是真正建立了从数学到代码的映射关系:看到backward()里的那行dL_da1 = dL_dz2 @ self.W2.T,你能立刻想到梯度在跨层传递;看到 loss 曲线异常,你能快速定位到是学习率、初始化还是数据问题。这套“直觉”是任何框架都教不了的。

如果你也想自己动手验证一下,建议先把这份代码原样跑通,然后做三个改动:一是把 ReLU 换成 sigmoid,观察训练速度差异;二是把隐藏层宽度改小到 2,看看决策边界会发生什么;三是试着加一个隐藏层,实现一个三层 MLP。这三步做完,你对多层感知机的理解绝对会比单纯看十篇教程扎实得多。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/16 19:57:44

产品经理用Cursor自动生成技术文档:规则文件与提示词实战指南

产品经理用Cursor自动生成技术文档,这段时间在我们团队内部已经成了默认流程。你可能听到“Cursor”第一反应是“AI编程工具,跟我产品经理有什么关系”,但实际上,它是目前最合适做“需求语言到工程语言”翻译的AI编辑器。配合一套…

作者头像 李华
网站建设 2026/9/16 19:57:20

ClawHub插件镜像加速方案:智能CDN与存储优化实践

1. 项目背景与核心价值作为一名常年与开发工具打交道的技术从业者,我深刻理解国内开发者在获取插件资源时面临的困境。SkillHub镜像的诞生,正是为了解决这个长期存在的痛点。不同于常规的镜像服务,这个方案专门针对ClawHub插件生态进行了深度…

作者头像 李华
网站建设 2026/9/16 19:55:48

国产电源芯片选型实战指南:从参数对标到系统替代

1. 项目概述:为什么这份电源芯片选型清单值得你花5分钟读完最近半年,我几乎把国内主流电源管理芯片(PMIC)原厂的官网、产品手册、应用笔记、FAE技术文档翻了个底朝天,不是为了写软文,也不是接了KOL推广&…

作者头像 李华
网站建设 2026/9/16 19:55:42

3个免费降AIGC平台,让你的论文AI率直降个位数[必看]

最近不少同学私信我,说论文明明是自己一个字一个字敲的,用AI辅助整理了一下思路,结果在学校的AIGC检测系统里,相似度直接飙到30%以上,人都傻了。这还真不是个例,随着各大查重平台上线AI检测,&qu…

作者头像 李华
网站建设 2026/9/16 19:55:37

Kimi Chat 连上 TaoToken 后,20 万字长文一次能读完

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华