记录“免费池”功能从模糊报错到全链路跑通的调试全过程,涵盖前后端协议错位、模型轮询策略、SSE兼容与多调用路径差异,提炼出可复用的系统化排查方法。
一个"免费池"功能的调试记:从一句模糊的报错到全链路打通
每天和代码打交道,最费时间的往往不是写新功能,而是排查一个"说不清楚"的报错。今天这篇,记录一个叫"免费池"的功能从报错到彻底跑通的全过程——前后修了七处问题,中间还走过一段弯路,最后归位到一个不到五十行的改动。整个过程没什么高深技术,但把调试的常见坑几乎踩了个遍,值得记一笔。
起点:一个模糊的报错
事情的起因是用户一句话:
我加入免费池功能有问题,提交报错,好像是传的参数不对。
"免费池""提交报错""参数不对"——三个关键词,每个都模糊。作为排查的第一步,我习惯先把模糊变具体:到底是哪个接口、什么样的报错、收到的参数是什么。
第一反应是看本地的改动(git diff),结果发现本地改的是 Todo 功能(加 user_id、due_time 之类),和"免费池"八竿子打不着。这本身就是一个信号:用户说的"免费池"代码,根本不在本地工作区里。
顺着这个线索往下查,发现本地仓库和远程服务器压根没同步。远程的 main 分支领先本地三个 commit,工作区还有没提交的 api/server.go。换句话说,免费池的代码是在远程服务器上直接开发的,没走完整的 push → pull 流程回到本地。
这是个容易被忽略的前置问题。用户的工作流里,正常应该是本地改、push、远程 pull 部署。但这次免费池是直接在远程捣鼓出来的,导致本地看到的状态和线上跑的代码完全对不上。排查线上问题,第一步永远是确认你在看的代码,和线上跑的是不是同一份。 不然你看半天本地代码,线上根本不是这么回事。
把远程的改动同步回本地(stash 本地的 Todo 改动 → pull → 恢复,中间自动合并了 admin.html 而没冲突),才终于能开始真正的排查。
免费池到底是个什么东西
排查之前,得先搞清楚"免费池"是什么。读完代码,它的设计其实挺干净:
• 一个 free_providers.json 文件,存一串模型名字,比如 ["kimi-proxy-deep", "dsfree", "qwen-free"]。
• 一个叫 free 的虚拟 agent,它的 endpoint 指向 weclaw 自己的 /api/free/chat。
• handleFreeChat 这个处理函数:收到请求后,把池子里的模型随机打乱,挨个试,谁先成功就用谁的回复。
说白了,免费池就是一个"便宜 LLM 轮询器":用户发消息,它从一堆免费或本地的模型里随机挑一个来回答。这种设计的好处是,把几个不那么稳定、但免费的模型凑在一起,谁活着用谁,整体可用性比单依赖一个要高。
理解了这个架构,后面所有的问题才有讨论的基础。
第一个真正的 bug:前后端在说两种语言
同步完代码,定位"提交报错"。
报错出在保存免费池配置这个操作上。前端点保存,后端返回 400。看后端代码:
func (s *Server) handleSaveFreeProviders(w http.ResponseWriter, r *http.Request) {
var names []string
json.NewDecoder(r.Body).Decode(&names) // 期望字符串数组
...
}
后端期望收到的是 []string——一个纯字符串数组,比如 ["kimi", "agnes"]。
但前端发的是什么?
const providers = await apiGet('/api/free/providers'); // 对象数组
// ... 改 checkbox ...
await apiPut('/api/free/providers', providers); // 把整个对象数组发回去
前端先 GET 拿到一组对象([{name, model, endpoint, enabled}, ...]),改完勾选状态,又把整个对象数组原样 PUT 回去。
一边要字符串,一边发对象,json.Decode 自然失败,报 cannot unmarshal object into Go value of type string。这就是"参数不对"的真相——不是哪个参数错了,是整个请求体的格式不对。
修复很直接:前端在发送前,把勾选的对象转成名字数组:
const selected = [];
cbs.forEach(cb => {
if (cb.checked && providers[idx]) selected.push(providers[idx].name);
});
await apiPut('/api/free/providers', selected);
顺手还修了同一处的另一个小问题:rand.Shuffle 写在了 for 循环里面,导致每试一个模型就重新洗一次牌,应该挪到循环外面只洗一次。
同一个误解,散落在三个地方
这个 bug 修完,我以为是收尾了。结果用户接着反馈:"编辑 agent 的时候,免费池的勾又空了。"
看代码,openAgentModal(打开编辑弹窗)里有这么一行:
fpCb.checked = n.indexOf(agent.name) >= 0;
n 是 apiGet('/api/free/providers') 的返回值——又是那个对象数组。但这里用 indexOf 去找一个字符串,对象数组里当然找不到字符串,永远返回 -1,checkbox 永远不勾。
和上一个 bug 一模一样的根因:把 GET 返回的对象数组当成了字符串数组来用。
再查,发现 loadAgents(渲染 agent 列表)里还有第三处:
const freeSet = new Set(freeNames); // freeNames 其实是对象数组
freeSet.has(name) ? 'Free' : '' // 字符串 vs 对象,永远 false
这处导致 agent 列表里的 "Free" 徽章从来不显示。
同一个数据格式的误解,散布在三处不同的代码里。修完一处,另外两处还在坏。这是个很典型的教训:当你发现一个"模式"bug(比如某种数据被一致地误用),别只修用户报的那一处,要全量搜一遍所有相关调用点。我用 grep 把所有 apiGet('/api/free/providers') 的调用列出来逐个核查,才确认没有第四处。
另外还顺带处理了一个手机端的问题:Free 徽章只加在了桌面表格行里,手机用的是卡片视图(@media max-width:768px 时显示卡片、隐藏表格),卡片里压根没渲染这个徽章。这是个纯粹的 UI 遗漏,卡片视图补上同样的标记就行。这类"桌面看着对、手机不对"的问题,根源往往是两套渲染逻辑没有保持同步——改了一处别忘了另一处。
为什么每次都是同一个模型?
徽章和回填都修好了,用户又抛出新问题:"配置了四个免费模型,为什么只在两个之间切换?"
免费池里明明有四个模型,但实际回复永远只用其中两个。这个问题更有意思,因为它不是"报错",而是"行为不符合预期"。
排查这种问题,光看代码不够,得看实际运行数据。我先确认了两件事:
- 四个模型在配置里是否都"可用"(type=http、有 endpoint、有 key)。
- 实际运行时,每个模型被选中的次数是多少。
为了看清第二点,我临时给 handleFreeChat 加了一行日志,成功选中模型时打印名字([free-chat] served by xxx)。这是调试很实用的手段:当一个关键决策没有留下痕迹时,先给它加上可观测性,再复现问题。盲猜不如让系统自己告诉你它干了什么。
日志加完,数据出来,真相浮出水面:
• kimi 这个模型没配 api_key,而代码里有一行 if apiKey == "" { continue }——没 key 就直接跳过。所以 kimi 永远没机会被选中。
• 第四个模型 qwen 配置完全正常,只是样本太小没轮到(头几次请求里它恰好都没被随机选中),属于正常波动。
针对 kimi,用户说:"kimi 是我本地建的服务,不需要 key。"
这就对上了。强制要求 key 的逻辑,对公网付费模型是对的,但对本地自建的无鉴权服务就是误伤。修法:去掉强制检查,改成"有 key 才带 Authorization 头,没有就照常请求":
headers := map[string]string{"Content-Type": "application/json"}
if apiKey != "" {
headers["Authorization"] = "Bearer " + apiKey
}
这里有个设计上的小判断:去掉强制检查后,如果某个模型本该有 key 但忘了配,请求会带着空 Authorization 打过去,大概率 401 失败,然后 fallback 到下一个模型。代价是多一次注定失败的请求,但不至于卡死。对于"轮询 + 失败重试"的免费池来说,这个代价完全可以接受,换来的是对本地无鉴权服务的兼容。权衡之下,值得。
流式的坑:一个模型"假装"不能用
key 的问题修完,我以为四个模型该都能轮换了。一测,kimi 还是选不上。
这次更隐蔽:kimi 不是被 skip,而是调用它的时候超时。
直接测 kimi 的 endpoint,发现一个关键差异:
• 用 stream: false(非流式)请求 → 15 秒超时,卡死。
• 用 stream: true(流式)请求 → 正常返回。
原来 kimi 这个本地服务只实现了流式接口,非流式它会一直 hang 住。而 handleFreeChat 发的恰好是 stream: false。
这是个真实存在的兼容性问题:不同模型对流式/非流式的支持不一致。大部分 OpenAI 兼容 API 两种都支持,但有些自建服务只做了其中一种。
到这里,设计上出现了两条岔路:
• 要么让 handleFreeChat 改用流式调用(但其他模型未必都需要)。
• 要么放弃 kimi。
选了前者。于是写了个函数,用流式调用模型,再把 SSE 流解析拼回完整文本,最后包装成非流式的 JSON 返回。为什么包装成非流式 JSON?因为当时我以为调用方(微信那条路径)期望的就是非流式。这个判断对了一半,但也埋下了后面踩坑的种子。
走弯路:我亲手删掉了正确的解法
流式调用加上之后,大部分模型能跑了。但有个模型 agnes 解析出来是空内容。我去看它的 SSE 原始数据,发现它先发一大段 reasoning_content(思考过程),真正的 content 在后面,而我当时的解析逻辑只认 content 字段,在思考阶段一直是空。
正纠结这个的时候,用户问了一句:
发请求和返回响应不都是兼容 weclaw 原来的样式吗,为何还要自己解析?
这句话让我动摇了。我去翻 weclaw 的代理层(proxy)代码,发现它本来就有一段流式透传的逻辑(if stream { 把 SSE 直接 pipe 给客户端 })。我一拍大腿:对啊,既然代理层会处理流式,handleFreeChat 干嘛要自己解析?直接把模型的流式响应原样转发不就行了?而且自己解析还有 agnes 这种格式兼容问题,透传反而最干净。
于是我把刚写好的解析函数删了,改成 io.Copy 直接转发,只在开头注入一个 [模型名] 的前缀 chunk。编译通过,部署。
然后微信就炸了。
用户发消息测试,微信这边报错:
parse response: invalid character 'd' looking for beginning of value
'd' 是 data: ... 里的 d。报错信息很明确:某处代码期望收到一个 JSON,结果收到了一段以 data: 开头的 SSE 文本。
这一下点醒了我。原来 weclaw 调用模型有不止一条路径,而它们对响应格式的期望不一样:
• Web、API 这类路径:走代理层(proxy),代理层本来就支持流式,会自己解析 SSE。流式透传没问题。
• 微信路径:走 HTTPAgent.Chat,这个函数的逻辑是 io.ReadAll 读完整响应,然后 json.Unmarshal 当非流式 JSON 解析。收到 SSE 自然报错。
用户那句"发请求和返回响应不都是兼容原样式吗",其实问得对——但"原样式"对不同调用方是不一样的。代理层兼容流式,HTTPAgent.Chat 只兼容非流式 JSON。我之前以为整个系统统一处理流式,是错的。
这是今天最深刻的一个教训。重构之前,必须先搞清楚:这段逻辑被谁依赖?每个调用方期望的输入输出分别是什么? 贸然删掉一个看起来"多余"的中间层,很可能它正在为某个你没注意到的调用方做关键的格式转换。我当时只看到了代理层支持流式,就以为没人需要非流式 JSON 了,完全没想到微信那条路径是另一套独立的调用代码。
回到正解:让微信路径也认 SSE
理清楚之后,方向就明确了:保持免费池的流式输出不动(因为它让 web 等渠道工作正常),只把微信这条路径教会它识别 SSE。
改动落在 HTTPAgent.Chat 里。原来它读完响应直接 json.Unmarshal,现在加一道判断:如果响应是以 data: 开头的 SSE,就走专门的累积逻辑,把所有 delta.content 拼起来;否则还是按原来的非流式 JSON 解析。
var reply string
if looksLikeSSE(body) {
reply = extractSSEContent(body) // 逐行累积 delta.content
} else {
// 原来的非流式 JSON 解析
json.Unmarshal(body, &result)
reply = result.Choices[0].Message.Content
}
extractSSEContent 的实现很朴素:按行扫,挑出 data: 开头的行,解析 JSON,累加 delta.content。值得一提的是,agnes 那个让我头疼的"先思考后回答"的格式,在这里反而不用特殊处理——思考阶段的 content 是空字符串,累加进去等于没加,等真正的回答内容来了再累加就行。之前解析为空,是因为我测试时 max_tokens 设得太小,模型还停在思考阶段就被截断了,换个参数就有内容了。
这一个小函数,兼容了所有模型:有的先发 role 再发内容,有的夹带 reasoning_content,有的 chunk 里 content 为空——通通不用特殊处理,只管把每一帧里的 content 字段累加起来。
部署完,微信终于正常了:回复带上了 [模型名] 前缀,内容完整,四个模型随机轮换。用户确认:"全部正常了。"
最后又加了一个小改进:免费池配置变化时打印一条日志,带上模型数量和名单([api] free pool updated: 3 provider(s) [...]),方便以后排查时一眼看清当时的池子状态。
一些回头看才清楚的事
回头看这一整天的折腾,有几个点值得记下来。
第一,接口改动要前后端一起改。 最初那个"参数不对"的报错,本质是后端把保存接口从"收对象数组"改成了"收字符串数组",但前端没跟着改。这种单边改动是协作里最常见的坑。如果改动后能跑一遍完整的保存流程,马上就能发现。
第二,同一种误解会复制粘贴到多处。 "把对象数组当字符串数组用"这一个误解,在这份代码里出现了至少三处(保存、回填、徽章)。用户每报一个症状,我才修一处,来回好几轮。如果第一次修的时候就全量 grep 一遍同一个 API 的所有调用点,能少走很多弯路。修 bug 时,要修的是"模式",不是"症状"。
第三,真正理解调用链,再动手重构。 这是我今天最大的教训。用户问"为什么自己解析",我没有先把"不解析会怎样、有哪些调用方"想清楚,就动手删了正确的解法,结果把微信那条路径搞坏了。每一个看起来多余的中间层,都可能是为了适配某个你没看到的调用方而存在的。删之前,先问:谁在依赖它?依赖它做什么?
第四,实测比推理可靠。 kimi 为什么超时、agnes 为什么解析为空、qwen 为什么没轮到——这些光看代码是猜不准的。每次都是直接请求一下模型的接口、看一眼原始返回,真相才浮出来。代码读起来很合理,但它描述的是"想做什么",运行数据描述的是"实际做了什么",两者之间隔着环境、配置、网络、第三方实现。当代码逻辑看起来没错却出了问题,别继续盯代码,去看运行时数据。
第五,给关键决策加可观测性。 免费池"随机选了一个模型"这个动作,一开始没有日志,所以无法判断到底是没随机、还是选了但失败、还是选了同一个。加了一行 served by xxx 的日志之后,所有猜测都变成了可以直接观察的事实。调试时,日志是最廉价也最有效的工具。
最后
这天的活儿,从一句模糊的"提交报错"开始,牵出一连串问题:本地远程代码不同步、前后端协议不一致、同一数据格式被多处误用、权限模型假设错误、流式兼容性差异、调用路径分叉导致期望不一致。最后归位到一个核心改动:在微信的调用路径上加一道 SSE 识别。
整个过程没有用什么高深的技术,无非是 grep、直接请求接口、读日志、逐段验证。但调试这件事本来就是这样——九分耐心地缩小范围,一分灵感在最后捅破窗户纸。把每一步的依据都留痕(日志、测试、提交记录),下次遇到类似问题才有得参考。
如果这篇文章对你有点用,大概不是里面的代码细节(那些很快会过时),而是这几个习惯:排查前先确认代码版本对不对、修 bug 时修模式而不是修症状、重构前先理清调用链、逻辑没错就看运行数据、给关键决策留日志。这些习惯,比任何一段具体代码都耐用。