Skip to content

flow-control 服务 ​

1. 定位 ​

flow-control 是会话级的节流硬闸:禁言、回复后冷却、限速窗口。它只回答"现在能不能说",不参与"要不要开口"——后者由前一相位生效的触发插件判定(见 trigger;规则版 @aalis/plugin-trigger-policy 看 @ / 名字 / 计数与评分,另有闲置主动开口)。

  • 服务注册名:'flow-control'(描述符 flowControl,消费方经 flowControl.use 调用)
  • 契约包:@aalis/api-flow-control
  • 参考实现:@aalis/plugin-flow-control
  • 相邻相位:inbound:trigger(生效的触发插件)→ inbound:flow(本服务的参考实现)→ inbound:dispatch。

2. 契约 ​

完整接口(packages/api-flow-control/src/index.ts):

ts
export interface FlowControlService {
  /** 当前是否在禁言期 */
  isMuted(sessionId: string): boolean;
  /** 当前是否在回复后冷却期 */
  isCoolingDown(sessionId: string): boolean;
  /** 限速窗口内的回复数是否已达上限(true 表示已超限) */
  isRateLimited(sessionId: string): boolean;
  /**
   * 设置或解除禁言(禁言关键词命中或平台禁言事件时调用)。
   * - durationSec > 0:禁言到 now + durationSec 秒
   * - durationSec <= 0:解除禁言
   * 会话尚无流控状态时,只有同时给出 platform 才会建立状态并禁言。
   */
  setMuted(sessionId: string, durationSec: number, platform?: string): void;
}

要点:

  • 三个查询方法对未知会话返回 false(无状态即不限制)。
  • 冷却与限速按 agent 的真实回复(outbound:message 且 source === 'agent')计,由实现自己监听,契约不暴露记账方法。
  • 类型随描述符走:下游 import { flowControl } from '@aalis/api-flow-control' 写进 uses 即可,不必依赖实现包。

3. 谁提供 / 谁消费 ​

提供方(参考实现):@aalis/plugin-flow-control,provide.use(flowControl, service)(packages/plugin-flow-control/src/index.ts)。它同时占据 inbound:flow 相位做硬闸,并监听 outbound:message 记冷却与限速。禁言(setMuted 时长大于 0)时经 agent(optional)的 abort(sessionId, { queued: true, reason: '禁言' }) 停下这个会话在跑的一轮、放掉排着的,不交 by(停下的人):是谁禁的,会话历史里挨着那条(关键词那句话或平台的禁言通知)本来就有;解禁不停。

消费点(全部 optional,缺席即不设闸):

  • @aalis/plugin-trigger-policy:isMuted 决定禁言期不计数、不识别关键词;命中禁言关键词时 setMuted;session 档闲置到点前查 isMuted,platform 档挑候选时排除 isMuted / isCoolingDown / isRateLimited 为真的会话(packages/plugin-trigger-policy/src/index.ts、packages/plugin-trigger-policy/src/idle-scheduler.ts)。
  • @aalis/plugin-trigger-laya(仓库内私有插件):isMuted 为真时放行给 flow 相位吞掉,不识别关键词;命中禁言关键词时 setMuted(packages/plugin-trigger-laya/src/index.ts)。
  • @aalis/plugin-adapter-onebot:bot 自身被禁言/解禁的 notice、重连后按 shut_up_timestamp 恢复时调 setMuted(packages/plugin-adapter-onebot/src/index.ts)。

4. 写一个 provider ​

替换默认实现时须实现整个 FlowControlService,并把 inbound:flow 中间件与 outbound:message 记账一并搬过来,否则禁言、冷却、限速闸门会失效。双源 manifest 必须同步(package.json 的 aalis.service 与源码 provides / uses,见 concepts/manifest-metadata)。

参考实现的 package.json → aalis.service:

json
{ "aalis": { "service": {
  "required": ["config", "events", "hooks", "lifecycle", "logger", "provide"],
  "optional": ["storage", "message-archive", "session-manager", "session-settings", "host-config"],
  "provides": ["flow-control"]
} } }

最小骨架(省略禁言持久化、生效范围与按会话的参数):

ts
import { type FlowControlService, flowControl } from '@aalis/api-flow-control';
import { INBOUND_PHASE } from '@aalis/api-gateway';
import { hooks } from '@aalis/api-hooks';
import { definePlugin, events, provide } from '@aalis/core';

export default definePlugin({
  name: '@aalis/plugin-my-flow-control',
  provides: [flowControl],
  uses: { provide, hooks, events },
  apply({ provide, hooks, events }) {
    const mutedUntil = new Map<string, number>();
    const cooldownUntil = new Map<string, number>();

    const service: FlowControlService = {
      isMuted: sid => Date.now() < (mutedUntil.get(sid) ?? 0),
      isCoolingDown: sid => Date.now() < (cooldownUntil.get(sid) ?? 0),
      isRateLimited: () => false,
      setMuted(sid, sec) {
        if (sec > 0) mutedUntil.set(sid, Date.now() + sec * 1000);
        else mutedUntil.delete(sid);
      },
    };
    provide.use(flowControl, service);

    hooks.use.middleware(INBOUND_PHASE.FLOW, async (data, next) => {
      const { message } = data;
      if (service.isMuted(message.sessionId)) return; // 禁言期一律不说话
      // immediate 穿透冷却;带 source 的内部注入不过冷却(限速仍挡,示例从略)
      if (message.triggerType !== 'immediate' && !message.source && service.isCoolingDown(message.sessionId)) return;
      await next();
    });

    events.use.on('outbound:message', msg => {
      if (msg.source === 'agent' && msg.sessionId) cooldownUntil.set(msg.sessionId, Date.now() + 10_000);
    });
  },
});

