Fay 的多通道打断逻辑
- 课程ID:course-fay-interrupt-channels
- 作者:Fay 开源社区
- 版本:1.2.0
- 章节数:6
封面

目录
- 为什么 Fay 需要多通道打断
- 打断中枢:clear_Stream_with_audio
- 统一闸门:on_interact 的 if not no_reply
- 双重防御:conversation_id 校验
- 17 条链路对称性总览
- 新增入口与潜在坑位
第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 的'在呢'