先看结论与判断条件

  • 服务器模型任务、网络连接、客户端协程、仓库聚合器和 UI 订阅是五个不同生命周期,取消其中一层不能推断其他层已经停止。
  • 请求创建时固定 accountId、conversationId、requestId 和 generation,任何片段写入前都比较任务快照、仓库目标与当前有效代次。
  • 协程取消是协作式的,挂起点、计算循环、异常传播和 finally 清理都会影响任务何时真正结束;CancellationException 不应被当作普通错误吞掉。
  • 流式事件要有单调 sequence 和唯一终态,cancelled、completed 或 failed 之后的 chunk 一律拒绝,不能因重连或页面重建重新开放。
  • 页面销毁只表示 UI 不再接收,不一定说明服务端生成已经终止;产品文案、日志和删除策略必须准确区分本地停止与远端回执。
  • 设备回归应覆盖用户取消、返回键、页面替换、账号切换、网络断开、进程死亡和迟到片段,并把结果绑定同一构建与运行条件。

先拆开流式请求的五个生命周期

用户看到的是逐字出现的一段回复,工程链路却至少包含服务器模型任务、网络流、客户端协程、数据仓库聚合器和页面订阅。点击取消可能只停止 UI 动画,HTTP 读取仍在进行;关闭连接可能中断客户端接收,服务端任务仍可能继续;取消协程可能等待协作式检查,仓库里已经排队的片段也可能继续分发。设计文档必须逐层写出开始、取消、终态和回执。

每层取消都要说明能够保证什么。UI unsubscribe 只能保证当前订阅者不再接收;协程 Job cancelled 表示取消信号进入结构化并发,但计算代码仍需到达检查点;网络 call cancel 表示客户端尝试终止 I/O,不证明服务端任务立即停止;服务端 cancellation receipt 才能说明远端接受了取消请求,仍需定义已生成内容与缓存如何处理。

如果业务不需要继续后台生成,页面离开应沿链路向下传播取消;如果允许后台完成,则 UI 销毁与请求取消必须分成两个状态,后台结果仍绑定原会话,不能写入用户随后打开的新会话。产品团队需要明确这项选择,不能让页面实现自行决定。本文只讨论生命周期与回写边界,不声称某个 AI 服务支持远端取消。

流式输出各层取消的保证边界
层级取消动作可以保证不能保证
服务器任务业务取消协议按服务回执处理所有副本立即消失
网络流关闭或取消读取客户端停止继续接收模型停止计算
协程 Job传播 cancellation子任务进入取消非协作代码立即退出
仓库聚合关闭 request 通道拒绝后续提交旧队列已自动清空
UI 订阅解绑观察者当前页面不再渲染后台任务终止

用 requestId 和 generation 固定片段归属

请求建立时生成不可复用的 requestId,并从当前会话取得 generation。任务快照至少包含 accountId、conversationId、requestId、generation、modelRoute 和 policyVersion。片段到达网络层、解析层、仓库和 UI 时都携带该快照,不能在回调时读取全局 currentConversation 来推断归属。全局值可能已经因用户切换账号、重新提问或打开另一会话而变化。

用户重新发送、编辑上一问题、切换模型或清空会话时,当前 generation 原子递增,旧请求立刻失去写入资格。随后再取消旧 Job 和网络 call。这个顺序把停止回写变成同步门禁,即使取消传播较慢,旧片段也无法覆盖新状态。若先取消后递增 generation,取消动作和新请求之间仍可能插入一个迟到片段。

generation 不是消息序号,也不是数据库自增主键。它描述某个会话当前可接受的请求代次;requestId 区分同一代次内的任务,sequence 描述一个请求内片段顺序。三者混为一个数字会让重试、分支或恢复难以表达。存储结构和日志字段要分别命名,不能用 timestamp 猜测谁更新得更晚。

