Bug 修了三次还会复发?我用蓝耘元生代 GLM-5.1 搭了个“代码法医实验室”
文章目录
一个怎么都修不干净的 401
事情最早看起来很简单。
项目里有一层请求封装,访问令牌过期后,接口会返回 401。响应拦截器收到 401,再拿刷新令牌换一个新的访问令牌,最后重新发送原请求。这套逻辑在本地单步调试时一直正常,真正接到页面上却开始闹脾气:有时刷新一次就恢复,有时连续弹出多个 401,偶尔还会把用户直接踢回登录页。
我先后改了三次。
第一次是在重试前补上新的请求头;第二次增加 _retry 标记,防止同一个请求无限循环;第三次又给空响应和网络异常加了判断。每次修改后,单个请求都能通过。但只要页面初始化时同时请求用户信息、权限、菜单和消息数量,问题又会回来。
这类 Bug 最麻烦的地方不是报错难懂,而是每次修复都像已经抓到凶手。过两天,它换个条件再次出现,你才发现之前抓到的只是路过现场的人。
我不想再做第四次“看到哪里报错就改哪里”,于是准备换一种方式:把报错堆栈、拦截器代码、请求时序和已经尝试过的修复全部交给模型,让它先还原故障传播链,再讨论补丁。
这就是“代码法医实验室”的起点。

先认识蓝耘元生代 MaaS
我这次使用的是蓝耘元生代 MaaS 平台。MaaS 是 Model as a Service,也就是把大模型能力封装成可以调用的服务。开发者不需要在自己的电脑上准备 GPU、下载模型权重或维护推理环境,应用通过 API 提交请求,再接收模型返回的结果。
对于这个项目,我需要的不是一个只会解释报错的聊天页面,而是一个可以嵌入程序、按照固定格式返回诊断结果的模型接口。代码法医要连续完成材料整理、根因分析、补丁建议和测试设计,输出还要能够被前端拆成不同卡片,因此使用 MaaS API 比手动复制对话更合适。
注册并进入 MaaS 平台
注册过程并不复杂:进入蓝耘元生代官网,完成账号注册和登录后,从顶部导航进入“MaaS平台”。控制台左侧可以看到模型广场、文本模型、智能路由、批量推理、用量统计等入口。


我进入“模型广场”,选择文本模型,然后找到 GLM-5.1。模型卡片给出了模型类型、上下文长度、调用名称和 API 示例入口。这几项信息后面都会直接用到。

为什么选 GLM-5.1
代码诊断并不是“把错误信息翻译成中文”这么简单。为了判断并发 401 的根因,模型至少需要同时看到以下材料:
- 响应拦截器与刷新令牌函数;
- 多个请求同时失败时的时间顺序;
- 前三次修复改过什么;
- 测试期望与实际结果;
- 项目对重试、退出登录和异常提示的约束。
这些内容放在一起,比一条报错堆栈长得多。蓝耘模型广场给 GLM-5.1 标注了 198k 上下文,同时将它定位为面向智能体工程、长程任务和代码工作的模型。对“代码法医”这种需要阅读多份材料、分阶段推理并输出结构化报告的项目来说,这些特性与任务比较匹配。
这里需要说明:本文不测试延迟、吞吐量和价格,也不据此给出性能排名。项目只验证一件事——GLM-5.1 能否根据完整故障材料形成可检查的诊断,并帮助我产出一个能够通过本地测试的修复方案。
接入蓝耘 GLM-5.1
在 GLM-5.1 模型卡片中点击“API示例”,可以查看当前平台提供的请求格式。项目没有把密钥写进源码,而是通过环境变量读取:
LANYUN_API_KEY=请填写自己的API密钥
LANYUN_BASE_URL=https://maas-api.lanyun.net/v1
LANYUN_MODEL=/maas/zhipuai/GLM-5.1
项目本身使用 Node.js,因此实际调用代码也直接使用内置 fetch:
const endpoint = `${process.env.LANYUN_BASE_URL}/chat/completions`;
const response = await fetch(endpoint, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.LANYUN_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: process.env.LANYUN_MODEL,
messages: [
{
role: "system",
content: "你是蓝耘元生代 GLM-5.1 代码故障分析助手。必须依据用户提供的证据回答。",
},
{ role: "user", content: forensicPrompt },
],
temperature: 0.1,
stream: false,
}),
signal: AbortSignal.timeout(120_000),
});
if (!response.ok) {
throw new Error(`模型接口返回 ${response.status}`);
}
const payload = await response.json();
const report = payload.choices?.[0]?.message?.content;
完整项目还会检查接口地址、JSON 格式和 choices[0].message.content 是否存在。这里保留主调用流程,避免一段错误响应直接被当成尸检报告显示到页面上。

