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, ...);
}

配合前端的两次保存:

  1. 思考结束:saveHistoryTurn(用户原文, "", false, 'running'),先写用户消息;
  2. 流式结束: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 才算成立;只测成功路径,等于没测。
posted @ 2026-11-14 19:58  芋泥暖香柑  阅读(3)  评论(0)    收藏  举报