流式事件的最小归属字段
字段职责错误替代门禁动作
accountId账号隔离设备标识不一致直接拒绝
conversationId会话对象当前页面引用目标不存在即丢弃
requestId单次请求身份时间戳终态后永久关闭
generation当前可写代次消息序号旧代次不得写入
sequence片段顺序到达时间重复或倒序拒绝
policyVersion解析与展示策略客户端版本版本漂移时终止

协程取消要保留 CancellationException 的语义

Kotlin coroutine cancellation 说明协程取消是协作式的。挂起函数通常会检查取消,但长时间 CPU 循环、阻塞库调用或吞掉异常的代码可能继续执行。流式解析器在读取、解码和聚合之间应到达可取消点,计算循环检查 active 状态,阻塞 I/O 使用支持取消的调用。设置一个 cancelled 布尔值而不让执行路径观察,不能证明任务停止。

CancellationException 用来表达取消传播,不应在 catch Exception 后转换成普通网络错误并继续重试。若业务需要记录终态,可以在清理后重新抛出取消异常,或由上层 completion handler 映射为 cancelled。launch 与 async、父子 Job 和 supervisor 的异常传播不同,错误处理器要针对实际构造验证,不能把一种示例推广到全部并发结构。

finally 用于关闭流、释放解析缓冲和登记有限终态;需要在取消后执行的挂起清理应保持范围很小,并使用明确的超时与失败回执。清理阶段不能重新打开 UI 写入,也不能把 cancelled 改成 completed。任何重试都创建新的 requestId,若用户意图已经改变则创建新 generation,不能复活原终态任务。

协程取消中的常见错误
错误表面现象真正风险修正
吞掉取消异常显示普通失败任务继续或被重试保留取消传播
只取消父 Job按钮变为已取消孤立任务仍运行检查结构化父子关系
CPU 循环无检查取消延迟继续产生片段加入协作检查点
阻塞调用不可取消线程长期占用终态迟迟不达使用可取消接口或隔离
finally 写 UI偶发旧内容取消后回写清理只处理资源
复用旧 Job重试看似方便终态重新开放新 requestId

片段顺序与唯一终态要在仓库层关闭

网络到达顺序不应直接成为 UI 顺序。仓库为每个 requestId 保存 expectedSequence、terminalState 和 generation,收到 chunk 时先验证归属,再检查 sequence。重复片段可以按明确策略去重,倒序或跳号进入缓冲、重连或失败流程,但不能静默拼接。解析完成后才形成不可变 UI 事件,页面不接触原始网络帧。

terminalState 只能从 active 进入 completed、cancelled 或 failed 之一。任一终态落盘后,该 requestId 永久拒绝 chunk、toolResult 和新的终态;重连也不能打开。这样即使网络库在 cancel 后交付已缓冲数据,或页面重建重新订阅,仓库仍能阻止旧片段。只在 View 层判断 loading false,会被其他订阅者、缓存恢复和迟到任务绕过。

completed 需要明确的完成事件或协议条件,不能因为连接关闭就自动推断。连接关闭可能来自取消、网络故障、进程切换或服务端完成;仓库根据协议事件和错误上下文决定终态。若协议没有可靠完成标记,界面使用“连接中断”而不是伪造完整回复,并把是否可重试交给新的用户动作。

流式仓库状态机
当前状态输入事件允许结果拒绝条件
active连续 chunk追加并推进 sequence归属或代次不符
active重复 chunk去重并记录内容摘要冲突
activecancel进入 cancelled无
activecomplete进入 completed存在缺口
activeerror进入 failed无
任一终态任何 chunk拒绝并记迟到永久关闭
任一终态第二终态拒绝终态不可改写

页面销毁与后台继续必须是两种明确策略

页面销毁可能来自返回、导航替换、配置变化、账号切换或进程终止前清理,它们的业务含义并不相同。若请求只服务当前临时页面,页面销毁时递增 generation 并取消全链路;若会话允许后台生成,页面只解绑订阅,请求继续写入原会话仓库,重新打开同一会话时再显示。两种策略都不能把结果路由到当前最上层页面。

