Ways to Robot 从零到云端机器人

第五章 · 让程序住在云端

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 服务,并让它在收到停止信号时优雅退出
  • 用环境变量与配置分层管理密钥与可变配置
  • 自己实现限流、超时、重试与降级这四件保命装置

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 收发 WebSocket1 个包留下了——因为 Node 当时还没有稳定可用的服务端 WebSocket 实现

留下来 ws 的那一行,是整张表里最有信息量的一格:它不是「能省则省」,而是「省不了才留」。WebSocket 服务端需要处理握手、帧解析、掩码、ping/pong 心跳、分片、关闭握手——自己写这些不是在学习,是在重新发明一个已经打磨了十年的轮子。

我之所以在这里花这么多字,是因为初学者最常犯的错误不是写得少,而是引入太多。每一个依赖都是一个你不认识的作者写的、会不定期更新的、某天可能不再维护的代码块,而且它会出现在你的「攻击面」里。第九章会专门讲这件事,现在先给你一条可操作的标准:

技巧引入任何依赖之前,问一句:它替我解决的这个问题,我真的有吗?我遇到的是一台服务器还是十台?是三个用户还是三万个?如果答案是「只有 OWL 和她的几十个朋友」,那么一个 20 行的内置方案,通常比一个 20 个包的框架更值得。

3. 事件循环:单线程为什么能同时干很多事

这是本章的理论核心。前面所有的铺垫都是为了这一节,后面所有的代码都建立在这一节上。所以我允许它慢,也允许我反复换比喻——但每一个比喻之后,我一定会回到真实的机制。

3.1 一个具体的场景:三件「要等」的事

假设你写了一个处理一条消息的函数,它要做三件事:

  1. 从硬盘读一个 JSON 文件(history.json),大概要 2 毫秒;
  2. 向 DeepSeek 发一个请求,大概要 900 毫秒;
  3. 把回复通过 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. 整个脚本本身就是一个宏任务,它开始执行。
  2. "1 同步开始" 立刻打印。
  3. setTimeout(f, 0):把 f 注册进定时器。0 毫秒不等于立刻,它的意思是「尽快,但要排在后面」。f 被放进宏任务队列。
  4. Promise.resolve().then(g):这个 Promise 已经是成功状态,于是 g 被立刻放进微任务队列。
  5. "4 同步结束" 立刻打印。
  6. 当前宏任务(整个脚本)执行完毕。事件循环检查微任务队列:里面有 g,执行 → 打印 "3 Promise.then"。队列空了。
  7. 现在才轮到下一个宏任务: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("终于全部做完了");
        });
      });
    });
  });
});

三层嵌套就已经很难读了,五层就是灾难。更麻烦的是三件事:

  1. 错误处理被复制了五遍。每一层都要写一次 if (err) return handle(err)。你漏掉任何一层,错误就会被无声地吞掉。
  2. 你没法「取消」。写到第三层了才发现不该继续,你没有办法从外面把这串东西掐断。
  3. 你没法「一起等」。如果你有三件互不依赖的事,想「三个都做完了再继续」,用回调写会非常别扭。

这段代码之所以叫「地狱」,不是因为它丑,而是因为它把「顺序」和「缩进」绑死在一起了。你想改一下顺序,就要整块整块地搬家。

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);
}

三步,值得记牢:

  1. 造一个控制器 ctrl,把它的 signal(信号)交给 fetch。「信号」是一个能被通知的对象,fetch 订阅了它。
  2. 设一个定时器,到时间就调用 ctrl.abort()。这会触发那个信号。
  3. 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() 里的 awaitraw 变成一个 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。注意:这就是一个服务了——它不再「跑完就结束」,它在等你。

