灵犀 IM - 从零构建 Go IM 系统WebSocket AI Agent gRPC 全栈实践关键词Go / Gin / WebSocket / GORM / Redis / gRPC / go-zero / AI Function Calling / Docker一、项目背景与整体思路我决定做一个真实可用的 IM即时通讯系统而不是仅仅 CRUD 教程。目标是覆盖 WebSocket 长连接、离线消息、群聊等 IM 核心场景集成 AI 对话能力AI 指令 Function Calling 工具调用管理后台通过 gRPC 与业务服务通信支持 etcd 服务发现前后端分离Docker 一键部署最终项目实现了以下完整功能链路Vue3 前端 ↕ HTTP REST (Gin) ← JWT 鉴权、限流、日志脱敏 ↕ WebSocket ← 实时消息、心跳、ACK、离线推送 ↕ SSE 流式 ← AI 对话逐 token 输出 ↕ gRPC (go-zero) ← 管理后台服务调用数据层MySQLGORM Redis缓存 / 会话二、技术栈总览分层技术选型说明Web 框架Gin v1.12路由、中间件、参数绑定实时通信gorilla/websocket长连接管理ORMGORM v1.31 MySQL Driver数据持久化缓存go-redis/v9离线消息辅助、会话缓存认证golang-jwt/jwt v5JWT 无状态鉴权密码安全golang.org/x/crypto (bcrypt)密码哈希微服务框架go-zero v1.10gRPC 服务注册/发现 (etcd)日志go.uber.org/zap结构化日志限流golang.org/x/time/rate令牌桶AI 调用自封装 HTTP 客户端兼容 OpenAI 格式支持主备切换容器化Docker 多阶段构建 Docker Compose镜像最小化配置spf13/viperyaml 配置热加载三、项目架构分层 依赖注入3.1 目录结构go-zero-im/ ├── cmd/server/main.go # 程序入口统一创建服务实例 ├── config/ # 配置结构体 viper 读取 ├── internal/ │ ├── handler/ # HTTP HandlerGin 控制器 │ ├── service/ # 业务逻辑层interface 实现 │ ├── repository/ # 数据访问层GORM 封装 │ ├── model/ # GORM 数据模型 │ ├── middleware/ # JWT、限流、日志、CORS、Recovery │ ├── router/ # 路由注册用户侧 8080 管理侧 9090 │ └── ws/ # WebSocketHub Client Protocol ├── rpc/ │ ├── proto/ # Protobuf 定义 │ ├── pb/ # 生成的 Go 代码 │ └── server/ # gRPC Server 实现复用 service 层 └── pkg/ ├── ai/ # AI HTTP 客户端非流式/流式/工具调用 ├── prompts/ # 系统提示词从文件加载 ├── news/weather/route/ # AI 工具实现获取新闻/天气/导航 └── utils/ # JWT 工具、响应封装3.2 依赖注入单点创建多处共享这里踩过一个经典坑aiSvc在router.go和main.go里各创建了一次导致 WebSocket AI 和 HTTP /ai/chat/stream 的上下文完全隔离——用 WS 聊的内容HTTP 接口完全不知道。解决方案是严格在main.go统一创建所有服务实例router.Register只接收已创建好的服务// main.go所有 service 只创建一次 userSvc : service.NewUserService(userRepo) messageSvc : service.NewMessageService(msgRepo) groupSvc : service.NewGroupService(groupRepo) aiSvc : service.NewAIService(cfg.AI, aiContextRepo) adminSvc : service.NewAdminService(adminRepo, groupRepo, msgRepo) hub : ws.NewHub(messageSvc, groupSvc, aiSvc) go hub.Run() // 所有 handler 和 hub 共享同一套 service router.Register(r, log, cfg, hub, userSvc, messageSvc, groupSvc, aiSvc, adminSvc)四、WebSocket 核心Hub/Client 模型4.1 Hub在线连接注册表Hub 是整个 WebSocket 层的核心维护一张username → *Client的映射表通过 channel 驱动事件循环避免直接对 map 加锁。type Hub struct { mu sync.RWMutex // 保护 clients map clients map[string]*Client // key 用户名 register chan *Client unregister chan *Client messageSvc service.MessageService groupSvc service.GroupService aiSvc service.AIService }注册/注销操作全部通过 channel 发到事件循环里串行处理查询SendToUser使用读锁做到读写分离func (h *Hub) Run() { for { select { case c : -h.register: h.mu.Lock() // 同一用户二次登录时关闭旧连接踢下线 if old, ok : h.clients[c.username]; ok { close(old.send) } h.clients[c.username] c h.mu.Unlock() case c : -h.unregister: h.mu.Lock() // 防止新连接上线后旧连接的 unregister 误删新连接 if current, ok : h.clients[c.username]; ok current c { delete(h.clients, c.username) close(c.send) } h.mu.Unlock() } } }4.2 Client读写分离的双协程模型每个 WebSocket 连接对应一个Client启动两个 goroutinereadPump goroutine ← 读客户端发来的消息处理业务逻辑 writePump goroutine ← 消费 send channel把消息写回客户端心跳保活writePump 定时发 Ping 帧pingPeriod pongWait * 9/10readPump 的 PongHandler 收到后续期读超时确保长时间无消息的连接不被服务端或中间代理强制断开。ticker : time.NewTicker(pingPeriod) // 约 54 秒 // ... case -ticker.C: c.conn.SetWriteDeadline(time.Now().Add(writeWait)) c.conn.WriteMessage(websocket.PingMessage, nil)ACK 机制每条 TypeChat 消息落库后服务端回TypeAck区分delivered对方在线已推和stored对方离线已存库前端可据此渲染消息状态。4.3 消息协议设计type Message struct { Type int json:type // 1心跳 2单聊 3ACK 4群聊 5系统通知 MsgID string json:msg_id // 客户端生成的全局唯一ID幂等去重 From string json:from // 服务端强制写入防前端伪造 To string json:to // 目标用户名单聊或群ID字符串群聊 Content string json:content Timestamp int64 json:timestamp Status string json:status,omitempty // ACK状态 ChatType string json:chat_type,omitempty // private / group ConvPeer string json:conv_peer,omitempty // AI 回复时告知前端消息归属哪个会话 }From字段由服务端在 readPump 中强制覆盖为已认证的c.username客户端无法伪造发送方。五、消息存储与离线推送5.1 写入流程先落库再投递客户端发消息 ↓ readPump 接收 JSON ↓ messageSvc.SavePrivateMessage() // 先写 MySQL ↓ hub.SendToUser(to, rawMsg) // 尝试投递 ├── 在线 → 推送到对方 send channel → MarkDelivered └── 离线 → 消息已存库statusstored → 等待下次上线拉取 ↓ 给发送方回 ACKdelivered / stored5.2 上线时拉取离线消息连接建立ws.Serve函数时立即拉取两类离线消息单聊离线消息查询status stored且to username的消息推送后批量标记为delivered。群聊离线消息这里采用了per-user 读取位点方案每个群成员的group_members表记录last_read_at上次断线时间点-- 查询某用户在所有群的离线消息 SELECT m.* FROM messages m JOIN group_members gm ON m.to gm.group_id AND gm.username ? AND m.created_at COALESCE(gm.last_read_at, gm.joined_at) WHERE m.to IN (?) AND m.chat_type group ORDER BY m.created_at ASC断线时 readPump 的defer更新last_read_atdefer func() { myGroups, err : c.hub.groupSvc.GetMyGroups(c.username) if err nil { ids : // 提取 groupID 列表 _ c.hub.groupSvc.UpdateLastReadAt(c.username, ids) } c.hub.unregister - c c.conn.Close() }()这套方案彻底解决了群消息重复推送问题之前依赖共享status字段多人群聊时会漏推或重复推。5.3 会话列表统一排序会话列表同时包含单聊和群聊两种消息分别查询后 append 到同一切片最后统一按last_time倒序// 修复前单聊有序但 append 群聊后打乱顺序 // 修复后 sort.Slice(conversations, func(i, j int) bool { return conversations[i].LastTime conversations[j].LastTime })六、AI 集成从简单对话到 Agentic 工具调用这是整个项目里技术含量最高的部分分三个层次递进。6.1 AI 指令WebSocket 内嵌 AI 对话在聊天窗口发送AI 你好时readPump 拦截处理if len(msg.Content) 3 msg.Content[:3] AI { question : strings.TrimPrefix(msg.Content[3:], ) if question { continue } // 防空问题触发 AI // ... }群聊 AI 的完整流程顺序很关键1. 先把用户的 AI 消息落库保证历史完整 2. 提前查群成员列表后面两次广播复用减少 DB 查询 3. 立即把用户消息广播给其他群成员不等 AI 返回体验更流畅 4. 拉取近 30 条群聊记录拼入 AI 提问上下文 5. 调用 AI主备切换 6. 把 AI 回复存库并广播给所有在线成员AI 回复内容带上提问者信息群历史里上下文清晰[jack 向AI提问] 什么是 goroutine goroutine 是 Go 语言的轻量级线程...6.2 上下文管理滚动摘要压缩每个用户独立维护对话上下文超出阈值时异步压缩为摘要保证历史不丢失的同时避免 token 无限增长type userContext struct { summary string // 历史摘要已压缩部分 recent []ai.ChatMessage // 最近未压缩的对话 }构建发送给 AI 的消息列表messages append(messages, ai.ChatMessage{ Role: system, // 主人设 历史摘要合并为一条 system 消息 // 拆成两条 system 消息某些提供商会返回空回复 Content: prompts.WithChatSummary(prompts.ChatSystem, uc.summary), }) messages append(messages, uc.recent...) // 近期原始对话异步压缩不阻塞当前回复if needSummarize reply ! { go func() { newSummary, err : s.summarize(oldSummary, toSummarize) // 只清除已被总结的消息保留压缩期间并发进来的新消息 s.mu.Lock() uc.summary newSummary uc.recent uc.recent[removedCount:] s.mu.Unlock() s.persistContext(username) // 异步落库进程重启后可恢复 }() }上下文还支持持久化到 MySQLai_context表存summary recent_json进程重启后首次访问时按需从 DB 恢复无需重新建立对话。6.3 主备 AI 切换封装了一个简单但实用的主备切换func (s *aiService) callAI(messages []ai.ChatMessage) (string, error) { reply, err : ai.Call(s.primaryCfg, messages, 60) if err ! nil { log.Printf(primary ai failed: %v, switching to backup, err) reply, err ai.Call(s.backupCfg, messages, 60) } return reply, err }配置文件里分别配置主力如 DeepSeek和备用如 Qwen模型无缝切换上下文不丢失。6.4 Function CallingAgentic 工具调用这是项目最有意思的部分。用户可以用自然语言操作 IM 功能帮我建一个叫Go学习小组的群把 alice、bob、carol 都加进去系统会自动执行多轮工具调用轮次1AI 决策 → create_group(Go学习小组) → 执行得到 group_id 42 轮次2AI → invite_members(group_id42, [alice,bob,carol]) → 执行邀请结果追加到消息链 轮次3AI 不再调用工具 → 退出循环 最终流式生成自然语言回复每个 chunk 通过 SSE 推给前端工具执行器采用闭包注入当前用户名和业务服务func (h *AIHandler) buildUserToolExecutor(username string) func(name string, args map[string]interface{}) (string, error) { return func(name string, args map[string]interface{}) (string, error) { switch name { case create_group: name, _ : args[name].(string) group, err : h.groupSvc.CreateGroup(name, username, 200) // ... case invite_members: // 解析 group_id支持 group_name 模糊匹配兜底 groupID, err : resolveGroupID(args) // ... case get_weather: city, _ : args[city].(string) info, err : weather.FetchWeather(city) return weather.Format(info), err // ... 更多工具 } } }SSE 事件流前端能实时看到工具执行过程// AI 决定调工具 {type:tool_call,name:create_group,args:{\name\:\Go学习小组\}} // 工具执行完成 {type:tool_result,name:create_group,result:群聊创建成功ID42,success:true} // 最终回复的文字片段 {type:chunk,content:好的我已经帮你...} // 全部完成 {type:done,content:}工具对话历史同样支持滚动摘要压缩和持久化typetool的消息在摘要时被格式化为可读文本。内置工具清单工具名功能search_user搜索用户关键词get_my_groups查询我加入的群search_group按名称搜索群聊create_group创建群聊join_group加入群聊leave_group退出群聊支持 group_name 模糊匹配invite_member/members邀请批量成员kick_member/members踢批量成员disband_group解散群聊get_contacts获取联系人列表add_contact添加联系人delete_contact删除联系人get_news获取最新新闻分类get_weather查询城市天气get_route查询两地路线七、中间件安全与可观测性7.1 JWT 鉴权func AuthJWT(secret string) gin.HandlerFunc { return func(c *gin.Context) { auth : c.GetHeader(Authorization) // Bearer token claims, err : utils.ParseToken(secret, parts[1]) if err ! nil { response.Fail(c, 401, 401, invalid token) c.Abort() return } c.Set(username, claims.Username) c.Set(role, claims.Role) // 后续 handler 可做角色鉴权 c.Next() } }WebSocket 升级时也校验 JWT连接建立后绑定到client.username后续收到的消息From字段均由服务端填写无法伪造。7.2 结构化请求日志 敏感字段脱敏请求进入时记录 method / path / body / IP请求结束后记录状态码、耗时、响应大小。5xx 用 Error 级别4xx 用 Warn2xx/3xx 用 Info。核心是密码脱敏——正则替换 JSON body 中的 password、secret、api_key、token 字段值var sensitivePatterns []*regexp.Regexp{ regexp.MustCompile((password\s*:\s*)[^]*), regexp.MustCompile((secret\s*:\s*)[^]*), regexp.MustCompile((api_key\s*:\s*)[^]*), regexp.MustCompile((token\s*:\s*)[^]*), } func maskSensitive(body string) string { for _, p : range sensitivePatterns { body p.ReplaceAllString(body, ${1}***) } return body }这个正则在最初被注释掉了导致用户登录密码明文写入日志——这是真实修复过的安全 bug。7.3 令牌桶限流func RateLimit(qps int, burst int) gin.HandlerFunc { limiter : rate.NewLimiter(rate.Limit(qps), burst) return func(c *gin.Context) { if !limiter.Allow() { response.Fail(c, 429, 429, too many requests) c.Abort() return } c.Next() } } // 全局200 QPS允许 50 突发 r.Use(middleware.RateLimit(200, 50))八、群聊的安全设计群聊部分有几处容易被忽略的安全细节1. 发消息前校验群成员身份readPump 处理 TypeGroupChat 时查出群成员列表后校验c.username是否在其中不在则返回 ACK failed 并拒绝广播。2. 查看群历史需校验成员身份HTTP 接口GET /api/v1/message/history?group_idxxx需要先确认当前用户是该群成员否则任意用户可查任意群历史。3. 查看群成员需校验GET /api/v1/group/members?group_idxxx同样验证当前用户是否在群里防止非成员探测群成员列表。九、gRPC go-zero管理后台服务管理后台端口 9090使用 go-zero 框架通过 gRPC 暴露管理接口用户管理、群组管理、消息管理关键是复用了业务层的 service而非重新连接 DB// GroupRpcServer 复用现有 AdminService不新建 DB 连接 type GroupRpcServer struct { pb.UnimplementedGroupRpcServer adminSvc service.AdminService } func (s *GroupRpcServer) ListGroups(ctx context.Context, req *pb.ListGroupsReq) (*pb.ListGroupsResp, error) { groups, total, err : s.adminSvc.ListGroups(int(req.Page), int(req.PageSize), req.Keyword) // ... }Protobuf 统一定义通用响应message CommonResp { int32 code 1; string msg 2; }配置文件支持两种模式monolith单体部署gRPC Server 和 HTTP Server 同进程直连调用microservice独立部署通过 etcd 服务注册/发现十、Docker 部署多阶段构建最小化镜像# Stage 1编译golang:1.26-alpine FROM golang:1.26-alpine AS builder ENV GOPROXYhttps://goproxy.cn,direct # 先复制 go.mod/go.sum 利用构建缓存依赖不变时不重新下载 COPY go.mod go.sum ./ RUN go mod download COPY . . # 关闭 CGO静态链接-ldflags-s -w 去掉调试信息缩小体积 RUN CGO_ENABLED0 GOOSlinux GOARCHamd64 \ go build -ldflags-s -w -o server ./cmd/server/main.go # Stage 2最小运行镜像alpine:3.20 FROM alpine:3.20 RUN apk add --no-cache tzdata ca-certificates \ cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime COPY --frombuilder /build/server ./server COPY --frombuilder /build/config ./config COPY --frombuilder /build/pkg/prompts/data ./pkg/prompts/data EXPOSE 8080 9090 CMD [./server, -c, config/config.yaml]docker-compose 结构services: backend: image: im_backend:latest extra_hosts: - host.docker.internal:host-gateway # Linux 访问宿主机 MySQL/Redis 必须 expose: [8080, 9090] frontend: image: im_frontend:latest # Nginx Vue 静态文件 ports: [3001:80] depends_on: [backend]MySQL 和 Redis 以独立容器运行在宿主机通过host.docker.internal访问方便本地开发和生产复用同一份数据。十一、踩坑记录13 个真实修复的 Bug这部分记录了项目开发中遇到的真实问题每一个都代表一类典型错误#文件问题根因/修复1middleware/logger.go登录密码明文写入日志脱敏正则被注释掉已恢复2router.go main.goAI 与 HTTP AI 对话上下文隔离aiSvc 重复创建改为 main.go 统一创建3handler/group.gojoinned拼写错误改为joined4handler/group.goParseUintbit size 传了 60改为 645ws/client.go群聊 AI 回复不入库单聊有 SaveMessage群聊遗漏已补6ws/client.goAI发送空问题仍调 AIlen 3不足够加空内容守卫7message_repo.go单聊会话列表乱序缺ORDER BY last_time DESC已补8handler/message.go群聊历史无成员校验任意用户可查任意群已加成员身份验证9handler/group.go查群成员无鉴权任意用户可探测任意群成员已修复10ws/client.go群聊 AI 回复缺少提问者上下文补齐[user 向AI提问] 内容格式11message_repo.go会话列表混合单群聊后乱序append 后未统一排序加sort.Slice12handler/user.go/user/me只返回 usernameIM 需展示昵称头像新增 GetUser 返回完整字段13群消息离线推送依赖共享 status 字段导致漏推/重推引入 per-userlast_read_atJOIN 查询精确过滤这些 bug 里#2服务实例重复创建和#13群消息离线推送是最有价值的——前者揭示了依赖注入不规范的后果后者展示了多人场景下共享状态的经典问题。十二、总结与后续规划已实现完整的单聊/群聊 WebSocket 实时通信含心跳保活、ACK、离线消息per-user 群消息读取位点断线不丢群消息AI 指令私聊/群聊 SSE 流式对话 上下文滚动摘要 持久化AI Function Calling多轮 Agentic 工具调用 天气/新闻/导航等工具多 AI 提供商主备切换JWT 鉴权 密码脱敏日志 令牌桶限流gRPC 管理后台复用 service 层支持 etcd 服务发现Docker 多阶段构建docker-compose 一键部署待完善消息可靠性当前 ACK 没有客户端重发机制极端网络下可能丢消息水平扩展多实例部署时 Hub 是内存状态需引入 Redis Pub/Sub 跨实例推送安全加固config.yaml 中 DB 密码和 AI Key 尚为明文生产需迁移到环境变量或 Secrets 管理整个项目从架构设计到 bug 修复覆盖了 Go 后端开发中最核心的几类问题并发安全Hub 的读写锁、协议设计WebSocket 消息类型、AI 集成上下文管理与工具调用、安全实践鉴权与脱敏。希望这篇文章对正在学习 Go 的你有所帮助。