尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

AI对话项目V1封装实践:SSE流式输出与请求层边界设计

发布时间:2026/9/29 20:43:08

资讯中心
01
ARTICLE

AI对话项目V1封装实践:SSE流式输出与请求层边界设计

AI对话项目V1封装实践:SSE流式输出与请求层边界设计
“V1”这个词出现在项目里最多的场景通常不是版本号本身而是一句带着点心虚的话“先出一个 V1跑通就行。”我参与过不少这类项目也见过太多 V1 变成交付前的“烫手山芋”。这个项目挂的名字很直白V1 项目封装与总结。它对应的不是某个抽象概念而是把一个已经验证过的 AI 交互原型从“能跑”整理成“能交付、能复用、能交接”的版本。封装是其中最核心的动作总结是最后必须落地的产物。这篇整理主要面向两类人一类是刚写完第一个可运行版本、正在犹豫要不要重构代码的开发者另一类是把 AI 对话类功能集成进 H5、小程序或后台产品却不知道怎么处理流式输出、请求层和组件边界的同学。我先说说 V1 项目为什么要封装再按请求层、AI 交互逻辑、组件层、踩坑实录与总结文档这几个方向把整个过程拆开讲。1. 封装前先想清楚V1 项目真正缺的是什么1.1 从 demo 到 V1不是功能问题是结构问题demo 阶段的目标是“证明这件事可行”。我当时的第一版原型很简单一个输入框、一个发送按钮、一个回答容器。点击按钮后把用户问题发给大模型接口SSE 流式返回的内容一段段渲染到页面上。测试下来回答能显示滚动能看按钮能点看起来已经“跑通了”。但真正进入 V1 交付清单时问题全冒出来了同样的 fetch 逻辑散落在页面里错误处理是一堆重复的 if后端域名换了小版本前端的代码要改三处用户快速连点“发送”会出现多条并发请求回答界面乱到没法看最麻烦的是点击“停止生成”只会清空前端文案底层请求根本没有取消服务端的流量还在跑。所以这里要先纠正一个认知V1 缺的不是更多功能而是结构。你不可能在“一条平铺直叙的事件流”里长期稳定地维护网络状态、交互状态和业务状态。封装的本质就是给代码划边界把页面和网络之间、组件内部和组件外部之间、调用者和被调用者之间的规则定下来。边界清晰后功能才谈得上可控。1.2 封装的本质给代码划边界很多人一提“封装”就想到三大特性里的“封装继承多态”以为封装就是写个 class、把办法藏起来。实际操作中封装更重要的价值是边界调用方不需要知道内部细节只依赖一个稳定的入口和一套明确的返回值。我习惯用一个厨房的类比。后厨有自己的备菜区、炒菜区和出菜口菜单上写什么客人就看什么。客人不会闯进后厨去看菜怎么切、火怎么调只需要按菜单下单、拿到菜。代码里的接口层封装配的是“出菜口”组件封装是“菜单”AI 交互逻辑封装是“后厨的标准化流程”。在 V1 项目里我把要处理的边界分成三类函数边界把流式解析、错误码转换、token 获取这些可以复用的操作提取成独立函数。模块边界请求逻辑归请求层业务接口归 API 层UI 状态归组件层不互相渗透。服务边界前端只依赖由后端定义的消息协议包括 SSE 数据格式、结束标记、错误结构。定了这些边界之后后端只要不破坏消息协议内部怎么改都影响不到前端前端只要不绕过封装层控制好统一入口后续换域名、换鉴权方式也都有明确落脚点。1.3 项目模块拆分的基本顺序V1 阶段最忌讳上来就设计一个庞大的目录结构。我这次的拆分顺序是先底层、后业务、再界面。底层指的是环境配置和请求模块业务层是对具体接口的封装比如聊天接口界面层是组件和页面。顺着这个顺序搭出来的结构如下src/ ├── api/ │ ├── request.js # fetch 二次封装统一超时、鉴权、错误 │ └── chat.js # 聊天相关接口 ├── components/ │ └── ChatPanel/ │ ├── index.vue # 容器组件负责交互编排 │ ├── MessageList.vue # 展示消息列表 │ └── Sender.vue # 输入区与发送按钮 ├── utils/ │ ├── sse.js # SSE 流式读取器 │ └── env.js # 环境与多域名配置 ├── constants/ │ ├── errorCode.js # 错误码映射 │ └── copywriting.js # 提示文案集中管理 └── pages/ └── chat/ └── index.vue # 页面入口这个结构没有引入太重的东西每层各管一摊事。如果你现在手里也有一个功能能跑但结构混乱的 V1可以先按这个模板对号入座再逐步把代码搬进去。搬的过程不是机械复制而是顺手找出重复逻辑并归并这才是封装的真正起点。2. 接口层封装把网络请求做成“不用动脑”的模块2.1 先定标准用 axios 还是原生 fetch接口封装的第一个选择题是请求库。axios 的优势是拦截器、取消机制和对老浏览器更友好的兼容性原生 fetch 的优势是零依赖、更贴近浏览器标准。我这次选的是原生 fetch没有外挂 axios理由是项目运行环境已经能完整支持 fetch没必要为了一个可拦截的能力再引一个依赖。但不管是 axios 还是 fetch二次封装要解决的核心问题是一模一样的统一 baseURL避免请求路径散落各处。统一超时处理。统一鉴权 header。统一错误类型让调用方只认error.code不用自己判断字符串。预留取消能力给后面的 SSE 和“停止生成”做准备。下面这个createRequest是我在实际项目里用的一个基础版本export function createRequest(config {}) { const { baseURL , timeout 15000, getToken () , defaultHeaders {}, } config; const requestWithTimeout (url, options) { const controller new AbortController(); const timer setTimeout(() controller.abort(), timeout); return fetch(url, { ...options, signal: controller.signal }) .finally(() clearTimeout(timer)); }; return async function request(path, options {}) { const url /^https?:\/\//.test(path) ? path : baseURL path; const headers { Content-Type: application/json, ...defaultHeaders, ...options.headers, }; const token typeof getToken function ? getToken() : ; if (token) headers.Authorization Bearer ${token}; try { const response await requestWithTimeout(url, { ...options, headers }); if (!response.ok) { const error new Error(HTTP ${response.status}); error.status response.status; error.code HTTP_ERROR; throw error; } if (response.status 204) return null; return await response.json(); } catch (e) { if (e.name AbortError) { const error new Error(请求超时或被中断); error.code REQUEST_ABORTED; throw error; } if (e instanceof TypeError) { const error new Error(网络连接异常); error.code NETWORK_ERROR; throw error; } throw e; } }; }这里面有两个容易被忽略的点。第一个fetch只有在网络层失败时才会抛出TypeErrorHTTP 4xx、5xx 并不会抛异常必须自己在response.ok时抛错第二个AbortController同时承担了超时和中止两个职责所以必须在finally里清掉定时器否则中止会在之后误触发。2.2 一个请求模块管理 2 个域名AI 对话类的 H5 项目经常遇到一个情况首页和静态资源在一个域名聊天接口在另一个域名尤其是要部署到 App 或小程序容器里时多个域名几乎成了标配。热词里有人问“uniapp 封装 H5 如何指向 2 个域名”这确实是个高频问题。我的做法是把域名配置抽成独立的环境配置不给业务层“写死”的机会。举例来说// utils/env.js export const ENV { dev: { chatBaseURL: https://dev-api.example.com, pageBaseURL: https://dev-page.example.com, }, prod: { chatBaseURL: https://api.example.com, pageBaseURL: https://page.example.com, }, }; export const currentEnv ENV[import.meta.env.MODE] || ENV.dev;然后createRequest在创建实例时传入对应的baseURL聊天模块用chatBaseURL上传或静态资源相关请求用pageBaseURL。这样可以保证 H5 打包后只需要根据部署环境切换配置不需要重新改请求路径。这里要特别提醒别在封装层用“当前页面域名”来拼接口地址。H5 被套进 App 壳后window.location.href可能不是业务页面地址而是本地 file 或容器的虚拟地址依赖它很容易翻车。多域名这件事宁可配置多一些也不要运行时猜。2.3 错误码与异常类型让业务层拿到稳定信号项目跑起来之后最常见的不稳定因素就是错误处理不统一。有人直接catch到一串英文字符串有人拿到response.status后在页面里写401 ? 请登录 : 出错了。页面一多文案就五花八门。封装层要做的是把“HTTP 状态码”和“业务错误码”统一成一套稳定信号。我当时建立了一个错误码表错误码触发情况页面提示REQUEST_ABORTED请求超时或手动取消“请求已取消请重试”NETWORK_ERROR断网、DNS 失败、跨域被拦“网络连接异常请检查网络”AUTH_FAILED401 或 token 失效“登录已过期请重新登录”FORBIDDEN403 无权限“没有权限执行此操作”SERVER_ERROR500 以上“服务暂不可用请稍后重试”对应的实现也比较直接在createRequest里把 HTTP 状态映射成AUTH_FAILED、FORBIDDEN、SERVER_ERROR等错误码业务层收到后只负责看error.code页面里同一错误码只需要一个提示逻辑。后续就算后端换状态码也只要在封装层改一行不用满项目找文案。3. AI 交互逻辑封装SSE 流式输出与 abort 中断3.1 为什么选 SSE 而不是 WebSocketAI 对话场景里回答是逐字产生的前端需要第一时间渲染出来。常见方案有轮询、WebSocket 和 SSE。轮询是定时请求实时性和资源浪费都不理想WebSocket 是双向通道适合聊天室、实时协作这类需要持续双向通信的场景但对“一问一答”的对话接口来说复杂度有点高。SSE 是 Server-Sent Events服务端单向推送前端只收数据。它天然符合大模型对话场景用户发起一次 POST服务端把答案片段通过文本流持续推给前端推完就关闭。协议本身就是文本格式后端实现简单前端也能用原生fetch接流不需要额外依赖。这个 V1 项目选 SSE不是因为“新”而是因为它的实现路径最短且能直接复用现有 HTTP 网关、负载均衡和鉴权体系。3.2 通用流读取器实现用fetch接 SSE很多人会掉进一个坑里看到response.body.getReader()和read()之后不知道一帧数据可能既包含半行也可能包含好几行。如果只处理单次value有些消息会被截断有些会被拆散。我整理的sseRequest是这么处理的export async function sseRequest({ url, body, token, onMessage, signal, }) { const response await fetch(url, { method: POST, headers: { Content-Type: application/json, Authorization: token ? Bearer ${token} : , }, body: JSON.stringify(body), signal, }); if (!response.ok) { throw new Error(SSE 请求失败HTTP ${response.status}); } if (!response.body) { throw new Error(当前环境不支持流式读取); } const reader response.body.getReader(); const decoder new TextDecoder(utf-8); let buffer ; const handleLine (line) { if (line.trim() ) return; if (!line.startsWith(data:)) return; const data line.slice(5).trim(); if (!data || data [DONE]) return; try { onMessage(JSON.parse(data)); } catch (e) { // 当 JSON 被拆到下一帧时先跳过等待补充解析 console.warn(SSE 数据解析失败等待下一条消息, e); } }; while (true) { const { done, value } await reader.read(); if (done) break; buffer decoder.decode(value, { stream: true }); const lines buffer.split(/\r?\n/); buffer lines.pop() || ; lines.forEach(handleLine); } if (buffer.trim()) { buffer.split(/\r?\n/).forEach(handleLine); } }关键点在于buffer lines.pop()把最后一段可能不完整的内容留在缓冲区里等下一帧到达时再拼接。TextDecoder一定要用{ stream: true }否则 UTF-8 多字节字符在流中间被切断时会乱码。实际调接口时你还会看到有的服务端会先发一段注释行比如: ping所以handleLine里对不是data:开头的行直接跳过不能一刀切处理。3.3 会话状态与中断把“停止生成”做成正经能力V1 项目里最容易漏掉的是“停止生成”的完整闭环。按钮上只是调了一句abort()看起来界面停了但组件卸载时没有清请求、超时时没有恢复状态、用户点击停止后没有回到初始状态这些都属于半截工程。我封装了一个useChatSession把会话状态和中断能力绑定在一起import { ref, onBeforeUnmount } from vue; import { sseRequest } from /utils/sse; export function useChatSession(chatApi) { const state ref(idle); // idle | running | aborted | done | error const controllerRef ref(null); const start async (text) { if (state.value running) return; controllerRef.value?.abort(); const controller new AbortController(); controllerRef.value controller; state.value running; try { await chatApi.stream(text, { signal: controller.signal, onMessage(payload) { if (typeof payload?.delta string) { // 触发上层回调把增量文本交给渲染层 } }, }); state.value done; } catch (e) { if (e.name AbortError) { state.value aborted; return; } state.value error; } }; const stop () { controllerRef.value?.abort(); }; onBeforeUnmount(() { controllerRef.value?.abort(); }); return { state, start, stop }; }这里有一个非常重要的设计AbortError被单独捕获再置成aborted状态。如果把它当成普通错误处理那么用户主动点“停止”时界面会弹出一条“请求失败”提示体验非常奇怪。正确逻辑是停止是用户有意为之不是失败。另外我在组件卸载时也调用了abort()。这个动作不是可有可无的。AI 流式接口如果前端关了页面还允许请求继续跑后端可能还会持续生成并占用资源加一个卸载清理既省流量也避免组件已经销毁后再去更新 DOM 导致内存泄漏。3.4 并发切换与竞态V1 最容易翻车的地方还有一类问题容易被忽略用户在一次回答还没结束时就刷新页面、切换会话或发起了新问题。如果在旧请求的回调里还去更新新页面的状态就会出现“旧回答覆盖新回答”的竞态。通用的处理方式就是给会话加“代数”。我在封装里用controllerRef存当前请求的AbortController每次发起新请求就把旧控制器中止。这样旧请求的回调要么在不经意间执行也会因为界面已经切换到新状态而被过滤中止它之后状态不会再被旧流更新。如果你在 React 里也可以用一个requestSeqRef自增编号回调时只认最新一次请求的编号这是更保险的兜底方案。4. 组件层与工程化让 UI 和逻辑解耦4.1 对话界面拆成“容器 展示组件”组件封装最忌讳的是把所有逻辑都塞进一个巨型单文件组件。我把对话页拆成两个层次容器组件ChatPanel/index.vue负责拿到输入、启动会话、接收流式增量、更新消息列表、处理停止动作。展示组件MessageList.vue和Sender.vue只负责展示数据和触发事件不感知请求、不感知 SSE。这样的拆分带来的收益是MessageList可以独立调试也能在测试环境用静态假数据渲染ChatPanel里替换“聊天接口”时页面其他部分完全不用动。以后如果你要再接一个本地小模型只需要在 API 层新增实现组件层不变。组件内部状态也不要一股脑全放在全局。state正在生成、已停止、出错属于会话维度放在useChatSession里页面消息列表属于界面维度放在容器组件里按钮置灰、loading 文案则根据state计算出来。这样在报错排查时能顺着状态流找回问题而不是在十几个v-if里捞数据。4.2 常量、文案、环境配置这类“不起眼”的封装V1 项目到后期最琐碎的就是文案分散和魔法数字。按钮在停止时候要变成“停止生成”超时时要提示“请稍后重试”接口返回特定错误码时要拒绝操作。这些字符串如果不集中管理前端同事会在不同页面写出完全不同的话术。我把错误提示文案统一放到constants/copywriting.js把错误码状态统一放到errorCode.js再在项目里规定业务页面不直接写“网络异常”这类字面量而是引用统一导出。这个规则不复杂但是团队里几个文件在维护能有效避免文案不一致和后端状态码变动时改不全的问题。4.3 文档是封装的一部分封装不是代码提完就结束了。还有一类“半成品”是代码变得很干净但交接文档还在文档中。V1 总结想要对其他人生效至少要把下面这些写清楚接口路径和请求头怎么带。SSE 流的数据格式增量字段、结束标记、错误结构。如何切换多域名环境。前端如何触发中止后端收到中止请求后要做什么。已知问题列表和回退方案。我把这些信息整理成组件的 README 和docs/api.md。这个动作看起来花时间但实际在 V1 价值巨大你不用把代码逻辑从头到尾解释一遍只需要把边界和协议写清楚接手的人就能在半小时内运行起来。5. V1 阶段踩过的坑与排查实录5.1 常见问题速查表下面是这个项目里真实遇到过的问题我整理成一张速查表后面再接类似需求时能直接对照。现象可能原因解决办法回答显示到一半不再更新SSE 流被浏览器误判超时或后端连接断开检查后端 keep-alive前端把 TCP/HTTP 层空闲超时调大点击“停止”后按钮没恢复只中止了 UI 状态没有 abort 底层 fetch统一用AbortController关联会话停止时调用stop()快速连点“发送”出现多条回答缺少并发控制在useChatSession.start里判断running状态或强制中止前一次请求中文回答开头出现乱码忘记用TextDecoder的 flow 模式解码参数改为decoder.decode(value, { stream: true })页面刷新后接口还在请求组件卸载或页面刷新时没清理onBeforeUnmount/useEffect清理函数里调用abort()部署到 App 容器后拿不到接口域名误用window.location拼地址域名改为环境配置注入不依赖页面地址接口返回 200 但页面一直报 “网络错误”后端返回了非 JSON 但前端仍调用response.json()判断response.content-type或对解析做容错一条 SSE 消息被拆到多个 chunk 后解析失败缓冲区分行逻辑不完整使用buffer.pop()保留半行内容下轮拼接5.2 几条值得记住的避坑经验第一不要信任“看起来正常的几次流式响应”。我在本地测 SSE 很顺利但在包了一层网关的测试环境里要么第一帧数据被网关吞了要么整条连接被空闲超时掐断。排查方式是先在后端用 curl 直接看流再从前端读原始chunk确认在哪一层被切不要一上来就改前端代码。第二AbortController不是“点击停止之后再也不用管”的状态。它还需要和一个“会话状态”绑定。我把controllerRef存放的控制器同时作为“当前会话是否活跃”的标记这样既能在页面上判断按钮是否可点又能在组件销毁时统一清理比单独维护多个布尔值可靠得多。第三流式解析的分行逻辑一定要用\r?\n兼容处理。有的后端按标准 SSE 发\r\n有的只发\n。只处理一种就可能出现相邻消息粘连或数据丢失。我统一用buffer.split(/\r?\n/)既兼容换行也不怕最后一段没有换行。这个细节在联调阶段最容易让人抓狂提前做好能省很多时间。6. 总结篇V1 总结怎么写才有价值6.1 先定边界再写“能复用”的经验项目到了收尾阶段写总结不是把开发过程按时间顺序复述一遍那是流水账。真正有价值的总结是把这次封装过程中确认下来的边界、决策和教训讲清楚。我的总结包含这么几块目标与范围V1 做了哪些功能主动没做哪些功能。系统结构与职责请求层、API 层、SSE 层、组件层各自负责什么。关键技术决策为什么选 SSE、为什么用 fetch、为什么把 abort 合并进会话状态。遗留问题哪些地方只做了临时方案在什么条件下需要替换。下一步建议如果要做 V2优先处理哪些风险。其中“主动没做哪些功能”特别重要。V1 最怕的是边界不明导致后面每提一个需求都被质疑“为什么没有”。写明范围和前提接手的人才能知道哪些是你的责任边界哪些是产品规划里故意砍掉的。6.2 交接文档清单让总结变成可执行的交接手册总结不能只停留在口头或会议纪要我习惯把它落成几份可更新的文档README.md项目启动方式、环境变量、本地联调步骤。docs/api.md接口协议、SSE 数据格式、错误码表。docs/pitfalls.md踩坑记录和排查手册。CHANGELOG.md从 V1 开始的版本记录。这套文档配合前面的封装代码才是真正能交给下一个开发者的完整产物。代码展示“怎么做”文档解释“为什么这么做”边界和遗留问题让人知道“接下来从哪里接手”。这三个问题说清楚V1 封装就算真正完成了。我个人每次写总结时都默认一个心态如果某一天有人对着这份代码问“这个设计当时是怎么想出来的”文档里应该能找到答案。封装解决的是代码边界问题总结解决的是时间边界的问题。V1 项目把这两件事做完后面才不会再踩一遍已经踩过的坑。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

◈

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

◐

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

▲

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。