CI 挂了本地却全绿:先分清环境差异还是代码问题,再决定让 AI 改哪里

2026-07-28

数据截至 2026-07,各产品的额度与报错口径以官方最新说明为准。

CI 挂了本地却过,绝大多数时候不是代码逻辑错了,而是你和 CI 跑的根本不是同一件事。 这句话决定了整条排查路线:如果成因在环境、依赖解析、执行顺序或缓存上,那你让 AI 反复改业务代码就是在拿正确的代码去迁就错误的假设,改三轮之后 diff 面目全非,红叉还在,你只能回滚。归错因是这类问题唯一真正的成本,其余都是体力活。

站内另有两篇相邻的内容:一篇讲测试在本地全绿、上线之后仍然出事的原因分层(本地测试全绿一上线就炸),一篇讲 AI 改完代码之后回归测试该怎么组织和跑(Agent 怎么做回归测试)。本篇只管中间那一段——同一个 commit,本地绿、CI 红,这个断层怎么定因、怎么把失败信息喂给模型、什么时候该停手。三篇的分工是:一篇管测试口径,一篇管改动之后的验证流程,本篇管定位。

一、把「不一致」拆成五类,别混着查

CI 与本地的差异不是一团雾,可数的就那么几类。按我的经验,出现频率大致是这个顺序,你也照这个顺序排查,命中最快。

第一类:依赖解析结果不同。 你本地的依赖树是几个月里一点点长出来的,CI 是每次从锁文件或者从版本范围重新解析。锁文件没提交、锁文件和清单文件不同步、或者某个依赖用了浮动版本,都会让 CI 装到另一套版本。表现是报错指向某个库的内部调用栈,而你根本没碰过那个库。

第二类:环境变量与配置来源不同。 本地有 .env、有 shell 里几个月前 export 的变量、有编辑器注入的变量;CI 只有你在配置里显式声明的那些。缺一个变量,代码可能不报「缺变量」,而是走进了另一条默认分支,最后在很远的地方断掉。这一类最能骗人,因为报错位置和真实原因隔得很远。

第三类:执行环境本身不同。 操作系统、语言小版本、文件系统大小写敏感性、时区、locale 与默认编码、CPU 核数。大小写这一条尤其阴——本地大小写不敏感的文件系统上 import Utilsimport utils 都能过,Linux 上直接找不到模块。时区会让所有跨日期边界的断言在某些时段变红。编码问题通常表现为解码失败或者输出乱码,站内讲过这一类的具体表现(AI 生成的代码只在我机器上能跑)。

第四类:测试之间互相污染,或者顺序、并发不同。 本地你只跑了改动相关的那几个文件,CI 跑全量;本地串行,CI 按核数并发。共享的临时目录、共享的数据库、模块级全局状态、没清理的 monkeypatch,都会让「单独跑绿、一起跑红」。随机顺序插件会让同一份代码今天绿明天红。

第五类:网络与凭据。 CI 跑在受限网络里,出网要过代理,TLS 链上可能有企业自签根证书;有的凭据只在受保护分支上注入,来自复刻仓库的合并请求拿不到。表现是 ETIMEDOUT、ECONNRESET、证书链校验失败,或者接口返回 401/403。这一类基本不用怀疑代码。

先归类,再动手。跳过归类直接改代码,是这类问题最常见的失败模式。

二、判别表:现象到动作

下面这张表按现象查。你在 CI 日志里看到左边那一列,就先按第三列去验证,验证成立才做第四列。