这段代码里有四处值得注意的东西,它们全都指向第六章:

  1. messages.length = 1:清空数组但保留第一项(那个 system)。用 = 1 而不是 = [],是因为人设不能丢。OWL 的 /重置 命令做的也是这件事,只是它清的是 llm.history 这个 Map 里的一个会话。
  2. messages.push({ role: "assistant", ... }):模型的回答要回填进历史。很多人只 push 用户的话,结果模型完全不记得自己上一句说了什么,对话会变得非常奇怪。
  3. 失败时 messages.pop():把刚才那句用户输入撤掉,否则历史里会留下一条「问了但没有回答」的孤立消息,下一轮模型看到会很困惑。这是「保持数据结构自洽」的一个小例子。
  4. 没有做任何长度控制。这个数组会一直长下去,每问一句就把整包再发一遍——越聊越贵,直到超出上下文窗口直接报错。

第 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 说出任何话。

鉴权的实现分成四步:

  1. 从配置里取 token。如果没配 token,整段跳过——这是为了本地开发方便(token: "" 时不做检查)。所以「安全」在这里是一个显式的配置决定,而不是默认行为。这一点你要带着批判的眼光看:默认不设防是方便,也是风险。OWL 的 config.json 里确实配了一个随机字符串,这是正确做法。
  2. 先看标准的 Authorization 头,用正则 /^Bearer\s+/i 把 Bearer 前缀去掉。i 表示忽略大小写,\s+ 表示一个或多个空白。
  3. 如果头里没有,再去看网址查询参数里的 access_token。new URL(req.url || "/", "http://localhost") 这一步是把一个相对路径(比如 /?access_token=xxx)补成一个完整网址,然后 searchParams.get 取出参数值。那段 try/catch 是因为 new URL 遇到畸形字符串会抛错,而握手阶段抛错会留下一个半开的连接。
  4. 不匹配就记日志、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 行。

逐行读:

  1. return new Promise((resolve) => { ... }):把「将来某刻才会有的结果」包装成一个立刻返回的 Promise。调用者写 await client.call("send_group_msg", ...),然后就可以「等」了。而这里的 resolve 被存了起来,谁拿着它,谁就能在将来某一刻决定这个 Promise 什么时候成功。
  2. if (this.ws.readyState !== this.ws.OPEN) return resolve(null);:连接已经断了就别发了,直接当场结束,返回 null。注意这里是 return resolve(...),一行做完「结束这个 Promise」和「从这个函数返回」两件事。这是一个很常见的写法。
  3. 造编号:const echo = `echo_${++seq}_${Date.now()}`;。这里用的是模板字符串(第二章讲过的反引号写法),把两段内容拼在一起。++seq 是「先加一再取用」,所以第一个编号是 echo_1_…。后面接一个时间戳,是为了「即使进程重启过、计数器从头开始,也不会和上一次的编号撞上」。唯一标识这种东西,通常都是「一个计数器 + 一个时间戳」的组合。
  4. const timer = setTimeout(...):这是整段代码的灵魂。给这次调用设一个闹钟。到点了还没收到回话,就把登记删掉、记一条日志、然后 resolve(null) 让 Promise 成功结束(值是 null,表示「没办成」)。如果不设这个闹钟,一次丢包就会让这个 Promise 永远 pending、pending 这个 Map 永远多一条记录、调用者的 await 永远不返回。这就是内存泄漏和服务卡死的开始。
  5. pending.set(echo, { resolve, timer });:把「将来怎么结束这次等待」登记下来。注意存的是两样东西:怎么把结果交出去(resolve),和那个闹钟的编号(timer,将来要取消它)。
  6. 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;
}

