在涉密内网项目中,需要对接内部自研AI Agent,实现对话问答能力。环境比较特殊:不能访问外网,无法引入第三方成熟AI对话组件,SSE流式返回、会话上下文管理、MarkDown与代码块渲染、请求中断、异常降级全部需要自己从零封装。最终封装出通用对话组件,供内部多个业务页面复用,业务开发效率提升30%。本文梳理开发全流程、核心实现思路以及生产环境踩坑,适合想自己实现流式AI对话的前端同学参考。
技术栈:Vue3+TypeScript+Fetch ReadableStream+marke
需求梳理
- 会话上下文管理:多轮对话记忆,携带历史消息请求Agent接口;上下文做膨胀防护,避免请求体过大
- SSE流式返回处理:后端分片输出文本,前端实现打字机实时输出效果;解决二进制分片中文乱码问题
- Markdown+代码块渲染:实时渲染Markdown内容,处理流式下代码块标签未闭合的样式异常
- 请求手动中断:支持用户点击停止生成;组件销毁自动关闭流,杜绝内存泄漏。
- 异常降级策略:网络错误、服务报错、流断开,保留已有输出内容,友好提示,不破坏整个页面
- 组件复用:封装通用组件,业务页面直接传入绘画参数即可使用。
为什么放弃EventSource?
EventSource只支持GET请求,如果把会话上下文放在URL参数中,多轮对话之后参数过长会触发URL长度限制。因此项目选择Fetch+ReadableStreamPOST的方案传输会话数据。
一、会话上下文管理
会话上下文本质维护一组消息列表,每条消息标记角色和内容,每次请求把完整历史传给后端Agent,实现多轮对话记忆。
javascript
// 消息类型定义
interface ChatMessage {
role: 'user' | 'assistant'
content: string
}
// 当前会话上下文
const chatContext = ref<ChatMessage[]>([])
// 流式输出临时文本
const streamText = ref('')
// 上下文截断,防止消息过多请求体膨胀
function trimContext(context: ChatMessage[], maxCount = 10): ChatMessage[] {
if (context.length <= maxCount) return [...context]
// 保留最新maxCount条消息
return context.slice(-maxCount)
}
// 发送提问
async function sendChat(question: string) {
// 用户消息入栈
chatContext.value.push({ role: 'user', content: question })
streamText.value = ''
// 做上下文裁剪
const context = trimContext(chatContext.value)
await startStream(context)
}
关键点:
1.不能无限制保存历史会话,消息量大会造成POST请求body过大,服务端压力上升
1.切换绘画场景,直接清空chatContext.value
3.AI完整回复结束之后,将assistant消息push进上下文,共下一轮对话使用
二、Fetch ReadableStream处理SSE流式返回
后端SSE接口返回二进制流,前端循环读取流的分片,拼接文本
这里最大坑:二进制分片可能把一个多字节中文拆分成两段,直接解码会出现乱码
解决方案:借助TextDecoder的steam模式,外加butter缓存未完成的片段
javascript
let reader: ReadableStreamDefaultReader<Uint8Array> | null = null
async function startStream(context: ChatMessage[]) {
// 如果上一个流还在运行,先关闭,避免并发多个请求
closeStream()
const res = await fetch('/api/agent/stream', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ context })
})
if (!res.ok) {
handleError(`服务异常,状态码:${res.status}`)
return
}
if (!res.body) {
handleError('数据流获取失败')
return
}
reader = res.body.getReader()
const decoder = new TextDecoder('utf-8', { stream: true })
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) {
// 流读取完毕,把AI完整回答存入上下文
chatContext.value.push({ role: 'assistant', content: streamText.value })
closeStream()
break
}
// 解码二进制片段,未完成字节保留在buffer
buffer += decoder.decode(value, { stream: true })
// 按换行切分SSE数据行
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (const line of lines) {
if (line.startsWith('data:')) {
const chunk = line.replace('data:', '')
streamText.value += chunk
}
}
}
}
三、Markdown 与代码块渲染
流式输出过程中:、拿到的是原始Markdown字符串,使用marked库解析为HTML
坑点:流输出过程中,代码块的结束标记````还没有返回,解析会出现标签不闭合,页面样式错乱
处理策略:
- 流式过程中:只做基础Markdown解析,不执行代码高亮
- 流全部读取完成之后,再执行代码高亮逻辑
- 内网可信服务,依然增加基础的XSS防护
javascript
<template>
<div class="chat-content">
<div class="agent-response" v-html="renderMd(streamText)" />
</div>
</template>
<script setup lang="ts">
import { marked } from 'marked'
const renderMd = (text: string) => {
if (!text) return ''
return marked.parse(text) as string
}
</script>
<style scoped>
/* 简单定制代码块样式 */
:deep(pre) {
background-color: #282c34;
color: #fff;
padding:12px;
border-radius: 6px;
overflow-x: auto;
}
</style>
四、请求中断:手动停止生成+防内存泄漏
业务需要"停止生成"按钮;同时组件销毁时,流如果没有关闭,会持续后台接收数据,造成内存泄漏
javascript
// 关闭流,中断请求
function closeStream() {
if (reader) {
reader.cancel('用户主动中断')
reader = null
}
}
// 组件销毁钩子强制释放流
onUnmounted(() => {
closeStream()
})
模板中绑定停止按钮
javascript
<button @click="closeStream">停止生成</button>
踩坑:发送新一轮问答后,必须调用closeStream关闭上一个流,防止多流同时输出,造成文本错乱叠加
五、异常降级设计
异常场景: 网络断开、接口报错、流意外终止
降级原则:不丢失已经输出的内容,不摧毁整个对话页面,区分手动中断和服务报错
javascript
const errorMessage = ref('')
function handleError(msg: string) {
errorMessage.value = msg
closeStream()
// 已输出内容保留,追加异常提示
streamText.value += `\n\n> ⚠️ ${msg}`
}
UI层展示错误信息,用户可以直接重新发起提问
不要一出错就清空所有对话记录,这是面向业务系统很重要的体验细节
六、汇总开发踩坑清单
1.二进制分片乱码
直接对 Uint8Array 解码,中文多字节被分片截断。使用TextDecoder(stream:true) + buffer 缓存剩余字节解决。
2.多流并发,文本叠加错乱
用户快速连续提问,上一个流还未结束,新流又发起。每次发起请求前关闭旧流。
3.流式代码块标签未闭合
打字机输出阶段,``` 结束标记还没返回,marked 解析出来 HTML 标签残缺。流式阶段只渲染基础结构,流结束后再做完整高亮。
4.内存泄漏
页面切换组件销毁,忘记 cancel 可读流,后台依然在接收数据,内存持续上涨。onUnmounted钩子强制关闭流。
5.上下文无限膨胀
多轮对话消息越来越多,请求体过大。增加上下文截断逻辑,只保留最近 N 轮对话。
6.EventSource GET URL 长度限制
一开始使用 EventSource,上下文放在 URL 参数,多轮对话参数超长,切换 Fetch+ReadableStream POST 方案。