AI 聊天需要流式输出——用户提问后,模型逐字生成回应的过程通过 SSE 实时推送到前端。这里记录一下前后端各做了什么优化。
一、前端 SSE 解析
前端 useSseChat composable 负责 SSE 流的消费:
1 | import { ref } from 'vue' |
设计要点
1. 双重 data: 前缀兼容。有些上游 SSE 会写出 data:data:{...},normalizeSseLine 用 while 循环递归去掉所有 data: 前缀。
2. 自定义事件。除了标准 SSE data: 行,还支持自定义事件:
event: metadata— 后端返回会话 UID、消息 UID,前端用于后续关联event: error— 流中发生错误时抛出,终止解析
3. remainder 行残留处理。SSE 流是分 chunk 到达的,\n 可能出现在 chunk 中间。用 remainder 变量缓存最后不完整的行,下个 chunk 来时拼接。
4. AbortController 取消。调用 stop() 时中止 fetch,后端检测到客户端断开后关闭上游流,释放连接资源。
二、聊天 Composable 编排
useChatConversation 在 useSseChat 之上做会话层编排:
1 | export function useChatConversation(options: UseChatConversationOptions) { |
状态管理关键点
- 两阶段写入策略:前端先写 user 消息和空 assistant 占位消息;流式过程中只更新 assistant 的内容;流结束/失败/取消时写终态状态
- reasoning_content 独立处理:深度思考模型的推理文本走独立的
onReasoningDelta回调,和正文content分离存储 - AbortError 判断:
error instanceof DOMException && error.name === 'AbortError'区分”用户主动停止”和”网络异常”
三、后端优化要点
有界线程池
1 | // AsyncConfig.java |
客户端断开时传播取消
1 | // 关键逻辑 |
归一化上游 SSE payload
不同供应商(OpenAI、DeepSeek、通义、Kimi)的 SSE 格式不完全相同。后端统一归一化为格式:
1 | data: {"choices":[{"delta":{"content":"xxx"}}]} |
前端只需要处理一种格式。
会话落库策略
1 | // ChatConversationService |
reasoning_content 保存规则
1 | // 只保存模型显式返回且允许展示的 reasoning_content |
上下文窗口裁剪
1 | // 每次构建上游请求时: |
四、不可破坏边界
- 流式格式必须兼容现有前端。不能改 SSE payload 的 JSON 结构
- 用户主动断开时不能继续无意义消耗上游资源。后端必须关闭上游 SSE 流
- token 计费仍按上游 usage 后扣费。缺少 usage 时不额外猜算
- 会话保存不能逐 token 写库。长文本输出下,逐 token 写库会放大 DB 压力
- 所有会话/消息读写必须绑定 account_id。不能仅凭前端传入的 uid 操作
当前主链路(前端 SSE 解析、后端流式代理、会话持久化)已线上验证通过,后续只做观察和小修,不做大改。