1. 你以为的“小错误”,正在变成供应链上的“大窟窿”——先看几个真实场景
先说一个我在技术社区里看到的真实帖子,也是这篇文章的引子。
一个后端开发在项目里需要用到一个“解析用户代理字符串”的库,他随手把需求扔给AI助手,AI给出了一个包名,说“用这个,很成熟,文档齐全”。他pip install之后代码跑通了,CI也过了,直到一个月后安全团队做依赖审计,才发现那个包在PyPI上压根不存在——它是AI根据训练数据中成千上万个相似包名“推算”出来的一个幻影。而更讽刺的是,那个包名对应的真实位置,早被某个抢注者注册成了一个仿冒包,代码一旦拉下来,服务器上的环境变量、密钥文件全得裸奔。
这不是个例。过去半年我陆陆续续排查过好几个“构建失败找不到模块”的案子,最后都指向同一个根因:AI生成的代码里出现了一个不存在的依赖,而这个依赖被原样写进了 requirements.txt 或 package.json。
我把这类问题统称为“AI代码依赖陷阱”。它跟传统的“拼写仿冒攻击”“恶意包投毒”有本质区别——传统攻击是有人故意挖坑,而AI幻觉依赖是模型无意中画了一个坑,但坑里恰好有人等着。
这篇文章想讲清楚三件事:
第一,AI幻觉为什么在“依赖名”这件事上特别容易翻车,技术原理到底是什么; 第二,为什么现行的代码扫描、安全审计手段很难拦住这类“假依赖”; 第三,我实战中踩坑后的完整排查链路,以及现在团队里在用的三层防护方案。
如果你正在用AI辅助编程,或者你的团队已经允许AI写代码,这篇文章建议耐心看完。依赖层面的幻觉,跟语法层面的幻觉完全不是一个量级——语法错了编译器会报错,依赖错了,整个供应链的可信边界直接崩塌。
2. “假依赖”到底假在哪:从大模型生成机制拆解幻觉产生的三个环节
2.1 大模型不检索事实,它在“续写”看起来合理的词
很多人对AI编程助手有个误解:以为它像个编译器一样,从某个知识库里精确查找并返回正确的包名。实际上,大模型的生成逻辑是概率补全——它根据上文,逐个预测“下一个最可能的词”。
拿依赖名来说,当你输入“Python解析User-Agent的库有哪些”,模型并不会去实时查询PyPI的注册表,它只是在训练数据里见过的所有“解析UA库”相关文本之间做概率采样。它知道这类问题通常伴随哪些词汇出现,于是拼装出一个“高概率存在”的字符串。
问题就在这里:高概率,不等于真实存在。
训练数据里的包名不是结构化同步的,今天PyPI上新增的包、更名的包、下架的包,模型根本不知道。它只记得在2023年之前某个版本的README里出现过user_agent_parser这个写法,还把它和另一个库ua-parser的特征混在了一起,于是就生成一个user-agent-parser-lite这种既像又不像的名字。
我管这个叫“语义惯性”——模型不是查不到正确的包,而是被同类文本的高频词汇带跑了。
2.2 词频污染:越流行的前缀,越容易被“顺拐”到错误包
还有一个长期被低估的因素:训练数据中的词频分布。像js-、react-、python-、django-这样的前缀,在开源仓库文本里大量出现,模型对这些前缀的“路径依赖”极强。
一旦你问它“有没有一个处理日期格式的Node库”,它大概率会往date-前缀的方向生成。于是你就可能得到date-utils-ts这样的名字。这不是恶意,但后果跟恶意投毒几乎一样:如果恰好有攻击者提前注册了这个包,并在里面塞了恶意脚本,你的构建过程就变成了供应链入侵的门户。
更麻烦的是,AI生成的幻觉包名往往具有“高仿性”——它像某个流行包,但多了个连字符、不同后缀,或者换了大小写。这种相似性会绕过人的直觉审查,因为开发者潜意识里会觉得“我见过这个包”。你见过的是它的近亲,而不是它本身。
2.3 时间冻结:模型知识截止日,就是供应链风险的起跑线
模型的知识截止日是一个比想象中严重得多的隐患。软件生态是高度动态的:一个包今天还叫foo,明天因为商标问题改名了;一个库的API接口在新版本里被彻底移除;一个曾经流行的项目停止维护后,名字被另一个人接管。
当AI用“知识截止日之前的世界”来回答“现在应该装哪个包”时,它给出的答案天然带滞后性。如果只是语法上的API不兼容,那还好,最多改几行调用代码。但如果它推荐了一个已经在官方源上下架的包名,而这个包名被别有用心者抢注为新包,那问题就完全变味了——你以为引用了“老牌的稳定库”,实际上装了一个“新生的伪装库”。
我见过一个案例,一个在国外运营的Node项目,AI推荐了某个npm包来加密数据,但那个老包早就被原作者废弃了。废弃之后,名字落到了另一个人手里,新版本里带着挖矿脚本。要不是这个项目在测试环境里先跑了一周,挖矿进程被监控系统揪出来,后果不堪设想。
2.4 测试假阳性:AI连“它自己写的代码”都没测过
这里要单独提一个更隐蔽的坑——AI不但会生成错误依赖,还会围绕这个错误依赖生成“自洽”的调用代码、测试用例和README说明。
你让AI写一个“用包xxx-tool实现图片压缩”的功能,它不只是推荐这个包,还会顺便写出import xxxTool from 'xxx-tool'、调用.compress()方法、捕获异常的处理逻辑。全程代码风格统一、逻辑合理,就像真有其物。开发者照着写完,一跑测试,发现“居然全通过了”?
其实不是代码对,而是AI“顺着自己编出来的API”写了配套的测试桩,测的是自洽的假想接口,根本不是真实世界的包。CI绿了,但绿得毫无意义。这种“自我验证”造成的信任错觉,比直接报错还可怕。
3. 防不住的老朋友:为什么传统供应链防线对AI幻觉依赖几乎失效
3.1 传统供应链攻击的检测思路,默认前提是“包真实存在”
先说清楚类型区别。软件供应链攻击大体分几类:
- 恶意包投毒:攻击者注册一个有吸引力的包名,受害者主动安装;
- 依赖混淆:利用私有包与公共包的同名冲突,诱导解析器拉到恶意公共版本;
- 上游被入侵:一个正常的流行包被植入恶意代码,又通过依赖关系传播;
- 拼写仿冒(typosquatting):注册流行包名的“高相似变体”,等开发者手误命中。
这些攻击有一个共同点:恶意包在仓库里是真实存在的。它的名字、版本号、发布者都能在PyPI、npm、Maven Central上查到。安全工具的检测逻辑,本质上是在“已存在的包集合”里做黑名单、行为分析、信誉评分——包存在,才有得查。
而AI幻觉依赖不一样。很多时候,AI生成的那个包名在官方源里根本不存在。SCA工具扫了个寂寞,因为扫描清单里的依赖名在库里查无此项;SAST工具也查不出漏洞,因为“不存在”本身不在漏洞库里。传统防线建立在一个没法成立的前提上,防线直接空转。
3.2 构建日志里的“NameError”,往往被当成临时网络抖动
当幻觉依赖真的导致构建失败,现象也很有欺骗性。最常见的是ModuleNotFoundError或者 npm 的404 Not Found。
比如在Python项目里,你装包的时候看到报错:
ERROR: Could not find a version that satisfies the requirement neopackage==1.0.0 (from versions: none) ERROR: No matching distribution found for neopackage==1.0.0在Node项目里,你会看到:
npm ERR! 404 Not Found - GET https://registry.npmjs.org/neopackage - Not found这类报错太常见了。公司内网代理抽风、镜像源同步延迟、私有源漏配,全都表现为“找不到包”。多数人第一反应是重试一次、换个镜像,或者干脆把依赖名去掉,改用手写实现。没人会往“AI编了个假包名”的方向想。
3.3 最凶险的变体:名字能用,但内容被掉包
更让人挠头的是第三种情况,也是整个问题的“致命升级版”——AI生成的包名不光存在,还被人抢注成了恶意包。
这种场景下,你pip install是能成功的,代码能跑通,测试能过,安全扫描也未必告警。因为恶意包往往做了大量伪装,功能上会真的实现一部分业务逻辑,只是在背后多夹带点私货。
安全团队查包有没有问题,一般看来源、(历史版本)、函数行为。但一个刚注册三天、发布了一两个版本、还恰好实现了“你需要的功能”的包,行为分析系统很难在第一时间判定恶意。再加上它跟AI生成的幻觉名完全一致,开发者根本没有任何怀疑理由。
这种“幻觉生成 + 抢注命中”组合,风险已经不是黑天鹅,而是灰犀牛。
4. 一次真实的排查链路:从构建报错到揪出三个幽灵依赖
下面这段是上个月我带团队排的一个真实问题,删去了业务细节,保留完整排查思路。你也可以把它当一张检查表,遇到“依赖安装失败但找不到原因”的情况直接对照。
4.1 背景:CI突然挂了,报错指向一个“刚加的包”
当晚10点多,CI上跑的一个Python服务在依赖安装阶段失败,报错信息是找不到某个包,但团队成员都很笃定:“这个包昨天还用过,没问题的。”拉出提交记录一看,是当天下午进来的一个AI辅助生成的PR,新增了三个依赖,用来处理数据清洗。其中有一个名字很眼熟,但当时谁也没注意。
4.2 第一步:锁定“不存在但被引用”的模块
我第一件事不是去查“为什么不通过”,而是把构建日志里的requirements.txt解析结果和pip install的实际拉取结果做对比。
在CI环境里执行:
pip install -r requirements.txt --dry-run --report report.json--dry-run不会真正安装,但会输出依赖解析报告。然后我直接在这个报告里找有没有解析失败的依赖名。果然,有一个包的解析结果是“No matching distribution”,而全项目里所有import这个模块的代码,都是同一个PR添加的。
把代码里搜到的import语句和真实安装包名的对应关系列出来,我用了一个看起来很笨但很有效的办法:手写脚本,遍历项目里所有被import的顶层模块名,跟pip list的已安装包名做一次差集。
import ast, pathlib, pkg_resources installed = {p.project_name.lower() for p in pkg_resources.working_set} imported = set() for path in pathlib.Path('src').rglob('*.py'): tree = ast.parse(path.read_text()) for node in ast.walk(tree): if isinstance(node, ast.Import): for name in node.names: imported.add(name.name.split('.')[0].lower()) elif isinstance(node, ast.ImportFrom): imported.add(node.module.split('.')[0].lower() if node.module else '') print('疑似不存在:', imported - installed)这种“import集合与已装包集合求差集”的笨办法,专门用来抓“模块名直接来源不明”的引用。跑完之后,差集里出现了三个名字——其中两个是项目虚拟环境里确实没装的旧模块,第三个就是那个AI编出来的幻觉包。
4.3 第二步:逆向追溯“这个包是怎么进到项目里的”
锁定目标之后,第二步是搞清楚这个幻觉包是怎么混进来的。我直接把那个PR的代码改动从头到尾过了一遍。
AI生成的代码里有一整段实现:一个utils/cleaner.py,里面import了那个不存在的包,还包装了它的函数。最误导人的是,AI在这个包旁边贴心地生成了一层“兼容适配层”,以至于即使包不存在,只要把这层代码一删,主业务逻辑依然能跑。也就是说,这个包的作用本来就是可替代的,属于AI“顺手”引入的冗余依赖。
这种“顺手引入”,恰恰是AI辅助编程里最常见的毒点。它不像程序员去查官方文档后有意选择的依赖,而是模型在生成代码时觉得“这里大概率要有个库”,然后硬塞了一个。开发者review的时候,看到整段代码结构完整、逻辑顺畅,只会检查业务逻辑对不对,很少有人去逐一核对“每个import背后有没有真实的包”。
我把这个结论发到团队群里,结果另外两个后端同事也去查了自己的代码,分别又发现了一个js项目里的类似问题——其中一个包名甚至是从某个论坛帖子里“抄”来的,但那个名字早被注册成了仿冒包。
4.4 第三步:清点污染范围,锁定风险边界
定位到问题后,没有急着删包。我先把三个幽灵依赖的全部引用点找出来,确认没有真实业务代码依赖它们,然后做了三件事:
第一,在requirements.txt里删除三个依赖名。 第二,在项目里重新搜索相关import,凡是引用幽灵包的代码段,要么删掉,要么替换成已经验证过的真实库。 第三,把 AI 生成的那个utils/cleaner.py打开,逐个核对里面每个import的来源,避免有“间接幽灵依赖”漏网。
这里补充一个排查技巧:检查锁文件(lockfile)里所有传递依赖的“来源字段”。如果是正常安装的包,来源会是某个官方索引;如果一个依赖的来源指向未知仓库,或者根本没有任何来源信息,那就得留意了。
4.5 这次排查得到的三个教训
事后复盘,真正让我后怕的不是“发现了一个假包”,而是“假包在项目里生活了多久”。这个PR在代码仓库里合并了将近一周,CI每天都正常跑过。要不是那个包恰好装不上——可能是抢注者还没上架——我们的安全团队根本不会注意到有一个“不存在的依赖”在生产代码里潜伏了这么久。
第二个教训是:团队里所有人都看了那个PR,却没有任何一个人质疑“这个包为什么没有被lockfile锁定”。因为我们习惯性地认为“能进PR的依赖都是被AI验证过的”,而恰恰是AI没有验证任何东西。
第三个教训,也是最重要的:依赖审查不能只靠人工,必须变成机器流程的一部分。我把这次排查的脚本固化了下来,每次CI构建后自动执行“import集合与已装包集合差集检查”,只要出现新的未注册依赖,构建直接红。
5. 三重防线:从生成源头到依赖解析再到持续监控的落地做法
为了不让你看完前面那段“惨案”只觉得焦虑,我把目前团队在用的一套防护方案完整写出来。思路分三层:让幻觉少发生、让假依赖装不进去、让漏网之鱼能被快速发现。
5.1 第一重:在代码生成阶段给AI“戴上枷锁”
AI生成代码这个环节,很多团队是完全放任的。开发者把需求贴给AI,AI给什么就装什么。想要从源头防住幻觉依赖,一定要给AI设定硬性规则。我现在的做法是在团队的AI提示词模板里加了这么几条:
- 所有的依赖名必须引用“官方文档中真实存在的包”,并在注释中标注来源地址;
- 不确定的包名必须显式写出“我无法确认这个包的真实存在性”,不得编造;
- 禁止生成“看起来像某流行包但其实不是”的相似包名;
- 优先推荐已经被项目使用的现有依赖,而不是每次引入新包。
不要小看这几行提示词。它不能100%消除幻觉,但能把AI从“自信地编造”变成“谨慎地提示”。模型本身并不知道什么是对错,但它知道“被要求给出来源”时,生成倾向于收敛到训练数据中更常见、更真实的包名。
更好的做法是在IDE插件层面拦截。现在部分AI编程插件允许设置“自定义规则”,你可以配置一个依赖名黑名单/白名单检查,在AI返回代码的瞬间,对所有的import语句做正则匹配,凡是命中“已知真实包名列表”之外的,就直接标红提示。这一步相当于把“AI的幻觉输出”卡在进入编辑器之前。
5.2 第二重:在依赖安装阶段引入“名称合法性校验”
这层是目前最有效、最容易被团队接纳的防线。思路很简单:在依赖解析和安装之间,加一道“名称真实性检查”。
具体做法分两步。
第一步,锁死来源。项目必须使用锁文件(requirements.txt+pip-tools或者poetry.lock、package-lock.json),每次新增依赖必须通过锁文件的更新流程,禁止手动往requirements.txt或package.json里塞包名。
第二步,在CI里加一个“依赖身份”检查步骤。用脚本把锁文件里的每一个包名,跟官方源做一次head请求,确认能拿到真实的元数据:
# Python 示例 import json, urllib.request pkg_name = "your-dependency" url = f"https://pypi.org/pypi/{pkg_name}/json" try: with urllib.request.urlopen(url, timeout=10) as resp: data = json.load(resp) print("真实包,版本:", data["info"]["version"]) except urllib.error.HTTPError as e: print("包不存在或已下架,HTTP状态码:", e.code)这个检查对标的是“新出现在锁文件里的依赖”。对项目里原本就存在的、已经被验证过的依赖,可以跳过,没必要每次构建都全量检查。对于新增依赖,则必须通过真实源验证,否则构建直接失败。
同时还要给“新增依赖”设一个冷静期。我的习惯是:AI建议的新包,不在当天引入。先记到待办清单,过了24小时,如果确实还需要,再由人工去官方源查一遍,确认包名、作者、最近版本更新时间、star数,然后才纳入锁文件。这一步其实是在用“人的慢”对冲“AI的快”。
5.3 第三重:持续监控“可复现构建”与“依赖血缘”
前两层防线防的是引入阶段。但依赖是活的,包名今天存在不代表明天还存在,今天的作者可信不代表明天依然可信。所以第三层防线是持续监控,目的只有一个:尽快发现“以前没问题的依赖开始变得不对劲”。
我目前用的办法组合:
- 构建机每天做一次全量依赖清单快照,对比昨天的清单,任何新增、删除、版本变更都会单独告警;
- 核心服务强制使用
hash-pinned依赖锁定——不光锁版本号,还锁安装包的哈希值。这样即便上游仓库被投毒,拉到本地的安装包哈希对不上,构建会直接拒绝; - 定期生成SBOM(软件物料清单),把每个依赖名、版本、来源、许可证、哈希全部记录在案。安全团队做审计时,直接拿SBOM去比对公共情报库,就不用每台机器挨个问了。
这里多提一句“可复现构建”。如果你的项目不能做到“同一份锁文件在全新环境里安装结果完全一致”,那安全审计基本无从谈起。可复现构建是供应链安全的地基,不是可选项。
在npm项目里,建议用npm ci代替npm install,它严格按照锁文件安装,不会因为环境差异悄悄改版本。Python项目建议用pip install --require-hashes -r requirements.txt或直接上uv,前者强制哈希校验,后者解析速度快到可以让安全检查频繁跑起来不心疼成本。
5.4 给团队流程的“三道硬要求”
除了技术手段,流程上一定要有制度兜底。我踩过坑之后的团队规范只有三条,简单但管用:
- 任何AI生成的代码进PR前,必须由另一个人类开发者做一次“只审查依赖变更”的专项review,不允许混在业务代码里带过;
- 锁文件的变更记录与代码变更记录挂钩,凡是新依赖都要能找到对应的功能说明和引入理由;
- 每个月做一次依赖清点,把“没有被任何代码import的依赖”以及“超过半年没有更新的依赖”单独列出来,逐项确认去留。
这三条制度不重,但能逼着团队养成“对依赖敏感”的肌肉记忆。依赖不是免费午餐,每一次引入都在扩大攻击面,这个账必须有人算清楚。
6. 写在最后——AI辅助编程不是毒药,但使用的人必须长出抗体
从我个人的体感来说,AI辅助编程带来的效率提升是实打实的,我没有任何劝退的意思。恰恰因为它的效率高,我们才必须在“代码产出速度”和“依赖可信度”之间重新寻找平衡。
这几个月排查下来,我最深的一个体会是:AI幻觉在语法层面只是小麻烦,在依赖层面是生死问题。语法错了,编译器会挡在门口;依赖错了,它直接绕过编译器,住进你的构建环境,跟着你的应用一起上线。
所以我不建议团队因为怕幻觉就禁用AI,更不建议完全信任AI的包名推荐。正确姿势是把它当成一个“有点能力但经常虚报”的下属——它给出的代码要用,但给出的依赖必须先验证。
最后分享一个顺手的小技巧:如果你也在用AI生成代码,可以在提示词里固定加一句“不要推荐任何未经我确认的第三方库,如果确实需要,请先列出三种可选方案让我选择”。这短短一句话,能把幻觉依赖的概率压下一个量级。跟我一起排查过问题的那几个同事,已经把这个习惯固化成了他们自己的提示词模板,到现在也没再翻过车。