1:1 对话流:用户一条,AI 一条,绝不连发
写于 2026-11-08。聊天产品最容易破坏的体验,不是回答错,而是「连发」:用户问一句,AI 回三条。这篇讲「如期朝夕」怎么把 1:1 做成硬约束——前端一个占位气泡、后端一次保存,重试、超时、失败都不破坏节奏。
一、现象:一条提问下面,跟着两条 AI 回复
1:1 对话流的意思很朴素:用户发一条,AI 回一条;AI 回完之前,用户不能再发;AI 回完之后,也不会补发第二条。
真机上,这条规则很容易被打破。最常见的几种画面:
- 用户问「明天天气怎么样」,先出现一个「正在思考…」的气泡,几秒后下面又出现一个正式回答。用户看到的是两条 AI 消息。
- 用户点了一个澄清选项,AI 先回「收到」,再回「好的,那您更看重哪一点?」。两条。
- 用户创建提醒成功,正文回答下面又跟一条「✅ 已为您创建提醒」。两条。
- 网络抖了一下,用户点重试,旧回答还在,新回答又冒出来。两条。
- 用户手快,回车和发送按钮各触发了一次,同一句话发了两遍,AI 回了两遍。四条。
单看每一条都不算大 bug,但叠在一起,聊天记录就变成「用户一句、AI 三句」的刷屏。用户会开始怀疑:到底哪条才算数?我是不是要等它说完再说话?
1:1 不是排版偏好,而是消息所有权问题。一条用户消息产生一条 AI 消息,这条 AI 消息从占位、流式、失败到重试,始终是同一个对象。所有权不清楚,连发就一定会出现。
二、连发是怎么产生的:五条常见路径
把线上和测试里见过的连发逐个拆开,来源无非五类。
| 来源 | 具体机制 | 用户看到的结果 |
|---|---|---|
| 加载态独立成消息 | 「正在思考」被 push 成一条消息,正式回答又 push 一条 | 两条 AI |
| 流程确认语独立成消息 | 先回「收到」,再回真正的问题 | 两条 AI |
| 异步回执独立成消息 | 提醒创建成功后,额外发一条系统通知 | 两条 AI |
| 重试没清理旧消息 | 旧气泡还在,新请求又 push 一个占位 | 两条 AI |
| 重复触发 | 回车 + 点击、按钮连点、两处都调了发送 | 同一问题两条 AI |
这五类的共同点是:它们在某个时刻都「新建了一条消息」,而不是「更新原来那条」。
最典型的是加载态。最早我觉得「正在思考」是一个独立状态,就把它当成一条消息渲染。后来发现,用户根本不需要知道「思考」和「回答」是两条消息,他只需要看到同一个气泡从「思考中」变成「正文」。一旦拆成两条,1:1 就破了。
流程确认语也是一样。「收到」不是一条回答,它只是流程的一个过渡态。把它发成独立消息,等于让 AI 在一轮里说了两句话。
三、原则:一条用户消息,最多一条 AI 消息
定下 1:1 之后,代码结构反而简单了。核心只有一句话:请求开始时创建一个 AI 占位消息,之后所有状态都改这个对象,不再 push 新消息。
useChatStreamer.js 里的写法大致是这样:
// 请求开始:只创建一个 AI 占位气泡
const placeholderMsg = {
role: 'ai',
content: PLACEHOLDER,
time: now(),
options: null,
stepCurrent: null,
stepTotal: null,
thinkContent: "",
thinkPending: payload.deepThink === true,
status: 'streaming',
reqPayload: { ...payload },
feedback: ""
}
messages.value.push(placeholderMsg)
msgIdx = messages.value.length - 1
后面的所有事件都只做一件事:改 messages.value[msgIdx]。
- 收到思考 token:追加到
cur.thinkContent; - 收到正文 token:追加到
cur.content; - 收到选项:写到
cur.options; - 收到进度:写到
cur.stepCurrent/cur.stepTotal; - 收到错误:覆盖
cur.content; - 收到结束:把
cur.status从streaming改成done。
整个生命周期里,messages.length 只增加一次。这就是 1:1 的结构保证。
这里要区分两个概念:消息条数和内容块。一条 AI 消息里可以有思考折叠块、正文、选项按钮、进度徽章、表单卡片,这些都不算「第二条消息」。它们挂在同一条消息的结构化字段上,渲染时组合在同一个气泡里。用户看到的是一条,不是五条。
四、前端队列:不是消息队列,是「单飞」门闩加串行落库
很多文章讲 1:1 会说「前端加队列」,但实际做下来,聊天发送不需要真正的消息队列。用户不能在 AI 回复期间发第二条,所以不存在「排队等待发送」的场景。真正需要的是两道门。
第一道门:单飞(single-flight)门闩。
流式请求进行中,isStreaming 为 true:
- 输入框的发送按钮切成「停止」;
canSend为 false,回车不触发发送;- 重试入口第一行就是
if (isStreaming.value) return; - 选项点击后如果还在流式,直接忽略。
这不是「把第二条消息排进队列」,而是「根本不允许产生第二条消息」。1:1 的前提是同一时刻只有一个在途回合。
第二道门:串行落库队列。
消息可以只保留一条,但历史保存不能并发。useChatHistory.js 里维护了一个 saveQueue,每次保存都 push 进队列,flushHistorySaves 串行 await 消费:
const saveQueue = []
async function flushHistorySaves() {
if (flushing) return
flushing = true
try {
while (saveQueue.length) {
await postHistoryTurn(saveQueue.shift())
}
} finally {
flushing = false
if (saveQueue.length) flushHistorySaves()
}
}
为什么要串行?因为同一轮可能保存两次:思考结束(首个正文 token)时先保存用户问题,用于后端立即生成标题;流式结束时再保存完整回答。如果两次并发,后端可能先收到「回答」再收到「用户问题」,消息顺序就反了。串行队列保证「先用户、后 AI」的写入顺序。
这两道门的分工是:单飞门闩保证运行时只有一条 AI 消息,串行队列保证落库时只有一份正确顺序。前者管 UI,后者管数据,缺一不可。
| 场景 | 没有门闩时 | 有门闩后 |
|---|---|---|
| 流式中按回车 | 再发一条,两个 AI 气泡并行 | 回车无效,按钮是「停止」 |
| 流式中点重试 | 旧请求未断,新请求又起 | 直接 return,提示稍后 |
| 思考结束先落库 | 与最终保存并发,顺序可能反 | 进入串行队列,按序写 |
| 快速连点发送 | 两次请求,两条回答 | 第一次点击后按钮即切停止 |
五、后端幂等:没有唯一键,靠协议约定
前端能保证「不主动发第二条」,但网络重试、旧版本客户端、直接调接口都可能绕过。后端还得有一层幂等。
先看保存接口的输入。HistorySaveDTO 里没有 messageId,也没有 clientMsgId,只有:
| 字段 | 作用 |
|---|---|
conversationId |
会话 ID,定位写到哪个会话 |
userText |
用户这轮说的话;为空表示「已经保存过」 |
aiReply |
AI 这轮的回答 |
status |
running / done |
uiMeta |
选项、表单、思考等结构化信息 |
幂等规则就藏在 userText 的约定里:
String userText = dto.getUserText() == null ? "" : dto.getUserText().trim();
// 只有 userText 非空才插入用户消息
if (!userText.isEmpty()) {
aiConversationMapper.insert(userId, sessionId, "user", userText, ...);
}
// AI 回答非空则插入
if (!aiReply.isEmpty()) {
aiConversationMapper.insert(userId, sessionId, "assistant", aiReply, ...);
}
配合前端的两次保存:
- 思考结束:
saveHistoryTurn(用户原文, "", false, 'running'),先写用户消息; - 流式结束:
saveHistoryTurn("", 完整回答, ...),用户原文传空字符串。
第二次保存时 userText 为空,后端跳过用户插入,只写 AI 回答。这样一轮对话最终只有一条 user、一条 assistant。
这不是数据库层面的强幂等,没有唯一索引,也没有 ON CONFLICT。它是一条协议约定:同一轮只允许一次带 userText 的保存,后续保存必须传空。前端用 earlyUserSaved 标志保证「提前保存只做一次」,后端用「空则跳过」兜底。
这个方案有明确的边界:如果调用方不守约定,重复传 userText,后端还是会插入两条用户消息。它适合当前的单端、单用户场景,代价是将来做多端同步时,需要补一个真正的幂等键(比如客户端生成的 turnId)。这一点必须诚实写出来,不能把「约定」说成「数据库保证」。
六、异常态:失败、超时、停止怎么保持 1:1
正常路径容易,异常路径才见真章。1:1 的难点不是「成功时一条」,而是「失败时也只有一条」。
失败。 流式过程里收到 error 事件,不新建气泡,直接覆盖当前占位气泡的正文:
messages.value[msgIdx].content = '❌ 出错了:' + (chunk.message || '未知错误')
如果是请求层异常(断网、超时、HTTP 错误),同样只改 msgIdx 指向的那条:
messages.value[msgIdx].content = '❌ 请求失败:' + msg
用户看到的是一条带错误提示的 AI 消息,不是「错误提示 + 原回答」两条。
超时。 请求正常结束但一个正文 token 都没收到,且没有终态事件,就在原气泡里写「(服务端无响应)」。这仍然是同一条消息,只是内容从占位符换成了提示。
主动停止。 用户点「停止」,保留已经生成的部分,在末尾追加「(已停止生成)」;如果一个字都没生成,就把整条内容设为「(已停止生成)」。状态记为 done,同时带一个停止标记,历史里也能看出这轮是用户中断的。
重试。 这是最容易制造连发的地方。正确做法是先删旧、再请求:
// 从这条 AI 消息开始,把它和它后面的消息一起删掉
messages.value.splice(idx)
scrollToBottom(true)
await runStreamRequest(payload)
splice(idx) 删掉的是 AI 气泡及其之后的所有消息,然后重新发起同一个请求。新请求会创建一个新的占位气泡,最终仍然是一条。重试入口还有 if (isStreaming.value) return,避免在流式中重试。
澄清选项。 用户点选项也走同一套流式请求,不额外发「收到」。选项的选中态、下一题的题干、进度,全部落在同一条 AI 消息的结构化字段里。一轮仍然是一条。
| 异常 | 旧实现 | 现在 |
|---|---|---|
| 流式错误 | 新建一条错误消息 | 覆盖当前气泡 |
| 请求失败 | 新建一条失败提示 | 覆盖当前气泡 |
| 无响应 | 再发一条「服务端无响应」 | 覆盖当前气泡 |
| 用户停止 | 保留占位 + 新增停止提示 | 原气泡追加「(已停止生成)」 |
| 重试 | 旧气泡不动,新请求再 push | 先 splice 旧气泡,再重发 |
七、无效尝试与回滚
这一节记录五个真实走过弯路的做法。
| 尝试 | 结果 | 回滚后的做法 |
|---|---|---|
| 把「正在思考」做成独立消息 | 用户看到「思考气泡 + 回答气泡」两条 | 思考作为同一气泡的折叠块 |
| 澄清前先发一条「收到」 | 一轮出现两条 AI,用户以为要多等一轮 | 确认语并入同一条回答的开头 |
| 提醒创建成功后单独发系统消息 | 正文一条、回执一条 | 回执写进同一条消息的 uiMeta |
| 重试时直接再发请求,不删旧气泡 | 旧回答和新回答并排,用户不知道哪条有效 | splice(idx) 后重发 |
| 用时间戳合并相邻 AI 消息 | 流式分片时间太近,误合并;不同轮又合并不了 | 放弃合并,改成占位气泡所有权 |
「时间戳合并」那次最典型。当时想在前端渲染层做兜底:如果两条 AI 消息时间差小于某个值,就合并成一条。实际跑下来,流式 token 的时间差可能只有几十毫秒,正常的两轮也可能因为用户点得快而挨得很近,阈值根本没法定。更重要的是,合并只是把两条变成了看起来像一条,消息数组里还是两条,历史保存、重试定位、错误归属全都会错。
后来放弃了渲染层合并,回到源头:一条用户消息只创建一个 AI 占位对象,所有后续状态都改它。这比任何后处理都可靠。
八、验收:怎么证明没有连发
1:1 是可以用断言验证的,不需要靠肉眼数气泡。
第一层,消息数组断言。每条用户消息之后,AI 消息数量必须恰好为 1;流式开始前是占位,流式结束后是同一条变成正文,长度不变。
第二层,DOM 断言。渲染完成后,.msg-ai 的数量必须等于用户消息数量;流式过程中也只能多一个占位,不能多两个。
第三层,历史断言。调用历史接口,一轮对话必须是一条 user、一条 assistant;标题只生成一次;userText 为空的第二次保存不能新增 user 行。
第四层,异常矩阵。正常、报错、超时、停止、重试、选项、澄清、提醒创建、深度思考、断网,十个场景逐个跑,每个场景都断言「AI 消息数 = 1」。
项目里跑过 415 条自动化用例,核心断言就是「AI 回复数量 = 1」。这个数字不是为了好看,而是因为连发这个问题太容易在改动中回归:只要有人在新分支里多 push 一条消息,断言就会红。
| 验收项 | 方法 | 通过标准 |
|---|---|---|
| 正常一轮 | 发一句话,等回答结束 | 消息数组新增 1 条 AI |
| 流式过程 | 观察占位到正文 | 始终同一个 msgIdx |
| 失败 | 模拟 500 / 断网 | 原气泡显示错误,不新增 |
| 超时 | 模拟无终态 | 原气泡显示「服务端无响应」 |
| 停止 | 生成中点停止 | 原气泡追加「(已停止生成)」 |
| 重试 | 点重试 | 旧气泡被删,新气泡只有一条 |
| 历史 | 查会话记录 | 1 条 user + 1 条 assistant |
这套验收的关键是:不要只测成功路径。连发几乎都发生在异常分支,正常路径反而是最不容易出问题的。把失败、超时、停止、重试跑一遍,才算真的验证了 1:1。
写在最后
- 1:1 的本质是消息所有权。一条用户消息只创建一个 AI 占位对象,后续所有状态都改它,不 push 新消息;结论是:结构对了,连发自然消失。
- 加载态、确认语、异步回执都不是消息。它们是同一条 AI 消息里的状态、开头或结构化字段;一旦独立成消息,1:1 立刻破。
- 前端要的是单飞门闩,不是消息队列。流式中不允许发第二条,比排队发送更简单,也更符合聊天的心智。
- 后端幂等靠协议,不靠唯一键。
userText为空表示已保存,这是当前单端场景的约定;多端同步时必须补真正的turnId。 - 异常态才是验收重点。失败、超时、停止、重试都能保持一条,1:1 才算成立;只测成功路径,等于没测。

浙公网安备 33010602011771号