四步,正好和发送时对称:

  1. data.echo && pending.has(data.echo):这条回来的消息带着编号,而且这个编号确实是我们登记的。两个条件缺一不可——只检查 data.echo 存在的话,一个伪造的 echo 会让后面的 pending.get 拿到 undefined,然后解构就崩了。
  2. clearTimeout(timer); pending.delete(data.echo);:撤掉闹钟、销掉登记。顺序很重要:先取消闹钟,再删登记。如果反过来,理论上存在一个「闹钟在两者之间触发」的窗口(虽然在一个 JS 线程里同一段代码不会被中途打断,但养成「先撤后删」的习惯没有坏处)。这两个动作合起来叫「清理」,是写异步代码时最容易漏掉的部分。
  3. resolve(...):把结果交给那个一直在等的调用者。data.status === "ok" || data.retcode === 0 是在兼容 OneBot 的两种成功表示法;data.data ?? {} 表示「有 data 就用 data,没有就给一个空对象」。三元的两个分支分别是「成功的数据」和「null 表示失败」。
  4. 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 焊死在了代码里,于是产生三个无法解决的问题:

  1. 它会被提交到 Git。第三章讲过,Git 记录的是「每一次改动」,你后来把它删掉了也没用——历史里还在,任何能看这个仓库的人都能看到。公开仓库几分钟内就会被爬虫扫走。
  2. 你没法在本地和服务器上用不同的 Key。也许你想本地用一个便宜的额度、服务器用正式的那个;也许你想给测试环境一把只读的 Key。硬编码之后,改 Key 就等于改代码、重新提交、重新部署。
  3. 你没法把代码给别人。你想让朋友帮你看看,只能把整个文件夹发过去——于是把 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。

逐行:

  1. 第一层:if (process.env.DSH_QQBOT_LLM_KEY) return ...。process.env 是「这个进程能看到的全部环境变量」。第一章讲过环境变量是什么——它由启动进程的人设置,程序只能读、不能改别人的。一旦这个变量存在,立刻返回,后面的两层根本不会被看到。
  2. 第二层:拼出 llm.local.json 的路径,检查文件存不存在,存在就读出来、解析、取 apiKey 字段。注意 fs.existsSync 这个检查是必要的——不检查就直接读,文件不存在会抛 ENOENT,虽然被 catch 接住了,但会打一条误导人的日志(「解析失败」,其实是「文件不存在」)。
  3. 那段 try/catch:如果这个 JSON 写坏了(少一个逗号、多了个尾巴),JSON.parse 会抛错。这里的选择是记一条日志然后继续往下走,而不是崩掉。为什么不崩?因为这个文件是人在手动编辑的,写坏是常事;而一个「Key 文件写坏了」不应该让机器人整体无法启动——也许第三层里还有一个能用的。「能让程序继续跑的失败,就不要让它崩」,这是服务化思维的核心之一。
  4. 第三层:this.getConfig()?.llm?.apiKey。注意这里调的是 getConfig()(一个函数)而不是直接用某个变量。为什么要绕一下?因为配置支持热重载——config.json 改了之后,那个变量会被整个替换掉。如果这里缓存的是旧对象,你就永远读到旧配置了。这是一个非常容易被忽略、但在热重载场景下会立刻暴露的设计。
  5. 最后 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);
});

我要明确地说:不要这么写。特别是不要写成「打个日志就继续跑」。理由有三个:

  1. 这句话背后的假定是错的。「继续跑」意味着你相信出错之后程序的状态还是好的。但你不知道 err 是在哪一层冒出来的——也许是在写一个文件的中间,也许是在修改一个重要对象的中途。你留下的是一个「半完成」的状态。
  2. 你会把崩溃变成一个更隐蔽的问题。原本进程退出,你立刻知道出事了;现在它带着坏状态继续活着,可能开始发出错误的消息、重复处理同一条事件、或者慢慢吃掉内存。这种故障比崩溃难查十倍。
  3. 它会掩盖真正的 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 用这个机会做了四件事,顺序不能乱:

  1. llm.saveHistory():把内存里的对话历史写到硬盘。这一行就是「重启不丢上下文」的全部秘密。如果这一行不存在,this.history 那个 Map 会随着进程一起消失——用户的对话就没了。而上一次保存是什么时候?看 llm.mjs 的 chat():每成功回复一次就 this.saveHistory()。所以其实每轮都在存。那为什么还要在这里再存一次?因为可能有人正在聊天、或者有一些改动还没落盘。「经常存」和「退出时再存一次」是两道不同的保险。
  2. wss.close():不再接受新的 WebSocket 连接。
  3. server.close(() => process.exit(0)):停止接受新的 HTTP 请求,等现有的请求都结束之后再执行回调退出。exit(0) 里的 0 表示「正常退出」,非 0 表示「因为出错退出」——systemd 会看这个数字决定要不要算作失败。
  4. 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 毫秒内,忽略所有后续触发」。

