基于 fetch + ReadableStream 流式输出 实现 AI 聊天问答

🤖 作者简介:水煮白菜王,一个web开发工程师 👻
👀 文章专栏: 前端专栏 ,记录一下平时在博客写作中,总结出的一些开发技巧、记录和知识归纳总结✍。
感谢支持💕💕💕
目录
在构建 AI 聊天问答功能时,通常采用 Server-Sent Events(SSE)进行流式数据推送。然而,标准的 EventSource 接口不支持 POST 请求,仅允许使用 GET 方法发起连接。考虑到需要传递复杂参数或大量请求体的场景,可以使用 fetch 结合 ReadableStream 来模拟 SSE 的流式输出行为。
可访问 测试地址 提前体验实际效果 😊
下面将详细介绍如何通过 fetch + stream 实现一个兼容 POST 请求的流式问答接口对接。
✅ 使用 fetch 获取流式响应并解析
⏳ 代码重点分析
- 使用 fetch 发起请求,获取响应对象 response。
- 通过 response.body.getReader() 获取流式数据读取器 ReadableStreamDefaultReader。
- 读取并解析流式数据:读取、解码、解析、输出更新
// 请求问答流式输出
async getFetchStream() {
// 初始化聊天内容占位符
this.talkList.push({ id: Date.now(), userType: 0, content: "..." });
const responseIndex = this.talkList.length - 1;
try {
// 构造请求参数
const url = "https://models.csdn.net/v1/chat/completions";
const API_KEY = "sk-yeaxrzuqufgtkqnvmdzszrsahlaaiyfxmezciiugk";
const headers = {
Authorization: `Bearer ${API_KEY}`,
"Content-Type": "application/json",
};
const params = {
model: `Deepseek-V3`,
messages: [
{
role: "user",
content: this.content,
},
],
stream: true,
};
// fetch发起请求
const response = await fetch(url, {
method: "POST",
headers: headers,
body: JSON.stringify(params),
});
// 检查响应状态
if (!response.ok || response.status != 200) {
this.talkLoading = false;
this.talkList[responseIndex].content = this.chatDesc.responseError;
return;
}
// 获取响应体的读取器
const reader = response.body.getReader();
// 解码
const decoder = new TextDecoder("utf-8");
let done = false;
let buffer = ""; // 缓冲文本
this.talkList[responseIndex].content = ""; // 清除占位符内容
while (!done) {
const { value, done: doneReading } = await reader.read();
done = doneReading;
buffer += decoder.decode(value, { stream: !done });
let start;
// 解析 JSON
while ((start = buffer.indexOf("data:")) !== -1) {
const end = buffer.indexOf("\n", start);
if (end === -1) break;
const chunk = buffer.substring(start + 5, end).trim();
buffer = buffer.substring(end + 1);
console.log(JSON.parse(chunk));
// 内容输出
try {
const data = JSON.parse(chunk);
// 提取content内容
this.talkList[responseIndex].content +=
data.choices[0]?.delta?.content || "";
} catch (error) {
console.error("Failed to parse JSON:", error);
} finally {
this.talkLoading = false;
}
}
}
} catch (error) {
this.talkLoading = false;
}
},
📌不同模型的接口,返回的流式数据格式可能略有差异,这里调用的是CSDN提供 模型服务接口 ,大部分AI模型厂商返回的数据格式都是按OpenAI 标准的 API 响应格式,来兼容OpenAI 的 API 格式。
📅后端返回格式(SSE)
后端需返回如下格式的流式数据,每条消息以 data: 开头,并以换行符号 \n\n 分隔结尾。
返回格式示例:
data: {"created":1753689169,"usage":null,"model":"Deepseek-V3","service_tier":null,"id":"02175368916923595a4d016b90785da794806a35be38526786d8e","choices":[{"finish_reason":null,"delta":{"role":"assistant","function_call":null,"refusal":null,"tool_calls":null,"content":"关于"},"index":0,"logprobs":null}],"system_fingerprint":null,"object":"chat.completion.chunk"}
data: {"created":1753689169,"usage":null,"model":"Deepseek-V3","service_tier":null,"id":"02175368916923595a4d016b90785da794806a35be38526786d8e","choices":[{"finish_reason":null,"delta":{"role":"assistant","function_call":null,"refusal":null,"tool_calls":null,"content":"AI"},"index":0,"logprobs":null}],"system_fingerprint":null,"object":"chat.completion.chunk"}
⚠️注意事项:
- 响应头必须包含:Content-Type: text/event-stream
- 必须开启 flush 输出,确保浏览器能及时接收到流式数据,否则可能因缓冲机制导致延迟
🧩 对比
| 方法 | 是否支持 POST 请求 | 是否支持 SSE(Server-Sent Events) | 特点说明 |
|---|---|---|---|
EventSource(或 EventSourcePolyfill) |
❌(仅支持 GET) | ✅(原生支持) | - 原生支持 SSE - 自动重连机制 - 无法发送请求体(不能传大量参数) - 只能使用 GET 请求 |
fetch + ReadableStream |
✅ | ✅(模拟支持) | - 使用 fetch 获取响应流- 支持 POST 请求 - 可发送请求体(适合复杂参数) - 需要手动处理消息解析、连接保持和重连逻辑 - 更加灵活,适用于复杂控制场景 |
🧠 总结
通过 fetch + ReadableStream 的方式,我们可以在不依赖原生 EventSource 的前提下,实现对 AI 聊天模型服务的流式对接。这种方式不仅支持 POST 请求,还能处理复杂的认证和参数传递需求,适用于现代 Web 应用中对实时性和交互性的高要求场景。
📅 代码示例

