Fay 的多通道打断逻辑

Fay 的多通道打断逻辑

  • 课程ID:course-fay-interrupt-channels
  • 作者:Fay 开源社区
  • 版本:1.2.0
  • 章节数:6

封面

目录

  1. 为什么 Fay 需要多通道打断
  2. 打断中枢:clear_Stream_with_audio
  3. 统一闸门:on_interact 的 if not no_reply
  4. 双重防御:conversation_id 校验
  5. 17 条链路对称性总览
  6. 新增入口与潜在坑位

第1节 为什么 Fay 需要多通道打断

欢迎来到 Fay 多通道打断逻辑课程。

Fay 作为一个多入口的数字人框架,至少有七条独立路径会让数字人开口说话:GUI 聊天框 api_send、OpenAI 兼容接口 /v1/chat/completions、消息透传接口 transparent_pass、本地麦克风的唤醒词识别、远程音频推送、to_stop_talking 纯打断接口,以及 fay_booter 里的 auto_play 自动播报服务------它会定时轮询外部 automatic_player 服务器并把返回的文本/音频直接交给 on_interact 播报。

这里要特别澄清一个容易混淆的点:日程功能并不是一个独立入口。Fay 的日程由 mcp_servers/schedule_manager 实现,它在任务到点时通过 POST 请求调用 /v1/chat/completions 把提醒内容推给 Fay。也就是说,日程本质上是 B 这个入口的一个外部客户端,打断语义完全继承自 B,不需要额外划一条通道。真正算独立入口的是 auto_play 那条内部轮询路径------它不走 HTTP,直接构造 Interact 并调 on_interact。

这七条路径都是异步进入的,意味着它们随时可能在 Fay 已经在说话的中途到达。如果不做任何协调,结果就是多段语音同时播放、文本流互相干扰、用户问的问题被上一条还没说完的回答盖过去,整个数字人会显得「乱说话」。

Fay 的解决方案是:所有需要让数字人开口的路径,统一收敛到一个打断中枢,新消息到达时先把当前的所有进行中输出干净地清掉,再开始新的处理。本课程会一层层拆开这个机制,最终给出一张包含 17 条链路的对称性总览表,并展示其中曾经踩过的一个不对称坑位。

复制代码
# 七个入口对应的代码位置
#
# A. gui/flask_server.py:593
def api_send():                    # GUI 文字发送框

# B. gui/flask_server.py:674
def api_send_v1_chat_completions(): # OpenAI 兼容接口
#   ↑ 日程 schedule_manager 也是走这个接口推消息,
#     所以日程不是独立入口,是 B 的客户端

# C. gui/flask_server.py:1412
def transparent_pass():            # 消息透传

# D. gui/flask_server.py:1355
def to_stop_talking():             # 纯打断接口

# E. fay_booter.py:130
class DeviceInputListener:         # 远程音频 (10001)
    def on_speaking(self, text): ...

# F. core/recorder.py:108
# 唤醒词检测分支(普通 + 前置词)

# G. fay_booter.py:241
def start_auto_play_service():     # auto_play 自动播报
#   - 定时轮询外部 automatic_player 服务器
#   - 返回的文本/音频直接构造 Interact('auto_play', ...)
#   - 调 on_interact,不经过 HTTP
#   - 这才是真正的 G 入口(和 schedule 不是一回事)

第2节 打断中枢:clear_Stream_with_audio

所有打断动作最终都会走到一个函数:core/stream_manager.py 第 254 行的 clear_Stream_with_audio。它做三件事。

第一步是设置 stop_generation_flags[username] = True。这是给所有下游循环看的「全局停止信号」,包括 LLM 流式生成循环、TTS 合成循环、还有 pygame 音频播放循环。它们每隔几毫秒就会用 should_stop_generation 检查一次这个 flag,看到 True 就立刻 break。

第二步是清空 sound_query 中该用户的所有音频项。Fay 用一个全局队列承载所有用户的待播放音频,_clear_user_specific_audio 会非阻塞地遍历队列,把目标用户的项丢弃,保留其他用户的,确保多用户场景下不会互相串扰。

第三步是清理用户级状态:重置 think_mode_users、清空 SentenceCache 文本流和 NLP 流、再写入一个 _ 哨兵让监听线程优雅退出当前句子。

三步加起来确保:当下次新消息到达时,Fay 处于一个干净的、没有任何残余输出的初始状态。