flow-control 是单实例服务,用全局 Map<sessionId, state> 管多会话,不需要 entryId;priority 留默认即可。同名时选中哪一家的规则见 concepts/service-model。

5. 标准消费方式 ​

业务句柄 flowControl.use 是活的(规则见 选择层与业务层)。缺席时先看 present,缺席按"不设闸"处理:

ts
// platform 档闲置挑候选时(packages/plugin-trigger-policy/src/idle-scheduler.ts)
const flow = this.caps.flowControl.present ? this.caps.flowControl.use : undefined;
for (const [sid, s] of this.states) {
  if (flow?.isMuted(sid) || flow?.isCoolingDown(sid) || flow?.isRateLimited(sid)) continue;
  // ...
}

6. 行为不变量 ​

  • owner 自己的会话不经本相位。房间类别为 owner-only 的会话由 plugin-gateway 跳过 inbound:flow(结构性放行),禁言、冷却与限速对它们的入站都不起作用;出站照常记账,只是不挡消息。
  • 禁言不看生效范围、不看来源。inbound:flow 先查禁言:闲置触发、定时任务注入的消息在禁言期同样被吞。禁言状态只由关键词或平台禁言针对具体会话写入,所以不会误伤生效范围外的会话。禁言在入站挡之后的消息,同时经 agent 停下这个会话在跑的一轮、放掉排着的:禁言写入时正在生成的回复不外发、不落库,提交点之后已在外发的那一条照常发出(见插件页「已知局限」)。
  • immediate 穿透冷却与限速。被 @、戳一戳、叫名字时即使处于冷却或限速窗口也放行;禁言期除外。带 source 的内部注入(闲置触发、定时任务、workflow)不受冷却约束,但仍受禁言与限速约束(限速只在生效范围内时)。内部注入没有网关算的聊天窗口,判生效范围与回复记账同一口径:先取会话管理记下的聊天窗口,没有再用流控状态里记下的,所以 bot 在某个群的限速窗口已满时,发往该群的内部注入同样被挡下。
  • 生效范围先于关键词。生效范围是 api-gateway 导出的「生效范围」字段(REPLY_AUDIENCE_FIELD,默认多方房间),按聊天窗口的多方标记判(audienceIncludes),可按会话设。触发插件在识别禁言关键词之前先判生效范围,否则群聊的禁言关键词会作用到私聊等不在生效范围内的会话。
  • 禁言态要持久化。禁言可能是小时级的用户意图,参考实现把 mutedUntil 落盘到 data:/flow-control-mutes.json,冷却与限速不落盘。该文件读不懂(不存在以外的读取错误、解析失败、顶层不是对象)时,本次运行不再整表回写,禁言改动只在内存生效,storage 换人重读时重新判定。换 provider 时若不持久化,重启会让被禁言的群立即恢复发言。
  • 限速是防刷屏与平台风控的护栏。冷却与限速记在 agent 真实回复上,只对生效范围内的房间记账:先取会话管理记下的出生平台与聊天窗口(originPlatform、originChat 的多方标记),没有再用流控状态里记下的,结果只写进流控自己的状态、不回写消息,入站带 source 的内部注入判生效范围也用这一口径;没有聊天窗口的会话(WebUI、终端、子任务)不是房间,不计入。自建主动发送通道应发 source: 'agent' 的 outbound:message,才能被计入。

7. 注意事项与边界情形 ​

影子归档顺序竞态(现状未收口)。被吞掉的入站消息会做影子归档,下次触发时作为上下文:

  • flow-control 的 shadowArchive(packages/plugin-flow-control/src/index.ts)与触发插件共用的 archiveSwallowed(packages/api-trigger/src/index.ts)都直接 await message-archive 的 archiveIncoming(message)。
  • 真正进了一轮的消息由 agent 在它进入那一轮时归档(packages/plugin-agent/src/index.ts),agent 在一个会话里的写入依次发生。
  • 两条路径不共享顺序,archiveIncoming 以 Date.now() 作时间戳(packages/plugin-message-archive/src/index.ts)。吞掉与 agent 的写入在毫秒级交错时,归档可能乱序或时间戳并列。

消息都会进档、都会发 inbound:message:archived,但会话内严格时序不保证。依赖严格时序的下游应以单调序列号而非 Date.now() 排序。闲置触发的合成提示不做影子归档(与 agent 的归档规则一致)。

其它:

  • 命令回复与系统回复(source 不是 agent)不计冷却与限速。
  • 会话状态每天扫描一次,无挂起禁言/冷却且 30 天未见的会话被删除。

8. 交叉链接 ​

文档对应 dev@c4e046e41,core 0.21.0,构建于 2026-11-02
基于 MIT 许可发布