Skip to content

边说边听:模型输出途中插入新提问,如何做到无缝衔接?

更新: 9/19/2026 字数: 0 字 时长: 0 分钟

开篇:那个"打断 AI 说话"的瞬间

你一定体验过这个场景:让 ChatGPT 写一段长代码,它正一行行往外吐字,你突然发现方向不对,于是不等它写完就打字:"等等,改用 TypeScript"。理想情况下,AI 会优雅地停下当前输出,接住你的新要求,并且记得刚才聊到哪儿了,继续无缝往下答。

但如果实现得糙,你会遇到三种糟糕体验:

  • 生硬打断:旧回答被拦腰截断,新回答里前言不搭后语,上下文全丢了;
  • 状态错乱:两条回答的流(stream)混在一起,界面上文字乱蹦;
  • 响应卡顿:发了新消息半天没反应,因为后端还在傻等旧请求跑完。

这背后其实是实时对话系统里一个相当有工程含量的问题:如何在"流式输出进行中"这个特殊时刻,安全地中断、保存现场、并让新一轮生成能接住上文。 这篇文章,我们就从前端交互、后端服务、模型接口三层,把它彻底拆开讲清楚。

一、先建立全局:实时对话系统的三层架构

实时对话系统三层架构

要理解"无缝衔接",先得知道一次对话流经哪些环节。一个典型的流式对话系统分三层:

三层各司其职:

  • 前端交互层:管理 UI 状态(谁在输出、能不能打断)、通过 WebSocket 收发消息、渲染增量文字;
  • 后端服务层:维护会话状态、控制中断、拼装上下文——这是"无缝衔接"的核心战场;
  • 模型接口层:提供可被中途取消的流式生成能力。

为什么强调 WebSocket 而不是普通 HTTP? 因为"输出途中插入新提问"要求全双工:服务端正在往下推 token 的同时,客户端还得能往上发新消息。传统"请求-响应"式的 HTTP 做不到这种双向并发,而 WebSocket(或 SSE + 独立上行通道)天生适合。

二、核心原理:中断、保存、衔接三步曲

"无缝衔接"看似一个动作,拆开其实是三个精确的技术动作。

边输出边接新问题的数据流

2.1 中断检测:如何知道"要打断了"

关键前提:流式生成必须是"可取消"的。现代做法是用浏览器和 Node 都原生支持的 AbortController——它就是为"取消进行中的异步任务"而生的标准 API。

当后端收到一条新用户消息,而当前会话状态是"正在生成"时,就触发中断:调用 abortController.abort(),正在跑的 LLM 流式请求会立即收到取消信号并停止。

2.2 现场保存:别把"半成品"扔掉

这是"无缝"与"生硬"的分水岭。糙的实现会直接丢弃被打断的半截回答;好的实现会把已经生成的部分文本完整保存下来,作为一条"未完成的助手消息"存进对话历史。

为什么这很重要?因为用户可能只是想补充,而不是否定。比如 AI 正在讲"防抖函数的三个要点",用户插一句"顺便给个 React 例子"——旧的半截内容依然有效,新回答要在它基础上继续,而不是从零重来。

2.3 上下文衔接:把"半成品 + 新问题"拼成完整输入

保存好现场后,发起新一轮生成时,要把三样东西按正确顺序拼进上下文:历史对话 + 那段被中断的半成品回答 + 用户的新问题。模型拿到这个完整序列,就能理解"我刚才说到一半,用户又追加了要求",从而接着往下答。

这三步的状态流转,可以用一个状态机精确描述。

三、状态机:整个流程的"总控图"

对话状态机:中断与恢复

用状态机来建模,能避免各种竞态和混乱。前端和后端都应该维护这样一套状态:

为什么必须用状态机? 因为"输出途中"是个危险的中间态。如果不显式建模,很容易出现:两个生成流并发写同一个界面、中断信号发出后旧 token 还在乱入、或者用户连点多次把状态搞乱。状态机保证了任一时刻系统只处于一个确定状态,状态转移有明确入口,这对处理并发中断至关重要。

四、实现方案与代码:三层落地

4.1 前端状态管理:管好"谁在输出"

前端的核心职责是:维护对话状态、支持随时发送新消息打断、正确渲染两段可能衔接的输出。用一个轻量 Store 实现(思路同 Zustand/Redux):

typescript
type ChatStatus = 'idle' | 'streaming' | 'interrupting';

interface Message {
  id: string;
  role: 'user' | 'assistant';
  content: string;
  interrupted?: boolean; // 标记这条是否为被中断的半成品
}

class ChatStore {
  status: ChatStatus = 'idle';
  messages: Message[] = [];
  private currentAssistantId: string | null = null;
  private ws: WebSocket;

  constructor(ws: WebSocket) {
    this.ws = ws;
    // 接收后端增量推送
    this.ws.onmessage = (e) => this.onServerEvent(JSON.parse(e.data));
  }

