编程 Moonshine 深度解剖:比 Whisper 快 5 倍的端侧语音识别引擎——变长音频、卷积前端、RoPE 与实时流式 ASR 工程实战

2026-07-26 01:44:11 +0800 CST views 8

Moonshine 深度解剖:比 Whisper 快 5 倍的端侧语音识别引擎——变长音频、卷积前端、RoPE 与实时流式 ASR 工程实战

一句话总结:Whisper 用「30 秒定长切片 + 梅尔频谱」把所有音频拉平成同一形状,简单粗暴但浪费算力;Moonshine 干脆扔掉这套祖传设计,直接吃原始波形、按音频真实长度计算,于是在 10 秒以内的短音频上跑出了 Whisper 五倍的速度,还把模型压到了 27MB。这篇文章我们把它从预处理、编码器、解码器一路拆到量化、流式与端侧落地。

如果你做过语音交互、实时字幕、语音命令这类产品,一定被 Whisper 的一个「特性」折磨过:不管你的音频是 0.5 秒还是 8 秒,它都要 padding 到 30 秒去算。也就是说,识别「打开灯」这三个字,你付出的计算量和识别一整段半分钟的会议录音是一样的。在服务器上你可能感知不到,但一旦要塞进手机、树莓派、耳机、手表这种资源受限设备,这个「定长税」就成了压死实时性的最后一根稻草。

Moonshine(由 Useful Sensors / Moonshine AI 开源,MIT 许可)就是冲着这个痛点来的。它给自己的定位很朴素:为资源受限设备做的实时语音转文字。本文基于 moonshine-ai/moonshinemoonshine-v2 的公开设计,从工程视角把它讲透——不是复述 README,而是回答一个程序员真正关心的问题:它凭什么快,快在哪,以及我该怎么用它。


一、背景:为什么 Whisper 的「30 秒定长」是端侧的原罪

先把 Whisper 的处理流程摆出来,才能看清 Moonshine 改了什么。

Whisper 的输入管线大致是:

  1. 音频重采样到 16kHz;
  2. 切成 固定 30 秒 的窗口,不足 30 秒的用静音 padding 补齐;
  3. 计算 log-Mel 频谱图(80 或 128 个 mel bin,帧移 10ms),得到一个形状固定的 [80, 3000] 二维张量;
  4. 送入 Transformer Encoder-Decoder。

这套设计在训练时非常香:所有样本形状一致,batch 好凑,卷积层的感受野固定,工程实现简单。但它在推理端有三个致命问题:

问题一:定长 padding 的计算浪费。
识别一句 2 秒的短语,Whisper 也要按 30 秒算 encoder。encoder 的自注意力是 O(n²) 的,序列长度 3000(对应 30 秒)时,注意力矩阵就是 3000×3000。你的 2 秒有效信息被淹没在 28 秒静音里,93% 的算力是白烧的。

问题二:梅尔频谱是「人耳先验」的手工特征。
log-Mel 是几十年前语音信号处理留下的遗产,本质是一组固定的三角滤波器组。它是「人类工程师认为重要」的特征,不一定是「神经网络最想要」的特征。而且它是一个不可学习的、独立于模型的预处理步骤,占用额外的 CPU(FFT 计算在端侧并不便宜)。

问题三:延迟与音频长度解耦。
对实时场景,你希望「说得短、出得快」。但 Whisper 的延迟基本恒定在处理 30 秒的量级,无法随着输入变短而变快。

Moonshine 的三个核心改动,恰好一一对应这三个问题:

维度WhisperMoonshine
输入log-Mel 频谱(手工特征)原始波形 + 可学习卷积前端
长度固定 30 秒 padding变长,按实际时长计算
位置编码绝对/正弦位置编码旋转位置编码 RoPE
计算量与音频长度无关(恒定)与音频长度线性相关

这张表就是全文的骨架。下面逐条深入。


二、核心概念一:扔掉梅尔频谱,用卷积直接啃原始波形

Moonshine 最反直觉、也最关键的一步,是不再计算梅尔频谱,而是让一个轻量的一维卷积栈直接在 16kHz 原始波形上提取特征。

2.1 为什么可学习前端更适合端侧

