Microi吾码 AI 流式请求停止链路四层示意图

按钮、浏览器、SSE 服务和上游模型,是四个不同的停止边界。

摘要| 我沿 Microi吾码当前 AI 流式链路,从停止按钮追到 AbortController、SSE、服务端和上游调用。结论:点击停止可结束浏览器读取;只有取消信号继续穿过服务端并被提供方接受,才能说模型已停止。本文绑定当前源码行号、SHA-256 与一次5/5聚焦测试,并区分源码推断和未做的生产验证。

① 用户点下的不是一只总闸

在 Microi吾码当前 AI 对话界面里,停止按钮做了两件确定的事:调用 `abortController.abort()`,再把 `sending` 设为 `false`。这足以让页面停止等待与渲染,却不能自动证明远端 GPU 已经结束计算。

原因是一次流式生成至少经过四层:UI 状态、浏览器 Fetch、服务端 SSE 与上游模型。每一层都能独立结束,也都可能没有把“取消”继续传给下一层。

停止动作穿过 UI Fetch SSE 业务调用和上游模型的链路图

任意一层只结束自己的工作,都不能替下一层作保证。

先给结论|| 用户看到“停止输出”,直接证明的是客户端不再读取;只有服务端取消令牌和提供方确认都成立,才等于模型停止。

用户点击停止
  → UI 清掉 sending
  → AbortSignal 终止 fetch
  → 连接关闭是否被服务端感知?
  → CancellationToken 是否继续传入模型调用?
  → 提供方是否确认停止与结算?

② SSE 解决的是连续送达,不是自动取消

普通 `/api/Ai/ChatStream` 当前明确返回 `text/event-stream`,并设置 `no-cache`、`keep-alive` 与 `X-Accel-Buffering: no`。服务端把内容包装成 `event:` 与 `data:`,每写一帧就执行 `FlushAsync()`。这解释了文字为什么能像打字机一样抵达。

  • `message`:逐段文本,客户端累积后更新回答。
  • `result`:最终结构化结果,客户端尝试 JSON 解析。
  • `error`:失败信息,客户端转为异常或错误结果。
  • `done`:服务端发送 `[DONE]`,表示正常流结束。

关键差别|| Flush 证明数据已经被推出服务端缓冲区;它并不等于浏览器仍在接收,更不等于上游计算收到取消。

③ 浏览器这层其实做得很清楚

当前页面在发起 ChatStream 前新建 `AbortController`,并把 `signal` 交给 `fetch`。点击停止后,正在等待的读取会以 AbortError 一类的方式结束。通用 `V8.AI.ChatStream` 也支持从调用参数接收 Signal。

这条 SDK 还先清除调用方伪造的 OsClient、用户、ApiKey、Endpoint、Token 和 Authorization,再由宿主注入平台上下文。也就是说,取消能力可以开放给调用者,身份边界仍然不能交给调用者覆盖

const controller = new AbortController();
V8.AI.ChatStream(param, onChunk, { Signal: controller.signal });

// 用户只是在浏览器侧先发出取消意图
controller.abort();

客户端语义|| AbortController 的成功标准是本次 Fetch/读取停止;不要把它的成功文案写成“已停止模型计费”。

④ 同样是流式接口,服务端取消链路并不相同

Microi普通ChatStream与ProxyChatStream取消链路源码差异矩阵

当前源码里,代理流显式传入 RequestAborted;普通 ChatStream 的可见调用没有。

普通 ChatStream 调用 `_microiAi.ChatStreamWithContextAsync(param, chunkCallback)`,当前参数未见 `HttpContext.RequestAborted`;`ProxyChatStream` 则把该令牌明确交给 `ExecuteAuthenticatedStreamAsync`。