  // 用户发送消息(可能发生在输出途中)
  sendMessage(text: string) {
    // 关键:如果正在输出,先发中断信号,再发新消息
    if (this.status === 'streaming') {
      this.status = 'interrupting';
      this.ws.send(JSON.stringify({ type: 'interrupt' }));
    }
    const userMsg: Message = { id: crypto.randomUUID(), role: 'user', content: text };
    this.messages.push(userMsg);
    this.ws.send(JSON.stringify({ type: 'user_message', payload: text }));
  }

  // 处理后端事件
  private onServerEvent(evt: any) {
    switch (evt.type) {
      case 'stream_start':
        this.status = 'streaming';
        this.currentAssistantId = evt.messageId;
        this.messages.push({ id: evt.messageId, role: 'assistant', content: '' });
        break;
      case 'token': // 增量 token,追加渲染
        this.appendToken(evt.messageId, evt.text);
        break;
      case 'interrupted': // 后端确认已中断,标记半成品
        this.markInterrupted(evt.messageId);
        break;
      case 'stream_end':
        this.status = 'idle';
        this.currentAssistantId = null;
        break;
    }
  }

  private appendToken(id: string, text: string) {
    const msg = this.messages.find((m) => m.id === id);
    if (msg) msg.content += text; // 增量拼接
  }

  private markInterrupted(id: string) {
    const msg = this.messages.find((m) => m.id === id);
    if (msg) msg.interrupted = true;
  }
}

要点:sendMessage 里的判断是灵魂——发现处于 streaming 就先发 interrupt 再发新消息,两个信号分开发,让后端有机会先干净地停掉旧流。前端用 messageId 区分不同的输出流,避免 token 串台。

4.2 后端中断处理:安全地喊停

后端会话 Manager 负责用 AbortController 控制 LLM 请求的生命周期:

typescript
class SessionManager {
  private abortController: AbortController | null = null;
  private currentDraft = ''; // 当前正在生成的半成品

  constructor(private ws: WebSocket, private history: Message[]) {}

  async handleUserMessage(text: string) {
    this.history.push({ role: 'user', content: text });
    await this.startGeneration();
  }

  // 收到中断信号
  handleInterrupt() {
    if (this.abortController) {
      this.abortController.abort(); // 立即取消进行中的 LLM 流
    }
  }

  private async startGeneration() {
    this.abortController = new AbortController();
    this.currentDraft = '';
    const msgId = crypto.randomUUID();
    this.send({ type: 'stream_start', messageId: msgId });

    try {
      const stream = await llm.chatStream(
        { messages: this.buildContext() },
        { signal: this.abortController.signal } // 传入取消信号
      );
      for await (const chunk of stream) {
        this.currentDraft += chunk;
        this.send({ type: 'token', messageId: msgId, text: chunk });
      }
      // 正常结束
      this.history.push({ role: 'assistant', content: this.currentDraft });
      this.send({ type: 'stream_end', messageId: msgId });
    } catch (err: any) {
      if (err.name === 'AbortError') {
        // 被中断:保存半成品到历史,打上标记
        this.history.push({
          role: 'assistant',
          content: this.currentDraft,
          interrupted: true,
        });
        this.send({ type: 'interrupted', messageId: msgId });
      } else {
        this.send({ type: 'error', messageId: msgId, error: String(err) });
      }
    }
  }

  private buildContext(): Message[] {
    return this.history; // 下一节详解拼接逻辑
  }

  private send(obj: any) { this.ws.send(JSON.stringify(obj)); }
}

核心机制:AbortController.signal 传给 LLM 流式接口,abort() 会让 for await 循环抛出 AbortError。我们在 catch捕获这个特定错误,把 currentDraft(已生成的半成品)存进历史并标记 interrupted——这就是"保存现场"的落地。注意区分 AbortError(正常中断)和真正的错误,两者处理策略完全不同。

4.3 上下文衔接算法:拼出连贯的输入

上下文拼接:把半成品接上

这是让模型"接得住"的关键。拼接时要处理好被中断的半成品:

typescript
interface Message {
  role: 'user' | 'assistant' | 'system';
  content: string;
  interrupted?: boolean;
}

function buildContext(history: Message[], maxTokens = 8000): Message[] {
  const result: Message[] = [];

  for (const msg of history) {
    if (msg.role === 'assistant' && msg.interrupted) {
      // 关键处理:给被中断的半成品加上提示,让模型知道"这段没说完"
      result.push({
        role: 'assistant',
        content: msg.content,
      });
      // 注入一条系统提示,明确衔接语义
      result.push({
        role: 'system',
        content: '上一条回答因用户插入新问题而中断。请结合新问题,自然地继续或调整回答,不要重复已说内容。',
      });
    } else {
      result.push(msg);
    }
  }

  // 上下文窗口管理:超长则裁剪(保留 system + 最近若干轮)
  return truncateToWindow(result, maxTokens);
}

