
文章摘要前端点击“停止生成”后页面虽然不再显示内容但后台日志、Token统计或模型控制台仍显示请求继续执行。这是因为停止前端渲染、关闭HTTP连接、取消Reactor订阅和通知模型Provider停止生成是四个不同层次。本文解释取消信号如何沿前端、网关、Spring WebFlux和模型SDK传播并给出任务状态、doOnCancel、幂等关闭、超时和计费监控的完整实现思路。一、“停止生成”至少有四种含义用户点击按钮时系统可能只完成了其中一项1. 停止页面渲染 2. 中断浏览器请求 3. 取消后端Flux订阅 4. 取消上游模型生成只有第一项时页面不再更新 但网络请求仍存在完成前三项时客户端和后端停止消费 但Provider可能仍在生成能否完成第四项取决于模型API和SDK是否支持显式取消以及取消信号是否及时传播。二、前端必须真正Abort请求错误做法letstoppedfalse;functionstop(){stoppedtrue;}while(true){constchunkawaitreader.read();if(!stopped){render(chunk);}}这里只停止渲染没有取消请求。正确做法constcontrollernewAbortController();constresponseawaitfetch(/api/ai/stream,{method:POST,headers:{Content-Type:application/json},body:JSON.stringify({message}),signal:controller.signal});functionstopGeneration(){controller.abort(user_cancelled);}捕获try{awaitreadStream(response);}catch(error){if(error.nameAbortError){showStatus(已停止生成);return;}throwerror;}三、Spring WebFlux如何感知取消客户端断开后Reactor订阅通常会收到取消信号。returnchatClient.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();});returntaskEventStream;这里创建了一个独立订阅。即使HTTP客户端断开独立任务仍会继续。推荐让HTTP响应直接持有同一个订阅链returnchatClient.prompt().user(message).stream().content().map(this::toEvent).doOnCancel(()-cancelTask(requestId));如果业务确实需要后台继续则应明确标记DETACHED_BACKGROUND_TASK不能误以为它会随连接自动取消。五、网关断开不一定立即传到服务链路浏览器 → CDN → API Gateway → Nginx → Spring Boot浏览器取消后中间代理可能缓冲数据保持上游连接延迟关闭重试请求将取消转换为普通连接结束。需要检查Nginx日志网关上游连接状态SpringdoOnCancel是否触发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七、建立统一任务状态publicenumAiTaskStatus{CREATED,STREAMING,CANCEL_REQUESTED,CANCELLED,COMPLETED,FAILED,TIMED_OUT}任务记录publicrecordAiStreamTask(StringtaskId,StringproviderResponseId,AiTaskStatusstatus,InstantstartedAt,InstantcancelRequestedAt){}取消流程用户请求取消 → 状态变为CANCEL_REQUESTED → 取消前端流 → 取消Reactor订阅 → 调用Provider取消 → 状态变为CANCELLED八、提供独立取消接口仅依赖客户端断开不够可靠。PostMapping(/tasks/{taskId}/cancel)publicMonoVoidcancel(PathVariableStringtaskId){returntaskService.cancel(taskId);}前端controller.abort();awaitfetch(/api/ai/tasks/${taskId}/cancel,{method:POST});这样即使网关延迟传播连接断开也能通过业务接口明确取消。取消接口必须幂等重复取消 → 返回当前状态 → 不重复调用高成本操作九、取消与工具调用Agent流中可能已经执行搜索查询数据库创建订单发送邮件扣减库存。用户停止文本生成并不代表这些操作可以撤销。工具分类类型取消策略只读查询可中断或忽略结果长计算支持任务取消创建类使用幂等键不能简单重做支付类进入正式撤销流程外部通知发送后不可撤回UI应区分停止回答 取消整个任务 撤销已执行操作这三者不是同一按钮。十、流结束后的保存逻辑常见需求保存完整回答。StringBuilderbuffernewStringBuilder();returnstream.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-newAiTaskTimeoutException());更精细地分为首次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动作。