Vue3对接AI Agent: 封装流式对话组件

在涉密内网项目中,需要对接内部自研AI Agent,实现对话问答能力。环境比较特殊:不能访问外网,无法引入第三方成熟AI对话组件,SSE流式返回、会话上下文管理、MarkDown与代码块渲染、请求中断、异常降级全部需要自己从零封装。最终封装出通用对话组件,供内部多个业务页面复用,业务开发效率提升30%。本文梳理开发全流程、核心实现思路以及生产环境踩坑,适合想自己实现流式AI对话的前端同学参考。

技术栈:Vue3+TypeScript+Fetch ReadableStream+marke

需求梳理

  1. 会话上下文管理:多轮对话记忆,携带历史消息请求Agent接口;上下文做膨胀防护,避免请求体过大
  2. SSE流式返回处理:后端分片输出文本,前端实现打字机实时输出效果;解决二进制分片中文乱码问题
  3. Markdown+代码块渲染:实时渲染Markdown内容,处理流式下代码块标签未闭合的样式异常
  4. 请求手动中断:支持用户点击停止生成;组件销毁自动关闭流,杜绝内存泄漏。
  5. 异常降级策略:网络错误、服务报错、流断开,保留已有输出内容,友好提示,不破坏整个页面
  6. 组件复用:封装通用组件,业务页面直接传入绘画参数即可使用。

为什么放弃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

坑点:流输出过程中,代码块的结束标记````还没有返回,解析会出现标签不闭合,页面样式错乱

处理策略:

  1. 流式过程中:只做基础Markdown解析,不执行代码高亮
  2. 流全部读取完成之后,再执行代码高亮逻辑
  3. 内网可信服务,依然增加基础的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 方案。

相关推荐
陈驰_05045 小时前
需求列表接口 422 Unprocessable Entity 排查与修复全记录
状态模式·fastapi·vue 3
两点王爷1 天前
Java 与前端加载 MVT 数据:从服务端切片到浏览器渲染
java·前端·状态模式
码匠许师傅1 天前
【设计模式精讲】25.状态模式(State)
c++·ui·设计模式·状态模式·uml
szarron1 天前
国产手持式频谱分析仪选型攻略:TFN RC系列 vs HTOOL SA8T频谱分析仪 专业参数对比(军工/路测/调试全覆盖)
开发语言·前端·状态模式
Wang's Blog2 天前
Java框架快速入门: Spring Security+OAuth2之工程结构与开发环境配置
java·spring·状态模式
晴天163 天前
ES6+ 核心语法
前端·es6·状态模式
漂着的圆木5 天前
MCP 2026-07-28 长任务改造:别再用 HTTP 超时判断任务失败
分布式·架构·状态模式·ai agent
lincats6 天前
大白话讲解Addy Osmani 的循环工程Loop Engineering工作流
人工智能·驱动开发·架构·prompt·状态模式·知识图谱
MetaLite6 天前
Java通用枚举驱动下拉框-元数据接口与前端契约
java·前端·状态模式