字节笔记本
2026年7月20日
用 Go 搭建流式 AI 对话接口:Gin + JWT + SSE 实战
用 Go 实现一个流式 AI 对话接口,核心就是让后端把 OpenAI API 的流式响应(SSE)原样转发给前端。用 Gin 框架搭 API、JWT 做鉴权、前端用 fetch + ReadableStream 接收流式数据并逐步渲染。下面把完整实现拆开讲。
项目结构
chat-app/
├── main.go
├── config/
│ └── config.go
├── middleware/
│ └── auth.go
├── handlers/
│ └── chat.go
├── utils/
│ ├── jwt.go
│ └── response.go
├── routes/
│ └── routes.go
└── .env配置管理
用环境变量管理 API Key 和 JWT 密钥:
// config/config.go
package config
import (
"os"
)
type Config struct {
Port string
JWTSecret string
OpenAIKey string
Debug bool
}
var Config *Config
func Init() {
Config = &Config{
Port: getEnv("PORT", "8080"),
JWTSecret: getEnv("JWT_SECRET", ""),
OpenAIKey: getEnv("OPENAI_API_KEY", ""),
Debug: os.Getenv("DEBUG") == "true",
}
}
func getEnv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}.env 文件:
PORT=8080
DEBUG=true
JWT_SECRET=your-secret-key
OPENAI_API_KEY=your-openai-api-keyJWT 工具
// utils/jwt.go
package utils
import (
"errors"
"fmt"
"time"
"github.com/golang-jwt/jwt/v5"
)
type JWTClaims struct {
UserID uint `json:"user_id"`
Username string `json:"username"`
jwt.RegisteredClaims
}
func GenerateToken(userID uint, username string) (string, error) {
claims := JWTClaims{
UserID: userID,
Username: username,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now()),
Issuer: "chat-app",
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(config.Config.JWTSecret))
}
func ParseToken(tokenString string) (*JWTClaims, error) {
token, err := jwt.ParseWithClaims(tokenString, &JWTClaims{}, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return []byte(config.Config.JWTSecret), nil
})
if err != nil {
return nil, err
}
if claims, ok := token.Claims.(*JWTClaims); ok && token.Valid {
return claims, nil
}
return nil, errors.New("invalid token")
}中间件:JWT 鉴权 + CORS
// middleware/auth.go
package middleware
import (
"net/http"
"strings"
"chat-app/utils"
"github.com/gin-gonic/gin"
)
func AuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing authorization header"})
c.Abort()
return
}
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
claims, err := utils.ParseToken(tokenString)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
c.Abort()
return
}
// 将用户信息存入上下文
c.Set("userID", claims.UserID)
c.Set("username", claims.Username)
c.Next()
}
}
func CorsMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
c.Header("Access-Control-Allow-Origin", "*")
c.Header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(http.StatusNoContent)
return
}
c.Next()
}
}流式 Chat Handler(核心)
这是整个项目的核心——接收前端请求,转发给 OpenAI API,然后逐块把流式响应返回给前端。
// handlers/chat.go
package handlers
import (
"bufio"
"bytes"
"encoding/json"
"io"
"log"
"net/http"
"os"
"strings"
"chat-app/config"
"chat-app/utils"
"github.com/gin-gonic/gin"
)
// ChatRequest 聊天请求体
type ChatRequest struct {
Model string `json:"model"`
Messages []map[string]interface{} `json:"messages"`
Stream bool `json:"stream"`
}
// LoginRequest 登录请求体
type LoginRequest struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
}
// Login 登录接口(简化版,实际应查数据库)
func Login(c *gin.Context) {
var req LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 简化验证,实际应对比数据库中的密码哈希
if req.Username == "admin" && req.Password == "password" {
token, err := utils.GenerateToken(1, req.Username)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate token"})
return
}
c.JSON(http.StatusOK, gin.H{"token": token})
return
}
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
}
// StreamChat 流式聊天接口
func StreamChat(c *gin.Context) {
var req ChatRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 如果前端没传 stream,默认开启
if !req.Stream {
req.Stream = true
}
// 构造发给 OpenAI 的请求体
reqBody, err := json.Marshal(map[string]interface{}{
"model": req.Model,
"messages": req.Messages,
"stream": true,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to marshal request"})
return
}
// 向 OpenAI 发送流式请求
apiURL := os.Getenv("OPENAI_BASE_URL")
if apiURL == "" {
apiURL = "https://api.openai.com/v1/chat/completions"
}
apiReq, err := http.NewRequest("POST", apiURL, bytes.NewBuffer(reqBody))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create request"})
return
}
apiReq.Header.Set("Content-Type", "application/json")
apiReq.Header.Set("Authorization", "Bearer "+config.Config.OpenAIKey)
apiReq.Header.Set("Accept", "text/event-stream")
client := &http.Client{}
resp, err := client.Do(apiReq)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "upstream request failed"})
return
}
defer resp.Body.Close()
// 设置 SSE 响应头
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
c.Header("X-Accel-Buffering", "no") // 防止 Nginx 缓冲
// 逐行读取 OpenAI 的流式响应并转发给前端
flusher := c.Writer
reader := bufio.NewReader(resp.Body)
for {
line, err := reader.ReadString('\n')
if err != nil {
if err == io.EOF {
// 流结束,发送 [DONE] 标记
c.Writer.Write([]byte("data: [DONE]\n\n"))
c.Writer.Flush()
} else {
log.Printf("Stream read error: %v", err)
}
break
}
// 跳过空行和注释行
trimmed := strings.TrimSpace(line)
if trimmed == "" || strings.HasPrefix(trimmed, ":") {
continue
}
// 转发 data 行
if strings.HasPrefix(trimmed, "data: ") {
c.Writer.Write([]byte(line))
c.Writer.Write([]byte("\n\n"))
c.Writer.Flush()
}
}
}关键点:
X-Accel-Buffering: no防止 Nginx 代理时缓冲 SSE 响应。- 用
bufio.NewReader逐行读取,拿到data:开头的行就立即转发并 flush。 - 流结束时手动发送
data: [DONE]\n\n,让前端知道结束了。 OPENAI_BASE_URL环境变量支持替换为 DeepSeek 等兼容 OpenAI 格式的第三方 API。
路由配置
// routes/routes.go
package routes
import (
"chat-app/handlers"
"chat-app/middleware"
"github.com/gin-gonic/gin"
)
func SetupRoutes(r *gin.Engine) {
r.Use(middleware.CorsMiddleware())
// 公开路由
public := r.Group("/api")
{
public.POST("/auth/login", handlers.Login)
}
// 需要鉴权的路由
protected := r.Group("/api")
protected.Use(middleware.AuthMiddleware())
{
protected.POST("/chat/stream", handlers.StreamChat)
}
}主入口
// main.go
package main
import (
"log"
"chat-app/config"
"chat-app/routes"
"github.com/gin-gonic/gin"
"github.com/joho/godotenv"
)
func main() {
// 加载 .env 文件
_ = godotenv.Load()
config.Init()
r := gin.Default()
routes.SetupRoutes(r)
addr := ":" + config.Config.Port
log.Printf("Server starting on %s", addr)
if err := r.Run(addr); err != nil {
log.Fatal("Server failed to start:", err)
}
}前端:单 HTML 文件对接
前端用 fetch + ReadableStream 读取 SSE 流,逐步拼接 AI 回复并渲染。核心是 handleSendMessage 函数中的流式解析逻辑。
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<title>AI Chat</title>
<style>
body { font-family: sans-serif; max-width: 800px; margin: 0 auto; padding: 20px; }
.message { margin: 10px 0; padding: 10px 15px; border-radius: 8px; max-width: 80%; }
.message.user { background: #2563eb; color: white; margin-left: auto; }
.message.bot { background: #e5e7eb; }
#chatMessages { height: 500px; overflow-y: auto; border: 1px solid #ddd; padding: 15px; }
#loginSection { display: flex; flex-direction: column; max-width: 300px; gap: 10px; }
input, button { padding: 8px; }
</style>
</head>
<body>
<div id="loginSection">
<h3>登录</h3>
<input id="username" placeholder="用户名" value="admin">
<input id="password" type="password" placeholder="密码" value="password">
<button onclick="handleLogin()">登录</button>
</div>
<div id="chatSection" style="display:none">
<div style="display:flex;justify-content:space-between;align-items:center">
<h3>AI Chat</h3>
<button onclick="handleLogout()">登出</button>
</div>
<div id="chatMessages"></div>
<div style="display:flex;gap:10px;margin-top:10px">
<input id="messageInput" placeholder="输入消息..." style="flex:1" onkeypress="if(event.key==='Enter')handleSendMessage()">
<button onclick="handleSendMessage()">发送</button>
</div>
</div>
<script>
let token = '';
let messages = [];
async function handleLogin() {
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
const resp = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
const data = await resp.json();
if (data.token) {
token = data.token;
document.getElementById('loginSection').style.display = 'none';
document.getElementById('chatSection').style.display = 'block';
}
}
function handleLogout() {
token = '';
messages = [];
document.getElementById('chatMessages').innerHTML = '';
document.getElementById('loginSection').style.display = 'flex';
document.getElementById('chatSection').style.display = 'none';
}
async function handleSendMessage() {
const input = document.getElementById('messageInput');
const message = input.value.trim();
if (!message || !token) return;
addMessage(message, 'user');
input.value = '';
try {
const response = await fetch('/api/chat/stream', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
model: 'gpt-3.5-turbo',
messages: [...messages, { role: 'user', content: message }],
stream: true
})
});
if (response.status === 401) {
handleLogout();
return;
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let currentResponse = '';
// 预创建 bot 消息气泡
const messagesDiv = document.getElementById('chatMessages');
const botEl = document.createElement('div');
botEl.classList.add('message', 'bot');
messagesDiv.appendChild(botEl);
while (true) {
const { value, done } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
const lines = chunk.split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith(':')) continue;
if (trimmed.startsWith('data: ')) {
const data = trimmed.slice(6);
if (data === '[DONE]') continue;
try {
const parsed = JSON.parse(data);
const content = parsed.choices[0].delta.content;
if (content) {
currentResponse += content;
botEl.textContent = currentResponse;
messagesDiv.scrollTop = messagesDiv.scrollHeight;
}
} catch (e) {
// 非 JSON 行(如空 data 或事件行),跳过
}
}
}
}
messages.push(
{ role: 'user', content: message },
{ role: 'assistant', content: currentResponse }
);
} catch (error) {
addMessage('请求失败: ' + error.message, 'bot');
}
}
function addMessage(content, role) {
const div = document.getElementById('chatMessages');
const el = document.createElement('div');
el.classList.add('message', role);
el.textContent = content;
div.appendChild(el);
div.scrollTop = div.scrollHeight;
}
</script>
</body>
</html>SSE 解析的坑
这个项目调试过程中踩过的几个问题:
-
data:前缀和 JSON 混在一起:后端转发的 SSE 行格式是data: {"choices":[...]},前端解析时必须先去掉data:前缀再JSON.parse。有些实现返回的是event:message\ndata:xxx双行格式,这时只处理data:开头的行就行,event:行直接跳过。 -
Nginx 缓冲:部署到生产环境后 SSE 可能不工作,因为 Nginx 默认缓冲上游响应。解决方案是在 handler 里加
X-Accel-Buffering: no响应头,或者在 Nginx 配置中proxy_buffering off。 -
data 后面有空行:SSE 协议用空行(
\n\n)分隔事件,有些 API 返回的数据中data:行后面可能紧跟空data:行(data:\n\n),解析前先trim()再判断是否为空。 -
流没有正常结束:OpenAI API 在流结束时发送
data: [DONE],后端转发后前端收到就跳出循环。如果后端自己做聚合或转换,别忘了手动发这个结束标记。
依赖
// go.mod
module chat-app
go 1.21
require (
github.com/gin-gonic/gin v1.9.1
github.com/gin-contrib/cors v1.4.0
github.com/golang-jwt/jwt/v5 v5.2.0
github.com/joho/godotenv v1.5.1
)整套实现可以直接作为 AI 对话类应用的骨架,换成 DeepSeek、Claude 等 API 只需要改 OPENAI_BASE_URL 环境变量。