TimeFlow 多轮语音助手改造方案
版本:V3
状态:讨论确认稿
范围:账号设备绑定、安全 WebSocket、多轮上下文、指代消解、语音日程增删改查、语音地点解析
1. 背景
TimeFlow 当前的语音处理方式是一次录音对应一次独立的日程解析,模型调用只接收本次 ASR 文本,没有持久化对话上下文,也没有真正的账号认证和账号数据隔离。
本次改造目标是在保持实现相对简单的前提下,将其升级为可持续多轮交互的语音日程助手,同时通过账号与设备绑定为未来多设备支持预留基础。
2. 本期目标
本期实现:
- 建立账号与客户端设备的绑定关系。
- 使用短期、单次 WebSocket ticket 完成连接认证。
- 同一 App 进程内 WebSocket 临时断线重连时,可以继续当前对话。
- App 第一次打开,或 App 进程被清除后再次启动时,始终创建新对话。
- 使用一张
conversation_records 表保存消息和对话状态事件。
- 每次模型调用携带最近 20 条上下文消息。
- 支持“这个、那个、刚才的、第二个”等简单指代消解。
- 支持通过语音查询、新增、修改、删除日程。
- 所有日程写操作在真正执行前由用户确认。
- 支持从语音中提取地名并搜索地点。
- 不明确时通过多轮对话让用户从候选地点或候选日程中选择。
3. 本期不做
本期明确不包含:
- 不同设备之间共享对话。
- 多设备之间实时同步日程。
- 账号下全部设备的 WebSocket 广播。
device_schedule_states 等设备日程同步表。
- 多设备系统日历、闹钟同步。
- 地理围栏多设备策略调整。
- Redis 和多实例部署。
- 长期对话摘要、向量检索或长期记忆。
- App 冷启动后恢复或继续上一次对话。
这些能力以后确有需求时再单独设计,不进入本期实现。
4. 核心身份模型
本期区分四种身份:
| 对象 |
标识 |
生命周期 |
| 账号 |
user_id |
账号长期有效 |
| 账号设备 |
account_device_id |
账号撤销设备前有效 |
| 对话 |
conversation_id |
一段对话期间有效 |
| WebSocket 连接 |
connection_id |
每次连接重新生成 |
WebSocket 断线后无法复用原来的底层连接。同一 App 进程内发生临时断线时,重连会生成新的 connection_id,但进程内正在使用的 conversation_id 可以保持不变。如果 App 进程已经被清除,新的 App 进程不再使用旧 conversation_id,而是创建新对话。
本期同一个 account_device_id 只允许存在一个活跃 WebSocket。新连接认证成功后,服务端主动关闭旧连接。
5. 账号认证前提
本方案假设系统已有或同期接入账号登录能力,并能够向客户端签发 access token。
如果账号来自外部认证服务,user_id 保存外部账号主体标识;如果完全自建认证,应独立设计账号和凭证表,不能把密码或长期账号凭证放入设备表。
设备 UUID 只是设备标识,不是登录凭证。仅知道某个设备 UUID,不能获得对应账号权限。
6. 账号设备绑定
6.1 account_devices 表
新增最小化设备绑定表:
id UUID PRIMARY KEY
user_id TEXT NOT NULL
device_uuid UUID NOT NULL
created_at TIMESTAMPTZ NOT NULL
revoked_at TIMESTAMPTZ NULL
UNIQUE(user_id, device_uuid)
字段说明:
id:服务端生成的 account_device_id。
user_id:认证后的账号 ID。
device_uuid:当前客户端安装实例的稳定 UUID。
created_at:账号首次绑定该设备的时间。
revoked_at:账号撤销该设备的时间;为空表示仍然有效。
本期不保存设备名称、平台、App 版本和最后在线时间。如以后确有展示或管理需求,再增加相关字段。
6.2 客户端不判断“首次登录”
客户端不需要单独维护 is_first_login。
每次 App 启动统一执行:
- 从持久化存储读取
device_uuid。
- 如果不存在,使用安全随机 UUID 生成器创建并保存。
- 完成登录或刷新 access token。
- 调用幂等设备绑定接口。
- 服务端存在绑定时返回原来的
account_device_id。
- 服务端不存在绑定时创建记录并返回新的
account_device_id。
- 客户端申请 WebSocket ticket。
- 客户端自动建立 WebSocket。
设备绑定接口:
POST /api/v1/devices/bind
Authorization: Bearer <access_token>
Content-Type: application/json
{
"device_uuid": "550e8400-e29b-41d4-a716-446655440000"
}
响应:
{
"account_device_id": "服务端生成或已经存在的 UUID"
}
客户端可以在每次启动或登录后重复调用该接口,不会产生重复绑定。
7. WebSocket ticket
7.1 申请 ticket
设备绑定完成后,客户端自动申请 ticket:
POST /api/v1/ws-tickets
Authorization: Bearer <access_token>
Content-Type: application/json
{
"account_device_id": "..."
}
服务端验证:
- access token 对应的
user_id。
account_device_id 是否属于该账号。
- 设备是否已经被撤销。
验证通过后返回:
{
"ticket": "安全随机字符串",
"expires_in": 30
}
客户端立即连接:
wss://api.example.com/ws?ticket=<ticket>
以上步骤全部由 App 自动完成,用户仍然只会感知到“打开 App 后自动连接”。
7.2 TTL
TTL 是 Time To Live,即数据的有效存活时间。
本期 ticket 的 TTL 暂定为 30 秒。ticket 发出 30 秒后,无论是否使用都失效;成功使用一次后立即删除,不能再次连接。
7.3 内存 ticket 存储
当前项目以单进程示例运行,因此本期使用内存保存 ticket:
ticket_hash
→ user_id
→ account_device_id
→ expires_at
服务端只保存 ticket 哈希,不保存原始 ticket。
WebSocket 验证成功时,使用类似以下语义的原子消费操作:
ticket_record = ticket_store.pop(ticket_hash)
如果找不到、已经过期或已经被使用,则拒绝连接。
过期记录可以在读取时顺便删除,也可以通过简单的后台定时任务清理。
7.4 什么时候迁移到 Redis
是否需要 Redis 不单纯取决于用户数量,而取决于是否出现多个后端进程或实例。
出现以下情况时需要将 ticket 存储迁移到 Redis:
- Uvicorn 使用多个 worker。
- 后端启动多个进程。
- 部署多个容器或服务器。
- ticket HTTP 请求和 WebSocket 连接可能落到不同实例。
本期定义统一的 WebSocketTicketStore 接口,并实现 InMemoryWebSocketTicketStore。以后只需替换为 RedisWebSocketTicketStore,上层认证流程不变。
后端重启会导致尚未使用的内存 ticket 失效。客户端连接失败后重新申请 ticket 即可,这对于 30 秒临时凭证是可以接受的。
8. WebSocket 连接上下文
ticket 验证成功后,服务端建立可信连接上下文:
ConnectionContext
├── user_id
├── account_device_id
├── connection_id
└── connected_at
后续业务处理器从 ConnectionContext 取得身份,不接受客户端在业务消息中自行指定 user_id 或 account_device_id。
连接管理器本期保存:
account_device_id → WebSocket connection
同一账号设备重连时:
- 验证新连接。
- 为新连接生成
connection_id。
- 注册新连接。
- 主动关闭旧连接。
- 旧连接不再允许执行后续业务消息。
认证成功响应:
{
"type": "session.ready",
"account_device_id": "...",
"connection_id": "...",
"server_time": "..."
}
本期不建立账号下全部设备的连接索引,也不进行账号级广播。
9. 单表对话记录
9.1 设计原则
本期不分别建立 conversations 和 conversation_messages,而是合并成一张 conversation_records 表。
不把全部消息保存进一行 JSON 数组,而是每条消息或状态变化各占一行。conversation_id 相同的记录共同组成一段对话。
9.2 conversation_records 表
id BIGSERIAL PRIMARY KEY
conversation_id UUID NOT NULL
user_id TEXT NOT NULL
account_device_id UUID NOT NULL
record_type TEXT NOT NULL
role TEXT NULL
content TEXT NULL
payload JSONB NOT NULL DEFAULT '{}'
client_message_id UUID NULL
operation_id UUID NULL
created_at TIMESTAMPTZ NOT NULL
UNIQUE(conversation_id, client_message_id)
UNIQUE(operation_id, record_type)
建议索引:
(user_id, account_device_id, conversation_id, id)
(conversation_id, record_type, id)
9.3 记录类型
本期使用以下 record_type:
user.message
assistant.message
tool.result
schedule.candidates
schedule.focus.changed
location.candidates
location.selected
pending_action.set
pending_action.cleared
operation.completed
operation.failed
普通用户消息:
{
"record_type": "user.message",
"role": "user",
"content": "把周五的会议改到四点",
"payload": {}
}
工具结果:
{
"record_type": "tool.result",
"role": "tool",
"content": null,
"payload": {
"tool_name": "schedule.search",
"result": {}
}
}
9.4 对话创建与生命周期
App 进程启动或用户主动新建对话时,由服务端生成新的 conversation_id 并返回给客户端。此时不立即写数据库;用户发送第一条消息后,第一条 user.message 就代表这段对话正式开始。
如果用户尚未发送任何消息就结束 App,该空对话不会留下数据库记录。
conversation_id 只保存在当前 App 进程内,不写入客户端长期持久化存储。
生命周期规则:
- App 第一次打开:创建新对话。
- App 进程被用户清除、被系统回收或彻底结束后再次启动:创建新对话。
- 同一 App 进程内只是 WebSocket 临时断开并自动重连:继续当前
conversation_id。
- 用户主动点击“新对话”:创建新的
conversation_id。
- 新对话不会自动加载上一段对话的消息,模型上下文也不包含上一段对话。
- 旧对话记录仍可保留在数据库中用于日志和问题排查,但本期客户端不提供恢复入口。
同一 App 进程内重连并继续当前对话时,服务端必须校验:
record.user_id == connection.user_id
record.account_device_id == connection.account_device_id
record.conversation_id == requested_conversation_id
本期不同设备之间不共享对话。即使登录同一个账号,不同 account_device_id 也不能读取彼此的对话记录。
客户端只在进程内存中保存当前 conversation_id。进程结束后该值自然丢失,下次启动直接创建新对话,不查询或恢复上一次对话。
以后需要跨设备共享时,可以保留 user_id 归属并调整设备访问规则,无需改变每条记录的基本结构。
10. 最近 20 条上下文
数据库保存完整对话记录,模型调用时只加载最近 20 条上下文消息。
计入 20 条的记录类型:
user.message
assistant.message
tool.result
地点候选、日程候选、焦点和待确认操作作为结构化状态单独加载,不占普通消息条数。
每次模型调用包含:
- 系统提示词。
- 当前时间和时区。
- 最近 20 条有效消息。
- 当前日程焦点和最近候选。
- 当前地点候选。
- 当前待确认操作。
- 当前用户消息。
历史消息必须按照实际角色传入模型:
system
user
assistant
tool
不能把全部历史拼接成一条用户消息。
除 20 条数量限制外,再设置一个可配置的 token 上限。消息过长时从最早记录开始裁剪,但必须保留当前用户消息、当前待确认操作、当前焦点以及必要的最近工具结果。
本期不做长期摘要和向量检索。
11. 消息顺序与幂等
客户端每次发送一条新的用户消息时生成 client_message_id。网络重试时继续使用原来的 client_message_id,不能重新生成。
唯一约束:
UNIQUE(conversation_id, client_message_id)
用于保证网络重试或重连后重复发送相同消息时,不会重复执行模型调用和日程操作。
request_id 标识某一次网络请求,重试时可以变化;client_message_id 标识用户逻辑上的同一条消息,重试时必须保持不变。
conversation_records.id 使用数据库自增的 BIGSERIAL。读取同一对话的历史记录时按 id 排序,因此不再维护每个对话自己的 sequence,也不需要序号分配锁。
同一对话一次只处理一条用户消息:
- 前端在助手处理中禁用下一次录音或发送。
- 后端使用简单的进程内忙碌状态做兜底。
- 如果同一对话仍在处理上一条消息,后端直接返回
CONVERSATION_BUSY。
- 本期不实现消息排队。
这样可以防止上一条查询还没有生成候选列表时,下一条“删除第二个”被提前处理。
12. 语音处理流程
改造后的语音流程:
开始录音
→ 建立语音流
→ 上传音频
→ ASR 生成最终文本
→ 写入 user.message
→ 加载最近 20 条上下文和结构化状态
→ 判断日程意图与目标
→ 执行日程查询,或生成需要用户确认的日程操作
→ 写入工具结果和 assistant.message
→ 返回助手结果
voice.stream.start 是客户端在开始上传语音前发送的一条 WebSocket 控制消息,不是数据库表,也不是直接发给模型的提示词。它用于告诉服务端“哪段对话现在要开始一条新的语音消息”。
字段含义:
| 字段 |
作用 |
request_id |
匹配本次开始语音请求与服务端响应 |
conversation_id |
指明这条语音属于哪段对话 |
client_message_id |
防止断线重试时重复处理同一条语音消息 |
audio_format |
音频数据格式 |
sample_rate_hz |
音频采样率 |
channels |
音频声道数 |
current_location |
客户端可选提供的当前经纬度 |
消息示例:
{
"type": "voice.stream.start",
"request_id": "...",
"conversation_id": "...",
"client_message_id": "...",
"payload": {
"audio_format": "pcm_s16le",
"sample_rate_hz": 16000,
"channels": 1,
"current_location": {
"latitude": 22.543,
"longitude": 114.057
}
}
}
current_location 可为空。只有语音中未明确城市且需要搜索地点时才使用。
活跃语音流按 connection_id 管理,避免旧连接的语音流影响重连后的新连接。
ASR 原始音频默认不持久化,只保存最终识别文本。
13. 日程工具能力
13.1 查询工具
schedule.list
schedule.search
schedule.get
查询可以直接执行,不需要用户确认。
所有日程工具都从 ConnectionContext 取得 user_id,所有仓储查询必须同时匹配 schedule_id 和 user_id。
查询结果超过一条时,追加 schedule.candidates 记录,支持用户通过“第一个”“第二个”“项目会那个”等后续表达选择目标。
唯一目标确定后追加 schedule.focus.changed。
13.2 创建日程
根据用户输入生成日程草稿。能力名称统一叫“创建日程”,不使用“提议”作为工具名称。
为了防止 ASR 识别错误,第一次调用只生成待确认草稿;用户确认后才真正写入 schedules。
13.3 修改日程
先确定唯一日程目标,再生成修改后的字段差异。能力名称叫“修改日程”,但实际数据库更新仍然要等用户确认后执行。
如果当前正在确认一个尚未创建的日程草稿,用户说“改到四点”时,应修改当前草稿,而不是查询数据库中的已有日程。
13.4 删除日程
先确定唯一日程目标,再生成删除确认内容。能力名称叫“删除日程”,但实际删除仍然要等用户确认后执行。
14. 写操作确认
考虑到 ASR 可能识别错误,本期所有写操作统一确认:
- 新增需要确认。
- 修改需要确认。
- 删除需要确认。
- 查询不需要确认。
待确认操作写入 pending_action.set:
{
"record_type": "pending_action.set",
"operation_id": "...",
"payload": {
"type": "update",
"schedule_id": "schedule_123",
"patch": {
"start_time": "2026-08-07T16:00"
},
"base_updated_at": "...",
"expires_at": "..."
}
}
一个对话同一时间只允许存在一个未清除的待确认操作。
用户可以通过按钮或语音回复“确认”“取消”。
确认时重新验证:
- 操作属于当前对话。
- 对话属于当前账号设备。
- 日程属于当前账号。
- 操作没有过期。
- 目标日程在等待期间没有发生变化。
- 相同
operation_id 没有完成过。
- 日程字段仍然符合业务规则。
执行成功后依次追加:
operation.completed
pending_action.cleared
assistant.message
执行失败时追加 operation.failed,并根据失败类型决定是否保留待确认状态。
待确认操作保存在数据库中,因此后端重启后不会丢失。
15. 日程指代消解
日程指代优先依赖最近的结构化记录,不完全交给模型猜测。
解析顺序:
- 检查当前是否存在待确认草稿或待确认操作。
- 检查最近的
schedule.focus.changed。
- 检查最近的
schedule.candidates。
- 使用当前语句中的标题、日期、时间等条件缩小候选。
- 只有一个候选时确定目标。
- 多个候选时要求用户补充或选择。
- 没有候选时提示未找到。
示例:
用户:周五下午三点创建项目会
助手:已生成草稿,请确认
用户:改到四点
第二句优先修改当前待确认草稿。
用户:查询周五的会议
助手:找到三条会议
用户:把第二个删掉
“第二个”从最近的 schedule.candidates 中解析。
最终执行前必须重新使用 schedule_id + user_id 查询数据库,不能直接信任模型生成的日程 ID。
16. 语音地点解析
16.1 提取内容
从 ASR 文本中提取:
- 地点原文。
- 语音中是否明确包含城市。
- 城市名称(如有)。
- 用于搜索的地点关键词。
本期不设计置信度评分。
16.2 语音中包含城市
例如:
处理流程:
- 搜索结果只有一个:直接写入日程草稿。
- 搜索结果有多个:使用地图服务的默认排序,展示前三个。
- 没有结果:按照地点解析失败处理。
16.3 语音中没有城市
例如:
处理流程:
以用户当前位置为中心
→ 搜索 50 公里范围内符合关键词的地点
→ 按距离从近到远排序
- 搜索结果只有一个:直接写入日程草稿。
- 搜索结果有多个:展示距离最近的前三个。
- 没有结果:按照地点解析失败处理。
- 客户端无法提供当前位置:询问用户地点所在城市。
候选展示示例:
1. 万象城(福田区幸福路 88 号),距你 2.3 公里
2. 万象天地(南山区深南大道),距你 8.6 公里
3. 龙岗万象汇(龙岗区翔鸽路),距你 18.2 公里
16.4 多轮地点选择
多个地点候选写入 location.candidates:
{
"record_type": "location.candidates",
"payload": {
"location_text": "万象城",
"candidates": [
{
"index": 1,
"name": "万象城",
"address": "福田区幸福路 88 号",
"latitude": 22.543,
"longitude": 114.057,
"distance_meters": 2300
}
]
}
}
用户可以回复:
解析时读取最近一条未完成的 location.candidates,不重新搜索。
确定后追加 location.selected,并把地点写入当前日程草稿:
{
"location_name": "万象城",
"location_address": "福田区幸福路 88 号",
"latitude": 22.543,
"longitude": 114.057
}
候选地点确定之前,不允许确认最终日程草稿。
16.5 地点解析失败
如果地图服务请求失败或没有搜索结果:
- 日程包含明确时间:仍允许创建时间日程,只保存用户说出的地点原文;
location_address、latitude 和 longitude 为空。
- 用户只提供地点、没有时间,准备创建位置提醒:暂时不能创建,需要用户补充城市、重新选择地点或补充时间。
最终规则:
语音包含城市
→ 在指定城市搜索地点
语音不包含城市
→ 在当前位置 50 公里内搜索
→ 按距离升序
一个结果
→ 自动写入草稿
多个结果
→ 展示前三个
→ 通过多轮对话选择
零结果或地图服务失败
→ 有时间则保存地点原文并继续创建时间日程
→ 纯位置提醒则要求用户补充地点
17. schedules.user_id 的账号隔离方式
本期不重命名 schedules.user_id。这个字段表示“这条日程属于哪个账号”。需要修正的是它当前始终写入 default_user 的使用方式。
当前固定 default_user 的方式需要删除。修改后:
ScheduleService 每次处理请求时都接收当前已认证账号的 user_id,不再在服务对象中固定保存 default_user。
user_id 来自 WebSocket ticket 验证后生成的 ConnectionContext。
- 客户端业务消息不能自行声明
user_id,否则客户端可以伪装成其他账号。
- 创建日程时,将当前连接的
user_id 写入 schedules.user_id。
- 查询日程列表时,只查询当前
user_id 的日程。
- 查询、修改和删除单条日程时,同时使用
schedule_id 和当前 user_id 查找。
例如,账号 A 要修改 schedule_123 时,数据库实际查询条件相当于:
WHERE id = 'schedule_123'
AND user_id = '账号A'
如果 schedule_123 实际属于账号 B,账号 A 会得到“日程不存在”,不能读取或修改它。即使别人知道了某条日程的 ID,也不能跨账号操作。
所谓“每次调用显式接收 user_id”,就是将调用方式从类似:
schedule_service.list(query)
调整为:
schedule_service.list(connection_context.user_id, query)
这里的 connection_context.user_id 由服务端认证得到,不由客户端填写。
建议业务接口形态:
upsert(user_id, command)
list(user_id, query)
get(user_id, schedule_id)
delete(user_id, schedule_id)
这样可以完成账号数据隔离,同时避免本期进行字段重命名迁移。
18. 前端改造
前端需要完成:
- 使用安全随机 UUID 替换时间戳加
Math.random() 的设备 ID。
- 持久化保存
device_uuid。
- 登录后调用幂等设备绑定接口。
- 每次 WebSocket 连接前自动申请 ticket。
- ticket 过期或失效时自动重新申请。
- 只在当前 App 进程内存中保存
conversation_id,不做长期持久化。
- 同一 App 进程内 WebSocket 临时重连时继续当前对话。
- App 冷启动时始终创建新对话,不加载上一段对话。
- 从服务端加载对话记录,不再只依赖 React 内存状态。
- 展示用户最终 ASR 文本。
- 展示助手回复、日程草稿、日程候选和地点候选。
- 支持按钮或语音确认、取消。
- 支持“第一个”“第二个”“某某区那个”等选择表达。
- 在语音流开始时按需附带当前经纬度。
- 不加载其他设备的对话。
19. 后端模块建议
identity/
├── account_device_model
├── account_device_repository
├── account_device_service
├── websocket_ticket_store
└── connection_context
conversation/
├── conversation_record_model
├── conversation_record_repository
├── context_builder
├── reference_resolver
└── pending_action_service
assistant/
├── assistant_orchestrator
├── intent_parser
├── schedule_tools
└── location_search_service
ticket 存储接口:
WebSocketTicketStore
├── issue(...)
└── consume(...)
本期实现 InMemoryWebSocketTicketStore,未来多进程时替换为 RedisWebSocketTicketStore。
地图服务调用放在后端统一处理,客户端只提供当前经纬度并展示候选结果。
20. 实施顺序
第一阶段:账号设备与安全连接
- 新增
account_devices 表。
- 修改客户端设备 UUID 生成与存储。
- 实现幂等设备绑定接口。
- 实现内存 ticket 存储。
- 改造 WebSocket 握手。
- 建立
ConnectionContext。
- 实现同一账号设备新连接替换旧连接。
第二阶段:账号日程隔离
- 删除业务层固定的
default_user。
- 日程服务方法显式接收认证后的
user_id。
- 所有日程仓储操作增加账号过滤。
- 修复当前
device_id 与 user_id 混用。
第三阶段:单表对话持久化
- 新增
conversation_records 表。
- 实现新进程创建新对话,以及同一 App 进程内 WebSocket 重连继续当前对话。
- 使用自增记录 ID 确定消息顺序,并实现
client_message_id 幂等。
- 实现按
conversation_id 读取当前对话记录。
第四阶段:多轮上下文
- ASR 结果写入
user.message。
- 加载最近 20 条消息。
- 使用正确角色调用模型。
- 保存助手消息和工具结果。
- 限制同一对话同时只处理一轮请求。
第五阶段:语音日程增删改查
- 实现日程查询工具。
- 实现创建、修改、删除日程能力及确认流程。
- 实现待确认操作。
- 支持按钮和语音确认、取消。
- 增加重复操作保护。
第六阶段:指代与地点选择
- 实现日程候选和当前焦点。
- 实现“第二个”“刚才那个”等日程指代。
- 实现城市内地点搜索。
- 实现当前位置 50 公里地点搜索和距离排序。
- 实现地点前三候选展示和多轮选择。
- 实现地图失败后的时间日程降级。
第七阶段:验证与稳定性
- 完成账号隔离测试。
- 完成 ticket 过期和重复使用测试。
- 完成 WebSocket 重连测试。
- 完成同账号不同设备对话隔离测试。
- 完成最近 20 条上下文测试。
- 完成重复消息和重复确认测试。
- 完成地点搜索、排序、选择和降级测试。
- 完成同一 App 进程内 WebSocket 重连后继续当前对话和读取待确认状态的测试。
- 完成 App 新进程启动后始终创建新对话的测试。
21. 验收标准
完成后应满足:
- 只知道
device_uuid 无法冒充账号设备。
- ticket 超过 TTL 后不能建立连接。
- ticket 使用一次后不能再次使用。
- 同一账号设备重连后旧连接失效。
- 后端重启导致临时 ticket 失效时,客户端能够自动重新申请。
- 客户端不需要判断是否首次登录。
- 同一 App 进程内 WebSocket 临时断线重连后能够继续当前对话。
- App 第一次打开或进程被清除后再次启动时始终创建新对话,不加载上一段对话。
- 同账号不同设备看不到彼此的对话。
- 不同账号不能读取或修改彼此日程。
- 模型最多加载最近 20 条消息,并受 token 上限控制。
- 重复发送同一
client_message_id 不会重复处理。
- 语音可以查询、新增、修改和删除日程。
- 所有写操作必须经过确认。
- 重复确认不会重复执行操作。
- “改到四点”“删除第二个”等表达能够结合上下文处理。
- 多个日程候选时不会由模型自行猜测。
- 语音中带城市时能够在指定城市搜索地点。
- 语音中不带城市时能够在当前位置 50 公里内搜索并按距离排序。
- 单一地点结果能够自动写入草稿。
- 多个地点结果能够展示前三个并通过多轮对话选择。
- 地图解析失败时,带明确时间的日程仍可以保存地点原文并创建。
- 纯位置提醒在缺少有效地点坐标时不会被错误创建。
22. 后续扩展预留
本期完成后,未来可以在不推翻核心设计的情况下增加:
- Redis ticket 存储。
- 多后端实例。
- 跨设备共享对话。
- 对话汇总表和对话列表。
- 账号级日程广播。
- 长期上下文摘要。
- 更完整的地点排序和个性化地点偏好。
这些能力不影响本期交付,也不应提前进入当前实现范围。
TimeFlow 多轮语音助手改造方案
1. 背景
TimeFlow 当前的语音处理方式是一次录音对应一次独立的日程解析,模型调用只接收本次 ASR 文本,没有持久化对话上下文,也没有真正的账号认证和账号数据隔离。
本次改造目标是在保持实现相对简单的前提下,将其升级为可持续多轮交互的语音日程助手,同时通过账号与设备绑定为未来多设备支持预留基础。
2. 本期目标
本期实现:
conversation_records表保存消息和对话状态事件。3. 本期不做
本期明确不包含:
device_schedule_states等设备日程同步表。这些能力以后确有需求时再单独设计,不进入本期实现。
4. 核心身份模型
本期区分四种身份:
user_idaccount_device_idconversation_idconnection_idWebSocket 断线后无法复用原来的底层连接。同一 App 进程内发生临时断线时,重连会生成新的
connection_id,但进程内正在使用的conversation_id可以保持不变。如果 App 进程已经被清除,新的 App 进程不再使用旧conversation_id,而是创建新对话。本期同一个
account_device_id只允许存在一个活跃 WebSocket。新连接认证成功后,服务端主动关闭旧连接。5. 账号认证前提
本方案假设系统已有或同期接入账号登录能力,并能够向客户端签发 access token。
如果账号来自外部认证服务,
user_id保存外部账号主体标识;如果完全自建认证,应独立设计账号和凭证表,不能把密码或长期账号凭证放入设备表。设备 UUID 只是设备标识,不是登录凭证。仅知道某个设备 UUID,不能获得对应账号权限。
6. 账号设备绑定
6.1
account_devices表新增最小化设备绑定表:
字段说明:
id:服务端生成的account_device_id。user_id:认证后的账号 ID。device_uuid:当前客户端安装实例的稳定 UUID。created_at:账号首次绑定该设备的时间。revoked_at:账号撤销该设备的时间;为空表示仍然有效。本期不保存设备名称、平台、App 版本和最后在线时间。如以后确有展示或管理需求,再增加相关字段。
6.2 客户端不判断“首次登录”
客户端不需要单独维护
is_first_login。每次 App 启动统一执行:
device_uuid。account_device_id。account_device_id。设备绑定接口:
响应:
{ "account_device_id": "服务端生成或已经存在的 UUID" }客户端可以在每次启动或登录后重复调用该接口,不会产生重复绑定。
7. WebSocket ticket
7.1 申请 ticket
设备绑定完成后,客户端自动申请 ticket:
服务端验证:
user_id。account_device_id是否属于该账号。验证通过后返回:
{ "ticket": "安全随机字符串", "expires_in": 30 }客户端立即连接:
以上步骤全部由 App 自动完成,用户仍然只会感知到“打开 App 后自动连接”。
7.2 TTL
TTL 是 Time To Live,即数据的有效存活时间。
本期 ticket 的 TTL 暂定为 30 秒。ticket 发出 30 秒后,无论是否使用都失效;成功使用一次后立即删除,不能再次连接。
7.3 内存 ticket 存储
当前项目以单进程示例运行,因此本期使用内存保存 ticket:
服务端只保存 ticket 哈希,不保存原始 ticket。
WebSocket 验证成功时,使用类似以下语义的原子消费操作:
如果找不到、已经过期或已经被使用,则拒绝连接。
过期记录可以在读取时顺便删除,也可以通过简单的后台定时任务清理。
7.4 什么时候迁移到 Redis
是否需要 Redis 不单纯取决于用户数量,而取决于是否出现多个后端进程或实例。
出现以下情况时需要将 ticket 存储迁移到 Redis:
本期定义统一的
WebSocketTicketStore接口,并实现InMemoryWebSocketTicketStore。以后只需替换为RedisWebSocketTicketStore,上层认证流程不变。后端重启会导致尚未使用的内存 ticket 失效。客户端连接失败后重新申请 ticket 即可,这对于 30 秒临时凭证是可以接受的。
8. WebSocket 连接上下文
ticket 验证成功后,服务端建立可信连接上下文:
后续业务处理器从
ConnectionContext取得身份,不接受客户端在业务消息中自行指定user_id或account_device_id。连接管理器本期保存:
同一账号设备重连时:
connection_id。认证成功响应:
{ "type": "session.ready", "account_device_id": "...", "connection_id": "...", "server_time": "..." }本期不建立账号下全部设备的连接索引,也不进行账号级广播。
9. 单表对话记录
9.1 设计原则
本期不分别建立
conversations和conversation_messages,而是合并成一张conversation_records表。不把全部消息保存进一行 JSON 数组,而是每条消息或状态变化各占一行。
conversation_id相同的记录共同组成一段对话。9.2
conversation_records表建议索引:
9.3 记录类型
本期使用以下
record_type:普通用户消息:
{ "record_type": "user.message", "role": "user", "content": "把周五的会议改到四点", "payload": {} }工具结果:
{ "record_type": "tool.result", "role": "tool", "content": null, "payload": { "tool_name": "schedule.search", "result": {} } }9.4 对话创建与生命周期
App 进程启动或用户主动新建对话时,由服务端生成新的
conversation_id并返回给客户端。此时不立即写数据库;用户发送第一条消息后,第一条user.message就代表这段对话正式开始。如果用户尚未发送任何消息就结束 App,该空对话不会留下数据库记录。
conversation_id只保存在当前 App 进程内,不写入客户端长期持久化存储。生命周期规则:
conversation_id。conversation_id。同一 App 进程内重连并继续当前对话时,服务端必须校验:
本期不同设备之间不共享对话。即使登录同一个账号,不同
account_device_id也不能读取彼此的对话记录。客户端只在进程内存中保存当前
conversation_id。进程结束后该值自然丢失,下次启动直接创建新对话,不查询或恢复上一次对话。以后需要跨设备共享时,可以保留
user_id归属并调整设备访问规则,无需改变每条记录的基本结构。10. 最近 20 条上下文
数据库保存完整对话记录,模型调用时只加载最近 20 条上下文消息。
计入 20 条的记录类型:
地点候选、日程候选、焦点和待确认操作作为结构化状态单独加载,不占普通消息条数。
每次模型调用包含:
历史消息必须按照实际角色传入模型:
不能把全部历史拼接成一条用户消息。
除 20 条数量限制外,再设置一个可配置的 token 上限。消息过长时从最早记录开始裁剪,但必须保留当前用户消息、当前待确认操作、当前焦点以及必要的最近工具结果。
本期不做长期摘要和向量检索。
11. 消息顺序与幂等
客户端每次发送一条新的用户消息时生成
client_message_id。网络重试时继续使用原来的client_message_id,不能重新生成。唯一约束:
用于保证网络重试或重连后重复发送相同消息时,不会重复执行模型调用和日程操作。
request_id标识某一次网络请求,重试时可以变化;client_message_id标识用户逻辑上的同一条消息,重试时必须保持不变。conversation_records.id使用数据库自增的BIGSERIAL。读取同一对话的历史记录时按id排序,因此不再维护每个对话自己的sequence,也不需要序号分配锁。同一对话一次只处理一条用户消息:
CONVERSATION_BUSY。这样可以防止上一条查询还没有生成候选列表时,下一条“删除第二个”被提前处理。
12. 语音处理流程
改造后的语音流程:
voice.stream.start是客户端在开始上传语音前发送的一条 WebSocket 控制消息,不是数据库表,也不是直接发给模型的提示词。它用于告诉服务端“哪段对话现在要开始一条新的语音消息”。字段含义:
request_idconversation_idclient_message_idaudio_formatsample_rate_hzchannelscurrent_location消息示例:
{ "type": "voice.stream.start", "request_id": "...", "conversation_id": "...", "client_message_id": "...", "payload": { "audio_format": "pcm_s16le", "sample_rate_hz": 16000, "channels": 1, "current_location": { "latitude": 22.543, "longitude": 114.057 } } }current_location可为空。只有语音中未明确城市且需要搜索地点时才使用。活跃语音流按
connection_id管理,避免旧连接的语音流影响重连后的新连接。ASR 原始音频默认不持久化,只保存最终识别文本。
13. 日程工具能力
13.1 查询工具
查询可以直接执行,不需要用户确认。
所有日程工具都从
ConnectionContext取得user_id,所有仓储查询必须同时匹配schedule_id和user_id。查询结果超过一条时,追加
schedule.candidates记录,支持用户通过“第一个”“第二个”“项目会那个”等后续表达选择目标。唯一目标确定后追加
schedule.focus.changed。13.2 创建日程
根据用户输入生成日程草稿。能力名称统一叫“创建日程”,不使用“提议”作为工具名称。
为了防止 ASR 识别错误,第一次调用只生成待确认草稿;用户确认后才真正写入
schedules。13.3 修改日程
先确定唯一日程目标,再生成修改后的字段差异。能力名称叫“修改日程”,但实际数据库更新仍然要等用户确认后执行。
如果当前正在确认一个尚未创建的日程草稿,用户说“改到四点”时,应修改当前草稿,而不是查询数据库中的已有日程。
13.4 删除日程
先确定唯一日程目标,再生成删除确认内容。能力名称叫“删除日程”,但实际删除仍然要等用户确认后执行。
14. 写操作确认
考虑到 ASR 可能识别错误,本期所有写操作统一确认:
待确认操作写入
pending_action.set:{ "record_type": "pending_action.set", "operation_id": "...", "payload": { "type": "update", "schedule_id": "schedule_123", "patch": { "start_time": "2026-08-07T16:00" }, "base_updated_at": "...", "expires_at": "..." } }一个对话同一时间只允许存在一个未清除的待确认操作。
用户可以通过按钮或语音回复“确认”“取消”。
确认时重新验证:
operation_id没有完成过。执行成功后依次追加:
执行失败时追加
operation.failed,并根据失败类型决定是否保留待确认状态。待确认操作保存在数据库中,因此后端重启后不会丢失。
15. 日程指代消解
日程指代优先依赖最近的结构化记录,不完全交给模型猜测。
解析顺序:
schedule.focus.changed。schedule.candidates。示例:
第二句优先修改当前待确认草稿。
“第二个”从最近的
schedule.candidates中解析。最终执行前必须重新使用
schedule_id + user_id查询数据库,不能直接信任模型生成的日程 ID。16. 语音地点解析
16.1 提取内容
从 ASR 文本中提取:
本期不设计置信度评分。
16.2 语音中包含城市
例如:
处理流程:
16.3 语音中没有城市
例如:
处理流程:
候选展示示例:
16.4 多轮地点选择
多个地点候选写入
location.candidates:{ "record_type": "location.candidates", "payload": { "location_text": "万象城", "candidates": [ { "index": 1, "name": "万象城", "address": "福田区幸福路 88 号", "latitude": 22.543, "longitude": 114.057, "distance_meters": 2300 } ] } }用户可以回复:
解析时读取最近一条未完成的
location.candidates,不重新搜索。确定后追加
location.selected,并把地点写入当前日程草稿:{ "location_name": "万象城", "location_address": "福田区幸福路 88 号", "latitude": 22.543, "longitude": 114.057 }候选地点确定之前,不允许确认最终日程草稿。
16.5 地点解析失败
如果地图服务请求失败或没有搜索结果:
location_address、latitude和longitude为空。最终规则:
17.
schedules.user_id的账号隔离方式本期不重命名
schedules.user_id。这个字段表示“这条日程属于哪个账号”。需要修正的是它当前始终写入default_user的使用方式。当前固定
default_user的方式需要删除。修改后:ScheduleService每次处理请求时都接收当前已认证账号的user_id,不再在服务对象中固定保存default_user。user_id来自 WebSocket ticket 验证后生成的ConnectionContext。user_id,否则客户端可以伪装成其他账号。user_id写入schedules.user_id。user_id的日程。schedule_id和当前user_id查找。例如,账号 A 要修改
schedule_123时,数据库实际查询条件相当于:如果
schedule_123实际属于账号 B,账号 A 会得到“日程不存在”,不能读取或修改它。即使别人知道了某条日程的 ID,也不能跨账号操作。所谓“每次调用显式接收
user_id”,就是将调用方式从类似:调整为:
这里的
connection_context.user_id由服务端认证得到,不由客户端填写。建议业务接口形态:
这样可以完成账号数据隔离,同时避免本期进行字段重命名迁移。
18. 前端改造
前端需要完成:
Math.random()的设备 ID。device_uuid。conversation_id,不做长期持久化。19. 后端模块建议
ticket 存储接口:
本期实现
InMemoryWebSocketTicketStore,未来多进程时替换为RedisWebSocketTicketStore。地图服务调用放在后端统一处理,客户端只提供当前经纬度并展示候选结果。
20. 实施顺序
第一阶段:账号设备与安全连接
account_devices表。ConnectionContext。第二阶段:账号日程隔离
default_user。user_id。device_id与user_id混用。第三阶段:单表对话持久化
conversation_records表。client_message_id幂等。conversation_id读取当前对话记录。第四阶段:多轮上下文
user.message。第五阶段:语音日程增删改查
第六阶段:指代与地点选择
第七阶段:验证与稳定性
21. 验收标准
完成后应满足:
device_uuid无法冒充账号设备。client_message_id不会重复处理。22. 后续扩展预留
本期完成后,未来可以在不推翻核心设计的情况下增加:
这些能力不影响本期交付,也不应提前进入当前实现范围。