用户取消AI流式输出后,模型为什么还在继续生成?前端Abort、Reactor取消与上游计费排查
文章摘要
前端点击“停止生成”后,页面虽然不再显示内容,但后台日志、Token统计或模型控制台仍显示请求继续执行。这是因为停止前端渲染、关闭HTTP连接、取消Reactor订阅和通知模型Provider停止生成是四个不同层次。本文解释取消信号如何沿前端、网关、Spring WebFlux和模型SDK传播,并给出任务状态、doOnCancel、幂等关闭、超时和计费监控的完整实现思路。
一、“停止生成”至少有四种含义
用户点击按钮时,系统可能只完成了其中一项:
1. 停止页面渲染
2. 中断浏览器请求
3. 取消后端Flux订阅
4. 取消上游模型生成
只有第一项时:
页面不再更新
但网络请求仍存在
完成前三项时:
客户端和后端停止消费
但Provider可能仍在生成
能否完成第四项,取决于模型API和SDK是否支持显式取消,以及取消信号是否及时传播。
二、前端必须真正Abort请求
错误做法:
let stopped = false;
function stop() {
stopped = true;
}
while (true) {
const chunk = await reader.read();
if (!stopped) {
render(chunk);
}
}
这里只停止渲染,没有取消请求。
正确做法:
const controller = new AbortController();
const response = await fetch("/api/ai/stream", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({ message }),
signal: controller.signal
});
function stopGeneration() {
controller.abort("user_cancelled");
}
捕获:
try {
await readStream(response);
} catch (error) {
if (error.name === "AbortError") {
showStatus("已停止生成");
return;
}
throw error;
}
三、Spring WebFlux如何感知取消
客户端断开后,Reactor订阅通常会收到取消信号。
return chatClient.prompt()
.user(message)
.stream()
.content()
.doOnCancel(() ->
log.info(
"stream cancelled requestId={}",
requestId
)
)
.doFinally(signal ->
log.info(
"stream finished requestId={} signal={}",
requestId,
signal
)
);
doFinally可能收到:
ON_COMPLETE
ON_ERROR
CANCEL
日志必须区分正常结束和用户取消。
四、不要在中间把Flux变成不可取消任务
错误:
CompletableFuture.runAsync(() -> {
chatClient.prompt()
.user(message)
.stream()
.content()
.doOnNext(this::save)
.subscribe();
});
return taskEventStream;
这里创建了一个独立订阅。
即使HTTP客户端断开,独立任务仍会继续。
推荐让HTTP响应直接持有同一个订阅链:
return chatClient.prompt()
.user(message)
.stream()
.content()
.map(this::toEvent)
.doOnCancel(
() -> cancelTask(requestId)
);
如果业务确实需要后台继续,则应明确标记:
DETACHED_BACKGROUND_TASK
不能误以为它会随连接自动取消。
五、网关断开不一定立即传到服务
链路:
浏览器
→ CDN
→ API Gateway
→ Nginx
→ Spring Boot
浏览器取消后,中间代理可能:
- 缓冲数据;
- 保持上游连接;
- 延迟关闭;
- 重试请求;
- 将取消转换为普通连接结束。
需要检查:
- Nginx日志;
- 网关上游连接状态;
- Spring
doOnCancel是否触发; - Provider连接何时关闭。
如果浏览器取消后Spring没有收到CANCEL,问题在中间链路。
六、取消后Provider是否停止
不同模型Provider行为不同。
可能情况:
情况一
关闭HTTP流后,Provider停止生成。
情况二
Provider继续生成,但不再传输。
情况三
Provider支持显式取消接口,需要调用:
cancel(response_id)
情况四
模型已经生成完成,只是网络仍在传输。
因此,不能仅根据页面停止判断计费已经停止。
需要记录:
client_cancelled_at
backend_cancelled_at
provider_cancel_sent_at
provider_stream_closed_at
usage_reported_tokens
七、建立统一任务状态
public enum AiTaskStatus {
CREATED,
STREAMING,
CANCEL_REQUESTED,
CANCELLED,
COMPLETED,
FAILED,
TIMED_OUT
}
任务记录:
public record AiStreamTask(
String taskId,
String providerResponseId,
AiTaskStatus status,
Instant startedAt,
Instant cancelRequestedAt
) {
}
取消流程:
用户请求取消
→ 状态变为CANCEL_REQUESTED
→ 取消前端流
→ 取消Reactor订阅
→ 调用Provider取消
→ 状态变为CANCELLED
八、提供独立取消接口
仅依赖客户端断开不够可靠。
@PostMapping("/tasks/{taskId}/cancel")
public Mono cancel(
@PathVariable String taskId
) {
return taskService.cancel(taskId);
}
前端:
controller.abort();
await fetch(`/api/ai/tasks/${taskId}/cancel`, {
method: "POST"
});
这样即使网关延迟传播连接断开,也能通过业务接口明确取消。
取消接口必须幂等:
重复取消
→ 返回当前状态
→ 不重复调用高成本操作
九、取消与工具调用
Agent流中可能已经执行:
- 搜索;
- 查询数据库;
- 创建订单;
- 发送邮件;
- 扣减库存。
用户停止文本生成,并不代表这些操作可以撤销。
工具分类:
| 类型 | 取消策略 |
|---|---|
| 只读查询 | 可中断或忽略结果 |
| 长计算 | 支持任务取消 |
| 创建类 | 使用幂等键,不能简单重做 |
| 支付类 | 进入正式撤销流程 |
| 外部通知 | 发送后不可撤回 |
UI应区分:
停止回答
取消整个任务
撤销已执行操作
这三者不是同一按钮。
十、流结束后的保存逻辑
常见需求:保存完整回答。
StringBuilder buffer = new StringBuilder();
return stream
.doOnNext(buffer::append)
.doOnComplete(() ->
saveCompletedAnswer(
requestId,
buffer.toString()
)
)
.doOnCancel(() ->
saveCancelledAnswer(
requestId,
buffer.toString()
)
);
取消时只保存已收到部分,并标记:
partial = true
status = CANCELLED
不要把部分回答当成完整答案进入缓存。
十一、增加总超时与空闲超时
用户不点击取消,系统也需要自动停止异常任务。
stream
.timeout(Duration.ofMinutes(5))
.onErrorMap(
TimeoutException.class,
error -> new AiTaskTimeoutException()
);
更精细地分为:
首次Token超时
Token间隔超时
任务总时长
工具超时
十二、慢客户端和背压
客户端处理速度慢时,缓冲区可能持续增长。
策略:
stream
.onBackpressureBuffer(
256,
dropped -> log.warn(
"stream buffer overflow"
),
BufferOverflowStrategy.ERROR
);
对于文本Token,可以合并:
20—50毫秒
或达到一定字符数
→ 发送一个Chunk
减少网络包和DOM更新。
十三、计费监控
记录:
started_requests
cancelled_requests
cancel_after_first_token_rate
average_tokens_before_cancel
usage_after_cancel_detected
provider_cancel_success_rate
cancel_propagation_latency_ms
关键指标:
用户取消到Provider停止的时间
如果经常很长,说明取消链路没有真正闭环。
十四、完整排查顺序
1. 前端是否调用AbortController
2. 浏览器网络请求是否关闭
3. Nginx是否关闭上游连接
4. Spring是否收到CANCEL
5. 是否存在独立subscribe
6. Provider连接是否关闭
7. 是否需要显式cancel API
8. 工具任务是否可取消
9. 取消后Token是否继续计费
10. 部分回答是否误入缓存
总结
用户点击停止后模型仍生成,通常是因为系统只完成了:
停止显示
而没有完成:
中断请求
+取消订阅
+停止上游生成
+处理已启动工具
生产级取消必须是一个跨前端、网关、后端和Provider的业务流程,而不是一个隐藏文本的UI动作。
延伸阅读
如果你正在关注企业级 AI 应用、Agent、RAG、MCP 与大模型工程化落地,欢迎访问 智元界:
https://www.zyentor.com/
智元界将持续分享可运行的技术实战、架构设计、问题排查与企业应用案例。