现象大概率成因怎么验证处置动作
报错栈落在某个第三方库内部,你没改过它依赖解析出的版本不同在 CI 里加一步打印已安装版本清单,和本地同一命令的输出逐行比提交并锁定锁文件;CI 用严格按锁文件安装的模式,禁止就地升级
找不到模块或找不到文件,路径拼写看着没错文件系统大小写敏感性差异git ls-files 看仓库里记录的真实文件名,和代码里的引用比对改代码里的引用,或用 git mv 修正仓库中的文件名大小写
断言只在一天里某些时间段失败时区或系统时钟差异本地加 TZ=UTC 复跑一次,看是否复现测试里固定时区,业务代码用带时区的时间对象,禁止用本地时间做边界判断
单独跑绿、全量跑红测试之间状态污染只跑失败用例确认它单独能过,再把它和前面几个用例一起跑找到留下状态的那个用例,补清理;共享资源换成每个用例独立的临时目录
同一 commit 重跑,有时绿有时红顺序随机、并发竞争或外部依赖不稳先连续重跑几次记录失败率与失败用例是否固定;再分三次单独变量复跑——把并发度降到 1、把随机顺序固定成同一个种子、把外部调用换成打桩,哪一次红消失了,成因就在那一项上先隔离标记为不稳定,再单独治;不要把它和当前改动混在一起查
解码失败或输出乱码locale 与默认编码不同两边各跑一次 locale,再各打印一次语言运行时算出来的默认编码,逐项对照,重点看 LANGLC_ALL 是不是空的在 CI 里显式设定 UTF-8 环境,读写文件一律显式传编码参数
超时、连接被重置、证书链校验失败出网受限、代理或自签证书curl -sS -o /dev/null -w '%{http_code}\n' <你的目标地址> 在 CI 里跑一次;证书问题用 openssl s_client -connect host:443 -showcerts </dev/null 看链(不接 </dev/null 它会一直挂在等标准输入,在 CI 里就是卡到超时)该走代理的显式配代理,该信任的根证书装进信任库;测试里该打桩的外部调用打桩,别真出网(公司内网连不上 AI 编程工具
接口返回 401/403,本地同样的调用正常凭据未注入或作用域不同在 CI 里打印凭据是否存在与长度,绝不打印值本身补齐注入配置;来自复刻仓库的合并请求改用不需要凭据的路径
缺一个环境变量却报了个八竿子打不着的错代码静默走了默认分支在入口处打印关键配置项的来源与是否为空启动时对必需配置做显式校验,缺就立即失败并打印缺哪一项

表里没有的现象,回到第一节的五类去套,通常能落进某一类。

三、把 CI 的失败信息喂对

这一步决定 AI 是帮你还是坑你。同样一个失败,喂法不同,结论质量差得很远。

该喂的是完整的失败段落,不是一行报错。 一行「测试失败」不含任何可推理的信息。把从执行命令开始、到失败断言的完整输出、到框架打印的摘要,整段给出去。断言失败要带上期望值与实际值。栈要完整,别只留最后一帧——真正有用的那一帧常常在中间。

该喂的是执行上下文。 这一步跑的是什么命令、工作目录在哪、用的什么镜像或运行器、装依赖用的哪条命令。没有这些,模型只能猜,而它猜的时候不会告诉你它在猜。

该喂的是两次运行的差。 最有信息量的输入是「上一次绿的那次运行」和「这次红的运行」之间的差异:commit 差、依赖清单差、环境变量清单差。你把两份版本清单一起贴上去,模型能直接指出哪个库跳了版本;只贴报错,它只能泛泛地建议你「检查依赖」。

该喂的是你已经排除了什么。 「本地同一 commit 用同一命令跑过,全绿;重跑 CI 三次都红在同一个用例」这两句话,比十屏日志都值钱,因为它把一大片假设一次砍掉。

不该喂的东西。 整份几万行的日志——里面绝大部分是安装依赖的噪声,只会把真正的信息挤到注意力边缘。多个不相关的失败混在一起——一次只查一个。带密钥、内网主机名、真实客户数据的原文——顺手脱敏是底线,你不知道日志会被带到哪里去。还有你自己的结论:不要写「我觉得是缓存问题,你帮我改缓存」,模型会顺着你的假设走,你就永远走不出错误的分支。给它现象,让它给假设排序。

顺序上我的建议是:第一轮只让它做分析,明确要求「先列出可能成因,按可能性排序,每条给出验证方式」,不要让它直接产出补丁。你自己跑验证命令确认成因,第二轮再让它改,并且把改动范围限定死。让模型在没有定因的时候直接改代码,是这类问题里最容易失控的一步(一句需求换来 30 个文件的改动,AI 的手)。

四、AI 帮得上什么,帮不上什么

帮得上的部分很实在。读长日志、从噪声里挑出真正的失败段落,它比人快。看到某个库的报错,把它翻译成「这通常意味着传进去的对象类型变了」,它做得不错。对着两份依赖清单找差异版本、对着两份环境变量清单找缺项,这类机械对比适合交给它。写一段临时诊断代码——打印当前 locale、打印解析出的路径、打印配置来源——让它写你审,效率高。把一个不稳定的用例改成不依赖执行顺序,它也能给出像样的方案。

帮不上的部分要认清。它看不到你的 CI 实际运行状态,你不给日志它就只能从常见模式里编一个听着合理的解释。它不知道你们组织内部的运行器怎么配的、哪些分支注入哪些凭据、代理策略是什么,这些它只能猜。它对「本地和 CI 到底差在哪」没有观测能力,差异必须由你打印出来告诉它。它也无法判断某个失败是不是真实缺陷——只在 CI 上红的失败,可能是 CI 暴露了本地掩盖的真 bug,也可能是 CI 环境本身配错了,这个判断需要你对业务的了解。

产品选择上说一句实话:这类活儿吃的是长日志读取和多轮定位,不同工具在这方面的体感确实有差别,但各家的额度口径、单次会话可承载的内容量、是否对长输出做截断都在变,以官方最新说明为准。海外的一部分工具与模型,官方对中国大陆有区域限制、不支持直连,市面上存在第三方中转,我不背书也不给渠道,你自己评估合规与数据风险。

五、什么情况下别再折腾

这一节比前面几节更重要,因为拖着不止损的代价通常大于查不出来的代价。

遇到下面任一条,停手。 同一个假设方向改了三轮还红,说明假设错了,回到第一节重新归类,不要在第四轮里继续加补丁。日志里的报错位置每轮都变,说明你在制造新问题而不是在修旧问题,立即回滚到最后一次绿的状态重来。你已经开始往代码里加「如果在 CI 里就跳过」这类分支——这不是修复,这是把红叉藏起来,下次在生产上还它。你为了让 CI 过而放宽了断言的精度或删掉了断言,同理。

回滚点怎么留。 开工前给当前状态打个可回去的锚:确认工作区干净,或者把手上的改动单独存一份。查的过程中每个假设一次提交,信息写清「验证假设 X」。这样任何一步走错,你都能用一条命令回到干净状态,而不是在一堆混杂的改动里考古。

git log --oneline -5              # 确认你要回到哪一点
git status --short                # 开工前确认干净
git diff --stat origin/main...HEAD  # 看清这次到底动了多少

换条路的判断依据。 如果排查已经指向 CI 平台配置或运行器镜像,而你没有改那部分的权限,别一个人硬扛,把已经收敛的证据交给有权限的人,比你继续试快得多。如果失败只在某个不常用的组合上出现,而这个组合的价值低于修它的成本,允许暂时把它从必需检查里摘出来,同时留一条明确记录说明摘的原因和什么时候再看——摘出来但不记录,半年后就变成没人知道的黑洞。如果本地怎么都复现不了,别再猜,直接用 CI 用的同一个镜像在本地跑一遍:

docker run --rm -v "$PWD":/app -w /app <CI里用的同一镜> <CI里那条测试命>

能在本地拿到同一份失败,后面的效率是另一个量级。

六、避坑清单

坑一:拿本地已装好的依赖当基准。 为什么会踩——你本地跑得通,就默认版本没问题。怎么避——排查一开始就在 CI 里输出一份已安装版本清单,与本地同一命令的输出逐行比,把这一步当成体检而不是可选项。

坑二:一次改多个地方。 为什么会踩——重跑一次 CI 要等,你想一次多试几个假设省时间。怎么避——多改并行会让你无法归因,成功了也不知道是哪一处起的作用。忍住,一次只动一个变量,用提交把每次尝试隔开。

坑三:把日志整份贴给模型。 为什么会踩——不知道该截哪一段,索性全给。怎么避——按「执行命令 + 失败段落 + 摘要」三块截取,安装依赖的输出除非报错就不要带;顺手删掉密钥与内网地址。

坑四:让模型先给结论。 为什么会踩——你急着要答案,直接问「这是什么问题」。怎么避——它一定会给你一个听着合理的答案,而你会当成结论去改代码。改成要求它列出候选成因与各自的验证方式,验证权留在你手里。

坑五:把重跑当修复。 为什么会踩——重跑一次碰巧绿了,感觉过去了。怎么避——重跑变绿只证明这个失败不稳定,不证明它不存在,它会在最不合适的时候再出现。看到重跑能过,就该去查竞争、顺序依赖或外部服务抖动。

坑六:为了过 CI 而放宽检查。 为什么会踩——交付压力下,改断言是最快的路。怎么避——先判断这个失败是不是 CI 替你抓到了本地被掩盖的真缺陷。要动断言就单独提交、写清理由,让它可被看见、可被追回。

坑七:本地脏文件让你误判「本地能过」。 为什么会踩——工作区里有没提交的文件、被忽略的构建产物、上次的缓存,本地跑的其实不是仓库里那份代码。怎么避——排查前先确认,看清有哪些未跟踪与被忽略的文件:

git status --short
git ls-files --others --exclude-standard
git clean -xdn                    # 只列出会被删的东西,先看清再决定

坑八:让 AI 顺手「优化」了旁边的代码。 为什么会踩——你只让它修一个失败,它顺带重构了周边。怎么避——在开始就把可改文件的范围写死,改完先看 diff 再跑测试;范围外的变更一律撤掉,别因为「看着更好」就留下。

收束

这类问题的解法一直是同一个形状:先承认本地和 CI 是两台不同的机器,再把差异逐项打印出来,最后才谈改代码。AI 在其中的位置是加速读日志、加速对比、加速写诊断代码,而不是替你做归因判断——归因需要观测数据,观测数据只能由你提供。

下次红叉出现,按这五条走一遍:

  1. 本地同一 commit、同一命令复跑,确认真的绿;工作区干净,没有未提交文件在帮忙。
  2. 同一 commit 在 CI 重跑几次,确认是稳定失败还是不稳定失败。
  3. 输出并比对两侧的依赖版本清单、环境变量清单、locale 与时区。
  4. 归到五类里的哪一类,按判别表验证成因,验证成立再改,一次只改一处。
  5. 三轮无进展就回滚重新归类,别在错误的假设上继续加补丁。

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。