AI 原生应用实战:网站、小程序与桌面应用
一句话定位:把一次模型调用变成用户摸得到的功能——本文给出流式 UI 的界面模式(打字机与思考态)、六个必须设计的状态、成本与限流在客户端的配合,与一个 62 行可直接照做的请求包装器。前置:AI Coding 方法论——怎么用 AI 把产品造出来在那篇;本篇讲造出来的产品怎么把 AI 用好。
先从一个「转圈圈」说起
你给自己的应用接上了 AI 功能:本地调试时提问、回答、再追问,一切顺滑,遂上线。测试全绿的按钮,是「本地开发者在好网络下点了一次」的按钮——
上线第一天,用户的反馈换了一副面孔:有人对着转圈圈的白屏等了三十秒,以为死机,直接杀掉进程;有人嫌慢连点五次发送,收到五份重复答案;赶上模型供应商抖动,用户看到的失败反馈是一整屏给开发者看的报错。同一个功能,在开发者手里顺滑、在用户手里脆弱。
差别不在模型,在界面没有为 AI 的「脾气」做设计:AI 调用比普通接口慢得多、失败是常态、随时可能被用户打断、还按用量收钱。
同一碗饭,为什么开发者端顺滑、用户端翻车?
AI 原生应用与套壳应用(只包一层界面、不为 AI 的脾气做设计)的分界线,就在六个状态的覆盖度:①加载、②失败、③取消、④降级、⑤排队、⑥超时。 本篇把六个状态逐个拆开,末尾给你一个 62 行的请求包装器与一张体检清单。
全景图:一次 AI 请求的六状态生命周期
图 1:AI 请求六状态生命周期——实线是必经主干(⑤归队后回①),虚线是条件岔路:限流岔进⑤、②失败可退避重试、重试仍败通向④降级,③取消与⑥超时是两种必须给出口的叫停
渐进拆解:六个状态,像点一份外卖
这套生命周期外卖平台早已替你设计过一遍。下单后商家迟迟不接单,平台不会让你干等——高峰期限流,先告诉你「前方排队」(⑤);商家接单了,出餐进度与骑手位置实时更新,你不用刷新(①与流式);中途改主意,随时能取消,已做的部分按规则处理(③);超时没人接单,自动退款并道歉(⑥);商家爆单关店,平台推荐同类可用的店(②通向④)。AI 请求的每一个「脾气」,外卖平台都提前给了出口——你要做的是把这套体验搬到自己的界面上。
① 加载:思考态是给「慢」的礼遇
AI 的延迟以秒计,普通接口的转圈动画在这里不够用——用户需要知道「它在想,不是死了」。首字到达前用思考态(「正在对比三个方案……」这类过程提示),首字到达后切打字机逐字渲染。
业界主流前端框架已经把这套节奏做成了标准件:Vercel 的 AI SDK 在 v5 版本里把布尔值的「加载中」换成了四档 status(submitted / streaming / ready / error),停止生成的 stop() 是一等公民,结束回调还会标记终态类型(isAbort 标用户主动中断、isDisconnect 标断连)——六个状态里框架替你内置了一部分,剩下的(排队、降级、超时文案)要自己补。
② 失败:先分诊,再说话
AI 的失败分两类:可重试的(限流 429、服务过载 529——这一秒坏了下一秒就好)与真失败的(密钥失效、内容被安全拦截)。界面上不该让用户看到报错堆栈,也不该一律说「出错了请重试」——可重试的给「再试一次」按钮,真失败的给替代路径。失败文案写给用户,报错日志留给控制台。
③ 取消:长生成必须给刹车
生成动辄十几秒,用户点错、问错、等不及都是常态。停止按钮要用请求中止机制真正掐断请求(浏览器里是 AbortController),而不是假装停止、后台继续烧 token。取消后已生成的部分有两种处理:保留并标注「未完」,或整段丢弃——按内容价值选,代码生成适合丢弃,长文写作适合保留。
④ 降级:坏了也要有东西给用户
模型挂了、额度用完了,功能不能整个消失。兜底有三档:缓存的上次结果、不依赖模型的规则版本、干脆的「功能暂不可用」卡片。关键纪律是明示——兜底结果要告诉用户「这不是 AI 生成的」,静默降级比失败更伤信任(用户拿着规则版的粗糙输出,以为是模型的退步)。
⑤ 排队:限流的产品化表达
服务端扛不住时,最先感知的应是界面上的一句「当前使用人数多,排队中」,而不是一个报错。排队的同时,服务层做带退避的自动重试(等几秒再试一次),次数到顶再把失败信号传回界面、转失败话术。这里有一条铁律:客户端绝不疯狂自动重试——每次重试都在给已经过载的服务添堵,退避与重试上限是纪律不是优化。
⑥ 超时:区分「还在转」和「已经死了」
网络会吞掉请求,供应商会静默挂起,不设超时的转圈会永远转下去。给每次请求配一个看门狗计时器,超时就主动掐断请求、停掉动画、给重试入口。看门狗在两层各有一份、分工不同:客户端的是体验兜底(掐断挂起的那次请求、停掉转圈),服务层的是决策机构(批量熔断不健康的上游,见图 2)。超时阈值按体验定:聊天类十秒上下,长任务明确告知预计时长。
成本与限流,客户端也是当事人
模型按 token 计费,客户端的每个默认值都是账单的形状:请求进行中禁用发送按钮(防连点重复计费与重复排队)、输入框提示长度上限(裁剪长文在发送前做)、排队与降级文案把限额状态如实告知。这些小设计加起来,就是把「服务端的限流策略」翻译成「用户能理解的语言」。
图 2:六状态的职责分界——服务层做重试与降级的决策,客户端做状态的呈现与配合,跨层只传状态信号与文案
实战参照:三类载体,三种坑
独立开发里最常见的三类载体,六状态的实现难度各不相同,一套常见的独立开发路线正好三类全占——一个内容网站、一个带 AI 配音与插画的小程序、一个桌面画板工具:
- 网站最顺:浏览器原生支持流式响应(SSE,Server-Sent Events,服务器单向推送),打字机与停止按钮都是标准写法,最容易漏的是取消——开发者忘了接「停止」按钮。
- 小程序的坑在通信层:请求要显式开启分块传输(
enableChunked: true),再用onChunkReceived逐块拼接,微信官方文档对此有专门页面;相比长连接方案它更轻,但断连重连要自己兜,最容易漏的是超时、取消与切后台再回来的状态恢复。 - 桌面应用长任务最多,后台跑着长生成、用户又触发新任务是常态,最容易漏的是排队。
同一套六状态,要在三套界面范式里各落一遍。
动手:62 行的六状态请求包装器
第一步:跑通四个场景。 下面这个包装器(Node 18+,零依赖)把六个状态全装进一次调用的生命周期,连注释带空行 62 行(下面两块直接相连拼成一个文件),保存为 states.js:
// 六状态请求包装器:把一次模型调用包成完整生命周期(Node 18+,零依赖)
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const log = (name, state, note) => console.log(`[${name}] ${state} —— ${note}`);
async function pause(ms, signal) { // 可被打断的等待:中止立即抛出
for (let t = 0; t < ms; t += 10) {
if (signal?.aborted) throw new Error(signal.reason);
await sleep(10);
}
}
// 模拟模型供应商:可配置故障(failWith)与分片间隔(chunkDelayMs)
async function* provider(chunks, { failWith = null, chunkDelayMs = 50, signal } = {}) {
if (failWith) { await pause(30); throw new Error(failWith); }
await pause(60, signal); // 首字延迟
for (const c of chunks) {
yield c;
await pause(chunkDelayMs, signal);
}
}
function aiCall(name, genFn, { timeoutMs = 2000 } = {}) {
const ctrl = new AbortController();
setTimeout(() => ctrl.abort("timeout"), timeoutMs); // ⑥超时看门狗
log(name, "①加载", "请求已发出,界面进思考态");
const done = (async () => {
try {
let first = true;
for await (const c of genFn(ctrl.signal)) {
if (first) { log(name, "①加载→流式", "首字到达,思考态切打字机"); first = false; }
console.log(` · 分片:「${c}」`);
}
log(name, "完成", "整段收齐,正常展示");
} catch (err) {
if (err.message === "user") log(name, "③取消", "用户点了停止:已生成部分保留,标注「未完」");
else if (err.message === "timeout") log(name, "⑥超时", "限时没等到:停转圈,提示稍后重试");
else log(name, "②失败→④降级", `主模型不可用(${err.message}):切兜底版本,明示「非 AI 结果」`);
}
})();
return { abort: (reason) => ctrl.abort(reason), done }; // ③取消的入口
}
async function withQueue(name, genFn, opts) { // ⑤排队:限流不硬刚,退避后重试
log(name, "⑤排队", "收到限流信号,界面显示「前排人多,稍等」");
await pause(80);
return aiCall(name, genFn, opts);
}
第二步:四个场景各踩一条路。 把 main 函数拼到文件末尾运行(node states.js):
const main = async () => {
const a = await withQueue("场景A", (s) => provider(["正在", "对比", "三个", "方案"], { signal: s }));
await a.done;
const b = aiCall("场景B", (s) => provider(["这答案", "可能相当长"], { signal: s }));
setTimeout(() => b.abort("user"), 80); // 用户中途点「停止」
await b.done;
const c = aiCall("场景C", (s) => provider(["思考中"], { signal: s, chunkDelayMs: 1000 }), { timeoutMs: 200 });
await c.done;
const d = aiCall("场景D", (s) => provider([], { signal: s, failWith: "529 过载" }));
await d.done;
};
main();
实跑输出(已验证):
[场景A] ⑤排队 —— 收到限流信号,界面显示「前排人多,稍等」
[场景A] ①加载 —— 请求已发出,界面进思考态
[场景A] ①加载→流式 —— 首字到达,思考态切打字机
· 分片:「正在」
· 分片:「对比」
· 分片:「三个」
· 分片:「方案」
[场景A] 完成 —— 整段收齐,正常展示
[场景B] ①加载 —— 请求已发出,界面进思考态
[场景B] ①加载→流式 —— 首字到达,思考态切打字机
· 分片:「这答案」
[场景B] ③取消 —— 用户点了停止:已生成部分保留,标注「未完」
[场景C] ①加载 —— 请求已发出,界面进思考态
[场景C] ①加载→流式 —— 首字到达,思考态切打字机
· 分片:「思考中」
[场景C] ⑥超时 —— 限时没等到:停转圈,提示稍后重试
[场景D] ①加载 —— 请求已发出,界面进思考态
[场景D] ②失败→④降级 —— 主模型不可用(529 过载):切兜底版本,明示「非 AI 结果」
对着输出读:
- 场景 A 走完了「排队→加载→流式→完成」的顺路全程;
- 场景 B 在第二分片前被用户叫停,已生成的「这答案」按「保留并标注」处理;
- 场景 C 的看门狗在 200 毫秒限时到点时掐断了还在等下文的请求——「还在转」和「已经死了」就此分了家;
- 场景 D 的主模型报 529,直接切了带明示的兜底版本——真实系统里 529 属于可重试失败,会先按⑤节的纪律退避重试、到顶才降级,此处演示的是最短路径。
把 provider 换成真的模型接口调用(分片解析照调用篇的流式规格),这套状态机原样可用。
第三步:给你的 AI 功能做一次六状态体检。 五个动作各踩一条状态路径:
- 断网或改错密钥跑一遍——看②失败的文案给的是出口还是堆栈;
- 把限流阈值临时调低跑一遍——看有没有⑤排队的提示,还是直接报错;
- 生成中途点停止——看③取消是真掐断(控制台不再有后续分片)还是假停止;
- 给网络加限速再跑——看⑥超时的看门狗会不会叫停、提示什么;
- 临时封掉主模型额度——看④降级的兜底是否明示「非 AI 结果」。
五条全绿,你的 AI 功能才算从「能跑」变成「能交付」。
边界与取舍
不是所有产品都需要六个状态
自用内部工具,排队与降级可以砍(过载了大家看得懂控制台);面向陌生用户的产品,六个都躲不掉——你不在场解释的时候,文案就是全部的服务态度。判断标准是「出错时用户能不能自己找到解释」:能,砍状态;不能,补齐。
两层职责别串门
退避重试、模型降级链、排队队列是服务层的事(容错四机构在模型调用与容错篇成套拆过);客户端只做呈现与配合。两层都做自动重试会打架——服务层正在退避,客户端又叠一层疯狂重试,雪崩就是这么来的。界线两侧只传状态信号与文案,见图 2。
最后的坏味道预警
如果你的产品里存在「永远转圈的按钮」(没有超时看门狗)、「静默消失的回答」(失败不上报、用户以为发成功了),那是缺监控与状态埋点——哪些状态被触发得最多、文案被看了多少次,是下一阶段「生产化」的原料,在生产化里展开;而模型可能被诱导产出有害内容这件事,让「失败」多了一种安全类目,在Agent 安全边界里展开。
自测
你的小程序的 AI 功能覆盖了六个状态中的几个?缺的怎么补?
先用体检清单盘一遍:断网试②失败、调低限流试⑤排队、中途点停止试③取消、限速试⑥超时、封额度试④降级,加上①加载的思考态与打字机是否都做了。小程序最常见的缺口是⑥超时与③取消——分块传输拼到一半遇上网络异常,可能既不结束也难归因,补法是给每次请求配看门狗计时器,并把请求任务对象的 abort(中断方法)接到停止按钮上;后台切前台的状态恢复靠把分片拼接状态存在页面数据里而非局部变量。缺哪个状态,就按正文对应小节的「出口」补哪个。
取消和超时都会让请求半路终止,客户端怎么区分两者、各自配合什么界面反馈?
区分靠中止时携带的原因标记:用户点停止走 abort("user"),看门狗到点走 abort("timeout")——包装器里 catch 据此分流。界面反馈不同:③取消是用户主动的,反馈要轻(已生成部分保留并标注「未完」,不打扰);⑥超时是被动的,反馈要给出路(停掉转圈、说明「等太久没等到」、给重试按钮)。混成一团的表现就是「我明明没点停止它自己停了」却没有任何解释。
为什么「失败后客户端自动疯狂重试」是反模式?限流的正确产品化表达是什么?
失败最常见的一类是限流与过载(429/529),此刻每次自动重试都在给过载的服务添堵,多个客户端同时重试就是重试风暴,可能把「抖一下」变成「彻底瘫」。正确做法是把重试决策留在服务层(带退避、有上限的容错机构),客户端只做两层配合:先出示⑤排队的排队文案(把限流翻译成用户语言),重试次数到顶再转②失败的失败话术并给手动重试入口。
参考与延伸
- useChat – AI SDK(Vercel,截至 2026-09 仍在维护):前端接入 AI 的主流框架参考,status 四档与 stop() 的官方口径
- RequestTask.onChunkReceived(微信官方文档):小程序分块传输监听的官方说明
- 下一站:Agent 安全边界——AI 功能上线后,安全这条线怎么守