逐行看这个「忽略」是怎么做到的:

  1. let reloading = false;:一个模块级的状态标记。
  2. if (reloading) return;:如果已经有一个「待执行的重载」排着队,就直接走人。注意这里不是在「去抖重新计时」,而是「丢弃新的触发」——这样一个快速连发的一串事件只会产生一次重载。
  3. reloading = true;:占住标记。
  4. setTimeout(..., 200):等 200 毫秒,让文件写完。这是关键——事件触发的那一刻,文件可能还只写了一半。延迟 200 毫秒几乎肯定能等到写操作完成。
  5. 回调里第一件事是 reloading = false,而且放在 try 外面。这个位置很讲究:如果放在 try 里面,而 loadConfig() 抛错了,标记就永远是 true——热重载从此彻底失效,你改一百次配置也不会生效,而且完全不知道为什么。「状态标记的复位要放在一定会执行到的地方」,这是一个非常贵的教训。
  6. cfg = loadConfig():把整个配置对象换掉。这就是第 7 章说的「为什么 getConfig 必须是函数」——如果别的地方缓存了旧的 cfg,它们看不到新配置。
  7. 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 秒 ────────]
       记录 记录 记录 记录 记录          现在
       ↑ 超出窗口的记录会被丢掉

逐行读:

  1. const win = 60000;:窗口长度一分钟。它是写死的,不是配置项——因为配置项的名字叫 userPerMinute(每分钟),窗口长度已经在这个名字里了。
  2. const take = (key, limit) => {...}:定义一个内部小函数,参数是「哪个桶」和「上限多少」。注意它是定义在 #checkLimits 里面的,所以能直接用外面的 now 和 win。这是第二章讲过的闭包——内部函数可以访问外部函数的变量。它之所以找得到 now,是因为 JavaScript 查一个变量时会先看自己,再看外层函数,一层层往外找,直到全局;这条查找路径叫作用域链。闭包就是「函数记住了它出生时所在的那条链」。这样每次调用都不用把 now 当参数传来传去。
  3. if (!limit || limit <= 0) return false;:没配置上限,或者配了 0 或负数,就视为不限流。这是一个「配置缺省」的处理:不写配置也能跑。
  4. (this.buckets.get(key) ?? []).filter((t) => now - t < win):这是「滑动」的那一步。取出这个桶里所有的时间点,把「距离现在已经超过 60 秒」的那些过滤掉。剩下的就是「这一分钟内的请求」。filter 是第二章数组那一节讲过的。
  5. if (arr.length >= limit) { this.buckets.set(key, arr); return true; }:已经满了。注意:即使被拒绝,也把「已清理过的数组」写回去——这叫「顺手做清理」,不然被刷屏的桶会一直带着一堆过期数据。这是一个很细但很对的写法。
  6. arr.push(now); this.buckets.set(key, arr); return false;:没满,把这次的时刻记进去,放行。
  7. 三级检查的顺序是从大到小:先全局、再按人、最后按会话。这个顺序有意义——如果全局已经满了,你不需要再去检查个人额度,早退出一次省一点。但也要注意:一旦某一级拒绝,后面的都不会记录。这意味着一个被全局限流的人,他的个人计数不会增长——这是刻意的还是意外的?你可以在笔记本上想一下这个问题。

最后回答那个最重要的问题:为什么 AI 机器人必须有刹车?

