全栈AI应用骨架:SSE流式对话与中断机制实战解析
2026/9/24 11:28:54 网站建设 项目流程

自从开始做全栈 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].delta,DeepSeek 也类似,但其他一些国产模型可能在 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 API(OpenAI 兼容模式)流式对话生成

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: delta),data 为纯文本字符串,前端直接追加到当前回复文章末尾。
  • 收尾元信息(event: done),data 为 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.RequestContext传入,使整个 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 流式读取方案

前端这块我用的是fetchReadableStream来实现,没有依赖eventsource-polyfill。原因有两点:原生 EventSource 不支持自定义请求头和 POST 方法,而我们需要在 POST body 里传 sessionID 和用户消息;EventSource 的自动重连机制在流式场景下不太好控制。搭配 AbortController 管理取消动作。

核心代码封装在composables/useSSE.ts

export 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 = reactive<Record<string, 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)工具函数,在interruptederror两个状态下都会调用。如果只是正常完成则不需要,done事件里的内容肯定是完整闭合的。

5. 从 Harness 到前后端:流式面板的布局设计与多端适配

标题里还有一个关键词"流式布局面板"。当对话内容持续动态变化时,传统的静态布局方案不够用——输入框、消息区、状态栏的高矮位置都得跟随内容流动调整。

我在 M1 里做了一个会话面板组件,核心思路可以浓缩成三个布局状态:

  • idle 态:输入框可供编辑,消息列表显示历史会话。
  • streaming 态:模型输出逐字追加,消息区自动滚动到底部,输入框变为"停止生成"按钮区。
  • collapsed 态:面板宽度可调节,聊天内容与整体应用窗口可以左右分栏或叠放。

实现时有一个困扰很久的问题:自动滚动。用户往上翻看历史记录时,浏览器会持续触发滚动事件把页面拽到底部,干扰阅读。后来用了一个判断——只有当用户滚动位置接近底部(比如距离底部小于 100px)时才自动跟随;用户有明显上翻意图时暂停跟随,直到用户手动滚回底部。这个机制虽然代码不过几行,却是真实使用体验的分水岭。

另一个难点是 Uniapp 端的适配。H5 端能顺畅使用fetch流式读取,但微信小程序原生环境不支持ReadableStream。这意味着useSSE.ts在 H5 端能跑通,拿到小程序端要另选方案。我用的是 Uniapp 的requestenableChunked: 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设置ReadTimeoutWriteTimeout,以及 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; charset=utf-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 的骨架打扎实,后面的路会顺畅很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询