# 桌上说话 —— 从「主持人转达」换成公共信道 > 2026-10-07。**续 `2026-10-05-桌面与信道.md` §4**——那一节当时把「自动广播」押后了, > 理由是「主持人刚跟某位玩家私聊过时,那位玩家的回复不该被自动摊到桌上」,并注明 > 「**这是权衡,不是实测结论**:私聊那一侧还没有实测过」。这一篇就是那份实测结论。 ## 0. 一句话 **桌上说话本来就没有收件人,而现在的实现给每一句都加了一个中间人。** 那个中间人会漏、会改、会摘掉地址——而它做这些事的时候,桌上没有人知道。 ## 1. 四起错位,一个单点 2026-10-06 到 10-07,四起实测里出问题,而它们的形状是同一种。看调用日志: | 现象 | 日志里的原话 | | --- | --- | | 说了话没人接 | `relay` 的返回值是「桌上只有 林小满 一个人,没有人可转达」——她没有同桌可说话 | | 署名不准 | 转达时拼成 `(林小满)<原话>` 是**后来才补的**;补之前收信人把那句当成主持人说的 | | **地址被摘** | 真人说「**回阿May**:你说你进门的时候我才走」,广播版成了「你说你进门的时候我才走」——**「回阿May」没了**,而苏小满也是进过门的人,于是她回了一句「这句我没说过」 | | 你可以接 | 真人对着阿May 问的一句话,DM 转给苏小满时说「他这句话是当着所有人说的,**你可以接**」 | **四处都在修同一个东西**:一句话经过主持人的手之后,**它带上了主持人的理解**。 而理解可以错、可以漏——而桌上没有人能发现,因为每个人收到的都是**主持人版本**,而不是原话。 ## 2. 根因:两个职责挤在一个动作里 现在的链路: ``` 真人打字 ────────────────┐ ├─→ 主持人的会话 ─→ 主持人决定「谁该听见」─→ 逐个 say/relay ─→ 各玩家 AI 玩家 send_message → lead ┘ ``` **传输**(谁说过什么,每个人都收到)与**判断**(这句话意味着什么、该谁接、要不要拦) 被压进了同一个动作:`relay`。于是: - 传输出错的代价由判断承担(主持人得记得调 `relay`,而它一直在忙别的); - 判断出错的表现是传输问题(地址被摘、署名不准——看上去像「这个人说错了话」)。 **而 10-05 那一节的担心(私聊被摊到桌上)在实测里没有发生过**:真人这一侧只有一个输入框, 会话对话**就是**桌面(那一节自己也写了「还没做的:人类玩家这一侧没有私聊信道」); AI 玩家这一侧没有私聊——他们的唯一通道是发给 lead,而那句本来就是桌上说的。 **所以那个权衡的前提不成立**:没有任何东西需要被保护,而保护它的代价是每一句话都要过一道手。 ## 3. 方案:宿主广播,DM 判断 ``` 真人打字 ────────────────┐ ├─→ 主持人的会话 ─→ 宿主逐句原样广播(带署名)─→ 全桌 + 主持人 AI 玩家 send_message → lead ┘ ``` **广播只做一件事**:把那句话(**署名 + 原文,一字不改**)送到桌上每个人那里。 **「对谁说」留在话里**(「阿May,你几点到的?」),传输层不碰它。 ### 3.1 DM 的职责不减,只是腾出手 **这一点在评审里被明确指出过**(用户原话:「DM 还是要肩负监督和控场的职责」)。 分工要说清,否则这次改动读起来像在削弱 DM: | 职责 | 归谁 | 现在的状态 | | --- | --- | --- | | **传输**——谁说过什么、每个人都收到 | **宿主** | 现在靠 DM 手打 `say`/`relay`,**会漏会改** | | **监督**——谁还没说、谁在回避哪段时间、谁前后矛盾 | **DM** | 它做得很好(实测里「武斌,你还在吗」「谁最后一个跟他说话」都是它自己看出来的) | | **控场**——该把话头交给谁、什么时候停、什么时候推阶段 | **DM** | 现在有四道闸托着(人齐、行踪、线索、节奏),它不必自己盯 | | **主持人的嘴**——转场、提问、宣布 | **DM 的 `say`** | 不动 | **广播让它的控场更容易**:桌上每句话它都看得见同一份流,于是「谁还没答我」不必靠记忆—— 而它的记忆正是短板(实测里四个人的行踪一条都没记进时间线)。 ### 3.2 机制上做得到(已查证) `agent-team/src/mailbox.ts:121`: ```ts const target = resolveActiveMember(root, state, request.target) if (target.id === caller.id) throw new TeamError('a Team member cannot message itself', 'TEAM_SELF_MESSAGE') ``` **只禁止「发给自己」,没禁止成员之间互发。** 而广播甚至不需要走成员之间的直接消息—— 宿主已经有一个 `deliver(ctx, dm, listeners, message, signal)`(`say` 与 `relay` 用的就是它), 而**发言的原文与署名都能拿到**:`tools/result` 钩子已经在记玩家说过的话(`players.recordSaid`)。 **怎么读到新消息**(审查时改正的一处):**没有 `session.on()` 那样的订阅口子**。 会话事件是**拉**的——`snapshotEvents(fromSeq, toSeqExclusive)` 带区间参数,所以宿主自己记住 「上次读到哪个 seq」,每次只取那之后的一段。**这不是「订阅」,是「读增量」。** **什么时候读**:没有一个现成的插件定时器服务(`packages/schedule` 那个是给用户的定时任务, 不是给插件用的)。所以用一个裸的 `setInterval`,**而它必须包在 `ctx.effect` 里**—— 否则插件卸载之后那个定时器还活着,会一直去读一个已经不存在的局面。间隔取 2 秒左右: 再密没有意义(一轮发言本来就是几十秒级的),再疏会让桌上的人觉得别人「反应慢」。 **一条必须记住的约束:广播要跳过主持人自己的话。** 主持人的 `say` 也落在同一个会话里, 而它已经自己发过一次了——一起广播等于同一句说两遍。 ### 3.3 与 `relay` 的关系 - **`relay` 降级**:宿主自动广播之后,逐句转达不再需要。保留这个 action 一段时间(兼容), 但指令与手册不再要求 DM 调它。 - **`say` 保留**:那是**主持人的嘴**(转场、提问、宣布),与转达是两件事。 - **`say seat="*"` 保留**:主持人有时候确实要一次说给全桌(比如宣布进入搜证)。 ## 4. 判据 这次改动能不能算成,看三件事: 1. **同一句话,六个人收到的是一模一样的**——包括称呼、语气词、错别字。转述走样的那一类 问题(署名不准、地址被摘)应当**不再出现**,因为它们不再是转述。 2. **DM 不再因为「忘了调 relay」而让一位玩家整场没听见**。现在这件事只能靠它自觉。 3. **真人说一句带指向的话,被指的那个人答、其余人不抢**——因为地址在话里, 而 `playerBrief` 会告诉每个人「看到点你名字的才接」。 **反过来的判据**(这一条同样重要):**DM 的监督与控场没有变弱**——它照样发现「谁在回避」、 照样决定话头交给谁、照样记时间线。若广播之后它变得只会转达、不再判断,那这次改动是失败的。 ## 5. 风险与最小版 **风险:上下文增长。** 六人桌上每句话都会进六个人的会话,而现在的转达只覆盖 DM 决定转的那些。 **而长会话的上下文压力是实测过的**(见 `2026-10-04-单机剧本杀-设计方案.md` 的压缩一节: 压缩之后玩家会「忘掉」桌上的话)。 **所以分两步走**: - **第一步(最小版)**:宿主加广播器,`relay` 保留但不再被要求。**跑一局,量上下文的实际增长** (会话日志里能读到 token 数与压缩点)。 - **第二步**:按那一局的数字决定广播的粒度——全量广播、或者「只广播桌上发言、不广播元信息」、 或者在上下文接近阈值时降级为摘要。 ### 5.1 量出来的数(2026-10-07 晚,隔离实例) 同一本子(`07` 夜场,六人)、同一台机器,广播前后各一局。读的是**单次请求的 `totalTokens`** (那是上下文压力;`inputTokens` 只是缓存没命中的那部分,第一版量错了那个): | | 10-06(广播前) | 10-07(广播后) | | --- | --- | --- | | **主会话**(DM) | 20 次请求 / 峰值 67,790 → **每请求约 3,390** | 82 次请求 / 峰值 138,684 → **每请求约 1,690** | | 五个玩家会话(峰值) | 17k – 26k | 37k – 42k | | 压缩 | 没触发 | 没触发 | **两个方向相反的结论,而都对**: - **玩家会话涨了约 2 倍**(20k → 39k)——**那正是这次改动的目的**:他们现在真的听得见全桌, 而不是听主持人的转述。 - **主会话的每请求成本降到约一半**(3,390 → 1,690)——因为主持人不再逐句转达了, 它的会话里只装它自己说的话。**它的峰值高是因为轮次多了一倍,不是因为每轮更重。** **所以最小版通过**:全量广播可行,不必先做粒度降级。按每句 100–300 token 估,一个六人局跑到 一百句时玩家会话约到 60–70k,**离这一档的窗口(256k)仍然很远**。 **这次量数还纠正了一个我自己的误读**:页面底栏那个「7.4M tok」是**一局累计花掉的总量** (每一轮都重算前缀),与「上下文有多大」是两件事。要判断压缩风险,只能读单次请求的 `totalTokens`。 **在没有那个数字之前,不做「彻底废掉 relay」这件事。**(数字现在有了,而 `relay` 已经不被调用—— 它的去留留到下一局之后再定:要看主持人会不会在广播之下**仍然**觉得需要转达。) ### 5.2 四条判据的实测(2026-10-07 晚,隔离实例) | 判据 | 证据 | | --- | --- | | ① 同一句话人人收到一样 | **面对面比了 338 对,逐字一致 338 对**——说话人发出去的原文(剥 Team 信封)与收信人收到的(剥信道前缀与署名)逐字相等,署名正确(何晏 / 苏晚 / 武斌) | | ② DM 不再漏转 | **结构性解决**:它不再转达,也就没有「忘了转」这件事。宿主日志为证:`广播 p1 的一句(56 字)给 4 位`(六人桌上送给了其余四位) | | ③ 带指向的话,被指的人答、其余人不抢 | p1 问 p5「你说你上去是为了跟他说一句话——**那句话是什么**?」→ p5 答「我说的是:下周那场,能不能让我上一回。就这一句,我没说出口。」**其余四个人没有一个人抢那句**(而改动前,这个问题会被别人认领——见 §1 的「地址被摘」) | | ④ **反向:DM 的监督与控场没变弱** | 它照样在追问(「你 22:15 上楼那趟还是没答:你到底看见什么了」)、替玩家钉记录(「值班员那条还欠一句」)、整理证据(「21:35 那一下,到现在只有一个人证——苏晚」)。**广播没有把它变成传声筒。** | **第二步的结论(按 §5.1 的数字)**: - **⑥ 广播粒度:维持全量广播。** 玩家会话到 39k、主会话到 138k,而这一档窗口是 256k,两次都没触发 压缩;按每句 100–300 token 估,六人局跑到一百句仍在安全区。**不做降级为摘要那一层**—— 而在会话接近阈值时再回来看。 - **⑦ `relay`:保留,但不要求调用。** 它现在一次都没被调(宿主日志与调用序列都证实了)。 **退役它的动作留到确认「主持人在广播之下确实不再需要转达」之后**——而这一步已经验过一半。 ### 5.3 这一轮验证踩的四个坑(都值得留痕) **广播一直是好的,而我的测量工具骗了我三次。** 记下来,因为下一次还会有同样的形状: 1. **日志说假话**:`console.info` 打在「有没有听众」判断**之前**,于是桌上一个人都没有时也报 「广播到桌上其余人」。**修法**:让 sink 返回「实际送给几位」,日志按那个数打。 2. **`kind` 认错**:检查脚本找 `source.kind === 'user'`,而广播落进玩家会话时是 `agent-message`。 **修法**:先 dump 一个会话的 `kind` 分布,再写判据。 3. **全角括号进正则**:`/(([^)]{1,24}))/` 报 `Unterminated group`——全角括号在字符类里是普通字符, 而 `)` 被当成了类的一部分。**修法**:用码位(`String.fromCharCode(0xFF08)`)拼。 4. **读错会话**:一局一换会话,而窗口里同时有几个「主会话」——取「最新的」才拿到这一局。 **一条更一般的教训**:**「我以为的送达」与「实际送达」必须在代码里分开**。 第 1 条是它的直接结果;而第 2–4 条是它的镜像——**测量工具的输出不等于事实**。 ## 6. 故意不做的 - **不给真人加第二个输入框**(「对主持人说」与「对桌上说」分开)。**取舍**:那是这一整件事里 唯一真正的私密需求,而它要新增一个界面元素;而现在没有实测表明用户需要它。 如果哪天需要,那才是加的时候——**而不是为了给自动广播留退路先加上**。 - **不做「谁在听」的可见性**(比如显示「这句话被谁收到了」)。桌上说话没有回执, 而加一个回执会把一次发言变成一次投递——那是另一种走样。 ## 7. 还没验到的 - 六人桌上广播一局的上下文增长(见 §5,那是决定第二步的前提)。 - 真人这一侧「对着某个人说」与「对全桌说」的**区分度**:现在都靠话里带不带称呼, 而实测里真人确实会带(「回阿May:」「阿May 你几点来的?」)——**样本还少**。