复制代码
# core/stream_manager.py:254
def clear_Stream_with_audio(self, username):
    """清除指定用户的文本流和音频队列"""
    # 第一步:设置停止标志
    with self.control_lock:
        self.stop_generation_flags[username] = True

    # 第二步:清除该用户的音频队列项
    self._clear_audio_queue(username)

    # 重置 think 状态机
    try:
        uid = member_db.new_instance().find_user(username)
        if uid is not None and fei is not None:
            fei.think_mode_users[uid] = False
            fei.think_time_users.pop(uid, None)
            fei.think_display_state.pop(uid, None)
    except Exception:
        pass

    # 第三步:清空文本流
    with self.stream_lock:
        self._clear_Stream_internal(username)

第3节 统一闸门:on_interact 的 if not no_reply

六条会让数字人开口的入口路径里,除了 to_stop_talking 直接调用 clear 之外,其他五条都通过 fay_core.on_interact 这个统一闸门来触发打断。

on_interact 在第 851 行有一段非常关键的代码:if not no_reply 块。只要新到达的 Interact 没有显式标记 no_reply=True,这一整段就会原子地执行四件事。

一是判断当前是否有会话在进行中------通过 state_manager.is_session_active 检查------如果有,立刻调 clear_Stream_with_audio 把进行中的输出全部干掉。

二是用 uuid 生成一个新的 conversation_id,覆盖到 stream_manager 上。 三是把这个新 conv_id 同步到 state_manager,确保两边状态一致。 四是把 stop_generation_flags[username] 重置成 False,给新的生成开绿灯。

no_reply=True 的路径会完全跳过这一整段。设计意图很明确:no_reply 是用来「静默注入观察」或「队列追加播放」的,不应该打断当前进行中的对话。具体使用场景包括 transparent_pass 的 queue 模式和 /v1/chat/completions 接口里只携带 observation 不要求回复的模式。

复制代码
# core/fay_core.py:824 on_interact
def on_interact(self, interact: Interact):
    username = interact.data.get('user', 'User')
    no_reply = interact.data.get('no_reply', False)

    if not no_reply:
        try:
            from utils.stream_state_manager import get_state_manager
            import uuid

            # 1) 清掉进行中的输出
            if get_state_manager().is_session_active(username):
                stream_manager.new_instance().clear_Stream_with_audio(username)

            # 2-3) 生成并对齐新会话 ID
            conv_id = 'conv_' + str(uuid.uuid4())
            stream_manager.new_instance().set_current_conversation(username, conv_id)
            interact.data['conversation_id'] = conv_id

            # 4) 解除停止标志,给新生成开绿灯
            stream_manager.new_instance().set_stop_generation(username, stop=False)
        except Exception:
            util.log(3, '开启新会话失败')

    # 之后才进入实际处理
    if interact.interact_type == 1:
        MyThread(target=self.__process_interact, args=[interact]).start()
    else:
        return self.__process_interact(interact)

第4节 双重防御:conversation_id 校验

前一节我们说过 no_reply=True 的路径会跳过 clear。但是这并不意味着这些路径产生的输出就「打不掉了」------Fay 还有第二道防线:conversation_id 校验。

每次新的 on_interact 进入时,会用 uuid 生成一个新的 conv_id 替换掉 stream_manager 上的旧值。这个新 conv_id 会被注入到本次 Interact 的 data 字段,并随着流式句子写入文本流时附加在隐藏标签里。

下游所有需要「看是否要停下来」的循环------包括 LLM 流式生成、TTS 合成、pygame 音频播放循环------都不是只看 stop_flag,而是同时校验自己持有的 conv_id 是不是当前 stream_manager 上的最新值。一旦不匹配,should_stop_generation 立即返回 True,循环 break。

这个机制带来的好处是:哪怕是队列模式 transparent_pass 推进 sound_query 的音频项------它们当时被 enqueue 时记录的是旧的 conv_id------只要后续来一个 no_reply=False 的请求,新 conv_id 一覆盖,旧的队列项在下一次 should_stop_generation 检查时就会被识别为过期,pygame 循环立即停止播放。

所以 Fay 实际上有两道防御线:clear 是粗暴的「立即清场」,conv_id 校验是细致的「过期识别」。两道线一起,确保多通道间的打断没有盲区。

复制代码
# core/stream_manager.py:192 双重判定
def should_stop_generation(self, username, conversation_id=None, ...):
    with self.control_lock:
        # 第一道:stop_flag 立即停止
        flag = self.stop_generation_flags.get(username, False)
        if flag:
            return True

        # 第二道:conv_id 不匹配也停止
        current_cid = self.conversation_ids.get(username, '')
        if conversation_id is not None and conversation_id != current_cid:
            return True

        return False

