简介:这套源码是一份可直接运行的微信公众号后台 Golang 服务,实现 DeepSeek API 的快速接入,面向有一定 Go 基础、希望让微信用户直接使用 AI 能力的开发者,省去打开网页或插件的麻烦。压缩包共 14 个文件,以 11 个 go 源文件为主,配合 go.mod、go.sum 完成依赖管理与校验,入口文件则负责 HTTP 服务启动与路由注册,整体仅 5.35MB,轻量易部署。代码按 wxapi、chatapi、convert、util 等模块清晰划分,覆盖微信文本消息的接收与回复、DeepSeek 聊天请求/响应结构体,以及天气、段子、指数等扩展 API 调用示例,并附带消息格式转换工具,方便理解和二次开发。目前已有 1584 人学习下载。通过这份工程,读者可快速掌握微信公众号后台对接大模型 API 的完整链路,理解消息加解密与接口调用的衔接方式,并据此搭建属于自己的微信 AI 助手,也可作为学习 Go 微信开发的实战参考。
1. 把DeepSeek塞进公众号的Golang后端:为什么这条路值得走
你的公众号用户发来一句“帮我写一份周报”,后台的Golang服务收到XML消息,转成一次大模型API调用,几秒后把答案作为公众号消息推回去。这个链路听起来简单,实际做起来要过微信的签名校验、消息格式解析、5秒超时限制、API参数调优和上下文管理这几道坎。这篇文章就沿着一条可复现的路线把整个后台源码逻辑拆开讲:从微信公众号开发者模式怎么配置,到Golang服务怎么校验Token、怎么把文本消息转发给DeepSeek API,再到参数怎么设、踩坑怎么避。适合手头有公众号、会一点Go、想快速做一个AI助手的人,照着一路走到能跑通。
2. 公众号接入前的三件事:服务器配置、Token校验和消息路由设计
2.1 公众号服务器配置里的URL、Token和EncodingAESKey分别是什么
公众号后台的“服务器配置”是一切的起点。在公众号后台找到“设置与开发 → 基本配置”,你会看到三个必填项:服务器地址URL、Token、EncodingAESKey。URL是你Golang服务对外可访问的HTTP/HTTPS地址,微信服务器会把用户消息POST到这个地址上。Token是你自己定的一个字符串,英文加数字即可,它的作用不是鉴权,而是参与签名校验——微信服务器每次请求都会带上签名,你的服务拿着这个Token算一遍,能对上才处理,对不上就拒绝。
EncodingAESKey是消息加解密的密钥,在“消息加解密方式”里选明文模式时它不会被用到,但后台仍然强制要求生成。我一般建议前期先用明文模式或兼容模式跑通链路,等业务稳定了再切安全模式。兼容模式的意思是:微信推送来的消息可能是明文也可能是密文,你的服务需要能同时处理两种情况。最省事的做法是先选明文模式,因为所有报文直接用XML明文传输,调试时用curl就能看到完整内容。明文模式的问题是公网接口容易被恶意伪造请求,所以签名校验必须写对,不能图省事跳过。
还有一个容易忽略的点:服务器配置保存时会立刻触发一次“验证请求”,微信服务器用GET方式往你的URL发一个带signature、timestamp、nonce、echostr参数的请求,你的服务必须正确返回echostr原文,后台才会提示“保存成功”。这一步卡住的人非常多,多半是URL不可达或者校验代码有误。后面会给出可以直接跑通的校验代码。
2.2 用Golang在本地先跑通Token校验:一个Handler搞定
Token校验的逻辑微信文档写得很清楚:把token、timestamp、nonce三个参数按字典序排序,拼接成一个字符串,做SHA1加密,得到的值跟signature参数对比,一致就返回echostr。用Golang写出来大约二十行代码。下面是完整实现。
package main import ( "crypto/sha1" "encoding/hex" "fmt" "net/http" "sort" "strings" "github.com/gin-gonic/gin" ) var wxToken = "your_wechat_token_here" // 公众号后台自己填的那个Token func wxVerifyHandler(c *gin.Context) { signature := c.Query("signature") timestamp := c.Query("timestamp") nonce := c.Query("nonce") echostr := c.Query("echostr") tmp := []string{wxToken, timestamp, nonce} sort.Strings(tmp) hash := sha1.Sum([]byte(strings.Join(tmp, ""))) if hex.EncodeToString(hash[:]) == signature { c.String(http.StatusOK, echostr) // 原样返回才能保存成功 return } c.String(http.StatusForbidden, "invalid signature") }逻辑说明:sort.Strings把三个字符串按字典序排好,strings.Join拼成无分隔符的字符串,sha1.Sum算哈希,最后用hex转成小写十六进制字符串和signature比对。注意这里的Token是公众号后台你自己定义的那个字符串,不是DeepSeek的API Key,也不是后面要讲的LLM计费单位,三个东西完全不一样。校验失败时我习惯返回403而不是200,这样微信后台的验证失败日志里能看出来是被主动拒绝的,而不是网络不通。
参数说明:gin框架里c.Query取的是URL参数,微信的GET验证请求用不到请求体,所以不用处理POST body。如果你不用gin,标准库写法也完全一样,逻辑不变。排序顺序是字典序,不是时间序,也不是数组原序,这个细节错一个字符都校验不过。
2.3 消息路由:文本消息分发给DeepSeek,其他消息怎么办
Token校验通过后,微信服务器的POST消息请求就会打到同一个URL上。这时请求体是XML格式,msgType字段标明消息类型,取值有text、image、voice、video、event等。我们的目标很明确:只有text消息才转发给DeepSeek,event里的subscribe(关注)事件可以回一个欢迎语,其余类型返回空字符串即可。
路由的关键是先把XML解析成结构体。Golang标准库encoding/xml可以胜任,不需要引第三方包。文本消息的XML结构包含ToUserName、FromUserName、CreateTime、MsgType、Content、MsgId这几个字段。注意ToUserName是公众号原始ID,FromUserName是用户的OpenID,后面回复消息时这两个字段要互换。
type WxTextMsg struct { ToUserName string `xml:"ToUserName"` FromUserName string `xml:"FromUserName"` CreateTime int64 `xml:"CreateTime"` MsgType string `xml:"MsgType"` Content string `xml:"Content"` MsgId string `xml:"MsgId"` } func wxMsgHandler(c *gin.Context) { body, _ := c.GetRawData() var msg WxTextMsg if err := xml.Unmarshal(body, &msg); err != nil { c.String(http.StatusOK, "success") return } switch msg.MsgType { case "text": // 进入DeepSeek调用流程 handleTextMessage(c, msg) case "event": // 关注/取消关注事件,可按需返回欢迎语 c.String(http.StatusOK, "success") default: c.String(http.StatusOK, "success") } }逻辑说明:xml.Unmarshal把微信推送的XML解析到结构体。解析失败时不要返回非200状态码,直接返回success让微信侧的“重试机制”停下来,不然微信会连续重试三次。event事件这里先不透出逻辑,关注事件如果想要欢迎语,也是拼一个XML回复文本返回。路由层只做分流,不处理具体AI逻辑,这样后期加指令词、加开关都比较干净。
3. 对接DeepSeek API的后端实现:从消息到回复的完整链路
3.1 DeepSeek API的请求格式:为什么要复用Chat Completions协议
DeepSeek的API对外走的是目前大模型行业通用的Chat Completions接口风格:一个HTTP POST请求,带上你的鉴权密钥,请求体是JSON,里面包含model、messages、temperature这些标准字段。这个设计的好处是客户端代码几乎不需要针对特定厂商做适配,你已经见过的任何一份大模型HTTP客户端代码,改一下BaseURL和model名就能跑。坏处是网上大量示例代码是Python或Node写的,Golang的版本得自己拼HTTP请求,但这反而简单。
请求体的核心是messages数组,每个元素有role和content两个字段。role有三种:system用来设定模型的行为边界,user代表用户输入,assistant代表历史上模型回复过的内容。多轮对话时只需要把历史消息按顺序塞进这个数组,模型就能感知上下文。我们需要发的请求大约长这样。
{ "model": "deepseek-chat", "messages": [ {"role": "system", "content": "你是一个微信公众号助手,回答尽量简洁。"}, {"role": "user", "content": "帮我写一份周报"} ], "temperature": 0.7, "max_tokens": 1024 }参数说明:model字段填的是你账户下可用的模型ID,具体型号以API文档列出的为准,我一般先拿官方文档上标注的默认聊天模型跑通,再根据成本和效果切换。temperature控制随机性,0.3以下适合代码生成和结构化输出,0.7到0.9适合闲聊。max_tokens限制回复的最大长度,不是总对话长度,只算本次生成的tokens。这个字段不设的话,模型可能一直生成到上限,导致费用超预期,我建议显式设置。
3.2 用标准库写一个DeepSeek HTTP客户端,不引额外依赖
Golang的net/http足够完成聊天补全请求,不需要引入厂商SDK。下面这段代码把调用封装成一个函数,输入消息历史和参数,输出模型回复文本。有一个值得注意的点:把HTTP超时时间设为30秒,因为公众号异步方案下我们并不同步等结果,30秒是给后台任务用的合理上限。
type ChatMessage struct { Role string `json:"role"` Content string `json:"content"` } type chatResponse struct { Choices []struct { Message ChatMessage `json:"message"` } `json:"choices"` } func callDeepSeek(apiKey string, messages []ChatMessage) (string, error) { // endpoint以API文档给出的地址为准,通常是ChatCompletions路径 url := "https://api.deepseek.com/chat/completions" payload := map[string]any{ "model": "deepseek-chat", "messages": messages, "temperature": 0.7, "max_tokens": 1024, } body, err := json.Marshal(payload) if err != nil { return "", err } req, err := http.NewRequest("POST", url, bytes.NewReader(body)) if err != nil { return "", err } req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+apiKey) client := &http.Client{Timeout: 30 * time.Second} resp, err := client.Do(req) if err != nil { return "", err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("upstream status %d", resp.StatusCode) } var cr chatResponse if err := json.NewDecoder(resp.Body).Decode(&cr); err != nil { return "", err } if len(cr.Choices) == 0 { return "", fmt.Errorf("empty choices") } return cr.Choices[0].Message.Content, nil }逻辑说明:json.Marshal把请求体序列化成JSON,http.NewRequest创建POST请求,Authorization头用Bearer方式带上API Key。响应的choices是个数组,正常情况下取第一个元素的message.content就是模型生成文本。这里返回错误的方式统一用fmt.Errorf包一层,上层调用方可以根据错误判断是网络问题、鉴权失败还是上游限流,日志里能直接看到原因。
参数说明:timeout 30秒是给后台异步任务用的,不能把这个超时直接用到公众号被动回复链路上,否则用户会撞上5秒超时。apiKey从环境变量读取,不要硬编码在代码里。还有一个容易忽略的点:http.Client要复用,不要每次调用都new一个,否则连接和端口很快被耗尽,本地测试看不出来,一上线就出问题。
3.3 被动回复XML和异步回复:两种方案怎么选
把DeepSeek的回复返回给用户,公众号有两条路:同步被动回复和异步客服消息。同步被动回复的流程是:收到用户消息后,你的服务必须在5秒内以XML格式返回一个文本消息,微信会把它发给用户。DeepSeek在冷启动或上下文很长时经常超过5秒,所以同步方案直接撞超时,微信会替你回一句“该公众号暂时无法提供服务”,体验很差。
我的做法是异步方案:先立刻返回一个“收到,正在思考中”的被动回复让微信满意,同时后台goroutine去调用DeepSeek,拿到结果后再通过公众号的客服消息接口主动推送给用户。这种做法要额外维护一个access_token,用公众号的appid和secret换,有效期7200秒,需要缓存。
func asyncReply(openid, apiKey, content string, accessToken string) { replyURL := "https://api.weixin.qq.com/cgi-bin/message/custom/send?access_token=" + accessToken payload := map[string]any{ "touser": openid, "msgtype": "text", "text": map[string]string{"content": content}, } body, _ := json.Marshal(payload) resp, err := http.Post(replyURL, "application/json", bytes.NewReader(body)) if err != nil { log.Printf("push reply failed: %v", err) return } defer resp.Body.Close() }逻辑说明:这个接口是公众号的客服消息接口,报文结构固定,touser是用户的OpenID,text.content是推送的文本内容。access_token作为URL参数拼接,不是放在Header里。推送有频率限制,普通用户每45秒只能接收不超过一定条数的客服消息,所以如果用户连续发多条消息,要做一个简单的合并节流,不能让两个goroutine同时推。
被动回复方案里,如果DeepSeek在超时前返回了,可以直接把结果作为被动回复返回,省掉客服消息这一步。所以我建议路由层这样设计:先设一个2秒的短超时去等DeepSeek,2秒内结果回来了就走同步回复,不然立刻返回缓冲话术,后台再异步完成推送。这个“2秒缓冲”是我试过比较舒服的阈值,过小容易白白走异步,过大留给DeepSeek的时间不够。
4. 参数配置与对话体验:temperature、max_tokens和上下文管理
4.1 上线前必调的三个参数:temperature、max_tokens和系统提示词
先看temperature。这个参数控制生成随机性,取值范围一般是0到1之间。公众号对话场景里,用户问的是生活化问题,希望回复有温度,我通常设0.7到0.8。但如果你的公众号定位是工具型,比如翻译、日报生成、代码解释,temperature要降到0.2到0.3,不然输出经常偏离格式要求。测试方法很简单:同一个问题反复问十次,看答案差异大不大,差异太大就说明temperature偏高。
max_tokens设置要结合公众号场景考虑。微信被动回复的XML没有硬性长度限制,但用户手机屏幕能舒适阅读的长度大约两三百字,超过一千字反而没人看完。我把max_tokens设为1024,足以覆盖大多数场景,同时控制费用。如果业务需要长文输出,比如帮用户写邮件、写方案,可以按接口调大,但要考虑异步推送的体感——用户等一分钟收到一篇两千字的推送,阅读意愿并不高。
系统提示词是被严重低估的参数。DeepSeek这类模型在没有任何system约束时,回复会默认长篇大论,夹带大量解释性内容。我的做法是在system里给一个完整的人设和行为边界:“你是公众号的AI助手,回复使用中文,表达口语化,正文控制在300字以内,不要使用markdown格式,不要输出列表以外的前缀。”这个提示词要反复打磨,因为大模型首次输出的格式、口吻、长度完全由它决定。
4.2 多轮对话的上下文管理:从内存map到Redis
每个用户给公众号发一条消息,就是一个独立的HTTP请求,你的服务默认是无状态的。如果你把每次请求都独立发给DeepSeek,用户问“刚才那道题你还没做”的时候,模型完全不知道“刚才”指的是什么。要支持多轮对话,就必须在服务端保存每个用户的会话历史,并在调用API时把历史拼接进messages数组。
最简单可行的方案是内存map,key用用户的OpenID:
var sessionStore = struct { sync.RWMutex m map[string][]ChatMessage }{m: make(map[string][]ChatMessage)} func loadHistory(openid string, limit int) []ChatMessage { sessionStore.RLock() defer sessionStore.RUnlock() msgs := sessionStore.m[openid] if len(msgs) > limit { msgs = msgs[len(msgs)-limit:] } return msgs }逻辑说明:sessionStore用读写锁保护并发安全,每个用户最多保留limit条历史消息,超出就从最早的开始丢弃。对话历史的裁剪很有必要,因为DeepSeek API按输入tokens计费,历史消息越长,每次请求的成本越高。10轮以内的对话体验最好,超过10轮,模型对早期内容的依赖会迅速下降,直接把最早的一半丢掉,效果几乎不受影响。
内存方案的边界要讲清楚:单实例部署没问题,一旦上了多实例,每个实例的map是独立的,用户请求被负载均衡打到不同实例上,上下文就丢了。这时候要换成Redis,key设计成wx:session:{openid},value直接用JSON序列化后的messages数组,TTL设24小时。公众号对话场景很少会跨一整天,TTL能控制内存和存储成本。
4.3 错误处理:上游超时、限流和兜底话术
DeepSeek API在高峰期可能返回限流错误或网关超时。Golang调用时的错误类型要区分对待:连接超时、HTTP 429限流、HTTP 401鉴权失败、HTTP 5xx上游故障,这四类情况的处理策略完全不同。429要退避重试,退避间隔建议至少1秒起步,最多重试2次;5xx可以先重试一次,仍然失败就走兜底;401说明API Key配置错了,重试也没有意义,直接告警。
兜底话术的文案不要用工程味太重的句子。“系统正忙,请稍后再试”比“服务内部错误”更适合给用户看到。我习惯在异步推送失败时把兜底话术作为被动回复的备选,保证用户至少收到一条回复。日志方面,把上游status_code、耗时、用户openid和model名全部打全,排查问题时少走弯路。
5. 避坑:公众号接入DeepSeek的常见翻车点
5.1 服务器配置一直验证失败,问题出在哪
现象:公众号后台点“保存”后提示配置失败,没有任何具体原因,浏览器看URL也能正常打开。
原因:这个现象九成是URL没有用公网能访问的HTTPS地址,或者访问URL时返回了非200状态码。网页能打开不代表微信服务器能打开,微信验证请求是GET方式,会带上签名参数,如果你在浏览器里访问没报错,而在后台配置时报错,说明校验逻辑本身有问题。
解决:第一步,用命令行curl带参数测试验签逻辑是否正常,比如curl -G -d"timestamp=xxx&nonce=xxx&signature=xxx&echostr=hello"你的URL,看是否返回hello。第二步,检查URL是否用了不支持的端口或证书。第三步,如果一切正常仍然失败,看服务日志里有没有收到请求——收不到请求说明是网络层问题,收到了但返回403说明是校验逻辑问题。这个排查顺序能覆盖绝大多数情况。
5.2 消息发出去用户收不到回复,一看日志全是被动回复超时
现象:用户发消息后一切看起来正常,服务日志也打印了收到消息、也调了DeepSeek,但用户收不到任何回复,偶尔能收到“该公众号暂时无法提供服务”。
原因:用户消息进来后,同步调用了DeepSeek,响应时间超过5秒,微信在服务端直接断开了连接,你后续就算写了被动回复也来不及送达。
解决:按前面第三篇的方案改成异步:先立即返回“正在思考”的被动回复,后台goroutine慢慢调DeepSeek,结果通过客服消息接口推送。注意客服消息接口有频率限制,需要做并发控制,不能同时推多条。另外一个隐藏坑:被动回复接口返回的创建时间必须用当前Unix时间戳,用了固定值或0会被微信判定非法。
5.3 API Key直接写在代码里,部署之后被扫描工具盯上
现象:代码推到远端仓库后,很快就收到安全扫描通知,说代码里发现了明文密钥。
原因:API Key直接硬编码在源码文件里,一旦仓库被扫描到,密钥立刻失效或被盗用,账单就可能被刷爆。
解决:API Key只能存在环境变量或配置中心里。Golang启动时从os.Getenv读取,进程内使用。如果已经泄漏,不要想着删掉重推,而是立刻到平台控制台撤销旧Key,换一个新的。习惯上我会在本地用.env文件管理配置,但.env文件必须写进.gitignore,不能入库。
5.4 把微信Token、DeepSeek API Key、LLM的token混为一谈
现象:新人接这个项目时,经常把“Token校验”理解成“要用API Key去请求微信”,或者调试时发现上下文管理存的所谓token数跟API返回的不一致。
原因:这三个完全不同的东西都叫token。微信的Token是公众号服务器配置里你自己写的一个参与签名的字符串;DeepSeek API Key是调接口时的鉴权凭证,通常是一个Bearer密钥;大模型单位token是模型切分的文本片段,API按它计费。把三者混谈会闹出在微信后台填API Key的笑话,签名必失败。
解决:代码里命名严格区分。微信Token变量叫wxVerifyToken,API Key叫deepSeekAPIKey,模型的token计数只出现在日志字段里。如果要存上下文,用messages数组的长度作为轮数依据,不要自己拼token数字,模型每轮消耗的token数只有API返回的usage字段值才是真的。
6. 进阶:让公众号AI助手更像一个产品
6.1 用户级限流:防止单用户刷爆API额度
每个用户随手连发几十条消息,你的DeepSeek额度会在一分钟内被耗尽。用一个每用户一分钟最多5次的限流器,成本很低:
func allow(openid string, limitPerMinute int) bool { key := "rate:" + openid count, _ := redisClient.Incr(key).Result() if count == 1 { redisClient.Expire(key, time.Minute) } return count <= int64(limitPerMinute) }逻辑说明:利用Redis的INCR自增命令做固定窗口限流,第一次自增时设置一分钟过期时间,后续自增超过阈值就拒绝。真实场景下,用户通常不会一分钟连发几十条,限到5条已经足够防刷,同时不会误伤正常对话。内存方案也可以做,但重启清零,Redis更可靠。拒绝时回复一句“消息发得太快,请慢一点”,避免把限流错误暴露给用户。
6.2 日志里的三个关键字段:排查问题只看这三个
公众号后台调试时,你只看得见用户视角的“没回复”,后端日志必须能还原整个过程。我习惯在每条消息处理结束时打印一行结构化日志,包含openid、上游响应时间和上游状态码。openid用来追踪单个用户的操作序列,上游响应时间直接暴露出超时瓶颈,状态码区分限流和故障。没有这三个字段的日志,出了问题只能靠猜。
6.3 我的收尾习惯
每次上线新提示词或参数改动,我会先用一个专用测试OpenID在真实环境里连发几条消息,确认回复格式、长度和语气符合预期,再放量。公众号这类项目没有灰度环境,改动直接作用在真实用户上,养成小范围自测的习惯能省掉很多尴尬。另一个坑了我一次的细节是:修改服务器配置后微信后台可能需要清除缓存或等几分钟才生效,改完配置不要急着拿真机测试,先等服务日志出现验证请求再开测。这套从配置到源码再到参数的想法,我已经跑通过不止一次,希望帮到你。
本文还有配套的精品资源,点击获取