ARTICLE · 1105371
给小程序加了个 AI 助手,顺手补齐了流式输出的几个坑
前段时间在小白源码站这个小程序里,加了一个 AI 助手。
它不是那种“嵌个第三方网页进来”的做法——就是一个抽屉,挂在几个页面右下角,点开就能问。
这篇文章不讲它能干什么(那是产品说明书的事),只讲它是怎么做出来的。做的时候踩的坑,比功能本身有意思得多。
PART 01
一个抽屉,六个入口
设计上先定了一件事:不做「AI 页面」,做一个抽屉。
理由很实际——如果做成独立页面,用户就得先离开正在看的东西,切过去问,问完再切回来。刷题刷到一半想问一句“这题考什么知识点”,切页是最打断思路的。
所以最后是两样东西:一个可复用的抽屉组件,加一个只负责开关和拖动的悬浮球。每个页面接入只要一行:
抽屉本身内置在悬浮球组件里,页面不用管。目前悬浮球装在六个页面上(首页、快速刷题、在线刷题、错题本、积分兑换、资源列表),搜索页单独挂了一份抽屉实例,首页宫格和「我的」页也各有入口。
悬浮球上有个细节:位置全站共享。它只能沿右侧上下拖(横向固定),纵向位置存进本地缓存,所有页面读同一个 key——在首页拖到哪里,切到刷题页还在哪里。实现上就是一行本地存储,但体验差别很大。
另外,小程序在 touchend 之后还会补发一次 click,不处理的话就是“拖一下就弹开抽屉”。所以记一个 4px 的位移阈值,超过就判定为拖动,抬手不再触发点击。

「小白 AI」抽屉 · 打开就能问,下面列着它能做的三件事
PART 02
流式两关:nginx 的缓冲,和半个汉字
「打字机」效果——一个字一个字往外蹦——是这类功能的观感底线。做起来要过两关。
第一关:本地永远是对的,生产永远不对。
后端走 SSE(Server-Sent Events)逐块下发。但线上链路是:小程序 → nginx 网关 → 宿主服务 → 内网穿透 → 后端。nginx 默认开着 proxy_buffering,它会把上游响应攒满缓冲区再整块发出去。
结果就是:后端老老实实一个字一个字 flush,用户端却要干等十几秒,然后“唰”一下看到全文。
最容易误判的地方是——本地直连后端端口时一切正常。这个问题只在生产暴露,第一次遇到很容易怀疑是自己代码写错了。
两处一起改:nginx 那边对 /app/ai/ 关掉缓冲;后端这边下发一个 X-Accel-Buffering: no,nginx 见到就自动关掉这条响应的缓冲。
这里还有一个坑:后端下发响应头,不能直接 response.setHeader()。因为 SSE 走的是 Servlet 异步分发,在方法体里设的头会被异步处理覆盖掉——实测响应里根本没有这两个头。必须包成 ResponseEntity 返回:
第二关:一个汉字,被网络切成了两半。
网络分片是按字节切的,不按字符。一个汉字在 UTF-8 里占 3 个字节,切点极有可能正好落在汉字中间——这时候直接解码,你 就会变成 ä½ 。
这也不是偶发,只要回答够长就必然发生。所以流式客户端必须有一个有状态的解码器:每块结尾那半截不完整的字节不要丢,留着和下一块拼起来再解。
做法是:把「上一块的残留 + 本块」拼起来,从尾部往前退到最后一个字符起始字节(UTF-8 里后续字节都是 10xxxxxx,往前退到不是它的那个就是起始字节),再看这个字符凑满没有——没凑满就整段留到下一块。
还有一个同源的坑:SSE 的帧结构(event: delta / data: {...} / 空行结束)也会被切断,所以行缓冲同样省不掉。字节解码 + 帧解析两块拼一起,客户端才算完整。

提问“出一道 HashMap 面试题” · 代码块边生成边成形,右上角可一键复制
PART 03
手机不肯给分块,那就自己造一个泵
上面这套方案听着挺完整,但真机上还是“唰”一下全出来了。
两个原因,都真实存在。
一是平台侧可能把分块攒一段再给。微信开发者工具里分块接收经常不灵,真机上也可能一口气给一大块。这时候所有增量在同一批回调里到达,怎么写都是一次性上屏。
二是一开始判断分块能力的逻辑太保守——拿不到基础库版本号就当成“不支持”,直接退回非流式接口(整包返回)。
所以做了两个改动。
第一个:判断改成“拿不准就放行”。拿不到版本号时返回“支持”,而不是“不支持”。因为判错的代价不对称——错判成不支持,打字机直接消失;错判成支持,最多是整段到达,有下面的泵兜着。
第二个更关键:渲染不再直接跟随网络,统一走一个“打字机泵”。
上游只管往缓冲区塞字,一个 24ms 的定时器从缓冲区往屏幕上吐。
真流式:缓冲基本是空的,每帧吐一两个字,就是自然的打字机
整包到达:缓冲一次灌满,大约 60 帧(≈1.4 秒)排空——同样是打字机,而且不会“先闪全文再回头重打”
这里有个反直觉的点,值得单独说:每帧吐多少字,必须线性算,不能按剩余量现算。
还有个配套细节:关抽屉的时候,要先把缓冲区里已经收到的字吐完,再收尾。整包场景下回答其实早就完整收到了,直接丢掉缓冲等于白扔一整段答案。
PART 04
额度:两个池子,先花会过期的
AI 是要花钱的,所以必须限量。一开始想的也是“每天给 N 次”,真做的时候才发现只有一种额度不够用——
签到、看广告、积分兑换、后台补偿,这些得到的次数如果也按“当天清零”算,用户不乐意;可反过来,每天白送的次数如果无限累积,运营成本就没有上限了。
所以分成两个池子:
池 A · 每日赠送
每天零点重置,不累积。回答的是“今天能免费用几次”。
池 B · 永久余额
积分换的、看广告得的、后台补的,都进这里,不清零。
可用次数 = max(0, 每日赠送 − 今日已用) + 永久余额,消耗顺序先扣 A 再扣 B。
道理很简单:先花会过期的。反过来先扣永久余额的话,用户会眼睁睁看着每天送的次数白白作废。
额度耗尽之后还有个取舍:AI 本来能帮忙找站内资源,但那一步也要花 token。现在的做法是额度用完后仍然保留站内检索(这一步几乎不花钱),只是不再做生成——直接给一个“带关键词跳站内搜索”的按钮,成本是 0。