# core/fay_core.py:1966 pygame 播放循环里的检查
while length < audio_length:
    user_for_stop = interact.data.get('user', 'User')
    conv_id_for_stop = interact.data.get('conversation_id')  # 队列项的旧 conv_id
    if stream_manager.new_instance().should_stop_generation(
            user_for_stop, conversation_id=conv_id_for_stop):
        pygame.mixer.music.stop()
        break
    length += 0.01
    time.sleep(0.01)

第5节 17 条链路对称性总览

七个入口两两组合原本是 21 对,我们去掉 4 对在工程上不成立的组合,得到 17 对。这一节把 17 条链路全部摊开讲清楚。

先统一缩写,方便对照:A 是 api_send(GUI 聊天框)、B 是 /v1/chat/completions(OpenAI 兼容接口)、C 是 transparent_pass(消息透传接口)、D 是 to_stop_talking(纯打断接口)、E 是远程音频(10001 端口推来的语音)、F 是唤醒(本地麦克风的 wake word)、G 是 auto_play 自动播报(fay_booter.py:241 start_auto_play_service,内部轮询外部播报服务器并直接 on_interact)。

再强调一遍:日程 schedule 不在这七个入口里。mcp_servers/schedule_manager 是通过 POST /v1/chat/completions 把到点提醒推给 Fay 的,它的打断语义完全继承自 B------当它发送提醒时就是 B 的一次普通调用,后续所有对 B 的讨论同样适用于日程。

【双向链路 10 对】

链路 1 --- A↔B。GUI 聊天框和 OpenAI 兼容接口互相打断。典型场景:用户在网页聊天框里发一条消息,Fay 还没说完,外部 Agent(或者到点触发的日程提醒)通过 /v1/chat 又发来一条,后者会打断前者;反过来也成立。

链路 2 --- A↔C。GUI 聊天框和透传接口互相打断。这条曾经被文档误标为 A←C 单向,经代码核对确认是双向------因为 api_send 和 transparent_pass 都走 on_interact 的同一段 if not no_reply 块,对称性从代码层就保证了。

链路 3 --- A↔E。GUI 聊天和远程音频互相打断。例如用户在聊天框里发字的同时,远程音频也推了一段语音进来,谁后到谁打断前一个。

链路 4 --- A↔G。GUI 聊天和 auto_play 自动播报互相打断。轮询到点推来的播报内容会被用户的新提问打断,反过来用户正在问话时如果遇到一次 auto_play tick,播报也会中断当前对话。

链路 5 --- B↔C。OpenAI 兼容接口和透传接口互相打断。两个都是外部 HTTP 入口,互相之间没有优先级。

链路 6 --- B↔E。/v1/chat 接口和远程音频互相打断。

链路 7 --- B↔G。/v1/chat 接口和 auto_play 互相打断。注意日程走的就是这一对 B 侧。

链路 8 --- C↔E。透传接口和远程音频互相打断。

链路 9 --- C↔G。透传接口和 auto_play 互相打断。

链路 10 --- E↔G。远程音频和 auto_play 互相打断。会场景、导览场景里最常见。

【单向 ←D 3 对】

D 是 to_stop_talking 这个纯打断接口,它自身没有任何语音输出,所以永远只能作为「打断源」,不能作为「被打断目标」。实际业务里这个接口只会被 A/B/C 这三类客户端调用,不会从远程音频、唤醒、auto_play 侧触发,所以有效链路就是以下 3 对:

链路 11 --- A←D。GUI 聊天框上的「打断」按钮调用 to_stop_talking。 链路 12 --- B←D。外部 Agent 通过 /v1/chat 侧同步调一次 to_stop_talking。 链路 13 --- C←D。透传接口的客户端触发打断。

【单向 ←F 4 对】

F 是唤醒入口,它比较特殊:唤醒检测本身是 recorder.py 的一段长驻逻辑,持续监听麦克风,这个「听」的过程不算 on_interact 意义上的输出,所以它「不可被外部打断」。但唤醒成功后产生的回复------比如普通模式的「在呢,你说?」,或前置词模式直接处理的语音------走的是普通 on_interact 流程,可以被后续请求打断。所以 ←F 是单向的,对应 4 对:

链路 14 --- A←F。用户在网页发字可以打断「在呢,你说?」还没播完的回复。 链路 15 --- B←F。/v1/chat 请求(包含日程提醒)打断唤醒回复。 链路 16 --- C←F。透传请求打断唤醒回复。 链路 17 --- E←F。远程音频推一段新语音,打断本地唤醒的回复。

【未列出的 4 对】