三个理由,每一个都是真实的风险:

  1. 钱。每一次 AI 调用都按 token 计费。OWL 的 limits 配的是「单人每分钟 6 次、单群 10 次、全局 60 次」。全局上限 60 意味着最坏情况下每分钟 60 次调用、一小时 3600 次。如果没有这个上限,一个深夜无聊的人可以让你一晚上花掉几百块。
  2. 可用性。「限流」保护的不仅是钱包,还有服务本身。如果所有请求都挤进来,每一条都要等 45 秒超时,那么服务器的内存会堆积大量待处理的请求——最后 OOM 被杀。术语库里的「容错」和「降级」就是为了这种场景。
  3. 账号安全。第一章就说过,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,每一种失败原因对应一句给人看的话,最后有一个兜底。这条链上有三个值得学的设计:

  1. 它把机器语言翻译成了人的语言。用户不会看到「HTTP 402 Insufficient Balance」,而是看到「AI 账户余额不足了,去充点钱吧~」。注意那句「去充点钱吧」是对管理员说的——而 OWL 的用户是高中生,他们帮不上忙。这是一个设计上的不完美,你在自己的项目里可以想得更好:也许该给用户一句「我现在有点累」,同时单独给你自己发一条管理员通知。
  2. return null 表示「我不给提示,让调用方处理」。too-long 和 limited: 就是这种——因为调用方知道更多信息(比如配置里的上限数字),能给出更具体的提示。「用一个特殊返回值表示『我处理不了,交给上面』」是一种常见的分工方式。
  3. 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(握手请求)