我没有让模型一上来就改代码
最初版本的提示词很直接:“分析下面的 401 报错并修复代码。”结果通常也很直接:增加重试标记、刷新令牌、重新发送请求。问题是,这些正是我之前已经做过的事。
后来我把流程拆成四步:
- 只整理现有证据,不提出修改;
- 按时间顺序还原故障传播链;
- 区分表面触发点与根本原因;
- 最后才生成最小补丁和回归测试。
核心系统提示词如下:
你是一名代码故障分析工程师。
请把收到的材料视为一次待调查的软件故障,不要只根据最后一行报错下结论。
按以下顺序工作:
1. 提取已经确认的事实;
2. 标记缺失信息与相互矛盾之处;
3. 还原从触发条件到最终报错的传播链;
4. 给出最多三个根因假设,并为每个假设列出支持证据和反证;
5. 选择最可能的根因;
6. 生成最小修复方案;
7. 生成能够证明问题已修复的回归测试。
如果证据不足,必须明确写出“不确定”,不要补造项目事实。
这个限制很重要。模型很擅长给出“看起来能用”的代码,但代码法医首先要做的是解释为什么。如果它无法把错误、并发请求和刷新逻辑连成一条完整链路,再漂亮的补丁也不值得直接采用。
案发现场:五个请求同时撞上过期令牌
为了稳定复现问题,我写了一个最小测试场景:令牌已经过期,页面同时发出五个业务请求。每个请求第一次都会收到 401。
原来的响应拦截器大致如下:
api.interceptors.response.use(
response => response,
async error => {
const request = error.config;
if (error.response?.status === 401 && !request._retry) {
request._retry = true;
const token = await refreshAccessToken();
request.headers.Authorization = `Bearer ${token}`;
return api(request);
}
return Promise.reject(error);
}
);
只看单个请求,这段代码没有明显问题:401、刷新、重试,步骤都在。但五个请求几乎同时失败时,它们会各自进入拦截器,然后分别调用 refreshAccessToken()。
模型需要判断的核心问题是:这里究竟是某一个请求的重试逻辑写错了,还是多个请求之间缺少协调?
我把以下材料一起提交给代码法医:
- 拦截器代码;
- 刷新令牌函数;
- 五个请求的本地时间线;
_retry标记的作用范围;- 单请求测试与并发测试的差异;
- 前三次修复记录。
GLM-5.1 给出的“尸检报告”
实际调用后,GLM-5.1先给出了一个很明确的判断:问题不在某一个请求有没有写 _retry,而在多个请求之间没有共享刷新状态。
表面死因:
并发请求同时收到401后,各自独立触发令牌刷新。
根本原因:
refreshAccessToken()缺乏并发控制。多个请求同时发起刷新,
第一个请求成功轮换refresh token后,后续请求继续使用旧令牌,因此刷新失败。
故障传播链:
页面初始化并发发出5个请求
→ 5个请求在12ms内收到401
→ 5个请求分别进入响应拦截器
→ 5次刷新在4ms内启动
→ 第一次刷新成功并轮换令牌
→ 后续4次刷新使用旧令牌失败
→ 原请求无法恢复,触发意外退出登录
支持证据:
日志中的401和刷新请求时间高度重合;
拦截器直接调用refreshAccessToken(),没有共享Promise或请求队列;
前三次修复均未处理请求之间的并发关系。
仍不确定的信息:
刷新函数的完整实现、服务端旧令牌处理方式、刷新失败后的全局登出逻辑。
建议补丁:
增加刷新状态和等待队列,只允许第一个401请求发起刷新,
其余请求等待刷新结果。
回归测试:
验证5个并发401只触发一次刷新;
验证刷新失败时所有等待请求都能收到错误,而不是一直挂起。