21 - 17 = 4,剩下的四对为什么不算:

  • D-E / D-F / D-G:D 只有打断能力没有输出,且实际业务里不会从 E/F/G 侧发起 to_stop_talking,组合在工程上不成立。
  • F-G:唤醒检测进程与 auto_play 都属于「被动触发」,两者不会出现直接相互打断的真实场景;如果真出现 F 的回复和 G 的 tick 碰到一起,那其实走的是 A/B/C 入口的同类机制,归到对应链路里更清晰。

【记忆锚点】

把这 17 条记住最省事的方法不是背表,而是记住一句话:只要新入口走 on_interact 且 no_reply=False,它就自动和其余所有同类入口构成双向打断关系;遇到像 D 那样只打断不输出、或者像 F 那样「听」的过程长驻的特殊入口,才会出现单向链路。下一节会沿着这一点展开,告诉你如何新增第八个入口并自动继承这套对称性。

复制代码
# =========================================================
# Fay 多通道打断 · 17 条链路全量对照表
# =========================================================
# 入口缩写:
#   A = api_send                     (gui/flask_server.py:593)
#   B = /v1/chat/completions         (gui/flask_server.py:674)
#   C = transparent_pass             (gui/flask_server.py:1412)
#   D = to_stop_talking              (gui/flask_server.py:1355)
#   E = 远程音频 (10001)             (fay_booter.py:130)
#   F = 唤醒                         (core/recorder.py:108-218)
#   G = auto_play 自动播报           (fay_booter.py:241)
#
# 澄清:日程 schedule 不是独立入口
#   mcp_servers/schedule_manager 通过 POST /v1/chat/completions
#   推送提醒 -> 本质是 B 的外部客户端 -> 打断语义继承 B
#
# 21 对组合 - 4 对工程上不成立 = 17 条有效链路
#
# +----+--------+----------+------+--------------------------------------+
# | #  | 链路   | 方向     | 状态 | 语义说明                             |
# +----+--------+----------+------+--------------------------------------+
# |  1 | A<->B  | 双向     |  OK  | GUI 聊天 ⇄ OpenAI 兼容接口          |
# |  2 | A<->C  | 双向     |  OK  | GUI 聊天 ⇄ 透传接口(曾误写为单向)   |
# |  3 | A<-D   | D->A     |  OK  | GUI 上的「打断」按钮打断 GUI 回复   |
# |  4 | A<->E  | 双向     |  OK  | GUI 聊天 ⇄ 远程音频                 |
# |  5 | A<-F   | F->A     |  OK  | GUI 聊天打断唤醒的回复              |
# |  6 | A<->G  | 双向     |  OK  | GUI 聊天 ⇄ auto_play 自动播报       |
# +----+--------+----------+------+--------------------------------------+
# |  7 | B<->C  | 双向     |  OK  | OpenAI 接口 ⇄ 透传接口              |
# |  8 | B<-D   | D->B     |  OK  | to_stop_talking 打断 /v1/chat 回复  |
# |  9 | B<->E  | 双向     |  OK  | /v1/chat ⇄ 远程音频                 |
# | 10 | B<-F   | F->B     |  OK  | /v1/chat 打断唤醒回复               |
# | 11 | B<->G  | 双向     |  OK  | /v1/chat ⇄ auto_play (日程走此对)   |
# +----+--------+----------+------+--------------------------------------+
# | 12 | C<-D   | D->C     |  OK  | to_stop_talking 打断 transparent    |
# | 13 | C<->E  | 双向     |  OK  | 透传接口 ⇄ 远程音频                 |
# | 14 | C<-F   | F->C     |  OK  | 透传请求打断唤醒回复                |
# | 15 | C<->G  | 双向     |  OK  | 透传接口 ⇄ auto_play                |
# +----+--------+----------+------+--------------------------------------+
# | 16 | E<-F   | F->E     |  OK  | 远程音频打断唤醒回复                |
# | 17 | E<->G  | 双向     |  OK  | 远程音频 ⇄ auto_play                |
# +----+--------+----------+------+--------------------------------------+
#
# 未列入的 4 对(工程上不成立 / 无实际场景):
#   D-E  D-F  D-G  : D 无输出,且业务不从 E/F/G 侧调用 to_stop_talking
#   F-G           : 唤醒「听」过程长驻,与 auto_play 无直接相互打断场景
#
# 对称性根源:
#   所有入口共享 core/fay_core.py:851 on_interact 的 if not no_reply 块。
#   任何 no_reply=False 的请求都会触发 clear_Stream_with_audio,
#   并将 conversation_id 翻新 ------ 这是 17 条对称性的唯一来源。

第6节 新增入口与潜在坑位

最后一节给出新增入口的扩展指南,并指出两个已知的潜在坑位。