收到一条 JSONws.on("message")raw(Buffer)
那是一条 echo 回复pending.get(...).resolve那个等了很久的 Promise 的结果
那是一条聊天消息onEventevent 对象(谁、在哪个群、说了什么)
配置被改了fs.watch 的回调无(但 cfg 会被替换)
收到 SIGTERMshutdown信号名("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 对照着看。顺序完全一致:

  1. 指令最先(/重置、/状态),命中就 return,绝不往下走。
  2. 静态规则其次,命中就 return。
  3. 冷却检查在 AI 之前——因为冷却的目的是省钱,而不是省静态回复。这个位置很重要:如果你把冷却放在最前面,那么「你好」这种不需花钱的回复也会被拦住。
  4. 调用真正的「AI」,并处理两种结果。
  5. 失败时 messages.pop():还记得第 5 节讲过的吗?保持历史自洽。
  6. 裁剪历史: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 轮的多少倍?这个实验没有标准答案,但它能让你第一次真正感受到「上下文」和「成本」之间那条直接的线。

自查:你是不是真的懂了

先自己回答,再展开参考答案。凡是「输出顺序」类的题目,请务必先用笔在纸上写一遍,再去看答案,最后一定要打开电脑跑一次。凡是你只能靠「感觉」回答的,说明还要再读一遍。

  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 毫秒只是「延时为零」,它仍然是一个宏任务,必须等所有微任务跑完。

  2. 下面这段代码的输出顺序是什么?请写出完整顺序,并指出哪一项的顺序是不保证的。
    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 才会稳定地跑在前面。

    这道题真正在考的是:你有没有区分「我实测到的」和「标准保证的」。一个只会背输出顺序的人会栽在第二台机器上。

  3. 为什么 await 不会阻塞整个程序?请用「调用栈」「任务队列」「事件循环」这三个词说清机制。再说一个反例:什么情况下 await 反而会让整个机器人哑掉?
    参考答案

    机制:await 遇到一个还没出结果的 Promise 时,做的事情是「把当前 async 函数后面的代码登记成一个回调,然后让出调用栈」。调用栈一空,事件循环就能从任务队列里取别的回调来执行。所以被暂停的只有「当前这个 async 函数」,整个程序照常运行。这一点我在本章第 4 节用两个并发的 await sleep(500) 验证过:总耗时 501 毫秒,不是 1000 毫秒。

    反例:如果 await 后面那个 Promise 的「解决时机」依赖于一段同步的耗时计算,那么调用栈实际上还是被占住了。比如你在 async 函数里写了一行 await Promise.resolve(重活())——括号里的 重活() 是同步执行完才交给 Promise.resolve 的,那一段计算照样会堵死事件循环。另一个更常见的反例是:忘记 await 造成的并发洪水——它不是哑掉,而是被淹没。

    为什么这个反例重要:它说明「异步」不是一种写在函数上的属性,而是「你有没有把等待交出去」。一个函数写着 async,里面全是同步计算,那它对事件循环毫无贡献。

  4. 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 未就绪 / 被冷却 / 无规则命中))。这属于第七章「可观测性」的内容,但你现在就应该能看出来。

  5. 你在 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 个会话」这句话,所以和原因三可以区分开。

  6. 请写出 throttled(key, windowMs) 和 #checkLimits 里的 take(key, limit) 在被拒绝时分别做了什么。为什么它们的行为不同?这个不同带来了什么后果?
    参考答案

    throttled 被拒绝时什么都不做——直接 return true,lastReplyAt 里的时间保持旧值。语义是「一次放行之后安静 windowMs」。

    take 被拒绝时把清理过的数组写回桶(this.buckets.set(key, arr)),但不 push 新的时间点。语义是「窗口里已经装满了,这次不记账」。

    为什么不同:两者在记账方式上根本不一样。throttled 只记「上一次放行的时间」这一个数,拒绝时不需要改它;take 记的是一串时间点,而它刚刚顺手把过期的时间点过滤掉了,如果不写回去,这次过滤就白做了(下次还得重新过滤)。这是一个性能与简洁之间的取舍。

    后果(也是这道题真正的考点):take 是三级串联调用的。一旦「全局」那一级拒绝,函数立刻 return,后面的「按人」和「按会话」两级根本不会执行。所以一个被全局限流的人,他自己的个人计数不会增长。这意味着:全局压力过去之后,曾经被全局限流的那批人不会「欠账」——他们是干净的。这是好事还是坏事?你可以自己判断。但如果你没意识到这件事,你就无法解释「为什么刚才被拦了,现在立刻就能发」这个现象。

  7. 请用不超过 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 节。

  8. 请写一段能稳定复现的代码,证明「事件循环被同步代码堵住时,定时器会迟到」,并写出你预期的输出。
    参考答案
    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 节讲过「凡是你写不出一条确定输出顺序的示例,就不要把它写进自己的笔记」——这就是那条纪律的具体操作。

  9. 辨析:Promise.all 和 Promise.allSettled 的区别是什么?请各举一个 OWL 里的真实场景,说明什么时候必须用后者。
    参考答案

    区别:Promise.all 是「一荣俱荣,一损俱损」——只要有一个失败,整体立刻失败,你拿不到其他成功的结果。Promise.allSettled 是「等所有人都交代清楚」——无论成功失败都等齐全,返回每一项的 {status, value} 或 {status, reason}。

    用 all 的场景:「启动时同时读配置、读历史、读记忆」。这三件事任缺一件程序就没法正常工作(比如配置读不到,baseUrl 就是 undefined),所以第一个失败就应该立刻报告出来,没必要等其余两个。

    用 allSettled 的场景:「给群里被 @ 到的五个人各发一条私聊通知」。这五条互相独立,而且一条失败不该影响其余四条。如果用 all,第一个人因为「不是好友」发不出去,剩下四个人就都不会收到——这在产品上是不可接受的。

    第三层辨析(答出来说明你真的懂了):这两个都不是唯一的选择。第三种做法是「用 allSettled 拿到全部结果,然后自己决定哪些失败要上报、哪些要忽略」。比如上面那个场景里,「不是好友」应该被忽略(并且从名单里剔除),而「协议端未确认」应该被记进日志。这已经是从「用哪个 API」升级到「设计错误策略」了,正是第八章和第九章要讲的东西。

  10. (开放题)本章反复说「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 行脚本原来只是一个开始。