传统 ASR 的预处理是「STFT → mel 滤波 → log 压缩」,这三步都是固定算子。Moonshine 用几层 Conv1d 替代它们,让「如何把波形变成特征」这件事本身也参与训练。好处有三:

  • 端到端联合优化:前端的特征提取和后端的识别目标一起学,特征天然是「对识别有用」的,而不是「对人耳好听」的。
  • 省掉一次 FFT:在树莓派、MCU 这种设备上,实时算 STFT 的开销不可忽视。卷积可以复用 SIMD/NEON 指令高度优化,甚至能塞进 NPU。
  • 降采样在卷积里一步到位:波形 16000 采样点/秒,直接送 Transformer 会导致序列超长。卷积前端通过 stride 把时间维压缩掉几十倍,输出一个「每帧约几十毫秒」的特征序列。

2.2 卷积前端的典型结构

一个类 Moonshine 的前端可以这样实现(示意,非官方逐行代码,但结构与思路一致):

import torch
import torch.nn as nn

class ConvFrontend(nn.Module):
    """直接吃 16kHz 原始波形,输出 [B, T', D] 的特征序列。
    通过三层带 stride 的一维卷积,把时间维大幅降采样。
    """
    def __init__(self, dim=288):
        super().__init__()
        # 输入: [B, 1, T]  (T = 采样点数, 16kHz)
        self.conv1 = nn.Conv1d(1,   dim, kernel_size=127, stride=64, padding=63)
        self.act1  = nn.Tanh()                 # 第一层用 Tanh 稳定波形幅度
        self.gn    = nn.GroupNorm(1, dim)      # 组归一化,batch 无关,端侧友好
        self.conv2 = nn.Conv1d(dim, 2*dim, kernel_size=7, stride=3, padding=3)
        self.act2  = nn.GELU()
        self.conv3 = nn.Conv1d(2*dim, dim, kernel_size=3, stride=2, padding=1)
        self.act3  = nn.GELU()

    def forward(self, wav):
        # wav: [B, T] -> [B, 1, T]
        x = wav.unsqueeze(1)
        x = self.act1(self.conv1(x))
        x = self.gn(x)
        x = self.act2(self.conv2(x))
        x = self.act3(self.conv3(x))
        # [B, D, T'] -> [B, T', D]
        return x.transpose(1, 2)

注意几个端侧友好的细节:

  • 第一层大 kernel + 大 stride:kernel 127、stride 64 意味着第一层就把 16kHz 降到约 250Hz 的帧率,一步吃掉最重的降采样,后面卷积处理的序列已经很短了。
  • GroupNorm 而非 BatchNorm:推理时 batch 常常是 1,BatchNorm 依赖 batch 统计量会翻车,GroupNorm/LayerNorm 对 batch size 无感。
  • 总降采样倍数决定序列长度:上例 64×3×2 = 384,即约每 24ms 一帧。1 秒音频 → 约 42 帧,10 秒 → 约 417 帧。对比 Whisper 固定 3000 帧,短音频的序列长度差了一个数量级,这就是速度的第一来源。

2.3 一个直觉:速度差距从哪来

假设注意力是主要瓶颈(O(n²)),识别一段 4 秒音频:

  • Whisper:序列长度 3000(定长),注意力规模 ∝ 3000² = 9,000,000
  • Moonshine:序列长度约 167(4 秒 × 约 42 帧/秒),注意力规模 ∝ 167² ≈ 27,889

差了 300 多倍 的注意力计算量。当然实际速度还受解码步数、常数因子、内存带宽影响,官方给出的实测是「短音频上约 5 倍」,10 秒以内加速最明显——这和上面的分析完全吻合:音频越短,Moonshine 的变长优势越夸张。


三、核心概念二:变长处理与 RoPE 位置编码

3.1 变长为什么需要换位置编码

Whisper 用固定长度,所以可以用一张固定大小的正弦/学习位置编码表。但 Moonshine 是变长的,序列可能是 40 帧也可能是 4000 帧,你没法预先分配一张「够大」的绝对位置表,也不希望超出训练长度就外推失败。

旋转位置编码(RoPE, Rotary Position Embedding) 完美契合这个需求。它不是把位置加到 embedding 上,而是在计算注意力时,对 query 和 key 向量按位置做旋转。核心性质是:两个 token 注意力打分只依赖它们的相对距离,而不是绝对下标。这带来两个好处:

  1. 天然支持变长:没有固定尺寸的位置表,多长都行。
  2. 相对位置泛化好:训练时见过的相对距离模式,推理时对更长序列也能复用。

