阅读视图

发现新文章,点击刷新页面。
🔲 ☆

Tauri 应用苹果签名踩坑实录

昨天终于心一横开了苹果开发者,一大早开了,想着我要一天搞定上架提交!然而,钱是付了,等到晚上九点多,才成功开通。好嘛,那第二天再努力吧,带着兴奋入睡,第二天一早起来开干。

事实是我把这想得太简单,出了舒适区,就真的想踏入泥沼一样寸步难行。搞了大半天才终于把不用上架的版本签好,晚上之前也把 pkg 打出来了。不容易啊!

下面讲讲一些值得注意的点吧。如果你也打算入坑 Tauri 开发,而且打算构建 macOS 应用或者 iOS 应用,记得收藏,以后会用到的。

证书

你先得搞清楚,CSR、CER、P12 这些概念分别是啥,不然肯定被 N 个证书搞得晕头转向。

在数字证书和公钥基础设施(PKI)领域,这三个缩写分别代表了证书申请、证书本身以及证书存储的不同阶段和格式。 简单来说,它们的关系可以看作是一个从申请到签发再到打包的过程。

CSR

本质:申请表

当你需要一个正式的 SSL/TLS 证书时,你首先要在服务器上生成一对密钥(私钥和公钥)。CSR 就是由你的公钥和一些身份信息(如域名、公司名称、国家等)组成的请求文件。

  • 作用: 你把这个文件发给证书颁发机构(CA,如 Digicert, Let's Encrypt)。
  • 隐喻: 办护照时填写的“申请表”。表上有你的照片(公钥)和个人资料,但它还不是护照。
  • 包含: 公钥 + 身份信息 + 数字签名。

CER

本质:正式证件

CA 收到你的 CSR 并核实身份后,会用他们的私钥对你的信息进行签名,生成一个证书文件,通常后缀是 .cer.crt

  • 作用: 安装在服务器上,向客户端证明你的身份并提供公钥。
  • 隐喻: 已经盖了章的正式“护照”。
  • 包含: 你的公钥、CA 的签名、有效期、颁发者信息。它不包含私钥。

P12

本质:全家桶安装包

.p12 是一种二进制格式的容器,它可以把私钥公钥(CER)以及中间证书链全部打包在一个文件里,并且通常由密码保护。

  • 作用: 方便迁移。比如你想把证书从一台服务器搬到另一台服务器,直接导出一个 P12 文件即可。在 iOS 开发或 Java 服务中非常常见。
  • 隐喻: 你的“保险箱”,里面装着护照(证书)、开启护照配套的钥匙(私钥)以及其他证明文件。
  • 包含: 证书 + 私钥 + (可选) 证书链。

在解决了证书本质上区别之后,你还要搞清楚苹果自己的 N 种证书。Developer ID Application 用于不上架的分发,上架还要用到 Distribution 和 Mac Installer Distribution 两个证书。

机子里证书有两个,一个 Apple Development,一个 Developer ID Application,不小心把证书导错了一次,排查又卡住。

其次 Tauri 一定程度上有点黑盒,加上对苹果应用开发不熟悉,从 Tauri 那不算太完整的文档里逐步实现签名。而且关键是这些信息还散落在 macOS Application BundlemacOS Code SigningApp Store 三个页面。

为了理清这三个页面的内容,又得把一堆苹果开发流程中的重要概念搞清楚。

概念

Entitlements

它是一组 key-value 对(权利字典),告诉操作系统“这个 App 允许使用哪些特殊能力”。例如:访问 iCloud、HomeKit、推送通知、相机、App Sandbox 等。这些权利会嵌入到 App 的二进制代码签名里。

iOS / macOS 上架时必须正确声明;Xcode 会自动生成 .entitlements 文件,签名时合并进去。

Notarization

Notarization 翻译过来就是做公证。你把已签名的 macOS App 上传给苹果,它会扫描恶意代码、检查签名问题。

扫描通过后,苹果给你的 App 发一个“公证票据”(ticket),你可以把它“钉”(staple)到 App 上。macOS Gatekeeper(门卫)看到有公证票据,就会放心让用户运行,而不会弹出应用损坏的错误

使用 Developer ID 证书在 App Store 外分发的 macOS App 必须公证。

Provisioning Profile

一个由苹果服务器签名的 .mobileprovision / .provisionprofile 文件,里面包含:

  • App ID(Bundle ID)
  • 开发者证书
  • 授权的设备列表(开发阶段)
  • 允许使用的 Entitlements 和服务

上架必须,非上架不需要。

双生配置

在搞清楚上面的概念之后就大概能明白了,Tauri 的构建配置必须分两种。

之前的一个卡点是,签名成功了也公证了,结果反而打不开,签名之前还能用 xattr -cr,现在用了都不行。

{
  // ...
  "macOS": {
    "entitlements": "./entitlements.plist",
    "signingIdentity": "Developer ID Application: XXX"
  }
  // ...
}

问了一轮 AI 以为是不知道什么原因导致的 entitlements 没写进去。但是后来又发现即使通过 codesign --force --deep --options runtime 手动把 entitlements 写进去了,依然打不开。

最后才恍然大悟,Tauri 文档上写的分开 tauri.appstore.conf 文件的必要性……

实际上打包非上架包的时候应该把 entitlements 删掉,这样反倒是打出来的包可以正常运行。于是!Mind Elixir v1.7.0 终于不用绕过安全策略,支持直接运行啦!

App Store 版本

然后发布 App Store 的版本我们外加一个配置文件 tauri.appstore.conf.json

{
  "bundle": {
    "macOS": {
      "entitlements": "./entitlements.plist",
      "signingIdentity": "Apple Distribution: Dexter Chow (9J69XMW5FC)",
      "files": {
        "embedded.provisionprofile": "./provisioning/MindElixirMac.provisionprofile"
      }
    }
  }
}

构建时运行:

pnpm tauri build --config src-tauri/tauri.appstore.conf.json --target universal-apple-darwin

App Store 版本不需要公正,跑公正只会提示你需要用 Developer ID Application 证书。因此我们需要把环境变量里公正用到的值清空,这样 Tauri 就不会自动公正了。

pnpm tauri build 之后拿到了 .app,接着还要用 pkgbuild 打包成 pkg。

这两步就用到了上面提到的两个证书:

  • Apple Distribution → 签 App 本身(.app)
  • Mac Installer Distribution → 签 安装包(.pkg)

最后使用 Transporter 上传 pkg 包(开了虚拟网卡 Transporter 传不了),注意打包兼容 Intel 芯片的 universal 包,如果不想兼容 Intel 芯片,系统要限定在 12.0 以上。

后话

我真的不敢想象没 AI 我看这些文档要看到何年何月。但是做好了,又觉得其实没那么难。所以确实,一件事做到过和没做到过就是完全不一样。没做到过你会怀疑每一个细节有问题,脑子炸炸的,做到了你就知道大致什么是没问题的。后续再处理问题就简单多了。

🔲 ☆

久违的生活碎片记录

失业之后多次想写点东西,也确实写了,不过都是零散地写在 Obsidian 里面,时间过了,又暂时不想整理发到博客。于是今天还是直接在博客写吧,最近的非日常生活。

2026-02-17

初二被 CC 邀请去他家烧烤,一扫除夕初一的无聊。工具食材都到位了,结果生火生了好久都生饿了。

山姆的羊扒真香嘻嘻!

从天亮吃到天黑,要是我找不到工作,要不要落魄前端在线烧烤呢(

最后摸摸 CC 的猫,乖乖的好猫~

2026-02-28

迫于准备结婚,过完年 2 月底,把送礼的任务完成,又解决一件事。结婚真花钱呀。

2026-03-11

快要领证了,在领导强烈建议下去修脸,换一个更好懂的词,那就是美容。第一次修脸,感觉还不错。

这是直接在大众点评搜的一家店,在石围塘地铁站附近,就地理位置来说比较偏僻,但是因为也住得偏僻,所以一拍即合了。

店的评分很高,可能因为位置比较偏,价格跟同类相比也算实惠,一百多的套餐,服务还挺多的,躺了一个多小时顺便当休息了。按摩挺舒服的最后一步都快睡着了🥱。洁面和剃须的步骤有点小痛不过第二天没什么问题,摸着是挺滑的。

2026-03-12

之前看到工行有羊毛,换一千外汇送积分和微信立减金,于是换了些港币,今天去取。

发现了一个问题,储蓄卡过期了不能在柜台取钱。我倒是知道卡过期了,但是提示只写着过期后 ATM 不能取钱,没想到柜台也不能取。

这一刻,我终于记起来了,ATM 的全称是自动柜员机,柜员机不行,所以柜员也不行(狗头)。

那怎么办呢,就换卡呗,然后被告知工本费 20 元。绝了,宇宙行是我见过第一家办储蓄卡还收工本费的银行。贵行开成宇宙行的资金,就是从这里薅来的吧?

换卡取钱,完事之后我突然想起来,噢,我旧卡还能拿回来吗?被告知不行,已经被剪了,而且不能拿回来。我知道这个需求也是比较怪,但那好歹也是大学交学费的卡,跟了我十几年,它就这样被砍头了,尸骨都不能交由我处理它后事,有点伤感。

🔲 ☆

Vue 异步更新机制深度解析

深入剖析 Vue 异步更新原理:从 setter 触发到 queueWatcher 入队,详述 flushSchedulerQueue 的执行逻辑。解析 nextTick 如何利用微任务/宏任务优化 DOM 渲染性能。
🔲 ☆

一个插件让你在 Obsidian 画思维导图

最近新鲜出炉的一个 Obsidian 插件 mindelixir-mindmap:https://github.com/SSShooter/obsidian-mindmap

主要有两个功能:

  1. 让你可以以思维导图的形式阅读 markdown 文件
  2. 让你可以在 markdown 文件中插入思维导图

markdown 转思维导图

mindelixir-mindmap 可以根据标题和列表的层级关系把 markdown 文件转换为思维导图。

Mind Elixir Plaintext

Mind Elixir Plaintext 是一种类似 markdown 嵌套列表的格式,不过加上了连线、总结和样式的语法。

你可以通过简单的缩进、ID 引用和类似于 JSON 的尾部声明,快速在文本里构建复杂的思维导图结构。同时,这种结构 AI 生成起来也非常方便。

- 产品研发流程
  - 调研阶段 [^research]
    - 用户访谈 {"color": "#3298db"}
    - 竞品分析 {"color": "#3298db"}
    - }:2 调研总结
  - 开发阶段 [^dev]
    - 架构设计 {"color": "#2ecc71"}
    - 前后端联调 {"color": "#f39c12"}
    - } 开发总结
  - > [^research] >-进入-> [^dev]

Mind Elixir Plaintext 同样也可以作为代码块嵌入到现有的文章中,顺便看看移动端的显示效果:

普通 markdown 只能通过编辑文本更新思维导图,针对 Mind Elixir Plaintext 文本,现在正在开发编辑思维导图反向更新文本的功能。

安装方式

尽管已经提交了官方插件列表的 PR,但是现在 AI 时代随手出插件,前面一千个 PR 排着队……我估计维护团队都要放弃审批第三方插件了。

所以呢,下面推荐两种非官方安装方式。

BRAT