扩展指南很简单。如果你要新增第八个让数字人开口的路径,三个选择: 第一种是普通输出路径,构造一个 Interact,调 fay_core.on_interact,no_reply 不要传或者传 False,其余什么都不用做,自动获得「和已有所有路径互相打断」的能力。 第二种是静默注入路径,构造 Interact 时设置 no_reply=True,Fay 会跳过 if not no_reply 块,不会清理当前进行中的输出。适合用来给 LLM 上下文塞 observation 但不要求 Fay 立即回应。 第三种是队列追加路径,参考 transparent_pass 的 queue=True 模式,可以把音频/文本排在当前播放之后顺序播放,但请注意它仍会被后续 no_reply=False 的请求中断。

现在说两个隐患。

隐患一在 core/recorder.py 第 151 到 157 行的唤醒非打断分支。代码顺序是:先调 on_interact 把「在呢,你说?」塞进 sound_query,然后又调一次 clear_Stream_with_audio。如果时序不利,刚塞进队列的「在呢」可能会被立即清掉,用户体验上偶尔会出现唤醒后没有回应的情况。这是一个时序 bug 隐患,需要后续修复。

隐患二是日程功能的 bug。mcp_servers/schedule_manager/server.py 第 176 行和 570 行调用了 self.send_to_fay 方法,但这个方法在整个 server.py 里没有定义;同时文件头定义了 FAY_API_URL 指向 /v1/chat/completions,说明设计意图是日程通过 B 入口推消息。所以日程本身不是独立的第八个入口,而是 B 的客户端;只是当前 send_to_fay 未实现,到点会抛 AttributeError,日程永远不会真正打到 Fay。如果你发现日程到点没动静,根因就在这里。

排查打断相关问题的标准思路是:先在 stream_manager 上 print stop_generation_flags 和 conversation_ids,确认双重判定到底拦住了谁、放行了谁,然后顺着 on_interact 的调用栈往上回溯入口。理解了 if not no_reply 块和 conv_id 校验,整个 17 条链路就都通透了。

复制代码
# 扩展示例:新增第八个入口,自动获得打断能力
@__app.route('/api/my-new-channel', methods=['POST'])
def my_new_channel():
    data = request.get_json()
    username = data.get('username', 'User')
    msg = data.get('msg')
    interact = Interact('mychannel', 1, {
        'user': username,
        'msg': msg,
        # no_reply 不传 → 默认 False → 自动触发打断
    })
    fay_booter.feiFei.on_interact(interact)
    return jsonify({'result': 'ok'})

# 静默注入示例(只塞 observation 不打断)
interact = Interact('text', 1, {
    'user': username,
    'msg': '',
    'observation': '用户走进了房间',
    'no_reply': True,  # 关键:跳过 if not no_reply 块
})
fay_booter.feiFei.on_interact(interact)

# 已知隐患 1:唤醒非打断分支的 in-flight clear
# core/recorder.py:151-157
intt = interact.Interact('auto_play', 2, {..., 'text': '在呢,你说?'})
self.__fay.on_interact(intt)               # 已塞进 sound_query
if not is_interrupt:
    stream_manager.new_instance().clear_Stream_with_audio(self.username)
    # ↑ 时序不利时会清掉刚 enqueue 的'在呢'
相关推荐
June bug2 小时前
【ISTQB-CTFL(基础级)】错题D卷
经验分享·职场和发展
郭泽斌之心2 小时前
MT5开启算法交易
经验分享·fay数字人
一个人旅程~2 小时前
双系统时windows如何读取linux ext4格式硬盘分区?
linux·windows·经验分享·电脑
M ? A3 小时前
Vue转React最佳工具对比:Vuera、Veaury与VuReact
前端·javascript·vue.js·经验分享·react.js
sweetone4 小时前
用一个电阻及一段胶带修复 VORWERK (福维克) THERMOMIX(美善品) TM5-1食品料理机 不工作故障
经验分享·单片机·嵌入式硬件
m0_716765234 小时前
数据结构三要素、时间复杂度计算详解
开发语言·数据结构·c++·经验分享·笔记·算法·visual studio
SccTsAxR5 小时前
算法进阶:贪心策略证明全攻略与二进制倍增思想深度解析
c++·经验分享·笔记·算法
中屹指纹浏览器5 小时前
2026分布式多账号运维体系中指纹浏览器的架构设计与工程落地
经验分享·笔记
智者知已应修善业5 小时前
【51单片机独立按键控制数码管动态显示和LED间隔闪烁并清零】2023-5-28
c语言·经验分享·笔记·算法·51单片机