在这里插入图片描述

一个怎么都修不干净的 401

事情最早看起来很简单。

项目里有一层请求封装,访问令牌过期后,接口会返回 401。响应拦截器收到 401,再拿刷新令牌换一个新的访问令牌,最后重新发送原请求。这套逻辑在本地单步调试时一直正常,真正接到页面上却开始闹脾气:有时刷新一次就恢复,有时连续弹出多个 401,偶尔还会把用户直接踢回登录页。

我先后改了三次。

第一次是在重试前补上新的请求头;第二次增加 _retry 标记,防止同一个请求无限循环;第三次又给空响应和网络异常加了判断。每次修改后,单个请求都能通过。但只要页面初始化时同时请求用户信息、权限、菜单和消息数量,问题又会回来。

这类 Bug 最麻烦的地方不是报错难懂,而是每次修复都像已经抓到凶手。过两天,它换个条件再次出现,你才发现之前抓到的只是路过现场的人。

我不想再做第四次“看到哪里报错就改哪里”,于是准备换一种方式:把报错堆栈、拦截器代码、请求时序和已经尝试过的修复全部交给模型,让它先还原故障传播链,再讨论补丁。

这就是“代码法医实验室”的起点。

在这里插入图片描述

先认识蓝耘元生代 MaaS

我这次使用的是蓝耘元生代 MaaS 平台。MaaS 是 Model as a Service,也就是把大模型能力封装成可以调用的服务。开发者不需要在自己的电脑上准备 GPU、下载模型权重或维护推理环境,应用通过 API 提交请求,再接收模型返回的结果。

对于这个项目,我需要的不是一个只会解释报错的聊天页面,而是一个可以嵌入程序、按照固定格式返回诊断结果的模型接口。代码法医要连续完成材料整理、根因分析、补丁建议和测试设计,输出还要能够被前端拆成不同卡片,因此使用 MaaS API 比手动复制对话更合适。

注册并进入 MaaS 平台

注册过程并不复杂:进入蓝耘元生代官网,完成账号注册和登录后,从顶部导航进入“MaaS平台”。控制台左侧可以看到模型广场、文本模型、智能路由、批量推理、用量统计等入口。

在这里插入图片描述

进入蓝耘MaaS平台

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

蓝耘模型广场中的GLM-5.1

为什么选 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 是否存在。这里保留主调用流程,避免一段错误响应直接被当成尸检报告显示到页面上。

GLM-5.1 API示例页面

我没有让模型一上来就改代码

最初版本的提示词很直接:“分析下面的 401 报错并修复代码。”结果通常也很直接:增加重试标记、刷新令牌、重新发送请求。问题是,这些正是我之前已经做过的事。

后来我把流程拆成四步:

  1. 只整理现有证据,不提出修改;
  2. 按时间顺序还原故障传播链;
  3. 区分表面触发点与根本原因;
  4. 最后才生成最小补丁和回归测试。

核心系统提示词如下:

你是一名代码故障分析工程师。

请把收到的材料视为一次待调查的软件故障,不要只根据最后一行报错下结论。

按以下顺序工作:
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个请求全部收到失败结果 没有遗留等待请求

在这里插入图片描述

12项测试全部通过

这一步也给“代码法医”划了一条边界:它负责整理证据、提出根因和生成候选补丁,但不能宣布自己已经修好 Bug。测试没通过之前,尸检报告只是一个分析意见。

用完整案卷代替一段报错

以前遇到报错,我经常只截最后几十行日志,再贴一小段出错函数。模型当然可以解释异常,但它看到的只是事故最后几秒。

这次把材料按“案卷”组织后,体验发生了变化。真正有用的信息往往不在最后一行错误里,而在更早的位置:什么时候开始并发、哪些请求共享状态、之前修复过什么、哪些测试只能在单请求下通过。

蓝耘 GLM-5.1 在这个项目里的价值,也不只是生成一段修复代码。模型广场提供了清晰的调用名称和 API 示例,MaaS 接口让我能把故障材料整理、根因分析、补丁生成和报告展示串进同一个应用。模型输出不再停在聊天窗口里,而是成为项目工作流的一部分。

当然,一次案例不能证明它能处理所有代码故障。认证、并发和重试只是软件问题中的一类。涉及数据库事务、分布式一致性、权限和生产数据时,输入材料会更复杂,模型给出的结论也更需要人工核对。

最后:这次抓到的不是某一行代码

这个 Bug 前三次修改都围绕单个请求展开:补请求头、加重试标记、处理异常。它们没有完全错,只是没有碰到真正的问题。故障只在多个请求同时出现时发生,根因自然也藏在请求之间。

“代码法医实验室”没有替我承担最终判断。它做的是另一件事:逼我把零散材料整理成完整案卷,再按照证据、假设和反证去看问题。GLM-5.1给出的补丁最终是否保留,仍然由本地测试决定。

这套流程比“把报错贴进聊天框,复制第一段答案”慢一点,却更像真正的排障。

模型找到了并发刷新这个真正死因,本地测试又替它补上了失败路径。这个结果比直接复制一段“标准答案”更让我放心:GLM-5.1负责缩小调查范围、给出候选方案,我负责用测试决定它能不能进入项目。

“代码法医实验室”没有替我承担最终判断。它做的是另一件事:逼我把零散材料整理成完整案卷,再按照证据、假设和反证去看问题。GLM-5.1给出的补丁最终是否保留,仍然由本地测试决定。

这套流程比“把报错贴进聊天框,复制第一段答案”慢一点,却更像真正的排障。

模型找到了并发刷新这个真正死因,本地测试又替它补上了失败路径。这个结果比直接复制一段“标准答案”更让我放心:GLM-5.1负责缩小调查范围、给出候选方案,我负责用测试决定它能不能进入项目。

截图补齐后,这个案子的记录就完整了。

更多推荐