中断、重试与恢复
中断、重试与恢复 · 阅读后完成下方实验与检查题,再用「标记完成」记录进度。
学习目标
实现「真正」的停止——从按钮一路断到上游模型连接;把失败分类成可解释的重试;处理断网、上游超时与半截消息的保存。
问题场景
你给聊天加了「停止」按钮:点击后界面停了,但服务端日志里模型还在继续生成、token 照扣;你又加了「重试」,结果同一条用户消息在会话里出现两次。停止没停到根上,重试重到了库里。
核心解释
停止是一条 abort 链,断在中间等于没断:
// 前端:一层 AbortController 传导到服务端
const controller = new AbortController();
fetch("/api/ai/chat", { method: "POST", body, signal: controller.signal }).catch(
(e) => {
if (e.name === "AbortError") markCancelled(); // 主动停止不是失败
}
);
// 服务端:把客户端断开传导给上游(本站 app/api/ai/chat/route.ts 的真实写法)
const upstreamController = new AbortController();
req.signal.addEventListener("abort", () => upstreamController.abort());
await streamChatCompletion({ messages, signal: upstreamController.signal }, cb);
// streamChatCompletion 把 signal 传给上游 fetch(lib/ai/adapter.ts):
// 客户端一断,模型连接即断,生成停止、不再计费
失败要分类,重试才有解释。本站 AiError 的分类与处置:
| 类别 | 症状 | 给用户的解释与动作 |
|---|---|---|
unreachable |
连不上上游 | 「网络或服务不可达」+ 重试按钮 |
rate_limit |
上游 429 | 「请求太频繁,稍等再试」+ 重试按钮 |
quota |
上游 402 | 「额度不足」,不重试,去检查配置 |
not_configured |
401/403 或缺配置 | 「检查 AI 配置」,跳设置 |
cancelled |
用户主动停 | 静默,保留半截内容 |
function describeError(category: string): { text: string; canRetry: boolean } {
const map: Record<string, { text: string; canRetry: boolean }> = {
unreachable: { text: "网络或服务暂时不可达,可重试", canRetry: true },
rate_limit: { text: "请求太频繁,稍等再试", canRetry: true },
quota: { text: "额度不足,请检查 AI 配置", canRetry: false },
not_configured: { text: "AI 服务未配置,请先填好密钥", canRetry: false },
cancelled: { text: "已停止", canRetry: false },
};
return map[category] ?? { text: "出了点问题,可重试", canRetry: true };
}
部分消息保存:已收到的半截文本不是垃圾。本站的处理是——正文非空就落库,status 标 cancelled 或 error;只有一字未出才删除占位。重试必须幂等:retry 请求不带 message,服务端不重复写用户消息,只清掉最后一条未完成的 assistant 再重新生成。
示例拆解
重试的服务端分支(本站 route.ts 的简化):
if (message && !retry) {
insertUserMessage(convId, message); // 正常发送才写用户消息
} else if (retry && !message) {
deleteUnfinishedAssistant(convId); // 只清「最后一条用户消息之后」的残留回复
}
// 之后重新走一次生成流程,占位消息重新建立
关键点:
- 清残留要按「最后一条用户消息之后」定位,不能一刀切删整个会话的 assistant 消息。
- 重试按钮只在
canRetry的错误态出现;cancelled 不给「重试」(那是重新生成,语义不同)。 - 超时是断网之外的另一种「不可达」:给上游调用加
AbortSignal.timeout(本站连接测试用 20 秒),TimeoutError归类到unreachable。 - 判断主动停止用
e.name === "AbortError",它和TimeoutError都继承自 abort 机制,但语义完全不同,先分流再进错误分类。
动手任务
- 实现停止按钮:点停止后在 Network 面板确认请求变 canceled,服务端控制台确认生成循环立刻退出。
- DevTools 切 Offline 复现断网:确认错误分类是 unreachable、界面给出重试按钮;恢复网络后点重试,确认成功且用户消息没有写两条。
- 在流式输出一半时点停止,刷新页面,观察半截消息的显示与状态(它的落库收尾在 A13 课完成)。
常见错误
- 只 abort 前端 fetch:模型继续生成继续扣费,「停止」只是把眼睛蒙上。必须传导到上游 fetch 的 signal。
- 把 AbortError 一律当失败弹红:用户主动停止报错误,吓人且没意义。
- 重试把用户消息再写一遍:聊天记录出现重复问题,发给模型的历史也重复。
- 无差别重试:quota、not_configured 也挂重试按钮,点了只会再失败一次,用户更困惑。
检查与复盘
自查清单:
- 能画出 abort 链:按钮 → fetch →
req.signal→ 上游 fetch - 每类错误都有对应文案与动作,cancelled 静默且保留半截内容
- 重试幂等:同一问题在库里只有一条
参考资料
- MDN:AbortController —— abort 链的机制与 AbortError
- MDN:AbortSignal.timeout() —— 上游超时的标准写法
- OpenAI 文档:Error codes —— 上游错误码分类的参照(具体类别以所用提供方为准)
- 下一课 A13 讲这些消息怎么落库、怎么做到不重不漏。
读完这一课了?
标记完成后,工作台会安排接下来的学习。