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

全栈AI应用骨架:SSE流式对话与中断机制实战解析

发布时间:2026/9/24 11:29:00

资讯中心
01
ARTICLE

全栈AI应用骨架:SSE流式对话与中断机制实战解析

全栈AI应用骨架:SSE流式对话与中断机制实战解析
自从开始做全栈 AI 应用我一直在琢磨一个事为什么好多项目 Demo 跑得通一上点规模就乱套后来想明白了不是模型能力不行是缺一层骨架——用来约束对话流程、承接流式数据、规范多端交互的中间层。所以有了 DevFlow Harness 这个全栈实践项目。M1 阶段目标很明确先把骨架立起来把 SSE 流式对话这条主线彻底打通。这篇就把我这段时间的完整思路、踩坑记录和核心代码实现一次性聊透包含 SSE 消息协议设计、abort 取消机制、服务端 context 链路和 Vue3/Uniapp 双端适配给后面想动手做同类项目的朋友一份能直接参考的落地笔记。1. 重新认识 Harness为什么 AI 应用需要一个骨架而不是裸调用聊 Harness 之前先说说我这段时间观察到的普遍现象很多人在接大模型 API 时第一个版本都是打开一个接口地址往后端 Postman 里怼一个请求然后等完整 JSON 回来再拼页面。这种方案前期确实快但一旦涉及多轮对话、用户中途打断、前端流式打字机效果、多端共用同一套逻辑问题会像多米诺骨牌一样倒。我在 DevFlow Harness 里定义的骨架第一层解决的就是对话生命周期管理。1.1 Harness 和 Agent 的本质区别不是换个名词现在社区里 Harness 和 Agent 两个词经常被混着用但实际定位完全不同。Agent 是决策和执行体——它接收任务、拆解步骤、调用工具最终产出结果Harness 是运行约束和管理框架——它负责把 Agent 的感知、思考、行动过程放进一个可控的容器里注入上下文、管理会话状态、拦截流式输出、处理异常中断。打个比方Agent 是发动机Harness 是发动机舱里那套管路和控制系统没有后者动力再强也会失控。这个区分直接决定了代码的组织方式。我在 M1 里没有引入任何重型 Agent 编排框架只做了一层薄薄的 Harness 管理模块职责如下会话级上下文仓库维护 history 列表限制窗口长度SSE 消息协议封装统一 event / data / id 结构前端 AbortController 与服务端 context.CancelFunc 的中断链路多端消息格式转换层Vue3 H5 / Uniapp 通用1.2 Harness 骨架的四大核心职责第一是状态机管理一次对话不是发起-结束两步走中间有 connecting、streaming、aborted、completed、error 这么多状态。骨架要把这套状态机收拢起来不能让前端每个页面各自维护一套。第二是输入输出裁剪大模型上下文窗口有限骨架要负责把历史消息按 token 预算裁剪。我最初偷懒没做直接把全部历史丢给后端结果上下文一长响应速度肉眼可见地下降控制台一片红色报错。第三是流式数据的标准化SSE 的 event 结构各家模型不完全一样OpenAI 风格用 choices[0].deltaDeepSeek 也类似但其他一些国产模型可能在 function_call 和 reasoning_content 字段上有差异。骨架层做一次字段映射前端永远只消费一种统一格式。第四是错误隔离网络抖动、模型超时、用户手动中断这些异常要分别处理。特别是中断它不能被视为 error——用户按停止生成是一个正常意图必须在状态机里单列否则前端容易把中断误报成网络错误。M1 阶段我只实现了上述职责的最小闭环但骨架抽象已经成型后面接多 Agent 编排、工具调用、RAG 检索都不会伤筋动骨。2. M1 里程碑的边界第一版只做完整对话闭环不做编排很多项目死在一开始就想做太完善上。DevFlow Harness 的 M1 我主动划掉了很多看起来很诱人的功能比如多 Agent 协作、工具调用、长期记忆、向量检索。这些留给后面的里程碑M1 只保证一件事从用户输入一句话开始到前端逐字渲染出模型回复再到用户随时可以打断、重新提问、在多端无缝切换——这条主链路干净利落稳定可复现。2.1 架构总览Vue3 Golang Uniapp 的分工逻辑项目整体的数据流是这样的前端Vue3 H5 / Uniapp 小程序把用户消息发送到 Golang 后端后端维护会话上下文调用大模型 API把流式响应通过 SSE 通道持续推给前端前端逐个 chunk 渲染到页面。这里要说明一下为什么用 Golang 做 BFF 层。主要三个原因goroutine 与 context 的配合做流式转发非常自然部署产物是单一二进制不需要 Node 运行时环境对个人服务器很友好lambda、容器、物理机部署模式一致后期做多实例也不太操心。前端选 Vue3 Uniapp 的理由更直接项目预期要覆盖 H5、微信小程序、App 三端Uniapp 是成本最低的跨端方案同时 Uniapp 全面支持 Vue3 语法组合式 API 组织状态管理比 Options API 干净太多。技术栈清单如下表层级技术选型核心职责前端Vue 3 TypeScript Vite页面渲染、流式文本解析、中断控制跨端UniappH5 / 小程序 / App 三端复用BFFGolang net/httpSSE 网关、上下文管理、模型调用代理协议Server-Sent Events (SSE)服务端实时推送模型DeepSeek APIOpenAI 兼容模式流式对话生成2.2 目录结构的骨架化设计M1 的代码目录我是按照骨架与业务分离的思路划分的。前端保留一个专门放流式逻辑的目录后端把与模型对接的代码集中在 provider 层方便切换不同模型供应商。devflow-harness/ ├── frontend/ # Vue3 Uniapp │ ├── src/ │ │ ├── api/ # 请求封装 │ │ ├── composables/ # useChat/useSSE 组合式函数 │ │ ├── types/ # 消息类型定义 │ │ └── pages/ # H5 页面 / 小程序页面 ├── server/ # Golang BFF │ ├── internal/ │ │ ├── harness/ # 骨架核心上下文管理、状态机 │ │ ├── provider/ # 模型供应商适配层 │ │ └── sse/ # SSE 协议封装 │ ├── cmd/server/main.go # 启动入口 │ └── go.mod └── docs/ # 架构设计文档从这个结构能看出Harness 相关的代码独立成模块后面即使把前端换成 React、后端换成 Node骨架的设计理念依然可以复用。3. SSE 流式对话的实现拆解消息协议、后端推送与前端渲染SSE 的全称是 Server-Sent Events基于 HTTP 长连接实现服务端单向推送。相比 WebSocket它有两个对 AI 对话场景非常友好的特点基于原生 HTTP不需要额外握手协议兼容性极好自带断线重连机制浏览器会在连接断开后自动重连。3.1 SSE 消息协议不要让前端去解析脏数据原始 SSE 格式长这样id: 1 event: message data: {content:你好}但如果后端只是把模型 API 的原始数据流原样转发给前端前端代码就废了——每个模型返回的消息体结构不一样有的还夹带 reasoning 字段和 function_call 对象前端解析逻辑写起来非常痛苦。我在 Harness 层做了一次标准化规定后端只向前端推送两种消息类型文本增量消息event: deltadata 为纯文本字符串前端直接追加到当前回复文章末尾。收尾元信息event: donedata 为 JSON包含本次回复的完整内容、token 消耗、模型耗时。这样前端根本不需要知道底层模型是 DeepSeek、GPT 还是其他看到的始终是一个易读的字符串流。协议设计上给每个 chunk 加上 id 字段自动递增便于排查丢包和乱序问题。3.2 Golang 后端 SSE 接口的完整实现核心逻辑我放在internal/harness/chat.go。先看接口入口部分func (h *Harness) HandleChat(w http.ResponseWriter, r *http.Request) { // 从请求体解析用户消息 var req ChatRequest if err : json.NewDecoder(r.Body).Decode(req); err ! nil { http.Error(w, invalid request, http.StatusBadRequest) return } // 关键从请求上下文派生一个可取消的 context // 前端断开连接时r.Context() 会被自动取消 ctx, cancel : context.WithCancel(r.Context()) defer cancel() // 设置 SSE 响应头 w.Header().Set(Content-Type, text/event-stream) w.Header().Set(Cache-Control, no-cache) w.Header().Set(Connection, keep-alive) w.Header().Set(X-Accel-Buffering, no) // 禁用 Nginx 缓冲 flusher, ok : w.(http.Flusher) if !ok { http.Error(w, streaming unsupported, http.StatusInternalServerError) return } // 构造消息历史上下文 messages : h.buildMessages(req.SessionID, req.Content) // 调用 provider 层发起流式请求 err : h.provider.StreamChat(ctx, messages, func(chunk string) error { // 将文本增量封装为标准 SSE 事件 event : SSEEvent{ID: atomic.AddInt64(h.counter, 1), Event: delta, Data: chunk} return writeSSE(w, flusher, event) }) if err ! nil { // 如果错误是因为客户端取消则静默结束 if errors.Is(err, context.Canceled) { return } writeSSE(w, flusher, SSEEvent{Event: error, Data: err.Error()}) return } // 正常结束推送 done 事件 writeSSE(w, flusher, SSEEvent{Event: done, Data: summaryJSON}) }writeSSE方法负责格式化消息注意每条消息必须以\n\n结尾这是 SSE 协议硬性要求漏了浏览器端会一直卡在等待状态。flusher 的Flush()方法是关键——HTTP 响应默认是有缓冲的不手动 flush数据会攒在缓冲区前端看到的就不是流式效果而是一坨文件一次性返回。OpenAI 兼容模型的手写语句如下关键是把http.Request的Context传入使整个 HTTP 调用可被取消func (p *OpenAIProvider) StreamChat(ctx context.Context, messages []Message, onDelta func(string) error) error { reqBody : ChatCompletionRequest{ Model: deepseek-chat, Messages: messages, Stream: true, } jsonBody, _ : json.Marshal(reqBody) req, _ : http.NewRequestWithContext(ctx, POST, p.apiURL/chat/completions, bytes.NewReader(jsonBody)) req.Header.Set(Content-Type, application/json) req.Header.Set(Authorization, Bearer p.apiKey) resp, err : http.DefaultClient.Do(req) if err ! nil { return err } defer resp.Body.Close() reader : bufio.NewReader(resp.Body) for { line, err : reader.ReadBytes(\n) if err ! nil { if err io.EOF { return nil } return err } trimmed : strings.TrimSpace(string(line)) if !strings.HasPrefix(trimmed, data:) { continue } payload : strings.TrimSpace(strings.TrimPrefix(trimmed, data:)) if payload [DONE] { return nil } var chunk ChatCompletionChunk if err : json.Unmarshal([]byte(payload), chunk); err ! nil { continue } if len(chunk.Choices) 0 { content : chunk.Choices[0].Delta.Content if content ! { if err : onDelta(content); err ! nil { return err } } } } }3.3 前端如何正确消费 SSE 流fetch 流式读取方案前端这块我用的是fetch加ReadableStream来实现没有依赖eventsource-polyfill。原因有两点原生 EventSource 不支持自定义请求头和 POST 方法而我们需要在 POST body 里传 sessionID 和用户消息EventSource 的自动重连机制在流式场景下不太好控制。搭配 AbortController 管理取消动作。核心代码封装在composables/useSSE.tsexport function useSSE() { const controller new AbortController() async function chatStream(payload: ChatPayload, handlers: StreamHandlers) { const response await fetch(/api/chat, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify(payload), signal: controller.signal, }) if (!response.ok || !response.body) { throw new Error(network error) } const reader response.body.getReader() const decoder new TextDecoder(utf-8) let buffer while (true) { const { done, value } await reader.read() if (done) break buffer decoder.decode(value, { stream: true }) // SSE 事件以空行分隔按双换行切分 const events buffer.split(\n\n) buffer events.pop() || for (const rawEvent of events) { handleSSEEvent(rawEvent, handlers) } } } function abort() { controller.abort() } return { chatStream, abort } }需要注意一个细节TextDecoder.decode要传{ stream: true }。如果漏掉当 UTF-8 中文字符跨越两个 chunk 时解码就会产生乱码。这是流式中文输出最容易踩的坑我在调试时被这个坑卡了整整一个晚上。handleSSEEvent内部对事件类型做分发delta事件把data行内容追加到currentMessage对应的 reactive 对象中。done事件解析最终 summary把临时消息标记为完成状态清空 loading。error事件切换到 error 状态前端弹出错误提示。渲染层用 Vue3 的v-for遍历消息列表配合 CSSwhite-space: pre-wrap保留换行理由很简单——模型返回的 Markdown 内容里包含大量空行和列表符号pre-wrap能保证换行结构在文本累积过程中不会错乱。4. abort 中断链路从停止生成按钮到服务端取消用户点击停止生成这个动作涉及的链路到底有多长前端要取消 HTTP 请求后端要取消对模型 API 的调用上下文要正确标记为中断状态已渲染的部分文本要保留。任何一环没处理好都可能把这次中断变成一次事故现场。4.1 前端取消请求的正确姿势AbortController 生命周期管理实现上我封装了一个useChatcomposable统一管理 messages 列表和 abort 逻辑。每个会话实例维护独立的 AbortController因为多个并发会话可能同时存在——用户可能在不同页面开启了两个对话不能互相干扰。interface ChatSession { controller: AbortController messages: ChatMessage[] isStreaming: boolean } const sessions reactiveRecordstring, ChatSession({}) function abortGeneration(sessionId: string) { const session sessions[sessionId] if (!session) return session.controller.abort() session.isStreaming false session.messages[session.messages.length - 1].status interrupted }一个关键细节abort 之后前端不能再把这个会话的状态改成error显示网络错误是错误行为。用户的主动中断与真实网络异常在业务语义上是两回事。我的方案是新增一个interrupted状态UI 层展示已停止生成并且给出重新生成按钮。这个细节在用户调研中反馈非常好因为可取消在用户的预期里是产品基础能力但你做错了用户会立即产生不信任感。另一个细节abort 后 model 的响应流可能还在后端跑只是没人接收了。如果后端不做处理这会导致模型侧的资源浪费尤其是长回复场景下白白消耗 token。所以后端必须联动取消这就用到前面提到的context.WithCancel(r.Context())。4.2 服务端 context 取消链路Goroutine 不会平白无故停下Go 的 context 链设计天然适合做请求级取消。r.Context()在客户端断开时会被自动 cancel但这里有个极易忽略的坑如果你直接把r.Context()传给下游 HTTP 请求理论上没问题但如果你在中间包了一层context.WithTimeout要确保这个超时不是从请求开始前就固定计时的否则长对话超过 60 秒被截断用户看到的却是模型生成中断体验极差。我建议始终从请求 context 派生新 context并留个可配置的超时兜底ctx, cancel : context.WithCancel(r.Context()) defer cancel() // 模型调用整体超时上限防止意外卡死 modelCtx, modelCancel : context.WithTimeout(ctx, 120*time.Second) defer modelCancel()在 provider 层如果收到context.Canceled错误不要把它包装成业务错误返回直接静默退出即可。前端的chunk推送函数会由于连接断开返回错误这时onDelta返回 err循环自然结束不会继续浪费资源。这里有个经验之谈一定要在HandleChat的 defer 里执行cancel()否则 context 在请求结束后不会释放关联资源并发量一高文件描述符和内存会涨得飞快。这在压测时是必炸项。4.3 半截标签与未闭合段落流式渲染的脏数据兜底用户中断后前端数据区停在一个半截状态非常常见尤其是模型回复中包含 Markdown 代码块时。用户看到一半突然点击停止页面可能停在下面这种状态以下是示例代码 python def hello(): print(hello)代码块没有闭合Markdown 渲染器会把它后面的所有内容都当成代码块处理页面样式直接崩掉。我的处理方案是监听中断事件对未闭合的标记做收尾处理检测到 未配对就自动补上检测到**未闭合就丢弃最后一对星号。这一步不要在后端做因为后端只负责字节流转发业务渲染层才能知道最终停在了哪里。我在前端写了个repairIncompleteMarkdown(text)工具函数在interrupted和error两个状态下都会调用。如果只是正常完成则不需要done事件里的内容肯定是完整闭合的。5. 从 Harness 到前后端流式面板的布局设计与多端适配标题里还有一个关键词流式布局面板。当对话内容持续动态变化时传统的静态布局方案不够用——输入框、消息区、状态栏的高矮位置都得跟随内容流动调整。我在 M1 里做了一个会话面板组件核心思路可以浓缩成三个布局状态idle 态输入框可供编辑消息列表显示历史会话。streaming 态模型输出逐字追加消息区自动滚动到底部输入框变为停止生成按钮区。collapsed 态面板宽度可调节聊天内容与整体应用窗口可以左右分栏或叠放。实现时有一个困扰很久的问题自动滚动。用户往上翻看历史记录时浏览器会持续触发滚动事件把页面拽到底部干扰阅读。后来用了一个判断——只有当用户滚动位置接近底部比如距离底部小于 100px时才自动跟随用户有明显上翻意图时暂停跟随直到用户手动滚回底部。这个机制虽然代码不过几行却是真实使用体验的分水岭。另一个难点是 Uniapp 端的适配。H5 端能顺畅使用fetch流式读取但微信小程序原生环境不支持ReadableStream。这意味着useSSE.ts在 H5 端能跑通拿到小程序端要另选方案。我用的是 Uniapp 的request加enableChunked: true选项小程序真机上可以接收流式数据但 chunk 之间的切分逻辑与 H5 不完全一致需要单独处理。这一块我放在第五篇的跨端专题里详细展开M1 里先保证 H5 端的完整实现。6. 流式输出的边界条件与异常处理不要被看起来能用骗了做流式项目最危险的就是本地跑通了就以为完事了。我把 M1 阶段遇到的边界情况列一下这些都是压测和高并发场景逼出来的新手很容易踩雷。6.1 Nginx 缓冲导致的不流式问题本地开发环境一切正常一旦部署到带 Nginx 的服务器上流式效果可能完全消失。原因在于 Nginx 默认对上游响应做缓冲要等后端传完才一次性发给浏览器。解决方案就是这么一行响应头X-Accel-Buffering: no。另外需要把proxy_buffering off加到 Nginx 配置里。验证方法很简单用 curl 请求后端接口如果能看到一个个 chunk 间隔输出说明后端正常浏览器端不显示流式效果就查 Nginx 配置。6.2 客户端断连后服务端能否感知之前提到的r.Context()取消依赖一个前提服务端要持续向客户端写数据。如果模型的流式响应停在某处长时间不产生新 chunk服务端可能感知不到客户端已经断开。注意上一行handler层的写超时设置也要配置好。我的方案是http.Server设置ReadTimeout和WriteTimeout以及 provider 整体超时兜底。在真实场景中模型 API 卡住的情况虽不常见但一旦发生没有兜底的请求会一直挂到地老天荒连接池被占满后整个服务就瘫了。6.3 token 计费与上下文长度告警流式响应对应的 token 消耗往往被忽视。开发调试时反复请求不觉得上线后使用量一大费用就开始积累。我在 Harness 里做了一个简单的用量日志每次done事件都记录 prompt_tokens 和 completion_tokens按月汇总超过预设阈值时在日志里输出告警。这个功能实现成本极低但是防止花呗刷爆的有效手段。6.4 中文乱码的 text/event-stream 响应一个很容易被忽略的结果Golang 的http.ResponseWriter会默认按Content-Type来推断字符编码。text/event-stream默认没有指定 charset某些网络环境下会以 Latin-1 返回中文直接乱码。解决方案是设置Content-Type: text/event-stream; charsetutf-8。这种问题在不同浏览器/平台上的表现完全不一致不亲自踩一遍很难注意。7. M1 验证清单与性能实测数据M1 阶段我在结束前做了一轮相对完整的验证。下面把验证清单和关键数据贴出来给大家一个可参照的验收标准。验证项预期结果实测结果多轮对话连续上下文模型能记得前文关键信息通过历史窗口 10 轮内正常流式打字机效果字符逐段渲染无明显大块跳变通过首字延迟约 480ms用户中断后快速停止点击停止后 500ms 内前端停止渲染通过平均约 200ms中断后服务端资源释放Goroutine 数量回落无堆积通过异常网络下的断线重连前端提示错误可一键重试通过重试逻辑已实现中文文本跨 chunk 解析无乱码、无字符丢失通过实测并发 20 路请求同时对话单台 2 核 4G 服务器稳定运行无内存泄漏迹象。Goroutine 数量在请求结束后 30 秒内回到基线水平。这里特别想提醒一点流式项目的测试必须包含中断这个维度。我发现很多人在验收时只测了正常全流程从来不点停止按钮导致中断相关 bug 全部留到线上被用户发现。建议测试用例里强制加入三种中断触发方式点击按钮中断、刷新页面中断、手机端 App 切后台中断。前后端联调时也可以用一段脚本来模拟流式输出给后端压测 SSE 的服务稳定性方便判断性能瓶颈在前端渲染还是后端转发。8. 从 M1 走向 M2骨架的扩展方向与经验沉淀M1 完成后骨架已经具备了一个最小 AI 应用需要的全部底座能力。后面我打算按三条主线迭代。第一条线是增强上下文管理。现在的历史消息窗口比较简单按条数裁剪没有考虑每条消息的实际 token 数。M2 要做的是引入 token 计算器在把消息发往模型前预估消耗动态裁剪最久远但无关紧要的内容能省下可观的资源。第二条线是引入工具调用。DevFlow Harness 的核心目标之一就是让 Agent 能调用外部工具——查数据库、读文件、调 API。M1 还没涉及但骨架的 provider 层做好了字段映射后面加入 tool_calls 解析和工具执行器相对容易。第三条线是多端会话同步。目前 H5 端和小程序端各自维护消息列表换端就断档。后面想通过后端持久化会话让用户从 H5 切到小程序时无缝续聊。利好是骨架层已经做了 sessionID 抽象数据模型不需要动大手术。最后再分享一个我在整个项目过程中反复迭代出来的心得做全栈 AI 项目最大的风险不在功能太少而在功能太散。每加一个新能力先问它服务的是不是用户真实场景里那条主线流程。DevFlow Harness 的 M1 到现在我一直坚持一个原则——只有当SSE 对话闭环稳定到完全不用操心时才开始碰更大胆的铺陈。项目走到今天我终于理解为什么好多人一上来就栽在流式输出上——他们追求的只是能冒出字来而没有意识到流式输出的背后是一场涉及网络协议、并发控制、异常兜底与多端差异的系统工程。把 M1 的骨架打扎实后面的路会顺畅很多。
02
RELATED NEWS

相关资讯

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

03
WHY YAOTU

想打造同款高转化官网?

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

场景化定制

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

营销型架构

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

全周期服务

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

免费获取你的建站方案

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