第四章 · 消息如何跨越一千公里
HTTP、JSON、API 与 WebSocket:两个程序怎么对话
你已经知道「OWL 靠 API 调用 DeepSeek」这句话了。但你知道那个 API 到底长什么样吗?它其实就是一个网址、一个方法、一段文本——加上一个只有你和服务器知道的密钥。这一章要做的,是把那句话拆成你能亲眼看见、亲手发出去的东西。
0. 先看地图:这一章要带你去哪
读完这一章,你应该能回答
- 一条 QQ 消息从别人的手机出发,到 OWL 吐出一句回复,中间经过了哪几个程序、哪几种协议、哪几次「谁连谁」?
- HTTP 请求由哪四部分组成、响应由哪三部分组成?对着一段原始报文,你能逐行说出每行是干什么的吗?
- 看到 401、404、429、502,你能立刻判断「这是谁的问题」,以及第一步该去查什么吗?
- HTTP 的「无状态」是什么意思?为什么它逼出了 Cookie、Token 和「会话」这一整套东西?
- JSON 和 JavaScript 对象到底差在哪?为什么多一个逗号、多一个看不见的字符,就能让机器人整个崩掉?
- 为什么 OWL 只监听
127.0.0.1:3001,NapCat 在另一台「机器」(容器)里却还能连上它? - 「反向」WebSocket 的反向,反在哪?为什么这个设计让 OWL 不需要公网地址、也不需要开放任何端口?
学完这一章,你会做这些事
- 用
curl亲手调通任何一个 REST 接口,并读懂它的响应 - 读懂一个 HTTP 请求与响应的每个部分,用状态码判断问题出在谁身上
- 用 Node 写出最小的 WebSocket 服务端与客户端
- 读懂 OneBot v11 的事件 JSON,并说清
echo与 token 鉴权在防什么 - 按分层思路排查「连不上 / 收不到 / 回不去」这三类故障
需要的前置:第一章(计算机常识与 Linux)里的「进程」和「端口」;第二章(JavaScript 基础)里的「对象」与「函数」(这一章会大量用到它,但不会考你语法);以及你在序章里看过的那张架构图。
这一章会为后面铺路:第五章(Node.js 与异步)要讲的 await、事件循环、fetch,全部建立在这一章的「一次请求—一次响应」之上;第六章(数据库与记忆)要解决的正是「HTTP 记不住事」这个麻烦;第七章(部署与运维)会亲手填上本章留下的 Docker 网络模式那个坑;第八章(大模型应用)会把本章那个孤零零的 API 调用,扩成一整套提示词工程与成本控制。
回头看看:序章 2.2 节「一句话的一生」里,我用了九步把这条路的轮廓画给你,并且说「看不懂是正常的」。现在请你回去读一遍那九步——你会发现自己已经认识里面一半的词了。这一章要做的事,就是把那九步放慢,一步一格地走完。
1. 一条消息的旅程:先把整条路走一遍
场景:夜里十一点半,一个高三学生在 QQ 里给 OWL 打了一行字——「我最近什么都学不进去」——然后按了发送。
1.1 从按下发送,到 OWL 看见这段话
第 1 步,消息先到腾讯的服务器。腾讯不会把消息直接送给你的程序——在它眼里,你的程序什么都不是。真正被腾讯当作「一个登录中的 QQ 客户端」的,是跑在云服务器上的 NapCat。NapCat 的角色叫协议端(第 10 节展开):它替 OWL 完成「登录、维持在线、收发消息」,并把这些事翻译成程序能读的文本。
第 2 步,翻译成 JSON。NapCat 把发送者、群号、时间、消息内容整理成一段结构化的文本:
{
"post_type": "message",
"message_type": "private",
"user_id": 10001,
"message_id": 774321,
"self_id": 1876148307,
"sender": { "nickname": "小舟", "sex": "unknown", "age": 18 },
"message": [
{ "type": "text", "data": { "text": "我最近什么都学不进去" } }
]
}
▲ NapCat 上报给 OWL 的事件,节选。注意三件事就够了:它是一段有花括号的文本;里面有「键」和「值」;它明确写了「谁发的、发在哪、发了什么」。这种格式叫 JSON(JavaScript Object Notation,JavaScript 对象表示法),第 7 节整节讲它。
第 3 步,谁连谁。这段 JSON 由 NapCat 主动推给 OWL,走的是一条 WebSocket(全双工通信协议)长连接,地址是 ws://127.0.0.1:3001。「127.0.0.1」说明这条连接根本没离开这台服务器(第 3 节);「WebSocket 长连接」说明它建立之后就一直开着、双向可用,而不是「问一句答一句」(第 9 节)。至于「为什么是 NapCat 连 OWL、而不是反过来」,是本章最漂亮的一个设计点,第 9.4 节讲透。
第 4 步,OWL 醒来做判断。它先把这段 JSON 「翻译」回自己内部的数据结构——这叫解析——然后依次问自己:这是不是一条消息事件(也可能是「机器人上线了」这类系统通知)?去掉 @ 之后是不是 /ping 这样的指令?有没有达到「交给 AI」的条件?这里有一件事现在就值得记住:OWL 对收到的每一段 JSON 都先做一次「解析失败就算了」的处理,而不是假设它一定合法。这是第 7 节要讲的健壮性。
那三个判断长什么样?其实就是第二章(JavaScript 基础)里你写过的那种代码——if (event.post_type !== "message") return;,或者 if (clean === "/ping") { ... }。协议负责把消息送到手上,判断「该怎么回应」用的却是最普通的编程语句。这也是为什么本站把第二章排在第四章前面。
1.2 一次请求,和一次响应
第 5 步,OWL 向一个网址发一段 JSON。决定交给 AI 之后,它要做的事只有一件:发一段 JSON 出去,拿另一段 JSON 回来。发出去的东西,形状是这样的(第 5 节整节拆它):
POST /v1/chat/completions HTTP/1.1
Host: api.deepseek.com
Content-Type: application/json
Authorization: Bearer sk-xxxxxxxxxxxxxxxx
{"model":"deepseek-flash","messages":[{"role":"user","content":"我最近什么都学不进去"}],...}
▲ 第一行是「方法 + 路径 + 协议版本」;中间几行是「头」;空行之后是「正文」。这里的模型名是官方文档当前推荐的 deepseek-flash(为什么不是 OWL 项目里那个名字,见 5.8 节的时效性提示)。这里还出现了三个陌生人:HTTP(第 5 节)、API(第 8 节)、Authorization(第 8 节)。
第 6 步,千里之外开始计算。这段 JSON 出了网卡、穿过公网,落到 DeepSeek 机房的某台机器上。GPU 上一个字一个字地「猜」,大约一秒后把结果写成另一段 JSON 送回来。这一秒里网络只占几十到几百毫秒,绝大部分时间花在模型生成上——第 12 节会教你怎么用一条命令把这两者分开。
第 7 步,回程,以及一个编号。OWL 拿到回复后先检查有没有危险信号,若踩到就再补发一条写死的求助渠道(序章提过的「代码兜底」)。然后它要在同一条连接上写一段 JSON 发回去,让 NapCat 去执行「发送私聊消息」。但这里立刻有个问题:OWL 怎么知道 NapCat 到底发成功了没有?这条连接上 NapCat 也在源源不断推消息,两边的数据混在一条管道里。所以 OWL 在每次请求里塞一个自己生成的编号字段 echo,NapCat 处理完把它原样带回来,OWL 靠它配对。这个机制的完整实现,是第 10.3 节的主角。
第 8 步,消息回到那个人的手机上。NapCat 把回复交给腾讯,腾讯推到学生手机上。同时 OWL 把这一轮对话写进 history.json,把「最近学不进去」抽成一条记忆写进 memory.json。注意:这一步和网络协议已经没关系了。协议只负责把字送过去,不负责记住它——那是第六章的事。(顺带记两个真实数字:history.json 里每个会话最多留 maxTurns 轮,实际配置是 8;一份会话记录超过 ttlMinutes 就没用了,实际配置是 180 分钟——也就是说,隔了三小时再找她,上一个话头她自己放下了。)
1.3 把这条路压成一张图
【别人的手机】
│ ① 发消息(腾讯自己的协议,我们不管)
▼
腾讯 QQ 服务器 ───────────────► 公网
│
▼
┌──────────── 阿里云服务器 · 华东2(上海)─────────────┐
│ [NapCat 容器] [OWL 本体 · Node.js 20]│
│ 协议端 监听 127.0.0.1:3001 │
│ │ ▲ │ │
│ │ ② JSON 事件 │ │ │
│ └─── WebSocket 长连接 ────────────┘ │ ④HTTPS│
│ (③ NapCat 主动连过来) ▼ │
└──────────────────────────────── api.deepseek.com ────┘
(GPU,约 1 秒)
⑤ JSON 响应(choices / usage)
│
▼
OWL 解析 → 检查安全 → 回程
▲ 序章那张图的细版。多出来的是:具体端口、连接方向、以及「请求里带了什么、响应里带了什么」。
技巧读这一章时,请把这张图放在手边。每学完一节,回到图上找它的位置,用一句话说出「它负责把消息从哪送到哪」。能对着图讲完一遍,比背下十个定义有用得多。
想一想上面这条路里,有几步是「不可靠」的?哪几步一旦失败,你的机器人会「安静地什么也没发生」,而你从 QQ 界面上完全看不出来?先记下你的猜测,学完第 12 节再回来核对。
2. 为什么要分层:一条消息被拆成了好几层工作
刚才那条路,如果只用一个程序去实现,会变成一场灾难:这个程序要同时操心「网线里的电压」「数据包走哪条路」「丢了怎么办」「对面发的文本是什么意思」「这句话该不该回」。
网络世界的解决办法和人类社会一样:分工,然后约定接口。这就是网络分层。
2.1 四层,每层只解决一个问题
不用背 OSI 的七层模型(那是另一套说法)。你只要记住下面这四层,它们足够解释 OWL 的全部行为:
| 层 | 它只负责回答一个问题 | 在 OWL 里的样子 |
|---|---|---|
| 物理层 | 0 和 1 怎么变成电信号、光、无线电 | 你完全不用管。机房里的网线 |
| IP 层 | 这个包要送去哪台机器 | api.deepseek.com 解析出来的那个 IP |
| TCP 层 | 怎么保证这些包不丢、不乱、拼成完整的一段 | OWL 与 DeepSeek 之间那条「连接」 |
| 应用层 | 这段字节到底是什么意思 | HTTP、WebSocket、OneBot |
关键的理解在这里:每一层只跟对面的同一层对话,并且只为上一层提供服务。你的 HTTP 请求根本不知道自己在电缆里是电压还是光;物理层也完全不知道自己在搬运的是「我最近什么都学不进去」。这就是抽象在工程里最朴素的含义:把复杂性关在盒子里,只把接口露出来。
2.2 分层的三个实际价值
第一,换掉一层,其他层不用动。你把家里的 WiFi 换成手机热点,浏览器里的 HTTPS 请求一点没变;OWL 从 DeepSeek 换成智谱,只改配置里的两行——因为「HTTP + JSON」这套格式没变。这不是巧合,这是分层刻意买来的自由。
第二,排障可以一层一层问。一个「连不上」,会被拆成四个可以独立回答的小问题。这是本章最实用的一段,下面写成口诀。
2.3 排障口诀:四个问题,从下往上问
第一问:能 ping 通吗?——「那台机器活着吗、我找得到路吗?」这一步只测到 IP 层。通不了,问题在 DNS、路由、或者对方整个机房。
第二问:端口开着吗?——「那个程序在不在门口等人?」这一步测到 TCP 层。ping 通但端口连不上,说明机器活着,但服务没起、或者被防火墙挡了。
第三问:应用回什么?——「它听懂我的请求了吗?」有响应就说明网络全通,问题在「你发的格式」或「它的规则」。
第四问:业务逻辑对不对?——「它回的东西,是不是我想要的?」这是唯一一步与网络无关的排查。
这四问的顺序不能颠倒。新手最常见的错误是从第四问开始:机器人不回话,立刻去翻自己的代码、改提示词、重装依赖,折腾两小时;而真相是安全组没放行端口,或者 API Key 里多了一个空格。
警告从下往上问的实际收益是省时间。第四问的排查空间是无限大的(代码可以有一万种写法),第一问的排查空间只有两三个可能性。先做可能性少的判断,是做工程和凭感觉瞎试的分水岭。
2.4 一个真实的对照
OWL 的真实场景(见 qq-bot/deploy/KNOWN-ISSUE-掉线.md):某天早上健康检查报告「QQ 登录已失效」,但进程在跑、端口在监听、API 也正常。用四问一套:能 ping 通吗?通。端口开着吗?开着。应用回什么?NapCat 说「账号当前登录已失效」。业务逻辑对不对?——业务逻辑根本没错,是腾讯把登录踢了。
答案落在第三问,而且是「对方的规则」而不是「你的代码」。如果你直接从第四问入手,你会去读 bot.js 的每一行——那里什么都没有。
想一想如果你只有一条命令行工具可用,你会怎么实现「第一问」和「第二问」?提示:第一章里你学过好几个能看网络状态的命令,其中有一个专门用来「连一下看看通不通」。先自己想,再看第 12 节的清单。
3. IP 与端口:楼号,和房间号
现在往下走一层,把「送到哪台机器、哪个程序」这件事说清楚。
3.1 一个很多人都有的误解
先说一个必须纠正的直觉:「服务器」不是一台特别的机器。OWL 住的那台阿里云服务器,硬件上和你家的旧笔记本没有本质区别——它就是一台普通电脑,只不过放在机房里、插着稳定的电和网、24 小时开着。
3.2 楼号 + 房间号
比喻是这样:一台机器是一栋楼,IP 地址是楼号,端口是房间号。但立刻回到真实机制,因为比喻有一个地方会骗你:楼是恒定的,房间是可以随时换主人的。端口不属于某个程序,它只是操作系统手上的一个编号,谁先来申请谁先拿到。OWL 关掉,3001 号房间就空了。你在第七章会看到 systemd 怎么保证「总是同一个程序、总是在同一个房间里」。
常用端口有一些默认约定,不是强制规定,只是全世界都习惯了:
| 端口 | 通常是谁 | 你会在哪里遇到 |
|---|---|---|
| 22 | SSH | 你远程登录那台阿里云服务器 |
| 443 | HTTPS | 所有带小锁的网站,包括 api.deepseek.com |
| 3001 | OWL 的 WebSocket 服务端 | NapCat 连过来的那个端口 |
| 6099 | NapCat 的 WebUI | 你扫码登录 QQ 的那个网页面板 |
3.3 127.0.0.1 和 localhost 究竟是什么
127.0.0.1 是一个特殊的 IP 地址,叫回环地址,含义是:「我自己」。数据包一旦被发往它,操作系统根本不会把包交给网卡,它在机器内部直接转身送回去。所以 127.0.0.1 永远指向你正在用的这台机器本身——在你的笔记本上它是你的笔记本,在阿里云服务器上它是那台服务器。
localhost 是给人类用的名字,通常映射到 127.0.0.1。两者在绝大多数情况下等价,但有一点要知道:localhost 是一个名字,需要经过一次解析(可能查 hosts 文件,也可能查 DNS),而 127.0.0.1 是写死的地址。所以配置里优先写 127.0.0.1——少一个环节,少一种出错方式。OWL 的配置里写的正是它。
3.4 内网、公网、NAT
公网地址全世界唯一,任何一台连着互联网的机器都能直接找到它。内网地址只在一个小范围里有效,比如家里的 192.168.1.x、云服务器内部的 172.16.x.x。同一个内网地址可以在千千万万个家庭里同时存在,互不冲突,因为它们的消息根本出不了各自的局域网。
那家里的电脑怎么上网?靠 NAT(Network Address Translation,网络地址转换):你家路由器对外有一个公网地址,对内给每台设备一个内网地址。你发出去的包,路由器把「来源地址」换写成自己,记一笔账,等回包来了再按账本换回去。
这件事有一个非常重要的后果,请现在就记住:NAT 让「从外面主动连进来」变得困难。外面的机器看不到你家的 192.168.1.5,它只看到路由器;路由器没有账本记录时,不知道该把这个陌生的包转给谁,于是直接丢掉。第 9.4 节的「反向连接」就是靠这一条才显得漂亮的。
3.5 为什么 OWL 只听 127.0.0.1 是安全的
把这几件事合起来,回答一个很多人没想清楚的问题。OWL 的启动代码里有这么一行(bot.js 里的真实配置):
cfg.server ??= { host: "127.0.0.1", port: 3001 };
// ...
server.listen(cfg.server.port, cfg.server.host, () => { /* 打印启动日志 */ });
▲ 它明确指定了「监听哪个地址」。这一个参数,决定了这台服务器上有没有第二个东西能连上它。
改一个字会有什么后果?如果写成 0.0.0.0,含义就变成「监听本机所有网卡」——包括那张连着公网的网卡。于是全世界任何一个扫描器,只要扫到这个 IP 的 3001 端口,就能连上你的机器人服务端,直接往里推伪造的 QQ 消息。而写 127.0.0.1,意味着这个服务只在回环接口上存在:外网的包即使打到了这台服务器的 3001 端口,也不会被交给它——因为它从来没在公网网卡上开过门。
要点「只监听 127.0.0.1」是自写服务最省事、也最强的一道安全措施:它不依赖防火墙、不依赖密码、不依赖任何后续配置。你只要不监听,就没有攻击面。这也解释了 OWL 的安全策略为什么是「防火墙只放行 22(SSH)」——真正的第一道门,是 listen 这行参数。
3.6 NapCat 在容器里,凭什么连得上?
这里有一个看起来很矛盾的地方,必须解释清楚,否则你以后一定会被它绊住。NapCat 跑在一个 Docker 容器里,容器是「隔离出来的一小块环境」,它有自己的网络空间。那么容器里的 127.0.0.1,指的是容器自己,还是宿主服务器?
默认情况下指的是容器自己。所以在默认网络模式下,容器里的程序去连 127.0.0.1:3001,它会敲到容器内部那间空房间——这正是初学者最常见的「明明两个程序都在跑,就是连不上」。
OWL 的解决办法在 qq-bot/deploy/docker-compose.yml 里,就一行:
napcat:
image: mlikiowa/napcat-docker:latest
container_name: napcat
network_mode: host # ← 关键的一行
environment:
- NAPCAT_UID=${NAPCAT_UID:-0}
- NAPCAT_GID=${NAPCAT_GID:-0}
▲ network_mode: host 让容器不再拥有自己独立的网络空间,而是直接使用宿主机的网络。
效果是:容器里的 127.0.0.1 变成了宿主机的 127.0.0.1。NapCat 连 127.0.0.1:3001,敲的正是 OWL 那间房间。两个程序隔着容器边界,却在同一个回环地址上见面了。
想一想如果 OWL 也放进容器,和 NapCat 放在同一个自定义 Docker 网络里,那么 OWL 应该监听哪个地址?127.0.0.1 还行吗?NapCat 又该连什么地址?先自己推一遍,第七章会给你答案。
4. TCP:那三件被反复承诺的事
IP 层只承诺一件事:尽力把包送到。它不保证送到、不保证顺序、不保证完整。往上补这个窟窿的,就是 TCP。
4.1 TCP 提供的三样东西
TCP(Transmission Control Protocol,传输控制协议)向上层承诺三件事:
- 面向连接:说话之前先打招呼、建立一条「连接」。双方都记住对方,之后的每个包都属于这条连接。
- 可靠传输:每个包编号;收到要回确认;超时没收到确认就重发;乱序到达的重新排好;重复的丢掉。
- 字节流:这是最容易被忽略的一条——TCP 不保留「消息边界」。你调用一次
send("你好"),对面可能一次收到「你好」,也可能先收到「你」再收到「好」,甚至和下一个消息的一部分粘在一起。
第三条对你的影响是具体的:一次 send 不等于一次 receive。所以凡是「在一条 TCP 连接上连续发多个消息」的协议,都必须自己规定「一条消息到哪结束」——HTTP 靠 Content-Length,WebSocket 靠帧的长度(第 9.3 节会讲这种帧)。
4.2 三次握手:为什么不能是两次
客户端 ──── SYN(我想连,我的编号是 x) ────► 服务端
客户端 ◄─── SYN + ACK(好,我的编号是 y,收到你的 x 了) ── 服务端
客户端 ──── ACK(收到你的 y 了) ──────────► 服务端
连接建立,开始传数据
▲ 三次握手的示意(真实报文里还有序列号、窗口大小、各种选项,这里只留骨架)。
为什么是三次而不是两次?用一个不涉及术语的方式说:两次握手的话,服务端在收到第一个 SYN 之后就直接认定「连接建立了」。问题在于——它凭什么确定「这个 SYN 是现在的,而不是很久以前迷路的一个旧包」?
想象一个场景:客户端很久以前发过一个 SYN,这个包在网络里绕了很久才到。服务端收到后如果直接建立连接并分配资源,它就会一直守着一个客户端根本不知道、也不承认的连接,白白占着资源。而三次握手里,服务端必须等到客户端对「y」的确认,才能确定双方此刻都活着、都认可这条连接。
技巧记忆方式:握手的目的不是「打个招呼」,而是「双方各自确认对方收发能力正常,并协商出初始编号」。能确认这两件事的最少来回次数就是三次。你也可以试着论证「四次行不行」——行,只是第三次之后没有新信息了。能解释「为什么不能再少」,才说明你懂了。
4.3 四次挥手:为什么断开比建立贵
关闭连接通常要四次来回,因为 TCP 是双向的:一方说「我没数据要发了」,但另一方可能还有数据在途。所以「我发完了」和「我也发完了」必须分成两次说,中间那段时间连接处于「半关闭」状态。
4.4 为什么「连接」这个概念对 WebSocket 至关重要
HTTP 的默认工作方式是「请求—响应—结束」:一次请求用完,连接就可以关掉。这也是为什么很多人误以为「HTTP 不能做实时推送」。
而 WebSocket 的做法是:先用一次 HTTP 请求把协议「升级」,然后把这条 TCP 连接留着不关,长期当成一条双向管道使用。所以理解 WebSocket 的关键不在 WebSocket 本身,而在这一句:它复用了 HTTP 的握手,但复用的是一条 TCP 连接。
想一想套接字(socket)在操作系统里和「文件」很像:你能读它、写它、关掉它,它也是一个数字编号。回想第一章里 Linux「一切皆文件」这句话——你觉得这条设计带来了什么好处?提示:如果网络连接能像文件一样读,那么「等一个连接上有数据」和「等一个文件有内容」就可以用同一套机制处理。
5. HTTP 完整解剖:一次请求里到底写了什么
前面几层都在搬字节。现在到了应用层——字节终于有含义了。这一层最重要、也最常被新手当成「魔法」的协议,叫 HTTP(HyperText Transfer Protocol,超文本传输协议)。
我先把结论放在这里,整节都在展开这一句话:HTTP 就是「一段有固定格式的文本,问一个问题」,和「另一段有固定格式的文本,答这个问题」。没有任何魔法,只有格式。
5.1 请求的四个部分
一次 HTTP 请求由四部分组成,我们逐段拆开:
POST /v1/chat/completions HTTP/1.1
Host: api.deepseek.com
Content-Type: application/json
Authorization: Bearer sk-xxxxxxxxxxxxxxxx
Content-Length: 268
{"model":"deepseek-flash","messages":[{"role":"user","content":"你好"}]}
▲ 一个真实的请求形状(正文按 5.5 节的完整版本还原,这里缩短了)。注意:头和正文之间必须有一个空行,那是 HTTP 用来看家的分界线。
| 部分 | 在上面哪里 | 它回答什么问题 |
|---|---|---|
| 方法 + 路径 + 版本 | 第一行 | 我要「做什么」(POST)?对「哪个东西」(/v1/chat/completions)做?用的哪版协议? |
| 头(headers) | 中间若干行 | 关于这次请求的元信息:正文是什么格式、我是谁、我能接受什么 |
| 空行 | 一个孤零零的空行 | 「头到此为止」 |
| 正文(body) | 最后一段 | 真正的数据本身 |
5.2 方法:动词,用来表达意图
第一行的第一个词是方法。最常用的四个:
| 方法 | 语义 | 在 OWL 里你会怎么用 |
|---|---|---|
GET | 读取,不改动任何东西 | 查「今天的余额还剩多少」(DeepSeek 有 /user/balance 接口) |
POST | 提交、触发一个动作 | 把「人设 + 历史 + 这句话」发给模型,请它生成回复 |
PUT | 整体替换一个东西 | 把某个群的全部配置整体覆盖掉 |
DELETE | 删除 | 删掉某个人的记忆(对应 /忘记 指令) |
还有一个你一定会见到的 PATCH,意思是「只改一部分」。它和 PUT 的区别很像「重写整篇作文」和「只改一个错别字」。
关于方法有两条真实的规矩:
GET按约定不应该改变服务器上的任何状态。这不是技术限制,是约定——因为浏览器、爬虫、预加载器都可能「顺手」发一个 GET。如果某个网站把「删除文章」写成了 GET 链接,那么一个爬虫爬过去就能删库。GET的参数放在路径里(?a=1&b=2),会出现在浏览器地址栏、服务器日志、浏览器历史里。所以 GET 绝对不能用来传密码或 API Key。凡是涉及凭证的,一律用 POST 放进正文或头里。
警告一个新手很容易犯的错误:把 API Key 拼在 URL 上(?api_key=sk-xxx)。这条 URL 会被记进服务器日志、代理日志、浏览器的历史记录,可能被任何人看到。OWL 的 llm.mjs 把 Key 放在 Authorization 头里,是正确做法。
5.3 路径与查询串
/v1/chat/completions 叫路径。它不是文件路径——服务器上不一定真有这么个文件。它只是一个约定好的名字,服务器看到它就明白「你要的是对话补全这个功能」。
路径后面可以跟查询串(query string),形如 /search?q=读书&page=2。? 之前是路径,之后是「键=值」的列表,用 & 分隔。
还记得第 9 节的伏笔吗?OneBot 的鉴权还有另一种兼容传法:把 token 放进查询串,?access_token=xxx。现在你知道那是什么意思了。
5.4 关键的头:五个你躲不开的
头是最容易被跳过、也最容易出错的部分。这五个必须记住:
| 头 | 含义 | 写错会怎样 |
|---|---|---|
Content-Type | 「我这段正文是什么格式」 | 写错或不写,服务器可能把你的 JSON 当纯文本,返回 400 |
Authorization | 「我是谁,凭这张票」 | 不写或写错,401 |
Accept | 「我能接受什么格式的回复」 | 一般可以省略;写错可能拿到你解析不了的东西 |
User-Agent | 「我是什么客户端」 | 对 API 通常无所谓;但有些网站会按它决定给你网页还是给你别的 |
Content-Length | 「我的正文有这么多字节」 | 大量场景下由工具自动补上;手写错了会导致对方读不完整 |
关于 Content-Type 有一个细节值得停下来说:它常常带一个编码声明,比如 text/plain; charset=utf-8。charset 解决的是「这段字节怎么变成字符」——中文的「你」在 UTF-8 里是三个字节,在 GBK 里是两个字节。如果发送方用 UTF-8 编码,接收方按 GBK 解码,你就会看到一堆问号或方块。
这就是第 12 节里「乱码」那一条的根源:绝大多数乱码不是「坏了」,而是「两边对同一段字节的解释不一致」。
另外注意 Content-Length 数的是字节数,不是字符数。一个中文字符在 UTF-8 里占 3 个字节,所以「你好」的 Content-Length 是 6,不是 2。这也是为什么 OWL 的输入长度限制写的是「400 字」,而 QQ 单条消息的限制写的是「约 4500 字节」——两个单位。
5.5 响应的三个部分
服务器回给你的东西结构对称,也是三部分:
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 431
Date: Sat, 03 Oct 2026 15:12:07 GMT
{"id":"chat-abc","object":"chat.completion","created":1759504327,
"model":"deepseek-flash",
"choices":[{"index":0,"message":{"role":"assistant","content":"我在。"},
"finish_reason":"stop"}],
"usage":{"prompt_tokens":31,"completion_tokens":3,"total_tokens":34}}
▲ 一个仿真的响应(字段名与结构参照 DeepSeek 官方文档;数值是示例)。真实运行时,正文可能长得多,但骨架一模一样。
| 部分 | 在上面哪里 | 它告诉你什么 |
|---|---|---|
| 状态行 | HTTP/1.1 200 OK | 协议版本、状态码、一句人类可读的短语 |
| 响应头 | 中间若干行 | 正文格式、长度、时间、缓存规则、限流信息等 |
| 响应正文 | 空行之后 | 真正的数据 |
请特别注意那句 200 OK 里的 OK:它只是给人看的,程序不应该依赖它。判断成功与否,只看数字状态码。有些服务器会返回 200 一切正常但还是失败了 这种荒唐的短语,甚至返回一个拼错的词——你的代码如果去匹配 "OK" 就会崩。
5.6 状态码:先分类,再记数字
状态码是三位数字,第一位就是它的分类:
- 1xx:临时信息。日常几乎见不到。
- 2xx:成功。你想要的都在这。
- 3xx:重定向。「你要的东西不在这,去那边找」。
- 4xx:你(客户端)这边有问题。
- 5xx:服务器那边有问题。
要点4 和 5 的区别,是这一整节最有实用价值的一条判断:看到 4xx,先查自己发的东西;看到 5xx,先别改代码,去看对方的状态页、等一会儿、或者加重试。很多新手在这件事上浪费大量时间——拿到 500 就去重写请求格式,拿到 400 就等着「可能过一会儿就好了」。
下面是你在 OWL 的开发与运维中真正会碰到的十一个。每一个我都写了「你会在什么时候遇到它」。
| 码 | 名字 | 你会什么时候遇到它 |
|---|---|---|
200 | OK | 一切正常。DeepSeek 返回了回复,NapCat 确认消息已发送——日常最常见的同学 |
201 | Created | 你 POST 过去创建了一个新东西,比如新建了一条记忆记录。它和 200 的区别只是「顺便告诉你了,确实新建了」 |
301 | Moved Permanently | 你请求的地址永久换了。比如你把 api.deepseek.com/v1/... 写成了旧域名 |
302 | Found | 临时跳转。最常见于「你还没登录,先跟我去登录页」 |
400 | Bad Request | 你的请求格式错了。JSON 少了一个花括号、model 字段拼错、stream 用了不支持的值——DeepSeek 的官方文档把这类叫「格式错误」 |
401 | Unauthorized | 没带钥匙,或者钥匙不对。Key 写错了、过期了、Bearer 后面少了一个空格,都会是它 |
402 | Payment Required | 账户余额不足。DeepSeek 的官方错误码表里把它写成「余额不足」。这个码不只出现在书面上——8.4 节那个 llmFailureHint 专门为它写了一条「去充点钱吧」,你迟早会亲眼见到它 |
403 | Forbidden | 钥匙是真的,但你没权限做这件事。比如拿一个只读的 Key 去调需要写权限的接口(通用 HTTP 码,官方错误码表里没有它) |
404 | Not Found | 路径写错了。最常见的是 baseUrl 少写或多写 /v1,或者把 /chat/completions 拼成了 /chat/completion(通用 HTTP 码,官方错误码表里没有它) |
422 | Unprocessable Entity | 请求能读懂,但参数值不合法。官方错误码表里把 400 叫「格式错误」、把 422 叫「参数错误」——400 是「你这段 JSON 本身有问题」,422 是「JSON 没问题,但里头的值不对」,比如 temperature 填了 3 |
429 | Too Many Requests | 你发得太快了。DeepSeek 的文档称之为「请求速率达到上限(TPM 或 RPM)」。这时正确的动作是退避重试,而不是立刻再发一次 |
500 | Internal Server Error | 对方服务器内部出错了。跟你没关系。等一会儿再试 |
502 / 504 | Bad Gateway / Gateway Timeout | 你(或对方)和真正的服务之间隔了一层代理/网关,而那个中间人没拿到有效回应或超时了。(通用 HTTP 码,官方错误码表里没有它们。)OWL 是直连 DeepSeek 官方 API、中间没有中转站,所以这两个码正常情况下不该出现——如果出现了,第一件要查的事就是「是不是有人加了代理」 |
503 | Service Unavailable | 服务器繁忙。官方错误码表里的原话是「服务器负载过高」,解决方法是「稍后重试」。它和 500 的区别是:500 是「我这个请求出错了」,503 是「我现在整体忙不过来」——所以 503 更值得重试,也更值得给它加退避 |
这张表里,400 / 401 / 402 / 422 / 429 / 500 / 503 是 DeepSeek 官方错误码表里明确列出的七个;403 / 404 / 502 / 504 是通用 HTTP 码,官方专门说明里没有它们。这个区分本身就是一个好习惯:遇到一个码,先想「这是这个服务特有的规矩,还是整个 HTTP 世界的共识」。
补一个 429 的实用细节:很多服务返回 429 时会同时给一个 Retry-After 头,告诉你「至少等这么多秒再来」。看到它就照做——它比你自己猜的数字准确得多。
想一想「没带钥匙」和「钥匙不对」都是 401,而「钥匙是真的但你没权限」是 403。想一想:为什么服务器要区分这两件事?如果一律都回 401,使用者会有什么麻烦?
5.7 无状态:整章最重要的一个概念
现在讲这一章我最希望你能带走的东西。
HTTP 被设计成无状态的:服务器默认不记得上一个请求是谁发的,也不记得你们刚才聊过什么。每个请求都是一张独立递进来的纸条,服务器看完、答完、忘掉。
为什么这样设计?因为无状态让服务器可以非常简单、非常容易扩展:任何一个请求,随便交给哪台机器处理都行,因为它们不需要知道「之前那台机器上发生过什么」。你还记得序章里那个数字吗——OWL 那台服务器只有 1.6 G 内存,却能一秒回复一次。无状态是这种「便宜、可复制」的基础之一。
但无状态立刻带来一个所有人都要面对的问题:那「登录」怎么实现?如果服务器不记得我,我下次刷新页面,它凭什么还认识我?
答案是:无状态不是「不允许记」,而是「HTTP 协议本身不替你记」。谁需要记忆,谁自己想办法把状态带上。历史上长出了三种主流办法:
| 办法 | 怎么工作 | 代价 |
|---|---|---|
| Cookie | 服务器在响应头里说「把这个小纸条存下来」,浏览器之后每次都自动带上它 | 浏览器会自动带,所以容易成为跨站攻击的目标(CSRF) |
| 会话 + 会话 ID | 状态存在服务端(内存或数据库),只给客户端一个号码;靠号码去查 | 服务端要存东西,机器一多就要共享存储 |
| Token | 把「你是谁」签个名写进令牌本身,服务端不存,验证签名即可 | 签发出去就难以立刻作废;令牌泄露风险高 |
OWL 用的是第三种思路的一个变体,只不过它把「会话」这个词用在了自己的场景里:OWL 记住的不是「你登录过」,而是「这个群的最近 8 轮对话」。它把这份状态存在自己的内存和 history.json 里,用「群号」或「QQ 号」当索引。这里有一个读代码时必须养成的习惯:这个「8」来自 config.json 里 llm.history.maxTurns 的实际配置值,而 bot.js 给同一个字段写的代码内兜底默认值是 6——两者不一致。凡是「代码里的默认值」和「配置文件里的实际值」,永远以配置文件为准;读代码时看到 ??= 或 || 后面的数字,都要去配置文件里确认一遍它有没有被覆盖。同样的不一致还有 llm.history.ttlMinutes:代码兜底是 60,实际配置覆盖成了 180。
这个设计非常值得琢磨:HTTP 的无状态并没有消失,只是被挪到了应用层。每一次请求依然是独立的;「记得」这件事,是 OWL 自己在服务端拿一个 Map 做出来的。
由此你可以解释一整片现象:为什么刷新页面后有的网站还认识你(它把状态存下来了)?为什么聊天记录换台设备就没了(状态存在那台服务器上)?为什么 /重置 能清掉上下文(它删的正是服务端那份 Map)?无状态是这一整片领域的地基。
关于「记忆」这件事,这里只埋一个钩子:目前 OWL 的这份 Map 住在内存里,进程一重启就没了——history.json 是它做的补丁。真正干净的做法是放进数据库。这就是第六章(数据库与记忆)要解决的核心问题。
5.8 实战:用 curl 亲自调一次 DeepSeek
时效性提示(请先读这一条)deepseek-chat 是 OWL 项目 config.json 里现在写的模型名,这一点没错。但模型清单是会变的:服务商会上线、下线、重命名模型,旧名字有时仍被兼容一段时间,有时直接返回 400。
本书写作期间核对官方文档(Models & Pricing 与 更新日志)时,列出的模型是 deepseek-flash 与 deepseek-v4-pro,文中已经看不到 deepseek-chat;更新日志里还写明旧模型名会被下线。所以你有必要亲自确认一次:你的 Key 现在还能不能用这个名字。
一分钟自检法:照下面 5.8 节的命令跑一次。能正常回答说明这个名字仍然兼容,继续用即可;返回 400、且提示里带 model 字样,就把 model 换成官方文档当前的推荐值(写作时是 deepseek-flash),其他一个字都不用改。
这正好是本章想教的东西:任何写死的 API 细节都可能过期,包括这一章写的这一句。
现在请把前面所有概念用一次。你不需要写任何代码,只需要一个终端和一个 Key。这个工具叫 curl。
先把 Key 放进环境变量——不要直接写在命令里,因为写在命令里会被记进 shell 历史和日志:
export KEY="sk-你从 DeepSeek 控制台复制的那串"
# Windows PowerShell 里对应的是:
# $env:KEY = "sk-你复制的那串"
▲ 用 $KEY 占位是行业惯例。这样分享命令给别人时,不会顺手泄露自己的钥匙。
然后发一次请求:
curl https://api.deepseek.com/v1/chat/completions \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $KEY" \
-d '{
"model": "deepseek-flash",
"messages": [
{"role": "system", "content": "你说话很短,一句话之内。"},
{"role": "user", "content": "用一句话介绍你自己。"}
],
"stream": false
}'
▲ 这是把 OWL 内部那次调用原样搬到了命令行上。逐段解释见下表。注意反斜杠 \ 是「这一行还没完,下一行接着」,在 PowerShell 里要换成反引号 ` 或者干脆写成一行。
| 这一段 | 它在说什么 |
|---|---|
curl | 发一个 HTTP 请求 |
https://api.deepseek.com/v1/chat/completions | HTTPS + 域名 + 路径。HTTPS 说明这一路的正文是加密的(第 6 节);域名靠 DNS 变成 IP(第 3 节);路径告诉对方「我要对话补全」 |
-H "Content-Type: application/json" | 加一个头:我的正文是 JSON。少了它,很多服务器会拒绝 |
-H "Authorization: Bearer $KEY" | 加一个头:这是我的钥匙。Bearer 是「持有者」的意思,这个方案叫 Bearer Token(第 8 节讲它的来历和风险) |
-d '{...}' | 请求正文(data)。-d 会自动把方法变成 POST,所以你不用写 -X POST |
"model": "deepseek-flash" | 用哪个模型。这个字段写错是最常见的一类 400,而且它也是最容易过期的一个值——写作时官方推荐 deepseek-flash,用之前请到控制台或官方文档核对一次 |
"messages": [...] | 对话消息数组。每条消息有 role(谁说的:system 是设定,user 是你,assistant 是模型)和 content(内容) |
"stream": false | 一次性返回完整结果。改成 true 就是「一个字一个字地流式返回」——第八章 §13.6 会讲它,并且会说明 OWL 为什么现在故意不用它(cleanReply 需要完整文本才能做清理) |
警告模型名请以你控制台和官方文档当时写的为准。服务商会在不同时期上线、下线、重命名模型——deepseek-chat 是 OWL 项目里 config.json 当前写的值,来自项目本身的真实配置,不代表它永远有效。如果你哪天收到 400 并且提示里出现「model」,第一件事就是去控制台核对模型名,而不是怀疑自己的 JSON 写错了。
一句话记住区别:上面这条 curl 命令是给你照抄执行的,所以用的是官方文档当前推荐的 deepseek-flash;而项目那一处 deepseek-chat 是历史事实,我们照原样保留并标注。如果你的 Key 仍然兼容 deepseek-chat,继续用也可以——但以官方文档为准,并且请自己动手确认一次。
你会看到一段 JSON 被打印在屏幕上。它和 5.5 节那段一模一样:
{
"id": "chat-...",
"object": "chat.completion",
"created": 1759504327,
"model": "deepseek-chat",
"choices": [
{
"index": 0,
"message": { "role": "assistant", "content": "……" },
"finish_reason": "stop"
}
],
"usage": {
"prompt_tokens": 31,
"completion_tokens": 18,
"total_tokens": 49
}
}
▲ 简化后的响应。字段名与结构参照 DeepSeek 官方文档,数值是示例。
现在逐段读懂它。这是本章最重要的一次「读真实数据」训练:
choices:候选回复的列表。为什么是数组?因为这个接口从设计上就允许一次生成多个候选(n参数)。OWL 不用这个能力,所以永远只取第 0 个。你以后在llm.mjs里看到j?.choices?.[0]?.message?.content这一串问号,就是在「一层一层小心地取这个数组的第 0 项」——每一层都可能是空的,所以每层都要问一下。content:回复的正文。这就是你要发给 QQ 用户的那句话。finish_reason:模型为什么停下来。stop是「自然说完了」;length是「撞到了max_tokens上限,被截断了」。如果你发现 OWL 的话总是说一半就断,来查这个字段,不要猜。usage:这次的用量:输入多少 token、输出多少 token、合计多少。这就是你的账单。第八章会讲 token 怎么算、怎么省。这里先记住一句话:「历史对话」是重复计费的——你每一轮都要把前几轮重新发一遍,所以聊得越久,每条消息越贵。OWL 把maxTurns设成 8(代码里的兜底默认值其实是 6,config.json把它覆盖成了 8),既是为了不让模型跑题,也是为了钱包。
技巧把 curl 的输出存下来慢慢看:在命令末尾加 > reply.json。想连响应头一起看,就加 -i;只想看正文、不想看进度条,加 -s。这三个参数能解决你调试期 80% 的「我到底收到了什么」问题。
想一想如果你故意把 Authorization 头里的 Key 改掉一个字符,会发生什么?如果把 Content-Type 删掉呢?如果把 model 改成一个不存在的名字呢?请真的去试一次,然后写下三次的状态码和错误信息。这三个 400/401 会长在你手上,而不是留在这一页纸上。
6. HTTPS 到底加密了什么
刚才那条 curl 命令用的是 https://。多出来的那个 s,是这一整章里唯一一处「你不能省掉的东西」。这一节不讲密码学,只讲一件事:它解决了什么问题。
6.1 先看没有它会怎样
回想第 2 节的分层:你的请求从你家路由器出发,要经过运营商、若干骨干节点,才到 DeepSeek 的机房。你的数据不是「从 A 直接飞到 B」,而是被一台一台机器转发的。
这就是 HTTP 在今天的公网上几乎不可用的原因。它不是设计得不好,而是它出生的年代(1991 年)没人预期它会承载密码、支付和私密对话。HTTPS 就是给 HTTP 套上一层保护。
6.2 加密解决三件事,不是一件
很多人以为 HTTPS 就是「加密」,其实它同时承诺三件事,而这三件事对应三种不同的攻击:
| 它保证 | 防的是什么 | 用一个词说 |
|---|---|---|
| 内容别人看不懂 | 有人在路上偷看 | 机密性 |
| 内容没被改过 | 有人在路上偷偷改数字(比如把收款账号换掉) | 完整性 |
| 对面确实是它声称的那个网站 | 有人冒充 DeepSeek | 身份认证 |
6.3 两把钥匙的分工
HTTPS 用的加密技术有两类,分工非常清晰,理解了分工你就理解了整套设计:
非对称加密:慢,但能安全地「第一次交换」。它有一对钥匙:公钥和私钥。用公钥加密的东西,只有对应的私钥能解开;反过来也一样。关键性质是:公钥可以公开给全世界,泄露了也没关系。
对称加密:快,但需要双方事先知道同一个密钥。问题在于,怎么在一条被人监听着的线路上,把那个密钥安全地交给对方?
HTTPS 的答案就是把这个死结解开一次:用非对称加密协商出一把临时的对称密钥,之后所有数据都用那把对称密钥加密。因为对称密钥是这次连接现生成的、用完就丢,即使以后私钥泄露,也解不开当时录下来的流量。
这个协商的过程叫 TLS(Transport Layer Security,传输层安全协议)握手。你不需要知道其中的算法细节——TLS 握手大致包含这几步:客户端说「我想加密通信」并给出自己支持的算法;服务器出示证书和公钥;双方协商出会话密钥;之后开始加密传输。
6.4 证书:防的是「中间人」
但这里还有一个漏洞:如果一开始跟你握手的那个「DeepSeek 服务器」其实是假的呢?
攻击者完全可以自己生成一对公钥私钥,然后在你和真服务器之间站着:你对它说加密的话,它解开、看一遍、再加密转给真服务器;回复也照样转发一遍。你和真服务器都以为自己在跟对方说话。这个攻击有个形象的名字:中间人攻击。
破解这个局面的东西叫证书。它的逻辑是「引入第三方背书」:
- 世界上有一批被所有操作系统/浏览器预先信任的机构,叫 CA(Certificate Authority,证书颁发机构);
- 网站向 CA 证明「这个域名是我的」,CA 就签一张证书,内容是「这个域名的公钥是这个」;
- 你的浏览器连上某个网站时,要求它出示证书,然后检查:是不是这些受信任的 CA 签的?签名对不对?证书上写的域名是不是我正在连的这个?有没有过期?
- 四项全过,才继续握手。有一项不过,你就看到红色的「不安全」警告。
于是中间人就无法伪造了:他造不出一张被你的系统信任的、写着 api.deepseek.com 的证书。
6.5 为什么不要忽略浏览器的证书警告
新手最常见的处置方式是:看到「此连接不安全」,点「高级」→「继续前往」。
请理解你那一刻按下的按钮是什么:你是在说「我不在乎对面可能是别人」。如果你正在输入密码、正在看私信、正在向某个网址发送 API Key,那么你按下的这个按钮,等于把这些东西的明文交给了一个未知的第三方。
警告证书警告只有三种合理原因:证书过期(网站管理员忘了续)、域名不匹配(配置错误)、或者真的有人在中间。这三种都不该由你按「继续」来解决。正确做法是关掉页面,换条路走。
6.6 改一行代码,就能关掉这一切
这件事对程序员尤其要注意,因为很多语言里「跳过证书校验」就是一个参数:Node.js 里可以设 NODE_TLS_REJECT_UNAUTHORIZED=0,Python 的 requests 有 verify=False。
请记住这个判断标准:当一段代码把「安全校验」变成一个开关时,你要问的不是「关掉能不能跑通」,而是「我到底在用什么换取这条能跑通的路」。如果换出去的是密钥,那么这条路不值得走。
想一想OWL 的架构里,NapCat 与 OWL 之间走的是 ws://(不是加密的 wss://)。为什么这里可以不用加密?请用第 3 节学过的「回环地址」和第 5 节学过的「token 鉴权」两点来回答。如果哪天你把 OWL 挪到另一台服务器上,让 NapCat 通过公网连过来,这个判断还成立吗?
7. JSON 深入:一门非常小、但非常严格的语言
现在回到每一步都在用的那个东西。JSON 的长度大概只有几百字就能说完全部语法——它的所有麻烦都来自「太严格」。
7.1 五条语法规则
- 键必须用双引号包起来。
{"name": "owl"}合法;{name: "owl"}不合法。 - 不能有注释。没有
//,没有/* */。想写说明就加一个"_comment"字段——但对方可能会当成错误。 - 最后一个元素后面不能有逗号。
[1, 2, 3,]不合法。这是最经典的坑,因为 JavaScript 允许尾逗号,很多人改配置时就是这么把系统改崩的。 - 没有
undefined,没有函数,没有日期。只有这些值:对象{}、数组[]、字符串、数字、true/false、null。 - 字符串里某些字符必须转义。双引号写成
\",反斜杠写成\\,换行写成\n。所以一条含换行的消息在 JSON 里是看不到真实换行的。
7.2 JSON 和 JavaScript 对象到底差在哪
这是新手最高频的困惑点,我用一张表说清。左列是 JSON,右列是 JavaScript 对象字面量:
| 对比项 | JSON | JavaScript 对象 |
|---|---|---|
| 本质是什么 | 一段文本(可以被写进文件、通过网络发送) | 内存里的数据结构(有方法、有原型、可以是任意函数) |
| 键 | 必须双引号 | 可以不加引号,也可以用单引号 |
| 注释 | 不允许 | 允许(//、/* */) |
| 尾逗号 | 不允许 | 允许 |
| 值可以是函数吗 | 不行 | 可以 |
undefined | 不存在 | 可以赋这个值 |
| 能不能直接点出来用 | 不能,它是字符串 | 能,obj.name |
| 能不能带 BOM | 不能,会解析失败 | 不适用(它不在文件里) |
这解释了一个你一定会遇到的场景:你在 bot/config.json 里加注释,机器人起不来了;你在 JS 代码里写同样的东西,一点事没有。不是 JS 更宽容,是你把两种语言搞混了。
7.3 stringify 与 parse:一对必须对称的操作
在 JS 里,这两种转换用两个函数:
const obj = { name: "OWL", tags: ["AI", "QQ"] };
const text = JSON.stringify(obj); // 对象 → 文本
console.log(text); // {"name":"OWL","tags":["AI","QQ"]}
console.log(typeof text); // string
const back = JSON.parse(text); // 文本 → 对象
console.log(back.tags[1]); // "QQ"
// 一个你一定会踩的坑:
console.log(obj.name); // "OWL"
console.log(text.name); // undefined —— 文本没有 .name 这个属性!
▲ stringify 是序列化,parse 是反序列化。请特别注意最后两行:忘了 parse 就去点属性,是「拿到的数据是 undefined」这类 bug 的头号来源。
记住这个对称性:凡是要通过网络发送或写进文件,就得 stringify;凡是从网络收到或从文件读出,就得 parse。中间但凡跳过一步,你后面的代码就在对一段没有属性的字符串做操作。
7.4 解析失败的三类原因
JSON.parse 只有成功和抛异常两种结果。失败的原因基本只有三类,请按这个顺序查:
| 类型 | 典型样子 | 怎么确认 |
|---|---|---|
| 一、它根本不是 JSON | 收到的是 HTML 错误页(<!DOCTYPE html>)、一句纯文本、或者空的 | 打印收到的原始文本,看第一个字符是什么 |
| 二、有 BOM | 文本开头有三个看不见的字节 EF BB BF | 看第一个字符的编码值(下面细讲) |
| 三、语法有错 | 尾逗号、单引号、注释、少一个括号 | 看报错信息里给的位置,通常直接指到出错的那个字符 |
技巧JSON.parse 抛出的错误信息里通常带「position N」。那个 N 是零基的字符位置——也就是说,如果它说 position 10,你要看的是第 11 个字符。新手经常数错一位,然后盯着正确的地方看不出问题。还有一招:把这段文本粘到任何编辑器的 JSON 格式化功能里,它会直接把出错位置标红。
7.5 真实案例:那两行 .replace(/\uFEFF/, "")
现在讲这一章最值得学的一个真实坑。OWL 的 bot.js 和 llm.mjs 里都有一模一样的一行:
/**
* 读取 JSON 文件并解析。
* 注意:用 PowerShell 的 Set-Content -Encoding UTF8 改过的文件会带 UTF-8 BOM,
* 而 JSON.parse 不容忍 BOM 会直接抛错。这里统一剥掉,避免"改个配置机器人就崩"。
*/
function readJson(file) {
const raw = fs.readFileSync(file, "utf8").replace(/^\uFEFF/, "");
return JSON.parse(raw);
}
▲ 这是 bot.js 里的真实代码,一字未改。llm.mjs 里也有一个功能相同的 readJson,注释更短但道理一样。
逐行读懂它:
fs.readFileSync(file, "utf8"):把文件读成一段字符串。注意这个"utf8"——它告诉 Node「按 UTF-8 把这堆字节解释成文字」。如果不给,你会拿到一串原始字节(Buffer),没法直接当文本用。.replace(/^\uFEFF/, ""):把开头那个 BOM 字符删掉。^表示只在开头匹配,\uFEFF是 BOM 的 Unicode 码位。JSON.parse(raw):正式解析。如果前面没剥 BOM,这一行会直接抛异常。
现在回答三个「为什么」:
BOM 从哪来?BOM(Byte Order Mark,字节顺序标记)本来是 UTF-16 时代用来标记字节序的,在 UTF-8 里其实没有意义,但很多 Windows 工具仍然会习惯性地写上去。你用 PowerShell 的 Set-Content -Encoding UTF8 改配置文件,它就会加 BOM。在 Windows 上用记事本「另存为 UTF-8」在某些版本上也会。这是 Windows 世界和 Unix 世界一个非常经典的不一致。
为什么看不见?因为它是一个「零宽度」字符,在编辑器和终端里不显示任何形状。你打开文件、肉眼检查、觉得一切都对——错的那个东西恰好是你看不见的。
为什么会让机器人崩?因为 JSON 规范规定文档的第一个字符必须是 { 或 [(允许前面有空白)。BOM 既不是空白也不是合法的起始字符,所以严格的解析器直接报错。JSON.parse 就是严格的:它抛出的信息通常是 Unexpected token \uFEFF in JSON at position 0——记住这个报错,你以后一眼就能认出它。
这段代码真正教给你的,不是「BOM 是什么」,而是一个工程判断:作者在这里没有说「以后不要用 PowerShell 改配置」——因为那是管不住的(下次还会有人用别的工具、别的编辑器、别的系统改)。他做的是在自己的程序里,为这个已知的现实做一次宽容处理。
一行 replace,换来了「改配置永远不会因为编码问题崩」。把「环境事实」当作设计输入,而不是当作偶发意外——这是成熟代码和幼稚代码最明显的差别之一。
想一想这段代码只剥掉开头的 BOM。如果有人在文件中间粘进一个 BOM(比如手动拼接两个文件),它能防住吗?如果不能,你会怎么改?再进一步:为什么作者选择「宽容地剥掉」而不是「检测到就报错停下来」?这两种做法各适合什么场合?
8. API 与鉴权:一个契约,一张门票
前面讲完了传输和格式。这一节讲「对面允许你做什么」,以及「你凭什么能调它」。
8.1 API 的本质是契约,不是函数
你已经在序章里见过这个词了。现在可以给它一个准确的定义:
API(Application Programming Interface,应用程序接口)是两套程序之间的约定。在 Web 语境里,这个约定由四件事构成,缺一不可:
- 路径:调哪个功能(
/v1/chat/completions) - 方法:用什么动作(
POST) - 请求格式:你必须发什么样子的 JSON 过去(哪些字段必填、什么类型)
- 响应格式:对方会回什么样子的 JSON(成功长什么样、失败长什么样)
这四件事合起来,就是一份契约。契约的价值在于:只要双方都守约,各自内部怎么实现完全自由。DeepSeek 不知道你的代码是用 Node 写的还是 Python 写的,也不关心你的机器人叫什么名字;你也不需要知道它的 GPU 集群怎么调度。这和第 2 节讲的抽象是同一件事,只是换了个层级。
OWL 里有一个很好的对照:bot.js 调 NapCat 的 send_group_msg,与 llm.mjs 调 DeepSeek 的 /v1/chat/completions——两者都是「按契约发 JSON」。一个机器人本质上就是「不断遵守一串契约」。这个认识,比记住十个接口名有价值得多。
8.2 REST:为什么「看到路径和方法就知道要干什么」值得遵守
REST 是一种设计 API 的风格,不是强制标准。它的核心思想只有两句:
- 用路径表示「东西」,用方法表示「动作」。路径里是名词(
/users/42/messages),方法里是动词(GET读、POST建、DELETE删)。 - 不要把动作写进路径。
POST /createNewMemoryForUser就不是 REST 风格;POST /users/42/memories才是。
为什么这件事值得遵守?因为它把「读代码的人要花多少时间去理解」从一件随机的事,变成了一件可以预期的事。
8.3 Authorization: Bearer 到底是什么
回到 5.8 节那行头:
Authorization: Bearer sk-xxxxxxxxxxxxxxxx
Authorization 是头的名字,Bearer 是「方案名」,后面那串是凭证本身。这个词的意思是「持有者」——这套方案的逻辑是:谁拿着这串东西,谁就是被授权的人。它不看你的 IP、不看你是谁,只看这串字符对不对。
所以有一个必须刻在脑子里的推论:API Key 等于钱。
那该怎么保管?OWL 给出了一套标准答案,值得你完整抄走:
| 做法 | 在 OWL 里具体是什么 | 它防的是什么 |
|---|---|---|
| 不写进代码 | Key 不写在 bot.js 或 config.json 的主字段里 | 代码会被提交、会被分享 |
| 放进独立文件 | bot/llm.local.json,内容是 {"apiKey": "sk-..."} | 分享 config.json 时不会连 Key 一起泄露 |
| 让 Git 忽略它 | 这个文件被写进了 .gitignore | 手滑 git add . 时不会把 Key 提交到仓库 |
| 支持环境变量优先级 | 读取顺序:DSH_QQBOT_LLM_KEY → llm.local.json → config.json | 云上部署可以不落盘、用注入的环境变量 |
| 限制文件权限 | 部署时把 Key 文件权限设为 600 | 同机器上的其他用户读不到 |
| 设置消费上限 | 在 DeepSeek 控制台设额度 | 最后一道防线:真泄露了,损失有上限 |
请回想第三章(Git 与版本控制)里的 .gitignore。那时它是一条「别把垃圾文件提交进去」的便利规则;现在你看到,它同时是一条安全边界。同一个概念,在不同的章节里有完全不同的重量——这就是序章 5 节说的「螺旋」。
警告如果 Key 已经泄露了(比如不小心提交到了 GitHub),正确的第一动作不是删掉那段代码,而是立刻去控制台把 Key 作废并重新生成。因为 Git 的历史里还留着它,别人可以翻出来。删代码只是让「未来」干净,作废 Key 才能让「过去」失效。
8.4 错误处理的分层:把机器语言翻译成人话
现在讲本节最重要的一段代码。bot.js 里有一个函数,专职做一件事:把 API 返回的冷冰冰的错误,翻译成一句人能看懂、并且知道该做什么的话。
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 暂时不可用,先记着这事。";
}
▲ bot.js 里的真实函数,一字未改。它接收的是 llm.mjs 传出来的「原因字符串」,返回一句要发给用户的话。
逐条看它的设计,你会发现每一行背后都有一个判断:
| 原因 | 它的翻译 | 这个翻译背后的判断 |
|---|---|---|
http-401 / http-403 | 「密钥好像不对,去检查 llm.local.json」 | 这是管理员(你)的问题,用户帮不上忙。所以提示指名道姓地说出该去改哪个文件 |
http-402 或含 Insufficient | 「余额不足了,去充点钱吧」 | DeepSeek 的官方文档把 402 定义为「余额不足」。这里还额外做了一次字符串匹配,因为余额不足有时会以别的码+错误信息的形式出现——不能只认状态码 |
http-429 | 「请求太频繁了,缓一下」 | 这是暂时性问题,等一会儿就好。所以提示里不能出现「去改配置」这种误导 |
network: | 「连不上 AI 服务(网络/超时)」 | 区分「对方拒绝了我」和「我根本没联系上对方」——这两件事的排查方向完全不同 |
limited: | 直接透传原因 | 限流是 OWL 自己做的(#checkLimits),原因文案本来就是人话,不需要翻译 |
too-long | 返回 null | 这个设计很讲究:它把提示的责任交给调用方,因为只有调用方知道用户的输入限制是多少(maxInputChars),可以拼出「超过 400 字,截短点再说」这种带具体数字的话 |
| 其他 | 「AI 暂时不可用,先记着这事」 | 兜底的诚实:不知道就说不知道,不要编一个原因 |
这个函数教的是错误处理的分层思想:底层(llm.mjs)负责准确分类——它把失败归纳成 network:、http-401、limited: 这样稳定的前缀;上层(bot.js)负责翻译成人话——因为它才知道「这句话是要发给一个高中生的」。
如果这两件事混在一起会怎样?你会在 llm.mjs 里看到「去充点钱吧~」这种带表情的句子,然后当你想把这个模块复用到别的项目时,就发现它绑死了 QQ 场景。反过来,如果谁都不翻译,用户就会收到一串 http-429: rate limit exceeded——技术上没错,但没有任何人能据此做出正确的动作。
还有一个细节值得单独指出:reason.startsWith("http-401") 用的是「前缀匹配」而不是「相等」。因为真实的 reason 里还带着服务器返回的错误信息(llm.mjs 里拼成了 http-${res.status}:${hint})。分类信息放在字符串开头、附加信息放在后面,这样上层可以只匹配前缀——这是一个非常实用的小技巧,你以后自己写错误码时会用到。
想一想现在假设你是 OWL 的用户(一个高中生),你收到了「我的 AI 密钥好像不对(401/403),去检查一下 llm.local.json」。这句话对你有什么用?它是不是一句「正确但对用户毫无价值」的话?如果你来改,你会怎么改这一条,让用户既有用、又不至于完全不知道发生了什么?
9. WebSocket 与「反向」:让机器人不需要公网地址
HTTP 已经够用了——直到你需要「别人一发消息,我立刻知道」。
9.1 轮询的浪费
HTTP 是「问一句、答一句」。如果用它来做消息推送,只有一种做法:每隔几秒去问一次「有新消息吗?」这叫轮询。
9.2 WebSocket:把「问答」变成「电话」
WebSocket 的思路完全不同:建立一条长连接,之后谁都可以随时说话。
它很聪明的地方在于第一步:它借用了 HTTP 来完成「建立连接」这件事。客户端先发一个看起来像普通 HTTP 请求的东西,但头里带了两个特殊的字段:
GET / HTTP/1.1
Host: 127.0.0.1:3001
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
▲ WebSocket 的握手请求(示意)。Upgrade: websocket 就是「请把这条连接升级成 WebSocket」的意思。
服务器如果同意,就回一个 101 Switching Protocols。从这一刻起,这条 TCP 连接上跑的东西不再是 HTTP 了,而是一串有自己格式的 WebSocket 帧。
9.3 WebSocket 帧:为什么 TCP 的「字节流」需要它
回到第 4.1 节那个必须记住的结论:TCP 不保留消息边界。如果你在一条 TCP 连接上连续发两段 JSON,对面可能一次收到「两段粘在一起」,也可能分两次收到「一段的一半」。
所以每个「在 TCP 上跑」的协议都要自己解决边界问题。HTTP 用 Content-Length(告诉对方「我这坨有 268 字节」),而 WebSocket 用的是WebSocket 帧:每一帧的头部里有长度信息,接收方先读头、知道这一帧多长,再读那么多字节。
9.4 正向与反向:本章最漂亮的一个设计
现在讲这一节、也是这一章我最想让你记住的东西。
WebSocket 有两个角色:连出去的一方(客户端)和等着被连的一方(服务端)。谁当哪个角色,决定了连接的方向。在 OneBot 里,这两种方向各有名字:
| 正向 WebSocket | 反向 WebSocket | |
|---|---|---|
| 谁主动连 | 你的机器人程序 | 协议端(NapCat) |
| 谁在监听等连接 | 协议端 | 你的机器人程序 |
| 你的机器人需要公网可达吗 | 需要(至少需要一个能连到的地址) | 不需要 |
| OWL 用的是哪种 | — | 这种 |
为什么「反向」能让 OWL 不需要公网地址?答案在第 3.4 节的 NAT。
请想清楚这件事的两个方向:
- 如果是正向:OWL 要主动去连 NapCat。这说明 NapCat 必须是「等着被连」的一方,也就是它必须监听一个地址。而 OWL 和 NapCat 虽然在同一台机器上还好说,一旦分处两地,NapCat 就得暴露一个能被 OWL 找到的地址。更麻烦的是反过来的场景——如果 OWL 需要被主动推送消息,那 OWL 就必须监听一个别人能连进来的地址,也就是必须暴露到公网,必须配防火墙、必须配鉴权。
- 而反向:NapCat 主动去连 OWL。于是是 NapCat 在做「连出去」这个动作。连出去从来不需要任何前提条件——你的浏览器能上网,就是因为它一直在「连出去」,而你家的路由器不需要为此开放任何端口。同理,NapCat 连
ws://127.0.0.1:3001,它只是从容器里向本机发起了一次连接。
结果就是:OWL 只需要监听回环地址,就完成了全部通信。不需要公网 IP,不需要域名,不需要开放端口,不需要证书,不需要担心有人从外面扫到它。整条链路只在一个方向上是「可达」的,而这个方向恰恰是不需要任何配置的方向。
把这个思路抽出来,它是:当你需要在两个程序之间建立一条通道,而其中一方处在「无法被外部访问」的位置时,让能被访问的那一方去做主动连接的动作。
这也是为什么 NapCat 的重连设计如此重要:反向连接里,主动方是 NapCat,所以「连不上时该怎么办」也由它负责。OneBot 标准规定反向 WebSocket 客户端的重连间隔默认 3000 毫秒,也就是每 3 秒重试一次。这条规则带来一个很让人安心的结果:先启动 OWL 还是先启动 NapCat,都不会出错——谁先起来都行,另一个会自己找上来。
想一想如果 NapCat 每 3 秒重连一次,而你的 OWL 因为某次崩溃重启花了 10 秒,这中间 NapCat 试了三次都失败了。它会放弃吗?如果它放弃了,你有什么办法让这条链路自动恢复?提示:第一章学过 systemd,第七章会讲它的重启策略——「谁来保证进程一直活着」和「谁来保证连接一直存在」是两个不同层面的问题。
9.5 心跳与假活:连接还在,不等于还能用
最后讲一个特别容易被忽略、但会在真实运维中咬你一口的问题。
你可能会觉得:连接断了,程序会收到通知(比如 ws.on("close")),我重新连不就行了?
问题在于,很多断线不会产生任何通知。
想象一下:运营商的路由在半夜重启,你的连接经过的某一段被悄悄掐断了。你的操作系统和对方的操作系统都没有收到「断开」的信号——没有 FIN 包,没有 RST,什么都没有。在它们看来,这条连接还好好地开着。你的程序会一直以为自己在等消息,而消息永远也来不了。
这就是「假活」:进程在跑、端口在听、日志一片安静,看起来一切正常,实际上这条链路已经死了。
解决方案是心跳:双方约定,每隔一段时间互发一个极小的消息(OneBot 的默认心跳间隔是 15000 毫秒)。如果连续几个心跳周期都没有收到对方的任何动静,就主动认定「这条连接死了」,然后关掉它、重连。
这件事还有一个你必须知道的变体:健康检查不能只看「进程活着」。OWL 的部署里有一个 healthcheck.sh,它区分了「容器在跑」「进程在跑」「QQ 登录还有效」这几种状态——因为「进程在跑」和「机器人还能收到消息」是两件完全不同的事。第七章会把这一整套讲透。现在你只要带走一句判断:
要点「连接还在」不等于「还能用」;「进程在跑」不等于「服务正常」。任何一条长连接、任何一个长期运行的服务,都必须回答一个问题:你怎么知道它还活着?如果答案是「我觉得它应该活着」,那你就是下一个半夜被叫起来的人。
想一想心跳消息本身也是流量。如果每一秒发一次,一年下来是多少次?如果每 15 秒发一次呢?(算一算,这只需要小学数学。)然后回答更难的那个问题:心跳间隔定得越短,「发现假活」越快,代价是什么?这个取舍里有没有一个「正确答案」?
10. OneBot v11 精读:拿真实代码当教材
现在把所有零件拼起来。OneBot v11 是一套「聊天机器人接口标准」——它规定协议端和应用之间用什么格式交流。NapCat 实现了它,OWL 也实现了它。
这一节我们不再讲抽象概念,只读 bot.js 的真实代码。你会发现每一行都有前面九节的影子。
10.1 事件对象:一段 JSON 里的每个字段
协议端推给 OWL 的每一条消息,都是一个「事件」。以一个群消息为例:
{
"time": 1759504327,
"self_id": 1876148307,
"post_type": "message",
"message_type": "group",
"sub_type": "normal",
"message_id": 774321,
"group_id": 123456789,
"user_id": 10001,
"raw_message": "[CQ:at,qq=1876148307] 你在吗",
"font": 0,
"sender": {
"user_id": 10001,
"nickname": "小舟",
"card": "小舟",
"role": "member"
},
"message": [
{ "type": "at", "data": { "qq": "1876148307" } },
{ "type": "text", "data": { "text": " 你在吗" } }
]
}
▲ 一条群消息事件(仿真示例,字段名与结构参照 OneBot v11 标准与 NapCat 上报格式)。
| 字段 | 含义 | OWL 拿它干什么 |
|---|---|---|
time | 事件发生的时间戳(秒) | 日志 |
self_id | 收到这个事件的机器人 QQ 号 | 用来判断「是不是在 @我」——见下面的 isAtBot |
post_type | 事件大类:message / notice / request / meta_event | 第一道分流:不是 message 就直接返回 |
message_type | group 或 private | 决定回消息用哪个接口、会话 key 怎么拼 |
group_id | 群号(私聊时不存在) | 回复的目标、会话 key 的一部分 |
user_id | 发送者的 QQ 号 | 记忆的索引、限流的计数对象 |
message_id | 这条消息的编号 | 引用回复时用它 |
raw_message | 原始消息文本(含 CQ 码) | OWL 主要用 message 数组,不用它 |
sender | 发送者信息:昵称、群名片、角色 | 把昵称塞进提示词,让 AI 知道在跟谁说话 |
message | 消息内容,消息段数组 | 核心字段,下面单独讲 |
- OneBot 标准明确说明:
sender里的各字段是「尽最大努力提供的」,不保证一定存在,也不保证完全正确(缓存可能过期)。所以你的代码不能写event.sender.nickname,而应该写event.sender?.nickname。OWL 里的event.sender?.card || event.sender?.nickname || String(event.user_id)就是三级兜底——一个都不能用的时候,用 QQ 号当名字。 message_type和sub_type是两级分类。群消息的sub_type还分normal(正常)、anonymous(匿名)、notice(系统提示,比如「管理员已禁止群内匿名聊天」)。也就是说,你的机器人收到的「群消息」里,有时根本不是人发的。OWL 目前不区分它,这是一个可以改进的点。
{ "type": "at", "data": { "qq": "1876148307" } }
{ "type": "text", "data": { "text": " 你在吗" } }
{ "type": "image","data": { "file": "abc.jpg" } }
{ "type": "face", "data": { "id": "178" } }
{ "type": "reply","data": { "id": "774300" } }
/** OneBot v11 消息段数组 -> 纯文本 */
function segmentsToText(segments) {
if (typeof segments === "string") return segments;
if (!Array.isArray(segments)) return "";
return segments
.map((s) => {
switch (s.type) {
case "text": return s.data?.text ?? "";
case "image": return "[图片]";
case "face": return "[表情]";
case "at": return `@${s.data?.qq ?? ""}`;
case "reply": return "";
default: return `[${s.type}]`;
}
})
.join("");
}
▲ bot.js 里的真实函数(只把箭头符号做了 HTML 转义)。注意第一行:它同时兼容「字符串格式」和「数组格式」。
逐行读它的设计:
- 第一行兼容字符串。OneBot 标准允许协议端把
message配成字符串格式(CQ 码那种)或数组格式。上面这段代码在说:不管你配哪种,我都不会崩。这就是第 7.4 节讲的「不信任输入」——同一份标准有两种合法形态,健壮的代码必须都接得住。 - 第二行对「不是数组」的输入返回空串。如果对面发来一个
null、一个数字、一个对象,函数不会抛异常,只是返回空。一个健壮的工具函数不应该让整个程序因为输入畸形而崩掉。 image换成[图片]、face换成[表情]。为什么?因为 OWL 只能处理文字。把图片变成一个占位符号,好处是模型能知道「这里发生过一件事」,而不是以为这句话只有一半。at换成@QQ号。这样isAtBot之外的地方也能看出「这里 @ 了人」。而stripAt会在之后把 @ 去掉,把干净的文本交给 AI——为什么给 AI 的文本要去掉 @?因为「@owl 我最近很累」和「我最近很累」对模型来说意思一样,但前者会浪费 token,还可能让模型学着你 @ 别人。这是第八章会讲的提示词卫生。reply直接返回空串。因为引用消息的正文不在这一段里,而引用本身对理解这句话通常没有帮助。default兜底成[类型名]。这一行是整个函数最有经验的地方:标准是会扩展的。以后 OneBot 加了新的消息段类型(json、mface、video……),这段代码不会崩、不会静默丢掉,而是老老实实显示[json]。面对未知输入,宁可显示得难看一点,也不要假装它不存在。
想一想segmentsToText 把 reply 段丢掉了。想一想:这样做在什么场景下会出问题?如果用户是「引用着 OWL 前面说过的一句话」来提问的(比如引用后只打一个「为什么」),OWL 收到的文本会变成孤零零的三个字——它还能正确回答吗?你会怎么改进这个函数?
10.3 echo 机制:一个没有框架的 RPC
现在讲本章技术上最精巧的一段。
先说问题。OWL 需要在同一条 WebSocket 连接上做两件方向相反的事:
- 收:NapCat 不断推事件过来(新消息、登录通知)。
- 发:OWL 不断发指令过去(「把这个私聊消息发出去」、「把那个群消息发出去」)。
第一条没问题。麻烦在第二条:OWL 发出一条「请发送这条私聊消息」的指令后,怎么知道它成功了?
这条连接上,NapCat 也会主动推消息。所以 OWL 收到一段 JSON 时,它面临一个判断:这到底是一份「新事件」,还是「我某次请求的回复」?
解决的钥匙是 echo。OWL 每次发指令时,都自己造一个唯一的编号塞进去:
let seq = 0;
const pending = new Map();
class OneBotClient {
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 }));
});
}
▲ bot.js 里的真实代码(节选,=> 已转义)。这是整个 OWL 里最值得反复读的一段。
现在逐行拆开它,看看一个「一问一答」的系统最少需要什么零件:
| 这一行 | 它在做什么 | 少了它会怎样 |
|---|---|---|
let seq = 0 | 一个全局计数器 | 没有它,编号可能重复,配对就会错乱 |
const pending = new Map() | 「等着被回复的请求」的登记本 | 没有它,收到回复时不知道交给谁 |
if (this.ws.readyState !== this.ws.OPEN) return resolve(null) | 连接不通就立刻放弃 | 你会一直等一个永远不会来的回复 |
echo_${++seq}_${Date.now()} | 造一个唯一编号:序号 + 时间戳 | 只用序号,进程重启后会重复;只用时间戳,同一毫秒内会撞车 |
setTimeout(..., timeoutMs) | 超时保障:15 秒没回音就放弃 | 对方挂了,你的程序就永久卡在这里(第 5 节说过的「没有超时的网络请求是灾难」) |
pending.set(echo, { resolve, timer }) | 把「编号 → 回调函数 + 计时器」记进登记本 | 回复来了没处去;计时器也清不掉 |
this.ws.send(JSON.stringify({ action, params, echo })) | 把请求发出去。注意 JSON.stringify | 你发的是一个 JS 对象,而线上只能走文本;忘了 stringify 会直接报错 |
再看收到回复的那一端(wss.on("connection") 里的 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;
}
▲ 真实代码。这五行半,是一个「请求—响应配对系统」的收尾部分。
它的逻辑是:如果这段 JSON 里有 echo,而且这个 echo 在我的登记本里,那它就不是新事件,而是我某次请求的回复。然后:清掉超时计时器、从登记本上划掉、把结果交出去。
还有两处细节,是这个实现「做得对」的地方:
clearTimeout(timer)放在最前面。如果不清理,那个 15 秒后触发的计时器还会继续跑——它会在结果已经交付之后,再去pending里删一次、再 log 一句「API 超时」。你会看到假的超时日志,然后开始怀疑网络。凡是「成功路径」和「超时路径」二选一的地方,成功时必须把另一条路关掉。- 判成功用的是
data.status === "ok" || data.retcode === 0。这是 OneBot 标准里响应对象的两个字段(status通常是ok/async/failed,retcode是数字返回码)。用「或」而不是「与」,说明作者知道不同协议端可能只填其中一个。面对一份「有多个等价字段」的标准,宽容一点没有坏处。
你可能已经意识到了:这整套东西——唯一编号、登记本、超时、配对——有一个正式的名字叫 RPC(Remote Procedure Call,远程过程调用),意思是「像调用本地函数一样调用远处的一个函数」。
成熟的框架会给你封装好的 RPC 实现。OWL 没有用框架,它用 25 行代码,手工做出了一个 RPC 的最小内核。这也是为什么我说这段代码值得反复读:你以后会用到各种高级的框架和库,但它们的骨架就是这 25 行。
想一想这段实现有两个明显的简化,你能找出来吗?提示一:pending 里的条目如果在 15 秒内既没收到回复、也没超时,会一直留着吗?提示二:如果 NapCat 收到了请求、发消息也成功了,但它的回复在网络上丢了,会发生什么?第二题没有标准答案,但它是所有「不可靠网络上的可靠调用」都要面对的根本问题,第五章和第八章都会再遇到它。
10.4 meta_event 与 lifecycle:机器人登录成功是怎么知道的
下一个问题是:OWL 怎么知道 NapCat 登录成功了?
答案在 OneBot 的元事件里。元事件和聊天无关,它只描述 OneBot 自身的运行状态。在 bot.js 里:
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;
}
▲ bot.js 里的真实代码。这一段是整个日志里最让人安心的两行。
OneBot 标准规定,lifecycle(生命周期)元事件有一个 sub_type 字段,取值可以是 enable、disable、connect。其中 connect 表示「WebSocket 连接成功」,而且标准明确说:connect 只在正向 WebSocket 和反向 WebSocket 通信方式下会收到。
OWL 用的正是反向 WebSocket,所以它会收到这个事件——这就是它知道「机器人已登录」的确切机制。
这段代码里还有一个细节值得学:client.selfId = data.self_id。机器人把自己的 QQ 号存在客户端对象里。为什么需要存?因为第 10.1 节的 isAtBot 要拿它和消息段里的 qq 比较——「有人在群里 @ 的那个号,是不是我自己?」这个判断完全依赖 self_id。
再看紧随其后的一行:client.selfId ??= data.self_id;。这是「兜底赋值」:如果某个事件里带了 self_id 而我还没记下来,就顺便记一下。为什么不假设「一定有 lifecycle 事件先到」?因为网络上的事件顺序不是你能保证的,重连时也可能丢掉一些。「不要依赖『另一个消息一定会先到』」是一条很重要的工程直觉——它同样是第五章要讲的异步世界里最常见的 bug 来源。
10.5 token 鉴权:那行看似多余的判断
最后一个问题:如果有人冒充 NapCat 连到 OWL 的 3001 端口呢?
这就是 wss.on("connection") 里那段代码要挡的事:
wss.on("connection", (ws, req) => {
// 鉴权:云端部署时 3001 端口可能对公网开放,没有 token 等于谁都能冒充协议端。
// OneBot 约定:token 通过 Authorization: Bearer xxx 或 ?access_token=xxx 传入。
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;
}
}
▲ bot.js 里的真实代码(=> 已转义)。注意:真实的 config.json 里有一个真实的 token 字符串——这段代码在保护它,所以永远不要把那个值抄进任何文章、截图或聊天窗口。
逐段读:
const token = cfg.server?.token || "";:从配置里读 token。如果没配,就是空字符串。if (token):这一行的意思很值得琢磨——只有当你配了 token,才检查。也就是说,不配 token 就等于没有门。这是「默认不安全」的典型例子。它的存在是为了向后兼容(本地开发时配起来麻烦),但它的后果是:你以为「我什么都没配,所以没事」,实际是「我什么都没配,所以谁都能进来」。const auth = req.headers["authorization"] || "";:先按 OneBot 规定的传法找——Authorization头。注意 Node.js 里头的名字统一是小写的,所以这里写"authorization"。规范的「反向 WebSocket」一节只规定了这一种;下面那种是兼容写法。auth.replace(/^Bearer\s+/i, "").trim():把Bearer前缀去掉,留下凭证本身。/i表示忽略大小写(bearer、BEARER都认),\s+表示一个或多个空白字符,末尾.trim()去掉多余空格。这三个细节都是被真实世界打磨出来的:有人会写成小写,会多打一个空格,会把空格打成两个。- 如果头里没有,就去查询串里找:
new URL(req.url || "/", "http://localhost").searchParams.get("access_token")。这是协议端文档里的兼容传法——规范只在「HTTP 和正向 WebSocket」那一节明确写了可以把它放进查询串,「反向 WebSocket」那一节只规定了Authorization: Bearer。OWL 的代码两种都收,这是防御性写法:规范没要求,但真实世界里确实有客户端改不了请求头(见 5.3 节)。「规范没写」不等于「不能支持」,只等于「你不能依赖它」。 if (provided !== token):不匹配就记日志、然后ws.close(1008, "unauthorized")关掉连接。1008是 WebSocket 的标准关闭码,含义是「违反策略」。return:这一行最关键。如果没有它,代码会继续往下走,把连接当成合法连接注册进去——那么前面所有检查都白做了。「检查失败后必须真的中断流程」是安全代码里最经典的一类 bug。
警告这段注释里那句话,请你抄在笔记本上:「3001 端口对公网开放时,没有 token 等于谁都能冒充协议端。」冒充之后能做什么?他可以伪造任意消息送进 OWL——包括让 OWL 拿你的 API 额度去回复任意内容,也可以读到 OWL 发回来的每一条回复。而且,因为 llm.local.json 和 config.json 就在同一个目录里,一个足够深入的程序还可能有别的机会。
所以 OWL 的完整安全策略是两层:第一层是「只监听 127.0.0.1」(第 3.5 节),它让外面根本连不上;第二层是 token 鉴权,它假设第一层万一失效(比如有人改成了 0.0.0.0、或者以后换成容器网络)时还能守住。真正可靠的安全从来不是一道墙,而是几道各自独立的墙。
想一想这段鉴权用的是 provided !== token——也就是「逐字符比较」。
安全领域有一个概念叫「时间侧信道攻击」:如果比较函数在第一个字符不同时就立刻返回,那么「比较花了多久」这件事本身就泄露了「我猜对了几个前缀」的信息。攻击者可以通过反复测量时间,一个字符一个字符地猜出完整 token。
请想两件事:(1)在这个具体场景里,这个攻击现实吗?(提示:攻击者需要多精确的计时?需要多少次请求?)(2)如果要把这个风险降到最低,你会怎么改这段比较?——这个问题不要求你现在答出,但它是你以后读任何「比较密码」的代码时都该有的警觉。
11. 动手:三个能跑起来的小实验
这一章的概念密度是全站最高的一章。所以这一节不再是阅读材料,而是三个必须亲手做完的实验。它们分别对应本章的三条主线:HTTP(发出一次请求)、JSON + API(读懂一次响应)、WebSocket(维持一条连接)。
做完这三个,你对本章的理解会从「我读过」跳到「我见过」。
11.1 实验一:用 curl 直接调 DeepSeek(不写一行代码)
- 先看一眼请求长什么样。在 curl 命令后面加
-v(verbose,啰嗦模式),它会把你发出去的头和收到的头都打出来。你会亲眼看到> POST /v1/chat/completions HTTP/1.1这些行——这不是我在纸上画的,是你的机器真的发出去的。 - 再把响应头单独看一遍。加
-i,你会看到HTTP/1.1 200 OK、Content-Type: application/json、Date这些行,然后才是 JSON 正文。至此 5.5 节讲的三部分,你就全都亲眼见过了一次。 - 故意犯三个错,把状态码记下来:Key 改一个字符(期望 401)、
model改成不存在的名字(期望 400)、把路径里的chat/completions拼错(期望 404)。这三个数字会长在你手上。
11.2 实验二:30 行的 Node 脚本
现在把 curl 那件事写成代码。这个脚本大约 30 行,能读命令行参数、调 API、打印回答。
// ask.mjs —— 用法:node ask.mjs "你的问题"
const KEY = process.env.DEEPSEEK_API_KEY;
const question = process.argv[2];
if (!KEY) {
console.error("先设置环境变量 DEEPSEEK_API_KEY");
process.exit(1);
}
if (!question) {
console.error('用法:node ask.mjs "你的问题"');
process.exit(1);
}
const res = await fetch("https://api.deepseek.com/v1/chat/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${KEY}`,
},
body: JSON.stringify({
model: "deepseek-flash",
messages: [
{ role: "system", content: "你说话很短,一句话之内。" },
{ role: "user", content: question },
],
stream: false,
}),
});
console.log("状态码:", res.status); // ← 先看它,判断「谁的问题」
const data = await res.json(); // ← 这就是 JSON.parse,fetch 帮你做了
console.log("回答:", data.choices?.[0]?.message?.content ?? "(没有内容)");
console.log("用量:", data.usage);
▲ 完整的、可以直接保存为 ask.mjs 运行的脚本。模型名用的是官方文档当前推荐的 deepseek-flash——如果你照抄后拿到 400 且提示带 model 字样,就把这一行换成官方文档当时的推荐值(见 5.8 节的时效性提示)。注意这里用了 await——那是第五章的主角,这一章你只需要接受「它会等结果回来」,细节第五章讲透。
| 这一行 | 它在做什么 | 对应本章 |
|---|---|---|
process.env.DEEPSEEK_API_KEY | 从环境变量读 Key,不写死在代码里 | 第 8.3 节的保管清单 |
process.argv[2] | 命令行参数:argv[0] 是 node,argv[1] 是脚本名,argv[2] 才是你写的第一个词 | 第一章的命令行 |
process.exit(1) | 出错时用非零退出码结束。为什么不是 0?因为 0 表示「一切正常」,脚本调用方要靠这个数字判断成败 | 第九章的自动化验收 |
await fetch(url, {...}) | 发一次 HTTP 请求。fetch 是 Node 内置的,不需要装任何库 | 第 5 节整节 |
method: "POST" | 指定方法 | 第 5.2 节 |
headers: {...} | 两个头:Content-Type 和 Authorization | 第 5.4 节、8.3 节 |
JSON.stringify({...}) | 把 JS 对象变成 JSON 文本——body 里只能放字符串或字节,不能放对象 | 第 7.3 节 |
console.log("状态码:", res.status) | 先看状态码,再解析正文。这是最重要的一行习惯 | 第 5.6 节 |
await res.json() | 把响应正文解析成 JS 对象(等价于 JSON.parse(await res.text())) | 第 7.3 节 |
data.choices?.[0]?.message?.content | 一层一层小心地取。这一串问号和 OWL 里的一模一样 | 第 5.8 节 |
data.usage | 打印用量——养成看账单的习惯 | 第八章 |
运行它:
# PowerShell
$env:DEEPSEEK_API_KEY = "sk-你的key"
node ask.mjs "我最近什么都学不进去"
警告这个脚本没有做错误处理。如果 Key 错了,res.status 会打印 401,然后 res.json() 解析出来的对象里没有 choices,最后你会看到「回答:(没有内容)」——而不是一句「你的密钥不对」。
这就是 8.4 节那个 llmFailureHint 存在的理由。请你亲手把 ask.mjs 改成「先判断 res.status,非 200 就打印一句人话然后退出」,再对照 OWL 那个函数读一遍。你自己写出来的那五行,比读十遍别人的代码都有用。
做完这两个实验,你会第一次拥有一种很具体的感觉:API 不是一个概念,是一个你能随时敲开的门。至于 await 到底做了什么、事件循环怎么调度它,那是第五章(Node.js 与异步)的主线。
11.3 实验三:最小的 WebSocket 服务端与客户端
最后体验「长连接」这件事。你需要先装一个库(这是 OWL 唯一的第三方依赖):
npm init -y
npm install ws
▲ package.json 与 node_modules 是什么,第五章讲。现在你只需要知道它把 ws 这个库装到了本地。
服务端(保存为 ws-server.mjs):
import { WebSocketServer } from "ws";
const wss = new WebSocketServer({ port: 3001, host: "127.0.0.1" });
wss.on("connection", (ws, req) => {
console.log("有客户端连进来了");
ws.on("message", (raw) => {
console.log("收到:", raw.toString());
ws.send("服务端收到了:" + raw.toString());
});
ws.on("close", () => console.log("客户端断开"));
});
console.log("在 ws://127.0.0.1:3001 等着…");
客户端(保存为 ws-client.mjs):
import WebSocket from "ws";
const ws = new WebSocket("ws://127.0.0.1:3001");
ws.on("open", () => {
console.log("已连接");
ws.send("你好,我是客户端");
});
ws.on("message", (raw) => console.log("收到:", raw.toString()));
ws.on("error", (e) => console.log("出错:", e.message));
▲ 两个文件、各十来行。注意它们和 bot.js 的关系:服务端这几行,和 OWL 的 wss.on("connection") 是同一个写法;客户端的 ws.on("open"),和 NapCat 的内部逻辑是同一个道理。
- 客户端发一条,服务端回一条,但这条连接一直没关。这就是「长连接」。对比一下 curl——curl 命令结束,连接就没了。
- 服务端什么都没等。它
listen完之后进程就停在那里,等着。回想序章里「让程序住进云端等人来敲门」那句话——你刚刚造出了一个最小版本。 - host 写
127.0.0.1的效果:把客户端里的地址改成你本机的局域网 IP(192.168.x.x),它会连不上。这就是第 3.5 节说的那件事的实验版本。 - 关掉服务端的终端,客户端会收到什么?去试一试。你会看到
close事件被触发。然后想一想:第 9.5 节说「很多断线不会有任何通知」——那你现在看到的这个通知,为什么会有?(提示:这一端是被正常关闭的,操作系统发了 FIN。被掐断的网络线缆不会。)
想一想把服务端的 ws.send(...) 挪到 wss.on("connection") 里、ws.on("message") 外面,会怎样?它会在连接建立的一瞬间就发出去,而不是等收到消息之后。再进一步:如果你想让服务端每 5 秒主动给所有客户端推一条消息(比如「现在时间」),你会怎么写?这个练习就是「服务端推送」的最小形态——也是 WebSocket 存在的全部理由。
12. 排障手册:七种最常见的坏法
12.1 连不上:三个完全不同的原因
症状:机器人不回话,日志里出现 network: 开头的失败,或者你的 curl 直接卡住/报错。
| 层次 | 现象 | 第一条命令 | 为什么是它 |
|---|---|---|---|
| DNS | 报 getaddrinfo ENOTFOUND api.deepseek.com 或 EAI_AGAIN | nslookup api.deepseek.com | 这个名字根本翻译不成 IP,后面所有事都不用查了。ENOTFOUND 是「这个名字不存在」,EAI_AGAIN 是「DNS 服务器没回应」——两个词指向完全不同的方向 |
| 端口/连接 | 报 ECONNREFUSED 或 ETIMEDOUT | curl -v https://api.deepseek.com(看它卡在哪一步) | ECONNREFUSED 是「对方明确拒绝了」——机器活着但端口没开;ETIMEDOUT 是「包发出去了没人理」——通常是被防火墙静默丢弃。这两个错误的排查方向完全相反 |
| 防火墙 | 本地通、云上不通 | sudo ufw status(服务器上) | 云服务器有两层防火墙:云平台的安全组和机器自己的 ufw/iptables。两层都要看,只看一层是最常见的漏查 |
技巧想知道「那个端口开着吗」,最直接的办法是试着连一下。第一章里你有 ss -lntp 可以看「本机在听哪些端口」,但如果目标在另一台机器上,你就得用 curl -v 或 nc -vz 主机 端口 这类工具去敲门。「看本机」和「敲对方」是两件事,别混。
12.2 收到重复消息:两个必须分清的原因
症状:同一个人的同一句话,OWL 回了两次甚至三次。
原因一:重连叠加。连接断开又重连时,如果旧连接没被彻底关掉,你可能同时持有两条连接,同一条事件会被推两遍。
原因二:多实例。最常见的是「我本地也跑着一个,服务器上还跑着一个」——两个进程都在监听,都在回。
第一条命令:
ps aux | grep node # 看看到底有几个 node 进程
ss -lntp | grep 3001 # 看看谁占着 3001 端口
为什么是它:一个端口只能被一个进程监听(除非用了特殊选项),所以「两个实例」通常会以「第二个启动时报端口被占用」的形式暴露。如果你没看到这个错却又收到重复回复,那就是连接层面的问题,去查日志里「协议端已连接」用了几次。
12.3 乱码:不是坏了,是理解错了
症状:日志里、收到的消息里出现 我 这样的怪字符,或者一排问号 ???。
第一条命令:
file -i bot/config.json # 看看这个文件到底是什么编码
hexdump -C bot/config.json | head -1 # 看头三个字节:ef bb bf 就是 UTF-8 BOM
为什么是它:编码问题不能靠肉眼判断——同一个文件用不同编码读出来的字符完全不同。file -i 会给你一个客观答案。hexdump 那一条更狠:它直接看原始字节,连 BOM 这种看不见的东西都能抓出来——这正是 7.5 节那个坑的排查工具。
12.4 401:Key 的问题,但有四种
症状:日志里出现 ❌ LLM HTTP 401,用户收到「我的 AI 密钥好像不对」。
原因:不要以为 401 只有一种。至少四种:
- Key 值错了——复制时少了一位,或者把
sk-前面的空格复制进去了。 - 格式错了——
Bearer后面少一个空格、多了换行,或者整个前缀写成token。 - 读错了文件——Key 写进了
config.json但代码优先读llm.local.json,而那个文件里的值是旧的。OWL 的读取优先级是环境变量 →llm.local.json→config.json,优先级高的那个如果是个错的,你就永远看不到低优先级里那个对的。 - Key 被作废了——你在控制台重新生成过,旧的自然失效。
第一条命令(用 curl 绕开所有代码):
cd qq-bot/bot
node test-llm.mjs
为什么是它:OWL 项目本身提供了这个脚本(见 AI-SETUP.md),它会直接调用你配置的接口、把原文和清洗后的结果都打出来。这一步的作用是把「代码的问题」和「Key/配置的问题」彻底分开——这也是第 5.8 节用 curl 的全部理由。
如果这个脚本也 401,那就一定不是代码的事,回去把「四种原因」逐条核一遍。如果它成功了,而机器人仍然报 401,那问题就在「机器人进程读到的那份配置」和「你改的那份」不是同一份——常见原因是改了文件但没重启,或者改的是另一台机器上的副本。
12.5 429:限流,以及为什么不能立刻重试
症状:日志里出现 http-429;用户偶尔收到「请求太频繁了,缓一下」。
为什么不能「立刻再试一次」:429 的意思是「你已经太快了」。你立刻再发一次,只会让对方的计数器更高。正确做法是退避重试:等 1 秒、2 秒、4 秒、8 秒(每次翻倍)再试,并且设一个次数上限。更重要的判断是「这个错误值不值得重试」——429、超时、5xx 值得;401 和 400 绝对不值得,因为你的 Key 不会因为多试几次就变对,你写错的 JSON 也不会自己变成对的。
警告「无脑重试」是新手最爱写、也最危险的一段代码。一个 while 循环里不断重试一个 429 或超时的请求,会让你的程序在对方已经喊停的时候变本加厉地打过去。轻则被限流更久,重则触发风控、账号被限制。第八章会讲完整的重试与退避策略,包括怎么区分「可以重试」和「重试只会更糟」。
12.6 延迟高:模型慢还是网络慢
症状:用户抱怨回复要等五六秒。
原因:这一步必须靠测量,不能靠感觉。因为这两件事的处置方式完全不同:网络慢要查链路,模型慢要改参数。
第一条命令:给 curl 加一个「只看耗时」的输出:
curl -o /dev/null -s -w "总耗时 %{time_total}s | 建连 %{time_connect}s | 首字节 %{time_starttransfer}s\n" \
https://api.deepseek.com/v1/chat/completions \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $KEY" \
-d '{"model":"deepseek-flash","messages":[{"role":"user","content":"你好"}]}'
为什么是它:它把总耗时拆成了三段,你立刻就能读出来:
| 指标 | 含义 | 它大了说明什么 |
|---|---|---|
time_connect | TCP 建连花了多久(含 DNS) | 网络/DNS 慢。正常应该只有几十毫秒 |
time_starttransfer | 从开始到收到第一个字节 | 这中间主要是「服务器在想」。它大 = 模型慢 |
time_total | 整个请求花了多久 | 和上一项相减,是「传输正文」的时间。正文大时这一项才明显 |
OWL 的实测数据是「单次回复约 1 秒(DeepSeek 正常时)」。如果你测出来的 time_starttransfer 就是个一两秒,而 time_connect 只有几十毫秒——那么慢的是模型,不是网络,去调 maxTokens 或者换模型,不要折腾服务器。
想一想为什么 -o /dev/null 要加在这一行里?(提示:你并不关心回答的内容,只关心耗时;如果不丢进 null,整段 JSON 会打满屏幕,你反而找不到那一行统计。)再想一个更深的问题:为什么第 9.5 节的「假活」不能靠这种「测一次响应时间」发现?
12.7 一张总表
| 症状 | 最可能在哪一层 | 第一条命令 |
|---|---|---|
| 连不上(域名错) | DNS | nslookup api.deepseek.com |
| 连不上(被拒) | TCP / 防火墙 | curl -v … |
| 重复回复 | 应用 / 进程 | ps aux | grep node |
| 乱码 | 编码 | file -i 文件名 |
| 401 | 鉴权 | node test-llm.mjs |
| 429 | 限流 | 看 bot.log 的时间分布 |
| 延迟高 | 先测再判断 | curl -w "%{time_total}" |
而它们背后是同一个信念:每一层都只解决一个问题,所以每一个坏法也只会发生在某一层里。
自查:你是不是真的懂了
先自己回答,再展开参考答案。凡是你只能靠「感觉」回答的,说明还要再读一遍。
-
用一句话说清:为什么
server.listen(3001, "127.0.0.1")比server.listen(3001)更安全?后者到底监听在哪个地址上?参考答案
listen(port)不写地址时,Node 默认监听::或0.0.0.0,也就是本机所有网卡,包括那张连公网的网卡。于是只要云平台的安全组放行了 3001,全世界都能连上来。
写127.0.0.1则只在回环接口上监听——这个接口的数据包根本不出网卡,外网的包即使打到了这个端口也不会交给该进程。
常见错误答案:「127.0.0.1 是加密的」「加了密码」。都不是——它和加密无关,它的本质是「这个服务在公网上不存在」。 -
把一次 HTTP 请求的四个部分和一次响应的三个部分各写出来。然后回答:头与正文之间那个空行,为什么必须存在?
参考答案
请求四部分:方法 + 路径 + 版本、头、空行、正文。响应三部分:状态行(版本 + 状态码 + 短语)、头、正文(同样有空行分隔)。
空行必须存在,是因为 HTTP 的头数量是可变的(可以有 3 个也可以有 30 个),接收方需要一个明确的信号来判断「头到此为止」。一个孤立的空行就是这个信号。没有它,解析器无法知道下一行是头还是正文的第一个字节。 -
场景判断一:你发出一段 JSON,服务器回
400,错误信息里写着model not found。这是谁的问题?你的第一步应该做什么?参考答案
4xx 表示客户端(你)的问题,所以不要去等、不要去查对方状态页。这条信息已经指明了原因:你请求的模型名对方不认识。
第一步是打开服务商控制台或官方文档,核对当前可用的模型名——服务商会下线、重命名模型。改完名字再试。
错误做法:「可能是服务挂了,等一会儿」——400 永远不会因为等待而好转;「可能是网络问题,重试」——你只是在重复发同一个错误的请求。 -
场景判断二:你的机器人突然不回话了。日志里最后一行是
❌ LLM HTTP 429。请问:这是 OWL 自己的限流,还是服务商的限流?你怎么证明?参考答案
是服务商的。理由:
llm.mjs自己的限流(#checkLimits)根本不会发出 HTTP 请求,它直接返回limited:...这样的 reason,前缀是limited:,不是http-429。所以只要日志里的前缀是http-429,就说明请求真的发出去了,是对方的服务器回的这个码。
证明办法:看日志前缀。这也解释了 8.4 节那个函数为什么要按前缀分类——前缀决定排查方向。 -
场景判断三:用户发来一条 600 字的私聊消息,OWL 回「你这条太长了(超过 400 字),截短点再说~」,没有调用 DeepSeek。请说出这个提示是从哪一行代码出来的,以及它为什么不是一个 400 状态码。
参考答案
它来自
llm.mjs的chat():if (text.length > maxChars) return { ok: false, reason: \`too-long:${maxChars}\` };然后由bot.js的if (r.reason.startsWith("too-long"))分支发出那句提示。
它不是 400,因为请求根本没有发出去——这是客户端本地的一道守门,目的是「不要花钱去问一个我们已知会被拒绝的问题」。maxInputChars: 400这道闸门的作用是防有人贴长文烧钱。
可以进一步想:如果删掉这道闸门,会发生什么?(长文本照发 → token 费用飙升 → 还可能因为超过上下文窗口被服务端拒绝。) -
解释机制:「HTTP 是无状态的」这句话,怎么解释「OWL 用
/重置能清掉上下文」这件事?参考答案
因为「记得上一轮聊过什么」这件事,从来不是 HTTP 提供的。HTTP 每次都只送来一条独立的请求,它不会附带「我们刚才聊了什么」。
OWL 的「上下文」是它自己在服务端存的一份 Map(this.history,用群号或 QQ 号当 key),并在每次调用模型时把这份历史拼进messages里一起发出去。所以/重置能生效,是因为它执行的是llm.clearHistory(sessionKey)——删掉的是 OWL 自己那份 Map,不是 HTTP 里的任何东西。
由此可推:进程重启后上下文容易丢(Map 在内存里),所以 OWL 才要写history.json兜底。这份记录的轮数上限与实际时长上限各是多少、代码默认值和配置文件实际值为什么不同,见 5.8 节。这正是第六章要解决的问题。 -
辨析易混:
{"name": "OWL", "tags": ["a","b"],}这段文本——它是合法的 JSON 吗?它是合法的 JavaScript 吗?为什么这个区别会咬人?参考答案
不是合法 JSON(最后一个元素后面有尾逗号);如果把它当成 JS 的对象字面量写在代码里,则是合法的(JS 允许尾逗号)。
它会咬人,因为两种语法长得几乎一样,而人脑靠「看起来对」来判断:你在 JS 代码里写惯了尾逗号,改config.json时顺手加一个,机器人就起不来了。而且报错信息往往是Unexpected token }这种不直观的话。
记住这个分界:写在.js/.mjs文件里的是 JS 代码,宽容;写在.json文件里或通过网络传输的是 JSON 数据,严格。 -
排查故障:你在服务器上用
nano(Linux 下的编辑器)修改了config.json,保存后机器人启动立即报Unexpected token \uFEFF in JSON at position 0。请给出完整的三步处理,并解释这个报错里的每个词。参考答案
报错含义:
Unexpected token=遇到了一个不该出现的字符;\uFEFF=BOM 字符的码位;position 0=在第一个位置。
三步:(1)确认它确实是 BOM——hexdump -C config.json | head -1,看头三个字节是不是ef bb bf;(2)去掉 BOM——用不带 BOM 的方式重写这个文件(很多编辑器有「UTF-8 无 BOM」选项,或用一个读进来剥掉再写回的小脚本);(3)重启并确认。
值得多想一层:OWL 的readJson已经会剥 BOM 了,为什么还会报这个错?可能的原因有两个——报错来自别的 JSON 读取点(不是readJson),或者你改的不是readJson读的那个文件。「我明明修了」和「修的地方对不对」是两件事。 -
排查故障:NapCat 显示「已连接」,OWL 日志里有「协议端已连接」,但用户发消息完全没反应,
bot.log里也没有任何新消息的日志。请按 2.3 节的四问给出你的排查顺序,并说明每一问在这一场景下具体查什么。参考答案
第一问(能 ping 通吗):不用查。连接已经建立,说明 IP 层没问题——能正确跳过不需要查的层,本身就是分层排查的价值。
第二问(端口开着吗):已经确认,连接就在 3001 上。
第三问(应用回什么):这是主战场。连接建立了,但事件没有到达 OWL 的应用逻辑。要查的是「NapCat 那边有没有真的在推事件」——如果 NapCat 本身没登录成功,它会保持 WebSocket 连接不断,但没有任何消息可推。所以去看 NapCat 的日志和登录状态。
第四问(业务逻辑):暂时不用查,因为连「收到事件」这一步都没发生,业务逻辑根本没被执行。
这道题的关键是:「连接建立」和「消息能到」是两个不同的成功,前者只覆盖了 TCP 层。 -
动手写 + 开放:先写出
segmentsToText里reply那一段的另一种处理方式(保留被引用消息的引用关系),然后回答开放问题:在 OWL 的场景里,「引用消息」到底该不该送进模型?参考答案
改进方向(示例):不要直接丢掉,而是留一个标记,例如
case "reply": return \`[引用:\${s.data?.id ?? ""}]\`;更好的做法是在onEvent里先用get_msg把被引用的那条消息取出来,拼成「(对方引用了你刚说的话:……)」,再交给模型。
开放部分的思考要点:支持送进去的理由——用户引用后只打一个「为什么」,如果没有被引用的上下文,这句话对模型毫无意义,必然答非所问;反对的理由——会多一次 API 调用、多耗 token,而且引用链可能无限延伸(引用一条引用……)。
没有唯一答案。但这道题要你体会到的是:协议字段「怎么设计」和产品「怎么体验」之间的那段距离,是靠人判断的,不是靠标准规定的。
自问自答:把知识变成你自己的
这些问题没有标准答案,有些甚至没有答案。请不要在页面上浏览,拿一张纸写下来。写的过程就是思考的过程。
- 在「一条消息的旅程」这八步里,哪一步是你到现在还说不清的?如果只能再去弄懂一处,你会选哪一处,为什么?
- 如果你要把「HTTP 是无状态的」讲给你父母听,你会用什么日常例子?讲完之后,你觉得他们最可能追问什么?
- OWL 只监听
127.0.0.1这件事,你觉得作者是先想到「安全」,还是先想到「省事」?这两种动机下的设计,长期会走向不同的地方吗? - 「反向连接」这个设计里,是 NapCat 承担了「主动连」的责任。那么在你自己未来的项目里,哪些麻烦是你希望别人主动来承担、哪些是你必须自己扛的?这个判断有标准吗?
- 你有没有见过哪一次故障,是因为「从最上层的业务逻辑开始查」而浪费了大量时间?那次如果按四问的顺序,会在哪一步停下?
- 那个 BOM 的坑,作者选择「在自己的程序里宽容处理」。但如果换成「数据正确性极端重要」的场景(比如银行转账),你还会选择宽容吗?宽容和严格,各自的风险是什么?
- 心跳能发现假活,但它本身也是流量和一次小小的失败可能。如果让你给 OWL 定心跳间隔,你会定多少?你会用什么标准来判断这个数字「够好」?
- 你注意到 12.5 节说「401 不应该重试」了吗?请自己想出另外两个「绝对不该重试」的错误,并说明理由。(提示:想想什么情况下,重复做同一件事只会让情况更糟。)
- 这一章里出现了很多「翻译」:错误码翻译成人话、消息段翻译成纯文本、角色翻译成消息。你觉得自己在这些翻译的哪一环最容易出错?为什么?
- 如果明天 DeepSeek 的接口全部换了一套格式,OWL 里哪些文件需要改?如果答案是「只有一个文件」,那说明了什么设计做对了?
小结
这一章说了四件事。
一、一条消息的路是可以被完整走完的。别人的手机 → 腾讯 → NapCat(协议端)→ 反向 WebSocket → OWL → HTTPS → DeepSeek → 原路返回。这条路上有八步,但只有三种技术:谁送到哪(IP 与端口)、怎么保证送到(TCP)、送到之后说的什么意思(HTTP / JSON / WebSocket)。你以后遇到任何「连不上」,都用四问从下往上查:能 ping 通吗 → 端口开着吗 → 应用回什么 → 业务逻辑对不对。
二、HTTP 是格式,不是魔法。请求四部分(方法、路径、头、正文),响应三部分(状态行、头、正文),状态码第一位就是分类。它最重要的性质是无状态——服务器默认不记得你。所以「记住」这件事从来不是协议给的,是程序自己在服务端做出来的:Cookie、会话、Token、以及 OWL 那份用群号当键的 history Map。理解无状态,你就理解了登录、记忆、缓存这一整片领域为什么会存在。
三、JSON 是严格的语言,错误都有原因。双引号、无注释、无尾逗号、无 undefined;stringify 和 parse 必须配对。解析失败只有三类原因:不是 JSON、有 BOM、语法有错。OWL 里那两行 .replace(/^\uFEFF/, "") 不是多余的谨慎,而是把「Windows 会写 BOM」这个环境事实,当作设计输入来处理——这是本章最值得抄走的一个工程态度。
四、连接的方向,比连接本身更重要。API 是契约(路径 + 方法 + 请求格式 + 响应格式),Key 是门票,而错误处理要分层:底层准确分类,上层翻译成人话。WebSocket 把「一问一答」变成「一直开着」,而反向让主动连接的责任落在协议端——于是 OWL 只需要监听回环地址,不需要公网 IP、不需要开放端口、不需要证书。「让能被访问的那一方主动连过来」,是本章最漂亮的一个设计。
这一章是全站最难的一章。它的难不在于概念深,而在于概念多——十几个缩写挤在几页纸里,谁都记不住。所以我给你留了一条出路:不要去背它们,去走一遍那条路。
当你能对着那张图,用自己的话把「一句话的一生」讲完,并且在讲到某一步时能随口说出「这一步是 POST,头里带了 Bearer,回来是 200 或者 429,如果是 429 要退避」的时候——你就不再是「知道有个东西叫 API」的人了。你已经见过它了。
到那时,序章 2.2 节那九步里的每一个词,都会变成你亲手碰过的东西。
延伸:可以去哪里继续
网站
- MDN · HTTP 中文文档——这一章最该常备的一份参考。它把方法、状态码、头字段逐条列全,而且有中文。什么时候去看它:当你想确认某个状态码的确切含义、或者遇到一个不认识的响应头时,来这里查,不要靠猜。
- OneBot v11 标准(GitHub)——OWL 用的这套接口标准的完整规范:通信方式、消息段类型、API 列表、事件字段。什么时候去看它:当你想给 OWL 加一个新功能(撤回消息、禁言、发图片)时,先来这里找对应的 action 和参数。你会发现在这份文档里,你能读懂大半了。
- DeepSeek API 文档——请求参数、返回结构、错误码表、模型与价格。什么时候去看它:每次你打算改
config.json里llm段的时候,以及每次收到http-开头的失败时。特别是「错误码」那一页,它和本章 8.4 节是同一件事的官方版本。 - httpbin.org——一个「专门用来被请求」的测试服务:你可以让它返回你发的头、故意返回某个状态码、故意延迟几秒。什么时候去看它:当你想验证自己对 HTTP 的理解、又不想拿真实 API 冒险时。它是练习 curl 最安全的沙盒。
- MDN · WebSocket API——浏览器端 WebSocket 的完整接口说明,含
onopen/onmessage/onclose的语义。什么时候去看它:做完 11.3 节那个实验之后。你会发现你写的那十几行,就是这份文档的最小例子。
值得读的书(三本,按顺序)
- 《网络是怎样连接的》(户根勤)——一本把「从你在浏览器里输入网址到页面显示」这条完整链路走了一遍的书,特点就是一次也不跳步。它和本章第 1 节的写法是同一个思路,只是走得更远(一直走到网卡和交换机)。什么时候读:读完本章、想真正搞懂「数据包在路上经历了什么」的时候。零基础可以直接读,它几乎不用数学。
- 《图解 HTTP》(上野宣)——图多、字少、覆盖全,把 HTTP 的方法、状态码、头、Cookie、HTTPS 讲成了一个体系。什么时候读:当作本章的「配套练习册」——读完一节,去对应的章节再看一遍图,你会发现自己看得比上次快。它是这三本里最容易上手的一本。
- 《HTTP 权威指南》(David Gourley 等)——厚,全,是这一领域的标准参考书。什么时候读:现在不要读。等你做完第七章的部署、真正被缓存、代理、连接管理这些问题绊过之后,再来查它对应的章节,你会读得进去。现在硬读,只会得到挫败感——这也是序章第五节说的「不要一开始就扎进原理层」。
提醒不要收藏了就算看过。这一章只要求你做一件事:把 11.1 节和 11.2 节那两个实验亲手跑一遍——用 curl 调一次 DeepSeek,再写那个 30 行的脚本。加在一起不到一小时。做完之后,你对「API」这个词的感觉会永久地改变:它不再是别人嘴里的一个术语,而是一个你能随时敲开的门。