// 滑动窗口裁剪:优先保留系统提示和最近对话
function truncateToWindow(messages: Message[], maxTokens: number): Message[] {
  const systemMsgs = messages.filter((m) => m.role === 'system');
  const others = messages.filter((m) => m.role !== 'system');

  let tokenCount = estimateTokens(systemMsgs);
  const kept: Message[] = [];
  // 从最近往前保留(倒序遍历)
  for (let i = others.length - 1; i >= 0; i--) {
    const t = estimateTokens([others[i]]);
    if (tokenCount + t > maxTokens) break;
    tokenCount += t;
    kept.unshift(others[i]);
  }
  return [...systemMsgs, ...kept];
}

function estimateTokens(msgs: Message[]): number {
  // 粗估:中文约 1 字 1.5 token,英文约 4 字符 1 token
  return msgs.reduce((sum, m) => sum + Math.ceil(m.content.length * 1.2), 0);
}

算法精髓:遇到 interrupted 的半成品,不仅把它原样放进上下文,还追加一条系统提示告诉模型"这段被打断了,请自然衔接、别重复"。这条元信息是模型能"无缝续上"的语义钥匙。同时 truncateToWindow 做上下文窗口管理,保证长对话不会撑爆 token 限制。

五、拓展:更硬核的挑战与趋势

5.1 长对话的上下文窗口管理

对话越长,历史越占 token。除了上面的滑动窗口裁剪,生产级方案还会引入:

  • 滚动摘要:把较早的对话压缩成摘要,替换原文,省 token 又保信息;
  • 分层记忆:system 约束常驻头部,关键事实结构化提取,最近几轮保留原文;
  • 重要性加权:被中断的半成品、用户明确强调的内容,优先保留不裁剪。

5.2 多轮中断的状态追踪

用户可能连续打断好几次。这要求状态机能处理中断嵌套:每次中断都生成独立的 messageIdAbortController,后端要保证旧的取消信号不会误伤新的生成流。用一个"代际号(generation id)"给每轮生成打标,是常见的防串台手段——只有当前代际的 token 才被接受。

5.3 性能优化

  • 打断响应要快:中断信号走独立的轻量通道,别排在数据流后面;
  • token 批量推送:高频 token 可做微批(如每 30ms 合并一次)减少渲染压力;
  • 背压处理:客户端渲染跟不上时,用队列缓冲,避免 UI 卡死;
  • 乐观 UI:用户一发新消息,前端立刻停旧输出的视觉更新,不等后端确认,体感更跟手。

5.4 前沿趋势

实时语义打断是下一步方向:不只是"用户发消息才打断",而是系统实时理解用户输入的语义,判断这是"补充"(接着答)还是"纠偏"(推倒重来),甚至在语音场景下做到"边听边判断是否该停"。这类能力正随着流式语义理解和更低延迟的推理架构逐步落地。

六、实现效果:生硬打断 vs 无缝衔接

生硬打断 vs 无缝衔接

同样是"输出途中被打断",两种实现的差距一目了然:

维度生硬打断(糙实现)无缝衔接(好实现)
旧回答处理直接丢弃保存为半成品,标记 interrupted
上下文丢失,新回答前言不搭后语完整拼接,模型知道"刚说到哪"
中断速度等旧请求跑完才响应AbortController 立即取消
界面表现两段流混乱、文字乱蹦messageId 区分,渲染清晰
多次打断状态错乱代际号追踪,互不干扰

七、测试 Checklist 与排查指南

上线前测试清单

  • [ ] 正常流程:一问一答,流式输出完整、stream_end 正确触发;
  • [ ] 输出途中打断:生成中发新消息,旧输出立即停止;
  • [ ] 半成品保存:被中断的内容完整存入历史并带 interrupted 标记;
  • [ ] 上下文衔接:新回答能接住上文,不重复、不失忆;
  • [ ] 连续多次打断:快速连点发送,状态机不错乱、无 token 串台;
  • [ ] 超长对话:上下文自动裁剪,不超 token 上限;
  • [ ] 网络异常:WebSocket 断连重连后状态可恢复;
  • [ ] 仅取消不追问:用户点停止但不发新消息,状态回到 idle。

常见问题排查

现象排查方向
打断后旧 token 仍在冒出AbortSignal 是否真的传给了 LLM 接口;是否用代际号过滤旧流
新回答"失忆",不记得上文buildContext 是否包含了被中断的半成品和历史
两段回答文字混在一起前端是否用 messageId 隔离不同流的渲染
打断响应很慢中断信号是否被排在数据流后面;是否阻塞在旧请求上
长对话报 token 超限滑动窗口裁剪是否生效;摘要机制是否启用
快速连点后状态卡死状态机转移是否覆盖了 interrupting 的并发情况

一句话总结:"输出途中插入新提问还能无缝衔接",本质是三个动作的精密配合——用 AbortController 干净地中断、在 catch(AbortError)保存半成品、在 buildContext 里把"半成品 + 新问题 + 衔接提示"拼成连贯上下文,再用一套状态机WebSocket 双工通道把整个过程的并发与竞态管住。对前端全栈开发者来说,这里用到的每一块——事件驱动、异步取消、状态管理、增量渲染——都是你的老朋友,难点只在于把它们在"流式进行时"这个特殊时刻组合正确。