3.2 RoPE 的最小实现

import torch

def build_rope_cache(seq_len, dim, base=10000.0, device="cpu"):
    """预计算 cos/sin 缓存。dim 必须是偶数。"""
    theta = 1.0 / (base ** (torch.arange(0, dim, 2, device=device).float() / dim))
    pos = torch.arange(seq_len, device=device).float()
    freqs = torch.outer(pos, theta)          # [seq_len, dim/2]
    cos = torch.cat([freqs.cos(), freqs.cos()], dim=-1)  # [seq_len, dim]
    sin = torch.cat([freqs.sin(), freqs.sin()], dim=-1)
    return cos, sin

def rotate_half(x):
    x1, x2 = x.chunk(2, dim=-1)
    return torch.cat([-x2, x1], dim=-1)

def apply_rope(q, k, cos, sin):
    # q, k: [B, H, T, D]; cos/sin: [T, D]
    cos = cos.unsqueeze(0).unsqueeze(0)
    sin = sin.unsqueeze(0).unsqueeze(0)
    q_rot = q * cos + rotate_half(q) * sin
    k_rot = k * cos + rotate_half(k) * sin
    return q_rot, k_rot

在多头注意力里,RoPE 应用在 QK 计算之前:

class RoPEAttention(nn.Module):
    def __init__(self, dim, n_heads):
        super().__init__()
        self.h = n_heads
        self.dh = dim // n_heads
        self.qkv = nn.Linear(dim, dim * 3, bias=False)
        self.out = nn.Linear(dim, dim, bias=False)

    def forward(self, x, cos, sin, mask=None):
        B, T, C = x.shape
        q, k, v = self.qkv(x).chunk(3, dim=-1)
        q = q.view(B, T, self.h, self.dh).transpose(1, 2)
        k = k.view(B, T, self.h, self.dh).transpose(1, 2)
        v = v.view(B, T, self.h, self.dh).transpose(1, 2)
        q, k = apply_rope(q, k, cos, sin)       # 关键:位置信息在这里注入
        att = (q @ k.transpose(-2, -1)) / (self.dh ** 0.5)
        if mask is not None:
            att = att.masked_fill(mask == 0, float("-inf"))
        att = att.softmax(dim=-1)
        y = (att @ v).transpose(1, 2).reshape(B, T, C)
        return self.out(y)

3.3 编码器-解码器整体架构

Moonshine 沿用了经典的 Encoder-Decoder Transformer 骨架,只是每个部件都做了端侧瘦身:

原始波形 (16kHz)
      │
   [卷积前端]  ──► 降采样 + 特征提取
      │  [B, T', D]
      ▼
 [Transformer Encoder]  ──► N 层,RoPE 自注意力 + FFN
      │  音频记忆 (memory)
      ▼
 [Transformer Decoder]  ──► 自回归生成 token
      │  self-attn(带因果掩码) + cross-attn(看 encoder memory)
      ▼
   [文本 token] ──► tokenizer 解码 ──► 文本

两个尺寸档位(数量级参考公开资料):

  • Tiny:约 27M 参数,内存占用极小(约 27MB 级别),适合 MCU、可穿戴。
  • Base:约 61M 参数,精度更高,适合手机、树莓派、边缘盒子。

对比一下:Whisper base 是 74M,tiny 是 39M。Moonshine 在更小的参数量下,靠变长和更优的特征前端,做到了「更快 + 相当或更低的 WER(词错误率)」。这是「架构红利」而不是「堆参数」,对端侧尤其宝贵。


四、代码实战:从零跑通 Moonshine 转写

理论讲够了,上手。Moonshine 官方提供了 Python、ONNX、以及 v2 的多平台(iOS/Android/macOS/Linux/Windows/树莓派)实现。这里给三条路径。

4.1 最快路径:Python + useful-moonshine

# 安装(PyTorch 后端)
pip install useful-moonshine

# 或使用 ONNX 后端,端侧部署更轻
pip install useful-moonshine-onnx

转写一个音频文件:

import moonshine

# model 可选 "moonshine/tiny" 或 "moonshine/base"
text = moonshine.transcribe("audio.wav", model="moonshine/base")
print(text[0])

背后发生了什么:读取 wav → 重采样到 16kHz → 卷积前端 → encoder → decoder 自回归解码 → tokenizer 还原文本。整个链路没有 30 秒 padding,音频多长算多长。

4.2 ONNX Runtime 手动推理(看清每一步)

想真正理解推理过程,手动跑 ONNX 版最清楚:

import numpy as np
import onnxruntime as ort
import librosa
from tokenizers import Tokenizer

# 1. 读音频,强制 16kHz 单声道
audio, sr = librosa.load("audio.wav", sr=16000, mono=True)
audio = audio.astype(np.float32)[np.newaxis, :]   # [1, T]

# 2. 加载三个 onnx 子图:preprocess / encode / decode
pre = ort.InferenceSession("preprocess.onnx")
enc = ort.InferenceSession("encode.onnx")
dec = ort.InferenceSession("uncached_decode.onnx")     # 首步
dec_cached = ort.InferenceSession("cached_decode.onnx") # 后续步(带 KV cache)

# 3. 前端 + 编码
features = pre.run(None, {"args_0": audio})[0]          # 卷积前端输出
seq_len = np.array([features.shape[1]], dtype=np.int32)
context = enc.run(None, {"args_0": features, "args_1": seq_len})[0]

# 4. 自回归解码
SOT, EOT = 1, 2                     # 起止 token(示意)
tokens = [SOT]
max_len = int(len(audio[0]) / 16000 * 6) + 8   # 经验:每秒最多约 6 token
# 首步(无缓存)
...

关键工程点:Moonshine 把模型拆成 4 个 ONNX 子图——preprocessencodeuncached_decodecached_decode。这么拆是为了:

  • encoder 对整段音频只跑一次,结果 context 缓存住;
  • decoder 首步用 uncached_decode 初始化 KV cache;
  • 后续每步用 cached_decode,只算新 token,复用历史 KV。这是自回归解码的标准加速手段,把每步复杂度从 O(t²) 降到 O(t)。

4.3 用 max_len 卡住幻觉

ASR 模型有个通病:输入静音或噪声时,解码器可能反复吐重复 token(幻觉)。Moonshine 官方建议用一个和音频时长挂钩的 max_len 上限来兜底:

# 经验规则:英文平均语速下,每秒不超过约 6 个 token
audio_seconds = len(audio[0]) / 16000
max_tokens = int(audio_seconds * 6) + 8

超过就强制停,避免死循环。这是端侧部署一定要加的护栏,否则一段风噪能让你的 CPU 空转几百步。


五、实时流式 ASR:Moonshine 真正的主场

单文件转写只是热身。Moonshine 的杀手锏是实时流式——边说边出字。但这里有个认知误区必须澄清:

Moonshine 本身是一个「离线段落」模型,不是「逐帧流式」模型。 流式效果是靠「VAD 分段 + 短段快速转写」拼出来的。

这恰恰是变长设计的价值所在:因为它能极快地处理短音频段,你可以把连续语音切成一个个小片段,每段几百毫秒到几秒,快速转写后拼接,用户感知就是「实时」。

5.1 流式管线设计

麦克风流 (16kHz)
   │
[环形缓冲区]  ──► 累积音频帧
   │
[VAD 语音活动检测]  ──► 判断"这一帧是不是人声"
   │
   ├─ 检测到语音起点 → 开始累积当前段
   │
   ├─ 检测到停顿(静音超过阈值) → 段结束
   │        │
   │        ▼
   │   [Moonshine 转写这一段] ──► 变长快速处理
   │        │
   │        ▼
   │   [拼接到输出文本 + 回调 UI]
   │
   └─ 语音持续过长 → 强制切段(避免单段太长)

5.2 用 Silero VAD + Moonshine 搭流式

import numpy as np
import moonshine_onnx as moonshine
from silero_vad import load_silero_vad, VADIterator

vad_model = load_silero_vad(onnx=True)
vad = VADIterator(vad_model, sampling_rate=16000,
                  threshold=0.5,
                  min_silence_duration_ms=300)  # 静音 300ms 视为段结束

SAMPLING_RATE = 16000
CHUNK = 512                       # Silero 要求的帧大小
speech_buffer = []
recording = False

def on_audio_chunk(chunk: np.ndarray):
    """每来一小块麦克风数据就调用一次"""
    global recording, speech_buffer
    result = vad(chunk, return_seconds=False)
    if result is not None and "start" in result:
        recording = True
        speech_buffer = []
    if recording:
        speech_buffer.append(chunk)
    if result is not None and "end" in result:
        recording = False
        segment = np.concatenate(speech_buffer)
        # 关键:这一段可能只有 1~3 秒,Moonshine 变长处理飞快
        text = moonshine.transcribe(segment, "moonshine/base")
        emit_to_ui(text[0])       # 回调给界面
        speech_buffer = []

def emit_to_ui(text):
    print(">>", text, flush=True)

三个必须调的参数:

  • min_silence_duration_ms:停顿多久算「说完一句」。太短会把一句话切碎,太长会增加延迟。300–500ms 是常用起点。
  • threshold:VAD 灵敏度。嘈杂环境调高(0.6–0.7)防误触发,安静环境调低(0.3–0.4)防漏字。
  • 强制切段上限:如果用户一口气说了 20 秒不停顿,也要强制切,否则单段变长反而拖慢。建议上限 10–15 秒。

5.3 延迟从哪来,怎么压

流式端到端延迟 = VAD 静音判定延迟 + 模型推理延迟 + 拼接/渲染延迟。

  • 静音判定延迟:由 min_silence_duration_ms 决定,这是主要延迟来源,本质是「等你停下来才确认句子结束」。想更快就得牺牲切分准确度。
  • 推理延迟:Moonshine 的强项,短段几十到几百毫秒。用 int8 量化还能再砍。
  • 实测体感:在树莓派这类设备上,Moonshine 的方案能做到「话音刚落,字就出来」,这是 Whisper 定长方案很难做到的。

六、性能优化:把模型塞进真正的端侧设备

6.1 INT8 动态量化

端侧最有效的加速手段。ONNX Runtime 的动态量化几乎无损:

from onnxruntime.quantization import quantize_dynamic, QuantType

for name in ["preprocess", "encode", "uncached_decode", "cached_decode"]:
    quantize_dynamic(
        model_input=f"{name}.onnx",
        model_output=f"{name}.int8.onnx",
        weight_type=QuantType.QInt8,
    )

收益:模型体积约缩到 1/4(27MB → 约 7MB),CPU 推理提速常见 1.5–2 倍,精度损失通常小于 1% WER。对 MCU/可穿戴,这一步基本是必选。

6.2 线程与算子调度

import onnxruntime as ort

opts = ort.SessionOptions()
opts.intra_op_num_threads = 4                 # 单算子内并行(按核心数调)
opts.inter_op_num_threads = 1                 # 算子间串行(ASR 链路本身是串行的)
opts.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
opts.execution_mode = ort.ExecutionMode.ORT_SEQUENTIAL

sess = ort.InferenceSession("encode.int8.onnx", sess_options=opts,
                            providers=["CPUExecutionProvider"])

要点:ASR 的 encoder→decoder 是天然串行依赖,inter_op 开并行没意义,把核心都给 intra_op 算单个大矩阵乘更划算。

6.3 硬件加速后端

  • 手机:iOS 用 CoreML EP,Android 用 NNAPI/QNN EP,把卷积和矩阵乘丢给 NPU。
  • 树莓派:ARM NEON 已被 ONNX Runtime 默认利用;再叠加 int8 收益明显。
  • x86 服务器批量转写:用 OpenVINO EP 或直接 PyTorch + CUDA,变长带来的省算力在批处理里同样成立。

6.4 内存与冷启动

  • 模型常驻:Session 初始化(加载权重、图优化)是最贵的一步,务必只做一次、常驻内存,别每次转写都重建 Session。
  • KV cache 预分配:解码循环里频繁 np.concatenate 会造成内存抖动,可预分配一个 [max_len, ...] 的缓冲区按下标写入。
  • 音频缓冲区复用:流式场景用环形缓冲区,避免每帧都新建 numpy 数组触发 GC。

6.5 性能优化 Checklist

  • 音频强制 16kHz 单声道,别让 librosa 每次重采样浪费时间
  • 用 ONNX + int8 量化,别在端侧跑 fp32 PyTorch
  • Session 常驻,冷启动只做一次
  • max_len 上限护栏,防幻觉空转
  • intra_op_num_threads 按物理核心数调,inter_op 设 1
  • 流式用 VAD 分段,别把整段长音频硬塞
  • min_silence_duration_ms 在「延迟」和「切分准确」间调平衡
  • 有 NPU 就上对应 EP(CoreML/NNAPI/QNN)
  • 长句加强制切段上限,防单段过长反噬变长优势

七、工程取舍与坑:Moonshine 不是银弹

任何技术选型都要看清边界。Moonshine 的甜区和雷区都很明确。

适合用 Moonshine 的场景:

  • 实时语音命令、语音助手唤醒后指令识别
  • 端侧实时字幕、会议记录(配合 VAD 分段)
  • 隐私敏感场景:全程本地、不上云、无 API key
  • 资源受限设备:手机、树莓派、IoT、可穿戴
  • 短音频高频转写:每次几秒,量大,变长优势最大化

不适合 / 要谨慎的场景:

  • 超长音频离线批处理:一段两小时的录音,Moonshine 的变长优势会被摊薄,且它本质是段落模型,需要你自己做分段,工程量不比直接用 Whisper large 小。
  • 语种覆盖:初代英文优先;moonshine-v2 才扩展多语言,且非英文模型走的是 Moonshine Community License(非商用),商用要单独授权——这是最容易踩的许可坑,务必看清 LICENSE 分区(代码与英文模型 MIT,其他语言模型非商用)。
  • 需要词级时间戳、说话人分离:这些能力要额外模块(v2 工具箱提供了 VAD、说话人识别、意图识别等,但要自己组装)。
  • 极高精度的书面转写:追求极致 WER、可接受高延迟和大模型时,Whisper large-v3 仍是强基线。

几个真实会踩的坑:

  1. 采样率不对:喂了 44.1kHz 音频不重采样,输出全是乱码。永远强制 16kHz。
  2. 静音幻觉:不加 max_len 护栏,纯噪声段会让 decoder 反复吐词。
  3. VAD 参数照搬:不同麦克风、不同环境噪声,VAD threshold 必须实测调,别用默认值上线。
  4. 每次重建 Session:把冷启动开销算进了每次转写,误以为模型慢。
  5. 许可证误用:拿非英文模型做了商业产品,属于典型合规风险。

八、总结与展望

把 Moonshine 的设计哲学浓缩成一句话:别为你不需要的算力买单。

Whisper 的 30 秒定长是「一刀切」的工程简化,在云端无所谓,在端侧就是暴殄天物。Moonshine 做的事情本质上是「回归第一性原理」——音频多长就算多长,特征让模型自己学,位置编码用相对而非绝对。三个改动叠加,换来了短音频五倍的加速和四分之一的体积,而这一切恰好是端侧实时语音最缺的东西。

从更大的视角看,Moonshine 代表了一个正在加速的趋势:AI 推理从云端下沉到设备端。当模型能在树莓派、手表、耳机上实时跑,且不需要账号、不需要联网、不上传一个字节的语音,语音交互的产品形态会彻底改变——真正私密的语音助手、断网也能用的实时字幕、嵌进任何硬件的语音命令层。

对我们工程师来说,值得学的不只是「用 Moonshine」,而是它背后的方法论:遇到一个被广泛接受的「标准做法」时,先问一句——这个约束到底是问题的本质,还是只是当年为了工程方便留下的历史包袱? 30 秒定长就是后者。识别出这类「祖传默认值」并敢于推翻,往往就是性能突破的起点。

如果你正在做端侧语音,我的建议很直接:先用 useful-moonshine-onnx 的 base 模型 + int8 量化 + Silero VAD 搭个流式 demo,半天就能跑通,亲手感受一下「话音刚落字就出来」的体感。然后再根据你的语种、精度、延迟需求,决定是留在 Moonshine 还是回退 Whisper。技术选型没有标准答案,但至少现在,端侧实时这条路上,Moonshine 是你绕不开的一块拼图。


本文基于 moonshine-ai/moonshine 与 moonshine-v2 的公开设计与文档整理,代码示例为说明架构思路的示意实现,具体接口请以官方仓库最新版本为准。生产落地前请务必确认模型许可证(英文模型 MIT,其他语言模型为非商用社区许可)。

推荐文章

css模拟了MacBook的外观
2024-11-18 14:07:40 +0800 CST
Golang 几种使用 Channel 的错误姿势
2024-11-19 01:42:18 +0800 CST
Vue3 结合 Driver.js 实现新手指引
2024-11-18 19:30:14 +0800 CST
程序员茄子在线接单