UI 控制器订阅的不是全局 latestResponse,而是 accountId、conversationId、requestId 与 generation 的精确流。渲染前再次比较当前页面身份,页面进入 destroyed 后所有 deliveredToUi 必须为 false。此检查是最后一道门,不替代仓库归属;即使 UI 安全拒绝,旧片段也不应污染数据库、搜索索引或通知。

账号切换比普通页面离开更严格。旧账号 generation 先失效,所有可见状态清空,后台策略若允许继续也只能留在旧账号隔离分区;新账号不能订阅或扫描旧请求。注销、删除会话和保留历史的差异由产品与合规策略决定。本文不复制多账号缓存隔离的完整答案,只说明流式回写必须继承账号域。

页面离开场景的取消策略
场景UI 订阅请求任务generation
用户点取消停止显示向下传播取消立即失效
返回且不后台生成解除取消网络和协程立即失效
返回但允许后台生成解除继续原会话任务原会话保持
打开新会话切换精确订阅旧任务按产品策略新会话独立
编辑并重发绑定新 request取消旧 request递增
账号切换清空旧状态旧账号隔离处理旧账号失效
页面重建重新核对身份不自动复活终态按状态仓库恢复

取消文案、日志与隐私边界要保持准确

用户点击取消后,界面可以准确说明“已停止在本设备继续显示”或“取消请求已发送”,不能在没有远端回执时声称模型已经停止、内容已经删除或计费已经终止。不同 AI 服务的取消、保留和计费规则需要具体协议证据。应用要区分 local_cancelled、remote_cancel_requested 与 remote_cancel_confirmed,避免一个布尔值掩盖边界。

流式日志不记录提示词、模型片段、访问令牌、账号信息或完整错误响应。可记录受控 request 别名、generation、最后 sequence、终态、取消来源、各层回执和 policyVersion,用于定位迟到写入。若崩溃分析 SDK 会自动抓取状态对象,需要建立字段 allowlist;当前文章不复制 AI 输出日志隐私专题,只保留生命周期所需的最小事件。

取消后删除哪些本地片段也要明确。产品可选择保留已显示部分并标记 incomplete,或删除当前未完成回复;无论哪种选择,都要在仓库终态和 UI 中一致,搜索索引、摘要和分享缓存不得把不完整回复当作完整。服务端副本、诊断和备份的保留由具体系统策略决定,客户端终态不能冒充远端删除证据。

取消回执的准确含义
回执已知事实未知边界界面措辞
local_cancelled本地停止接收或展示服务端是否停止已停止显示
network_closed连接不再读取模型任务状态连接已关闭
remote_requested取消请求已发送是否已处理正在取消
remote_confirmed服务声明已接受备份和计费边界已收到取消确认
partial_kept本地保留部分文本远端完整内容回复未完成
partial_deleted本地对象已删除远端与备份已从本设备移除

进程死亡与设备测试不能由正常取消替代

进程被系统终止时,内存中的 finally、取消请求和 UI 回执都可能没有机会完成。恢复时不能把 active 记录直接当作继续生成;仓库读取持久化的 requestId、generation、最后 sequence 和远端状态,按协议选择查询、标记 interrupted 或创建新请求。旧网络回调不应跨进程存在,任何恢复结果仍需比较当前账号和会话代次。

Android ApplicationExitInfo 可以提供进程退出原因,并在适用版本提供 ANR trace 或 Native tombstone 等信息。它用于把流式中断与同一版本、时间窗和用户路径关联,不能单独证明取消逻辑出错。退出原因、仓库终态和网络回执需要组合分析,且不记录用户内容。没有目标版本与真实回执时,本文不下进程稳定性结论。

Android instrumented tests 可覆盖真实运行时、组件和系统 API。测试矩阵应包含用户取消、页面销毁、页面重建、账号切换、网络断开、重复片段、倒序片段、终态后迟到片段和进程恢复,并验证仓库与 UI 两道门。单一设备通过不能代表完整系统和厂商范围,静态检查、协程测试通过或未出现崩溃也不等于发布闭合。

  • 用户取消、页面销毁与账号切换分别验证 generation 失效顺序
  • 网络、协程、仓库和 UI 各自产生有限状态回执
  • 重复、倒序、跳号和终态后片段均有拒绝用例
  • 进程恢复不自动复活 active 任务或旧终态 requestId
  • ApplicationExitInfo 与同一版本、时间窗和路径关联
  • 设备覆盖和真实回执不足时保持兼容与稳定性未证实