BRAT 是一个已上架的 Obsidian 插件,本意是可以让你更方便地测试你的插件。但是实际上你完全可以用这个插件来安装生产级的插件。

在社区插件列表搜索 BRAT 安装:

安装后在 BRAT 配置里点击 Add beta plugin 按钮,填入 https://github.com/SSShooter/obsidian-mindmap,就能自动安装思维导图插件:

手动安装

不想使用 BRAT 也可以进入插件 Release 页面下载以下 3 个文件:

  • main.js
  • style.css
  • manifest.json

然后在 Obsidian 的设置中,打开插件目录,建一个文件夹把这三个文件放进去,然后刷新一下插件列表即可。

目前 mindelixir-mindmap 仍在持续迭代优化中,如果你在使用中遇到任何问题,或是对新功能有什么好想法,非常欢迎到 GitHub 提交 Issue 和 PR 🤗

🔲 ☆

辣鸡云闪付,谁教你这么搞活动的?

云闪付联合汇丰推出境外消费返券活动,但优惠券居然可以被“领完”——到手的权益莫名消失,投诉无门。吐槽云闪付活动规则漏洞,以及银行活动领券必看的注意事项。
🔲 ☆

看完就懂 useSyncExternalStore

功能

React 引入 useSyncExternalStore 也很长一段时间了,但是存在感还不太强。简而言之,它专门用来搞定那些不受 React 内部生命周期控制的外部数据源

过去最大的问题其实是 React 渲染时的 「撕裂」,这是 React 为了优化页面响应速度引入的并发渲染机制带来的副作用。

简单来说就是 React 为了防止在渲染时长时间无法响应用户输入,把渲染过程拆分成多个可中断的小任务,这就能小任务的间隙中插入用户响应,从而模拟出「并发」的感觉。更完整的前因后果可以参考《React 的设计哲学》

在 React 并发渲染机制下,如果用普通的 useEffect 去同步外部数据,可能会出现渲染进行到一半时数据突然发生变化,导致同一份页面中,一半的组件拿着老数据,另一半拿着新数据的灵异现象(但是实际上出现这个问题的几率其实非常小,大家都忽略了,这就导致了 useSyncExternalStore 的存在感很低)。使用 useSyncExternalStore 后,如果在渲染过程中快照发生变化,React 会丢弃当前渲染并重新开始,从而保证同一次提交中的所有组件看到的是同一个版本的数据。

使用场景

订阅浏览器 API

拿监听网络状态来说。不使用这个 Hook 之前,我们通常得在组件里写个包含完整挂载和清理逻辑的 useEffect 去监听 onlineoffline 事件。

function subscribe(callback) {
  window.addEventListener("online", callback);
  window.addEventListener("offline", callback);
  return () => {
    window.removeEventListener("online", callback);
    window.removeEventListener("offline", callback);
  };
}

function getSnapshot() {
  return navigator.onLine;
}

// 组件里直接这么用
const isOnline = useSyncExternalStore(subscribe, getSnapshot);

监听媒体查询(Media Queries)响应式布局也是同样的套路:

const query = window.matchMedia("(max-width: 600px)");

function subscribe(callback) {
  query.addEventListener("change", callback);
  return () => query.removeEventListener("change", callback);
}

const isMobile = useSyncExternalStore(subscribe, () => query.matches);

轻量级全局状态

如果你接手了一个极小的项目,不想引入 Redux 或 Zustand 这样繁琐的包,但又迫切需要在几个跨层级的组件间共享某部分状态。这时候你可以直接手搓一个简易的 Store:

// 丢在 React 外面的状态中心
let internalState = { count: 0 };
const listeners = new Set();

const store = {
  increment() {
    internalState = { count: internalState.count + 1 };
    listeners.forEach((l) => l());
  },
  subscribe(callback) {
    listeners.add(callback);
    return () => listeners.delete(callback);
  },
  getSnapshot() {
    return internalState;
  },
};

// 任何组件里都可以直接同步获取状态
const state = useSyncExternalStore(store.subscribe, store.getSnapshot);

注意:useSyncExternalStore 内部用 Object.is 比较前后快照,如果 getSnapshot 在数据未变的情况下每次都返回新对象,会导致无限循环重渲染。

只要把这段代码看懂,你就掌握了 Zustand 这种现代状态管理库的核心原理

竞品 API

useEffect + setState

曾经大家都习惯在 useEffect 里监听外部变化,如果变了,再跑一下 setState 触发更新。

这就又到了日常批判 useEffect 的时候了。

useEffect 带来重复渲染和闪烁问题。如果你的外部状态和页面初始计算的状态不对齐,页面渲染就会经历「旧值 -> 闪烁 -> 新值」这三步。而 useSyncExternalStore 在渲染中途就能直接取走最新的正确值。

另外,在处理服务端渲染时,用副作用很容易抛出水合(Hydration)错误,因为服务端和客户端首次生成的 HTML 大概率因为外部数据对不上。useSyncExternalStore 为此专门开了一个叫 getServerSnapshot 的参数,让你传能兜底服务端的静态快照。

Context

很多人滥用 Context 做全局状态,但如果是频繁变动的数据,Context 的广播机制简直是一场灾难。只要 Provider 提供的值发生了变动,它底下所有的子组件也会跟着无脑重跑 Render,除非你给每个组件层级套一层 React.memo(当然现在有 compiler,但也不是毫无代价)。

相比之下,useSyncExternalStore 实现了高精度的按需订阅——只有从 Store 取出的快照真的有了变化,关联的组件才会再次渲染。在这里还是顺便强调一下,没事别用 Context。

总结

要判断何时使用 useSyncExternalStore 其实很简单,只要你的数据依然在 React 的生命周期里流转(例如表单实时输入、控制弹窗开闭的布尔值),那就老老实实用回你的 useStateuseReducer

一旦数据满足游离于 React 管理之外、会随时间变化、且你要让 UI 能自动响应这种变化这三个条件,就毫不犹豫上 useSyncExternalStore。日常写前端页面也许碰不到几次,但之后你要是去造底层 Hook 库,或者需要硬啃第三方库内部暴露出的状态时,useSyncExternalStore 绝对好使~

相关链接

🔲 ☆

看完就懂 useLayoutEffect

差异

useLayoutEffect 与大家熟悉的 useEffect 语法完全一致,从产生副作用的角度上看,功能上也是一样的,唯一差别就是调用时机。

useEffect 会在画面绘制后异步执行,而 useLayoutEffect 会在画面绘制前同步执行。为了讲清楚这个时机的具体区别,得先复习一下浏览器渲染页面的过程。

浏览器渲染流程

注意最后 js 运行的那一块,useLayoutEffect 和 useEffect 就分别位于 paint 之前和之后。

执行的顺序是:

  • useLayoutEffect
  • 画面绘制
  • 下一轮 js 运行 useEffect

顺便我们也能看出来,useLayoutEffect 之所以叫 useLayoutEffect 就是因为它的运行时间点沾着 layout。

使用场景

知道这两个函数的区别,我们还需要知道,到底什么时候用 useLayoutEffect 呢?

答案是,如果进行了 DOM 操作,且这个 DOM 操作会引起回流(reflow)、重绘(repaint),那么就应该使用 useLayoutEffect,例如:

function Tooltip() {
  const ref = useRef<HTMLDivElement>(null);
  const [pos, setPos] = useState({ top: 0, left: 0 });

  // 如果用 useEffect,这里会先渲染一次默认位置,再跳到正确位置 → 可能会造成闪烁
  useLayoutEffect(() => {
    const rect = ref.current!.getBoundingClientRect();
    setPos({
      top: rect.top + rect.height + 8,
      left: rect.left + rect.width / 2,
    });
  }, []);

  return (
    <>
      <div ref={ref}>hover me</div>
      <div style={{ position: 'fixed', top: pos.top, left: pos.left }}>tooltip</div>
    </>
  );
}

因为如果你用 useEffect,在浏览器绘制之后又要重新跑一遍 reflow、repaint,用户可能会看到画面“闪烁”。

如果你有代码洁癖,想要一个最优解,那么你确实该按上面说的这么做,但是事实上在这个场景使用 useEffect 可能也不会有很明显的问题。

其实即使是官网的例子里,作为反模式使用 useEffect,用户也不会感知到明显的“闪烁”,因为两次渲染的时间其实是快到肉眼看不清的,为了确定真的存在区别你还要故意写个 while 循环卡一下主进程。

既然一般情况下无论 useEffect 和 useLayoutEffect 都不会有明显区别,那么我觉得,作为一个有专业素养的 React 开发者,应该优先使用 useEffect,只在 reflow、repaint 造成闪烁的场景下,使用 useLayoutEffect。

当然,useEffect本身也不能乱用,之前在useEffect 清除计划里已经讲述了它的必要使用场景。

总结

useLayoutEffect 适用于“需要在浏览器绘制前同步完成的副作用”,典型场景是读取布局信息并立即修改 DOM,避免视觉闪动。

但因其会阻塞浏览器绘制,影响性能,因此不应滥用。在绝大多数副作用场景下,优先使用 useEffect,只有在感知到闪动才改为使用 useLayoutEffect。

相关链接

🔲 ☆

复习 DOM 事件机制

本期前端复习的主题是 DOM 事件机制,是前端开发非常重要的基础。除了常听到的“冒泡”,完整的事件流其实更有意思。

事件传播

完整的 DOM 事件传播分为三个阶段:

  1. 捕获阶段(Capturing Phase)
    • 事件从 window 一路向下传递到目标元素的父节点。
    • 期间可以通过 addEventListener(type, listener, true) 第三个参数设为 true 来监听此阶段。
  2. 目标阶段(Target Phase)
    • 事件到达目标元素本身,即 event.target
    • 此阶段监听函数会被触发。
  3. 冒泡阶段(Bubbling Phase)
    • 事件从目标元素向上传播至 window
    • 默认通过 addEventListener(type, listener, false) 注册的事件监听器会在这个阶段触发。

但也不是所有事件都支持冒泡,例如 focus, blur 等就不冒泡,具体各个事件是否支持冒泡可以 w3c 官方文档

监听事件

<div id="outer" class="box">
  Outer
  <div id="middle" class="box">
    Middle
    <div id="inner" class="box">Inner</div>
  </div>
</div>

事件监听注册如下:

const boxes = ["outer", "middle", "inner"];
boxes.forEach((id) => {
  const el = document.getElementById(id);

  // 事件捕获阶段
  el.addEventListener(
    "click",
    (event) => logEvent("捕获阶段", id, event),
    true, // 捕获阶段
  );

  // 事件冒泡阶段
  el.addEventListener(
    "click",
    (event) => logEvent("冒泡阶段", id, event),
    false, // 冒泡阶段
  );
});

可以参考这个示例:https://codesandbox.io/p/sandbox/w93cgd

阻止传播

调用 event.stopPropagation() 可以阻止事件传播。注意,这不仅能停掉冒泡,在捕获阶段也能把事件截下来。

举个例子:

child.addEventListener("click", (event) => {
  event.stopPropagation();
  console.log("child");
});

此时点击按钮,只会输出 child,不会触发 parentgrandparent 的监听器。

同理,如果是在捕获阶段调用 stopPropagation(),事件就无法到达目标元素和冒泡阶段:

