想象一家深夜食堂的直播厨房:大厨正在为一位客人定制套餐,每做好一道菜就立刻端到桌上,客人边看边吃。突然客人举手喊了一声“停,后面的别上了”。这时真正考验团队的,不是“停止”这个动作本身,而是怎么优雅地收场:厨房要把火关掉、把已经切好的菜放回冰箱、把已经端上桌的菜保留好、把账单按“已经吃过的菜”结清楚,还要记住这位客人的口味,方便他过会儿说“继续”时无缝接上。 大模型的流式输出也一样。用户看到一半不满意,点下“停止”按钮,背后要发生一连串事情:前端发送取消信号、连接关闭、服务端停止生成、GPU资源回收、Token计费结算、部分结果保存、上下文保留。这节课,我们就用这家“直播厨房”的视角,把流式中断处理的七个关键环节一次讲透。
掌握中断处理,能让你的 LLM 应用在用户取消时既不浪费算力、不丢结果、不花冤枉钱,是 AI 产品工程化的基本功。
可视化演示
展示系统处理流水线、实时性能指标曲线及多方案对比分析,包含数据流转、吞吐延迟监控与方案评估。
本节配有交互动效(进入视口后自动加载,约 3KB)。
系统可视化 处理流程:输入→处理→模型→输出 性能监控:实时指标 P=L×T
中断触发方式
深夜食堂的直播厨房里,客人有三种方式叫停:举手喊“停”、直接起身离开、或者停止观看直播。大厨并不总能立刻知道客人已经离开,他还在按原计划切菜。流式输出也一样——用户想中断大模型生成,可以有三个入口:点击界面上的“停止”按钮、关闭浏览器标签页、或通过代码里的 AbortController 主动取消请求。所谓 AbortController,就是前端向浏览器喊停的哨子,一声令下,底层请求就会被标记为“取消”。
这三种方式分别对应不同的信号来源,但最终都指向同一个目标:让正在进行的请求进入取消状态。点击停止按钮是最直接的用户动作;关闭标签页会销毁页面上下文,浏览器随即取消请求;AbortController 则让开发者能在任意时刻主动调用 abort(),不需要用户真的去点按钮。
在实际代码里,前端通过 fetch 或 XHR 发起流式请求时,通常会挂一个 AbortController。用户点“停止”时,前端先停止把新到的增量文本渲染到界面,再调用 abort(),让浏览器关闭底层连接。服务端收到连接断开或取消标志后,才会真正停止生成。所以“中断触发”从来不是单一路径,而是多个入口合力发出的刹车信号。
就像客人在第三道菜之后喊停,后厨要记住的是“三道菜”这个节点,而不是从头再数一遍菜单。先明确中断从哪个入口、哪个时刻开始,后面所有优雅退出的动作才有一个可靠的锚点。
- 三个候选时刻
\{ t_{\text{click}}, t_{\text{abort}}, t_{\text{close}} \}分别代表按钮点击、AbortController 调用和页面关闭发生的时刻,它们是三种可能的触发源。 - 取最小值
\min表示取集合中最早的时刻,最早触发的信号决定了中断的起点。 - 中断时刻确定
t_{\text{interrupt}}是后续资源回收和计费截止的时间锚点,它越准,整个取消流程越干净。
信号传递与连接关闭
餐厅里客人的喊声并不会自动传进厨房,需要服务员穿过走廊把话带到。流式场景里也一样,用户点击停止后,“取消”这个信号要穿过网络,让服务端真的知道。SSE 和 WebSocket 是两种常见的流式传输通道,相当于传话的两条走廊。SSE 是服务端到客户端的单向长连接,客户端不能往这条连接里发“停止”消息,只能把连接关掉。WebSocket 是双向通道,客户端可以直接发 Close 帧,相当于拿起电话说“我这边讲完了”。
在 SSE 场景下,前端通常用 AbortController 关闭连接。服务端什么时候才能意识到连接断了?答案是:下一次向这条连接写入数据时。如果一个生成任务卡在 GPU 上做长推理,连接断开事件不会立刻打断推理,而要等这次推理返回、服务端尝试写下一个 chunk 时,才会发现写入失败,从而触发取消。WebSocket 的 Close 帧更明确,服务端收到后可以立即把生成任务标记为取消,但也要等当前这一步计算结束才能安全停掉。
这里有一个容易混淆的“半关闭”语义:一端停止发送,但仍然可以接收对方的消息。就像你先说“我讲完了”,但还拿着电话听对方回应。服务端收到 Close 帧后,不能只清理连接对象,还要把正在运行的生成任务标记为取消,否则模型可能继续在后台计算,白白消耗资源。
服务员不用每时每刻盯着客人,而是在端下一盘菜时发现没人接,才知道客人已经离开。流式服务也不必提前预知中断,只要在下一次“上菜”时检查盘子有没有被接住,就能及时收手。
- 写入动作
\text{write}是服务端向客户端推送一个数据块的行为,它是断线检测的探针。 - 写入成功当服务端发现
\text{write succeeds}时,连接状态记为\text{active},流水线继续运行。 - 写入失败当
\text{write fails}发生时,连接状态记为\text{closed},服务端应开始取消任务并回收资源。
服务端资源回收
大厨接到“别上了”之后,要立刻关燃气、把切好的菜放回冰箱、把占着的灶台腾出来。在 LLM 服务端,这个动作叫资源回收:取消生成任务、释放显存和内存、清理请求生命周期里挂着的各类资源。一次流式请求往往不是一个孤立函数,前端连接、网关、模型推理服务,甚至下游工具调用都串在一起,中断信号必须沿调用链逐层传递。
对大模型来说,最重的资源是推理过程中缓存的中间状态,通常称为 KV Cache。它就像厨师提前备好的半成品食材,堆在灶台一侧。请求一旦取消,这些缓存必须尽快释放,否则高并发场景下,显存会被堆积的“半成品”占满,后面的请求反而排不上队。工程上常用取消令牌或标志位,让生成循环在下一次迭代前看一眼“要不要退出”。
更规范的做法是为每个生成请求分配唯一 request_id,并注册取消回调。中断触发时,取消回调会从任务队列、缓存表和计数器里移除这个请求,让它不再占用 GPU、CPU 和网络连接。这样,一次用户取消不只是“停止输出”,而是把整条链路占用的资源还给系统。
就像没有被及时关火的灶台会把锅烧干,没有被回收的 KV Cache 会让整条推理链路越来越慢。优雅退出的第一步,不是嘴上说“停了”,而是真正把火关掉、把台面腾出来。
- 任务总数
N表示当前服务上所有可能占用资源的生成任务,包括正在运行和排队中的任务。 - 单个任务占用
M_i表示第 i 个任务占用的显存或内存,它通常和序列长度、模型规模有关。 - 取消标记
I_i取 1 表示该任务已被取消,取 0 表示仍在运行,它决定这一项是否被计入回收量。 - 求和回收
\sum_{i=1}^{N}把所有已取消任务占用的资源相加,得到本次中断可释放的总资源。
Token 计费规则
客人喊停后,侍者要结账。账单既不是按整桌菜算,也不是只收还没上的菜,而是按已经端上桌的菜收费。LLM 的计费逻辑也一样:已经消耗的 token 都要计费。一次请求的 token 分为两部分:输入侧的 prompt tokens,也就是用户输入的提示词;输出侧生成的 completion tokens,也就是模型回答的内容。用户中断时,模型可能已经生成了几十上百个 token,这些字已经消耗了 GPU 算力,所以必须计入账单。
未生成的 token 没有产生计算,自然不计费。但“未生成”的边界要注意:客户端可能只收到了 50 个 token,服务端因为网络缓冲实际生成了 60 个。这种情况下,以服务端实际生成并记录的 token 数为准。因此用户越早喊停,能省下的输出成本越多;但已经发生的算力,不会因为取消而消失。
在工程实现上,流式接口返回的增量数据里通常带有 usage 信息,或在请求结束时返回 token 统计。为了成本控制,我们要把每次增量累计到计数器,而不是中断后重新数一遍文本。还要保存 prompt token 数、已生成 token 数和停止位置,方便账单溯源和用户申诉。
账单不是惩罚,而是算力消耗的镜子。让用户看到“已生成 120 个 token,费用约 0.003 元”,比默默扣费更能建立信任。
- 输入侧数量
N_{\text{prompt}}是用户发给模型的提示词 token 数,无论是否中断都计入成本。 - 输出侧数量
N_{\text{generated}}是中断前已经生成并记录的 token 数,它是成本控制的核心变量。 - 单价
P_{\text{token}}表示每个 token 的计费价格,通常按模型规格提前约定。 - 总价计算
(N_{\text{prompt}} + N_{\text{generated}}) \times P_{\text{token}}把输入和输出相加后乘以单价得到本次请求费用。
部分结果处理
中断之后,已经端上桌的菜不应该被倒进垃圾桶。用户虽然没吃到完整套餐,但已经上桌的几道菜可以继续吃。大模型的输出也一样,已经生成的部分内容是有价值的,应当保存并展示。流式输出的原始数据通常是一串增量块(chunk),每个块里是几个 token。中断时,客户端可能只收到了前 k 个块,需要把这些块按顺序拼接起来,形成当前已生成文本。
拼接时要注意顺序不能乱,尤其涉及流式 JSON、Markdown 代码块时,可能出现残缺的括号或未闭合的代码段。比如用户让模型写一段 Python 代码,中断时可能刚写到 def func():,后面还没有函数体。这种半截代码不是错误,而是“生成到一半”的正常状态,前端要给出完整性提示,避免用户误以为系统故障。
保存策略上,不要让客户端等中断后才一次性向服务端要结果,而应在收到每个 chunk 时就增量落盘,写入内存或数据库。这样即使页面刷新、网络抖动,用户也能立刻看到已生成内容。展示策略上,要明确区分“完整回答”和“中断回答”,用“已停止,内容生成到此处”这类提示,让用户知道边界在哪里。
半成品不是失败品。把已上桌的菜摆放整齐,告诉客人“这是三道菜的进度”,用户会觉得整个系统是体面的,而不是在事故中仓皇收场。
- 增量块
\Delta_i表示服务端第 i 次推送的文本片段,它是流式输出的最小单位。 - 已保存结果
D_{\text{saved}} = \bigcup_{i=1}^{k} \Delta_i表示把前 k 个片段按顺序拼接,得到中断时的半成品。 - 完整结果
D_{\text{complete}}表示在已保存结果之后继续拼接剩余片段,最终得到完整回答。
上下文会话管理
客人想继续吃,厨房不能重新买菜、重新点火。他要记得“这位客人之前点过什么、已经上过哪几道菜”。对应到 LLM,就是保留会话上下文,让模型在续接时知道前面说了什么。中断发生时,客户端的对话历史里已经有一条用户的原始消息,还可能有一条 assistant 的部分回复。要继续生成时,不能只把原始 prompt 再发一遍。
正确的做法是把系统提示词、用户消息、已生成的 assistant 部分一起放进新的请求。这样模型续写时能接着“本周完成了流式中断模块”往下写,而不是重新开始讲一遍。断点续接的本质是状态恢复:用户说“继续”,前端把保存的 partial_text 和原 prompt 拼成新的对话上下文,发送给模型,模型从中断处继续。
上下文管理还要考虑上下文窗口和会话过期。已保存的部分结果要设置生命周期:短时间内的“继续生成”可以复用;超过一定时间或用户主动关闭会话,就要清理,避免占用存储和 context 空间。系统可以通过会话 ID 关联消息,也可以记录最后活跃时间,定期把过期会话归档或删除。
像保留点菜单一样保留上下文,用户隔几分钟回来说“继续”,系统就能无缝接上;如果这单太久没人认领,再把菜单收进文件柜,等下次重新点菜。
- 系统提示词
C_{\text{system}}是模型需要遵守的系统级指令,比如“你是严谨的周报助手”。 - 用户消息
C_{\text{prompt}}是用户本次会话输入的原始内容,它是上下文的起点。 - 已保存结果
D_{\text{saved}}是中断前生成的 assistant 内容,它让续接有据可依。 - 拼接上下文
C_{\text{next}}是下一次请求携带的完整上下文,模型基于它继续生成。
产品交互体验
优雅退出还差最后一块拼图:让客人看见“厨房已经收到指令、现在是什么状态”。好的服务员不会在客人喊停后消失,而是告诉他“已经上了三道菜,剩下两道退掉了”。产品交互上,要有明确的停止态提示:显示“已停止生成”、保留的部分内容、本次已消耗的 token 和费用,以及“重新生成”按钮。
对成本较高的长文本请求,可以在用户点击停止时加一次二次确认,防止误触。停止按钮也不应紧挨着“发送”按钮,否则很容易手滑。产品设计上,要区分“主动停止”和“被动断开”:主动停止有明确的状态和账单;被动断开要提示用户“连接已断开,已生成内容已保存”,并提供恢复入口。
从工程角度看,产品交互是中断流程的最后一环:中断信号触发后,前端要更新按钮状态、隐藏加载动画、展示部分结果;后端要上报取消事件、写入计费日志。产品经理可以把“用户平均取消率”“取消后会话续接率”当作体验指标,持续优化防误触和续接入口。
服务员的一句“好的,马上停”,比沉默的等待更让人安心。产品交互就是把这份安心做成可见状态:让用户知道自己的喊停被听见、被处理、被记录。
- 停止总次数
N_{\text{stop}}表示用户点击停止的总次数,它是统计口径的总和。 - 误触次数
N_{\text{mis}}表示用户不小心点错造成的停止,这是要优化的坏指标。 - 误触比例
\frac{N_{\text{mis}}}{N_{\text{stop}}}表示所有停止动作中误触的占比,占比越高体验越差。 - 有效停止率
R_{\text{valid}} = 1 - \frac{N_{\text{mis}}}{N_{\text{stop}}}越接近 1,说明停止交互越可靠。
AI 应用场景
智能客服的“停止回答”按钮
阶段:在线推理与交互
解决的问题:传统方案里,用户对长回答不满意时只能干等,要么手动刷新页面,造成已生成内容全部丢失,同时服务端还在继续计算。引入流式中断处理后,前端用 AbortController 发送取消信号,服务端取消生成任务,已生成的内容保留并展示,计费只按实际消耗 token 结算,避免算力和费用的双重浪费。
具体案例:用户问“请介绍贵司全部产品”,看到第一段后觉得太啰嗦,点击“停止”。系统立刻停止新内容渲染,显示“已回答 180 字,已停止生成”,并保留对话上下文。用户接着问“继续”,模型基于已生成的 180 字继续补充,而不是从头开始。
长文档生成器的“中途暂停,稍后继续”
阶段:会话状态管理
解决的问题:长文档生成耗时长、成本高,如果用户中途离开就必须重新生成,体验极差。利用上下文会话管理机制,系统把原始 prompt 和已生成部分一起保存,下次请求时作为上下文传入,实现断点续接。
具体案例:法务人员让 AI 起草一份合同,生成到 60% 时被会议打断,点击“暂停”。系统把草稿和 token 消耗保存到会话中。会议结束后,用户在同一个会话点击“继续”,模型从上次中断处继续写出剩余条款。
直播字幕的“打断修正”
阶段:流式结果处理
解决的问题:实时字幕流式输出时偶尔出错,用户希望立刻停止并修正。通过中断后保留部分结果,用户可以对已生成字幕直接编辑,再把修正后的文本作为上下文继续生成,避免整段重来。
具体案例:直播字幕把“流式中断”识别成“牛市中段”,用户点击停止并手动改成“流式中断”,模型读取修正后的上下文,继续输出后续字幕。中断前的内容没有被清空,而是作为人工纠偏的依据。
Python 演示
import time class StreamingSession: """模拟一个支持中断和续接的流式生成会话.""" def __init__(self, prompt, price_per_token=0.02): self.prompt = prompt self.partial_text = "" self.generated_count = 0 self.billed_tokens = 0 self.price_per_token = price_per_token self._stopped = False def request_stop(self): self._stopped = True def _bill(self): self.billed_tokens = len(self.prompt) + self.generated_count def stream(self, chunks): try: for chunk in chunks: if self._stopped: break self.partial_text += chunk self.generated_count += 1 yield chunk time.sleep(0.05) finally: self._bill() def resume(self, chunks): self._stopped = False try: for chunk in chunks: if self._stopped: break self.partial_text += chunk self.generated_count += 1 yield chunk time.sleep(0.05) finally: self._bill() def cost(self): return round(self.billed_tokens * self.price_per_token, 4) def main(): session = StreamingSession("请写一份周报", price_per_token=0.02) chunks = ["本周完成", "了流式", "中断模块", "联调", "下周计划", "做性能", "压测"] gen = session.stream(chunks) for token in gen: if session.generated_count >= 3: session.request_stop() gen.close() break print("已保存部分结果:", session.partial_text) print("已计费 token 数:", session.billed_tokens) print("当前费用:", session.cost()) for token in session.resume(chunks[3:]): pass print("续接后的完整结果:", session.partial_text) print("最终费用:", session.cost()) if __name__ == "__main__": main()
self.partial_text += chunk每收到一个增量块就存进半成品文本,中断后不会丢内容。if self._stopped: break每次产出新块前检查取消标志,用户喊停就立刻退出生成循环。gen.close()关闭生成器,触发 finally 里的记账逻辑,让计费立即生效。self._bill()按“prompt 长度 + 已生成数量”更新计费,未生成的块不计费。session.resume(chunks[3:])用保存的上下文继续生成,模拟用户说“继续”后的断点续接。
常见误区
误区用户点停止后,前端按钮已经禁用、界面不再渲染,所以模型应该已经停了。
正解前端停止渲染不等于后端停止生成。大模型在服务端按增量生成,客户端断开后可能还有缓冲中的 token 继续产出。必须通过 AbortController 或 WebSocket Close 帧把取消信号传到服务端,服务端还要在生成循环里检查取消标志,才能安全停下。
为何常见:UI 层先停了,给人“已经取消”的错觉,但服务端可能仍在 GPU 上消耗算力。
误区我主动停止生成了,没有拿到完整答案,这次调用不应该收费。
正解计费按实际消耗 token 计算。prompt tokens 全部计费,已生成的 output tokens 也计费,只有未生成的 token 不计费。中断只帮你避免未来的消耗,不能免掉已经发生的算力成本。
为何常见:用户视角是“我没得到完整答案”,但计费视角是“服务端已经跑了一段算力”。
误区半截回答留着没用,还可能让模型下一轮生成出错,不如全部清掉。
正解应该把已生成部分作为 assistant 内容保留在会话历史,等用户说“继续”时拼进下一次请求,让模型续写而不是重写。半截代码块需要加完整性提示,但不应粗暴清空。
为何常见:恐惧半截结果污染上下文,其实半截结果也是一种有效状态,关键是在展示层说清楚“生成到哪了”。
误区只要 HTTP 连接断开,服务端马上就知道,不用做额外处理。
正解HTTP/SSE 是单向长连接,服务端通常在下一次写入 chunk 时才发现连接已断开。如果模型卡在某次长推理里,要等推理返回才能检查。需要心跳/注释帧及时发现,并在模型调用层设置取消传播。
为何常见:把 TCP 断开想象成即时电话挂断,忽略了服务端还沉浸在本地计算任务中。
面试题
Q1. 用户点击“停止”后,前端首先应做什么?
- A.立刻把页面上的文本全部清空
- B.停止本地渲染并发送取消请求← 正确答案
- C.马上刷新浏览器重新加载页面
- D.等待服务端自己发现断线
答案:B。前端要先停止渲染,让用户不再看到新内容,同时把取消信号发出去。清空文本会丢掉已有结果,刷新页面会丢失上下文,等待断线发现则浪费时间且可能无法真正取消。
Q2. 一次流式生成了 8 个 token 后用户中断,计费时通常应计算?
- A.只算未生成的 token,生成的不算
- B.只算提示词的 token,输出免费
- C.算提示词和已生成的 8 个 token← 正确答案
- D.全部 0 个 token,因为是用户主动停
答案:C。输入侧的 prompt tokens 和输出侧已生成的 token 都已经消耗算力,都要计费;未生成的 token 不计费。这就像已经端上桌的菜,客人退货也要结账。
Q3. 以下哪些方式可以触发流式中断?
- A.用户点击页面上的停止按钮← 正确答案
- B.浏览器标签页被用户关闭← 正确答案
- C.调用 AbortController 的 abort 方法← 正确答案
- D.服务端模型自动增加上下文窗口
答案:ABC。停止按钮、页面关闭和 AbortController 都是客户端主动取消的入口。模型自动增加上下文窗口与中断无关。
Q4. 在 SSE 流式输出中,服务端通常在什么时机感知到用户中断?
- A.用户点击停止按钮的那一刻
- B.TCP 连接一关闭就立刻中断推理
- C.下一次向连接写入内容时发现失败← 正确答案
- D.模型权重被动态卸载的时候
答案:C。SSE 是单向长连接,服务端不能实时监听关闭事件,只能在下一次写入时感知失败。这像服务员端下一道菜时才发现客人已经走了。
Q5. 用户中断后希望“稍后继续生成”,应用层应如何保存会话?
- A.清空全部历史,只保留系统提示词
- B.保留原始提示词和已生成的部分结果← 正确答案
- C.只保存最后输出的一个 token
- D.把中断前内容复制到用户剪贴板
答案:B。续接的关键是把原始 prompt 和已生成的 assistant 部分一起作为上下文。清空历史会让模型重写,只保存一个 token 则失去上下文语义。
Q6. 服务端收到中断信号后,合理的资源回收动作包括?
- A.取消当前模型的生成任务← 正确答案
- B.释放该请求占用的显存和内存← 正确答案
- C.通知下游链路取消级联调用← 正确答案
- D.立刻重启整个推理服务进程
答案:ABC。取消任务、释放显存、级联取消都是合理的清理动作。重启服务会影响其他用户,属于严重误操作。
Q7. WebSocket 半关闭语义的正确理解是?
- A.两端同时关闭消息通道并销毁连接
- B.一端停止发送但仍可接收对方消息← 正确答案
- C.连接关闭后服务端不再需要清理资源
- D.关闭帧只能由客户端发送给服务端
答案:B。半关闭像电话里“我讲完了,但还听着”。它不是瞬间销毁连接,服务端仍要按协议处理后续消息并完成资源清理。
Q8. 中断后已经生成的部分内容,最好的处理方式是?
- A.直接丢弃并告诉用户请求失败
- B.保留展示并说明生成在此中断← 正确答案
- C.强制模型继续生成完整结果
- D.只在控制台打印,不展示给用户
答案:B。部分结果有业务价值,应保留并展示,同时提示“生成到此处中断”。强制继续违背用户意志,只打印不展示则完全失去意义。
Q9. 要减少“误触停止”带来的成本和体验问题,产品可采取?
- A.对高成本请求二次确认暂停← 正确答案
- B.停止后展示已消耗 token 数← 正确答案
- C.停止后提供“重新生成”入口← 正确答案
- D.将停止按钮放在手机屏幕边缘
答案:ABC。二次确认、成本可视化和重新生成入口都能降低误触影响。把按钮放屏幕边缘反而更容易误触。
Q10. 某 API 返回了 1000 个 token,但客户端在 500 个 token 时断开,服务端日志显示实际生成了 600 个 token。计费以下哪个说法正确?
- A.按客户端接收的 500 个计费
- B.按服务端已生成的 600 个计费← 正确答案
- C.按模型完整生成的 1000 个计费
- D.按 prompt token 数单独计费
答案:B。计费以服务端实际生成为准,而不是客户端收到的数量。已生成的 600 个 token 消耗了算力,因此计入账单;未生成的 400 个不计费。这也说明尽早取消能减少多余消耗。