用事件清单拒绝页面销毁后的迟到回写

下面的 Python 示例读取脱敏 JSON 事件序列。顶层固定 accountId、conversationId、requestId 和 generation;每个事件包含 sequence、type、pageState 与 deliveredToUi。校验器要求 sequence 单调连续,归属字段始终一致,cancelled、completed 或 failed 只出现一次,终态后不接受任何事件,并拒绝 destroyed 页面仍标记 UI 已投递。

示例只检查事件声明的一致性,不能证明真实协程已经退出、网络连接关闭、服务端模型停止或 UI 框架没有旁路。生产门禁还要绑定真实 Job、call、仓库事务、订阅和服务端回执,使用受控测试 fixture 触发竞争窗口。代码不含客户数据、提示词、令牌、服务器地址、包名、设备标识或攻击链。

可同时参阅本站关于多账号 AI 会话缓存隔离与请求幂等的技术说明,分别处理账号分区和重复提交;当前页面只回答流式取消后的回写边界。准备具体 App 的生命周期评估时,可整理脱敏状态图、事件 schema、协程树、网络取消协议、仓库规则和设备夹具,再从御盾中央平台提交申请;页面框架会统一提供本站内链与中央行动按钮。

  • accountId、conversationId、requestId 与 generation 全程不变
  • sequence 从固定起点连续递增,不接受重复、倒序或跳号
  • destroyed 页面不得出现 deliveredToUi 为 true 的事件
  • cancelled、completed 与 failed 只能形成一个终态
  • 终态之后任何 chunk 或第二终态均被拒绝
  • 事件清单通过后仍验证真实网络、协程、仓库和 UI 回执
AI 流式输出归属与取消终态校验器
from pathlib import Path
import json
import re
import sys

if len(sys.argv) != 2:
    raise SystemExit("usage: stream_lifecycle_gate.py events.json")
path = Path(sys.argv[1])
if not path.is_file():
    raise SystemExit("event file is missing")
try:
    document = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
    raise SystemExit("event file is not valid JSON") from exc
if not isinstance(document, dict):
    raise SystemExit("event document must be an object")

safe_id = re.compile(r"^[A-Za-z0-9_.-]{1,64}$")
owner_fields = ("accountId", "conversationId", "requestId")
for field in owner_fields:
    if not safe_id.fullmatch(str(document.get(field, ""))):
        raise SystemExit(f"{field} is missing or invalid")
generation = document.get("generation")
if not isinstance(generation, int) or generation < 1:
    raise SystemExit("generation must be a positive integer")
events = document.get("events")
if not isinstance(events, list) or not events:
    raise SystemExit("events must be a nonempty list")

terminal_types = {"cancelled", "completed", "failed"}
expected_sequence = 1
terminal = None
for number, event in enumerate(events, 1):
    if not isinstance(event, dict):
        raise SystemExit(f"event {number} must be an object")
    for field in owner_fields:
        if event.get(field) != document[field]:
            raise SystemExit(f"event {number} crosses {field}")
    if event.get("generation") != generation:
        raise SystemExit(f"event {number} uses a different generation")
    if terminal is not None:
        raise SystemExit(f"event {number} occurs after terminal {terminal}")
    event_type = event.get("type")
    if event_type not in {"chunk", "cancelled", "completed", "failed"}:
        raise SystemExit(f"event {number} has an unsupported type")
    if event.get("sequence") != expected_sequence:
        raise SystemExit(f"event {number} breaks sequence continuity")
    if event.get("pageState") not in {"active", "destroyed"}:
        raise SystemExit(f"event {number} has an invalid pageState")
    if event["pageState"] == "destroyed" and event.get("deliveredToUi") is True:
        raise SystemExit(f"event {number} writes to a destroyed page")
    if event_type in terminal_types:
        terminal = event_type
    expected_sequence += 1