<template>
<div class="main" id="main">
<div class="chat-main">
<div class="chat-content w100 h100">
<!-- 问答主体talk -->
<div class="chat-content-list w100" ref="chatContentList">
<div
:class="['talk', item.userType == 0 ? 'aiTalk' : 'userTalk']"
v-for="(item, index) in talkList"
:key="index"
>
<span v-if="item.userType == 0" v-html="mdRender(item.content)"></span>
<span v-if="item.userType == 1">{{ item.content }}</span>
</div>
</div>
<!-- -->
<div class="chat-input w100">
<el-form @submit.prevent="getQuestion" class="w100" style="margin: 6px auto 0;width: 57%;">
<el-input
placeholder="请输入内容"
v-model="contentVal"
size="small">
<i slot="suffix" class="el-input__icon el-icon-position" style="cursor: pointer;" @click="getQuestion"></i>
</el-input>
</el-form>
<div class="chat-remark">{{ chatDesc.remark }}</div>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
name: "aiChat",
data() {
return {
chatDesc: {
talkPadding: "回答中",
inputTip: "请输入你的问题",
placeholder:
'来说点什么吧...(试试输入"周报策划"、"自我介绍"或"tag😊"; 挑选优质提示词模版)',
MarkdownRead: "Markdown解析器出错! ",
streamLoading: "内容输出中! 请稍等...",
inputNull: "输入内容不能为空",
allError: "无法获取有效内容,请重新尝试!",
responseError: "会话出现问题,请重试!",
remark:
"此对话生成的所有内容均由人工智能模型生成,其生成内容的准确性和完整性无法保证,不代表我们的态度或观点",
},
talkLoading: false,
talkList: [], // 对话列表
contentVal: "", // 输入内容
content: "", // 对话内容
isFocused: false, // 输入框是否获取焦点
};
},
computed: {},
updated() {
this.scrollToB();
},
created() {},
mounted() {},
beforeDestroy() {},
methods: {
// 解析markdown文本 需要引入markdown插件
mdRender(textObj) {
const text = textObj.toString();
// 如果 markdownit 未加载,返回原文
if (!window.markdownit()) {
console.warn(this.chatDesc.MarkdownError);
return text;
}
// 尝试解析 Markdown
try {
return window.markdownit().render(text);
} catch (error) {
console.error(`${this.chatDesc.MarkdownRead}`, error);
return text;
}
},
// 会话区域滚动
scrollToB() {
this.$nextTick(() => {
let box = this.$el.querySelector(".chat-content-list");
if (box) {
box.scrollTo({
top: box.scrollHeight,
behavior: "smooth", // 平滑滚动
});
}
});
},
// 发起提问
getQuestion() {
this.content = this.contentVal.trim();
if (!this.content) {
this.$message.error(this.chatDesc.inputNull);
return;
}
this.talkList.push({
id: Date.now(),
userType: 1,
content: this.content,
});
this.talkLoading = true;
this.getFetchStream();
this.contentVal = "";
},
// 请求问答流式输出
async getFetchStream() {
// 初始聊天内容占位符
this.talkList.push({ id: Date.now(), userType: 0, content: "..." });
const responseIndex = this.talkList.length - 1;
try {
// 构造请求参数
const url = "https://models.csdn.net/v1/chat/completions";
const API_KEY = "sk-yeaxrzuqufgtkqnvmdzszrsahlaaiyfxmezciiugk";
const headers = {
Authorization: `Bearer ${API_KEY}`,
"Content-Type": "application/json",
};
const params = {
model: `Deepseek-V3`,
messages: [
{
role: "user",
content: this.content,
},
],
stream: true,
};
// fetch发起请求
const response = await fetch(url, {
method: "POST",
headers: headers,
body: JSON.stringify(params),
});
if (!response.ok || response.status != 200) {
this.talkLoading = false;
this.talkList[responseIndex].content = this.chatDesc.responseError;
return;
}
// 获取响应体的读取器
const reader = response.body.getReader();
// 解码
const decoder = new TextDecoder("utf-8");
let done = false;
// 缓冲文本
let buffer = "";
this.talkList[responseIndex].content = ""; // 清除占位符内容
while (!done) {
const { value, done: doneReading } = await reader.read();
done = doneReading;
buffer += decoder.decode(value, { stream: !done });
let start;
// 解析 JSON
while ((start = buffer.indexOf("data:")) !== -1) {
const end = buffer.indexOf("\n", start);
if (end === -1) break;
const chunk = buffer.substring(start + 5, end).trim();
buffer = buffer.substring(end + 1);
console.log(JSON.parse(chunk));
// 内容输出
try {
const data = JSON.parse(chunk);
// 提取content内容
this.talkList[responseIndex].content += data.choices[0]?.delta?.content || "";
} catch (error) {
console.error("Failed to parse JSON:", error);
} finally {
this.talkLoading = false;
}
}
}
} catch (error) {
this.talkLoading = false;
}
},
// 延迟函数
delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
},
},
};
</script>
<style lang="scss" scoped>
.w100 {
width: 100%;
}
.h100 {
height: 100%;
}
.text-align-left {
text-align: left;
}
.text-align-center {
text-align: center;
}
.text-align-right {
text-align: right;
}
$border-color: #f0f0f0;
$border-radius: 15px;
.main {
width: 100vw;
height: 100vh;
background: #fff;
overflow: hidden;
}
.chat-main {
width: calc(100% - 20px);
height: calc(100% - 20px);
border: 1px solid $border-color;
border-radius: $border-radius;
overflow: hidden;
position: relative;
margin: 10px;
}
.chat-content {
position: relative;
margin: 0 auto;
font-size: 14px;
flex: 1;
z-index: 1000;
display: flex;
flex-direction: column;
}
.chat-content-list {
flex: 1;
margin: 0 auto 10px;
overflow: hidden;
overflow-y: scroll;
transition: height 0.2s ease;
width: 60%;
}
.talk {
display: flex;
margin: 10px 10px 25px 10px;
font-size: 15px;
transition: opacity 0.2s ease;
}
.aiTalk span {
max-width: calc(100% - 20px);
display: inline-block;
background: white;
border-radius: $border-radius;
padding: 0 15px;
border: 1px solid $border-color;
border-top-left-radius: 2px;
word-break: break-all;
text-align: left;
}
.userTalk {
display: flex;
flex-direction: row-reverse;
margin: 10px 10px 10px 10px;
}
.userTalk span {
display: inline-block;
border-radius: $border-radius;
border-top-right-radius: 2px;
background: #eff6ff;
padding: 15px;
word-break: break-all;
text-align: left;
}
.chat-input {
position: relative;
z-index: 1000;
margin: 10px auto 0;
z-index: 1001;
transition: height 0.2s ease;
}
.chat-remark {
width: 80%;
font-size: 11px;
margin: 10px auto;
color: #a3a3a3;
text-align: center;
letter-spacing: 0px;
}
</style>
✅ 这段代码主要实现了通过 fetch + ReadableStream 模拟 SSE 流式输出 的基础逻辑,用于支持 AI 聊天问答的实时流式响应。虽然已具备基本的流式接收与渲染能力,但整体实现仍较为简单,缺少中断请求、自动重连、详细的错误处理机制增强功能,在实际生产环境中需进一步完善。
⏳ 该输入框使用的是标准的 <input> 组件,适用于仅需支持基础文本输入的场景,实现方式简洁且易于维护。但如果需要支持 富文本格式、内容高亮、@提及、代码块插入 等高级功能,则更适合采用 contenteditable="true" 的 <div> 或者 <textarea> 元素,它在内容表现力和交互灵活性方面更具优势。
🧪 补充:自定义可编辑输入框实现如下:
<!-- 自定义输入框 -->
<div
@keydown.enter.prevent="handleEnter"
class="chat-input-box"
contenteditable="true"
ref="contentEditable"
:placeholder="contentVal ? '' : chatDesc.placeholder"
enterkeyhint="send"
v-contenteditable="contentVal"
:disabled="talkLoading"
tabindex="0"
@focus="isFocused = true"
@blur="isFocused = false"
autocomplete="off"
spellcheck="false"
></div>
📌 效果示例:

然而,由于contenteditable 元素本身不支持 Vue 的 v-model 双向绑定机制,因此需要通过自定义指令 v-contenteditable 来实现 DOM 与 Vue 数据的双向同步,具体实现方式要看项目框架来做对应策略。
directives: {
// 输入框值绑定指令
// 实现 contenteditable 属性元素与 Vue 数据的双向绑定。当用户编辑内容时自动更新数据,当数据变化时自动更新DOM内容。
// 解决 DIV 在 contenteditable="true" 模式下输入内容能够正确更新
contenteditable: {
// 初始化绑定
bind(el, binding, vnode) {
// el: DOM元素,binding: 绑定对象,vnode: 虚拟节点
el.innerHTML = binding.value; // 将初始值设置到可编辑元素
el.addEventListener("input", () => {
// 监听输入事件
vnode.context[binding.expression] = el.innerHTML; // 更新Vue实例数据
});
},
// 数据更新时触发
update(el, binding) {
if (binding.value !== el.innerHTML) {
// 防止无限循环更新
el.innerHTML = binding.value; // 同步Vue数据到DOM
}
},
},
✅ 最终优化效果示例
可访问 测试地址 体验实际效果 😊
其中对输入框进行了优化,支持内容高亮与代码块插入功能,结合 DeepSeek R1 模型进行智能思考回复,同时实现了代码语法高亮(Highlight),并提供了快速模板提问等实用功能,整体交互体验更加流畅与高效。

🌟感谢阅读,如果你在阅读过程中发现任何问题,或有改进建议,也欢迎在评论区指出,我会及时修正并持续优化内容。
如果你觉得这篇文章对你有帮助,请点赞 👍、收藏 👏 并关注我!👀

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐



所有评论(0)