parent.addEventListener(
  "click",
  (event) => {
    event.stopPropagation();
    console.log("parent capture");
  },
  true,
); // 注意第三个参数 true 开启捕获

此时点击子元素,事件在 parent 被截获,child 的点击事件将永远不会触发

常见场景

  1. 防止重复触发(阻止冒泡)
    • 场景:点击卡片中的按钮(如“删除”),但不希望触发卡片本身的点击事件(如“跳转详情”)。
    • 做法:在按钮的点击事件中调用 event.stopPropagation()
  2. 全局拦截(阻止捕获)
    • 场景:页面进入“编辑模式”或“引导模式”,需要禁用页面上所有元素的点击交互,只允许特定区域或完全接管交互。
    • 做法:在 window 或最外层容器上监听 click 事件(设置 capture: true),并调用 event.stopPropagation(),这样内部元素都无法收到点击事件。

如果同一个元素绑定了多个监听器,想把后续的监听器也一并拦住,得用 event.stopImmediatePropagation()

默认行为

浏览器会对某些事件执行默认动作。例如:

  • 点击 <a> 标签会跳转链接。
  • 点击表单的提交按钮会提交表单。
  • 在输入框按键会输入字符。
  • 选中文本后右键会弹出上下文菜单。

我们可以使用 event.preventDefault() 来阻止这些默认行为。

跟不是所有事件都会冒泡一样,也并非所有事件的默认行为都能被取消。可以通过 event.cancelable 属性查看事件是否可取消。

passive

passive 的意思是“被动”。用这个选项,等于向浏览器承诺:在这个监听器里,我绝不调用 preventDefault()

既然你不打算阻止滚动,浏览器就不用等你的 JS 跑完再动,页面滚动自然就丝滑多了。否则,浏览器为了确认你会不会拦截滚动,每一帧都得等你,很容易卡顿。

现代浏览器为了优化体验,默认把 touchstartwheel 等滚动事件设为 passive: true。也就是说,你在这个监听器里调 preventDefault() 是没用的。如果非要阻止滚动,必须在绑定时显式加上 { passive: false }

// 默认情况下 passive 为 true,preventDefault() 无效
document.addEventListener("touchstart", function (e) {
  e.preventDefault(); // 控制台会显示警告,滚动无法阻止
});

// 显式设置 passive: false,preventDefault() 生效
document.addEventListener(
  "touchstart",
  function (e) {
    e.preventDefault(); // 阻止滚动
  },
  { passive: false },
);

阻止传播与默认行为的影响

别搞混了:阻止传播(Stop Propagation)阻止默认行为(Prevent Default) 是两码事。

  • stopPropagation():让事件不再通过 DOM 树传播(冒泡/捕获),但阻止浏览器执行默认动作。
  • preventDefault():告诉浏览器不要做默认动作,但阻止事件在 DOM 中的传播。

连锁效应

虽然两者独立,但要注意一个事件的默认行为可能是触发另一个事件

例如,在输入框中按键,keydown 事件的默认行为通常包括“将字符输入到文本框”。如果你在 keydown 阶段调用了 event.preventDefault(),浏览器就会取消这个默认动作,即使你阻止的不是 input 的默认行为,字符也不会出现在输入框中。

示例

有个常见的坑:粗暴地在全局阻止默认行为。例如,为了禁用某个快捷键,但在 document 上直接阻止了所有 keydown 的默认行为:

document.addEventListener("keydown", (e) => {
  // 这会导致整个页面的输入框即使获得焦点也无法输入文字
  // 因为“输入文字”也是按键的默认行为之一
  e.preventDefault();
});

所以在调用 preventDefault() 前,一定要加条件判断(比如只针对特定键码 e.key === 'Enter' 阻止)。

事件委托

这是冒泡最实用的功能。

有了冒泡,**事件委托(Event Delegation)**才成为了可能:

document.getElementById("parent").addEventListener("click", (e) => {
  if (e.target.tagName === "BUTTON") {
    console.log("Clicked button:", e.target.id);
  }
});

这样可以只给 parent 绑定一次事件监听器,而不需要为每个 button 单独绑定,提高性能。

这里就得区分 targetcurrentTarget 了:

  • target 是事件触发的具体目标元素。
  • currentTarget 是事件监听器绑定的当前元素。

不得不吐槽,这个命名属实有点抽象,久了不用就总会把 currentTarget 记成是当前触发的元素,这就反了😂

<div id="parent">
  <button id="child">Click me</button>
</div>

<script>
  const parent = document.getElementById("parent");

  parent.addEventListener("click", function (e) {
    console.log("target:", e.target);
    console.log("currentTarget:", e.currentTarget);
  });
</script>

点击按钮 <button id="child"> 时:

  • e.target<button>:你点的元素
  • e.currentTarget<div>:绑定事件的元素(parent)

总结

  • 传播机制:事件流分为捕获、目标、冒泡三个阶段。日常开发主要利用冒泡进行事件委托,但在特定场景下捕获阶段也可以用于拦截事件。
  • 行为控制:区分 stopPropagationpreventDefault。前者阻止事件继续传播,后者阻止浏览器的默认行为(如表单提交),两者互不影响。
  • 性能优化:滚动类事件(如 touchstart, wheel)建议使用 passive: true,明确告知浏览器不会调用 preventDefault,从而让页面滚动更加流畅。
  • 对象区分event.target 是实际触发事件的元素,event.currentTarget 是绑定监听器的元素。在处理复杂的组件嵌套时,分清这两个属性非常重要。

参考文献

  • https://w3c.github.io/uievents/#event-type-keydown
  • https://developer.mozilla.org/en-US/docs/Web/Events
🔲 ☆

读完书容易忘?这个开源 AI 应用能帮你!

其实我们不必回避看完书就忘的问题,因为大多数人看书都是会忘的。其实人类的大脑就是这么设计的,它会过滤掉大部分不重要的信息,只保留下重要的信息。如果真的想要记住一本书重要的知识,需要反复阅读,反复思考,反复练习。

在前 AI 时代,做读书笔记是一件非常耗费精力的事情,但是有大模型之后,我们可以在做笔记这件事上偷偷懒。

注意:做笔记可以偷懒,但是思考和反复回看是绝对不能偷懒的。

那么有什么好用的工具呢?朋友们,有的!欢迎使用 ebook-to-mindmap!简单来说,你可以通过 ebook-to-mindmap 把 pdf 或 epub 格式的电子书转换为分章节的思维导图或者文字总结。

思维导图模式

点击这里即可立即体验。整个网页应用功能比较简洁,大家可以直接上手,当然,下面我也会比较详细地介绍一下这个应用的使用方法🤗

模型配置

使用 ebook-to-mindmap 的第一步是配置模型。它和很多 AI 应用一样,都是选择 byok(Bring Your Own Key)的模式,你可以在这里配置你自己的大模型。

这里还是要强调一下,在 ebook-to-mindmap 填写 Key 时不必担心 Key 泄露,因为 Key 只是保存在你自己的浏览器里,请求也是直接从你的浏览器发送到大模型提供商的服务器的。你可以在浏览器的开发者工具里查看网络请求,确认这一点。同时,ebook-to-mindmap 作为一个开源项目你可以随时检视它的代码,还可以自己部署一个属于你的 ebook-to-mindmap。

说回模型的选择,可能很多人会担心使用 ebook-to-mindmap 的花费太高,其实倒也不必,毕竟现阶段还是能找到很多免费或者低价的大模型。我的首推还是 openrouter,你只需要充值 10 刀,就能获得一个较大的免费模型(其中包括一些 deepseek 变体、最近小米的新模型、之前一段时间还有 grok)使用额度,基本上一天让它处理好几本书都没问题了。其他详细推荐可以参考免费和付费 AI API 选择指南

model list

在获取到 Key 后如上图填写信息即可。

你还可以配置多个模型,点击左侧的星星后会成为默认模型,后续处理时默认使用星标的模型:

model list

生成笔记

配置模型后,在主页选择电子书即可。之后 ebook-to-mindmap 会自动识别电子书的格式,然后开始识别章节:

AI 总结页面

[!TIP] 提示:如果 epub 无法获取到章节,可以在设置里勾选使用 Spine 获取章节

章节识别成功后,选择你需要总结的章节,或者使用分组功能(可以使用快捷键 Ctrl + G)把零碎的章节组合成分组一起发送给 AI 处理。

一切准备好后,点击开始解释按钮即可开始生成笔记。

默认情况下,ebook-to-mindmap 会生成思维导图,你也可以点击小齿轮切换到文字总结模式:

模式切换

[!TIP] 虽然有整书思维导图生成功能,但是如果书的内容比较长,AI 可能吃不下这么长的上下文,所以建议还是分章节生成,最后系统会自动拼接

生成笔记如果想要中途取消,放心点取消就好,之前处于完成状态的章节会被缓存,不用担心之后需要再浪费 Token 重新生成。

提示词

举个例子吧,你在提示词列表里添加一个“小·红书风格”提示词,在生成环节选择这个提示词,就能直接生成小红书风格的笔记。

小红书风格

不止小红书风格,你也可以让 AI 只简单地提取该章节最重要的 5 个观点,帮助你对整本书的主要内容有一个简要的了解。

你还可以使用“反论法”提示词:

选取本章的核心论点或思想,并探索它的对立面。如果作者要为相反的观点辩护,他们需要证明什么?文本中是否有无意间支持反面观点的蛛丝马迹?

参考分享几条有意思的 NotebookLM 提示词这篇文章,里面有几个有趣的提示词,或许能让你眼前一亮。

内容管理

ebook-to-mindmap 充满了下载按钮,是的,你生成的数据必须还是属于你的!你可以很轻易地把数据拿出来!

导出的文字内容可能是 markdown 文件或是思维导图 json 文件。

markdown 文件可以直接阅读,或者导入到 Obsidian、Notion 等笔记软件再细化修改。

思维导图 json 文件可以使用 mind-elixir-core 等前端库渲染,当然,如果你是技术人员,理解 json 数据的结构你也可以随意修改和渲染。

思维导图亦可导出为图片,点击思维导图页面右上角的下载按钮即可。

格式选择

最后谈谈电子书格式的问题,ebook-to-mindmap 支持 pdf 和 epub 格式的电子书,但是这两种格式如何选择呢?

或许大家都会比较喜欢看 pdf,因为看起来比较工整,但是使用 ebook-to-mindmap,我还是比较推荐 epub 格式的电子书。

稍微讲一下 pdf 和 epub 的原理吧。

pdf 的特点是在任何设备上看起来都一样,这就很容易想到,其实 pdf 的排版是非常固定的,而且更重要的是,pdf 的排版是没有语义的。也就是说,人类能看到一个标题是加粗黑字,但是 pdf 本身并不知道这是一个标题,它只是知道这一块区域的文字是加粗黑字的。

更严重的问题是 pdf 如果有一些复杂的排版,例如在角落嵌入一段文字,在解释的时候就很难理解那段文字的意义。所以,大模型理解 pdf 的难度会比较大。

而 epub 格式就不一样,它更像是一张网页,有语义,有结构,有层次,就跟 HTML 差不多。但缺点就是人类看来这样的排版有点粗糙,在不同的阅读器上显示效果也不同。在某些落后的 epub 阅读器上阅读时可能会觉得排版很有年代感。但是大模型不在乎排版,有清晰的结构就能得到好的输出结果。