if terminal is None:
    raise SystemExit("the event stream has no terminal state")
print(f"validated_events={len(events)} terminal={terminal}")

事实依据与适用边界

以下内容区分官方事实、本文工程判断和不能外推的范围,避免把设计建议写成未经验证的产品结论。

本文判断事实或工程依据适用限制
Kotlin 协程取消是协作式的,挂起点与取消检查会影响任务何时结束。Kotlin coroutine cancellation 说明取消、超时、可取消计算和 finally 清理等语义。通用取消规则不能替代目标流式函数、网络库和竞争窗口的逐路径回归。
协程具有明确生命周期,并在 JVM 上由线程和调度器承载执行。Kotlin coroutines basics 说明协程构建器、作用域、挂起与调度的基础模型。基础模型不证明特定页面作用域、线程阻塞或编译变换后的状态机仍保持语义。
launch、async、父子 Job 与 CancellationException 的异常传播规则需要分别处理。Kotlin coroutine exception handling 说明不同协程构建器、父子关系和取消异常的传播行为。示例语义不能证明某个 App 的 supervisor、重试和错误映射实现正确。
依赖真实 Android 生命周期和运行时的流式回写语义应通过设备端测试验证。Android instrumented tests 说明设备端测试运行于 Android 环境并可访问组件和系统 API。单一设备或模拟环境通过不能代表完整系统版本、厂商和网络条件。
进程退出原因可以用于关联流式中断,并在适用版本提供附加诊断信息。Android ApplicationExitInfo 描述进程退出原因、时间等字段,以及适用版本的 ANR trace 或 Native tombstone。退出记录需与同一版本、时间窗和用户路径关联,不能单独证明取消逻辑存在故障。
流式生命周期的发布过程应保留来源、构建、验证和变更证据。NIST SP 800-218 SSDF 给出组织级安全软件开发与供应链风险管理实践。SSDF 不定义流式协议,也不证明御盾或任何 AI 服务具备远端取消能力。
页面销毁或用户取消时,应先让旧 generation 失效,再向协程和网络传播取消。工程判断:同步失效写入资格可在异步取消尚未完成时拒绝迟到片段。generation 只能约束客户端接受结果,不能证明服务端生成、保留或计费已经停止。
流式仓库需要单调 sequence 和不可重开的唯一终态。工程判断:仓库层先处理顺序与终态,可避免 UI 状态、重连和缓存恢复绕过取消边界。sequence 起点、缺口恢复和协议完成事件由目标服务决定,需要具体网络回执验证。

工程常见问题

关闭页面后取消协程,是否就不会再收到模型片段?

不能仅凭这一点保证。网络、服务端和已排队回调可能仍有事件;旧 generation 失效后,每个片段还要在仓库和 UI 前重新校验。

点击取消后可以提示模型已经停止生成吗?

只有服务端协议提供明确回执时才能这样说明。否则只能确认本地停止显示、连接已关闭或取消请求已发送。

为什么不能在 catch Exception 中把 CancellationException 当普通错误重试?

这样会破坏取消传播,可能让终态任务重新工作。取消应完成清理并保持 cancelled,重试创建新的 requestId。

页面重建后可以继续订阅原流式请求吗?

取决于明确的后台生成策略。重新订阅前核对账号、会话、requestId、generation 和终态,不能订阅全局最新回复。

连接关闭是否等于回复完整完成?

不等于。关闭可能来自取消、故障或进程变化;只有协议完成事件和连续片段校验满足时才能标 completed。

事件 JSON 校验通过是否代表取消链已经安全?

不代表。它只检查声明一致性,还需验证真实服务协议、网络 call、协程树、仓库事务、UI 订阅和设备运行回执。

想用自己的 App 验证?

提交候选包、目标系统和关键业务路径,申请御盾 PoC 与兼容性评估。

继续阅读: 移动 AI 应用的运行时安全边界