用户取消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/

智元界将持续分享可运行的技术实战、架构设计、问题排查与企业应用案例。