因此可以做一个有边界的源码推断:普通路径的浏览器停止,不足以证明取消信号已穿透到上游;代理路径至少在控制器这一段完成了令牌传递。但这仍不是生产模型计费取消的实测。

  • 已确认:前端普通 ChatStream 的 Fetch 接收 AbortSignal。
  • 已确认:普通控制器当前可见调用未传 RequestAborted。
  • 已确认:代理流控制器显式传入 RequestAborted。
  • 未确认:具体模型提供方是否支持中途取消、何时停止计费。

证据边界|| “源码没传令牌”与“模型一定继续跑到最后”不是同一句话;后者还需要生产追踪与提供方账单回读。

⑤ 这次真正跑了什么测试

Microi V8 AI 聚焦测试五项全部通过的证据图

当前本地执行 5 项测试全部通过,失败为 0。

我在当前 Microi.Client 执行 `npm run test:v8-ai`:5 项测试、5 项通过、0 项失败;TAP 报告 634.7597 ms,命令墙钟 3237 ms。流式用例真实喂入“你”“好”两个 message 分片、一个 result 与一个 done,并检查最终结果和 Token 轮换。

tests: 5
pass: 5
fail: 0
duration_ms: 634.7597

received = ["你", "好"]
result = { Code: 1, Data: { Answer: "你好" } }

测试没有证明什么|| 这组测试证明 SSE 解析、身份字段清洗、结果组装和 Token 轮换;它没有连接真实上游模型,也没有测计费是否停止。

⑥ 正确的产品设计,是一套可对账的取消状态机

AI请求端到端取消状态机实施路线图

取消不是一个布尔值,而是一串可观测状态。

  1. 给每次生成分配稳定 RequestId,记录 started、cancel_requested、server_aborted、provider_acknowledged、completed。
  2. 把 `HttpContext.RequestAborted` 继续传入业务层、HTTP 客户端与可取消的 SDK 调用,不在中间换成新的无关令牌。
  3. 区分提供方“支持取消”“连接断开即取消”“无法确认”三种合同,避免统一写成成功。
  4. 任务结束后回读模型结果、Token 用量或账单状态,让取消成为可审计事实。
  5. 如果只能关闭显示,产品文案就写“已停止显示”,不要写“模型已停止”。
cancel_requested
  ├─ provider_acknowledged → cancelled_confirmed
  ├─ result_arrived_first  → completed_before_cancel
  └─ no_authoritative_ack  → cancelled_unknown

unknown 不是 success,也不应该盲目重试。

设计原则|| 把界面体验、连接生命周期、计算终止和费用结算拆开命名,用户才不会被一个绿色“已停止”误导。

⑦ 应该观测哪些指标

要验证取消是否真的生效,至少需要把客户端、服务端和模型提供方的时间线按同一 RequestId 串起来。只看浏览器控制台里出现 AbortError,证据仍停在第一跳。

  • cancel_click_to_fetch_abort_ms:交互反馈是否及时。
  • fetch_abort_to_server_aborted_ms:断连是否被服务端感知。
  • server_aborted_to_provider_ack_ms:上游是否确认取消。
  • tokens_before_cancel / tokens_after_cancel:取消后是否仍持续产生用量。
  • late_result_after_cancel_rate:取消后迟到结果的比例与处理策略。

实施提醒|| 这些是建议新增的观测项,不是本文声称 Microi吾码当前已经采集的线上指标。

⑧ 结尾:停止必须被逐层证明

Microi吾码当前前端已经给了用户及时停止读取的能力,SSE 也有清楚的事件格式与即时 Flush;代理流还显式携带 RequestAborted。真正值得继续补强的,是让普通 ChatStream 的取消语义一路贯穿,并把提供方确认与用量对账纳入同一个请求状态。

所以,下次看到“停止”按钮时,可以先问一句:它停止的是页面、连接、服务端工作,还是模型计算? 能回答到哪一层,产品就只能承诺到哪一层。

本文验证范围|| 当前本地源码 SHA、行号与 2026-08-22 聚焦测试成立;没有把源码分析冒充生产发布、真实提供方取消或账单验证。

Logo

宁波官方开源宣传和活动阵地,欢迎各位和我们共建开源生态体系!

更多推荐