构建生产级AI聊天流的5个模式——顺序很重要
你见过流式AI回复吗?模型一个字一个字往外蹦,前端实时渲染,看起来像在“思考”。但如果你真这么干,慢手机秒变暖手宝,引用标号飞到天边,用户点了个“停止”你还在悄悄扣费——这些都是我踩过的坑。
上个月我发布了一个AI助手,能流式输出4000 tokens的回复到React聊天框。第一个版本直接卡死浏览器标签页;第二个版本丢了一半的引用标注;第三个版本,也就是我现在实际用的那个,依靠下面五个模式才稳下来。
这篇文章不是教程,是我在生产环境用Anthropic SDK构建流式应用时,每当新建项目就按顺序加上的五个模式。顺序很重要——跳过第一个,后面全白搭。
两个让流式体验“活着”的UX模式
流式输出很容易,但让输出看起来像“正在思考”却很难。下面两个模式承担了绝大部分工作。
逐token渲染 + 背压控制
默认的流式循环会把每个content_block_delta直接塞进状态变量。模型吐得快(慢手机上大约80 tokens/秒),渲染器就跟不上了。解决办法是小队列,配合requestAnimationFrame批量刷新,而不是每来一个事件就刷一次。
let pending = '' // 暂存待渲染的文本块
for await (const ev of stream) { // 遍历流事件
if (ev.type === 'content_block_delta') {
pending += ev.delta.text // 追加新文本
schedulePaint(() => { flush(pending); pending = '' }) // 每个动画帧只调用一次
}
}
那个schedulePaint就是全部奥秘——每一帧只渲染一次,批量处理。流数据依然实时到达,但UI线程有喘息机会。我在一台Pixel 6a上测试,页面卡顿时间从4秒直接降到了0。
流式工具调用
真正让人眼前一亮的是:显示正在生成的tool_use块。Claude先流式输出工具名称,然后逐字符输出JSON参数。如果你等整个块结束再渲染,用户就得对着转圈圈看800ms。如果部分解析,200ms就能显示“Claude正在查询日历…”这样的状态。
if (ev.type === 'content_block_start' && ev.content_block.type === 'tool_use') {
showStatus(`查询中: ${ev.content_block.name}...`) // 工具名一到就更新状态
}
我在每个涉及MCP服务器的智能体上都用了这个。状态行在工具名称刚落地时立刻更新,参数还没流完。用户感知到的速度差异巨大,即使实际延迟完全一样。关于工具端更多内容,见Claude MCP服务器实践指南。
还有个精益求精的小改进:当输入JSON开始流式输出时,我还展示解析出的第一个参数键。于是状态从“查询日历”变成“查询日历:下周的行程…”,多一个词,就把转圈圈变成了让人信任的句子。
没人讨论的引用模式
当你开启带引用的回答时,SDK会把引用块交错在文本中流出来。天真的渲染器会先显示文本,在结尾才给引用标记,结果是一大段不可验证的文字后面跟着没人看的参考文献。
更好的做法是:引用到达时立即内联渲染,先用占位符上标,等引用块实际到达时再替换。
if (ev.type === 'citations_delta') {
const ref = registerCitation(ev.citation) // 注册引用
appendInline(`[${ref.id}]`) // 在原文位置插入上标
}
这么干有两个理由。第一,可读性高——脚注紧挨着它所支持的论点。第二,用户看到证据在累积,长流式输出中信任感更强。我给一个研究工具加了这个功能,平均会话时长从90秒暴涨到将近4分钟。人们会往回滚动验证引用,前提是引用找得到。
有个实现细节坑过我:引用的delta可能比它引用的文本稍微早到或晚到(取决于模型怎么发射)。我维护了一个小队列,按字符偏移量做键,等两部分都到齐后才解析占位符。没有这个队列,上标就会指向错误的句子,那还不如没有引用。
如果你要在一个同时有语音的聊天中接入引用,同样的模式也能用于ElevenLabs语音输出。先排队句子,解析引用,再把清理后的文本发给TTS。语音用户希望引用在论点之后被读出,而不是在句子中间被强塞进音频。
省钱模式:中途取消
这个模式用一周就能自己“回本”。
每个生产环境的聊天都需要一个“停止”按钮。没有正确的取消,用户点停止只是隐藏了UI,模型还在后台继续生成,你依然要为永远不显示的token付费。以Sonnet定价计算,4000 token的回复,一个月下来因为无聊用户反复停止而浪费的钱相当可观。
AbortController就是杠杆。SDK接受一个signal,当abort时,上游HTTP连接会在下一个chunk边界处关闭。
const ac = new AbortController() // 创建取消控制器
stopButton.onclick = () => ac.abort() // 点击停止按钮触发
const stream = client.messages.stream({ ... }, { signal: ac.signal }) // 传给SDK
陷阱在善后处理。当signal触发时,你需要最后刷新一次待处理队列,把这条消息标记为“已取消”并存到存储里。我第一次忘了持久化,结果半成品消息在刷新后消失,用户会以为这是个bug,尽管技术上它只是被取消的流。
数字很干净。在我运行的几个智能体上,平均取消发生在流式输出的12%处。所以一个点击停止的用户只花费了完整回复的12%,而不是100%。成千上万次会话下来,这比任何缓存技巧都省钱。如果想叠加省钱,配合提示缓存一起用,我在1小时缓存四月更新里写过。
还有一个微妙点:不要在异步迭代器内用抛异常的方式取消,因为某些SDK版本会吞掉部分队列。直接用signal取消,让迭代器正常退出,然后再刷新。
另外值得做的是:告诉用户你取消了什么。用浅灰色标签显示“已停止,247个token”,让人感觉专业。隐藏取消状态把回复变成空白,则像是bug。同样的技术,截然相反的体验。
可靠模式:让我栽过跟头的那个
重放安全的持久化。这个模式是我在部署中间,一个用户的3000 token回复已经渲染了18秒,突然被中断后硬学到的教训。
解决办法:把流式结果按每N个token或每M毫秒(哪个先到就哪个)分块写入持久存储。重连时,立即重放已有的分块,如果请求还在存活,则从模型的下一个token恢复流式输出;如果请求已死,就把部分回复当作最终结果展示。
let queued = '' // 当前待持久化的块
stream.onChange(text => {
queued += text
if (queued.length > 200) { // 累积超过200字符就写入
db.appendChunk(messageId, queued)
queued = ''
}
})
我在客户端写IndexedDB,服务器端写Postgres,镜像存储。客户端写入是即时的,服务器端每500ms批量写入一次。刷新时,客户端先读取自己的日志,一帧内渲染出消息,然后再问服务器流是否还能继续。
这个模式消灭了“刚才保存了吗”的焦虑。用户重新加载,答案已经在,每个token都在。他们永远不会知道连接断裂了。这跟好编辑器几十年来建立的信任契约一样,也是AI聊天要摆脱“测试版”感觉必须跨越的门槛。
需要注意重复写入。如果客户端和服务器独立持久化,再分别回放,同一个token可能出现两次。我用 (messageId, byteOffset) 作为分块主键,读取时去重。字节偏移量比块索引更重要,因为不稳定的网络重试时可能用一个不同的序号发送相同的内容范围。信偏移量,别信计数器。
关于在生产环境中运行SDK应用的更多模式,我写在Claude Agent SDK生产笔记里。
总结
流式输出,是AI应用让人觉得有生命力还是像在填表单的分水岭。五个模式让我做到了:背压让慢手机不卡顿;部分工具调用解析让智能体看起来有意图;内联引用建立信任;AbortController真金白银省钱;分块持久化让刷新无感。
它们都不复杂,但就是这些无聊的基础设施,让Claude应用的有趣部分看起来像是完成了。我在任何新智能体项目的第一天就加上这五个模式,早于操心提示词、检索或评估——因为UX成本出错了,任何模型升级都救不回来。
如果你想看我在SDK应用、MCP服务器、提示缓存和智能体循环中使用的全部模式,可以收藏在Claude蓝图页面。先从两个UX模式开始,其他的会在你需要的时候告诉你。
本文包含联盟营销链接。如果你通过这些链接注册,我可能会获得少量佣金,不会增加你的费用。