「小白 AI 次数」页 · 每日赠送与永久余额分开算,三种获取方式都在这里
PART 05
一份把预算算小 100 倍的账单
这一条是整件事里最值得记下来的。
除了“每个用户几次”,还需要一条全局熔断线:当天总成本超过预算就降级——不再做完整生成,只留站内检索,免得有人半夜拿它当免费 API 刷。
成本记账要写进数据库。字段名叫 cost_cents,配置项叫 ai.quota.daily.budget.cents,注释写的也是“分”。所有人(包括我)都以为单位就是分。
直到实测才发现:写入端算出来的根本是“万分之一元”。
换算比例是 1 元 = 10000 单位。也就是说:
配置里填 500,心里想的是 5 元
实际比较时,这 500 是被当作 500 个单位 = 0.05 元用的
差了整整 100 倍
后果是:一天的预算实际只有 5 分钱,大约 30 次对话就会触发熔断。用户看到的 AI 会突然变成“深度回答暂时不可用”——离“上线就崩”只差一点点。
修法本身很简单,但值得说:把换算比例收进一个地方。在额度服务里定义两个常量,预算判断写成 budgetCents × COST_UNITS_PER_CENT,写入端引用同一个常量。之所以会错,根子就是原先写入端和比较端各写了一份比例,然后两者漂移了。
量级感受一下:一次对话大约 16.5 个单位,也就是 0.00165 元。算错 100 倍,30 次就熔断;算对了,是 3000 次。
教训 · 值得抄走
字段名、配置名、注释里写的单位,都不可信——去看写入端和比较端到底怎么算。凡是“存储用高精度、配置和展示用常用单位”的字段,中间只允许有一次换算,而且比例只能定义在一个地方。

管理端 · 调用统计把 Token 拆成缓存命中 / 未命中 / 输出三段计价
PART 06
管理端:能聊天只是最低要求
用户看得见的是一个抽屉,看不见的是后面必须有东西能管。
这块在管理端单独开了一个模块,七个页签:数据总览、调用统计、会话与日志、用户额度、额度流水、内容洞察、AI 配置。
其中几个设计,是踩过之后才定下来的。
第一,调用日志要记“被拦下来的”。 只记成功调用,只能看到“用了多少”;把那些因为额度用完、非会员、输入超长、并发超限被挡掉的请求也记下来,才能看到“多少人想用但用不了”——后者才是运营真正要看的数。
第二,埋点绝不能影响对话。 写日志这个方法内部整个包了 try-catch。它属于审计能力,不是业务能力,它挂了不能把用户的对话带垮。
第三,后台赠送次数,必须“先写流水、再加余额”。
顺序反过来的话:管理员点了两次提交,第二次撞上唯一索引被数据库挡住——但加余额那一步已经执行过了。流水没记上,钱已经送出去了,账就错了。
所以顺序是:先写额度流水(用请求 ID 当防重闸门),再加余额,最后发站内消息。请求 ID 在打开弹层时生成一次,同一次提交里复用——重复点击被数据库挡掉,重新打开则换一个新 ID(否则想连送两次反而会被误挡)。

管理端 · 数据总览:调用次数、活跃用户、总成本一屏看完

管理端 · 赠送次数进「永久余额」,原因会写进流水备注

管理端 · 调用日志(含被拦截):只有这张表看得到被挡掉的调用

管理端 · AI 配置:全部读 sys_config,改完即时生效、不用重启
PART 07
几个没做好的地方
如实列一下,免得看起来像什么都做好了。
额度用完之后 AI 只保留站内检索、不做生成。这是刻意的取舍,但用户第一次遇到会有点落差,引导文案还可以再磨。
平台不支持分块接收时会退化成“整段到达”,靠前端的泵补出打字机观感。观感接近,但严格说不是真流式。
安卓端的键盘处理是交回系统自己顶页面的,不同机型上表现还有差异。
管理端的调用日志清理任务和统计预聚合还在待办里,现在数据量不大,先这么跑着。
写在最后
这个 AI 助手就在「小白源码站」这个小程序里。本来就在用的,位置在首页和几个刷题页面的右下角;没用过的,搜小程序名就能找到。
这篇的目的不是推荐你去用它——功能好不好用,自己试两分钟就有结论。真正想记下来的是这一路上踩的坑,尤其是那个“字段叫分却不是分”,和“本地永远正常、生产永远不对”。
用着哪里不对,或者想要什么功能,可以在「我的 → 意见反馈」里告诉我。
我不是每条都能马上做,但每条都会看。
如果你觉得还不错,可以亲自体验一下
