第五章 · 让程序住在云端
Node.js 与异步:从「跑完就结束」到「一直醒着」
你给自己定的目标是「能写一个 30 行小脚本,调用 DeepSeek API 并打印回答」。这一章就带你做到这件事——然后逼你回答一个更难的问题:为什么你写下的每一行能工作。整章真正的主角只有一个词,await。它是你到目前为止遇到的最反直觉的一行代码,也是你从「会照着抄」跨到「知道自己在干什么」的那道门。
0. 先看地图:这一章要带你去哪
读完这一章,你应该能回答
- 「脚本」和「服务」的根本区别是什么?为什么说 Node.js 让 JavaScript 从「跑完就死」变成了「一直醒着」?
- 为什么
package.json里写了"type": "module"之后,__dirname就没了?那四行fileURLToPath到底在补救什么? - 给我一段混着
Promise.then和setTimeout(f, 0)的代码,我能不看电脑、只用笔算出它的输出顺序,并说清每一步为什么排在那里。 - 为什么一个死循环能让整个机器人哑掉?单线程的 Node 到底靠什么扛住几十个同时说话的人?
await究竟做了什么?它「暂停」的是谁,「不阻塞」的又是什么?- 能独立写出一段 30 行以内、从环境变量读 Key、带超时和错误分支的 DeepSeek 调用脚本,并说清删掉其中任意一行会坏在哪里。
- OWL 的
OneBotClient.call是怎么用 Promise、Map 和一个定时器,把「发出去的请求」和「收回来的响应」配成一对的?
学完这一章,你会做这些事
- 写出那个 30 行脚本:调用 DeepSeek 并把回答打印出来
- 用
async/await正确地写异步代码,不再被输出顺序绕晕 - 起一个 HTTP + WebSocket 服务,并让它在收到停止信号时优雅退出
- 用环境变量与配置分层管理密钥与可变配置
- 自己实现限流、超时、重试与降级这四件保命装置
需要的前置:第二章(JavaScript 基础)的函数、对象、数组、回调;第四章(HTTP、JSON、API 与 WebSocket)的请求-响应、状态码、JSON 结构、WebSocket 与反向 WebSocket;第一章(计算机常识与 Linux)里的进程、端口、环境变量、信号。少了其中任何一个,这一章都会读得很吃力。
回头看看:第二章末尾我们只用了一小段话提「异步的直觉」,当时我刻意没讲透,只让你记住「有些函数不会立刻给你结果」。现在我们把那一页翻回来——你会发现「不会立刻给你结果」这句话里,藏着一整套机器的工作方式。第三章(Git)里那句「随时可以退回去」在这里也会再出现一次:你会不停地写坏代码,而这一章的教学方式是「跑一下、看输出、再改」,这本身就是一种时间机器。
这一章会为后面铺路:第六章(数据库与记忆)整章都建立在这一章的 await 之上——查询数据库是异步的,写记忆也是异步的;第七章(部署与运维)会接着这一节最后讲的「优雅退出」和「信号」往下走,把程序交到 systemd 手里;第八章(Prompt、RAG 与多模态)会回来改这一章那段 30 行脚本,把它从「等一整段」改成「一个字一个字地流出来」。
1. 脚本跑完就死,服务一直醒着
先看清这一章要跨过的那道坎。它不是「学一个新语言」,也不是「学几个新函数」。它是两种程序之间的区别。
1.1 你以前写的程序,都是「一次性」的
回想第二章你写的第一个程序。你在编辑器里敲下几行,运行它,屏幕上出现你想看的东西,然后——它就结束了。那个程序的生命只有几毫秒。它做的事情是:启动、算完、打印、退出。像一支蜡烛,点亮、烧完、灭掉。
这类程序有个名字:脚本(script)。给你一个词源上的直觉——它在英文里原本是「剧本」的意思:写好了一串台词,从头念到尾,念完散场。
脚本非常有用。转个格式、算个日期、批量改文件名、把一堆数据算成一张表,这些都是脚本的活。但它们有一个共同的、也是致命的特征:没有人叫它,它就不会醒来。
1.2 服务是另一种东西:它不结束
现在想象 OWL。凌晨两点,全世界都睡了,没人给 OWL 发消息。可如果这个时候你去服务器上敲一条命令,你会看到那个进程还活着,占着一点点内存,在那儿等着。等到三点有人发来一句「在吗」,它立刻醒来、处理、回复,然后回去继续等。
它没有「结束」这个状态。它只有「正在处理」和「正在等待」两种状态,来回切换,一直持续到有人把它杀掉。
这类程序叫服务(service)或者守护进程(daemon)。第一章我们见过「守护进程」这个词,那时它的定义是「在后台一直运行、不占终端的程序」。当时那句话对你只是一个描述;现在它变成了一个你必须亲手写出来的东西。
| 对比项 | 脚本 | 服务 |
|---|---|---|
| 生命 | 执行完最后一行就退出 | 永不主动退出,除非被要求 |
| 什么时候干活 | 你运行它的那一刻 | 别人来找它的那一刻 |
| 结束条件 | 代码跑到末尾 | 收到信号、崩溃、或被强制杀死 |
| 有没有端口 | 通常没有 | 通常占着一个端口,等着别人连 |
| 你怎么知道它坏了 | 看输出 | 得看日志、看进程、看端口 |
| OWL 里的例子 | test-llm.mjs(测完 Key 就退出) | bot.js(跑起来就不停了) |
这张表里最重要的一行是第三行。脚本的结束条件是「代码跑到末尾」——这是你写代码的方式决定的。而服务的结束条件是「收到一个信号」——这是外面的人决定的。这意味着服务的作者必须多写一件事:处理那个信号。
我们在这一章的第 8 节会看到 OWL 是怎么处理它的,而那里藏着一个真实的、会让你丢数据的坑。
1.3 Node.js 到底做了什么:把浏览器的心脏搬出来
现在说 Node.js 是什么。这件事值得说得非常慢。
JavaScript 这门语言,1995 年诞生在浏览器里,最初的唯一用途是「让网页上的一些小东西动起来」。它的运行方式是这样的:浏览器里装了一个叫 V8(Google 为 Chrome 写的 JavaScript 引擎)的东西,V8 负责读你写的 JavaScript,把它翻译成机器能执行的指令并执行。
关键点在这里:V8 本身并不知道什么是「文档」,什么是「按钮」,什么是「弹窗」。这些能力是浏览器额外塞给它的。V8 只会算数、处理字符串、管理对象、调用函数。它是一个纯的计算引擎。
2009 年,有人做了一个非常大但很简单的决定:既然 V8 只会算数,那我把 V8 单独拿出来,再给它换一套「外部能力」——不给它文档和按钮,而给它文件读写、网络连接、进程管理。这样,同一种语言就能用来写服务器程序了。
这个「V8 + 外部能力」的组合,就是 Node.js。它的名字里的 Node 是「节点」的意思,最初指的是「网络里的一个节点」。
所以最准确的一句话定义是:Node.js 不是一门语言,它是把浏览器里的 JavaScript 引擎搬出来,重新装上文件、网络和进程能力的运行时。
要点JavaScript 是语言,Node.js 是运行这门语言的环境。同一段 JavaScript,在 Node 里能读文件,在浏览器里不能;在浏览器里能操作页面,在 Node 里不能。不是语言变了,是语言周围的世界变了。这句话现在先记住,它会在第八章讲「为什么浏览器里不能直接调 DeepSeek API」时救你一命。
1.4 Node 20、V8 和「服务器端 JS 为什么能快」
OWL 跑在 Node.js 20 上。这个「20」是主版本号,Node 大约每半年发一个新主版本,偶数号(18、20、22)会进入长期支持状态(LTS),生产环境一般选偶数号。qq-bot/deploy/install.sh 里的检查写得很直接:
if ! command -v node >/dev/null 2>&1; then
warn "没有找到 node。请先安装 Node.js 20+:"
echo " curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -"
echo " sudo apt-get install -y nodejs"
die "安装完 Node 后重新运行本脚本。"
fi
NODE_MAJOR="$(node -p 'process.versions.node.split(".")[0]')"
if [[ "$NODE_MAJOR" -lt 18 ]]; then
die "Node 版本过低(当前 $(node -v)),需要 18 以上,推荐 20。"
fi
▲ 摘自上章项目里的 qq-bot/deploy/install.sh。它先看有没有 node 命令,再解析出版本号的第一段,低于 18 就拒绝安装。为什么不直接在脚本里写死 node -v 就完事?因为它要的只是主版本号这一个数字,而 node -p 让 Node 自己算出来——比你用文本工具去切字符串可靠得多。
接下来是那个常见问题:JavaScript 一直被认为是「慢」的语言,凭什么能写服务器?
答案分两层。第一层是 V8 自己的进步。JavaScript 是解释型语言,本来应该是「读一行翻译一行」,但 V8 引入了即时编译(JIT,Just-In-Time compilation):它在运行中观察哪些函数被反复调用,然后把那些「热点」编译成机器码。于是同一段代码,跑得越久越快。
第二层,也是更重要的那一层:服务器程序的瓶颈根本不在计算上。
OWL 处理一条消息,它自己做的计算有多少?拼一个字符串、做几次正则匹配、查一下 Map、把 JSON 序列化——加起来可能不到一毫秒。而它在等 DeepSeek 返回的那一秒里,CPU 完全闲着。一个 Web 服务 90% 以上的时间不是在算,而是在等。
而 Node 恰好把「等」这件事处理得非常好。这就是下一节的主题,也是这一章的理论核心。
想一想如果服务器程序 90% 的时间都在等,那么「让 CPU 算得更快」和「让等待更高效地重叠起来」,哪个对整体吞吐量的提升更大?先别急着回答,读完第 3 节再回来算一遍这笔账。
2. 模块系统与你的第一个工程
在讲异步之前,得先让你能把这个工程「立起来」。这一节讲的全是脚手架,但每一根都是有理由的——尤其是那四行看起来莫名其妙的 __dirname。
2.1 一个文件装不下的时候
脚本可以只有几十行,一个文件写完。但 OWL 不行:它要处理消息解析、指令匹配、限流、日志、AI 调用、安全兜底。如果全塞进一个文件,两千行,你会找不到自己昨天写的东西。
办法是拆成多个文件,每个文件负责一件事,再让它们互相引用。这个机制叫模块。bot.js 管「连接和调度」,llm.mjs 管「模型调用」,safety.mjs 管「安全识别」,values.mjs 管「价值观介入时机」。每个文件都是一个模块。
2.2 两套写法:CommonJS 与 ESM
JavaScript 的模块系统有新旧两套,你现在遇到的所有教程混乱都来自这里。
旧的那套叫 CommonJS(缩写 CJS),2009 年随 Node 一起出现,语法长这样:
// 导出:在一个文件里写
module.exports = { add, sub };
// 导入:在另一个文件里写
const { add } = require("./math.js");
新的那套是语言标准自己定义的,叫 ESM(ECMAScript Modules,ECMAScript 模块),也是浏览器里原生的写法:
// 导出
export function add(a, b) { return a + b; }
export default class LlmClient { /* ... */ }
// 导入
import { add } from "./math.js";
import LlmClient from "./llm.mjs";
两套的区别不需要背,你只需要记住三条能救命的事实:
.mjs后缀强制按 ESM 解析,.cjs强制按 CommonJS 解析。OWL 的 AI 模块叫llm.mjs,名字里那个 m 就是这个意思。- 在
package.json里写"type": "module",会让该目录下所有.js文件都按 ESM 解析。OWL 的bot.js就是这么变成 ESM 的——看它的后缀是.js,但它里面写的是import。 - ESM 里
require、module、exports、__dirname全都不存在。这是这一节最重要的那句。下一小节我们就要处理它。
警告当你从网上抄到一段 Node 代码却报 require is not defined 或者 Cannot use import statement outside a module 时,99% 的原因是模块系统不匹配,不是你的代码写错了。不要急着改逻辑,先看两件事:文件后缀是什么、package.json 里有没有 type。
这两个报错正好是同一件事的两个方向,值得你各记一次:require is not defined 说明这段代码被当成 ESM 执行了,而它里面写的是 CommonJS 的 require;Cannot use import statement outside a module 说明这段代码被当成 CommonJS 执行了,而它里面写的是 import。两句话都不是在说「你写错了」,而是在说「Node 猜错了你用的是哪一套」。
第一条的完整原文我实测出来了,值得你读一遍,因为它把判断依据直接写在了报错里:
ReferenceError: require is not defined in ES module scope, you can use import instead
This file is being treated as an ES module because it has a '.js' file extension and
'...\package.json' contains "type": "module".
To treat it as a CommonJS script, rename it to use the '.cjs' file extension.
▲ 在 Node v22 上实测的输出。第二、三行是 Node 主动告诉你「我为什么这么判定,以及你可以怎么改」——这就是序章里说的「错误信息是计算机对你说的最诚实的话」。把这三行读完,你不需要去搜任何教程。
而 Node 猜的依据只有两条,按顺序看:先看后缀——.mjs 一定是 ESM,.cjs 一定是 CommonJS,这两个后缀不需要任何配置;后缀是 .js 时就去看「离这个文件最近的那个 package.json 有没有 "type" 字段——写着 "module" 就按 ESM,写着 "commonjs" 或者干脆没写这个字段,就按 CommonJS。
// 同一个目录下的两种情况,差别只在这个文件里有没有那一行
// ---------- 情况 A:bot/package.json 里有 "type": "module" ----------
// bot.js 写 import → 正常,被当成 ESM
// bot.js 写 require → ReferenceError: require is not defined in ES module scope
// ---------- 情况 B:那行写成 "type": "commonjs",或者删掉它 ----------
// bot.js 写 require → 正常,被当成 CommonJS
// bot.js 写 import → SyntaxError: Cannot use import statement outside a module
▲ 情况 A 那两条我在本机验证过。情况 B 的报错原文随 Node 版本略有差别,但错误类型(SyntaxError)和那句话的意思是一致的。你可以真的把 bot/package.json 里 "type": "module" 那一行改掉、运行一次 node bot.js,亲眼看一遍。
技巧所以你自己新建一个文件时,先想清楚一件事:我这一份代码整体用的是哪一套?OWL 的选择是「全项目声明一次」——在 package.json 里写 "type": "module",然后所有 .js 都安心写 import;只有确实需要按 CommonJS 跑的文件才起名 .cjs。最糟的做法是「一部分文件靠后缀、一部分靠配置」,那样你每加一个文件都要重新猜一次 Node 会怎么解析它。
2.3 为什么 __dirname 需要 fileURLToPath
现在看 bot.js 的开头。我要一行一行地讲,因为这四行是初学者最容易「照抄但不懂」的地方。
import fs from "node:fs";
import path from "node:path";
import http from "node:http";
import { fileURLToPath } from "node:url";
import { WebSocketServer } from "ws";
import { LlmClient } from "./llm.mjs";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const CONFIG_PATH = path.join(__dirname, "config.json");
▲ 摘自 qq-bot/bot/bot.js 第 10–18 行。前五行是导入,第六行是推导出 __dirname,第七行用它拼出配置文件的绝对路径。
先看导入。注意 "node:fs" 里那个 node: 前缀。这是 Node 后来引入的写法,意思是「我要的是 Node 内置模块 fs,不是 node_modules 里某个碰巧也叫 fs 的第三方包」。加上前缀,你就永远不会因为装错包而导入到别人的东西。不加前缀也能跑,但加上是一种纪律。
ws 没有前缀,因为它是装在 node_modules 里的第三方库。./llm.mjs 有 ./,表示这是相对当前文件的路径,而不是一个包名。这三种写法的区分很重要:Node 看到 ./ 就去文件系统找,看到裸名字就去 node_modules 找。
现在到了核心那一行:
const __dirname = path.dirname(fileURLToPath(import.meta.url));
拆成三步看,它其实在翻译。最里面是 import.meta.url。在 ESM 里,每一个模块都有一个关于自己的信息对象,叫 import.meta,其中 url 是这个模块自己的位置。但它的格式是网址格式,在 Windows 上长这样:
file:///C:/你的项目目录/qq-bot/bot/bot.js
(在 Linux 上会是这样)
file:///opt/qq-bot/bot/bot.js
注意三件事:开头有个 file://,Windows 上盘符前面还多一条斜杠,中文或空格会被编码成 %E4%B8%AD 这种东西。人的眼睛能看懂,但操作系统的文件函数看不懂——你把这样一个字符串传给 fs.readFileSync,它会告诉你「文件不存在」。
所以第二步,fileURLToPath(...) 把网址格式还原成本机路径格式:
file:///C:/你的项目目录/qq-bot/bot/bot.js
↓ fileURLToPath
C:\你的项目目录\qq-bot\bot\bot.js
第三步,path.dirname(...) 把这个文件路径的「最后一段文件名」砍掉,留下它所在的目录:
C:\你的项目目录\qq-bot\bot\bot.js
↓ path.dirname
C:\你的项目目录\qq-bot\bot
于是 __dirname 得到了它该有的值:这个脚本自己所在的目录。下一行 path.join(__dirname, "config.json") 就拿到了配置文件的完整位置。
要点为什么非要绕这一圈?因为 ESM 是按语言标准设计的,标准当然不会为 Node 的 __dirname 开后门。旧版 CommonJS 里 Node 直接把 __dirname 塞给你,方便但不标准;ESM 里 Node 只能提供标准的 import.meta.url,剩下的转换要你自己做。你写的这一行不是怪癖,而是「标准」和「方便」之间的那道手续。
那为什么不干脆用相对路径 "./config.json"?这是一个真实的坑。相对路径是相对于你敲命令时所在的目录,而不是脚本所在的目录。你在 qq-bot/bot 里运行 node bot.js 一切正常;但 systemd 启动它时,工作目录可能完全不同,于是 "./config.json" 就指向了别处,程序报「找不到文件」。用 __dirname 拼出来的绝对路径,跟你在哪敲命令无关。第七章我们会看到 systemd 配置里那行 WorkingDirectory 就是专门管这件事的。
2.4 package.json:一个工程的身份证
OWL 的 package.json 只有 14 行,正好够看清楚它每个字段在干什么:
{
"name": "qq-bot-minimal",
"version": "1.0.0",
"private": true,
"type": "module",
"description": "最小可用 QQ 机器人 (OneBot v11 反向 WebSocket)",
"main": "bot.js",
"scripts": {
"start": "node bot.js"
},
"dependencies": {
"ws": "^8.22.0"
}
}
▲ qq-bot/bot/package.json 全文。
name和version:包的名字与版本。不发布到 npm 的话,它们只是给人看的标签。private: true:明确声明「这个包不发布」。这是一道防误操作的锁——万一你手滑敲了npm publish,npm 会拒绝,而不是把 OWL 的代码推到公共仓库去。type: "module":如前面所说,让本目录的.js按 ESM 解析。main:别人require这个包时默认加载哪个文件。scripts:把常用命令起个短名字。配了这一行之后,npm start等于node bot.js。它的价值不在省几个字,而在于把「怎么启动这个项目」这件知识写进了代码里。三个月后你忘了启动命令,打开这个文件就知道了。dependencies:运行这个程序必需的第三方包,以及可接受的版本范围。
那个 "^8.22.0" 里的 ^ 也要解释。脱字符表示「主版本号相同的前提下,允许升级」:^8.22.0 允许安装 8.22.1、8.30.0,但不允许 9.0.0。因为按语义化版本的约定,主版本号变化意味着有「不兼容的改动」,可能会让代码坏掉;小版本和补丁版本则应该是兼容的。术语总表里的「锁定版本」讲的就是这件事。
2.5 npm、node_modules 与 lockfile
npm(Node Package Manager,Node 包管理器)是随 Node 一起装好的工具,三条命令覆盖 99% 的日常:
npm init -y:在当前目录生成一个默认的package.json。-y表示所有问题都取默认值,免得它一个个问你。npm install ws:下载ws,放进node_modules/,同时在package.json的dependencies里记一笔。npm ci:严格按照 lockfile 里记录的确切版本重新安装一遍。
node_modules 是包的落地目录,每装一个库,它和它的依赖都会被摊在这里。这个目录的特点是:大、乱、绝对不该提交到 Git。第三章学过的 .gitignore 就是为它准备的。
那别人克隆你的仓库之后怎么拿到依赖?靠 package-lock.json。它记录了本次安装时每个包实际被解析到的确切版本号和下载地址。因为它存在,OWL 的部署脚本会优先用它:
log "安装机器人依赖…"
cd bot
if [[ -f package-lock.json ]]; then
npm ci --omit=dev --no-audit --no-fund || npm install --omit=dev --no-audit --no-fund
else
npm install --omit=dev --no-audit --no-fund
fi
cd "$SCRIPT_DIR"
log "依赖安装完成 ✓"
▲ 摘自 qq-bot/deploy/install.sh。有 lockfile 就用 npm ci(可控、可复现),没有就退回 npm install。--omit=dev 表示不装只在开发时用得到的包,--no-audit --no-fund 是关掉「安全审计」和「募捐提示」的输出,让部署脚本更安静、更快。
要点npm install 和 npm ci 的关键区别:install 会看着 package.json 的范围去解析可能更新的版本,并顺手改写 lockfile;ci 完全信任 lockfile,装完就删掉 node_modules 重来,一毫米都不许偏。开发时用 install,部署时用 ci——因为你最不想要的事情,就是「昨天还好好的,今天重装一次依赖,机器人就崩了」。
2.6 OWL 只依赖一个 ws:克制是一种设计
你现在应该盯着那一行 dependencies 多看几秒。整个 OWL——一个能连 QQ、能调大模型、有记忆、有限流、有安全兜底的机器人——只依赖一个第三方库。
这不是因为它简陋,而是因为它每一个「不需要」都有理由:
| 常见的做法 | 需要依赖 | OWL 的选择 |
|---|---|---|
| 用 Express/Koa 起 HTTP 服务 | 若干包 | 用内置 node:http,二十行搞定 |
| 用 axios/node-fetch 发请求 | 若干包 | Node 18 起内置了 fetch,直接用 |
| 用 dotenv 读 .env 文件 | 1 个包 | 直接用 process.env,不引入 .env 这层 |
| 用 pino/winston 写日志 | 若干包 | 自己写 20 行的 log(),正好够用 |
| 用 ws 收发 WebSocket | 1 个包 | 留下了——因为 Node 当时还没有稳定可用的服务端 WebSocket 实现 |
留下来 ws 的那一行,是整张表里最有信息量的一格:它不是「能省则省」,而是「省不了才留」。WebSocket 服务端需要处理握手、帧解析、掩码、ping/pong 心跳、分片、关闭握手——自己写这些不是在学习,是在重新发明一个已经打磨了十年的轮子。
我之所以在这里花这么多字,是因为初学者最常犯的错误不是写得少,而是引入太多。每一个依赖都是一个你不认识的作者写的、会不定期更新的、某天可能不再维护的代码块,而且它会出现在你的「攻击面」里。第九章会专门讲这件事,现在先给你一条可操作的标准:
技巧引入任何依赖之前,问一句:它替我解决的这个问题,我真的有吗?我遇到的是一台服务器还是十台?是三个用户还是三万个?如果答案是「只有 OWL 和她的几十个朋友」,那么一个 20 行的内置方案,通常比一个 20 个包的框架更值得。
3. 事件循环:单线程为什么能同时干很多事
这是本章的理论核心。前面所有的铺垫都是为了这一节,后面所有的代码都建立在这一节上。所以我允许它慢,也允许我反复换比喻——但每一个比喻之后,我一定会回到真实的机制。
3.1 一个具体的场景:三件「要等」的事
假设你写了一个处理一条消息的函数,它要做三件事:
- 从硬盘读一个 JSON 文件(
history.json),大概要 2 毫秒; - 向 DeepSeek 发一个请求,大概要 900 毫秒;
- 把回复通过 WebSocket 发回给 QQ,大概要 1 毫秒。
这三件事加起来要 903 毫秒,其中 902 毫秒是「等待」——等待硬盘,等待网络。你的 CPU 在这 902 毫秒里做的事,大概只是「把数据搬来搬去」。
现在问题来了:在这 900 毫秒里,如果又有三个人给 OWL 发消息,会发生什么?
如果你的程序是「做完一件再做下一件」,那么第二个人要等 900 毫秒,第三个人要等 1800 毫秒。四个人同时说话,最后一个人要等三秒多才收到回复。这就是绝大多数初学者写出来的程序的行为。
而真实的 OWL 不会这样。四个人的请求几乎是同时发出去的,四个人的等待时间重叠在一起,所有人都在一秒左右收到回复。它靠的不是更快的 CPU,而是把「等」重叠起来。
这套机制叫事件循环。
3.2 三个角色:调用栈、任务队列、事件循环
先记住三个角色的名字,它们构成了全部机器。
第一个是调用栈(call stack)。就是「当前正在执行什么」的那一摞。第二章你学函数调用时其实已经见过它:你调用 a(),a 里调用 b(),b 里调用 c(),执行顺序是 c 先结束、然后 b、然后 a——这就是「栈」的后进先出。Node 里只有一个调用栈,这就是「单线程」的全部含义。
第二个是任务队列(task queue)。还没轮到的回调函数在这里排队。比如你写了 setTimeout(f, 1000),一秒钟后 f 会被放到这个队列的末尾,等着被调用。
第三个就是事件循环(event loop)。它不是一个你能调用的函数,而是 Node 内部的一个永不结束的循环体,大致等价于这样一段伪代码:
// 以下是伪代码,用来表达机制,不是真的源码
while (还有事情没做完) {
1. 看看有没有到时间的定时器回调 → 把这些回调放进队列
2. 把队列里的回调逐个拿出来,压进调用栈执行,直到队列空
3. 清空所有「微任务」(稍后解释)
4. 看看有没有新的网络数据到了 → 把对应的回调放进队列
5. 回到第 1 步
}
▲ 真实的事件循环比这复杂(它分成 timers、pending callbacks、poll、check、close 等若干个「阶段」),但上面这五步足够解释你未来一年会遇到的所有现象。
现在整个机制可以一句话说完:你的代码不是「一直运行」,而是在调用栈里一小段一小段地执行;每一小段之间,事件循环回去看看有没有新的活干。等待期间调用栈是空的,所以别人可以插进来。
要点注意最后那句话的分量:「等待期间调用栈是空的」。这不是一个比喻,这是字面意义上正在发生的事。当 OWL 在等 DeepSeek 的时候,它那个唯一的调用栈确实是空的,事件循环正在自由地把别人的回调一个一个压进去执行。如果你能理解这一句,这一章最难的部分已经过去了。
3.3 谁来告诉 Node「数据到了」?
这里有一个必须补上的洞。你说是「等」,可机器怎么知道等完了?谁来敲那扇门?
答案是操作系统。第一章我们讲过内核负责处理网络包。当 OWL 发出一个 HTTPS 请求时,它实际上是让内核「帮我建一条到 api.deepseek.com 的连接,有数据回来就告诉我」。然后 Node 就回去干别的了。内核收到数据后,把这件事记在自己的待办清单上;Node 通过几个操作系统提供的机制(在 Linux 上主要是 epoll)去问内核:「我那些连接里,有哪些现在有数据了?」
所以完整链条是:你的代码发起 I/O → 内核接手(你的代码立刻返回)→ 事件循环继续跑别的 → 内核说「有数据了」→ 事件循环把对应的回调放进队列 → 回调被执行,你的代码拿到结果。
这就是「非阻塞 I/O」这个词的真实含义:发起 I/O 操作的那一行代码不会停在那里等,它立刻返回;等结果的过程交给操作系统和事件循环。
3.4 微任务与宏任务:为什么 .then 排在 setTimeout(f, 0) 前面
现在到了这一章最值得你亲手做实验的地方。下面这四行代码,请你先别看输出,用笔写下你以为的顺序,然后再去运行它。
console.log("1 同步开始");
setTimeout(() => console.log("2 setTimeout 0"), 0);
Promise.resolve().then(() => console.log("3 Promise.then"));
console.log("4 同步结束");
▲ 保存成 order.mjs,用 node order.mjs 运行。
绝大多数人第一次猜的是 1 → 4 → 2 → 3(因为 0 毫秒嘛,应该马上执行)。真实的输出是:
1 同步开始
4 同步结束
3 Promise.then
2 setTimeout 0
输出是确定的,每一次都一样。原因在于:队列不止一个,而且它们有优先级。
事件循环里的任务分成两类:
- 宏任务(macrotask):
setTimeout、setInterval、setImmediate、以及每一次网络数据到达、文件读完所触发的回调。它们进入「宏任务队列」。 - 微任务(microtask):
Promise.then/.catch/.finally的回调、queueMicrotask()注册的函数。await后面的代码,本质上也是被包成微任务塞进这个队列的。它们进入「微任务队列」。
事件循环的规则是:每执行完一个宏任务,就把微任务队列彻底清空(一个不剩),然后才去取下一个宏任务。
现在我们可以逐行推演上面那四行了:
- 整个脚本本身就是一个宏任务,它开始执行。
"1 同步开始"立刻打印。setTimeout(f, 0):把f注册进定时器。0 毫秒不等于立刻,它的意思是「尽快,但要排在后面」。f被放进宏任务队列。Promise.resolve().then(g):这个 Promise 已经是成功状态,于是g被立刻放进微任务队列。"4 同步结束"立刻打印。- 当前宏任务(整个脚本)执行完毕。事件循环检查微任务队列:里面有
g,执行 → 打印"3 Promise.then"。队列空了。 - 现在才轮到下一个宏任务:
f→ 打印"2 setTimeout 0"。
所以输出顺序是 1 → 4 → 3 → 2。不是「setTimeout 慢」,而是「微任务永远排在所有宏任务前面」。
现在再看一个更完整的版本,它把 Node 里四种「稍后执行」的写法都放进去了:
console.log("1");
process.nextTick(() => console.log("2 nextTick"));
queueMicrotask(() => console.log("3 queueMicrotask"));
Promise.resolve().then(() => console.log("4 then"));
setTimeout(() => console.log("5 setTimeout"), 0);
setImmediate(() => console.log("6 setImmediate"));
console.log("7");
▲ 保存成 order2.cjs 运行(用 .cjs 后缀、require 那套写法,结果更稳定,原因下一段说)。
我实测的输出是:
1
7
2 nextTick
3 queueMicrotask
4 then
6 setImmediate
5 setTimeout
四条规律,你只需要记住这四条:
| 规律 | 说明 |
|---|---|
| 同步代码一定最先 | 1 和 7 在所有异步之前跑完,哪怕你写了 setTimeout(f, 0) |
process.nextTick 最先于所有 Promise 回调 | 它是 Node 自己的「插队队列」,官方文档明确写着:nextTick 队列总是先于微任务队列被处理。它比 Promise 更「急」 |
| 所有微任务跑完,才轮到宏任务 | queueMicrotask、then 都在 5、6 之前 |
setImmediate 与 setTimeout(f, 0) 的先后不确定 | 它们都属于宏任务,谁先到取决于当时的循环处在哪个阶段。我在自己的机器上测出的是 setImmediate 先,但不要依赖这个结果 |
警告在网上你会看到很多「标准答案」说 setImmediate 一定在 setTimeout(f,0) 前面。这不严谨。两者都在事件循环的不同阶段,如果在主模块里注册,谁先触发可能随机器和当次运行的耗时变化;只有在一个 I/O 回调内部注册时,setImmediate 才稳定地跑在前面。凡是你写不出一条确定输出顺序的示例,就不要把它写进自己的笔记。这一章里所有「输出是什么」的代码,我都在本机跑过至少三遍。
顺便解释一个你可能踩到的坑:为什么我说用 .cjs 运行时结果更稳定?因为在 ESM 的顶层代码里,模块加载器自己的机制会给微任务的时序带来一点额外的不确定性——我自己用 .mjs 跑同一段代码时,process.nextTick 反而排到了 queueMicrotask 后面。这不是我写错了,而是环境差异。这本身就是一条重要经验:当你看到一个「奇怪的现象」,先怀疑自己的实验环境,再怀疑自己对机制的理解。
3.5 同步阻塞的代价:一段 while 能让整个机器人哑掉
既然调用栈只有一个,那就有一种灾难性的写法:在回调里干一件很久的、纯计算的事。
下面这段代码模拟了 OWL 的心脏跳动(每 300 毫秒一次),然后让它在第 700 毫秒时「忙」1.6 秒。请你运行它,看心电图上会出现什么。
const log = (x) => console.log(x);
setInterval(() => log("心跳"), 300);
setTimeout(() => {
log("开始忙 1.6 秒");
const end = Date.now() + 1600;
while (Date.now() < end) {
// 什么都不做,只是占住 CPU 和调用栈
}
log("忙完了");
}, 700);
setTimeout(() => { log("结束"); process.exit(0); }, 3000);
▲ 保存成 block.mjs 运行。最后那个 3 秒的定时器是为了让程序自己结束,不然 setInterval 会让它永远跑下去——这本身也是一个知识点:只要还有定时器或监听的端口,Node 就不会退出。
实测输出:
开始
心跳
心跳
开始忙 1.6 秒
忙完了
心跳
心跳
心跳
结束
正常情况下的心跳应该出现在 0、300、600、900、1200、1500 毫秒……但从第二次心跳(约 600 毫秒)之后,下一次心跳直接跳到了 2200 毫秒之后。中间那一段时间,心跳一次都没有发生。
为什么?因为 while 循环一直占着调用栈。调用栈没空,事件循环就拿不到控制权。事件循环拿不到控制权,就没法去检查「有没有到时间的定时器」,也没法去检查「有没有网络数据到了」。
把这句换成 OWL 的语言:如果它的某段代码陷进了一个 1.6 秒的死循环,那么这 1.6 秒里所有人发的消息都收不到、所有人都在等,整个机器人哑掉。而且更糟的是,等待的消息不会丢——它们会在循环结束后一次性涌进来,把你的 AI 接口额度在几秒钟内打满。
警告这条规律的名字叫「不要阻塞事件循环」。初学者最常见的三个肇事者是:超大数组的同步循环(比如把十万条聊天记录全读出来做正则匹配)、同步的文件操作(fs.readFileSync 在服务里是危险动作)、以及写错的 JSON 递归。OWL 里确实用了 fs.readFileSync 和 fs.appendFileSync,但只用在小文件上(配置、日志各一行),耗时可忽略。「同步」不是绝对的错,而是一笔要在毫秒尺度上算清楚的账。第八章我们会讲怎么把大计算拆成小块,让出调用栈。
3.6 setTimeout / setInterval / setImmediate / process.nextTick
这四个名字很像,用途完全不同。记住它们各自的定位就够用了:
| 写法 | 什么意思 | 主要用途 | 属于 |
|---|---|---|---|
setTimeout(f, ms) | 至少 ms 毫秒之后执行 f(不保证准时,前面堵着就得等) | 延迟、超时 | 宏任务 |
setInterval(f, ms) | 每隔大约 ms 毫秒执行一次,直到 clearInterval | 心跳、轮询、健康检查 | 宏任务 |
setImmediate(f) | 在本轮循环的 I/O 之后尽快执行 | 先让 I/O 回调排完队,再干这件事 | 宏任务 |
process.nextTick(f) | 当前这一小段代码一结束就执行,比所有 Promise 都早 | Node 内部、库作者用来保证时序 | 插队队列(比微任务还优先) |
表格最后一列那个「宏任务」其实还能再往下分一层。事件循环转一圈不是「随便挑一个回调执行」,而是按固定的几个事件循环阶段依次走:timers(到点的定时器)→ pending callbacks → poll(收网络与文件 I/O 事件)→ check(setImmediate)→ close callbacks;每两个阶段之间,微任务队列都会被清空一次。这一层细节就是 3.4 节那道题里「setTimeout(f,0) 和 setImmediate 顺序不保证」的真正原因——它们被安排在两个不同的阶段,谁先被处理取决于注册那一刻循环走到了哪里。你现在不需要背这五个名字,但要知道「事件循环内部是有阶段的」这件事的存在,这样当你看到别人用「阶段」解释顺序时,不会以为他在编。
给你一个可以立刻用上的判断方法:
- 要「等一会儿」用它,用
setTimeout。这一章后面所有的sleep、超时、冷却,都是它。 - 要「每隔一会儿」用它,用
setInterval。但要记得在不需要的时候clearInterval,否则它会让进程永远不退出。 setImmediate和process.nextTick,你现在可以只做到「看得懂」,不必主动用。它们是给「需要精确控制执行顺序」的场景准备的。看到别人的代码里出现它们,你知道那是在抢时序就够了。
想一想回到第 1 节我留给你的那道账:如果服务器程序 90% 的时间在等,那么「让 CPU 算得更快」和「让等待重叠起来」哪个更重要?现在你知道了「重叠等待」靠的是事件循环在一次回调结束后立刻去看下一件事。但请注意一个反向的问题:如果一台机器只有一个调用栈,那么当真的有一件事必须算很久(比如给一万条消息做语义检索)时,你该怎么办?这个问题的答案在第八章,但我想让你先带着它往前走。
4. 异步三兄弟:回调、Promise、async/await
机制讲完了,现在讲写法。同一个机制,JavaScript 社区用了十几年,演化出三代写法。理解这三代的关系,比记住任何一个 API 都重要——因为你在网上看到的代码会横跨三代,而 OWL 自己的代码里三代都有。
4.1 第一代:回调函数
最早的做法非常朴素:「这件事我做完了,你就调用这个函数。」把「接下来做什么」当成参数传进去。
// 简化版,帮助你理解回调的形状
fs.readFile("config.json", "utf8", (err, data) => {
if (err) {
console.log("读失败了:", err.message);
return;
}
console.log("读到了:", data);
});
注意那个 (err, data) 的参数顺序:错误在前,结果在后。这是 Node 里几乎所有回调的统一约定,叫「错误优先回调」。为什么错误要放前面?因为如果你只想拿结果,可以只写一个参数忽略错误;但如果错误在后面,你想忽略它就得写个占位符。这是一个细节里的设计智慧。
回调的问题是:当你需要一连串「做完 A 再做 B,做完 B 再做 C」时,代码会长成这样。
// 这就是「回调地狱」,一段真实形状的代码
readUser(userId, (err, user) => {
if (err) return handle(err);
loadHistory(user.id, (err, history) => {
if (err) return handle(err);
askModel(user, history, (err, reply) => {
if (err) return handle(err);
sendMessage(user.qq, reply, (err) => {
if (err) return handle(err);
saveHistory(history, (err) => {
if (err) return handle(err);
console.log("终于全部做完了");
});
});
});
});
});
三层嵌套就已经很难读了,五层就是灾难。更麻烦的是三件事:
- 错误处理被复制了五遍。每一层都要写一次
if (err) return handle(err)。你漏掉任何一层,错误就会被无声地吞掉。 - 你没法「取消」。写到第三层了才发现不该继续,你没有办法从外面把这串东西掐断。
- 你没法「一起等」。如果你有三件互不依赖的事,想「三个都做完了再继续」,用回调写会非常别扭。
这段代码之所以叫「地狱」,不是因为它丑,而是因为它把「顺序」和「缩进」绑死在一起了。你想改一下顺序,就要整块整块地搬家。
4.2 第二代:Promise
Promise(承诺)的思路是:不要让函数「做完了才调用你」,而是让函数立刻返回一个「凭证」。这个凭证代表「一件将来会有结果的事」。你拿着凭证,用 .then() 登记「等它有结果了做什么」。
fs.promises.readFile("config.json", "utf8")
.then((data) => console.log("读到了:", data))
.catch((err) => console.log("读失败了:", err.message))
.finally(() => console.log("无论成功失败都会走到这里"));
一个 Promise 只有三种状态,而且只能从第一种走向后两种之一,走了就不能回头:
| 状态 | 英文 | 含义 |
|---|---|---|
| 待定 | pending | 事情还没出结果 |
| 已兑现 | fulfilled | 成功了,拿到了值 |
| 已拒绝 | rejected | 失败了,带着一个错误原因 |
这三个状态解决了一个回调时代的老问题:「不可能既成功又失败,也不会重复成功两次」。在回调世界里,如果一个库不小心把回调调了两次,你的代码会跑两遍;Promise 从设计上就杜绝了这件事。
然后是三个你必须记住的组合工具,它们专门解决「一起等」的问题:
Promise.all([a(), b(), c()])
// 三个都成功 → 得到一个结果数组 [A, B, C]
// 任何一个失败 → 立刻整体失败,你只拿到那个错误
Promise.allSettled([a(), b(), c()])
// 无论成功失败都等齐全,返回每一项的形状是 {status, value} 或 {status, reason}
// 用在「我不想因为一个失败就放弃全部」的场景
Promise.race([a(), b()])
// 谁先出结果(无论成功或失败)就用谁,其余的继续跑但结果被忽略
// 最典型的用途:给一个操作套一个「超时闹钟」
我用一段真实的代码验证过前两个的差别,你把它们抄下来跑一遍就能记住:
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const ok = () => sleep(100).then(() => "成功的结果");
const bad = () => sleep(150).then(() => { throw new Error("我坏了"); });
Promise.all([ok(), bad()]).catch((e) => console.log("all 被拒绝:", e.message));
Promise.allSettled([ok(), bad()]).then((rs) => {
for (const r of rs) {
console.log(r.status, r.status === "fulfilled" ? r.value : r.reason.message);
}
});
▲ 输出会是:all 被拒绝: 我坏了,然后是 fulfilled 成功的结果 和 rejected 我坏了。注意第一行 —— Promise.all 失败得比 allSettled 早(150 毫秒时立刻拒绝),而 allSettled 老老实实等到两个都有结果。
那 sleep 这个函数你要记住,它在这一章里会反复出现:
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
把它读成中文:「造一个 Promise,并且在一段延迟之后把它兑现。」new Promise 接收一个函数,这个函数会拿到一个叫 resolve 的工具;谁拿到这个工具,谁就有权决定这个 Promise 什么时候成功。setTimeout(r, ms) 的意思就是「ms 毫秒之后调用 r」,也就是「ms 毫秒之后让它成功」。OWL 的 bot.js 第 98 行有一模一样的一行,只是名字更长:
const delay = (ms) => new Promise((r) => setTimeout(r, ms));
4.3 第三代:async / await
Promise 解决了嵌套,但 .then().then().then() 还是不够像「正常的代码」。第三代要做的,是让异步代码看起来像同步代码。两个关键字:
async写在函数前面,表示「这个函数里会出现等待」。它带来两个后果:这个函数总是返回一个 Promise(你在里面return 1,外面拿到的是「一个会兑现成 1 的 Promise」);而且函数里面可以用await。await写在一个 Promise 前面,表示「在这里等它出结果,然后把结果给我」。如果那个 Promise 拒绝了,await会把它变成一个异常抛出来——所以你才能用try / catch去接。
把前面那段回调地狱用 async/await 重写:
async function handleMessage(userId) {
try {
const user = await readUser(userId);
const history = await loadHistory(user.id);
const reply = await askModel(user, history);
await sendMessage(user.qq, reply);
await saveHistory(history);
console.log("终于全部做完了");
} catch (err) {
handle(err);
}
}
五层缩进变成了一个 try,五个错误分支变成了一个。这才是 await 真正的贡献:它不是为了让你少打字,而是为了让「顺序」重新变成一条从上到下的直线。
4.4 await 到底做了什么
现在讲这一章的核心问题。我要先破除一个非常普遍、也非常致命的误解。
误解:await 会让整个程序停下来等。
不是。如果 await 真的会停下整个程序,那 Node 的单线程就没有任何意义了——await 会变成一个更隐蔽的死循环。
真相:await 只暂停「当前这个 async 函数」的后续代码,同时把控制权交还给调用它的人。整个程序、事件循环、其他所有回调,全都在照常运行。
我要用一个实验让你亲眼看到这件事。下面这段代码里有两个并发的 await sleep,每个 500 毫秒。如果 await 会阻塞全局,总耗时会接近 1000 毫秒;如果它只暂停自己,总耗时应该接近 500 毫秒。
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const t0 = Date.now();
const log = (x) => console.log(`${Date.now() - t0}ms ${x}`);
async function taskA() {
log("A 开始等");
await sleep(500);
log("A 等到了");
}
async function taskB() {
log("B 开始等");
await sleep(500);
log("B 等到了");
}
taskA();
taskB();
log("两个都发出去了,我不管它们了");
▲ 保存成 two-awaits.mjs 运行。
真实输出:
0ms A 开始等
0ms B 开始等
0ms 两个都发出去了,我不管它们了
501ms A 等到了
501ms B 等到了
请盯着这个输出看三十秒。三件事同时发生在 0 毫秒:A 登记了自己的等待,B 登记了自己的等待,然后主流程继续往下走,立刻打印了第三行。500 毫秒之后,A 和 B 的等待同时到期,它们的结果在同一个时刻出现。总耗时 501 毫秒,不是 1000 毫秒。
原因就是第 3 节讲的机制:await sleep(500) 做的是「注册一个 500 毫秒后的回调,然后让出调用栈」。调用栈一空,事件循环就能把 B 的回调压进去执行。所以 B 在 A 让出的一瞬间就出发了。
要点一个够用的心智模型:把 await 读成「这件事的后续,登记在它完成之后」。它不是「停车熄火」,而是「把剩下的行程写进日程表,然后先去干别的」。这一句如果你真的消化了,这一章后面所有的坑你都能自己看出来。
4.5 四个新手必踩的坑
下面这四个坑,是初学者在真实项目里最常遇到的。每一个我都会给你「坏代码 → 现象 → 原因 → 改法」四步。
坑一:忘记 await,拿到一个 Promise 对象
async function getReply(text) {
return "回答:" + text;
}
async function main() {
const r = getReply("你好"); // 忘了写 await
console.log(r); // 打印出 Promise { '回答:你好' }
console.log(r.length); // undefined —— Promise 没有 length
}
main();
现象:打印出来是 Promise { '回答:你好' };如果你把它拼进字符串,会得到 [object Promise];如果你对它取 .length,得到 undefined,然后你会发现机器人发出去的消息是空的。
原因:async 函数的返回值永远被包成一个 Promise。不写 await,你拿到的就是那个包装盒,不是盒子里的东西。
改法:加 await。判断方法很简单:只要右边是一个「要花时间」的函数(读文件、发网络请求、访问数据库),就一定有 await。你可以在心里给它起个名字:这类函数叫「异步函数」。一个可靠的经验是——凡是你自己写了 async 的函数,别人调用它时都该 await。
坑二:在 forEach 里 await(完全不生效)
这个坑极其隐蔽,因为代码看起来非常合理,不报错,只是「顺序不对」。
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function bad() {
console.log("开始");
[1, 2, 3].forEach(async (i) => {
await sleep(100);
console.log(`第 ${i} 个完成`);
});
console.log("forEach 后面这一行");
}
async function good() {
console.log("开始");
for (const i of [1, 2, 3]) {
await sleep(100);
console.log(`第 ${i} 个完成`);
}
console.log("for...of 后面这一行");
}
▲ 实测输出(bad() 部分):
开始
forEach 后面这一行
第 1 个完成
第 2 个完成
第 3 个完成
▲ 实测输出(good() 部分):
开始
第 1 个完成
第 2 个完成
第 3 个完成
for...of 后面这一行
现象:forEach 里那句「后面这一行」抢在了三个「完成」之前。你以为自己在「依次等着处理」,实际上下一行代码根本没等。
原因:forEach 的实现是「把每个元素交给回调函数,然后立刻继续」,它对回调的返回值完全不关心。你在回调里写了 async,那个回调返回的是一个 Promise,但 forEach 把它扔掉了,没有等它。于是三次 await 都变成了「各等各的,谁也不是在等整个流程」。
改法:要顺序执行,用 for...of——它才是真正的循环,await 在这里确实会卡住这一次迭代,等完了才进入下一次。如果你要的是并行,用 await Promise.all(list.map(async (i) => {...}))——map 会把每个回调返回的 Promise 收集成数组,交给 Promise.all 一起等。
警告这个坑在 OWL 里如果踩了会怎么样?想象「给群里十个被 @ 的人都发一条私聊」写成了 forEach(async ...):本来应该是「一条一条发,每条间隔一下,避免被风控」,结果变成「十条同时发出去」——这正是最容易触发 QQ 风控的行为之一。语法层面的小错误,会变成账号安全层面的大事故。
坑三:串行等待,白白浪费几倍时间
这个坑和上一个相反:不是「忘了等」,而是「等了不该等的」。看下面两段,同样的三件事,耗时差了三倍。
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const t0 = Date.now();
const log = (x) => console.log(`${Date.now() - t0}ms ${x}`);
async function main() {
log("串行开始");
await sleep(300);
await sleep(300);
await sleep(300);
log("串行结束");
log("并行开始");
await Promise.all([sleep(300), sleep(300), sleep(300)]);
log("并行结束");
}
main();
▲ 实测输出:串行结束 出现在 920ms,并行结束 出现在 1229ms 附近。并行那一步只花了 300 毫秒,而串行花了 900 毫秒。
判断标准只有一个:后一件事需不需要前一件事的结果?
- 「拿到用户的历史,再拿用户的历史去问模型」——需要,必须串行。
- 「同时给三个群里各发一条公告」——不需要,应该并行。
- 「读配置文件、读记忆文件、读历史文件」——不需要,三个文件互相不认识,应该并行。
OWL 里有一个真实的反例可以作为对照。llm.mjs 的构造函数里,三个加载动作是排在一起、各做各的:
this.apiKey = this.#loadKey();
this.history = new Map();
this.memory = new Map();
this.buckets = new Map();
this.crisisHits = 0;
this.#loadHistory();
this.#loadMemory();
▲ 摘自 qq-bot/bot/llm.mjs。这里用的是同步读文件(readFileSync),所以天然就是「一个接一个」。为什么同步在这里可以接受?因为它只在启动时跑一次、文件很小(几百 KB 的 JSON),而且启动阶段还没有任何人在等它。「并行还是串行」不是道德问题,是一个要按场景算的账。
坑四:try / catch 抓不到没有 await 的失败
async function bad() {
try {
Promise.reject(new Error("我坏了"));
} catch (e) {
console.log("catch 抓到了:", e.message); // 永远打印不出来
}
console.log("try 块结束了");
}
bad();
现象:catch 里的那句话永远不出现,但 Node 会在后面打印一段吓人的警告。
原因:Promise.reject(...) 是同步地造出一个「已拒绝的 Promise」,它并没有在这里抛出异常。异常要到「没有人处理这个拒绝」的时候才被 Node 报出来——而那个时刻发生在当前这段代码已经跑完之后,早就出了 try 的范围。所以 try/catch 什么都抓不到。
改法:只要加上 await,await 会把拒绝当场转换成异常,try/catch 就能接住:
async function good() {
try {
await Promise.reject(new Error("我坏了"));
} catch (e) {
console.log("catch 抓到了:", e.message); // 这次能抓到
}
}
反过来说,如果你故意不想等(比如「发一封通知邮件,失败也无所谓,不要影响主流程」),那就要显式地挂一个 .catch() 上去,不要让它变成一个「无人认领的拒绝」:
// 故意不等它,但要明确说清失败怎么办
sendNotification(text).catch((e) => log("通知失败,不影响主流程:", e.message));
为什么这件事在服务里特别要命?因为从 Node 15 起,「无人认领的 Promise 拒绝」的默认行为是让进程崩溃退出。在脚本里崩了就崩了,在服务里崩了就意味着 OWL 掉线——而掉线这一集我们在第七章会专门讲。
5. 网络 I/O 实战:写出你那 30 行脚本
理论讲完了。现在动手,把你给自己定的那个目标做出来:一个 30 行左右的小脚本,调用 DeepSeek API 并把回答打印出来。
5.1 fetch 的两个反直觉行为
fetch 是发 HTTP 请求的函数。第四章我们讲过 HTTP 的请求-响应结构,还记得「状态码」那一节吗?200 表示成功,401 表示没有身份,404 表示找不到,500 表示服务器出错。fetch 在这里有两个非常反直觉的行为,每一个都咬过无数初学者。
反直觉之一:服务器返回 404 或 500,它不会抛错
绝大多数语言或库在收到错误状态码时都会抛一个异常。JavaScript 的 fetch 不这么干。它认为「HTTP 请求成功地完成了一次往返」就算成功——哪怕那个往返带回来的是「你没有权限」。
const res = await fetch("https://api.deepseek.com/chat/completions", { /* ... */ });
console.log(res.status); // 可能是 200,也可能是 401
console.log(res.ok); // status 在 200-299 之间才是 true
// 关键:必须有这一段,否则 401 会被你当成成功
if (!res.ok) {
console.log("请求失败,状态码", res.status);
}
这条规则要是不知道,你会遇到一个特别难查的现象:Key 填错了,程序不报错,只是一声不响地打印出空字符串,然后你以为是模型的问题。实际上 401 的错误信息就躺在 res 里,只是没人去读它。
下面是真实的 401 响应正文(我用一个假 Key 实测的):
{
"error": {
"message": "Authentication Fails, Your api key: ****0000 is invalid",
"type": "authentication_error",
"code": "invalid_request_error"
}
}
▲ 真实返回。注意两点:错误信息在 error.message 这个嵌套位置,形状和成功响应完全不同;而且它会把你 Key 的末尾四位原样回显出来——所以日志里的错误信息也属于敏感数据,别随手往公开的地方贴。
反直觉之二:fetch 给你的不是正文,要再等一次
await fetch(...) 拿到的不是响应正文,而是「响应头已经到齐了,正文还在路上」这个中间状态。要拿正文,还得再等一次:
const res = await fetch(url, options); // 等到响应头到齐
const raw = await res.text(); // 再等到正文读完,拿到字符串
const data = JSON.parse(raw); // 把字符串变成对象
// 或者一行搞定(JSON.parse 的封装):
const data = await res.json();
为什么要分两次等?因为正文可能很大(比如下载一个文件),响应头却很小。分开设计,你可以在正文还没到的时候先看 res.status——那个三位数字就是第四章讲过的 HTTP 状态码,它表示「这次的请求结果属于哪一类」,res.ok 只是「它在不在 200–299 这个成功区间」的布尔版本。这是一个从 HTTP 协议结构里长出来的设计,不是 API 设计者的怪癖。
技巧OWL 的 llm.mjs 选择的是 raw = await res.text() 而不是 await res.json()。为什么?因为它需要在出错时把服务的原始回话存下来去打日志——如果直接 .json() 遇到非 JSON 的错误页(比如网关返回的 HTML),JSON.parse 会抛错,你连服务说了什么都看不到。「先拿到原始文本,再尝试解析」是一个值得学的稳健写法。
5.2 逐步构造 DeepSeek 请求
请求的形状在第四章见过:方法、路径、头、正文。我们逐字段过一遍,讲清每个字段为什么在那里。第八章会详细讲这些参数怎么调,这一节只回答「怎么把它发出去」。
const body = {
model: "deepseek-chat",
messages: [
{ role: "system", content: "你是一个说话简洁的助手。" },
{ role: "user", content: "用一句话解释什么是事件循环。" },
],
temperature: 0.8,
max_tokens: 800,
stream: false,
};
| 字段 | 它是什么 | 写错会怎样 |
|---|---|---|
model | 要用哪个模型。OWL 的 config.json 里写的是 deepseek-chat | 模型名不存在会返回 400;模型名换了但你可能没注意,结果是行为和价格都变了 |
messages | 对话数组,每一项是 {role, content}。role 只有三种常见值:system(设定,最先)、user(说话的人)、assistant(模型之前的回答) | 顺序错了,模型的行为就会怪。system 必须放在最前面,否则它会被当成一句普通的用户发言 |
temperature | 采样温度,越高越随机。DeepSeek 的取值上限是 2,官方文档说 0.8 左右偏向「更随机、更发散」 | 设成 0 会得到几乎固定的回答;设成 2 会跑题。OWL 用 0.8 |
max_tokens | 最多生成多少 token,也就是「回答的长度上限」 | 太小会被截断到半句话;太大会让偶发的长回答变贵。OWL 用 800 |
stream | 是否流式返回(一个字一个字地推回来) | 这一章我们写 false——等一整段。为什么以及怎么改成 true,是第八章的内容 |
请求的头里有两个字段:
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
}
Content-Type: application/json:告诉对方「我发过去的正文是 JSON」。少了它,服务可能按纯文本解析,直接拒收。Authorization: Bearer sk-xxxx:这就是第四章讲过的 Token 鉴权,而这种具体的写法叫 Bearer Token(持有者令牌)。Bearer是英文「持有者」的意思——「谁拿着这个令牌,谁就是被授权的」,所以它不需要再配一个用户名。注意Bearer和 Key 之间必须有一个空格,少了它服务器会认为你给的令牌字面量是Bearersk-xxxx,然后回一个 401。而 Key 属于密钥,只能从环境变量或本地文件读,绝对不能写进代码再提交到 Git。
警告这是一个真实会发生的安全事故:你在测试时图省事,把 Key 直接写在了第 8 行,跑通了,然后 git commit 推到了 GitHub。公开仓库的爬虫在几分钟内就会扫到它,然后有人在你的账户上刷额度。第三章讲的 .gitignore 和第七章讲的密钥管理都是为这件事准备的。如果你已经推上去了,正确的处理顺序是:去平台立刻吊销那把 Key 并重新生成,而不是去删提交——因为提交历史里仍然留着它。
5.3 超时与取消:AbortController
一个没有超时的网络请求是一个隐患。原因很简单:网络会卡住,而且不会告诉你它卡住了。服务器处理到一半失联、中途的网关吞掉了连接、对方响应极慢——在这些情况下,你的 await fetch 会一直等下去,等到天荒地老。在脚本里这顶多让你等烦;在服务里这意味着一个用户的消息处理永远占着资源不放手。
所以每个真实的网络请求都应该配一个闹钟。AbortController 就是那个闹钟的开关:
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
try {
const res = await fetch(url, { /* ... */ signal: ctrl.signal });
// 成功了就赶紧把闹钟关掉
} finally {
clearTimeout(timer);
}
三步,值得记牢:
- 造一个控制器
ctrl,把它的signal(信号)交给fetch。「信号」是一个能被通知的对象,fetch订阅了它。 - 设一个定时器,到时间就调用
ctrl.abort()。这会触发那个信号。 fetch一收到信号,就立刻放弃这个请求,并在await那里抛出错误。错误的name是AbortError——这就是你区分「超时」和「其他网络错误」的依据。
关于第 3 条,有一件必须提醒的事:ctrl.abort() 触发后,那个被放弃的请求是不是真的停止了工作,取决于对方和中间层。它保证的是「你不再等、连接被你这边放弃」;不保证「服务器那边一定不算这笔钱」。所以超时是第一道防线,不是全部。第八章我们会讲怎么用「限制输入长度」和「限流」一起把成本关住。
这就是 OWL 的真实实现,逐行看:
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), llm.timeoutMs ?? 45000);
let res;
let raw;
try {
res = await fetch(llm.baseUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${this.apiKey}`,
},
body: JSON.stringify(body),
signal: ctrl.signal,
});
raw = await res.text();
} catch (e) {
clearTimeout(timer);
const msg = e.name === "AbortError" ? "超时" : e.message;
this.log(`❌ LLM 请求失败: ${msg}`);
return { ok: false, reason: `network:${msg}` };
}
clearTimeout(timer);
▲ 摘自 qq-bot/bot/llm.mjs 的 chat()。三个细节值得单独说:
llm.timeoutMs ?? 45000:??是「空值合并」运算符,意思是「左边是undefined或null时用右边」。配置里写了调用超时就用配置的,没写就默认 45 秒——这里的 45000 同样是代码里的默认值,而 OWL 的config.json里确实把它写成了 45000,所以这次默认值和实际值是一致的(不一致的那种情况,我们在第 5.7 节见过)。e.name === "AbortError" ? "超时" : e.message:把技术性的错误名翻译成人类能看懂的两个字。45 秒之后日志里出现「❌ LLM 请求失败: 超时」,比出现一大堆英文堆栈有用得多。return { ok: false, reason: ... }:注意这个函数不抛异常,而是返回一个带ok字段的对象。为什么要这样设计?看下一节。
对比一下第一章里讲过的「clearTimeout 要放在成功路径上也执行」这件事:上面这段代码在 catch 里清了一次,在 catch 之外又清了一次。这是常见写法,但如果 raw = await res.text() 那里抛错,clearTimeout 就会被执行到——没问题。真正更稳的写法是把它放进 finally,因为 finally 无论走哪条路都会执行。我在本章第 5.5 节的脚本里用的就是 finally。
5.4 为什么不抛异常,而是返回 { ok: false }?
这是一个值得停一下的设计问题。上面的代码抓到错误之后,没有 throw,而是返回了一个描述失败的对象。这是一种风格选择,叫「把失败当成一种正常返回值」。
它的好处在你写上层逻辑时非常明显。看 bot.js 里怎么用这个返回值:
const r = await llm.chat({ sessionKey, userKey: String(event.user_id), userName, text: clean });
if (r.ok) {
await sendAndLog(r.text, "🤖 AI 回复");
return;
}
if (r.reason.startsWith("too-long")) { /* 提示太长了 */ return; }
if (shouldSendCrisisResource(r.crisis)) { /* 危机兜底 */ return; }
const hint = llmFailureHint(r.reason);
if (hint) { await sendAndLog(hint, "📤 已回复(AI 降级提示)"); return; }
log(` ↳ AI 不可用(${r.reason}),改走静态规则`);
▲ 摘自 qq-bot/bot/bot.js 的 onEvent(),为便于阅读裁去了注释与细节。
如果 llm.chat 是抛异常的风格,这段代码会被包进一个 try/catch,而「太长了」「余额不足」「网络超时」「被限流了」这四种失败就全都挤在同一个 catch 里,你得靠判断错误信息来区分它们。而现在的写法让每一种失败都变成一条明确的、可读的分支。
这不是标准答案,而是一种权衡。我把它写出来,是因为你会看到大量「教科书说应该抛异常」的说法,然后疑惑为什么 OWL 不这么做。抛异常适合「不该发生的事发生了」;返回失败对象适合「预期之内、需要分别处理的各种失败」。对一个每天会被限流、会被超时、会没余额的 AI 接口来说,后者显然更合适。
5.5 完整的 30 行脚本
现在把它拼出来。下面这段代码我逐字跑过(用假 Key 验证了失败分支),你可以直接复制。
const KEY = process.env.DEEPSEEK_API_KEY;
if (!KEY) {
console.error("请先设置环境变量 DEEPSEEK_API_KEY");
process.exit(1);
}
async function ask(text, timeoutMs = 45000) {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
try {
const res = await fetch("https://api.deepseek.com/chat/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${KEY}`,
},
body: JSON.stringify({
model: "deepseek-chat",
messages: [{ role: "user", content: text }],
temperature: 0.8,
max_tokens: 800,
stream: false,
}),
signal: ctrl.signal,
});
const raw = await res.text();
if (!res.ok) return { ok: false, reason: `HTTP ${res.status}: ${raw.slice(0, 200)}` };
const data = JSON.parse(raw);
return { ok: true, text: data.choices?.[0]?.message?.content ?? "" };
} catch (e) {
return { ok: false, reason: e.name === "AbortError" ? "超时" : e.message };
} finally {
clearTimeout(timer);
}
}
const r = await ask("用一句话解释什么是事件循环");
if (r.ok) console.log(r.text);
else { console.error("失败:", r.reason); process.exit(1); }
▲ 保存成 ask.mjs。运行方式见下面。
在 PowerShell 里设置环境变量并运行:
$env:DEEPSEEK_API_KEY = "sk-你的key"
node ask.mjs
在 Linux / macOS 的 bash 里:
DEEPSEEK_API_KEY="sk-你的key" node ask.mjs
注意上面最后三行我用了顶层 await:在 .mjs 文件的最外层直接写 await 是合法的,不需要包在 async function 里。这是 ESM 的特性之一,在 CommonJS 里做不到。OWL 的 bot.js 没有用顶层 await,因为它整个文件都是「注册回调」,本身不需要等什么;但你在写小脚本时,顶层 await 会让你少写一层包装函数。
5.6 如果去掉某一行,会发生什么
这一小节才是这段代码真正的价值。我把每一行都删一次,你就能知道它为什么在那里。
| 删掉这一行 | 会发生什么 | 本质原因 |
|---|---|---|
if (!KEY) { ... exit(1) } | 请求带着字符串 "Bearer undefined" 发出去,收回一个 401。程序没有早点告诉你配置缺了 | 越早失败越好。启动时就报「缺 Key」,比跑完一圈之后报 401 省事 |
const ctrl = new AbortController() 和 signal | 网络卡住时无限期等待。脚本看起来「卡死了」,你只能按 Ctrl+C | 没有闹钟的等待不叫等待,叫悬空 |
clearTimeout(timer) | 请求 200 毫秒就返回了,但那个 45 秒的定时器还在。Node 会因为这个定时器而不退出,程序在打印结果后又挂了 45 秒 | 定时器是「让进程保持活着」的东西之一。清掉它,进程才能自然结束 |
await(写成 const res = fetch(...)) | res 变成一个 Promise,res.ok 是 undefined,于是「不 ok」的分支被走,你会看到 HTTP undefined: ... | 坑一:忘记 await,拿到的是包装盒 |
await res.text() 里的 await | raw 变成一个 Promise,JSON.parse 收到的不是字符串,抛 Unexpected token o | 同上;而且报错信息会让你以为是 JSON 格式问题 |
if (!res.ok) | Key 错了、余额空了、被限流了——全部被当成成功,然后 data.choices 是 undefined,你打印出空字符串 | 反直觉之一:4xx/5xx 不抛错 |
try / catch | 网络错误(DNS 解析失败、连接被拒)变成一个未被捕获的异常,程序崩掉并打印一长串堆栈 | 该处理的失败没有被处理,它就变成了崩溃 |
finally { clearTimeout(timer) } | 只在成功路径上清零的话,出错时定时器残留——进程又不肯退出 | finally 是「无论走哪条路都要做」的位置 |
temperature / max_tokens | 不报错,服务会用默认值(温度 1、长度按模型的默认上限)。回答的风格和长度会变,费用也会变 | 默认值不是「和你配置一样」,是「别人的选择」 |
stream: false | 仍然是 false——这是流的默认值。但等你第八章要改成 true 时,会发现响应不再是一个 JSON,而是一串事件流 | 显式写出来,是为了让读代码的人知道这里做过选择 |
这张表是我希望你在这一章结束时能自己写出来的东西。能说出「删了这一行为什么会坏」,才算真的懂了一行代码。这正好是序章里「合上教程自己写一遍」加「用输出倒逼输入」两条心法的具体用法。
5.7 改成连续对话:第六章记忆的雏形
上面那个脚本每次问都是独立的——它记不住上一句。要让它记住,只需要维护一个 messages 数组,每轮把「用户说的」和「模型答的」都追加进去,下次整包发出去。
import readline from "node:readline/promises";
const KEY = process.env.DEEPSEEK_API_KEY;
if (!KEY) { console.error("缺少 DEEPSEEK_API_KEY"); process.exit(1); }
const messages = [
{ role: "system", content: "你是一个说话简洁的朋友,回答控制在三句话以内。" },
];
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
async function ask(timeoutMs = 45000) {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
try {
const res = await fetch("https://api.deepseek.com/chat/completions", {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${KEY}` },
body: JSON.stringify({
model: "deepseek-chat", messages,
temperature: 0.8, max_tokens: 800, stream: false,
}),
signal: ctrl.signal,
});
const raw = await res.text();
if (!res.ok) return { ok: false, reason: `HTTP ${res.status}: ${raw.slice(0, 200)}` };
const data = JSON.parse(raw);
return { ok: true, text: data.choices?.[0]?.message?.content ?? "" };
} catch (e) {
return { ok: false, reason: e.name === "AbortError" ? "超时" : e.message };
} finally { clearTimeout(timer); }
}
while (true) {
const line = (await rl.question("你: ")).trim();
if (!line) continue;
if (line === "/exit") break;
if (line === "/重置") { messages.length = 1; console.log("(上下文已清空)"); continue; }
messages.push({ role: "user", content: line });
const r = await ask();
if (!r.ok) {
console.log("(出错了:" + r.reason + ")");
messages.pop(); // 失败就别把这句留在历史里
continue;
}
messages.push({ role: "assistant", content: r.text });
console.log("OWL: " + r.text);
}
rl.close();
▲ 保存成 chat.mjs 运行。它会一直问你,直到你输入 /exit。注意:这就是一个服务了——它不再「跑完就结束」,它在等你。
这段代码里有四处值得注意的东西,它们全都指向第六章:
messages.length = 1:清空数组但保留第一项(那个system)。用= 1而不是= [],是因为人设不能丢。OWL 的/重置命令做的也是这件事,只是它清的是llm.history这个 Map 里的一个会话。messages.push({ role: "assistant", ... }):模型的回答要回填进历史。很多人只 push 用户的话,结果模型完全不记得自己上一句说了什么,对话会变得非常奇怪。- 失败时
messages.pop():把刚才那句用户输入撤掉,否则历史里会留下一条「问了但没有回答」的孤立消息,下一轮模型看到会很困惑。这是「保持数据结构自洽」的一个小例子。 - 没有做任何长度控制。这个数组会一直长下去,每问一句就把整包再发一遍——越聊越贵,直到超出上下文窗口直接报错。
第 4 点就是 OWL 用 maxTurns 解决的问题。看它怎么裁:
const maxTurns = llm.history?.maxTurns ?? 6;
const prev = this.history.get(sessionKey) ?? [];
// ...
const messages = [
{ role: "system", content: systemContent },
...prev.slice(-maxTurns * 2),
{ role: "user", content: text },
];
▲ 摘自 qq-bot/bot/llm.mjs。关键是 prev.slice(-maxTurns * 2)——负数参数表示「从末尾往前数」,一轮对话是「用户 + 助理」两条,所以 maxTurns 是 8 时就是 16 条。一个 slice 就实现了「只记得最近若干轮」。
警告那个 ?? 6 是代码里的默认值,不是 OWL 实际在用的值。OWL 的 config.json 里写的是 "maxTurns": 8,所以它实际记得的是最近 8 轮、16 条消息。同样的道理,下一节那个 ttlMinutes ?? 60 的默认值是 60 分钟,而配置里写的是 180 分钟。默认值和实际值不一致时,以配置文件为准。这正是为什么你读别人的代码时要顺手把配置文件也打开——只看代码,你会把「默认值」当成「真实行为」,然后在算成本、算记忆长度时错上一大截。
而「记得住跨天、跨个月的事」——那些不是上下文,那是记忆,需要真正落盘到硬盘上。第六章整章都在讲这件事:上下文是短期的、会过期的、丢了也不致命;记忆是长期的、要持久化的、丢了会让人失望。
想一想你刚才写的 chat.mjs 里,历史只存在内存里。如果你按 Ctrl+C 再重新运行,那些对话会怎样?那如果你把历史写进一个 JSON 文件,下次启动再读回来——这是不是就成了 OWL 的 history.json?那么这个文件应该多久写一次?每轮都写、还是退出时写?先想清楚这两个问题,第六章你会读得轻松很多。
6. 从脚本到服务:搭一个 HTTP + WebSocket 服务
上一节结束的时候,chat.mjs 已经在「等人」了——只不过它等的是你敲键盘。现在把等人换成等网络。
6.1 http.createServer 的最小例子
Node 内置了 node:http,二十行就能起一个能被人访问的服务:
import http from "node:http";
const server = http.createServer((req, res) => {
res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
res.end("hello,我活着\n");
});
server.listen(3000, "127.0.0.1", () => {
console.log("服务已启动:http://127.0.0.1:3000/");
});
▲ 保存成 hello-http.mjs。运行它,然后浏览器打开 http://127.0.0.1:3000/。你会看到那行字,而且刷新多少次都还有——这就是与脚本最大的区别。
三件事逐一说清:
http.createServer(handler):造一个 HTTP 服务器,把「有人来访问时做什么」这个函数登记进去。注意它本身就是「事件驱动」的:你没有写循环去「不断检查有没有人访问」,你只是登记了一个「当有人来时」。第 9 节会把这件事讲透。req和res:req是请求(第四章讲过的请求方法、路径、头都在里面),res是响应,你用writeHead写状态码和头,用end写上正文并结束这一次响应。server.listen(3000, "127.0.0.1"):占住 3000 端口,并且只监听127.0.0.1。第一章讲过,127.0.0.1是本机回环地址,外面的机器连不上。这是一个安全默认值。回调函数里的那行日志会在「端口真的占上了」之后才打印。
charset=utf-8 那一小段也别跳过。少了它,浏览器可能用拉丁字符集解释你的字节,中文就会变成乱码。第四章讲的「编码」在这里变成了一个必须写对的具体字段。
6.2 OWL 为什么也起了一个 HTTP 服务?
你可能会疑惑:OWL 明明是一个 WebSocket 服务,为什么还要起 HTTP 服务?看它的真实代码:
const server = http.createServer((req, res) => {
res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
res.end(`qq-bot running; OneBot reverse WS on ws://127.0.0.1:${cfg.server.port}/\n`);
});
const wss = new WebSocketServer({ server });
▲ 摘自 qq-bot/bot/bot.js 第 440–445 行。
答案有两层。第一层是技术上的必要性:注意第二行的 new WebSocketServer({ server })——WebSocket 服务器不是自己开一个端口,而是挂在一个已有的 HTTP 服务器上。原因是 WebSocket 的握手过程第一步就是一个普通的 HTTP 请求(第四章讲过:客户端发一个带 Upgrade: websocket 头的 GET 请求,服务器回 101,然后这条连接被「升级」成 WebSocket)。所以必须先有一个 HTTP 服务器,WebSocket 才有地方安身。
第二层是运维上的便利。那个 HTTP 处理函数返回的是一行人类可读的文字:
qq-bot running; OneBot reverse WS on ws://127.0.0.1:3001/
这意味着,当你在服务器上敲一句 curl http://127.0.0.1:3001/,你会立刻知道三件事:进程活着、端口占上了、配置里写的端口号就是这个。第七章讲健康检查时会用到这个——qq-bot/deploy/healthcheck.sh 就是这么判断「机器人是不是还醒着」的。
这就是「健康检查」最朴素的形式。一个服务最好的自证方式,是让外面能用一句谁都会的命令问它「你还在吗」,而它回答一句人话。
6.3 ws 的 WebSocketServer:三个必须记住的回调
现在把 HTTP 升级成 WebSocket 服务。ws 库把复杂的握手和帧解析都藏起来了,露出来的只有三个事件:
import { WebSocketServer } from "ws";
const wss = new WebSocketServer({ server });
wss.on("connection", (ws, req) => {
// 有人连上来了。ws 是这一条连接,req 是那次 HTTP 握手请求
console.log("新连接来自", req.socket.remoteAddress);
ws.on("message", (raw) => {
// raw 是 Buffer(一段字节),要自己变成字符串再解析
const data = JSON.parse(raw.toString());
console.log("收到:", data);
});
ws.on("close", () => console.log("连接断了"));
ws.on("error", (e) => console.log("连接出错:", e.message));
ws.send(JSON.stringify({ hello: "你好" }));
});
三个回调的角色完全不同,别混起来:
| 回调 | 什么时候触发 | 参数是什么 |
|---|---|---|
wss.on("connection", (ws, req) => ...) | 有一条新连接建立时,每条连接一次 | ws 是这条连接本身,req 是那次 HTTP 握手请求(鉴权信息在这里) |
ws.on("message", raw => ...) | 这条连接上收到一条消息时,可能很多次 | raw 默认是 Buffer,也就是一段原始字节,不是字符串 |
ws.on("close", ...) / ws.on("error", ...) | 连接断开、或者出错时 | 断开时可能带状态码和原因 |
raw 是 Buffer 这件事值得单独说。Buffer 是 Node 用来表示二进制数据的类型(第二章讲了字符串,Buffer 是它的「字节版」)。网络上的东西本质上都是字节,所以 Node 给你的是字节;raw.toString() 才把它按 UTF-8 解释成字符串。忘掉 .toString() 直接 JSON.parse(raw),在某些版本里能侥幸跑通,但在另一些情况下会得到莫名其妙的结果。不要靠侥幸。
6.4 逐行读 bot.js 的服务端段
现在看真实的东西。这是 OWL 的连接处理函数,我会一段一段地讲:
wss.on("connection", (ws, req) => {
// 鉴权:云端部署时 3001 端口可能对公网开放,没有 token 等于谁都能冒充协议端。
const token = cfg.server?.token || "";
if (token) {
const auth = req.headers["authorization"] || "";
let provided = auth.replace(/^Bearer\s+/i, "").trim();
if (!provided) {
try {
provided = new URL(req.url || "/", "http://localhost").searchParams.get("access_token") || "";
} catch {
provided = "";
}
}
if (provided !== token) {
log(`🚫 拒绝未授权连接(token 不匹配),来源 ${req.socket.remoteAddress}`);
try {
ws.close(1008, "unauthorized");
} catch {
/* ignore */
}
return;
}
}
const client = new OneBotClient(ws);
log(`🔗 协议端已连接 (${req.socket.remoteAddress})`);
▲ 摘自 qq-bot/bot/bot.js 第 447–473 行。
先说鉴权为什么必须做。OWL 的 3001 端口理论上只给本机的 NapCat 用,但「理论上」不等于「事实上」:如果服务器安全组配错了、如果 Docker 的网络模式把端口映射到了公网,那么全世界任何人都能连上这个 WebSocket,然后冒充 NapCat 发消息——他们可以让 OWL 说出任何话。
鉴权的实现分成四步:
- 从配置里取
token。如果没配 token,整段跳过——这是为了本地开发方便(token: ""时不做检查)。所以「安全」在这里是一个显式的配置决定,而不是默认行为。这一点你要带着批判的眼光看:默认不设防是方便,也是风险。OWL 的config.json里确实配了一个随机字符串,这是正确做法。 - 先看标准的
Authorization头,用正则/^Bearer\s+/i把Bearer前缀去掉。i表示忽略大小写,\s+表示一个或多个空白。 - 如果头里没有,再去看网址查询参数里的
access_token。new URL(req.url || "/", "http://localhost")这一步是把一个相对路径(比如/?access_token=xxx)补成一个完整网址,然后searchParams.get取出参数值。那段try/catch是因为new URL遇到畸形字符串会抛错,而握手阶段抛错会留下一个半开的连接。 - 不匹配就记日志、
ws.close(1008, "unauthorized")、然后return。1008是 WebSocket 标准里的关闭码,含义是「策略违规」。注意那个return——它让函数在这里结束,后面的OneBotClient根本不会被创建。新手常犯的错误是忘了 return,于是「拒绝」之后照样把连接当成合法连接用了下去。
这三行小逻辑里其实藏着一个重要的安全原则:验证要在最前面,而且验证失败必须立刻中断流程。这个原则在术语库里叫「最小权限原则」的兄弟——不给未验证的东西任何能力。
接下来是消息处理:
ws.on("message", (raw) => {
let data;
try {
data = JSON.parse(raw.toString());
} catch {
return;
}
if (data.echo && pending.has(data.echo)) {
const { resolve, timer } = pending.get(data.echo);
clearTimeout(timer);
pending.delete(data.echo);
resolve(data.status === "ok" || data.retcode === 0 ? (data.data ?? {}) : null);
return;
}
if (data.post_type === "meta_event") {
if (data.meta_event_type === "lifecycle") {
client.selfId = data.self_id;
log(`✅ 机器人已登录: ${data.self_id} (${cfg.botName})`);
log(`🧠 AI 状态: ${llm.status}`);
}
return;
}
client.selfId ??= data.self_id;
onEvent(client, data).catch((e) => log("❌ 处理事件异常:", e.message));
});
ws.on("close", () => log("🔌 协议端断开连接"));
ws.on("error", (e) => log("⚠️ WS 错误:", e.message));
});
▲ 摘自 qq-bot/bot/bot.js 第 475–506 行。
这个函数是整个 OWL 的分诊台。它收到一条条 JSON,然后决定每条该去哪里。按顺序讲:
第一,解析失败就 silently 返回。try { JSON.parse } catch { return }:这不是偷懒。WebSocket 上偶尔会出现半个包、心跳包、或者别的什么东西。为了一个不认识的输入让整个机器人崩掉,是不可接受的。这段代码的选择是:不认识就丢掉,什么都不做。你可以不同意这个选择(也许该记一条日志),但你得承认它的判断是对的——服务不能因为外部输入而崩。
第二,echo 配对——这是本章最精彩的一段。我先解释问题,再解释解法,最后逐行看。
问题是这样的:WebSocket 是双向对称的。OWL 通过同一条连接给 NapCat 发指令(「帮我往这个群发这条消息」),NapCat 也会通过同一条连接给它推事件(「有人说话了」)。发出去的指令,NapCat 会回一个「我办完了」;但这条回复混在事件流里,你怎么知道它对应你发的哪一条指令?
答案是:每条发出去的指令,带一个自己造的唯一编号;对方回话时必须把这个编号带回来。这个编号在 OneBot 协议里叫 echo——英文「回声」的意思,非常贴切:你喊什么,它就回什么。
现在看它怎么实现。先在文件顶部(模块级):
let seq = 0;
const pending = new Map();
一个自增计数器,一个 Map。Map 是「键值对集合」,第二章讲过对象也能存键值对,但 Map 有两个好处:键可以是任意类型;而且它有 has、delete 这样语义清楚的成对方法。这里用 Map 的真正原因是:它专门为「临时登记一批东西,然后按名字取出来删掉」这种用途设计的。
发送时(OneBotClient.call):
call(action, params = {}, timeoutMs = 15000) {
return new Promise((resolve) => {
if (this.ws.readyState !== this.ws.OPEN) return resolve(null);
const echo = `echo_${++seq}_${Date.now()}`;
const timer = setTimeout(() => {
pending.delete(echo);
log(`⚠️ API 超时: ${action}`);
resolve(null);
}, timeoutMs);
pending.set(echo, { resolve, timer });
this.ws.send(JSON.stringify({ action, params, echo }));
});
}
▲ 摘自 qq-bot/bot/bot.js 第 195–207 行。
逐行读:
return new Promise((resolve) => { ... }):把「将来某刻才会有的结果」包装成一个立刻返回的 Promise。调用者写await client.call("send_group_msg", ...),然后就可以「等」了。而这里的resolve被存了起来,谁拿着它,谁就能在将来某一刻决定这个 Promise 什么时候成功。if (this.ws.readyState !== this.ws.OPEN) return resolve(null);:连接已经断了就别发了,直接当场结束,返回null。注意这里是return resolve(...),一行做完「结束这个 Promise」和「从这个函数返回」两件事。这是一个很常见的写法。- 造编号:
const echo = `echo_${++seq}_${Date.now()}`;。这里用的是模板字符串(第二章讲过的反引号写法),把两段内容拼在一起。++seq是「先加一再取用」,所以第一个编号是echo_1_…。后面接一个时间戳,是为了「即使进程重启过、计数器从头开始,也不会和上一次的编号撞上」。唯一标识这种东西,通常都是「一个计数器 + 一个时间戳」的组合。 const timer = setTimeout(...):这是整段代码的灵魂。给这次调用设一个闹钟。到点了还没收到回话,就把登记删掉、记一条日志、然后resolve(null)让 Promise 成功结束(值是null,表示「没办成」)。如果不设这个闹钟,一次丢包就会让这个 Promise 永远 pending、pending这个 Map 永远多一条记录、调用者的await永远不返回。这就是内存泄漏和服务卡死的开始。pending.set(echo, { resolve, timer });:把「将来怎么结束这次等待」登记下来。注意存的是两样东西:怎么把结果交出去(resolve),和那个闹钟的编号(timer,将来要取消它)。this.ws.send(JSON.stringify({ action, params, echo }));:真的发出去。三个字段:action是要干什么(比如send_group_msg),params是参数,echo是我们的编号。
接收时(ws.on("message") 里那四行):
if (data.echo && pending.has(data.echo)) {
const { resolve, timer } = pending.get(data.echo);
clearTimeout(timer);
pending.delete(data.echo);
resolve(data.status === "ok" || data.retcode === 0 ? (data.data ?? {}) : null);
return;
}
四步,正好和发送时对称:
data.echo && pending.has(data.echo):这条回来的消息带着编号,而且这个编号确实是我们登记的。两个条件缺一不可——只检查data.echo存在的话,一个伪造的 echo 会让后面的pending.get拿到undefined,然后解构就崩了。clearTimeout(timer); pending.delete(data.echo);:撤掉闹钟、销掉登记。顺序很重要:先取消闹钟,再删登记。如果反过来,理论上存在一个「闹钟在两者之间触发」的窗口(虽然在一个 JS 线程里同一段代码不会被中途打断,但养成「先撤后删」的习惯没有坏处)。这两个动作合起来叫「清理」,是写异步代码时最容易漏掉的部分。resolve(...):把结果交给那个一直在等的调用者。data.status === "ok" || data.retcode === 0是在兼容 OneBot 的两种成功表示法;data.data ?? {}表示「有 data 就用 data,没有就给一个空对象」。三元的两个分支分别是「成功的数据」和「null表示失败」。return;:这条消息已经处理完了,不要再往下走。没有这个 return,这条echo回复会被当成普通事件传给onEvent,然后 OWL 会试图把它当成一条聊天消息去回复。这种 bug 非常难查,因为它表现得像一个「偶尔乱说话的机器人」。
现在退远一点看整件事。你刚才看到的是一个通用的模式:把一次异步通信拆成「登记—等待—配对—清理」四步,用唯一编号配对,用定时器兜底。这个模式你以后会在无数地方遇到它:数据库客户端的连接池、RPC 框架、消息队列、甚至第六章会讲的某些查询接口。它们的形状几乎一样,只是编号的名字不同(有的叫 id,有的叫 correlationId)。
要点如果你要记住这一章的一段代码,就记这一段。它是「异步」这件事从「语法现象」变成「工程模式」的那个转折点。前面你学的是「await 怎么写」,这里你看到的是「如果我要自己造一个能被 await 的东西,该怎么做」——答案是:return new Promise,然后把 resolve 藏起来,等到合适的时机再放出来。
剩下的两行是兜底:
client.selfId ??= data.self_id;
onEvent(client, data).catch((e) => log("❌ 处理事件异常:", e.message));
??=是「空值赋值」:只有当client.selfId是undefined或null时才赋值。它的意思是「如果还不知道自己的 QQ 号,从这条事件里猜一个」。onEvent(...).catch(...):这是本章第 4 节「坑四」的正解。onEvent是 async 函数,它返回一个 Promise。这里明确地挂了一个.catch,把任何未预料的异常接住、记进日志。如果不挂这个.catch,一次未处理的拒绝就会让进程崩溃——机器人就直接掉线了。对比一下:llm.mjs内部把可预期的失败(超时、401、限流)做成了返回值,所以onEvent里不太会抛;而.catch接住的是「没预料到的 bug」。两层防护,各管一段。
最后那两行 close 和 error 监听看着不起眼,但它们决定了你的排障体验:没有它们,连接断了你会完全不知道,只会看到「机器人不回消息了」。有了它们,日志里会留下「🔌 协议端断开连接」这一行,你从时间点上就能知道发生了什么。第七章的《KNOWN-ISSUE-掉线.md》整篇文档,就是靠这一类日志定位出来的。
6.5 启动与监听
最后是启动那段:
server.listen(cfg.server.port, cfg.server.host, () => {
log("================================================");
log(`🤖 ${cfg.botName} 已启动`);
log(` OneBot 反向 WS: ws://${cfg.server.host}:${cfg.server.port}/`);
log(` 关键词 ${cfg.keywords.length} 条 / 指令 ${cfg.commands.length} 条`);
log(` AI 对话: ${llm.status}`);
log(" 配置热重载: 编辑 bot/config.json 后自动生效");
log(" 等待协议端(NapCat)连接…");
log("================================================");
});
▲ 摘自 qq-bot/bot/bot.js 第 508–517 行。
这一段的每一行日志都是刻意的。你在服务器上排查问题时,第一时间要看的就是这个「开机横幅」:版本对不对、端口对不对、关键词加载了几条、AI 是不是就绪、协议端连上来了没有。一个会排障的程序,启动时就会把这几件事说清楚。
注意 server.listen 的第三个参数——那个回调函数。它不在 listen 被调用的那一刻执行,而是在「端口真的绑定成功」之后。这是一个典型的「事件驱动」写法,也是下一节的主题。如果你把日志直接写在 listen 后面而不是回调里,你会看到日志先打印、然后才可能报「端口已被占用」——顺序就反了。
想一想请数一数:这一节里,从 wss.on("connection") 到 server.listen,一共登记了几个「当……时」?如果把这几个登记全部删掉,程序会立刻退出还是继续运行?(提示:想想为什么加了 setInterval 的程序不会自己结束。)这个问题的答案就是下一节的全部内容。
7. 配置分层与密钥:同一个程序,三种环境
你已经能写出一个会等人的程序了。接下来这一节解决一个看起来很琐碎、但会在你最需要冷静的时候救你的问题:同一份代码,怎么在「你自己的笔记本」和「云上的服务器」上都能跑?
7.1 一个 Key 引发的三种写法
先说最坏的做法,它太常见了:
// 千万不要这样(下面这串是假的占位符,真 Key 长这样但绝不是这串)
const KEY = "sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx";
为什么这是最坏的?因为它把这个 Key 焊死在了代码里,于是产生三个无法解决的问题:
- 它会被提交到 Git。第三章讲过,Git 记录的是「每一次改动」,你后来把它删掉了也没用——历史里还在,任何能看这个仓库的人都能看到。公开仓库几分钟内就会被爬虫扫走。
- 你没法在本地和服务器上用不同的 Key。也许你想本地用一个便宜的额度、服务器用正式的那个;也许你想给测试环境一把只读的 Key。硬编码之后,改 Key 就等于改代码、重新提交、重新部署。
- 你没法把代码给别人。你想让朋友帮你看看,只能把整个文件夹发过去——于是把 Key 也给了他。
所以成熟的程序都会做一件事:把「会变的东西」从代码里搬出去,放到代码外面。这件事叫「配置」,而它有一层一层的优先级。OWL 的做法是三层:
| 优先级 | 来源 | 典型场景 | 安全性 |
|---|---|---|---|
| 最高 | 环境变量 DSH_QQBOT_LLM_KEY | 临时覆盖、容器部署、CI 里注入 | 好(不进文件系统) |
| 中间 | bot/llm.local.json 里的 apiKey | 本地开发和云上长期运行 | 好(被 .gitignore 排除) |
| 最低 | config.json 里的 llm.apiKey | 不推荐,仅作为最后的兜底 | 差(这个文件是要提交的) |
7.2 #loadKey() 的逐行拆解
这就是三层优先级的真实实现,一共十几行:
#loadKey() {
if (process.env.DSH_QQBOT_LLM_KEY) return process.env.DSH_QQBOT_LLM_KEY.trim();
const p = path.join(this.botDir, KEY_FILE);
try {
if (fs.existsSync(p)) {
const j = readJson(p);
if (j.apiKey) return String(j.apiKey).trim();
}
} catch (e) {
this.log(`⚠️ ${KEY_FILE} 解析失败: ${e.message}`);
}
const fromConfig = this.getConfig()?.llm?.apiKey;
return fromConfig ? String(fromConfig).trim() : "";
}
▲ 摘自 qq-bot/bot/llm.mjs。开头那个 # 表示这是一个私有方法——只能在 LlmClient 类内部调用,外面拿不到。这是「封装」的一个具体用法:把「怎么找 Key」这件脏活藏起来,外面只需要知道 this.apiKey。
逐行:
- 第一层:
if (process.env.DSH_QQBOT_LLM_KEY) return ...。process.env是「这个进程能看到的全部环境变量」。第一章讲过环境变量是什么——它由启动进程的人设置,程序只能读、不能改别人的。一旦这个变量存在,立刻返回,后面的两层根本不会被看到。 - 第二层:拼出
llm.local.json的路径,检查文件存不存在,存在就读出来、解析、取apiKey字段。注意fs.existsSync这个检查是必要的——不检查就直接读,文件不存在会抛ENOENT,虽然被 catch 接住了,但会打一条误导人的日志(「解析失败」,其实是「文件不存在」)。 - 那段
try/catch:如果这个 JSON 写坏了(少一个逗号、多了个尾巴),JSON.parse会抛错。这里的选择是记一条日志然后继续往下走,而不是崩掉。为什么不崩?因为这个文件是人在手动编辑的,写坏是常事;而一个「Key 文件写坏了」不应该让机器人整体无法启动——也许第三层里还有一个能用的。「能让程序继续跑的失败,就不要让它崩」,这是服务化思维的核心之一。 - 第三层:
this.getConfig()?.llm?.apiKey。注意这里调的是getConfig()(一个函数)而不是直接用某个变量。为什么要绕一下?因为配置支持热重载——config.json改了之后,那个变量会被整个替换掉。如果这里缓存的是旧对象,你就永远读到旧配置了。这是一个非常容易被忽略、但在热重载场景下会立刻暴露的设计。 - 最后
return fromConfig ? String(...).trim() : "":三层都没有就返回空字符串。空字符串是「假值」,所以下一节你会看到ready里可以直接写Boolean(... && this.apiKey && ...)。用「空值」表示「没有」,比抛异常更适合这种「缺了也能降级运行」的场景。
每一层上的 .trim() 也别跳过。它去掉首尾空白。为什么需要?因为你从网页上复制一个 Key,很可能在末尾带上一个空格或者一个换行符。带着这个不可见的字符去发请求,服务器会说「认证失败」,而你把 Key 盯着看十分钟也看不出来问题。这类「不可见字符」的问题,在第一次遇到时能耗掉你一整个晚上。
7.3 为什么这样设计:云上换 Key 不用改代码
这个三层结构的价值,在你真正做一次「换 Key」的时候才会显出来。三种场景:
- 本地调试:你在
llm.local.json里放一把临时 Key,玩坏了也不心疼。这个文件被.gitignore排除,不会进仓库。 - 云上长期运行:服务器上同样放一个
llm.local.json,权限设成只有你能读。部署脚本install.sh会专门检查它:
if [[ ! -f bot/llm.local.json ]]; then
warn "bot/llm.local.json 不存在,AI 将无法使用。"
warn '请创建它并写入:{ "apiKey": "sk-你的key" }'
elif ! grep -q '"apiKey"[[:space:]]*:[[:space:]]*"sk-' bot/llm.local.json 2>/dev/null; then
warn "bot/llm.local.json 里的 apiKey 看起来不像是有效值,请检查。"
else
log "API Key 已配置 ✓"
fi
▲ 摘自 qq-bot/deploy/install.sh。那个 grep 的正则在检查「"apiKey" 后面跟冒号、再跟一个以 sk- 开头的字符串」。它不验证 Key 是否有效(那要联网,安装脚本不该依赖网络之外的东西),只做一次形状检查——「看起来不像」这个提示能拦住一大类手滑。
- 换 Key 的时候:这是最关键的场景。如果 Key 泄露了、或者你换了服务商、或者你想临时用另一把额度做测试——你只需要改一个文件,然后重启进程。代码一行都不用动,也完全不需要重新部署。如果 Key 是硬编码在代码里的,这个操作就变成了「改代码 → 提交 → 部署 → 验证」,在紧急情况下(比如正在被人刷额度)这几分钟非常昂贵。
第一层的环境变量则解决了「不想写文件」的场景。第七章的 systemd 配置里有这么一行:
Environment=QQBOT_DATA_DIR=$SCRIPT_DIR/data/botdata
它把数据目录指向了 data/botdata,而不是代码所在的目录。为什么?因为这样「更新代码」和「保存数据」就分开了:你下次拉取新代码、覆盖掉整个 bot/ 目录,那些记录着用户对话的 history.json 和 memory.json 仍然安安静静地躺在 data/botdata 里。
这就是「配置分层」真正的样子:不是把东西藏起来,而是把它们放在正确的位置上——代码归代码,数据归数据,密钥归密钥。混在一起的时候,任何一次更新都可能顺手毁掉另一样。
7.4 readJson 和一个看不见的字符
OWL 里有一个只有三行的函数,被两处用到,第一次出现在 bot.js:
function readJson(file) {
const raw = fs.readFileSync(file, "utf8").replace(/^\uFEFF/, "");
return JSON.parse(raw);
}
▲ 摘自 qq-bot/bot/bot.js 第 29–32 行,注释里写着它的来历。
关键在 .replace(/^\uFEFF/, "")。\uFEFF 是一个 Unicode 字符的编号,它的名字叫 BOM(Byte Order Mark,字节顺序标记)。它本来是设计给「文本文件开头的一个小标记,用来告诉读的人这是大端还是小端」的,但现代用法里它基本上只剩下一个效果:在文件最前面多加三个看不见的字节。
问题出在 JSON.parse 身上。JSON 标准里没有 BOM 这个东西,所以 JSON.parse 见到它会直接抛错。于是你会遇到一个极度抓狂的场景:你打开 config.json,看起来完美无缺,你甚至逐字对照过括号和逗号——程序就是报「解析失败」。
那 BOM 是从哪儿来的?看 bot.js 的注释写的:用 PowerShell 的 Set-Content -Encoding UTF8 改过的文件会带 BOM。这是 Windows 上一个真实的历史遗留问题:Windows 的某些工具会把「UTF-8」默认理解成「带 BOM 的 UTF-8」。
replace(/^\uFEFF/, "") 这一行的读法:^ 表示「开头」,\uFEFF 就是那个字符,整体是「如果开头有这个字符,就把它删掉」。删掉之后再解析,无论文件有没有 BOM 都能过。
技巧这是一个值得抄进自己工具箱的小函数。它体现了处理「外部输入」的一条原则:不要假设输入是干净的。文件可能是别人用别的编辑器存的,可能是从 Windows 拷到 Linux 的,可能末尾多了一行空行。程序越靠近「人手工编辑的东西」,越要多做一点清洗。而这一行清洗的成本是零。
呼应一下第四章:我们在讲「编码」和「字节」时说过,同一个字符在不同编码下的字节序列不同。BOM 就是这件事的一个具体后果——它是三个字节,不是一个字符,只是恰好被解释成了那个编号为 \uFEFF 的不可见字符。你能在代码里「看见」它,只是因为你知道了它的编号。
8. 健壮性工程:六个真实踩过的坑
能跑起来只是开始。这一节讲的是「让它一直跑下去」,全部来自真实的踩坑记录。每一条我都会给你「症状 → 原因 → 处理」。
8.1 日志时间戳:一个让你查错方向的坑
先看 OWL 的日志函数:
function stamp() {
const d = new Date();
const p = (n) => String(n).padStart(2, "0");
return (
`${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ` +
`${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
);
}
▲ 摘自 qq-bot/bot/bot.js 第 63–70 行。
看起来有点啰嗦,对吧?JavaScript 有一个现成的 new Date().toISOString(),一行就能给你 2026-10-03T14:23:11.482Z。为什么要费力气用 getFullYear、getMonth 一个个拼?
因为 toISOString() 输出的是 UTC(协调世界时),也就是「格林尼治时间」,不是服务器所在地的时间。而 OWL 的服务器在阿里云华东2(上海),系统时区是 Asia/Shanghai,比 UTC 快 8 小时。
于是产生过这样一次真实的排障事故:日志里最后一条活动记录写着 06:12:33,而当时手机上显示的是 14:12。排查的人(也就是写这段注释的人)看着日志,得出的结论是「机器人已经 8 个小时没有活动了」,然后开始往「进程是不是早就死了」「是不是被 OOM 杀了」的方向查——查了半天,一无所获。
而事实是:那个时间戳就是刚刚发生的事,只是它记的是 UTC。不是数据错了,是数据的意思被误解了。而误解的方向是由那个格式决定的。
所以修法是:自己用本机时间格式化。逐行看那四行:
const p = (n) => String(n).padStart(2, "0"):padStart(2, "0")的意思是「如果长度不足 2,就在前面补0」。9会变成"09"。为什么需要?因为2026-10-3 9:5:1这种时间戳,长度不固定、排序会乱、人眼也难以快速比对。「固定宽度的数字」是日志和排版里一个反复出现的小需求。d.getMonth() + 1:这是一个 JavaScript 的历史遗留陷阱。getMonth()从 0 开始计数——一月是 0,十二月是 11。所以必须加一。getDate()却是从 1 开始的。这种不一致真实存在,你只能记住它。- 用
+把两段字符串接起来。反引号里的${...}是第二章讲过的模板字符串,它会把里面的表达式算出来填进去。
要点这个坑的教训比「记得用本地时间」更深一层:日志的格式决定了你能不能正确推理。一个 ISO 时间戳在技术上是「更标准」的,但对一个需要你手动比对手机时间的人来说,它是一个陷阱。写日志时要问的不是「哪种格式更规范」,而是「三个月后的凌晨两点,我在读这条日志时,能不能一眼判断它的时间」。第七章讲可观测性时会继续展开这件事。
顺便说一个 log 函数的细节:
function log(...args) {
const line = `[${stamp()}] ${args
.map((a) => (typeof a === "string" ? a : JSON.stringify(a)))
.join(" ")}`;
console.log(line);
try {
fs.appendFileSync(LOG_PATH, line + "\n");
} catch {
/* 日志写不进去不影响运行 */
}
}
▲ 摘自 qq-bot/bot/bot.js。三件事值得说:...args 是「收集所有参数成数组」(第二章讲过),所以这个 log 能像 console.log 一样接任意多个参数;.map 把非字符串的转成 JSON 字符串,因为对象直接拼进字符串会变成 [object Object];写完文件那个 catch 是空的,注释写得很清楚——日志写不进去不该影响机器人运行。但如果磁盘满了呢?那就是另一个故事了,第七章会讲「日志轮转」。
8.2 未捕获异常:process.on("uncaughtException") 的正确态度
Node 有两种「没人处理的错误」:
- 未捕获异常(uncaughtException):一段同步代码抛了错,一路没人 catch,冒到了最顶上。
- 未处理的 Promise 拒绝(unhandledRejection):一个 Promise 被拒绝了,但你没有
.catch,也没有await它。
从 Node 15 起,这两者的默认行为都是让进程退出。这是有道理的:出现这两种情况意味着程序已经处在一个「我们不知道它现在是什么状态」的境地,继续跑下去可能做出更糟的事。
于是网上会教你写这样一段:
process.on("uncaughtException", (err) => {
console.log("出错了,但我继续跑", err);
});
我要明确地说:不要这么写。特别是不要写成「打个日志就继续跑」。理由有三个:
- 这句话背后的假定是错的。「继续跑」意味着你相信出错之后程序的状态还是好的。但你不知道
err是在哪一层冒出来的——也许是在写一个文件的中间,也许是在修改一个重要对象的中途。你留下的是一个「半完成」的状态。 - 你会把崩溃变成一个更隐蔽的问题。原本进程退出,你立刻知道出事了;现在它带着坏状态继续活着,可能开始发出错误的消息、重复处理同一条事件、或者慢慢吃掉内存。这种故障比崩溃难查十倍。
- 它会掩盖真正的 bug。你会因为「反正不会崩」而不去修那个未捕获异常。三个月后你有一堆这样的监听,和一堆没人知道的错误。
那正确的态度是什么?分两种:
- 如果你还没搞懂为什么它会发生:让进程退出。然后用第七章的 systemd
Restart=always让它自动重启。「挂了就重启」比「带病运行」安全得多,因为它把一个「不可知的状态」换成了一个「已知的干净状态」。 - 如果你要用这个监听,它唯一合理的用法是「记录遗言然后退出」:把错误和现场信息同步写进日志文件(注意要用同步写,因为异步写可能来不及执行),然后
process.exit(1)。
process.on("uncaughtException", (err) => {
// 只做一件事:把现场留下来,然后干净地退出
try {
fs.appendFileSync(LOG_PATH, `[致命] ${err.stack}\n`);
} catch { /* 已经尽力了 */ }
process.exit(1);
});
警告你可能会发现 OWL 的 bot.js 没有这段监听。这不是遗漏,而是一个选择:它选择让未预料的异常直接把进程杀掉,交给 systemd 去重启。「不写这段代码」也是一种工程决定,而且往往比写错的那一版更安全。一个程序里最危险的东西,不是缺了什么,而是一段「看起来在保护你、实际上在掩盖问题」的代码。
8.3 优雅退出:为什么重启会丢上下文
这一小节直接回答一个你一定会问的问题:「为什么我重启一次机器人,它就把我们刚才聊的都忘了?」
看 bot.js 的最后一段:
function shutdown(sig) {
log(`👋 收到 ${sig},关闭中…`);
llm.saveHistory();
wss.close();
server.close(() => process.exit(0));
setTimeout(() => process.exit(0), 1500);
}
process.on("SIGINT", () => shutdown("SIGINT"));
process.on("SIGTERM", () => shutdown("SIGTERM"));
▲ 摘自 qq-bot/bot/bot.js 第 519–527 行。
先讲 SIGINT 和 SIGTERM 是什么。第一章讲过「信号」是操作系统给进程发的一种通知。SIGINT 是「中断」信号,你按 Ctrl+C 时终端会发出它;SIGTERM 是「请结束」信号,它是礼貌的关机请求——systemctl stop qqbot、Docker 停止容器,发的都是它。还有一个 SIGKILL,那是「立刻死」,进程没有机会执行任何代码,也就没有任何补救余地。
所以关键区别是:收到 SIGINT 或 SIGTERM 时,你的程序还有最后一次说话的机会。OWL 用这个机会做了四件事,顺序不能乱:
llm.saveHistory():把内存里的对话历史写到硬盘。这一行就是「重启不丢上下文」的全部秘密。如果这一行不存在,this.history那个 Map 会随着进程一起消失——用户的对话就没了。而上一次保存是什么时候?看llm.mjs的chat():每成功回复一次就this.saveHistory()。所以其实每轮都在存。那为什么还要在这里再存一次?因为可能有人正在聊天、或者有一些改动还没落盘。「经常存」和「退出时再存一次」是两道不同的保险。wss.close():不再接受新的 WebSocket 连接。server.close(() => process.exit(0)):停止接受新的 HTTP 请求,等现有的请求都结束之后再执行回调退出。exit(0)里的 0 表示「正常退出」,非 0 表示「因为出错退出」——systemd 会看这个数字决定要不要算作失败。setTimeout(() => process.exit(0), 1500):兜底。如果某个连接赖着不走,server.close的回调可能永远不执行,进程就卡在「正在关闭」的状态里。这一行保证了「最多 1.5 秒,无论如何都退出」。
第 4 条这个兜底非常值得学。它的形状是:「正常路径 + 定时兜底」。同样的形状你在这一章见过两次了——OneBotClient.call 里有「正常回话就 resolve + 超时就 resolve(null)」。凡是「等一个外部的东西」的地方,都应该有第二个出口。
那为什么重启还是丢了上下文?可能有三个原因,你以后可以照这个顺序查:
| 可能原因 | 怎么验证 |
|---|---|
进程被 SIGKILL 杀了(比如内存不够被 OOM Killer 干掉),没机会执行 shutdown | 看日志里有没有「👋 收到 SIGTERM,关闭中…」这一行。没有就说明是硬杀 |
| 写文件的位置不对,重启后读的是另一个目录 | 对比进程启动日志里的路径,和 QQBOT_DATA_DIR 的值 |
| 历史有 TTL(过期时间),重启时已经超时被丢弃了 | 看 llm.history.ttlMinutes 的值,OWL 的 config.json 里是 180 分钟;再看 #loadHistory 里的时间判断 |
第三条的机制在这里:
const ttlMs = (this.getConfig()?.llm?.history?.ttlMinutes ?? 60) * 60000;
for (const [k, v] of Object.entries(j)) {
if (v && Array.isArray(v.msgs) && now - (v.at ?? 0) < ttlMs) {
this.history.set(k, v.msgs);
}
}
▲ 摘自 qq-bot/bot/llm.mjs 的 #loadHistory()。Object.entries(j) 把对象变成 [键, 值] 的数组,才能用 for...of 遍历——这是第二章对象那一节的直接应用。注意那个时间判断:只恢复「最后活动时间在 TTL 以内」的会话。三天前聊过的人,今天回来是「重新开始」,不是「接着上次」——因为三天前的上下文已经过期了,而且把它再发给模型也会多花钱。
这里的 ?? 60 同样是代码里的默认值,不是 OWL 实际在用的值。OWL 的 bot/config.json 里写的是 "history": { "maxTurns": 8, "ttlMinutes": 180 },所以真实的参数是 8 轮 / 180 分钟:maxTurns 决定「这次请求带多少历史过去」(费钱的那一头),ttlMinutes 决定「会话在硬盘上能活多久」(不费钱的那一头)。
要点这两个数字合起来解释了你在第 5.7 节末尾追问的那件事:为什么按 Ctrl+C 再启动,上下文有时还在、有时没了?内存里的 history Map 会随进程消失,但每次成功回复都会 saveHistory() 落盘;重启时 #loadHistory() 只把 180 分钟以内活动过的会话捞回来,超时的直接丢弃。而这一整套行为,你在 llm.mjs 里只能看到 ?? 6 / ?? 60 两个默认值——真实答案在配置文件里。所以「默认值 vs 实际值」这条纪律,在第六章讲记忆的生命周期时会再出现一次,而且到时候代价更大:那里存的不是一段随时可以丢的对话,而是关于一个人的印象。
TTL(Time To Live,生存时间)是数据世界里一个到处出现的概念,第六章会专门讲它在记忆系统里的用法。这里你只要先记住:「记得住」和「记得太久」是两个不同的问题,后者是成本和隐私问题。
8.4 热重载:改完配置不用重启
这一小节解决一个开发体验问题:每次改配置都要重启进程,太烦了。
// 热重载 config.json(改完即生效,不用重启)
let reloading = false;
fs.watch(CONFIG_PATH, { persistent: false }, () => {
if (reloading) return;
reloading = true;
setTimeout(() => {
reloading = false;
try {
cfg = loadConfig();
llm.reloadKey();
log(`♻️ config.json 已重新加载 | AI: ${llm.status}`);
} catch (e) {
log("❌ config.json 解析失败,继续使用旧配置:", e.message);
}
}, 200);
});
▲ 摘自 qq-bot/bot/bot.js 第 113–128 行。
fs.watch(path, options, callback) 是 Node 内置的「监听文件变化」:文件被改了,就调你的回调。{ persistent: false } 的意思是「不要因为我在监听这个文件,就让进程保持活着」——如果没有这一项,一个「什么都不干、只是在监听文件」的程序会永远不退出。
剩下的部分是这一小节的精华:为什么要那 200 毫秒的延迟和那个 reloading 标志?
因为 fs.watch 的回调会触发多次。原因是编辑器的保存方式:很多编辑器保存文件时不是「原地写入」,而是「先写一个临时文件,再把临时文件改名覆盖原文件」。这样一次保存会产生好几个文件系统事件。而且操作系统的事件通知本身也不保证只发一次。
后果是什么?你会看到日志里连着刷出五条「♻️ config.json 已重新加载」,或者更糟:某两次触发之间文件刚好写到一半,loadConfig() 读到了一个残缺的 JSON,抛错,然后打印「解析失败」——而你其实什么都没写错。
这种「一件事连续触发很多次,我只想处理最后一次」的需求,叫去抖(debounce)。术语库里的定义是「等一段时间,如果这期间又触发了,就把计时重新开始」。而 OWL 用的是它更简单的兄弟:节流的一种写法——「第一次触发后 200 毫秒内,忽略所有后续触发」。
逐行看这个「忽略」是怎么做到的:
let reloading = false;:一个模块级的状态标记。if (reloading) return;:如果已经有一个「待执行的重载」排着队,就直接走人。注意这里不是在「去抖重新计时」,而是「丢弃新的触发」——这样一个快速连发的一串事件只会产生一次重载。reloading = true;:占住标记。setTimeout(..., 200):等 200 毫秒,让文件写完。这是关键——事件触发的那一刻,文件可能还只写了一半。延迟 200 毫秒几乎肯定能等到写操作完成。- 回调里第一件事是
reloading = false,而且放在try外面。这个位置很讲究:如果放在try里面,而loadConfig()抛错了,标记就永远是true——热重载从此彻底失效,你改一百次配置也不会生效,而且完全不知道为什么。「状态标记的复位要放在一定会执行到的地方」,这是一个非常贵的教训。 cfg = loadConfig():把整个配置对象换掉。这就是第 7 章说的「为什么getConfig必须是函数」——如果别的地方缓存了旧的cfg,它们看不到新配置。catch里打印错误然后什么都不做:保留旧配置继续跑。这是一个很暖的设计——你不会因为改配置时手滑写错一个逗号,就把正在服务的机器人搞下线。
技巧200 毫秒这个数字不是算出来的,是试出来的。这类「等待某个外部动作完成」的延迟,实践中通常是 100–500 毫秒。太小了拦不住,太大了你会觉得卡。遇到这类「魔数」时,养成写注释说明它为什么是这个值的习惯——「200ms 是为了等编辑器把文件写完」比一个光秃秃的 200 有价值得多。
最后提醒一句:这只是「配置热重载」,不是「代码热重载」。改了 bot.js 的代码,还是得重启进程。Node 从 18.11 起提供了一个实验性的 node --watch bot.js,能在文件变化时自动重启进程,适合开发时用;生产环境不要用——因为自动重启和「优雅退出」是两件需要协调好的事,而 --watch 的默认行为不会调用你的 shutdown()。
8.5 限流:AI 机器人必须有刹车
OWL 里有两套完全不同的限流,它们防的是两种不同的风险。先看简单的那套,在 bot.js:
const lastReplyAt = new Map();
function throttled(key, windowMs) {
const now = Date.now();
const last = lastReplyAt.get(key) ?? 0;
if (now - last < windowMs) return true;
lastReplyAt.set(key, now);
return false;
}
▲ 摘自 qq-bot/bot/bot.js 第 175–183 行。这段代码只有七行,但它是一个完整的「冷却」实现。
读法:为每个 key 记住「上一次是在什么时候回应的」。如果距离现在还不够 windowMs,就返回 true(表示「被拦住了」),并且不更新时间;否则更新时间为现在,返回 false(放行)。
两个细节值得注意。?? 0:第一次见到这个 key 时,get 返回 undefined,当成 0 处理——于是 now - 0 是一个巨大的数,一定大于 windowMs,也就是「第一次一定放行」。这是一个用默认值消除边界判断的漂亮写法。
第二个细节在「不更新时间」上。假设冷却是一秒。有人在第 0 秒发了消息(放行,时间记为 0);第 0.5 秒又发(拦住,时间保持 0);第 1.1 秒又发(放行,时间记为 1.1)。这是「冷却」的语义:一次放行之后要安静一秒。如果被拦住的时候也更新时间,那么在持续刷屏的情况下,它会永远被拦住——那是「封禁」,不是「冷却」。
key 是怎么设计的?看调用处:
const key = isGroup ? `kw:${groupId}` : `kw:p:${userId}`; // 关键词回复:群按群、私聊按人
const key = `at:${groupId}`; // @应答:按群
const key = `pv:${userId}`; // 私聊兜底:按人
▲ 摘自 qq-bot/bot/bot.js 的 decideStatic()。
key 的粒度决定了限流的效果。按群限流意味着「整个群一起共享这一秒」——群里十个人同时问,只有一个人能得到静态回复。这是在群聊里防止刷屏的正确粒度。而私聊按人,意味着每个人有自己的冷却。
然后看真正的 AI 限流,在 llm.mjs。它用的是滑动窗口:
#checkLimits(sessionKey, userKey) {
const lim = this.getConfig()?.llm?.limits ?? {};
const now = Date.now();
const win = 60000;
const take = (key, limit) => {
if (!limit || limit <= 0) return false;
const arr = (this.buckets.get(key) ?? []).filter((t) => now - t < win);
if (arr.length >= limit) {
this.buckets.set(key, arr);
return true;
}
arr.push(now);
this.buckets.set(key, arr);
return false;
};
if (take("global", lim.globalPerMinute)) return "全局频率已达上限,稍等一下";
if (take(`user:${userKey}`, lim.userPerMinute)) return "你说得太快了,缓一缓~";
if (take(`sess:${sessionKey}`, lim.groupPerMinute)) return "这边消息太多了,我歇口气";
return null;
}
▲ 摘自 qq-bot/bot/llm.mjs 第 194–215 行。
「滑动窗口」是什么意思?想象一根 60 秒长的尺子,它的右端永远是「现在」,左端是「60 秒前」。窗口里装着这段时间内每一次请求的时间点。
时间轴 ──────────────────────────────────────►
[──────── 这 60 秒 ────────]
记录 记录 记录 记录 记录 现在
↑ 超出窗口的记录会被丢掉
逐行读:
const win = 60000;:窗口长度一分钟。它是写死的,不是配置项——因为配置项的名字叫userPerMinute(每分钟),窗口长度已经在这个名字里了。const take = (key, limit) => {...}:定义一个内部小函数,参数是「哪个桶」和「上限多少」。注意它是定义在#checkLimits里面的,所以能直接用外面的now和win。这是第二章讲过的闭包——内部函数可以访问外部函数的变量。它之所以找得到now,是因为 JavaScript 查一个变量时会先看自己,再看外层函数,一层层往外找,直到全局;这条查找路径叫作用域链。闭包就是「函数记住了它出生时所在的那条链」。这样每次调用都不用把now当参数传来传去。if (!limit || limit <= 0) return false;:没配置上限,或者配了 0 或负数,就视为不限流。这是一个「配置缺省」的处理:不写配置也能跑。(this.buckets.get(key) ?? []).filter((t) => now - t < win):这是「滑动」的那一步。取出这个桶里所有的时间点,把「距离现在已经超过 60 秒」的那些过滤掉。剩下的就是「这一分钟内的请求」。filter是第二章数组那一节讲过的。if (arr.length >= limit) { this.buckets.set(key, arr); return true; }:已经满了。注意:即使被拒绝,也把「已清理过的数组」写回去——这叫「顺手做清理」,不然被刷屏的桶会一直带着一堆过期数据。这是一个很细但很对的写法。arr.push(now); this.buckets.set(key, arr); return false;:没满,把这次的时刻记进去,放行。- 三级检查的顺序是从大到小:先全局、再按人、最后按会话。这个顺序有意义——如果全局已经满了,你不需要再去检查个人额度,早退出一次省一点。但也要注意:一旦某一级拒绝,后面的都不会记录。这意味着一个被全局限流的人,他的个人计数不会增长——这是刻意的还是意外的?你可以在笔记本上想一下这个问题。
最后回答那个最重要的问题:为什么 AI 机器人必须有刹车?
三个理由,每一个都是真实的风险:
- 钱。每一次 AI 调用都按 token 计费。OWL 的
limits配的是「单人每分钟 6 次、单群 10 次、全局 60 次」。全局上限 60 意味着最坏情况下每分钟 60 次调用、一小时 3600 次。如果没有这个上限,一个深夜无聊的人可以让你一晚上花掉几百块。 - 可用性。「限流」保护的不仅是钱包,还有服务本身。如果所有请求都挤进来,每一条都要等 45 秒超时,那么服务器的内存会堆积大量待处理的请求——最后 OOM 被杀。术语库里的「容错」和「降级」就是为了这种场景。
- 账号安全。第一章就说过,QQ 的风控系统一直在观察异常行为。一个机器人如果在三秒内连发几十条消息,非常可能被判定为异常。限流不只是保护你的服务,也是保护你的账号不被封。
还有一条隐含的输入限制,在 chat() 的最前面:
const maxChars = llm.limits?.maxInputChars ?? 400;
if (text.length > maxChars) {
return { ok: false, reason: `too-long:${maxChars}` };
}
▲ 摘自 qq-bot/bot/llm.mjs。「一次不超过 400 字」这个限制和钱直接相关:输入的 token 也要计费,而且一段一万字的长文不仅贵,还可能已经超过模型的上下文窗口,导致请求直接失败。而失败提醒是怎么给用户的?回到 bot.js:
if (r.reason.startsWith("too-long")) {
const max = cfg.llm?.limits?.maxInputChars ?? 400;
await sendAndLog(`你这条太长了(超过 ${max} 字),截短点再说~`);
return;
}
注意那句提示里 ${max} 是从配置里动态读出来的,而不是写死「400」。这样你改了配置上限,提示语会自动跟着变——「同一件事只写一遍」是「硬约束」这个术语想表达的工程纪律。
8.6 超时、重试、降级三件套
最后来看 OWL 怎么处理「AI 挂了」这件事。它是这三件套的一个完整实例。
第一件:超时。前面讲过,AbortController + 45 秒。没有超时的服务不是「慢」,而是「会卡死」。
第二件:重试。我要在这里给一个可能有反直觉的结论:OWL 没有做重试,这是对的。
为什么?因为重试只有在「失败是暂时的、而且重试大概率会成功」时才有价值。llm.mjs 把失败分成了几类,看它怎么分类的:
| 失败原因 | 重试有用吗 | 为什么 |
|---|---|---|
network:超时 | 可能有用 | 网络抖动是暂时的;但重试会让用户多等 45 秒 |
http-401 / http-403 | 完全没用 | Key 不对。重试一百次还是不对,只会浪费你的额度 |
http-402 | 完全没用 | 余额不足。重试不会让钱变多 |
http-429 | 有用,但要等 | 被限流了。立刻重试只会让情况更糟,必须退避 |
too-long | 完全没用 | 输入超长。重发还是超长 |
limited:... | 完全没用 | 是你自己的限流拦下的 |
六种情况里有四种,重试是纯粹有害的。所以「无脑重试三次」是一个常见的、看起来很稳健、实际上很糟糕的写法:它会把「Key 填错了」变成「Key 填错了并且账户被限流了」。
如果要做重试,正确形状是指数退避(术语库里有这个词):第一次等 1 秒重试,第二次等 2 秒,第三次等 4 秒——而且只对「可能自愈」的错误做。这两条都写进代码里,才算真的懂了重试。
第三件:降级。这是 OWL 做得最好的部分。AI 不可用的时候,程序不会沉默,而是给一句人能看懂的话。看那个翻译函数:
function llmFailureHint(reason) {
if (!cfg.llm?.fallbackToStatic) return "(我这会儿有点懵,等会儿再聊)";
if (reason.startsWith("too-long")) return null; // 由调用方给提示
if (reason.startsWith("limited:")) return reason.slice("limited:".length);
if (reason.startsWith("http-401") || reason.startsWith("http-403"))
return "我的 AI 密钥好像不对(401/403),去检查一下 llm.local.json。";
if (reason.startsWith("http-402") || reason.includes("Insufficient"))
return "AI 账户余额不足了,去充点钱吧~";
if (reason.startsWith("http-429")) return "AI 接口请求太频繁了,缓一下。";
if (reason.startsWith("network:")) return "我连不上 AI 服务(网络/超时),稍后再试。";
return "AI 暂时不可用,先记着这事。";
}
▲ 摘自 qq-bot/bot/bot.js。这个函数不到十行,但它值得你抄进自己的项目。
它的形状是:一串 if,每一种失败原因对应一句给人看的话,最后有一个兜底。这条链上有三个值得学的设计:
- 它把机器语言翻译成了人的语言。用户不会看到「HTTP 402 Insufficient Balance」,而是看到「AI 账户余额不足了,去充点钱吧~」。注意那句「去充点钱吧」是对管理员说的——而 OWL 的用户是高中生,他们帮不上忙。这是一个设计上的不完美,你在自己的项目里可以想得更好:也许该给用户一句「我现在有点累」,同时单独给你自己发一条管理员通知。
return null表示「我不给提示,让调用方处理」。too-long和limited:就是这种——因为调用方知道更多信息(比如配置里的上限数字),能给出更具体的提示。「用一个特殊返回值表示『我处理不了,交给上面』」是一种常见的分工方式。fallbackToStatic开关。如果它是false,所有失败都被压成同一句「我这会儿有点懵」——适用于「不想暴露技术细节给用户」的场景。如果它是true,你会看到很具体的技术提示——适用于「这是给你自己用的测试机器人」。同一个函数,两种受众,靠一个配置项切换。
而最后一道降级在 onEvent 的最后一行:
log(` ↳ AI 不可用(${r.reason}),改走静态规则`);
// ---- 3) 静态兜底:关键词 / @ / 私聊 ----
const stat = decideStatic(event, text);
AI 挂了,至少还有关键词回复。用户发「你好」,还会收到「你好呀,我是 OWL~」。这就是降级:能力下降,但服务不消失。一个没有降级设计的机器人在 AI 接口出问题时会变成一块石头,而 OWL 会变成一个「今天有点笨但还在」的机器人。
把这一节的三件套串起来看:
超时解决「等太久」——给每次等待一个上限。
重试解决「偶尔失败」——但只对可能自愈的失败做,而且要退避。
降级解决「彻底不行」——保证用户至少能得到一句人话,而不是沉默。
这三件事的顺序也是判断顺序:先别等坏(超时),再试着救一次(重试),最后把话说清楚(降级)。
9. 事件驱动的思维方式:把「等待」变成「登记」
这一节不教任何新的语法。它讲的是这一章真正想在你脑子里换掉的那个东西。
9.1 同一件事的两种写法
假设你要做一件事:等 config.json 被改动。下面两种写法都能实现,但它们是两种完全不同的世界观。
第一种,叫轮询(polling):
// 每 500 毫秒看一次,文件改了吗?
let lastMtime = fs.statSync(CONFIG_PATH).mtimeMs;
setInterval(() => {
const m = fs.statSync(CONFIG_PATH).mtimeMs;
if (m !== lastMtime) {
lastMtime = m;
console.log("配置变了");
}
}, 500);
第二种,叫事件驱动,也就是 OWL 用的那种:
fs.watch(CONFIG_PATH, { persistent: false }, () => {
console.log("配置变了");
});
两者的差别不止是行数。列成表看得更清楚:
| 维度 | 轮询 | 事件驱动 |
|---|---|---|
| 你的代码在做什么 | 反复问「变了吗」 | 登记「变了就告诉我」,然后去干别的 |
| 反应有多快 | 最坏要等一个间隔(这里 500 毫秒) | 几乎立刻(操作系统通知) |
| 空闲时消耗 | 一直在跑,哪怕什么都没发生 | 零(操作系统在看到变化前不发通知) |
| 会不会漏 | 如果在两次检查之间变了两次,你只知道「变了」 | 都会通知到(但可能通知太多次,见去抖) |
| 代码的形状 | 一条直线,你看得到循环 | 散落的「当……时」 |
最后一行才是这一节的重点。事件驱动的写法会打散你对「程序在做什么」的直觉。
9.2 「你的代码不再是一条直线」
回想第二章你写的第一个程序:它是一条直线。第一行、第二行、第三行,从上到下,读完就知道它做什么。你可以用手指顺着代码滑下去,那就是它的执行过程。
现在看 bot.js。它有 527 行,但你从头读到尾,会发现主流程几乎是空的——它做的全部事情就是登记:
- 当配置变化时 → 重新读配置
- 当有协议端连上来时 → 检查 token
- 当有消息进来时 → 解析、配对或分发
- 当连接断开时 → 记一条日志
- 当端口占上时 → 打印启动横幅
- 当收到 SIGTERM 时 → 保存历史然后关闭
这个程序没有「什么时候做什么」的顺序,只有「什么条件下做什么」的清单。它像是一个值班表,而不是一份剧本。
这就是「事件驱动」这个词的真实含义,也是术语库里的定义想说的话。你的程序不再是一条从开始走到结束的线,而是一堆挂在墙上的铃铛;程序的主体工作,是等铃铛响。
让我把这个比喻接回机制,防止它变成误解的来源:「铃铛」就是操作系统和事件循环。当网络数据到达时,内核知道;内核告诉事件循环;事件循环去查「我有没有为这件事登记过回调」;有,就把那个回调压进调用栈执行。你登记的每一个回调,都对应事件循环里的一个具体检查动作。它不是隐喻,它就是第 3 节那张伪代码在跑。
9.3 这种思维方式为什么会让人难受
我要诚实地告诉你:从「直线思维」转到「事件思维」,会让很多人卡住,而且卡住的方式很特别——不是「看不懂」,而是「看懂了但不知道从哪下手」。
具体表现为三种困惑:
第一种困惑:我想让程序「先做 A 再做 B」,可 B 应该写在哪里?
直线思维下,B 就写在 A 的下一行。事件思维下,如果 A 是异步的,B 必须写在 A 的回调里、或者 await 之后。这是新手最容易出错的地方,也是本章第 4 节四个坑的共同根源。解法:把「之后」这个词当成信号。每当你在心里说「先……然后……」,就在纸上画一个箭头,问自己:箭头两边是同一个函数的上下两行,还是一个回调的注册?
第二种困惑:程序到底在什么时候「什么都没做」?
在直线程序里,「程序在跑第 30 行」是一个有意义的坐标。在事件驱动里,大部分时间它只是「在等」,你没法指出它在哪一行——因为它不在任何一行。第一次意识到这件事的时候,会有一种失控感:我写的代码什么时候执行?
解法:把日志当成你的眼睛。第 8 节那个 log() 函数在这里有了新的意义。当你不确定「到底发生了什么、按什么顺序」的时候,唯一的办法是在每个分支上打一句日志,然后读取真实的顺序。OWL 的日志里那些箭头符号(↳)就是为此存在的:它们标记出「这一条消息最终走了哪条路」。
第三种困惑:为什么我改了一处,坏的是另一处?
因为回调之间没有「调用关系」给你当线索。在直线代码里,如果第 10 行出错,你会去看第 9 行给了它什么。而在事件驱动里,一个回调拿到的数据,可能是五个不同的地方塞进来的。
解法:给数据流命名。注意 bot.js 里的 sessionKey、userKey、echo 这些名字——它们不是装饰。sessionKey 一路被传给 llm.chat、传给 #checkLimits、成为 Map 的键;echo 一路跟着一次调用从发出到收回。名字是你在事件迷宫里留下的绳子。
想一想请把 bot.js 里所有的 .on( 和 process.on( 找出来数一数。然后问自己:如果我想加一个新功能——「当机器人被拉进一个新群时,自动发一条欢迎消息」——我应该在哪里登记?这个回调里能拿到什么?它需要调用哪个已有函数?如果你能回答这三个问题,说明你已经开始用事件的方式思考了。
9.4 接受它的方法:把自己当成接线员
我给你三个具体可用的方法,它们都是我自己用过的。
方法一:画图,不画流程图画事件图。不要画「开始 → 判断 → 结束」的流程图。画一张表:「当什么发生」/「谁会被调用」/「我拿到什么」。
| 当什么发生 | 谁被调用 | 我拿到什么 |
|---|---|---|
| NapCat 连上来 | wss.on("connection") | ws(这条连接)、req(握手请求) |
| 收到一条 JSON | ws.on("message") | raw(Buffer) |
那是一条 echo 回复 | pending.get(...).resolve | 那个等了很久的 Promise 的结果 |
| 那是一条聊天消息 | onEvent | event 对象(谁、在哪个群、说了什么) |
| 配置被改了 | fs.watch 的回调 | 无(但 cfg 会被替换) |
| 收到 SIGTERM | shutdown | 信号名("SIGTERM") |
这张表比任何流程图都有用,因为它直接对应你要写的代码。而更关键的是,它能让你一眼看到「哪件事没人管」。
方法二:先写日志,再写逻辑。当你不知道从哪下手时,就先把「当这件事发生时,打印一行日志」写上。跑一次,看日志,你就知道这件事到底什么时候发生、会连着发生几次。然后再往那个回调里填真正的逻辑。OWL 的 fs.watch 那 200 毫秒的去抖,就是这么试出来的——先打日志发现「怎么响了五次」,才想到要去抖。
方法三:承认「不确定顺序」是正常的。直线思维的人有一种执念:必须知道每一步的确切顺序。而事件驱动的世界里,正确的态度是「不关心顺序,只关心关系」——我不需要知道 A 和 B 谁先发生,我只需要保证「A 发生时我知道该干什么,B 发生时我也知道」。甚至当顺序真的会影响结果时(比如初始化必须先于处理消息),你会用一个显式的状态位去保证它——bot.js 里 client.selfId ??= data.self_id 和 llm.ready 就是这种状态位。
最后说一句可能有点文艺、但我认为准确的话。写事件驱动的程序,本质上是在为「未来」写规则,而不是为「现在」写步骤。你不知道下一条消息什么时候来、从谁来、说了什么——你能做的只是把每一种情况下的应对方式写清楚,然后安心地去等。这件事里有一种很特别的信任感:你相信那些你写的规则,会在你需要它们的时候被正确地唤醒。
10. 动手项目:一个不联网的假机器人
现在把这一章所有的东西串成一个能跑的东西。这个项目刻意做了三个限制:
- 不联网——用假的模型函数代替 DeepSeek API。这样你不用花钱、不用配 Key,也能跑通全部逻辑。
- 不开 QQ——从标准输入读消息。这样你不用管 NapCat、不用管端口。
- 但结构完全一样——冷却、上下文、指令、超时、优雅退出,全都是真实的那一套。
写完它,你就已经把这个项目里 80% 的逻辑走过一遍了。之后把它接到真的 QQ 和真的 API 上,只是替换两个函数。
10.1 完整代码
import readline from "node:readline/promises";
// ---------- 1. 假的模型:一半成功,一半失败 ----------
let callCount = 0;
function fakeModel(messages, { timeoutMs = 3000 } = {}) {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
return new Promise((resolve) => {
const delay = 800;
const t = setTimeout(() => {
clearTimeout(timer);
callCount += 1;
// 奇数次的调用成功,偶数次失败 —— 这样你能稳定地看到两条分支
if (callCount % 2 === 0) return resolve({ ok: false, reason: "网络: 假装断线" });
const last = messages[messages.length - 1].content;
resolve({ ok: true, text: `(假回答第 ${callCount} 次)你说的是「${last}」` });
}, delay);
// 超时闹钟真的到点了:放弃等待
ctrl.signal.addEventListener("abort", () => {
clearTimeout(t);
resolve({ ok: false, reason: "超时" });
}, { once: true });
});
}
// ---------- 2. 状态:上下文与冷却 ----------
const MAX_TURNS = 3; // 只记最近 3 轮
const COOLDOWN_MS = 2000; // 同一个人 2 秒内只能触发一次 AI
const messages = [{ role: "system", content: "你是一个说话简洁的朋友。" }];
const lastAt = new Map();
function isCooling(user) {
const now = Date.now();
const last = lastAt.get(user) ?? 0;
if (now - last < COOLDOWN_MS) return true;
lastAt.set(user, now);
return false;
}
// ---------- 3. 静态规则(第二、三章的那一套) ----------
function staticReply(text) {
if (text === "你好") return "你好呀~";
if (text.includes("谢谢")) return "不客气~";
if (text === "帮助") return "可用指令:/重置 /状态 /exit";
return null;
}
// ---------- 4. 处理一条消息 ----------
async function handle(user, text) {
// 指令最优先,永远不被 AI 抢走
if (text === "/重置") {
messages.length = 1;
return "好的,咱俩刚才聊的我先放下了,重新开始~";
}
if (text === "/状态") {
return `上下文 ${messages.length - 1} 条 / 已调用模型 ${callCount} 次`;
}
// 静态规则其次
const s = staticReply(text);
if (s) return s;
// 冷却:拦住了就明确告诉他
if (isCooling(user)) return "(你说话太快了,喘口气再说~)";
// 交给 AI
messages.push({ role: "user", content: text });
const r = await fakeModel(messages);
if (!r.ok) {
messages.pop(); // 失败就把这句撤回,保持历史自洽
return `(我这边出了点问题:${r.reason})`;
}
messages.push({ role: "assistant", content: r.text });
// 只保留最近 MAX_TURNS 轮(一轮 = 用户 + 助理 两条)
while (messages.length > 1 + MAX_TURNS * 2) messages.splice(1, 1);
return r.text;
}
// ---------- 5. 主循环:等人说话 ----------
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
let closing = false;
async function shutdown() {
if (closing) return;
closing = true;
console.log("\n(保存现场……)");
console.log(`(本次共调用模型 ${callCount} 次,上下文 ${messages.length - 1} 条)`);
rl.close();
process.exit(0);
}
process.on("SIGINT", shutdown);
console.log("假机器人已启动。输入 /exit 退出,/重置 清空上下文,/状态 看状态。");
while (true) {
const line = (await rl.question("你: ")).trim();
if (!line) continue;
if (line === "/exit") break;
const reply = await handle("me", line);
console.log("机器人: " + reply);
}
await shutdown();
▲ 保存成 fake-bot.mjs,用 node fake-bot.mjs 运行。整段代码不联网、不依赖任何第三方包。
10.2 逐块讲解
第一块:假模型
这是整段代码里最需要讲清楚的部分,因为它模拟了真实 API 的失败模式。
callCount 在模块级(函数外面),所以它记住了「一共调用了几次」。为什么用奇偶来交替成功失败?因为你需要能稳定复现两条分支。用随机数的话,可能连着五次都是成功,你就看不到失败分支长什么样。「让测试可复现」是工程上的一个好习惯,这一小块就是它的最小形态。
那个 Promise 里有两个可能的出口:一个 800 毫秒的定时器(正常返回),一个 abort 事件(超时返回)。这又一次是「正常路径 + 定时兜底」的形状,和第 8 节的 shutdown 一模一样。
注意 { once: true } 那个选项:它表示这个监听器只触发一次就自动移除。为什么需要?因为 abort 信号可能被触发多次(这个例子里不会,但真实场景会),而你不希望 resolve 被调用两次。虽然 Promise 的 resolve 第二次调用会被忽略,但每次都清一次定时器、每次跑一遍逻辑,是浪费。这条小纪律值得养成。
还有一个刻意的设计:ctrl.abort() 是我手动在 signal 上监听的,而不是像真实 fetch 那样由 fetch 内部处理。这就是为什么这段代码要写两次「清理」——一次在正常路径上 clearTimeout(timer),一次在 abort 路径上 clearTimeout(t)。自己造的异步函数,所有的清理都要自己负责。这就是用真 fetch 比自己写网络请求省心的地方。
第二块:状态
const MAX_TURNS = 3;
const messages = [{ role: "system", content: "你是一个说话简洁的朋友。" }];
const lastAt = new Map();
三个状态,其中两个是这一章的产物:messages 是上下文(第 5 节),lastAt 是冷却记录(第 8 节的 lastReplyAt 的简化版,从「群/人」简化成了「人」)。
isCooling 这个函数的名字起得很好,因为它说的是「一个人正在冷却中吗」,而不是「throttled」。注意它做了两件事:判断,以及在放行时更新时间。名字只描述了判断,实际带了副作用。这在工程上算一个小小的瑕疵——一个更好的名字可能是 tryConsumeCooldown。但更重要的是你会发现:「判断 + 顺便改状态」这种函数在实践中非常常见。你能识别出它的副作用,就已经比大多数人强了。
第三块:静态规则
这一块故意留得很简单,因为它是第二章和第三章的复习:=== 精确匹配、includes 包含匹配、返回 null 表示「没命中」。这三个动作就是 OWL 的 matchCommand 和 decideStatic 的全部骨架。
第四块:处理一条消息——这一块是 onEvent 的缩小版
请把这段代码和 bot.js 的 onEvent 对照着看。顺序完全一致:
- 指令最先(
/重置、/状态),命中就 return,绝不往下走。 - 静态规则其次,命中就 return。
- 冷却检查在 AI 之前——因为冷却的目的是省钱,而不是省静态回复。这个位置很重要:如果你把冷却放在最前面,那么「你好」这种不需花钱的回复也会被拦住。
- 调用真正的「AI」,并处理两种结果。
- 失败时
messages.pop():还记得第 5 节讲过的吗?保持历史自洽。 - 裁剪历史:
while (messages.length > 1 + MAX_TURNS * 2) messages.splice(1, 1);
第 6 步那个 splice(1, 1) 值得解释:splice(1, 1) 的意思是「从下标 1 开始,删掉 1 项」。从下标 1 开始,是为了保住下标 0 的 system。用 while 而不是 if,是因为一次可能超出不止一条。这就是 OWL 里 prev.slice(-maxTurns * 2) 的手写版——同一个目的,两种写法。OWL 那个更简洁,但不修改原数组;这个会原地裁剪。你选了哪种写法,就要清楚地知道自己选了什么。
第五块:主循环与优雅退出
while (true) 里 await rl.question(...)——这一行是整个项目里最像「服务」的地方:程序停在这里等人说话,而且因为它是 await,调用栈是空的,事件循环在自由地跑(比如那个 800 毫秒的假模型定时器)。
那个 closing 标志防的是「按两次 Ctrl+C 触发两次退出」。这个模式你在 bot.js 的 reloading 里见过一模一样的形状。好的模式会在不同的地方反复出现——这正是「设计模式」这个词想表达的东西。
rl.close() 是必要的:它让 readline 释放对 stdin 的占用。不调用它,进程可能不肯退出——还记得第 5.6 节那张表里「忘掉 clearTimeout 会让程序多挂 45 秒」吗?这是同一类问题的另一个实例:任何「占着资源等外部输入」的东西,都会让进程不肯结束。
10.3 三个「故意弄坏它」的实验
现在到了这个项目最有价值的部分。请把这三个实验真的做一遍,不要只是读。序章讲的「亲手验证」就是这个意思。
实验一:在 handle 里去掉 await
把 const r = await fakeModel(messages); 改成 const r = fakeModel(messages);(只删掉 await)。
你预测会发生什么?先写下来,再运行。
实际结果:程序会立刻打印出 机器人: undefined,或者其他奇怪的东西。原因:r 变成了一个 Promise,r.ok 是 undefined,所以走了失败分支,r.reason 也是 undefined,于是打印 (我这边出了点问题:undefined)。
然后是一个更隐蔽的后果:那个 messages.push({ role: "user", content: text }) 已经被执行了,但 messages.pop() 也执行了——所以历史看起来是干净的。可是 800 毫秒之后,那个被抛弃的 Promise 还是会真正跑完,callCount 会加一。你会发现「已经调用了 1 次模型」这个状态被悄悄改掉了,而你什么都没看到。
这就是「忘记 await」的真实形态:它不只是「打印出 Promise」,它还会在你看不见的地方继续执行。这在真实的 OWL 里意味着:一次没有 await 的 AI 调用会在后台跑完、花掉钱、然后把结果丢掉。
实验二:去掉超时
把 fakeModel 里那个 ctrl.signal.addEventListener("abort", ...) 整块删掉,然后把 timeoutMs 改成 100(比 800 毫秒的延迟短)。
你预测会发生什么?
实际结果:程序会照常返回成功,只是等了 800 毫秒而不是 100 毫秒。因为「超时」这个能力完全来自那段监听代码——没有它,ctrl.abort() 触发了也没有人听。
这是一个极其重要的观察:AbortController 不会自动让任何事情停下来。它只是一个广播。谁来听这个广播、听了之后做什么,全都要你自己写。真实的 fetch 之所以「会自动中断」,是因为 fetch 内部帮你写了这个监听。你调用的每一个库,都在某个地方替你听了某个广播。当库不好用的时候,你要知道该去看哪一段。
实验三:去掉冷却
把 handle 里那三行冷却检查(if (isCooling(user)) return ...)注释掉,然后快速连打十次「在吗」。
你预测会发生什么?
实际结果:你会看到十条「你: 」的输入提示几乎同时涌出来,然后十条回答在 800 毫秒后一起出现。callCount 会一次性跳到 10。而这正是第 3 节那个 while 死循环的镜像版本——不是事件循环被堵住,而是事件循环被洪水淹没。
如果这是真的 OWL,后果是:十次 API 调用同时发出去(花十倍的钱)、十次调用可能都触发限流(然后十条降级提示一起发给同一个人)、以及腾讯的风控系统看到这个机器人一秒内发了十条消息。冷却、限流、退避这些东西不是「优化」,它们是「刹车」。一辆没有刹车的车不是快,是危险。
想一想请再做一个我自己没做的实验:把 MAX_TURNS 从 3 改成 100,然后跟它聊二十轮。观察两件事:(1)每次发给「模型」的 messages 数组有多长?(2)如果这是真实的 API,按照「历史也要计费」的规则,第 20 轮的输入 token 会是第 1 轮的多少倍?这个实验没有标准答案,但它能让你第一次真正感受到「上下文」和「成本」之间那条直接的线。
自查:你是不是真的懂了
先自己回答,再展开参考答案。凡是「输出顺序」类的题目,请务必先用笔在纸上写一遍,再去看答案,最后一定要打开电脑跑一次。凡是你只能靠「感觉」回答的,说明还要再读一遍。
-
下面这段代码的输出顺序是什么?请写出完整顺序,并说明每一步为什么排在那里。
console.log("A"); setTimeout(() => console.log("B"), 0); Promise.resolve().then(() => console.log("C")); console.log("D");参考答案
A → D → C → B。推演过程:整个脚本本身是一个宏任务,开始执行。
"A"同步打印;setTimeout(f, 0)把f放进宏任务队列(0 毫秒不等于立刻,它是「尽快但排在后面」);Promise.resolve().then(g)因为 Promise 已经成功,把g放进微任务队列;"D"同步打印。脚本这个宏任务结束,事件循环把微任务队列彻底清空——执行g,打印"C";然后才取下一个宏任务f,打印"B"。最容易答错的地方是以为 0 毫秒的定时器会插在
"C"之前。它的错误根源是把「0 毫秒」理解成「优先级最高」,而实际上 0 毫秒只是「延时为零」,它仍然是一个宏任务,必须等所有微任务跑完。 -
下面这段代码的输出顺序是什么?请写出完整顺序,并指出哪一项的顺序是不保证的。
console.log("1"); process.nextTick(() => console.log("2")); queueMicrotask(() => console.log("3")); Promise.resolve().then(() => console.log("4")); setTimeout(() => console.log("5"), 0); setImmediate(() => console.log("6")); console.log("7");参考答案
确定的部分是:
1 → 7 → 2 → 3 → 4,然后5和6之间顺序不保证。我在自己的机器上用.cjs运行,实测输出是1 7 2 3 4 6 5,但换一台机器、或者多跑几次,5和6的位置可能交换。依据:同步代码(1、7)最先;
process.nextTick的队列在 Node 里被明确保证「总是先于微任务队列被处理」,所以 2 在 3 和 4 之前;queueMicrotask与Promise.then都在微任务队列,先注册的先执行,所以 3 在 4 之前(注意:我在 ESM 顶层代码里实测时nextTick与queueMicrotask的顺序出现过反转,所以宁可把结论记成「nextTick 最先」这一条官方保证,而不是依赖某一次实验)。5 和 6 为什么不保证:它们是两个不同阶段的宏任务。
setTimeout属于 timers 阶段,setImmediate属于 check 阶段,谁先被处理取决于当时事件循环走到了哪里。如果你把这两行放进一个 I/O 回调内部,setImmediate才会稳定地跑在前面。这道题真正在考的是:你有没有区分「我实测到的」和「标准保证的」。一个只会背输出顺序的人会栽在第二台机器上。
-
为什么
await不会阻塞整个程序?请用「调用栈」「任务队列」「事件循环」这三个词说清机制。再说一个反例:什么情况下await反而会让整个机器人哑掉?参考答案
机制:
await遇到一个还没出结果的 Promise 时,做的事情是「把当前 async 函数后面的代码登记成一个回调,然后让出调用栈」。调用栈一空,事件循环就能从任务队列里取别的回调来执行。所以被暂停的只有「当前这个 async 函数」,整个程序照常运行。这一点我在本章第 4 节用两个并发的await sleep(500)验证过:总耗时 501 毫秒,不是 1000 毫秒。反例:如果
await后面那个 Promise 的「解决时机」依赖于一段同步的耗时计算,那么调用栈实际上还是被占住了。比如你在 async 函数里写了一行await Promise.resolve(重活())——括号里的重活()是同步执行完才交给Promise.resolve的,那一段计算照样会堵死事件循环。另一个更常见的反例是:忘记await造成的并发洪水——它不是哑掉,而是被淹没。为什么这个反例重要:它说明「异步」不是一种写在函数上的属性,而是「你有没有把等待交出去」。一个函数写着
async,里面全是同步计算,那它对事件循环毫无贡献。 -
OWL 部署到服务器之后,你私聊它「你好」,它不回。日志的最后几行是:
[2026-10-03 21:04:11] 🔗 协议端已连接 (127.0.0.1) [2026-10-03 21:04:40] 📩 [私聊 10086] 某人: 你好 [2026-10-03 21:04:41] ↳ 不回复日志里没有「❌ AI」之类的错误。请给出至少三条你的排查方向和判断依据。
参考答案
先读日志本身能告诉我们什么:协议端连上了、消息收到了、程序走到了最后那句
↳ 不回复。看bot.js的onEvent,走到这一行意味着指令没命中、AI 没接管、静态规则也没命中。所以问题不在「消息没进来」,而在三个判断中的一个。方向一:AI 触发条件。看
shouldUseLlm的三个前置条件:llm.enable是不是true?llm.ready是不是true(它要求 enable、apiKey、baseUrl、model 四个都齐)?私聊时trigger.private是不是true?注意:如果llm.ready是 false,程序是静默降级的——不接管,然后往下走静态规则。而启动日志里那行「🧠 AI 状态」会直接告诉你答案。判断依据:这是最可能的原因,因为它不会产生任何错误日志。方向二:静态规则没命中。
config.json里的关键词确实是「你好」,但要看reply.keywordReply是不是true(是),以及decideStatic里那一步的冷却 key。关键点:throttled被拦住时返回的也是null,于是日志同样显示「不回复」,不显示「被冷却拦住了」。所以「上次有人在这个时间窗口里发过同样的话」也会产生这个现象。方向三:文本不匹配。实际收到的
text可能不是干净的「你好」——群里可能是@OWL 你好(那stripAt会处理),也可能带了别的零宽字符或全角空格。日志里打印的是text原文,仔细看那一行有没有多余字符。还有一个应该被指出来的问题:
bot.js在这三个分支上给出的日志都是同一句「不回复」,这本身就是可观测性的缺陷。一个更好的写法是把原因打出来(不回复(AI 未就绪 / 被冷却 / 无规则命中))。这属于第七章「可观测性」的内容,但你现在就应该能看出来。 -
你在
bot.js里加了一行代码,之后机器人每次重启都会丢掉所有上下文。日志里也没有「👋 收到 SIGTERM,关闭中…」这一行。请给出至少两个可能的原因,以及各自的验证方法。参考答案
最重要的线索是「没有那一行日志」。看
shutdown函数,它的第一件事就是log("👋 收到 ...")。没有这行日志,说明shutdown根本没被调用。原因一:进程是被硬杀的,不是被礼貌地请求退出的。
SIGKILL无法被捕获,进程没有机会执行任何代码。最典型的场景是内存不足被 OOM Killer 杀掉(那台机器只有 1.6G 内存)。验证:dmesg | grep -i "killed process",或者看 systemd 的systemctl status qqbot里记录的退出信号(如果是signal=KILL就证实了)。原因二:新加的那行代码让进程「还没来得及」处理信号就退出了。比如你在某个地方加了
process.exit(),或者在顶层加了一个未被捕获的throw(进程会立刻死,SIGTERM处理函数根本跑不到)。验证:看bot.err.log(install.sh 里把标准错误指向了它),或者git diff看你自己改了什么。原因三(针对「丢上下文」而非「没日志」):
saveHistory()写到了别的地方。如果重启后进程的QQBOT_DATA_DIR变了(比如你从「直接 node bot.js」换成了 systemd 启动),它读写的history.json就是另一个文件。验证:对比两种启动方式下的启动日志——#loadHistory成功时会打印「🧠 已恢复 N 个会话的上下文」。没有这一行,说明它读的文件里什么都没恢复出来。另一个不该漏掉的可能:历史恢复是有 TTL 的(
ttlMinutes,OWL 配的是 180 分钟)。如果两次运行间隔超过这个时间,恢复出来自然是 0 个会话——这是设计行为,不是 bug。但注意:它仍然会打印「已恢复 0 个会话」这句话,所以和原因三可以区分开。 -
请写出
throttled(key, windowMs)和#checkLimits里的take(key, limit)在被拒绝时分别做了什么。为什么它们的行为不同?这个不同带来了什么后果?参考答案
throttled被拒绝时什么都不做——直接return true,lastReplyAt里的时间保持旧值。语义是「一次放行之后安静windowMs」。take被拒绝时把清理过的数组写回桶(this.buckets.set(key, arr)),但不 push 新的时间点。语义是「窗口里已经装满了,这次不记账」。为什么不同:两者在记账方式上根本不一样。
throttled只记「上一次放行的时间」这一个数,拒绝时不需要改它;take记的是一串时间点,而它刚刚顺手把过期的时间点过滤掉了,如果不写回去,这次过滤就白做了(下次还得重新过滤)。这是一个性能与简洁之间的取舍。后果(也是这道题真正的考点):
take是三级串联调用的。一旦「全局」那一级拒绝,函数立刻return,后面的「按人」和「按会话」两级根本不会执行。所以一个被全局限流的人,他自己的个人计数不会增长。这意味着:全局压力过去之后,曾经被全局限流的那批人不会「欠账」——他们是干净的。这是好事还是坏事?你可以自己判断。但如果你没意识到这件事,你就无法解释「为什么刚才被拦了,现在立刻就能发」这个现象。 -
请用不超过 15 行代码,写一个函数
withTimeout(promise, ms):在ms毫秒内promise出结果就用它的结果,超时则抛出一个Error("超时")。要求不修改原promise的写法(也就是说,不许要求传入方支持signal)。参考答案
function withTimeout(promise, ms) { let timer; const timeout = new Promise((_, reject) => { timer = setTimeout(() => reject(new Error("超时")), ms); }); return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)); }关键点一:用
Promise.race。它是「谁先出结果就用谁」,而且成功和失败都算「出结果」——这正是我们需要的:如果业务 Promise 先失败,我们也应该立刻把那个失败抛出去,而不是傻等超时。关键点二:
new Promise((_, reject) => ...)。第一个参数用_占位,表示「我不需要 resolve」,只需要 reject。这是一个很常见的写法。关键点三:
.finally(() => clearTimeout(timer))。这是这道题最容易漏的一步。少了它,即使业务 Promise 在 10 毫秒就成功了,那个ms的定时器还会一直挂着——它会让 Node 进程多活ms毫秒。如果ms是 45000,你的程序在打印完结果之后还要挂 45 秒才退出。一个诚实的补充:这个实现只是「你不再等它」,并没有真的取消原来的 Promise。原来的 Promise 还会继续跑、继续占资源。真正的取消需要
AbortController,也就是要求传入方支持signal——这正是这道题假设的「做不到」的情况。你能看出这个实现的局限,才算真的理解了本章第 5.3 节。 -
请写一段能稳定复现的代码,证明「事件循环被同步代码堵住时,定时器会迟到」,并写出你预期的输出。
参考答案
const log = (x) => console.log(x); const t0 = Date.now(); setInterval(() => log(`心跳 ${Date.now() - t0}ms`), 300); setTimeout(() => { log("开始忙 1.6 秒"); const end = Date.now() + 1600; while (Date.now() < end) { /* 占住调用栈 */ } log("忙完了"); }, 700); setTimeout(() => { log("结束"); process.exit(0); }, 3000);预期输出形状(我实测过,数字会有几毫秒浮动):
心跳 307ms 心跳 614ms 开始忙 1.6 秒 忙完了 心跳 2306ms ← 注意这里!本该有 907ms、1207ms、1507ms… 全部消失 心跳 2609ms 心跳 2912ms 结束为什么能「稳定复现」:因为堵住调用栈的
while循环是同步的——它一旦开始,事件循环就完全拿不到控制权,直到它结束。这不是概率事件,是必然结果。三个必须有的细节:(1)
while里要有Date.now(),否则引擎可能把空循环优化掉;(2)最后那个process.exit(0)的定时器是必须的,否则setInterval会让程序永不退出;(3)「心跳」打印里带上时间差,才能看出「迟到」这件事——光看次数看不出来。这道题真正在问的是:你能不能设计出一个「结论必然出现」的实验。本章第 8.4 节讲过「凡是你写不出一条确定输出顺序的示例,就不要把它写进自己的笔记」——这就是那条纪律的具体操作。
-
辨析:
Promise.all和Promise.allSettled的区别是什么?请各举一个 OWL 里的真实场景,说明什么时候必须用后者。参考答案
区别:
Promise.all是「一荣俱荣,一损俱损」——只要有一个失败,整体立刻失败,你拿不到其他成功的结果。Promise.allSettled是「等所有人都交代清楚」——无论成功失败都等齐全,返回每一项的{status, value}或{status, reason}。用
all的场景:「启动时同时读配置、读历史、读记忆」。这三件事任缺一件程序就没法正常工作(比如配置读不到,baseUrl 就是 undefined),所以第一个失败就应该立刻报告出来,没必要等其余两个。用
allSettled的场景:「给群里被 @ 到的五个人各发一条私聊通知」。这五条互相独立,而且一条失败不该影响其余四条。如果用all,第一个人因为「不是好友」发不出去,剩下四个人就都不会收到——这在产品上是不可接受的。第三层辨析(答出来说明你真的懂了):这两个都不是唯一的选择。第三种做法是「用
allSettled拿到全部结果,然后自己决定哪些失败要上报、哪些要忽略」。比如上面那个场景里,「不是好友」应该被忽略(并且从名单里剔除),而「协议端未确认」应该被记进日志。这已经是从「用哪个 API」升级到「设计错误策略」了,正是第八章和第九章要讲的东西。 -
(开放题)本章反复说「Node 的单线程也能扛住并发」,但第 3.5 节又证明了一段
while能让整个机器人哑掉。这两句话矛盾吗?请说清它们各自的前提,并举出一个你自己从没见过的、会让 OWL 哑掉的真实场景。参考答案
不矛盾,因为它们的「并发」指的不是同一件事。「单线程也能扛住并发」的前提是:这些并发的任务大部分时间在等待 I/O(网络、硬盘),CPU 只需要在处理每个任务的头尾各干一点点活。在这个前提下,一次只处理一个任务反而没有浪费,因为等你等的时候我也在等。「
while会哑掉」的前提是:有一段纯计算占住了调用栈,此时没有任何东西可以让出去。换句话说:事件循环的重叠能力,只对「等待型任务」有效。对「计算型任务」它完全无能为力。这两句话合起来才是完整的判断标准:先问「这件事是在等,还是在算」。
几个可能的答案(任选一个并说清为什么):(1)有人发来一条 400 字以内的消息,里面塞了一个能触发灾难性回溯的正则——
decideStatic里的new RegExp(needle, "i").test(clean)如果配置里写了一个糟糕的正则模式,这一行会算上几十秒;(2)cleanReply里的limitToSingleQuestion是一个双重循环穷举(for i×for j),如果模型返回一段很长的文本、句子数很多,这个 O(n²) 的计算会在调用栈上跑很久;(3)磁盘被日志写满之后,fs.appendFileSync每次都可能阻塞很久;(4)JSON 里嵌套了极深的结构,JSON.parse变成一个巨大的递归;(5)memory.json一个用户积累了成千上万条记忆,每次findIndex加slice都在同步地扫一个大数组。这道题的评分标准不在于你举出哪一个,而在于你有没有说清「它为什么是同步的」。比如第(2)个:
cleanReply是普通函数,不是 async,它内部全是同步循环——所以它一旦开始就必然占住调用栈到底。认出这一点,你就把「异步」从一个语法现象升级成了一条判断准则。
自问自答:把知识变成你自己的
这些问题没有标准答案,甚至有些没有答案。请不要在页面上浏览,拿一张纸写下来。写的过程就是思考的过程。
- 我对
await的第一直觉是什么?在读这一章之前,我以为它在做什么?现在这个直觉被换掉了吗,还是只是被压住了? - 「把等待变成登记」这句话,能不能用在我生活里的一件具体事情上?比如等一个回信、等一个结果。我现在的做法是轮询(反复去看),还是登记(做别的事,等它来)?两种做法带给我的感受有什么不同?
- 如果一个程序的行为不能用「从上到下」描述,那我要怎么向别人解释它?我能不能把
bot.js讲给一个完全不懂编程的人听懂? - 我这一章里有没有「照着抄跑通了、但说不清为什么」的代码?具体是哪一段?我打算怎么补上它?
- OWL 只依赖一个
ws。我自己平时解决问题时,是倾向于「找一个现成的工具」,还是「先想清楚我到底缺什么」?这两种倾向分别在什么情况下是对的? - 「让它继续跑」和「让它崩掉」在什么情况下是同一个选择?我心里是不是有一个暗暗的假设——「不崩就是好程序」?
- 第 8.1 节那个「日志慢八小时」的事故里,如果我是当时的排查者,我会在哪个时刻意识到方向错了?我有没有可能查一整天都查不出来?
- 限流保护的其实是三样东西:钱、可用性、账号安全。这三样在我自己的项目里,哪一样最容易被忽略?为什么它最容易被忽略?
- 这一章的所有实验里,哪一个让我最意外?我当时的预期是什么?预期和事实的差距告诉了我什么?
- 如果明天我要自己写一个「一直醒着」的小程序(不限于是聊天机器人),我会写什么?它需要处理几个「当……时」?
小结
这一章说了四件事。
一、脚本和服务是两种东西。脚本跑完就死,服务一直醒着等人敲门。Node.js 让这件事成为可能的方式很朴素:把浏览器里的 V8 引擎搬出来,先剥掉浏览器给它的所有能力,再装上文件、网络、进程。所以「Node 是什么」的准确答案不是一门语言,而是一个运行时——同一种语言,换了一个世界。
二、异步的全部机制在事件循环里。只有一个调用栈,所以同一时刻只有一件事在算;但等待 I/O 的时候调用栈是空的,事件循环就能把别人的回调压进去,于是「等」被重叠起来了。微任务永远排在宏任务前面——这就是 Promise.then 一定早于 setTimeout(f, 0) 的全部原因。反过来,任何一段占住调用栈的同步计算都会让整个程序哑掉,无论你写了多少 async。
三、await 只暂停当前这个函数。它把「剩下的代码」登记成一个回调,然后让出调用栈。所以两个并发的 await sleep(500) 一共只花 500 毫秒。抓住这一点,四个新手坑(忘记 await、在 forEach 里 await、串行等待、try/catch 抓不到未 await 的拒绝)就都变成了同一个问题的四种表现。
四、让程序活着比让它跑起来难得多。配置要分层(环境变量 > 本地文件 > 主配置),密钥永远不进仓库;日志的时间戳必须是你读得懂的那个时间;退出前要把内存里的东西写下来;配置改完要能热重载且不能因为手滑就崩;AI 必须有刹车(冷却、限流、输入长度),也必须有退路(超时、不盲重试、降级到静态回复)。这一条里的每一样,都不是「为了更好」,而是「为了不出事」。
现在回头看你在这一章开头给自己定的那个目标:「能写一个 30 行小脚本,调用 DeepSeek API 并打印回答」。你已经做到了,而且你可能已经发现了——那 30 行其实不是重点。重点是你在写它的过程中,第一次知道每一行为什么在那里:为什么要有 signal,为什么 if (!res.ok) 不能省,为什么 finally 里的 clearTimeout 看起来没用但不能删。
这一章真正交给你的东西,比 30 行代码大一档:你现在能看着一个「一直醒着」的程序,在脑子里看见它体内那条不停转的循环。你能想象出「现在调用栈是空的」「现在有三个回调在排队」「现在有一件事堵在了那里」。这是一副新的眼睛。有了它,第七章那些 systemd 配置、那些「进程活着但没反应」的怪现象,你会觉得它们是有道理的,而不是神秘的。
下一章我们去处理那个你按 Ctrl+C 就会丢掉的东西。你要让它记得住——而且要记得有分寸:记得够久,也不记得太久。
延伸:可以去哪里继续
网站
- Node.js 官方文档(v20)——最权威、最枯燥、也最值得你养成查阅习惯的地方。这一章涉及的每一个内置模块都在里面有完整条目:
fs、http、process、readline、url。什么时候看它:当你不确定某个函数到底返回什么、或者某个选项默认值时。别看教程的二手转述,直接看这里。 - Node.js 官方 Learn 区——官方自己写的入门教程,比文档好读,比博客可靠。里面有专门的「Asynchronous Work」和「Command-line」章节。什么时候看它:这一章读完、想再走一遍同一批概念但换一种讲法时。
- 现代 JavaScript 教程 · 异步章节——中文翻译质量很高。它对 Promise、async/await、微任务队列的讲解比绝大多数中文资料准确,而且有能直接在页面上跑的例子。什么时候看它:现在就值得看,特别是「微任务」那一页。这是我推荐的第一个补课点。
- DeepSeek API 官方文档——你的脚本调用的是它。
messages的精确结构、每个参数的范围与默认值、各种错误码的含义,全在这里。什么时候看它:当你想改temperature、想调max_tokens、或者收到一个你不认识的错误码时。 - npm 官网——搜索和查看任何第三方包。重点看三件事:每周下载量(活跃度)、最后发布时间(还维护吗)、README 里的示例(像不像我需要的)。什么时候看它:在你准备
npm install任何东西之前——先花两分钟看看它是不是还活着。 - MDN · Promise——MDN 的 Promise 和 async 章节是这类概念的「字典级」参考。什么时候看它:当你想确认
Promise.all和allSettled的返回值形状这类细节时。先看 MDN,再问 AI。
值得读的书(三本,够你用很久)
- 《深入浅出 Node.js》(朴灵)——中文世界里讲 Node 最扎实的一本,尤其是事件循环、异步 I/O、内存管理这几章,讲到了 V8 和 libuv 的层面。它有点旧(书里的 Node 版本很早),但机制部分几乎没过时。什么时候读:先把这一章过一遍、手上有了能跑的代码之后再读,直接读会非常吃力。难度:中等偏上,需要你会写 JavaScript 并且用过 Node。
- 《你不知道的 JavaScript》中卷(Kyle Simpson)——专注讲「异步与性能」,把 Promise、生成器、事件循环这几件事拆得非常细,而且它反复在纠正「你以为你懂了」的那些错觉。什么时候读:如果你这一章读完之后,对「微任务为什么排在宏任务前面」还有一点不甘心,就去看它。难度:中等,但它会让你觉得自己之前学的都是皮毛——这是好事。
- 《Node.js 设计模式》(Mario Casciaro 等)——这本不讲语法,讲的是「一个真实的 Node 项目该怎么组织」:模块化、回调与 Promise 的统一、流、可观测性、错误处理策略、优雅退出。什么时候读:等你写完这一章的假机器人、并且想把它真的做成一个能长期运行的东西时。难度:偏高,需要你有一定实践经验,否则会觉得它讲的都是空话。
提醒不要收藏了就算看过。上面六个网站里,这一章只要求你做两件事:第一,把「现代 JavaScript 教程」的异步章节里「微任务」那一页读完(大约二十分钟);第二,把你写的 fake-bot.mjs 真的跑起来,把 10.3 节那三个「故意弄坏它」的实验各做一遍。做完之后再去第八章——你会发现那 30 行脚本原来只是一个开始。