动机 / 用户故事
说完就想听见回应。 用户说「明天下午三点在 203 开会」,希望立刻听到助手确认,而不是盯着屏幕等。
一句话说不全的事,希望助手会问。 用户说「那个会几点开始」,助手不该猜一条,该问「是今天的早会还是明天的项目周会」。
补一句就该接着办。 上一句被问了,用户只答「周会」,助手要知道这是在回答刚才那个问题,不是新请求。
团队要在两条路之间做选择。 级联(ASR→LLM→TTS,实现 ASR、LLM Agent 与 TTS 流式处理能力 #183 )和端到端两条链路都在做,需要在同一套协议下跑出真实延迟数字,而不是凭供应商宣传。
共同需求(一句话):让语音链路真的能听、能说、能反问、能记住上一句——并把它跑在自己的协议上,数字才可比。
目标用户
App 使用者(语音交互的直接体验者);项目团队(做技术选型的依据)。
现有做法及其不足
#172 建好了传输层,#196 建好了 AgentPort / ResultSink 的一进五出边界。但边界后面挂的是 FakeAgent——它回一条固定结果,不转写、不理解、不出音频。
现状是:管子通了,里面没有智能,也听不到声音。 用供应商 playground 手工试能看到单点效果,但不过本项目的协议与端口,测不出集成后的真实延迟,也验证不了 function calling 在我们的工具形状下够不够可靠。
本期范围与明确不做
本期做
#
内容
1
实时模型会话的端口 + 千问 Qwen-Audio 适配器(音频进、文本与音频流式出)
2
把模型接到 #196 的五个出口上,跑通「说话 → 听见回应」
3
Function Call 查日程(按时间范围 / 标题 / 地点筛选)
4
语音追问:模型信息不足时反问,并在下一轮认出用户的回答
明确不做
不做
为什么
写操作 (创建 / 修改 / 删除日程)
需要架构设计 §5.7 的二次确认落点,且 ScheduleAgentService 五个方法目前全是 NotImplementedError)。
错误出口
模型失败时客户端收不到任何东西(只记服务端日志)。已知缺陷,本轮按「假设成功」推进,单独一个 PR 补。
ASR 部分转写(「你在说……」实时上屏)
纯体验优化。且实测转写可能出错而回答正确,提前显示错的转写可能更糟
重连与退避
网络抖动即整轮失败,属容错那一轮。
打断
push-to-talk 下打断该由我们做,但架构设计 §5 没定义打断消息,需先定协议。优先跑完全部功能
真实日程数据
用种子数据(8 条相对今天生成)。查询条件是真的在过滤,所以能验证模型有没有把「明天」理解对。换成真 service 是改一行
关键决策与依据
一、只读先行
#166 (@gac0812 )分析了用实时多模态模型替换级联链路的可行性,建议「先完成只读工具 POC,通过验收后灰度替换,旧链路保留为降级通道」。本轮照此执行。
二、模型型号
候选
取舍
qwen-audio-3.0-realtime-plus(采用)
阿里云百炼实时 API 文档列出的可用型号,能力最全
qwen-audio-3.0-realtime-flash
同上,对延迟与成本更敏感时可换。未实测 ,保留备选
三、push-to-talk,不让模型判断轮次
厂商支持自动轮次检测,但我们的协议已经用 voice.stream.start / end 界定了轮次。让模型也来判断等于两套机制打架。turn_detection 设为 null,且它只能在第一帧音频前设置。
后续可以使用,语音对话的形式作为新的 PR 提交。
四、会话按 conversation_id 复用(追问能成立的前提)
模型的上下文存在厂商会话里 (50 轮 / 300 秒上限)。若每轮开新会话,追问问出去了、用户答「周会」,落进一个从没问过问题的会话里。
协议本来就是这么设计的(架构设计 §5.4:客户端「直接再次发送 voice.stream.start,并携带上一次的 conversation_id」),且 VoiceStreamStartPayload.conversation_id 早就存在,网关层一行不用改 。
这是本轮第一个跨请求持有状态的组件,四件事都要处理:
事
做法
300 秒过期
到 240 秒就换新会话——厂商到点直接丢,表现为「这一轮什么都没有」。提前换会丢记忆,但丢记忆是差答案,静默是没答案
50 轮上限
到 40 轮就换
失败 / 断线
直接丢弃不复用——半句话的状态没人推理过
并发
每个 conversation 一把锁。会话是 append→commit→respond 的序列,两轮叠上去会把一个人的音频折进另一个人的回答
外加空闲会话主动回收:用户拿到答案就放下手机,那条会话没有下一轮,会一直占着一个到厂商的连接。
五、追问用工具声明,不靠猜文本
模型什么时候在「问」而不是在「答」?从文本判断不可靠。给它一个 ask_user 工具,四个参数正好是 §5.4 payload 的四个字段,复用已跑通的 Function Call 机制。两处服务端把关:
question_kind 不在四个枚举里就拒掉——转发模型自己编的值,等于把客户端没有分支的枚举放上线
提问的音频 purpose 标成 dialogue_question,客户端才知道这轮不是结束、可能要继续开麦
六、分层:适配器不许 import 对话层
架构测试 A.4 禁止 infrastructure 依赖任何产品层。所以适配器自己声明 一个同形状的 Observer 协议,对话层也自己声明 TurnObserver,两边互不 import。这一条是被架构测试拦出来的——初版适配器直接 import 了 timeflow.intelligence.realtime_ports,测试报错后重构。
基本概念与信息结构
音频 ──► AgentPort(#196)──► RealtimeAgent ──► 厂商会话(按 conversation_id 复用)
▲ │
│ ▼
ResultSink(#196)◄── 五种模型事件
厂商事件到五个出口是五进四出,不是一对一 :
厂商事件
走哪个出口
出去的消息
用户转写完成
deliver_transcript
voice.asr.completed
回复文字增量
deliver_reply_text
voice.dialogue.reply
工具调用完成 → find_schedules
deliver_result
voice.command.result
工具调用完成 → ask_user
deliver_question
voice.dialogue.question
回复音频增量
deliver_audio
voice.tts.start → 帧 → end
失败
无出口 (见「明确不做」)
无
新增两处目录,与级联方案(#189 已用 intelligence/conversation/)不重叠:
intelligence/realtime/ —— 端到端方案的对话层实现
infrastructure/external/realtime/ —— 厂商适配器
验收标准
以下均已在真机跑通,作为验收的具体例子:
一、能听见回应
说「明天有什么安排」,依次收到 voice.asr.completed → voice.command.result → voice.tts.start → 音频帧 → voice.tts.end,并能听到中文回答。首帧音频延迟:直连模型 1089ms,经我们的 WS 1329ms,带一次工具调用 2339ms。
二、查询条件真的在过滤
说「明天有什么安排」只返回明天那条,不是全部 8 条。问「203 有什么会」能匹配到存储为「203」的日程(地点双向包含)。
三、会反问,且候选可渲染
说「那个会几点开始」→ 模型先查(voice.command.result 2 条)→ 再问(voice.dialogue.question,question_kind=ambiguous_target,candidates 带 2 条),口播「您说的是今天的早会,还是明天的项目周会?」,音频 purpose=dialogue_question。
四、记得上一句
接上一轮,只说「周会」(带同一个 conversation_id)→ 模型答「明天的项目周会下午三点开始」,且不再查库 ——从会话上下文直接答出来,说明跨轮记忆生效。
五、时间与时区正确
模型知道今天是哪天(当前本地时间注入 instructions,每轮重建)。协议消息里时间一律 UTC;只有给模型看的 JSON 额外附一个本地时刻字符串——否则它会把 01:30:00Z 念成「凌晨一点半」。
PR 拆分
两个 PR,按开发流程递进,每个都能独立评审。PR 合并后在此勾选 ,本 Issue 的进度一眼可见:
原计划拆四个,合成两个:端口的消费者是 RealtimeAgent,分开提的话第一个 PR 里的端口文件没有任何调用方;而追问本身就是一个工具,和查询住在同一个 ToolBox 里,拆开等于刚交付就打开重改。
两项全部勾选即本 Issue 可关闭。补错误出口作为后续独立 PR,不并入本 Issue。
动机 / 用户故事
共同需求(一句话):让语音链路真的能听、能说、能反问、能记住上一句——并把它跑在自己的协议上,数字才可比。
目标用户
App 使用者(语音交互的直接体验者);项目团队(做技术选型的依据)。
现有做法及其不足
#172 建好了传输层,#196 建好了
AgentPort/ResultSink的一进五出边界。但边界后面挂的是FakeAgent——它回一条固定结果,不转写、不理解、不出音频。现状是:管子通了,里面没有智能,也听不到声音。 用供应商 playground 手工试能看到单点效果,但不过本项目的协议与端口,测不出集成后的真实延迟,也验证不了 function calling 在我们的工具形状下够不够可靠。
本期范围与明确不做
本期做
明确不做
ScheduleAgentService五个方法目前全是NotImplementedError)。关键决策与依据
一、只读先行
#166(@gac0812)分析了用实时多模态模型替换级联链路的可行性,建议「先完成只读工具 POC,通过验收后灰度替换,旧链路保留为降级通道」。本轮照此执行。
二、模型型号
qwen-audio-3.0-realtime-plus(采用)qwen-audio-3.0-realtime-flash三、push-to-talk,不让模型判断轮次
厂商支持自动轮次检测,但我们的协议已经用
voice.stream.start/end界定了轮次。让模型也来判断等于两套机制打架。turn_detection设为 null,且它只能在第一帧音频前设置。后续可以使用,语音对话的形式作为新的 PR 提交。
四、会话按
conversation_id复用(追问能成立的前提)模型的上下文存在厂商会话里(50 轮 / 300 秒上限)。若每轮开新会话,追问问出去了、用户答「周会」,落进一个从没问过问题的会话里。
协议本来就是这么设计的(架构设计 §5.4:客户端「直接再次发送
voice.stream.start,并携带上一次的conversation_id」),且VoiceStreamStartPayload.conversation_id早就存在,网关层一行不用改。这是本轮第一个跨请求持有状态的组件,四件事都要处理:
外加空闲会话主动回收:用户拿到答案就放下手机,那条会话没有下一轮,会一直占着一个到厂商的连接。
五、追问用工具声明,不靠猜文本
模型什么时候在「问」而不是在「答」?从文本判断不可靠。给它一个
ask_user工具,四个参数正好是 §5.4 payload 的四个字段,复用已跑通的 Function Call 机制。两处服务端把关:question_kind不在四个枚举里就拒掉——转发模型自己编的值,等于把客户端没有分支的枚举放上线purpose标成dialogue_question,客户端才知道这轮不是结束、可能要继续开麦六、分层:适配器不许 import 对话层
架构测试 A.4 禁止
infrastructure依赖任何产品层。所以适配器自己声明一个同形状的Observer协议,对话层也自己声明TurnObserver,两边互不 import。这一条是被架构测试拦出来的——初版适配器直接 import 了timeflow.intelligence.realtime_ports,测试报错后重构。基本概念与信息结构
厂商事件到五个出口是五进四出,不是一对一:
deliver_transcriptvoice.asr.completeddeliver_reply_textvoice.dialogue.replyfind_schedulesdeliver_resultvoice.command.resultask_userdeliver_questionvoice.dialogue.questiondeliver_audiovoice.tts.start→ 帧 →end新增两处目录,与级联方案(#189 已用
intelligence/conversation/)不重叠:intelligence/realtime/—— 端到端方案的对话层实现infrastructure/external/realtime/—— 厂商适配器验收标准
以下均已在真机跑通,作为验收的具体例子:
一、能听见回应
说「明天有什么安排」,依次收到
voice.asr.completed→voice.command.result→voice.tts.start→ 音频帧 →voice.tts.end,并能听到中文回答。首帧音频延迟:直连模型 1089ms,经我们的 WS 1329ms,带一次工具调用 2339ms。二、查询条件真的在过滤
说「明天有什么安排」只返回明天那条,不是全部 8 条。问「203 有什么会」能匹配到存储为「203」的日程(地点双向包含)。
三、会反问,且候选可渲染
说「那个会几点开始」→ 模型先查(
voice.command.result2 条)→ 再问(voice.dialogue.question,question_kind=ambiguous_target,candidates带 2 条),口播「您说的是今天的早会,还是明天的项目周会?」,音频purpose=dialogue_question。四、记得上一句
接上一轮,只说「周会」(带同一个
conversation_id)→ 模型答「明天的项目周会下午三点开始」,且不再查库——从会话上下文直接答出来,说明跨轮记忆生效。五、时间与时区正确
模型知道今天是哪天(当前本地时间注入 instructions,每轮重建)。协议消息里时间一律 UTC;只有给模型看的 JSON 额外附一个本地时刻字符串——否则它会把
01:30:00Z念成「凌晨一点半」。PR 拆分
两个 PR,按开发流程递进,每个都能独立评审。PR 合并后在此勾选,本 Issue 的进度一眼可见:
RealtimeAgent+ 组合根装配 —— feat(intelligence): 接上端到端实时模型,让一轮语音真的能听见回应 #201ask_user追问 + 会话按conversation_id复用原计划拆四个,合成两个:端口的消费者是
RealtimeAgent,分开提的话第一个 PR 里的端口文件没有任何调用方;而追问本身就是一个工具,和查询住在同一个 ToolBox 里,拆开等于刚交付就打开重改。两项全部勾选即本 Issue 可关闭。补错误出口作为后续独立 PR,不并入本 Issue。