写在最后

总的来说,ebook-to-mindmap 是一个能帮你快速复习或者把书本变薄的工具。在这个信息爆炸的时代,高效地获取和整理知识变得越来越重要。希望这个小工具能成为你阅读路上的得力助手,让你把更多的时间花在深度思考和理解上,而不是机械地摘抄。

如果你觉得这个项目对你有帮助,欢迎在 GitHub 上点个 Star ⭐️ 支持一下!如果你有任何建议或发现了 bug,也欢迎提 Issue 或者加入讨论。

Happy Reading!

🔲 ☆

博客功能更新 × 3

有种好久没更新本站历史的感觉,最近有 3 个新功能还是得记一下。

系列文章

使用方法:Frontmatter 中添加 seriesseriesOrder

slug: "kitten-large-language-model-1"
publishDate: "2025-11-30T15:01:45.814Z"
title: "小猫都能懂的大模型原理 1 - 深度学习基础"
tags: ["大语言模型", "深度学习", "神经网络", "机器学习"]
description: "用最简单易懂的语言解释大语言模型的基本原理,从深度学习基础到神经网络训练,包含梯度下降、反向传播等核心概念,适合初学者的AI入门教程。"
series: "小猫都能懂的大模型原理"
seriesOrder: 1
useKatex: true

相关文章

使用方法:Frontmatter 中添加 recommendTag

slug: "2025-summary"
publishDate: "2025-12-29T08:49:10.000Z"
title: "2025 年终总结"
tags: ["diary", "年终总结"]
recommendTag: "年终总结"

评论组件更新

Twikoo 似乎挺久没更新了,于是 Fork 了一份。Twikoo 本身用的还是 Vue2,本来想顺便把它升级成 Vue3,但是迁移起来比想象中的麻烦(而且我还发现自己已经把 Vue2 的使用方式忘掉一大半了😂),如果再过几年它依然没更新的话再重构吧。

最后就只是改成 Vite 构建,添加了主题色功能,顺便做了一点 UI 微调。

如果你也想使用,只要安装依赖:

pnpm i tttwikoo

然后在 global.css 中添加如下代码即可:

#twikoo {
  --tk-primary-color-rgb: 203, 42, 66;
}
:root[data-theme="dark"] #twikoo {
  --tk-primary-color-rgb: 232, 120, 142;
}

太好了,UI 终于统一起来了,从 cactus Fork 出来也这么久了,更新了不少,晚点也开源一下好了🤗

🔲 ☆

博客音乐播放器 + 1

尽管我本来不是想做博客播放器,而是做一个歌词解释器,但是做都做了,突然发现做成大杂烩不也行,于是开干呗~

最后出来结果还不错,接下来简单介绍一下这个开源项目:

  • Github 地址:Elixia Player
  • 直接来试用:https://elixia-player.koyeb.app/search

特别鸣谢 Meting,没有 Meting 就没有这个项目!

搜歌、看歌词、AI 功能就不在这多说了,下面主要介绍其作为博客音乐播放器的能力,主要是 3 个功能:

  • 插入播放卡片
  • 插入外链卡片
  • 生成图片分享

首先是播放卡片,可以播放插入的歌曲,但前提是必须提供对应音乐平台的 cookie。虽然体验非常好,不用跳转直接播放,但对维护者来说就非常麻烦了,需要在部署服务时设置 cookie,并且 cookie 过期的时候需要及时更换,否则无法播放。

举个 QQ 音乐的例子,在登陆 QQ 音乐之后按 F12,打开 Network 找到这个 cookie 在发布时填写,或直接在网页配置页填写都可以~

<iframe
  loading="lazy"
  height="80px"
  width="100%"
  style="border-radius: 15px;"
  src="https://elixia-player.koyeb.app/embed/tencent/003cI52o4daJJL"
  frameborder="0"
></iframe>

填好了再嵌入以上代码,无意外就能看到这样的播放器:

花海 - Elixia Player

<!-- <iframe loading="lazy" height="80px" width="100%" style="border-radius: 15px; margin: 20px 0;" src="http://elixia-player.koyeb.app/embed/tencent/003cI52o4daJJL" frameborder="0"

</iframe> -->

接着外链卡片,是一种比较折中的方式,也最推荐的方式

欧若拉 - Elixia Player

<!-- <iframe loading="lazy" height="80px" width="100%" style="border-radius: 15px; margin: 20px 0;" src="https://elixia-player.koyeb.app/card/tencent/001t1qJd0DaKOs" frameborder="0"

</iframe> -->

<iframe
  loading="lazy"
  height="80px"
  width="100%"
  style="border-radius: 15px;"
  src="https://elixia-player.koyeb.app/card/tencent/001t1qJd0DaKOs"
  frameborder="0"
></iframe>

最后是完全丢弃 HTML 的图片格式,完全固定的内容。很多 UGC 平台都不能插入 iframe,这时候就可以直接用生成的 PNG 图片:

![](https://elixia-player.koyeb.app/card/tencent/002POzud0db9lK/image)

Pretender

当然咯还是建议大家再套一层链接,让用户能直接点击跳转,所以完整版如下:

昔涟

[![昔涟](https://elixia-player.koyeb.app/card/tencent/002rhFKO3EjKAg/image)](https://y.qq.com/n/ryqq_v2/songDetail/002rhFKO3EjKAg)

注意,我这个白嫖服务生成图片非常慢,建议还是保存一份放服务器😂

其他官方选择:

Spotify 从设计和加载速度上都不失为一个好选择,但最致命的是需要一些魔法才能访问,而且你总不能要求你的读者都会用魔法😂

<iframe
  style="border-radius:12px"
  src="https://open.spotify.com/embed/track/2leJWl7tBdFVj5Imag5T8J?utm_source=generator"
  width="100%"
  height="152"
  frameborder="0"
  allowfullscreen=""
  allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture"
  loading="lazy"
></iframe>

网易云 也不是不行,就是加载贼慢,以前写二次元音乐推荐的时候插入了一堆网易云 iframe,感觉加载速度也不怎么样,而且有点嫌弃它的颜值……

<iframe
  frameborder="0"
  border="0"
  width="100%"
  height="100"
  src="//music.163.com/outchain/player?type=2&id=28915185&auto=0&height=66"
>
</iframe>

至于 QQ 音乐,好像没在官网找到可嵌入播放器。

大概就是这么个事,欢迎大家直接使用我已经部署好的服务,不过因为是白嫖服务所以打开可能会有点慢……所以也欢迎大家自己在 koyeb 部署 Elixia Player,这样服务也不拥挤,体验好那么一点。

🔲 ☆

2025 年终总结

又到了该年终总结的时候,真的一眨眼,2025 就过去了,所以对于这一年的总结,我的脑子有一点空白。

来吧,努努力吧,回想一下,拼凑今年的记忆碎片。

世俗的任务

上年在番禺当兄弟,今年在江门当礼炮手,明年不出意外该是轮到我了,搞婚礼这些世俗任务是真的烦人,花大价钱给亲戚朋友做秀,最后大多数人都并不会记得什么。不过既然我是明年,这事还是留着明年写吧。

这里想讲的是另外一个跟哥们讨论过的问题,那就是生孩子。首先表明立场,在这个随时失业且有房贷的前提下,我是不敢生育的,真的看过太多家庭因为钱的问题吵得一地鸡毛了。

回到主题,24、25 年的出生率处于新中国成立后最低的水平,这已经是人尽皆知的事情,很多网友也都开始看日本前瞻服预测中国老龄化结局。但奇妙的是什么,哥们说他身边的人陆续生孩子,还有不少二胎。一般我不会这么疑惑,这存在地域差异,或许他那就是爱生娃,但是今年年末,我同组的两个同事几乎同时休陪产假。除此之外,朋友圈晒孩子出生的粗略回想一下也得有两三个。

于是,我跟哥们不禁想问,到底是谁不生孩子。我这圈层肯定不是富裕人群,也不是农村人群,这两个刻板意义上的高生育群体,但是为什么非这两个圈层看起来生育率不低,出生率还是这么低。是幸存者偏差还是刻板印象已经被改变了?我仍然没有答案,个人的视角是真的太局限了。

AI 起飞

上年总结提到我已拥抱 AI,那时候的描述还只是跨文件处理简单需求,今年可以说彻底起飞。

大家都知道今年年头 Deepseek 把中国 AI 的火点燃了,国产大模型能力在今年得到跃迁,中国用户也有低价凑合着用的选择。不过当然,行业领先的 Claude 依然是写代码的神。

随着 Claude 的升级让 AI 编程能力继续飙升,通过 Agent、工具调用、超长上下文,AI 已经可以处理一些复杂的任务,并且是无需人类干预自主分步完成。到年末,Claude 都已经出到 4.5 了,只要你不是上班造火箭,「普通的」工作需求都基本都能由 AI 自主解决。优化的屠刀真是跑着来追我啊😂。

迫不得已,今年真的稍微认真学了一下 AI,主要是大模型的实现,于是有了小猫都能懂的大模型原理系列,整个过程真的不得不感叹数学和统计学的奇妙,语言、甚至是图像居然可以通过这么「简单的」思想建模,效果还能这么好。

在 AI 已经这么无敌的前提下,谁还没个「产品」呢?

于是,Mind Elixir Desktop 终于在今年发布啦~

顺便还衍生了两个完全开源的思维导图生成工具:

又有起色的 A 股

上面说 Deepseek 年头爆了,资本自然也会跟上,再加上上年的政策,今年的底还是挺扎实的,有跌,但是没跌太多,并且今年也顺利上 4000 点了。

虽然作为一个定投党,我还是没赚啥钱就是了,无论如何,比起前面几年心情还是好多了。

这年越来越明白想赚钱还是得会卖,只是道理都懂,我还没做到,明年真想试试啊,在一个高点割一笔大的再继续定投。

跟 A 股比,玩加密币就真的玩出个伤心的故事。简单来说就是先退坑了,总想着加杠杆赚快钱,最后只会亏钱,还好吧,我就亏了一百多刀没爆仓就出来了。如果说玩币的终局一定是爆仓的话,那为什么不提早退出呢。

Obsidian 里面记了写心路历程,到现在都还没有空整理,有空再另外开个坑讲讲吧。

书音游

微信读书 365 再次打卡完成,并依然参与中。看的书也不少,记住的没几本,感觉我在继续看下去之前,需要先想清楚到底怎么看书才能记住,或者有的读了记不住是不是不该看?又或者是,其实不需要太纠结有没有记住,因为只要一直看下去,你觉得自己没记住,但实际上已经腌入味了。

今年记得最牢的书都是些投资系的书,例如 Die With Zero,对我来说是心理按摩,稍微缓解我抠门的心结。接着是塔勒布的《反脆弱》,让我明白投资不要被噪音干扰,以及只有活着才能继续游戏,尤其后者,要是没了这句话我早就在币安爆掉了。

今年听的歌非常散,主要是在 Spotify,后面偶尔发现 QQ 音乐和网易云的一些白嫖机会,就会到那边听一下,又到最近,Apple Music 又嫖到了 3 个月,听歌听得像个游牧民族。

2025 音乐总结

今年好像没听啥新歌,都是挖到没听过的旧歌相逢恨晚。2025 年年度歌手是 BoA 就挺神奇。另外《跳楼机》不知道为啥没上榜,可能是在 Spotify 听得比较多吧,虽然是流水线生产的抖音神曲,但还是好丝滑好喜欢。荐歌环节就不放在这里了,挖好坑有空再填

游戏……至于游戏,Steam Deck 还给哥们之后就没玩过 PC 游戏,但手游还在各种上班。主要是 ZZZ、学马仕、永劫无间手游,还有其他零零碎碎的有空就签一下到,上班感非常强烈。但没办法,ADHD 总需要些乱七八糟的东西杀杀时间。

这里想特别提一下永劫无间,一个博弈型动作游戏。我这种又菜又爱玩的人对它,真是既爱又恨。段位上去了被压着打,打出一种毫无希望、无能狂怒的感觉。你面对的对手拥有你自知永远无法企及的反应速度,精准回避,连招永不失手,振刀博弈也超强,那种感觉你懂吗?多人竞技你还能赖一下队友,但是这种多半 1 v 1 的游戏,菜就是菜,或许多练,也没有用。如果说提不起玩游戏的兴趣,算是赛博阳痿,那这种一打就觉得自己菜的感觉,大概就是赛博早泄了吧。

薅羊毛

今年京东、饿了么(现在已经是淘宝闪购)、美团打外卖大战,消费者默默薅了半年羊毛,后面基本打完了,但是我的外卖点咖啡习惯也养成了,真是顶级阳谋。

于是我就往别的方向找补,在小红书发现了不少银行羊毛。例如建行,经常能抽到立减金,信用卡开户也送了几百块,又连带着看到云闪付又有各种羊毛,导致为了薅羊毛在云闪付充了几百块话费😂这一连串操作都让我薅出爽感了,现在装了一堆银行 App,在小红书看到羊毛就去点一下,真是觉得自己有点搞笑了。

失眠仍是难题

最近几年的失眠还没完美解决,不过年末这段时间似乎靠镁+鱼油压制了,鱼油不太确定,至少对我是真的有用,推荐给失眠的朋友们试试。

原因其一是咖啡因。上面也说习惯已经养成,奶茶咖啡天天忍不住喝。放在五六年前,这是我完全无法想象的。还在广发上班的时候就看到一老姐每天点咖啡,当时可真是觉得稀奇,现在我也成奇人了。下午太漫长,没有咖啡奶茶怎么过?一杯冰咖啡可以喝一个小时,之后被咖啡因冲昏头的感觉会让我上班更带劲……完了,怎么描述起来跟 Drag 一样了😂

问题就是,太晚摄入咖啡因,或是摄入量太大,即使一点半开喝,依然会睡不着。又菜又爱玩的另一个典例。

除了咖啡因,一点小小的「抑郁」也成了我的日常。这两年要处理的事情「成人」起来了,总觉得要耗费很多思维带宽处理这些问题。一直以来,各种媒体都在暗示年纪大了就会麻木,我是开始感受到一点了。但同时我又在想,就该这样吗?公司里一个精神小妹,每天快快乐乐,跟老公恩恩爱爱,物欲低,快乐阈值也低,真是快乐人类的典范。所以快乐是不是也没那么难呢。

个人定位

今年也第三人称地感受了一下自己这个人。

其一,今年发现自己毒舌程度越来越高,越熟的人越狠,这明明是我很讨厌的行为,但是莫名其妙地就会这么做。

其二,今年正式被工友们评为网络 E 人,有没有可能我在线下也 E 一点呢,总之先把话说利索吧,一个练习方向可能是……使用语音输入法?(广告位招租)

展望

完了,不太妙,今年写完总结,感觉也跟去年一样——有点麻木,甚至感觉有过之而无不及。

所以明年最重要的目标,大概是变得快乐起来吧。

接着是以下次要目标:

  • 学会止盈,不要贪心,试试真的止盈一次,看看感受是怎样的
  • 挖掘点实惠的,又能提高生活质量的营养补充剂,持续补镁,尝试着解决失眠问题
  • 明年继续加强大模型应用,不过再没什么突破的话明年应该瓶颈期,AI 泡沫可能也要破了吧
  • 多说点话(但不要太毒舌),尝试用说代替写,万一真被优化了也不怕面试憋不出几句话呀
🔲 ☆

小猫都能懂的大模型原理 6 - 模型优化

小猫都能懂的大模型原理 6 图片来源 pixabay.com

本文旨在用简单易懂的语言解释大语言模型的基本原理,不会详细描述和解释其中的复杂数学和算法细节,希望各位小猫能有所收获 🐱

蒸馏

简单来说,大模型蒸馏(Model Distillation) 就是一种“老师带学生”的技术手段。

它的核心目的是:把一个超大模型(老师)的能力,“传授”给一个较小的模型(学生),让小模型在变小、变快、变便宜的同时,还能保留大模型的大部分能力。下面稍微具体说说怎么做到蒸馏。

可以想象,现在头部模型 800GB - 1TB 的硬盘文件大小,里面有很多冗余信息。对于小模型不需要那么面面俱到,所以通过大模型对小模型某些领域的指导,即使小模型没有那么大的规模,也能让小模型在该领域效果足够好。蒸馏后的模型只有 2GB ~ 4GB 的大小,完全可以在消费级电脑和手机上使用。

虽说参数量能大幅减少,但是蒸馏模型遇到老师没教的知识,回答就会十分滑稽。

林黛玉是谁

顺带一提,类似的方法不只是可以用于大模型带小模型,也可以用于两个大模型之间左脚踩右脚互相进步,例如 2025 年春节爆火的 Deepseek R1 就是这么诞生的,他就是 V3 跟 R1 原型机互相成就,通过评分系统共同进步的典范。

蒸馏可以降低模型参数量且保留较高的智能,但优化方式还有不少。

量化

根据前面的章节我们也知道,每个 Token 都会被映射到很高维度的数组,这个数组里都带有精度很高的小数,聪明的小猫就会想到,哇小数那么难算,能不能砍掉几位?还真能。

量化就是通过降低模型的精度节省推理需要的显存,提高计算速度。

标准推理状态使用 FP16 的精度,研究表明 4-bit 量化到 INT4 也能保持模型大致能力,且节省很多内存空间。

打开 Hugging Face 的 Llama-3.2-3B-Instruct 仓库我们可以看到 Llama-3.2-3B 占硬盘大概 6.5G,而 4 bit 量化后的版本仅 2.02G。

量化结果

从原理上看,量化之后的回答越长,能力应该是越差的,毕竟计算的次数越多,丢失精度造成的误差就会不断扩大。

超长上下文

上下文限制是大语言模型很致命的缺点,在前面所说的注意力矩阵中,默认整个上下文的所有 Token 都会对当前 Token 的真正含义造成影响,也就是如果上下文长度到了 $256k$,这个矩阵的大小就是 $256k ^ 2$,完整计算整个矩阵需要的计算资源会高得十分离谱。

但是我们现在可以看到有的离谱模型上下文能到 1M,很明显,大佬们会想出各种优化方法解决超长上下文的问题,例如不要全量计算上下文矩阵:局部窗口注意力

量化结果

遇到内存不够的情况,也有环形注意力可以结合多张显卡共同计算一段上下文。这个环形注意力听起来很酷炫,简单来说就是一个上下文矩阵分多台机计算,设计一个算法只要按顺序环形交换计算结果就能并行运行超大上下文。

环形注意力虽然没有降低算力要求,但是解除了单卡显存限制,也就是只要你付得起钱,上下文很长也照样给你算(但是,真的十分贵呀)。

MoE

Mixture of Experts(混合专家)是一种减少计算量的优化方法。具体来说,MoE 通过门控网络选择性地激活部分专家,从而减少计算量。

MoE

以上的 Deepseek V2 的 MoE 架构,注意 MoE 是在 FFN 层里

假设我们输入 Token 的向量表示 $x$(维度为 $d$),以及 $N$ 个专家(Experts)。门控网络内部有一个可学习的权重矩阵 $W_g$(维度为 $d \times N$)。它将输入 $x$ 与权重矩阵相乘,计算出该 Token 与每个专家的“匹配度”。

然后重点来了,MoE 会根据匹配度只激活 Top-k 个专家,计算专家矩阵之后再对 Top-k 个专家的输出加权求和,得到最终的输出。例如 Deepseek V3 总参数 671B,激活的专家参数仅 37B,计算量大幅降低。

你或许会好奇 MoE 怎么知道分给哪个专家,其实这同样是通过计算损失反向传播,让门控网络自己学习分配目标。这里还有一点需要注意,如果完全放任不管,门控可能会把所有 Token 都分给少数的专家,那其他专家就等于废掉了,所以实现 MoE 还要注意负载均衡

以上就是优化大模型的几种常见方法,或许后面会再开一期优化总结,但是下个话题先定为多模态,敬请期待 🐱

参考资料

🔲 ☆

useEffect 清除计划

如果说 eval 是 JavaScript 的 Evil,那么 useEffect 就是 React 的 Evil。

useEffect 至少有三宗罪:

  1. 在其中使用 setState 会引起重复渲染
  2. 缺乏注释的 useEffect 往往让人意义不明
  3. useEffect 意味着让人苦恼的依赖管理

我直接给出一个暴论:尽量清除你项目中的 useEffect

下面我不会讲到底啥时候不该用 useEffect,因为 useEffect 本来™的绝大多数情况就不该用,一一列举这些反面例子就是浪费时间。

因此反向思考,我下面主要讲到底什么情况必须要用 useEffect,再伴以少量有代表性的反面教材。

响应组件挂载

要说 useEffect 无法清除的情况,首先肯定是作为组件挂载的钩子,比如说:

useEffect(() => {
  document.title = "My homepage";
  return () => {};
}, []);

这是绝对无法替代的使用场景,在组件挂载时,如果你需要操作 document 或者其他无法通过 React jsx 修改的对象,就必须使用 useEffect

类似情况还有这些:

  • 订阅/取消订阅(WebSocket、EventSource)
  • 定时器的设置与清理
  • ResizeObserver / IntersectionObserver 等浏览器 API

最理想的情况下,你需要让事件驱动一切。例如在 jsx 里面写 onClickonSubmit 等等,然后在事件处理器里面写你的逻辑。

本质上,依赖为空的 useEffect 其实也可以理解为一个事件,触发事件的是“组件挂载”这个动作,你无法用 onXxx 的方法实现,所以只能依赖 useEffect

下面是一个典型反例,用户选择了一个下拉选项,你就可以用 onChange 事件处理器来处理,而不是 useEffect

Before:

useEffect(() => {
  const defaultModel = getDefaultModel();
  if (defaultModel && !selectedModelId) {
    setSelectedModelId(defaultModel.id);
  }
}, [getDefaultModel, selectedModelId]);

// Update config when model selection changes
useEffect(() => {
  const selectedModel = models.find((m) => m.id === selectedModelId);
  if (selectedModel) {
    setAiProvider(selectedModel.provider);
    setApiKey(selectedModel.apiKey);
    setApiUrl(selectedModel.apiUrl);
    setModel(selectedModel.model);
    setTemperature(selectedModel.temperature);
  }
}, [selectedModelId, models, setAiProvider, setApiKey, setApiUrl, setModel, setTemperature]);

上面代码就是一个经典误用,监听 selectedModelId 去更新其他状态,事实上这完全可以在用户选择模型的事件中完成。

After:

const selectedModel = models.find(m => m.id === selectedModelId)

const handleModelChange = useCallback((id: string) => {
 setSelectedModelId(id)
 const model = models.find(m => m.id === id)
 if (model) {
   setAiProvider(model.provider)
   setApiKey(model.apiKey)
   setApiUrl(model.apiUrl)
   setModel(model.model)
   setTemperature(model.temperature)
 }
}, [models, setAiProvider, setApiKey, setApiUrl, setModel, setTemperature])

useEffect(() => {
 const defaultModel = getDefaultModel()
 if (defaultModel && !selectedModelId) {
   handleModelChange(defaultModel.id)
 }
}, [getDefaultModel, selectedModelId, handleModelChange])

这样,你就可以在用户选择模型的事件中直接调用 handleModelChange,在初始化时也可以调用同一个函数。

然而这不是一个完美状态,因为你可以见到,为了让 useEffect 用上这个事件,你还得把 handleModelChangeuseCallback 包裹起来,React 的依赖是一个传染病,这十分致命。

还好,在 React 19.2 之后,你可以使用 useEffectEvent 来避免这个问题,这意思就是,用 useEffectEvent 创造一个 Effect 事件,让它适配 useEffect,而不需要依赖管理。

Better:

const handleModelChange = (id: string) => {
 setSelectedModelId(id)
 const model = models.find(m => m.id === id)
 if (model) {
   setAiProvider(model.provider)
   setApiKey(model.apiKey)
   setApiUrl(model.apiUrl)
   setModel(model.model)
   setTemperature(model.temperature)
 }
}

const onInit = useEffectEvent(() => {
 const defaultModel = getDefaultModel()
 if (defaultModel && !selectedModelId) {
   handleModelChange(defaultModel.id)
 }
});

useEffect(() => {
 onInit()
}, [])

useEffectEvent 直接忽略依赖,对于里面的响应式变量无论何时都能获取到最新值。这样就能比较优雅地清空一堆依赖了!

注意:上述场景仅限于在挂载时就能获取到所有所需变量的情况,如果 models/selectedModelId 是后到的,它就会错过初始化。与官网给出的在挂载时添加 websocket 响应不太一样。

实在是一个史诗级更新!不过这很难称得上是一种称赞,说得难听点的话这只不过是 React 团队给之前的设计擦屁股而已……写好 react 这些依赖你可能觉得自己成为了内行人而沾沾自喜,殊不知这本来可能就可以更简单地实现这种逻辑……

响应异步数据变化

但是 React 的世界倒也没有这么简单,有的情况确实没有“用户触发的事件”,也没有“Effect 事件”,那就是响应 props 的变化

例如一个组件需要根据 props 的变化来执行某个操作,具体一点,需要收集 props 来请求数据,而且最致命的是,这些 props 本身也是异步获取的,这意味着你无法在子组件直接用 useEffect(() => {}, []) 简单实现,例如:

function UserPermissions({ userId, roleId }) {
  const [permissions, setPermissions] = useState(null);

  // 响应 props 变化
  useEffect(() => {
    if (!userId || !roleId) return;

    fetchUserPermissions(userId, roleId).then((data) => setPermissions(data));
  }, [userId, roleId]);

  if (!permissions) return <div>Loading permissions...</div>;
  return <div>Permission Level: {permissions.level}</div>;
}

function ParentComponent() {
  const [userId, setUserId] = useState(null);
  const [roleId, setRoleId] = useState(null);

  useEffect(() => {
    fetchCurrentUserId().then((id) => setUserId(id));
    fetchUserRole().then((role) => setRoleId(role));
  }, []);

  return <UserPermissions userId={userId} roleId={roleId} />;
}

当你把 UserPermissions 当成一个纯展示组件,只要 userIdroleId 一变化,UserPermissions 就需要自动更新。

为了实现逻辑分离,不关心里面的逻辑,这种情况你也是无法去除 useEffect 的。即使你使用 useQuery 之类的数据获取库,也无法避免你本质上是在使用 useEffect

针对这种情况,其实 useEffect 不是无可避免的。如果子组件的数据获取是同步的,你可以直接在渲染时计算或者借助 useMemo 缓存,一旦是异步获取,就没有办法了。

但是这里我不推荐用奇技淫巧消除 useEffect,因为不关心组件内部实现,通过 props 控制组件渲染也是一种比较优雅的做法。你只需要在 useEffect 里面用注释明确说明本次 useEffect使用意图即可。

这是把复杂度分离了,子组件并不需要关心数据到达的时机,只要知道,数据到齐了就可以行动。不过缺点是,如果 userIdroleId 是分批到达,那么子组件会存在重复渲染和竞态的情况,这个时候使用 Promise.all 是一个不错的优化方式。

下面我给出一个结合 Promise.alluseEffect 消除的例子,只要让子组件借助 useImperativeHandle 给出更新方法让父组件调用:

const UserPermissions = forwardRef((props, ref) => {
  const [permissions, setPermissions] = useState(null);

  useImperativeHandle(ref, () => ({
    fetchData: (currentUserId, currentRoleId) => {
      if (!currentUserId || !currentRoleId) return;
      fetchUserPermissions(currentUserId, currentRoleId).then((data) => setPermissions(data));
    },
  }));

  if (!permissions) return <div>Loading permissions...</div>;
  return <div>Permission Level: {permissions.level}</div>;
});

function ParentComponent() {
  const [userId, setUserId] = useState(null);
  const [roleId, setRoleId] = useState(null);
  const permissionRef = useRef();

  useEffect(() => {
    Promise.all([fetchCurrentUserId(), fetchUserRole()]).then(([id, role]) => {
      setUserId(id);
      setRoleId(role);
      permissionRef.current?.fetchData(id, role);
    });
  }, []);

  // 后续更新
  const handleUserSwitch = (newId) => {
    setUserId(newId);
    permissionRef.current?.fetchData(newId, roleId);
  };

  return (
    <>
      <button onClick={() => handleUserSwitch("user_999")}>Switch User</button>
      <UserPermissions ref={permissionRef} />
    </>
  );
}

这样一来,你就可以通过父组件的事件消除子组件的 useEffect,这说明,事件触发是可以传递的

再举个例子,例如数据回填,回填后发现某一个数据变化了,就需要修改另一个数据。这看似没有“用户触发的事件”,但是没关系,如上面所说,你可以创造一个函数让父组件事件触发。在这个例子里我个人是更推荐使用 useImperativeHandle 的方式来实现

import React, { useImperativeHandle, forwardRef } from "react";
import { Form, Select, Checkbox } from "antd";

const getDerivedPermissions = (role, currentPermissions = []) => {
  if (role === "admin") {
    return [...new Set([...currentPermissions, "manage_system"])];
  }
  return currentPermissions.filter((p) => p !== "manage_system");
};

const UserFormAfter = forwardRef((props, ref) => {
  const [form] = Form.useForm();

  const currentRole = Form.useWatch("role", form);

  useImperativeHandle(ref, () => ({
    fillData: (apiData) => {
      const safePermissions = getDerivedPermissions(apiData.role, apiData.permissions);

      form.setFieldsValue({
        ...apiData,
        permissions: safePermissions,
      });
    },
  }));

  const handleRoleChange = (newRole) => {
    const currentPermissions = form.getFieldValue("permissions");
    const nextPermissions = getDerivedPermissions(newRole, currentPermissions);

    form.setFieldsValue({ permissions: nextPermissions });
  };

  return (
    <Form form={form} layout="vertical">
      <Form.Item name="role" label="Role">
        <Select
          onChange={handleRoleChange}
          options={[
            { value: "user", label: "User" },
            { value: "admin", label: "Admin" },
          ]}
        />
      </Form.Item>

      <Form.Item name="permissions" label="Permissions">
        <Checkbox.Group>
          <Checkbox value="read_basic">Read Basic</Checkbox>
          <Checkbox value="manage_system" disabled={currentRole === "admin"}>
            Manage System (Locked for Admin)
          </Checkbox>
        </Checkbox.Group>
      </Form.Item>
    </Form>
  );
});

// 使用示例
export default function Page() {
  const formRef = React.useRef(null);

  React.useEffect(() => {
    // 模拟数据回填
    setTimeout(() => {
      formRef.current?.fillData({ role: "admin", permissions: ["read_basic"] });
    }, 500);
  }, []);

  return <UserFormAfter ref={formRef} />;
}

虽然 useImperativeHandle 能消除 useEffect,但它将数据驱动变成了过程驱动。 如果你的组件仅仅是为了展示(如一个纯图表组件、列表组件),请依然优先使用 Props 传递数据。

不止异步的 props 变化,所有异步数据变化都有上面的问题。如果你用 useQuery 封装了一次请求,那么你想根据获得的 data 进一步进行异步操作就需要用到 useEffect(不过 tanstack 提供比较优雅的 enabled 参数)。

是的,“封装”就是一道天堑,把两次异步操作分离开,你就只能为此多做一次 useEffect 了,或许以后 React 团队会给出更好的方案吧,谁知道呢?

总结

除了下面两种情况,你代码里剩下的所有 useEffect 都应该被当场处决:

  • 真正的组件生命周期副作用
    • 操作 DOM、第三方库初始化、订阅/取消订阅、定时器等
    • 这些操作无法通过 JSX 或事件处理器完成,useEffect 是唯一选择
  • 响应异步 Props 变化的场景
    • 子组件需要根据父组件传入的异步数据执行操作
    • 但即使是这种情况,也常常可以通过 useImperativeHandle 重构为事件驱动模式

彩蛋

如果你不得不使用 useEffect,给你三个建议:

  • 一个 effect 只做一件事
  • 必须写注释说明触发源和意图
  • 注意竞态
🔲 ☆

小猫都能懂的大模型原理 5 - 后训练

小猫都能懂的大模型原理 5 图片来源 pixabay.com

本文旨在用简单易懂的语言解释大语言模型的基本原理,不会详细描述和解释其中的复杂数学和算法细节,希望各位小猫能有所收获 🐱

GPT 训练完后并不能直接与用户流畅地聊天,就像是一个只会背书、不擅长与人交往的 Nerd 🤓。你说啥呢,他就接着从他大脑里想到的都一股脑说出来,接在你后面,情商约等于 0。

chatGPT 之所以叫 chatGPT,是因为它在 GPT 的基础上做了 chat 的后训练

SFT

对话的训练素材大概长下面这样:

instruction token

通过特定的 Token 标记对话的格式,然后把这些经过审阅的对话喂给模型即可。

在喂对话前,还需要注意整理数据和调整超参数:

  • 数据清洗、过滤(去除垃圾、泄密、违法内容)
  • 样本平衡(不同任务/风格的比例)
  • 学习率、训练步数等超参的控制,避免遗忘原有能力或过拟合

这个步骤也叫 SFT,全称 Supervised Fine-Tuning(监督式微调)。

Hugging Face 是一个找 AI 开源资源的好地方,这里也有对话训练集:https://huggingface.co/datasets/openchat/ultrachat-sharegpt

除了对话的 SFT,厂商可能还会进行工具调用(function calling / MCP)、多轮任务规划、搜索结果整合等子技能,这些微调对 AI Agent 的实现极为重要。

除了大模型出厂前的 SFT,厂商也提供出厂后微调的服务,当然你也可以自己微调开源模型。

举个例子:如果你原创了一门计算机语言,想训练一个专门帮你的新语言的助手,你可以在通用大模型的基础上,用大量的编程相关数据进行微调,这样模型就会更擅长写对应语言的代码、调试、解释代码等任务。

微调的好处是成本相对较低,不需要从头训练模型,就能在特定领域获得很好的效果。

RLHF

RL(强化学习):智能体通过与环境“互动试错”,利用“奖励反馈”来学习如何做出能实现“长期利益最大化”的决策。

再下一步,来到 RLHF(Reinforcement Learning from Human Feedback),解决“模型会说话,但不一定合人类偏好”的问题,用人类偏好信号做强化学习,把模型往“更符合人类期望”的方向推,从而实现 Alignment(例如禁止黄赌毒啦,不要鼓励自杀啦,还有一系列 ZZZQ)。

先让人类对多条模型回答做偏好排序,训练一个奖励模型(Reward Model) 去拟合这种偏好;再用强化学习(常见是 PPO)让生成模型最大化奖励,这是很常见的一种通过模型强化另一个模型的方法。后面章节会讲到 路人皆知的 Deepseek R1,他的训练方式更是左脚踩右脚。

简单来说就是循环下面三个步骤:

  • 自我生成: 原始模型生成一个回答。
  • 裁判打分: 刚才训练好的“奖励模型”给这个回答打一个分数(Scalar Reward)。
  • 参数更新: 如果分数高,算法(PPO)就会调整模型的参数,鼓励它以后多生成类似的回答;如果分数低,就抑制这种生成方式。

可能这时候就有小猫要问了,咋一句话的评分能影响到逐个 Token 生成的权重呢?Emmm,这个问题还是挺复杂的,但是知道像 PPO 这样的算法,会用这个总分来估算每一步动作的“好坏”(优势),从而对每个 token 的概率做梯度更新了。

来一个 RLHF 流程图方便各位小猫理解:

RLHF 流程

另外,现在也有一批“不要 RL 的 RLHF 替代品”,比如 DPO、IPO、ORPO 等,它们直接用人类偏好数据来训练,不再显式训练奖励模型和跑 PPO,但目标还是一样:让模型更符合人类喜欢的回答方式。

Reasoning

实现 Reasoning 的方式应该很多,例如与 RLHF 类似,你可以鼓励模型尽量使用逐步解题的方式回答问题,并把解题步骤放在 <think> 标签里,答案放在 <answer> 标签里,那它就可以学会逐步解题。

Deepseek 论文提到,通过一个叫 GRPO 的训练策略,通过一些固定的判断逻辑对输出结果进行评分。结果对就加分,格式对也加分,然后同一个 prompt 生成多个回答,奖励平均分以上的回答,这样就不需要额外训练一个奖励模型,只要设计好规则化奖励函数即可,节省掉传统 RLHF 里的花费高昂的奖励模型。

ds R1 zero 的回答长度逐渐变长

通过不断循环上述过程进行训练,模型会自发地让思考过程变长,为什么呢,因为经过长思考得到正确答案的概率更大,毕竟思考越长,它自己得到的信息就越多。最后,模型会自动产生“等等,我似乎错了”之类的惊喜时刻,这是属于 Reasoning 的“涌现”。

下一章将会介绍更多大模型优化策略,敬请期待 🐱

参考资料

🔲 ☆

小猫都能懂的大模型原理 4 - 大语言模型架构

小猫都能懂的大模型原理 4 图片来源 pixabay.com

llm 架构

整体结构

在经典的架构中,数据经过注意力模块后会进行归一化(LayerNorm)。不过现在很多先进的大模型(如 Llama)为了更稳定,会把归一化放在注意力模块之前。

深层网络里,各层输出的尺度和分布会不断漂移,导致后续层“吃进去”的数值忽大忽小、训练变得不稳定。归一化就是把每一层喂给下一层的数值,拉回到一个稳定、可学习的范围(更强调相对大小而不是绝对数值),从而让梯度更稳定(不会爆、不易消)。

然后进入到前馈神经网络模块(图中的 Feed forward),就是最开始提到的那种神经网络。在这里会有隐藏层对向量维度升级,从而学习更多隐藏的内容,最后降回输入维度。

另外可以注意到,侧面有一条线跳过部分模块直接连到后面的加号,这被称为残差连接

$$ y = x + F(x) $$

其中:

  • $x$:上一层的输出;
  • $F(x)$:这一层学习到的新信息;
  • $y$:二者相加后的结果。

残差连接带来以下好处:

  1. 防止信息丢失:原始输入 (x) 直接保留并传递;
  2. 防止梯度消失:反向传播时,梯度能直接穿过“+x”那条通道;
  3. 让训练更容易:每层只需“微调”已有知识,而不是重学一遍。

Transformer 层本身也不止一个,最小的 GPT2 都有 12 个 Transformer 层。

所以整个大语言模型的架构差不多就是:

输入文本 → 分词 → 向量化 (Token + 位置编码)
→ 经过 N 层 Transformer Block(注意力 + 前馈)
→ 层归一化 + 线性输出层 → 预测下一个 token 的概率分布

(注意:具体的归一化位置和顺序在不同模型中可能略有不同。)

生成(推理)阶段,下一个词的时候就会涉及到“温度 (Temperature)”和“Top-k / Top-p”参数,修改这些参数可以让生成下一个词的可选值更丰富,生成更天马行空的文本。

当然这只是一个实现方式,不同的模型会尝试排列组合、或者创新地加入其他模块,尝试优化模型的性能和上下文。

训练

就如之前所说的,你只要把现有的文本拆开,喂给模型,经过反向传播不断调整模型参数,最后它就自然能猜到下个字是什么。

其中涉及的参数包括:

  • 梯度下降算法:Adam、AdamW、RAdam 之类的;
  • 批量(Batch)训练:一次喂多条样本,让显卡更高效;
  • 学习率(Learning rate):调节“改参数的步伐”,太大容易崩,太小学不动;

在训练过程中,你可以把参数保存下来,做个 checkpoint,这样就不必一次跑完所有训练,也不怕越练越差,一旦练坏了,只要回滚到上个 checkpoint 就好了。

现在除了头部大公司基本上不会从 0 开始训练,因为花费的时间和算力都太多了。作为独立开发者,这注定是一个你知道原理、会写代码,但是自己就是无法实现的领域。

下一章会介绍怎么在一个 GPT 的基础上继续做后训练,敬请期待🐱

为什么这样能行

这是一个哲学问题,什么算是“能行”?LLM 是真的学会了什么,还是单纯的概率模型。

这让我想起高中的一个梗……数学强解法,什么数学强解物理、数学强解生物,而 LLM,就是用数学强解语言。注意力机制每一步都有其道理,但是我觉得没有人从一开始就觉得这样能行,要不怎么大家都说大模型是“大力出奇迹”呢,是的,这本身就是一个意外的奇迹。

如果要反过来解释为什么能行,只能说大模型这个实验证明了语言可以被数学强解。

参考资料

🔲 ☆

小猫都能懂的大模型原理 3 - 自注意力机制

小猫都能懂的大模型原理 3 图片来源 pixabay.com

本文旨在用简单易懂的语言解释大语言模型的基本原理,不会详细描述和解释其中的复杂数学和算法细节……但是本章还是一点点线性代数基础……希望各位小猫能有所收获 🐱

Transformer 的核心创新就是自注意力机制,如果忽略数学层面的问题,其实不难理解。

过去的深度学习框架对文字的处理,没有考虑到(大范围的)上下文,例如 RNN 就会一直循环计算前面的文字的影响力,但是距离一长,前面内容的记忆会丢失得比较多,而且 RNN 这个串行逻辑也跑不快。

自注意力

自注意力的突破点就在这里,它让整个上下文里的 Token 互相理解,计算过程是可以并行进行的。

之前说 GPT2 一个词维度有七百多,在下面这个例子里面,我们假设一个词维度只有 3。首先在词向量的基础上加上同样维度的位置向量,然后我们就要开始子注意力里面最精彩的 QKV 计算了。

我们从 Token 的向量开始:

  • “Your” → $x^{(1)} = [0.4, 0.1, 0.8]$
  • “journey” → $x^{(2)} = [0.5, 0.8, 0.6]$
  • “step” → $x^{(T)} = [0.0, 0.8, 0.5]$

然后,每个 $x^{(i)}$ 都会通过三个矩阵(每个注意力头都有自己的三个矩阵,这三个矩阵是可训练的):

$$ W_q, W_k, W_v $$

分别点积得到:

  • Query 向量 $q^{(i)}$
  • Key 向量 $k^{(i)}$
  • Value 向量 $v^{(i)}$

Query 其他 Token

以当前词 “journey” 为例,就是用它的 query 向量 $q^{(2)} = [0.4, 1.4]$,去点乘整个上下文其他词key 向量

  • “Your” → $k^{(1)} = [0.3, 0.7]$
  • “journey” → $k^{(2)} = [0.4, 1.1]$
  • “step” → $k^{(T)} = [0.3, 0.9]$

这就等于计算每个词与当前 query 的相似度

$$ \omega_{2j} = q^{(2)} \cdot k^{(j)} $$

例如:

  • $\omega_{21} = 1.2$
  • $\omega_{22} = 1.8$
  • ...
  • $\omega_{2T} = 1.5$

这些结果代表了当前词(“journey”)与其他词的“相关程度”。

点积: 点积不仅被视为一种将两个向量转化为标量值的数学工具,而且也是度量相似度的一种方 式,因为它可以量化两个向量之间的对齐程度:点积越大,向量之间的对齐程度或相似度就 越高。在自注意机制中,点积决定了序列中每个元素对其他元素的关注程度:点积越大,两 个元素之间的相似度和注意力分数就越高。

归一化注意力权重

接着对所有相似度 $\omega_{2j}$ 进行 Softmax 归一化(公式不用细看,归一化就是让所有值加起来等于 1):

$$ \alpha_{2j} = \frac{e^{\omega_{2j}}}{\sum_t e^{\omega_{2t}}} $$

得到:

  • $\alpha_{21} = 0.1$
  • $\alpha_{22} = 0.2$
  • ...
  • $\alpha_{2T} = 0.1$

这些值称为 注意力权重(attention weights),表示模型在处理当前词“journey”时,对其他词的关注程度。

上下文向量

最后一步: 每个词都有自己的 value 向量 $v^{(j)}$,将它与对应的注意力权重相乘并求和:

$$ z^{(2)} = \sum_j \alpha_{2j} v^{(j)} $$

如图中:

  • $v^{(1)} = [0.1, 0.8]$
  • $v^{(2)} = [0.3, 1.0]$
  • ...
  • $v^{(T)} = [0.3, 0.7]$

计算后得到:

$$ z^{(2)} = [0.3, 0.8] $$

这个向量 $z^{(2)}$ 就是 “journey” 的上下文向量(context vector)它综合了句子中各个词的语义信息,并且根据注意力权重动态决定了“关注谁”。

在点积之后,为了防止数值过大导致 Softmax 算出来的梯度太小(难以训练),我们通常会把结果除以一个缩放系数(通常是维度的根号,即 $\sqrt{d_k}$),然后再做归一化。

经过上面一同操作,就得出了著名得注意力公式:

$$ \text{Attention}(Q, K, V) = \text{softmax}\left(\frac{Q K^\top}{\sqrt{d_k}}\right)V $$

掩码

因果注意力就是把每个字后面的字都盖住,防偷看。

因果掩码让大模型只考虑前面的内容,不是因为后面的内容没有用,而是因为训练的目标就是从前面的内容生成后面的内容,所以即使有用,在这个运行机理上后面的内容就是不可访问的,大模型必须在后面不可知的情况下进行学习。

另一种掩码是 dropout,指每个头都随机选一些词盖住,让模型的注意力能集中到某些词上。

多头

前面也说每个头的 QKV 矩阵都是不一样,因为在初始化 QKV 矩阵时数值就是随机的,那么通过反向传播得到的值就不一样,所以各个头注意到的东西自然也不一样

虽说人类不好理解注意力,但还是可以通过注意力可视化找到一些提示,例如某些头会学习到被动语态,又有某些头会学习到词性分析。

最后系统会把多个头的信息汇总,最后输出到下一步。

参考资料

🔲 ☆

小猫都能懂的大模型原理 2 - 初见大语言模型

小猫都能懂的大模型原理 2 图片来源 pixabay.com

本文旨在用简单易懂的语言解释大语言模型的基本原理,不会详细描述和解释其中的复杂数学和算法细节,希望各位小猫能有所收获 🐱

现在大家遇到问题,第一反应都不是使用搜索引擎,而是问 chatGPT,chat 大家都知道是聊天的意思,但是 GPT 它到底是个什么呢?

展开一下全名:Generative Pretrained Transformer,翻译过来就是生成式预训练 Transformer

所以在此之前我们需要更清楚知道 Transformer 到底是个啥。

Transformer 架构

说到 Transformer 还是不能不提其源头,鼎鼎大名的《Attention Is All You Need》,这篇论文提出了这个名为 Transformer 的深度神经网络架构。

在论文里面,这个架构是长这样的:

其核心就是自注意力机制

注:后来很多 GPT 只保留了 decoder。

最基本的原理

回忆一下神经网络,我们的输入和输出,经过神经网络训练一顿操作!调整好权重,就能输出像模像样的答案。

那么对文字是不是也能这么操作呢?没错的,事实证明完全可以。

因为文字这个东西,现成的正确答案真的太多啦,我们训练的时候可以这样:

输入:番茄是
正确结果:番茄是红

如果猜到的不是红,那就计算损失,把权重往对的那边凹一下。

经过很多 TB 的文字数据训练之后,就成就了现在的大语言模型,它似乎了解地球上一切文字知识,并且表达出来毫无维和感。

科学似乎还无法解释这件事,大模型可以流畅回答训练样本里没有的内容,这就称为涌现。例如大模型其实没有专门学过翻译呢,但是它偏偏就可以在繁多的参数里面懂得如何翻译不同语言。

输入和输出

对于大模型来说,输入和输出,本质上是 Token。

通过这个分词工具,可以更清晰地理解 Token 的概念,它不一定是一个字母、一个单词、一个符号,而有可能是它们的组合。

如图所示:

所以在训练的时候,输入输出就是:

输入:30357, 21290, 226, 3221
输出:30357, 21290, 226, 3221, 16491

吗?

不是的,实际训练的肯定不是这个 Token ID,而是这个 ID 代表的含义本身。

把文字转换为向量就是所谓的词嵌入(embedding)(关于 RAG 后面再开坑)。根据《从零构建大模型》的说法:最小的 GPT-2 模型(参数量为 1.17 亿)使用的嵌入维度为 768,而 GPT-3模型(参数量为 1750 亿)使用的嵌入维度为 12288。

在这个场景下,简单来说就是让这个 Token 转换为一个 768 个值的数组……

例如番茄就是 [0.9,0.4,0.7,0.5,0.9...后面还有七百多个维度],对比其它水果可能是这样的:

维度 含义 “番茄”的值 “草莓”的值 “黄瓜”的值
1 有多红 0.9 0.85 0.1
2 甜度 0.4 0.8 0.1
3 水分 0.7 0.6 0.9
4 是否属于蔬菜 0.5 0.1 0.8
5 是否可生吃 0.9 0.95 0.8

注意:这里的含义只是比喻,实际上各个维度的含义人类是看不懂的。

大家应该差不多理解了,真正的输入输出就是这些几百上千维度

把这些值的正确排列经过神经网络训练,让其预测下一个 Token 是某个词的概率(logits),然后取概率比较高的值。

最后举个输入输出例子:

输入: "番茄是"
↓ Token化
Token: [123, 456, 789]
↓ 转换为向量
Vector: [[0.1,0.2,...], [0.3,0.4,...], [0.5,0.6,...]]
↓ 神经网络预测
Hidden State: [0.7, -0.2, 1.1, ..., 0.3]  # 768维隐藏状态
↓ 通过输出层投影
Logits: [-2.3, 1.5, ..., 4.2, ..., 2.1, ...]  # 词汇表大小的向量
↓ Softmax转换为概率
概率分布: [0.001, 0.003, ..., 0.45(对应"红"), ..., 0.25(对应"圆的"), ...]
↓ 选择最高概率
输出: Token ID 4567 (对应"红")

中间看不懂不用怕,下一章就会讲到自注意力机制的原理啦~

循环生成

上面一顿输入输出其实只生成了一个新 Token,你需要生成一句话的话,就继续把新的 Token 拼到原来的句子里,继续循环下去,就能生成一整段话啦。

参考资料

🔲 ☆

小猫都能懂的大模型原理 1 - 深度学习基础

小猫都能懂的大模型原理 1 图片来源 pixabay.com

本文旨在用简单易懂的语言解释大语言模型的基本原理,不会详细描述和解释其中的复杂数学和算法细节,希望各位小猫能有所收获 🐱

AI 的科技树

我们先来通过一条简单的链路,定位大模型在 AI 领域的位置: 人工智能 > 机器学习 > 深度学习 > 大语言模型

机器学习最开始就用大量数据线性回归从而对未知数据进行推测。

举个最简单的例子,二维的数据。直接就可以使用数学课就学过的线性回归求方程获取数据的趋势。当时就觉得算线性回归真麻烦呀,计算量那么大,没想到这都蹭到人工智能的边了,还能不难算吗?

接着,人类不满足于简单的线性回归,想要让计算机自动学习更复杂的数据特征,于是就有了深度学习。

深度学习

深度学习之所以深度是因为,它基于多层神经网络自动学习数据特征。多层神经网络就像人脑的神经元互相连接。每根连接的强度就是权重,网络会反复调整这些强度,让结果越来越接近正确答案。

代表性模型有:卷积神经网络(CNN)、循环神经网络(RNN)、Transformer、GAN 等。

大语言模型

Transformer 无情压榨 GPU 产生的奇迹,起初应该没人觉得这效果能这么好。

与深度学习一样,Transformer 也是使用多层神经网络处理矩阵,只不过矩阵异常的大,不到硬件发展到一定水平根本无法实现。

关于大语言模型我们停一下,先比较基础的机器学习原理!

训练的方式

还是从最简单的二维数据开始。

当我们有一堆房产离市中心距离及其价格的数据时,我们可以在一个二维坐标轴表示这些数据,例如 x 轴是距离,y 轴是价格。

在数据都画上坐标轴之后,作为一个人类可能一眼就能粗略看出整个曲线的趋势,从而“拟合”出一条距离价格的关系。

它很可能是一个类似这样的函数:price = distance * w + b

对于计算机,要求出 w 和 b 的最优解,就要让真实价格和通过 w b 计算出来的价格的差值最少。

最常用的方式是均方误差(Mean Squared Error, MSE)

$$ L(w, b) = \frac{1}{n} \sum_{i=1}^{n} (y_i - \hat{y}_i)^2 $$

我们要求的就是这个函数的最小值,在人工智能领域常用的就是梯度下降,也就是求 w 和 b 的偏导数,乘上学习率 α,让它自己慢慢收敛。

梯度下降

过拟合

在拟合出一条接近“规律”的线条后,其实就差不多了。

如果你硬加更多的节点,会造成过拟合,也就所有的训练数据的损失值都很完美,但是一让它生成训练数据以外的东西,它就猜不准了。

就上面房价的例子,假如本来趋势基本就是一个斜线,但是你最后硬是求得一条曲线方程,把所有的点都穿过了,损失值为 0,但是这样计算用户给你的值,反而是算不准的。

这也就是所谓的失去泛化能力

神经网络

上面只用二维数据,就只有一个 price = distance * w + b,但是如果想要做成神经网络,参数就会很多,并且与权重相乘的值的含义,人类并不能轻易理解,例如:

$$ a = w_1 a_1 + w_2 a_2 + w_3 a_3 + w_4 a_4 + b $$

又因为如果只用权重,无论经过多少层都无法拟合曲线,所以最后要添加一个非线性的激活函数计算结果:

$$ a = \mathrm{ReLU}(w_1 a_1 + w_2 a_2 + w_3 a_3 + w_4 a_4 + b) $$

这只是一个层对某一个神经元的计算,下面是一个比较形象的图(请忽略数字)

某层对下一个神经元的计算

所以当一个神经网络有多层,每层多个神经元的话计算量还是挺可怕的。

神经网络

比如判断一张图片有没有猫,你没法用一条简单的线来划分"有猫"和"没猫"的区域。

多层网络的魔力在于:

  • 逐层特征提取:第一层可能只学到边缘和颜色,第二层学到边缘组成的形状,第三层学到眼睛、耳朵,第四层才认识完整的猫脸
  • 非线性组合:通过激活函数,每一层都能创造新的特征组合,让网络可以表示任意复杂的函数
  • 层次化抽象:就像人类认识世界一样,先学简单概念,再组合成复杂概念

这就等于在计算的时候把数据的内涵升维到隐藏层,经过隐藏层额外的处理可以得到更精确的结果。当然这个时候你输入的值也要有足够的信息量它才能学到东西。

但问题又来了既然多层这么强大,那是不是层数越多越好?也不是的。神经网络有两个维度可以调整:

深度(Deep):层数多,每层神经元少

  • 参数少,计算效率高
  • 适合层次化特征学习
  • 容易出现梯度消失(层数太多,误差传不到前面)
  • 训练困难

宽度(Wide):层数少,每层神经元多

  • 训练相对简单
  • 能并行处理更多信息
  • 参数量大,容易过拟合
  • 缺乏层次化抽象能力

实际训练时深度和宽度的平衡需要把握好。

反向传播

上面我们知道用梯度下降的方式调节 w 和 b,对于神经网络也是一样的数学原理,需要通过链式法则(Chain Rule)一层一层反向调整所有权重。

这里就不详细解释怎么层层反推了,就结果而言,我们给出了正确的输入和输出,最开始,这个网络只是瞎猜权重,到最后计算出来,经过损失函数梯度下降调整各种权重,到最后,竟然就可以像魔法一样推导出准确率比较高的答案,喵,喵,喵呀!

P.S. 如果你真的找虐很想了解更多反向传播的计算过程,可以看 3blue1brown 🐱

参考资料

❌