模型给出的第一版补丁使用了 isRefreshing 和订阅队列。第一个401负责刷新,其余请求把成功回调放进 refreshSubscribers,刷新成功后再依次重试。它已经抓到了并发控制这个根因。
但我把它放进“刷新令牌失效”的回归测试后,又发现了一个新问题:这个队列只保存成功回调,没有为等待中的请求保存 reject。如果刷新本身失败,第一个请求会收到错误,其余四个请求可能一直等待。
这次模型找对了死因,首版补丁却没有覆盖完整的失败路径。最后我改成共享同一个 refreshPromise。这样刷新成功时,所有请求拿到同一个新令牌;刷新失败时,同一个拒绝结果也会传给所有等待请求。
let refreshPromise = null;
async function getFreshToken() {
if (!refreshPromise) {
refreshPromise = refreshAccessToken().finally(() => {
refreshPromise = null;
});
}
return refreshPromise;
}
api.interceptors.response.use(
response => response,
async error => {
const request = error.config;
if (error.response?.status === 401 && !request._retry) {
request._retry = true;
const token = await getFreshToken();
request.headers.Authorization = `Bearer ${token}`;
return api(request);
}
return Promise.reject(error);
}
);
法医报告不能代替回归测试
模型指出根因、生成补丁,只完成了一半工作。真正决定这份报告是否可信的,是测试。
我最后保留了四个最关键的验证场景:
测试没有只检查“函数没有抛错”,而是直接比较刷新次数、成功请求数和悬挂请求数。例如并发修复的核心断言是:
const result = await simulateSingleFlightRefresh(5);
assert.equal(result.requestCount, 5);
assert.equal(result.refreshCalls, 1);
assert.equal(result.failedRequests, 0);
assert.equal(result.succeededRequests, 5);
| 测试场景 | 实际结果 | 结论 |
|---|---|---|
| 旧实现:5个请求同时遇到401 | 发起5次刷新,4个请求失败 | 成功复现刷新风暴 |
| GLM-5.1首版队列补丁:刷新接口失败 | 1个请求收到错误,4个等待请求可能挂起 | 补丁还需处理失败通知 |
| 共享Promise修复:5个请求同时遇到401 | 只刷新1次,5个请求全部恢复 | 并发场景通过 |
| 共享Promise修复:刷新接口失败 | 只刷新1次,5个请求全部收到失败结果 | 没有遗留等待请求 |


这一步也给“代码法医”划了一条边界:它负责整理证据、提出根因和生成候选补丁,但不能宣布自己已经修好 Bug。测试没通过之前,尸检报告只是一个分析意见。
用完整案卷代替一段报错
以前遇到报错,我经常只截最后几十行日志,再贴一小段出错函数。模型当然可以解释异常,但它看到的只是事故最后几秒。
这次把材料按“案卷”组织后,体验发生了变化。真正有用的信息往往不在最后一行错误里,而在更早的位置:什么时候开始并发、哪些请求共享状态、之前修复过什么、哪些测试只能在单请求下通过。
蓝耘 GLM-5.1 在这个项目里的价值,也不只是生成一段修复代码。模型广场提供了清晰的调用名称和 API 示例,MaaS 接口让我能把故障材料整理、根因分析、补丁生成和报告展示串进同一个应用。模型输出不再停在聊天窗口里,而是成为项目工作流的一部分。
当然,一次案例不能证明它能处理所有代码故障。认证、并发和重试只是软件问题中的一类。涉及数据库事务、分布式一致性、权限和生产数据时,输入材料会更复杂,模型给出的结论也更需要人工核对。
最后:这次抓到的不是某一行代码
这个 Bug 前三次修改都围绕单个请求展开:补请求头、加重试标记、处理异常。它们没有完全错,只是没有碰到真正的问题。故障只在多个请求同时出现时发生,根因自然也藏在请求之间。
“代码法医实验室”没有替我承担最终判断。它做的是另一件事:逼我把零散材料整理成完整案卷,再按照证据、假设和反证去看问题。GLM-5.1给出的补丁最终是否保留,仍然由本地测试决定。
这套流程比“把报错贴进聊天框,复制第一段答案”慢一点,却更像真正的排障。
模型找到了并发刷新这个真正死因,本地测试又替它补上了失败路径。这个结果比直接复制一段“标准答案”更让我放心:GLM-5.1负责缩小调查范围、给出候选方案,我负责用测试决定它能不能进入项目。
“代码法医实验室”没有替我承担最终判断。它做的是另一件事:逼我把零散材料整理成完整案卷,再按照证据、假设和反证去看问题。GLM-5.1给出的补丁最终是否保留,仍然由本地测试决定。
这套流程比“把报错贴进聊天框,复制第一段答案”慢一点,却更像真正的排障。
模型找到了并发刷新这个真正死因,本地测试又替它补上了失败路径。这个结果比直接复制一段“标准答案”更让我放心:GLM-5.1负责缩小调查范围、给出候选方案,我负责用测试决定它能不能进入项目。
截图补齐后,这个案子的记录就完整了。
更多推荐



所有评论(0)