# 说话方式与节奏 > 攒批、气泡条数、分条与打字延迟、引用 / @ 的指向、`[[@昵称]]`,以及「没有正文的消息」。 > 人格与表达文件见 [persona-style.md](persona-style.md)。 ## 像真实群友一样说话 默认配置下,QQ 会话的自动回复会刻意模仿真人在群里聊天的样子。六件事: **0. 一轮回几个气泡:模型写几个就发几个(插件不设上限)** 真人群友有时只回一句、有时连发两三句。模型按 `STYLE.md` 自己决定写几条,插件**一条不删、 一条不并**。以前那两套机制都已经删掉:「发送前掷骰决定发到第几个」会把模型写在后面的答案 一起吞掉;「超出的部分并进最后一条」也会把换行写出来的几条挤成一大段(真实事故:私聊里 「面吧,什么面都行」只发得出前半句)。 - 模型写几个气泡就发几个,**没有条数上限**;换行就是分条,插件照原样发; - `burstMaxMessages` 是唯一可选的条数上限,默认 `0`(关着),只有显式设了才会并条; - 含图段原样按顺序发,图不会被合并或吞掉; - 发送途中对方又说话了,聊天型剩下的气泡就不再发(已经发的第一条留着)—— 真人也是这么停的; 信息型(去掉空白超过 `burstSingleMaxChars`,默认 100 字)整条发完; - 短气泡句末唯一的「。」在发送前剥掉(`stripShortPeriod`),问号、感叹号、句中句号都不碰。 **1. 几个人同时 @ 它,它只接一轮(`batchWindowMs`)** 群聊里最扎眼的「人机感」是:三个人几乎同时 @ 机器人,机器人就一条一条地分别答复三个人。 根子在 Harness 的收件箱 —— `next-turn` 每次只认领**一条**消息,也就是「一条消息 = 一轮 = 一条回复」,逐条投递必然逐条回复。 所以插件在投递前先攒一下:收到一条要回的消息后,先等 `batchWindowMs`(默认 2 秒) 看还有没有下一条。窗口内到的消息会攒成**一条** user message,每行标出是谁说的, 并在开头交代一句来由: ``` (下面 3 条是几乎同时到的,kaguya、iroha、mutsumi 都在跟你说话) kaguya: 你俩在吗 iroha: 帮我看看这个 mutsumi: 我也想问 ``` 于是一轮里模型就同时看到三个人的话,回一条、把该接的接住;「QQ 渠道」小节里的 「几乎同时在跟你说话的是:…」也会把这几个人都列出来。要点: - **只有一条消息时完全按原样投递**:不加表头、行为与以前一模一样。 - 窗口从**最后一条**往后算(还有人接着说就再等一个窗口),但整批以第一条到达时刻为起点 卡一个上限 `batchMaxWaitMs`(默认 5 秒)—— 否则群里你一句我一句地 @ 下去, 机器人会永远不吭声。 - 一批都是**同一个人连着发的**时,回复仍然指向他最后那条;一批里**有好几个人**时 不挂任何指向 —— 引用谁的截图都像是只在回他。 - 私聊同样适用:一个人连发几句,本来也常常是同一个意思。 - `/reset` 会先把攒着的那批投出去再开新一轮(那些话是重置前说的)。 - `batchWindowMs: 0` 关掉攒批,回到「来一条回一条」。 **1.1 长任务期间的 @:先攒一波,忙完挑着接** 它在跑一个几分钟的任务(写脚本、整理资料、翻记录)时,群里照样有人 @ 它。旧行为是 「每 `batchMaxWaitMs` 一批排队」—— 十分钟能攒出上百批,于是长任务一结束,它连着刷出 几十上百条回复,每一条都在回答几分钟前的问题,每个 turn 还要把完整上下文重发一遍。 现在这些消息先**只攒不投**:等它闲下来(那一轮结束、收件箱里也没有排着的),把这一整段 合成**一条** user message、只开一轮。交给模型的样子是: ``` (你刚才在忙别的事,下面这些是那期间陆续到的消息,你现在才抬头看到) kaguya(8 分钟前): 帮我看看那个报错 iroha(3 分钟前): 在吗 mutsumi(40 秒前): 那个图我找到了 (更早还有 17 条没贴出来;想查就用 qq_transcript 翻记录) 要接就接还活着的:谁还在等、哪句还没被别人答掉。别把它们当成刚发生的事,也别一条一条 点着名字回答。想叫谁就用 [[@昵称]];都过去了,就只写 SKIP。 ``` 几条要点: - **判断权在模型**:插件只给「这些话各自多旧」这个事实,接不接、接哪句由它决定;真的都过去 了就写 `SKIP`,一条都不发。插件不按年龄替它判死活。 - **不挂引用、之后不补一句**:积压的一波里每一条都是几分钟前的,引用谁都是在回一个过期 话头;刚接完一大段旧话再追一句「啊对了」只是噪音。 - **三个上限**:`batchBacklogEntries`(默认 20:最早 2 条 + 最近的一截,中间折成「更早还有 N 条」)、`batchBacklogChars`(默认 2000)、`batchBacklogHoldMs`(默认 2 分钟)。攒到条数 上限之后还一直有人 @,就只留「最初的由头 + 最近的」;超过憋的上限它还忙着,这一波照旧投 进队列(退回「排队等它忙完」的老行为)—— 它卡住了也不能让这些消息永远不投。 - **私聊同样适用**:一个人连着发几句、而它正忙,也会合成一轮回。 - **`/reset` 仍然先投**:那批话是重置前说的,强制投出去再开新一轮(随后旧 Agent 被销毁, 等于它们不会真的有回复 —— 与以前一致)。 - **撤回在积压波里降级成「当没收到」**:隔八分钟再为一句撤回调侃一句,不合时宜。 - 关掉 `batchBacklog` 就回到旧行为;`batchWindowMs: 0`(来一条回一条)时这一组不生效。 **2. 它看得到群里在聊什么(`ambientMessages`)** 只有**被 @ 的那几句**会作为 user message 送进会话(前后脚到的会攒成一条,见上一条); 其余群消息进一个每群独立的环形缓冲 (`{昵称: 内容}`,最多 `ambientMessages` 条),在每次提示词装配时渲染进「QQ 渠道」小节: ``` 群里最近的消息(旧 → 新;都不是发给你的,只是让你知道大家在聊什么,不必逐条回应): kaguya: 晚上吃啥 iroha: 随便啊,你们定 kaguya: [图片] ``` 这样模型是「读着聊天记录回话」,而不是每句都像客服首问。要点: - 缓冲在**内存**里(进程重启即空),且不会因为一条没人 @ 的消息就凭空建出会话。 - 只对**允许的群**记账(`allowGroups` / `groupChat` 同样适用),机器人自己的消息不进缓冲。 - 被 @ 的那条不进缓冲 —— 否则同一句话在 user message 和上下文里各出现一次。 - `/reset` 会连带清掉该群的上下文缓冲(也不带进新一轮)。 **3. 它分开几条、按打字节奏发(`humanize` / `burst*` / `*Delay*`)** 一条 1500 字的回复糊在群里是最不像人的地方。开启 `humanize` 后,回复会拆成 **一条条独立的消息**(不是一条消息里换行),每条发出前等一段「打字时间」: ``` 第 1 条前:readDelayMs + 本条字数 / charsPerSecond 其余每条:本条字数 / charsPerSecond 每次延迟再上下浮动 delayJitter,并夹在 [minDelayMs, maxDelayMs] 内 ``` 拆分的规则是两层,外加一道总闸: - **换行 = 分条**。模型写了几行,就发几条 —— 一行一条独立消息,正文里不会留下换行。 系统提示因此告诉模型「想连说几句就直接换行,每一行都会单独发出去」,同时提醒它别用 换行给一段话排版(那会变成连着刷好几条)。 - **一行太长才自动拆**。单行超过 `burstMaxChars`(默认 60 字,差不多一两句话)时, 按句末标点 → 句内标点 → 硬切的顺序断句,单行最多拆 `burstMaxParts` 条(默认 3), 超出的余量并进该行的最后一条。`burstMaxChars: 0` 只关掉这一层,换行照样分条。 正文里的 `[[图片: …]]` 标记也占一条 —— 只不过它那条发出去的是一张图,见 [只发表情、一个字都不说](media.md#只发表情一个字都不说)。 - **闲聊一条、展开多条由长度自动切换**(`burstSingleMaxChars`,默认 `0` = 关)。它压的是 模型「把一句话写成短句 + 长句两拍」的习惯 —— 那正是群里看到两个气泡的来源。 - 去掉空白后**不超过门槛**(比如 `100` 字)= 闲聊:上面两层拆出来的全部并回**一条**, 群友只看到一个气泡。模型写换行也只是同一条里的换行(跨行用一个空格接),内容一个 字不删。 - **超过门槛** = 真要展开(贴代码、列步骤、详细解释):并成一条反而难读,于是照常拆成 多条短句(一行一条、每条 `burstMaxChars` 以内)。 - 设了门槛之后,系统提示会同时说清这两半:「闲聊就一两句话、一行写完」+「真要展开就 换行,一行一条短句」,不会再给模型一条它照做也做不到的指示。 - **长回复的总条数由 `burstMaxMessages` 封顶**(默认 `0` = 不限)。它只管上面「超过门槛」 的那一半;超出的部分并进第 N 条(同一行内部直接接上,跨行补一个空格)。 设成 `1` 就是**连长的也永远只发一条** —— 内容不会被截掉,只有超过 `maxReplyChars` (协议上限,默认 1500 字)才会被切成两段。想允许最多两条填 `2`。 它只封文字:正文里 `[[图片: …]]` 标记自己那一条照发,不会被并进文字里 (那是模型显式要发一张图)。 **推荐的日常口味**(本仓库默认的 `cordis.patch.yml` 就是这套): ```yaml burstMaxChars: 60 # 展开时每条短句的目标长度 burstMaxParts: 3 # 单行长句最多拆几段 burstSingleMaxChars: 100 # 闲聊(≤100 字)并成一条;超过才走下面的分条 burstMaxMessages: 0 # 长回复不限条数(靠换行拆,别刷屏由提示词兜) ``` 门槛按自己的口吻调:想让更多回复并成一条就调大(比如 `150`),想让它更早开始分条就调小 (比如 `60`)。 默认(60 字上限、15 字/秒、读消息 0.8s)大概是「三句话、六七秒发完」。想让它秒回就调小 这几个值,或者直接 `humanize: false` 回到旧行为(一条整段直接发出,只在超过 `maxReplyChars` 时分段)。 - 每条都还会按 `maxReplyChars` 做一次硬校验 —— 模型偶尔写超长时仍然不会突破协议上限。 - 同一会话的发送是**串行**的:延迟让一次回复持续好几秒,不排队的话下一轮会插进来, 群里看到的就是两条消息交错。 - `qq_send_message` 工具**不做**拟人处理:那是 Agent 的显式动作(「就发这段」), 只按协议上限分段。 **4. 口吻是「普通群友」(`persona` / `instructions`)** 「QQ 渠道」小节现在明确它**是群里的一个普通群友,不是客服**,并给了可执行的约束: 短、口语、可以吐槽/接梗/敷衍/只回两个字,不要列点、不要总结、不要 Markdown (QQ 根本不渲染)、不要自称 AI。`persona` 非空时整段替换这份口吻描述; 格式与身份那几条红线始终保留,`instructions` 仍然是追加。 其中有一条**形状**上的硬要求(内置口吻与「硬性要求」里都写了,`persona` 换掉前面那份也照样生效): > 一条回复只有一个形状:**要么短**(一句话说完,几个字也行),**要么长**(把一件事完整讲清楚)。 > 绝不把「一句短的」和「一段长的」拼在一起,尤其别用「先嘴硬 / 先吐槽 / 先敷衍一句,再补一段正经的」 > 这种两拍 —— 那是模型最容易露馅的地方(傲娇、情绪、态度都该体现在**用词**上,而不是体现在 > 「先来一句、再补一段」的排布上)。 还有一条**是非题**的要求(同样在「硬性要求」里,任何 `persona` 都覆盖不掉): > 是 / 否题就回一个是 / 否:有人问「知不知道 / 会不会 / 有没有 / 是不是 / 能不能 / 行不行」这类 > 是非题时,整条回复只写肯定或否定本身(「是」/「否」,口语就是「知道」「不知道」「会」「不会」, > 可以带点语气),**不要再接一句解释**;对方追问了再展开。 写 `persona` 时别写出鼓励这种两拍的句子(例如「第一反应嘴硬否认,然后手比嘴快地把事做了」), 否则会跟这条硬要求打架。 **5. 回复有指向(`replyStyle`)** 先划一条界线:**「搭话」不挂任何指向**。群里没被 @ 的两种轮次 —— 活跃窗口里接上的那句 (`continued`)与群里聊热了自己凑的一句(`interject`)—— 都不是「有人在问它、它回那一条」, 所以既不挂 `reply` 段,也不 @ 人,`replyStyle` 配成 `quote` / `at` 也一样。真人凑话时不会先引用 一句再开口,那正是「人机感」最重的那个动作;群里本来就看得出它在接谁的话。 其余轮次(被 @ / 前缀 / `groupTrigger: all`)才有指向可选。群里人一多,裸文本看不出在回谁。 默认的 `auto` 只在**需要点名**时才挂指向:入站时记下这条消息 在群里的进度,真要发出去时再看一眼 —— 中间又出现了消息,回复的第一条就带 `reply` 段引用被回复的 那条消息;紧挨着回的(中间什么都没有)保持纯文本,不刷引用块。这里数的**包括机器人自己发的话**: 上一轮的回复、复读、主动搭话同样会把我回的那条挤远,看的人一样会对不上话头 —— 所以自己的话 也要记账,而且是在**发出去**那一刻记的(本机 NapCat 配着 `reportSelfMessage: false`,等不到自己的 消息回灌成事件)。`quote` 是引用被回复的那条消息,`at` 改成 @ 回提问者,`plain` 一律纯文本。 指向只挂在**第一条**上 —— 一次回复拆成好几条时,只有第一条带引用,后面的都是裸文本 (真人也不会每条都 @ 一遍,引用刷屏更烦)。私聊始终是 `plain`。 被回复的那条**本身只有表情**(表情包 / 商城表情 / 系统表情,没有一个字)时一律不引用: 引用块会把那张表情再贴一遍,看着像在郑重地回复一张图,而表情本来就只是一个反应。 带文字的消息不算(文字才是可以「接」的话头)。`replyStyle: 'at'` 不受这条影响 —— 它 @ 的是 人,不是那张图。主动搭话同样没有指向(它没有「在回的那条」)。 > 上面这些提示词都是**函数**而不是字符串:`SystemPrompt.section()` 每次装配都会重新求值, > 所以群里换了个人说话、或者中间又聊了几句,模型下一轮看到的就是最新的。 > (旧版把文本算成字符串,建 Agent 那一刻就定死了,「当前发言者」会永远停在第一个人; > 同时入站事件会把已查到的群名冲成空串 —— 这两个 bug 一并修掉了。) **6. 它能 @ 到人,而不会把 QQ 号念出来** QQ 里的 @ 是消息里的一个**段**(`{ type: 'at', data: { qq } }`),而模型只会写文本 —— 所以它想点名某个人时,插件给它一个标记: ``` [[@kaguya]] 你来说 ``` 发出去就是「先 @ 一下 kaguya、再说『你来说』」。名字就是正文里 `kaguya: 你俩在吗` 的那个 名字,原样照抄即可。`[[@全体成员]]`(也认 `[[@all]]`)是**唯一**的群发写法,随时可用 —— 提示词里写着「只在真的要喊大家的时候用」,但插件不做限制。 **提示词里不再印任何 QQ 号。** 旧版会在「刚跟你说话的人」那一行写成 `kaguya(QQ 100000003)`,模型看得见就会照着念 —— 群里收到的是一串号码,谁也不这么说话。 现在那一行只有名字;想 @ 谁就写标记,号码到发出去那一刻才由插件填进去。 名单(谁可以被 @)**按会话隔离**,来源只有「在这个会话里说过话」这一件事: - 当前这条消息的发言人、以及他这条消息里 @ 过的人(@ 得出来的人当然在这个群里); - 群里最近闲聊的人(`ambientMessages` 缓冲里那些); - 私聊里就是对方本人。 群里说话的人多,名单滚动保留最近 `64` 个(按「刚说过话」排序,谁最近说话谁排前面)。 一个群里学到的昵称不会跑到别的群去,私聊里也 @ 不到群里的人。 几条刻意的取舍: - **认不出来的名字整个丢掉**,而不是原样发出去:模型偶尔会编一个名字(或者 @ 了一个这个 会话里根本没露过面的人),那时把 `[[@某某]]` 一字不改地发进群里是最糟的结果 —— 宁可少一次 @。整条回复只有一个解析不出来的标记时,**一个字都不发**。 - **只有这一层解析标记**:`[[@…]]` 与表情包的 `[[图片: …]]` 一样,都是「正文里的一行」—— 复读那些**原样转发**消息的地方不解析它(复读的内容全部来自不可信的 群消息,一步都不多做)。 - **和「回复指向」的 `at` 是两回事**:`replyStyle: 'at'` 是插件自动 @ 回提问者(只挂在第一条 上);`[[@昵称]]` 是模型自己在正文里点名,位置由它自己写。两者可以同时出现, 顺序是「先指向段、后正文」。 - 名字比较**先精确、再忽略大小写**:`Kaguya` 与 `kaguya` 算同一个人,中文昵称不受影响。 群里两个人重名时,@ 到的是名单里靠前的那个(最近说过话的那个)。 `qq_send_message` 工具走同一套解析:它只认「目标会话聊过的人」,所以从 QQ 会话里发回当前 会话时 @ 得到人,而往一个从没聊过的群发 `[[@某人]]` 只会退化成纯文本(而不是报错)。 这一条**没有配置项**,也不需要开关:不给它出口,模型就只剩下「把 QQ 号写进正文」那一条路。 ## 没有正文的消息(`blankReply`) 「@ 它一下什么也不说」和「私聊发一个空格」都是**明确叫了它、却没写事**。默认 (`blankReply: true`)这两类消息不再被丢掉,而是照常走一轮:插件把正文位置换成一句说明, 模型看着这段聊天的上下文(群里还有「群里最近的消息」,更早的用 `qq_transcript` 查) 自己决定回什么 —— 常见的就是回个「?」、发一张 `[[图片: …]]` 表情,或者顺着上一句接一句。 ``` QQ: @机器人 → 模型看到的正文: (@了你一下,没有说别的) QQ: (私聊)一个空格 → 模型看到的正文: (对方发来一条空消息,什么都没写) ``` 三种措辞分开写,因为对模型的含义不同:群里被 @ 是**点名**,群里没 @(`groupTrigger: all`, 或者活跃对话窗口里接上的一句)只是这句话恰好是空的,私聊则没有 @ 这回事。正文里那对括号 是插件加的说明,不是谁发的字 —— 提示词里也会讲清楚,免得它回一句「你发了什么」。 几条边界: - **空 @ 不是指令**:`/reset` 这类旁路照样只认**明确叫了它且有正文**的消息, 空 @ 永远只走模型。 - **空 @ 不打断攒批**:有人 @ 一句「在吗」、紧接着又空 @ 一下,两句会攒成同一轮 (正文分别是两行),而不是一前一后回两次。 - **活跃对话窗口里接上的空消息不算接话**:那只是「他说了句话,正好没内容」,不是点名, 接上去就成了抢答(见[聊起来之后保持搭话](proactive.md#聊起来之后保持搭话活跃对话))。 - **前缀模式下 `"/"` 后面没内容不算**:那是没内容可切,不是「空消息」,别硬凑一句话回。 - **`blankReply: false`**:回到旧行为,空消息一律静默丢弃(群里被人反复空 @ 时可以一键关掉)。 - **想让它只发表情、一个字都不说**:靠回复正文里的 `[[图片: …]]` 标记, 见[只发表情、一个字都不说](media.md#只发表情一个字都不说)。