SwiftUI 流式 AI 聊天实战:取消、重试与状态恢复
流式 AI 聊天最常见的故障,不是模型答错,而是界面状态失控:文字重复出现,用户点击停止后旧内容继续写入,阅读历史消息时列表不断把人拉回底部,App 重新打开后还显示“生成中”。这些问题不能靠一个 isLoading 解决,需要给一次回答建立明确生命周期。
先分清 snapshot 和 delta
许多云端接口发送 token delta,客户端需要不断追加。Apple 的 LanguageModelSession.ResponseStream 不同:它是一个 AsyncSequence,每次产生的是当前回答的累计快照。假设依次收到 A、AB、ABC,正确结果是用新值覆盖草稿;如果逐个追加,就会得到 AABABC。
因此,Provider Adapter 应对上层统一承诺“每个元素都是完整的 answer-so-far”。第三方接口若提供 delta,由 Adapter 先累积;Foundation Models 则直接转交 partial.content。ViewModel 不需要知道底层供应商的流格式。
protocol ChatStreamingClient: Sendable {
/// Every value is the complete answer so far, not a delta.
func snapshots(for prompt: String)
async throws -> AsyncThrowingStream<String, Error>
}
一次生成只属于一个请求
状态模型除了消息,还要保存当前请求 ID 和自己拥有的 Task。新请求开始时创建一条具有稳定 ID 的 assistant 草稿;流中的每个快照只替换这条消息的文本。即使底层服务忽略取消,迟到结果也必须通过请求 ID 检查,不能覆盖下一次回答。
@MainActor
@Observable
final class ChatModel {
private(set) var messages: [ChatMessage] = []
private(set) var phase: Phase = .idle
@ObservationIgnored private var generation: Task<Void, Never>?
@ObservationIgnored private var activeRequestID: UUID?
@ObservationIgnored private let client: any ChatStreamingClient
func send(_ prompt: String) {
guard generation == nil else { return }
let requestID = UUID()
let answerID = UUID()
activeRequestID = requestID
phase = .streaming
appendDraft(id: answerID, prompt: prompt)
generation = Task { [weak self] in
guard let self else { return }
do {
let stream = try await client.snapshots(for: prompt)
for try await snapshot in stream {
try Task.checkCancellation()
guard activeRequestID == requestID else { return }
replaceDraft(id: answerID, text: snapshot)
}
complete(answerID, requestID: requestID)
} catch is CancellationError {
stop(answerID, requestID: requestID)
} catch {
fail(answerID, requestID: requestID, error: error)
}
}
}
}
真实实现应使用 defer 或统一的结束方法清理 generation 和 activeRequestID。同一个 LanguageModelSession 一次只允许一个响应,所以发送按钮在生成期间应变成停止按钮,而不是再发一个并发请求。
取消是状态,不是红色错误
Swift Task 的取消是合作式的。cancel() 设置取消标志并传播信号,但不会强行终止不配合的网络库或 AsyncSequence。消费循环要调用 Task.checkCancellation();Adapter 桥接第三方 SDK 时,也要在终止回调中取消底层请求。
用户主动停止后,保留最后一个可见快照并标记为“已停止”,不要显示“生成失败”。重新生成要创建新的 attempt ID,旧请求随后到达的内容全部丢弃。超时和限流可以有限重试;拒答、不支持的能力以及 guardrail 错误不应原样循环;上下文过大则需要裁剪或总结历史后再请求。
自动滚动必须先尊重阅读
消息和生成中的草稿始终使用稳定 ID,并配合 scrollTargetLayout() 与 ScrollPosition。只有用户仍贴近底部时,快照更新才滚到最新位置。用户向上阅读后暂停跟随,并显示“回到最新”按钮。
不要每出现一个字符就执行带动画的滚动。可以按较短时间窗口合并快照,减少布局和滚动抖动。键盘出现、Dynamic Type、横竖屏切换和长代码块都会改变内容高度,应放进真机测试,而不是只在短回答预览中验收。
恢复的是领域状态,不是正在运行的 Task
消息、最后成功提交的回答、输入草稿和 attempt 状态可以持久化;Task、AsyncIterator 和网络连接不可以。App 冷启动时若读到 .streaming,应迁移为 .interrupted,让用户选择重新生成。不要自动重发,因为远端任务可能已经成功,带工具调用的请求还可能造成重复副作用。
iOS 26 的稳定策略是:模型 transcript 回到上一个完整边界,界面保留最后快照作为未完成草稿。iOS 27 beta 新增 .preserveTranscript,可以在取消或工具错误后保留部分 transcript,但开发者必须等 session.isResponding 变为 false 后检查并修复不完整条目。它不是从某个 token 精确续传,继续回答仍然是一次新生成。
场景进入后台时,保存 checkpoint 并取消面向前台的流。轻量的会话 ID、输入草稿和可见消息 ID 可以放在场景存储;完整对话与可能包含敏感信息的 transcript 应进入应用自己的受保护持久层。
用可控的假流测试状态机
测试客户端依次发送 A、AB、ABC,验证最终文本没有重复;在首个快照前、中途和最后一个快照后分别取消;让旧请求故意迟到,确认请求 ID 屏障会拒绝它。还要验证用户上滑时不被抢回底部、冷启动不会自动重发,以及 refusal 与 context-too-large 不会进入盲目重试。
可靠的流式聊天不是“边生成边显示”这么简单。它需要统一的快照语义、单一任务所有权、合作式取消、稳定消息身份、尊重用户的滚动策略和明确的中断恢复。把这些规则放进状态机,SwiftUI 只负责渲染事实,模型或网络再慢也不会把界面带入无法解释的状态。