Appearance
边说边听:模型输出途中插入新提问,如何做到无缝衔接?
更新: 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 多轮中断的状态追踪
用户可能连续打断好几次。这要求状态机能处理中断嵌套:每次中断都生成独立的 messageId 和 AbortController,后端要保证旧的取消信号不会误伤新的生成流。用一个"代际号(generation id)"给每轮生成打标,是常见的防串台手段——只有当前代际的 token 才被接受。
5.3 性能优化
- 打断响应要快:中断信号走独立的轻量通道,别排在数据流后面;
- token 批量推送:高频 token 可做微批(如每 30ms 合并一次)减少渲染压力;
- 背压处理:客户端渲染跟不上时,用队列缓冲,避免 UI 卡死;
- 乐观 UI:用户一发新消息,前端立刻停旧输出的视觉更新,不等后端确认,体感更跟手。
5.4 前沿趋势
实时语义打断是下一步方向:不只是"用户发消息才打断",而是系统实时理解用户输入的语义,判断这是"补充"(接着答)还是"纠偏"(推倒重来),甚至在语音场景下做到"边听边判断是否该停"。这类能力正随着流式语义理解和更低延迟的推理架构逐步落地。
六、实现效果:生硬打断 vs 无缝衔接
同样是"输出途中被打断",两种实现的差距一目了然:
| 维度 | 生硬打断(糙实现) | 无缝衔接(好实现) |
|---|---|---|
| 旧回答处理 | 直接丢弃 | 保存为半成品,标记 interrupted |
| 上下文 | 丢失,新回答前言不搭后语 | 完整拼接,模型知道"刚说到哪" |
| 中断速度 | 等旧请求跑完才响应 | AbortController 立即取消 |
| 界面表现 | 两段流混乱、文字乱蹦 | messageId 区分,渲染清晰 |
| 多次打断 | 状态错乱 | 代际号追踪,互不干扰 |
七、测试 Checklist 与排查指南
上线前测试清单
- [ ] 正常流程:一问一答,流式输出完整、
stream_end正确触发; - [ ] 输出途中打断:生成中发新消息,旧输出立即停止;
- [ ] 半成品保存:被中断的内容完整存入历史并带
interrupted标记; - [ ] 上下文衔接:新回答能接住上文,不重复、不失忆;
- [ ] 连续多次打断:快速连点发送,状态机不错乱、无 token 串台;
- [ ] 超长对话:上下文自动裁剪,不超 token 上限;
- [ ] 网络异常:WebSocket 断连重连后状态可恢复;
- [ ] 仅取消不追问:用户点停止但不发新消息,状态回到 idle。
常见问题排查
| 现象 | 排查方向 |
|---|---|
| 打断后旧 token 仍在冒出 | AbortSignal 是否真的传给了 LLM 接口;是否用代际号过滤旧流 |
| 新回答"失忆",不记得上文 | buildContext 是否包含了被中断的半成品和历史 |
| 两段回答文字混在一起 | 前端是否用 messageId 隔离不同流的渲染 |
| 打断响应很慢 | 中断信号是否被排在数据流后面;是否阻塞在旧请求上 |
| 长对话报 token 超限 | 滑动窗口裁剪是否生效;摘要机制是否启用 |
| 快速连点后状态卡死 | 状态机转移是否覆盖了 interrupting 的并发情况 |
一句话总结:"输出途中插入新提问还能无缝衔接",本质是三个动作的精密配合——用 AbortController 干净地中断、在 catch(AbortError) 里保存半成品、在 buildContext 里把"半成品 + 新问题 + 衔接提示"拼成连贯上下文,再用一套状态机和 WebSocket 双工通道把整个过程的并发与竞态管住。对前端全栈开发者来说,这里用到的每一块——事件驱动、异步取消、状态管理、增量渲染——都是你的老朋友,难点只在于把它们在"流式进行时"这个特殊时刻组合正确。