先把结论甩前面:Vue3 里封装一个能用的 AI 流式对话组件,核心就三件事------消息流要能一个字一个字往外冒、loading 状态别卡死、用户点"停"得真能掐断那条还在吐字的请求。下面是我自己撸的一版,代码为主,踩的坑也一并写了。
起因:那个一次性蹦出整段话的对话框
上个月接了个内部小工具,要在后台塞一个问答框。第一版我图省事,fetch 拿完整 response 再 JSON.parse 渲染。结果产品同学点完发送,盯着转圈的菊花等了快四秒,啥都没有,然后"啪"一整段五百字直接糊脸上。
她原话:"这跟 ChatGPT 那种字儿一点点出来的差太远了,看着像卡了。"
行吧。流式。
组件想清楚再写
我把它拆成一个 useChat 的组合式函数 + 一个纯展示的 <ChatWindow>。逻辑全塞 hook,组件只管渲染,后面换 UI 库也不用动核心。
要 hold 住的状态就这么几个:
| 状态 | 干嘛的 |
|---|---|
messages |
整条对话列表,角色 + 内容 |
loading |
请求发出去了但第一个字还没回来 |
streaming |
字正在往外冒的阶段 |
abort |
一个能掐断当前请求的方法 |
关键区分:loading 和 streaming 不是一回事。第一个 token 没到之前是 loading(转菊花),token 开始流了就切 streaming(显光标),不然用户会觉得"怎么菊花转着转着又出字了",别扭。
核心:useChat
后端走的是 SSE 那套,text/event-stream,一行一行 data: 推。我没用 EventSource(它不支持 POST,带不了 body),直接 fetch + ReadableStream 手撸:
php
import { ref } from 'vue'
export function useChat(apiUrl) {
const messages = ref([])
const loading = ref(false)
const streaming = ref(false)
let controller = null
async function send(text) {
messages.value.push({ role: 'user', content: text })
const reply = reactive({ role: 'assistant', content: '' })
messages.value.push(reply)
loading.value = true
controller = new AbortController()
try {
const res = await fetch(apiUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages: messages.value.slice(0, -1) }),
signal: controller.signal,
})
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
loading.value = false
streaming.value = true
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() // 最后一行可能是半截,留着
for (const line of lines) {
if (!line.startsWith('data:')) continue
const data = line.slice(5).trim()
if (data === '[DONE]') break
reply.content += JSON.parse(data).delta
}
}
} catch (e) {
if (e.name !== 'AbortError') reply.content += '\n[出错了]'
} finally {
loading.value = false
streaming.value = false
}
}
function abort() {
controller?.abort()
}
return { messages, loading, streaming, send, abort }
}
两个坑我栽过,直接说:
- TextDecoder 必须带
{ stream: true }。中文是多字节,一个 chunk 切下来很可能把"好"字劈成两半,不带这个参数解出来就是乱码方块。我一开始全是�,查了半天。 buffer.split('\n')后那个pop()别省。SSE 一行没传完就分包了,直接 parse 半截 JSON 必崩。最后一截缓存住,下轮拼上。
用起来
xml
<script setup>
import { useChat } from './useChat'
const { messages, loading, streaming, send, abort } = useChat('/api/chat')
const input = ref('')
function onSend() {
if (!input.value.trim()) return
send(input.value)
input.value = ''
}
</script>
<template>
<div class="chat">
<div v-for="(m, i) in messages" :key="i" :class="m.role">
{{ m.content }}<span v-if="streaming && i === messages.length - 1" class="cursor">▍</span>
</div>
<div v-if="loading" class="dots">思考中...</div>
<input v-model="input" @keyup.enter="onSend" />
<button v-if="streaming" @click="abort">停止</button>
<button v-else @click="onSend">发送</button>
</div>
</template>
中断那个 abort 我特意试了下:让它写一篇八百字的东西,写到一半点"停",光标当场停住,后端连接也断了(network 面板里请求变红 canceled)。爽。早期版本我没传 signal,点了停前端不显示了,后端还在吭哧吭哧烧 token,白花钱。
后端这块我偷了个懒
组件归组件,后面那个真正答话的脑子我没自己搭。我拿一个零代码就能配 AI 小助手的平台,拖了拖、挂了个现成大模型、把几篇产品文档喂进去做了知识库,十几分钟产出一个能问答的智能体,直接发布成 API。前端 apiUrl 一填完事,我一行模型推理代码没写。
说实话第一版那助手答得特别干,问啥都端着官腔,后来我在配置里加了几句人设提示词才正常点。零代码不等于零调试,知识库切片大小、召回数量这些还是得自己试,踩了两三回才顺。但比起我自己搭推理服务、租卡、写 RAG,真省太多事了------它就是个干杂活的,把脏活包了。
流式这套封装好之后,我现在新项目直接 copy useChat,改个 url 就能跑。
你们做流式对话踩过最坑的是啥?我赌一半人栽在中文乱码上,评论区认领下。
(模型这块我直接调的讯飞 MaaS,现成 API,没自己部署算力)