AI 辅助编码已经普及到“不会用反而像在裸奔”的阶段,但真正进过生产环境的人心里都清楚:AI 生成代码最大的风险,根本不是格式、也不是跑不起来,而是它用了你项目里根本不存在的 API、编造了一个从未发布的函数签名、或者相信某个第三方库支持一个它从来不支持的能力。
这类问题有个更准确的名字——AI 幻觉。
大模型在文本场景里的幻觉,是“一本正经地胡说八道”;放到代码场景,它变成了“一段看起来完全合理、却引用了幽灵符号的代码”。这类代码不会在生成阶段报错,编译期也可能正常通过,直到运行时或者代码评审环节,你才发现自己正在和一个不存在的依赖搏斗。
最近我关注到一个很有意思的工具:Ledgerful。
它的描述很直接:I built a local tool to catch when AI invents stuff in my code。名字里的 Ledger 很关键——账本。它不是再套一层 AI 去审查 AI,而是用“证据链”和“信任清单”的方式,把 AI 生成代码里那些无法被现有依赖和项目源码支持的调用、导入、访问路径,当成可疑记录一条条揪出来。
这篇文章不会只聊概念。我会结合这类本地防幻觉工具的思路,拆解 AI 代码幻觉的典型模式,然后给出一个最小可落地的本地审计工具实现,包括环境准备、核心代码、运行验证和常见排查方式。
1. 为什么 AI 代码幻觉是一个必须正视的工程问题
先看一个几乎所有用过 AI 编程助手的人都会遇到的场景。
你让 AI 帮你封装一个 Redis 操作模块。它非常流畅地给出了类似这样的代码:
import redis.clients.jedis.JedisPooled; public class RedisClient { private JedisPooled jedis; public RedisClient() { this.jedis = new JedisPooled("localhost", 6379); } public String get(String key) { return jedis.get(key); } }表面上,这段代码没有任何语法问题。JedisPooled 确实是 Jedis 4.x 里真实存在的类。但问题在于:如果你的项目里实际依赖的是 Jedis 3.x,JedisPooled根本不存在。IDE 自动补全可能会提示错误,但如果你没注意,这段代码就会被合并进主干。
更隐蔽的是另一种情况:AI 生成一段调用某个私有工具类方法的代码,而这个工具类是你项目里真实存在的。AI 基于类的名字“合理推测”有一个batchProcess方法,实际上这个方法叫processBatch。这类错误,静态分析工具查不出来,编译检查也可能因为同名重载而蒙混过关,最终在测试环境才暴露。
这就是 AI 代码幻觉的可怕之处:它不产生随机错误,它产生的是“高仿真错误”。
我把常见的 AI 代码幻觉分成四类,你可以对照自己遇到的情况:
| 幻觉类型 | 典型表现 | 危害程度 | 发现难度 |
|---|---|---|---|
| 虚构 API | 调用了不存在的类/方法/属性 | 高 | 编译期或运行时才能发现 |
| 伪造库接口 | 第三方库真实存在,但接口被“脑补” | 高 | 运行时才发现 |
| 错误版本假设 | 用了当前项目依赖版本不支持的写法 | 中 | 编译或 IDE 提示 |
| 编造环境信息 | 假设存在某个环境变量、配置文件、服务地址 | 中高 | 部署时暴露 |
如果你正在用 Cursor、Claude Code 或 Copilot 等 AI 编程工具,又缺少严格的代码评审流程,这类幻觉代码实际上每时每刻都在往代码库里渗入。人工评审不可能逐行去查每个 API 是否真实存在,所以真正可行的路径是:用自动化机制把幻觉代码挡在合并之前。
2. Ledgerful 的思路:把代码变更当账本,把可信事实当凭证
“Ledgerful”这个名字听起来有些陌生,但拆开看其实非常直观:Ledger + ful,即“充满账本思维”。
账本思维的核心是什么?
- 每一笔变更都要有凭证。
- 每一笔变更都要有来源。
- 无法提供凭证的来源,整体标记为可疑。
如果把 AI 生成的代码视作“待入库账单”,那么项目里已经存在的源码、依赖、配置文件、历史代码,就是“可信凭证库”。账单上每一条涉及 API、函数、类、配置项的记录,都要去凭证库里对账。对不上,就是幻觉。
传统 AI 代码审查的思路通常是“让模型再看一遍”。比如把 AI 生成的代码交给另一个 AI 模型评判,让它找出错误。这种做法的问题在于:AI 评判 AI,只是从一个概率空间跳到另一个概率空间。前一个模型会幻想,后一个模型同样会幻想,而且两个模型可能共享同样的训练数据偏差。
Ledgerful 这类工具的优势在于:它不依赖模型的“判断力”,而是依赖项目的“客观事实”。
具体来说,它做的事情是:
- 监听/扫描工作区中 AI 生成或修改的代码。
- 提取代码里所有外部符号:导入路径、类名、方法调用、属性访问。
- 把提取结果和本地事实库——项目源码、编译产物、依赖包元数据、配置文件——进行比对。
- 对无法匹配的可疑符号记录为“幻觉候选”,并生成结构化报告。
这套逻辑用一句话概括就是:本地事实优先,模型判断退居二线。
这也是这篇文章里我认为最有价值的一点:它把“AI 是不是在骗我”这个问题,从主观感觉变成了可追踪、可回溯、可自动化的工程流程。你不需要一个新模型,你只需要把项目和代码之间的关系变成一张可以对账的账本。
3. 环境准备与前置条件
在动手实现之前,先明确一下本文要搭建的最小工具需要什么环境。这个示例的思路是通用的,不依赖特定操作系统,验证代码使用 Python 编写。
3.1 依赖清单
| 依赖项 | 用途 | 说明 |
|---|---|---|
| Python 3.9+ | 运行审计脚本 | 核心工具语言 |
| Node.js 18+ | 作为示例被审计项目 | 模拟真实前端/全栈项目 |
| pip | 安装 Python 依赖 | 系统自带或手动安装 |
| Git | 管理代码版本 | 用于对比和回滚 |
版本请以你机器上的实际情况为准,本文重点演示通用思路,不依赖某个特定小版本特性。
3.2 安装 Python 依赖
先创建一个独立的工作目录,并初始化虚拟环境:
mkdir ledgerguard cd ledgerguard python3 -m venv .venv source .venv/bin/activate接着安装解析代码用到的库。这里我们使用树莓派派系中常见的 ast 标准库来做静态解析,不需要额外依赖。如果后续你希望解析 TypeScript,可以用 Node.js 侧的@babel/parser或typescript编译器 API。
为了方便生成报告,再安装一个轻量的包:
pip install PyYAML这个库用于读取 YAML 格式的信任配置。
3.3 准备一个被审计的示例项目
为了演示“抓幻觉”的过程,我创建一个简单的 Node.js 项目,并在里面预埋一个典型的 AI 幻觉代码。这个项目只有一个文件,不是完整业务系统。
mkdir sample-ai-project cd sample-ai-project npm init -y假设 AI 生成了下面的工具文件utils/format.js:
// 文件路径:sample-ai-project/utils/format.js const { DateTime } = require('luxon'); function formatTimestamp(ts) { const dt = DateTime.fromMillis(ts); return dt.toLocaleString(DateTime.DATETIME_FULL_WITH_SECONDS); } module.exports = { formatTimestamp };这段代码本身没有语法错误。但它的两个关键假设需要验证:
- 项目是否安装了
luxon依赖? DateTime.DATETIME_FULL_WITH_SECONDS是否真实存在?
这就是审计工具要做的判断。
4. 核心流程拆解
我会把整个审计工具拆成四个步骤,每一步解决一类问题。
4.1 步骤一:扫描目标文件,收集外部符号
第一步是把 AI 生成的代码文件解析成抽象语法树,提取所有“与外部世界发生关联”的位置:
- import/require 语句引用的包名和导入路径。
- 函数调用中的方法名。
- 成员访问中的属性名。
- 实例化表达式中的类名。
在 Python 生态里,我们只需要使用标准库ast来解析 Python;但这里要审计 JavaScript 代码,就需要调用一个 JS 解析器。为了简化示例,我用 Node.js 侧脚本先做静态提取,再用 Python 侧脚本做最终的判定。整个链路可以理解为“采集端 + 分析端”。
4.2 步骤二:建立信任清单
“信任清单”是这个工具的灵魂。
它不是一份写完就不动的文件,而应该和项目一起演进。初始版本至少包含三类信息:
- 项目根目录里的所有本地模块导出。
- package.json 中声明的依赖列表及版本。
- 手动维护的“已知可信 API”清单。
我把信任清单放在 YAML 文件里,方便人工维护和 Git 追踪。
4.3 步骤三:交叉比对与可疑判定
拿到外部符号和信任清单之后,做一次集合运算:
- 符号在信任清单中 -> 标记为“可信”;
- 符号不在信任清单中 -> 标记为“可疑”;
- 符号对应的包在 package.json 中存在,但具体 API 不在已知接口中 -> 标记为“需要人工确认”。
这种分层方式避免了一刀切的误报。比如某个 API 可能来自项目里尚未建立索引的旧代码,直接标红会打扰开发流程。
4.4 步骤四:生成审计报告
报告应当包含:
- 被审计的文件路径。
- 可疑符号名称。
- 符号位置(行号、列号)。
- 判断依据:为什么可疑。
- 处理建议:补信任、删除、修改。
报告格式统一为 JSON,方便接入 CI 流水线或团队机器人。
5. 完整示例代码实现
下面给出三个可落地的文件示例:信任配置、JS 符号采集脚本、Python 审计主程序。请按路径创建文件。
5.1 信任清单配置
# 文件路径:ledgerguard/trust-base.yaml trustedModules: - name: "fs" exports: ["readFileSync", "writeFileSync", "existsSync", "mkdirSync"] - name: "path" exports: ["join", "resolve", "basename", "extname"] - name: "luxon" exports: ["DateTime", "Duration", "Interval"] apis: DateTime: methods: ["fromMillis", "fromISO", "toLocaleString"] properties: ["DATETIME_FULL", "DATETIME_FULL_WITH_SECONDS"] - name: "lodash" exports: ["get", "set", "cloneDeep", "debounce", "throttle"] trustedLocalFiles: - path: "src/utils/date.ts" exports: ["formatDate", "parseDate"] projectConfigFiles: - "package.json"这个文件解决的痛点非常明确:把模型脑补过的 API 变成项目方愿意接受的“白名单”。白名单之外的符号,一律当作可疑处理。
5.2 Node.js 符号采集脚本
// 文件路径:ledgerguard/collector/collect-symbols.js const fs = require('fs'); const path = require('path'); const parser = require('@babel/parser'); function collectSymbols(filePath) { const code = fs.readFileSync(filePath, 'utf8'); const ast = parser.parse(code, { sourceType: 'module', plugins: ['jsx', 'typescript'] }); const symbols = []; const importedModules = []; const memberCalls = []; function walk(node) { if (!node || typeof node !== 'object') return; if (node.type === 'ImportDeclaration') { importedModules.push(node.source.value); for (const spec of node.specifiers) { // import { xxx } from 'yyy' if (spec.type === 'ImportSpecifier') { symbols.push({ module: node.source.value, name: spec.imported.name, kind: 'import-specifier' }); } } } if (node.type === 'CallExpression' && node.callee.type === 'MemberExpression') { memberCalls.push({ objectName: node.callee.object.name || '', methodName: node.callee.property.name || '', kind: 'member-call' }); } for (const key in node) { if (key === 'loc' || key === 'start' || key === 'end') continue; const child = node[key]; if (Array.isArray(child)) { child.forEach(c => walk(c)); } else { walk(child); } } } walk(ast); return { file: filePath, importedModules, symbols, memberCalls }; } const target = process.argv[2]; const result = collectSymbols(path.resolve(target)); console.log(JSON.stringify(result, null, 2));注意:这个脚本用到了@babel/parser,你需要先安装:
npm install @babel/parser5.3 Python 审计主程序
# 文件路径:ledgerguard/audit.py import json import subprocess import sys from pathlib import Path import yaml class LedgerGuard: def __init__(self, trust_base_path: Path, package_json_path: Path): with open(trust_base_path, "r", encoding="utf-8") as f: self.trust_base = yaml.safe_load(f) with open(package_json_path, "r", encoding="utf-8") as f: self.package_json = json.load(f) self.trusted_modules = self._build_trusted_module_map() self.trusted_local_files = self.trust_base.get("trustedLocalFiles", []) def _build_trusted_module_map(self): module_map = {} for module in self.trust_base.get("trustedModules", []): module_map[module["name"]] = module return module_map def _is_local_export(self, name: str) -> bool: for local in self.trusted_local_files: if name in local.get("exports", []): return True return False def audit_file(self, js_file_path: Path, collect_script_path: Path): result = subprocess.run( ["node", str(collect_script_path), str(js_file_path)], capture_output=True, text=True, check=True, ) data = json.loads(result.stdout) findings = [] # 1. 检查依赖是否存在 for module in data["importedModules"]: if module not in self.package_json.get("dependencies", {}) and \ module not in self.package_json.get("devDependencies", {}): findings.append({ "type": "missing-dependency", "module": module, "message": f"模块 {module} 未在 package.json 中声明" }) # 2. 检查模块中的 API 是否在信任清单内 for symbol in data["symbols"]: module_name = symbol["module"] symbol_name = symbol["name"] trusted = self.trusted_modules.get(module_name) if trusted: exports = trusted.get("exports", []) if symbol_name not in exports: findings.append({ "type": "unknow-export", "module": module_name, "symbol": symbol_name, "message": f"{module_name} 的导出符号 {symbol_name} 不在信任清单中" }) elif not self._is_local_export(symbol_name): findings.append({ "type": "unknow-module", "module": module_name, "symbol": symbol_name, "message": f"{module_name} 不是已知本地模块,也不是信任模块" }) # 3. 检查成员方法调用 for call in data["memberCalls"]: module_name = call["objectName"] method = call["methodName"] trusted = self.trusted_modules.get(module_name) if trusted: apis = trusted.get("apis", {}) if module_name in apis: methods = apis[module_name].get("methods", []) if method not in methods: findings.append({ "type": "unknow-method", "module": module_name, "symbol": method, "message": f"{module_name} 没有方法 {method}" }) else: exports = trusted.get("exports", []) if method not in exports: findings.append({ "type": "unknow-member", "module": module_name, "symbol": method, "message": f"{module_name} 对象上不存在 {method}" }) return data, findings if __name__ == "__main__": if len(sys.argv) != 4: print("用法: python audit.py <trust-base.yaml> <package.json> <目标JS文件>") sys.exit(1) guard = LedgerGuard( trust_base_path=Path(sys.argv[1]), package_json_path=Path(sys.argv[2]), ) target_js = Path(sys.argv[3]) data, findings = guard.audit_file(target_js, Path("collector/collect-symbols.js")) print(json.dumps({ "auditedFile": str(target_js), "symbols": data["symbols"], "findings": findings }, ensure_ascii=False, indent=2))这个程序的核心逻辑不难理解:
- 先用 Node.js 脚本把目标 JS 文件解析成符号集合。
- 再把符号集合和信任清单、package.json 三方对账。
- 输出一个 JSON 报告,里面包含所有“可疑点”。
如果发现luxon没有出现在 package.json 的 dependencies 里,它会直接报missing-dependency。如果luxon存在但DATETIME_FULL_WITH_SECONDS不在信任清单里,它会报unknow-member或unknow-method。
6. 运行结果与效果验证
把示例项目放进审计流程,运行命令如下:
cd ledgerguard python audit.py trust-base.yaml ../sample-ai-project/package.json ../sample-ai-project/utils/format.js如果一切正常,预期输出类似:
{ "auditedFile": "../sample-ai-project/utils/format.js", "symbols": [ { "module": "luxon", "name": "DateTime", "kind": "import-specifier" } ], "findings": [ { "type": "missing-dependency", "module": "luxon", "message": "模块 luxon 未在 package.json 中声明" } ] }注意:上方的 memberCalls 没有出现在最终输出里,因为我在示例中只打印了symbols和findings。如果你想看完整的调用关系,可以在审计主程序里把data["memberCalls"]也打印出来。至于DateTime.DATETIME_FULL_WITH_SECONDS是否会被揪出来,取决于它最终在symbols和memberCalls中的形态,以及信任清单里的精确配置。
这里要强调一个验证原则:报告里的每一条项必须能追溯到证据。如果某条findings出现了,但你的项目里明明有这个 API,那说明你的信任清单不完整,或者 Babel AST 拿到的是不同的节点结构。审计工具的价值不在于“零误报”,而在于“每次误报都推动信任清单变得更完整”。
如果运行失败,按下面的顺序排查:
- 先跑
node collector/collect-symbols.js ../sample-ai-project/utils/format.js,确认 Node 侧能输出符号集合。 - 确认
@babel/parser已安装在 ledgerguard 目录下。 - 确认 YAML 文件缩进正确,yaml.safe_load 能正常解析。
- 确认 package.json 路径正确,不是相对路径拼错。
7. 常见问题与排查思路
下面是我在设计这类本地审计工具时最容易踩到的问题,整理成表格供大家对照。
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| Node 采集脚本报错 “Cannot find module '@babel/parser'” | 依赖没有安装 | 在 ledgerguard 下执行npm ls @babel/parser | 执行npm install @babel/parser |
| 审计结果没有任何 findings | 信任清单配置过宽,或者 AST 提取的节点类型不匹配 | 打印data["symbols"]和data["memberCalls"]看实际提取结果 | 调整符号采集逻辑,加入 CallExpression 和 MemberExpression 的完整路径提取 |
| 误报:项目里确实存在 API,但工具判定可疑 | 信任清单的 exports 列表未覆盖最新代码 | 查看测试文件或者源码里的导出语句 | 补充信任清单或者用脚本自动生成 export 列表 |
| 只能处理 JavaScript,无法处理 TypeScript | Babel parser 的 plugins 没有启用 typescript | 检查 parser.parse 的 plugins | 添加'typescript'插件 |
| 审计速度太慢 | 每次执行都重新解析整个依赖树 | 检查是否有重复扫描的目录 | 加入路径白名单/黑名单,忽略 node_modules 和构建产物 |
| 报告太长,无法在 CI 里阅读 | 没有按严重程度分级别 | 看输出 JSON 是否包含 type 字段 | 过滤missing-dependency级别的建议;未解析安全约定前保留全部信息 |
对这些问题的处理原则是:宁可多暴露可疑项,也不要静默吞掉错误。因为防幻觉工具的目标不是减少通知数量,而是降低幻觉代码进入主干的风险。
8. 最佳实践与工程建议
前面讲的是怎么把最小工具跑起来,接下来聊聊怎么把它放进实际开发流程,以及哪些设计原则值得长期坚持。
8.1 不要用“AI 审 AI”替代“代码对账”
我在前面已经提过,用一个 LLM 去检查另一个 LLM 的输出,是一个概率系统去验证另一个概率系统。虽然它能给出更高层的语义判断,但它同样可以被幻觉影响。本地账本审计的优势是“可验证性”:每一条可疑记录都对应一个本地事实,你可以去 grep、去打开源码、去查 node_modules。
在实际项目中,建议把“两阶段检测”作为最佳组合:
- 第一阶段:本地的静态审计工具跑一遍,快速过滤明显的幻觉。
- 第二阶段:再让人类评审或 AI 评审去关注那些“语义层面”的问题。
8.2 信任清单必须版本化
trust-base.yaml不是一份临时配置,它是项目知识库的一部分。每次新增依赖、每次确认某个 API 真实存在,都应该更新信任清单,并通过 Git 提交。这样你不仅能追踪“什么是可信的”,还能追踪“谁在什么时候确认了它是可信的”。
如果团队里有多个 AI 辅助开发成员,这份信任清单相当于一个“统一共识库”。每个人看到的主观判断可能不同,但共同维护一份账本之后,判断标准就能收敛。
8.3 给信任清单分级,而不是一刀切
我强烈建议把信任项分成三个等级:
- verified:由人工在真实代码中确认过,可信度高。
- library-declared:来自依赖包的 type 声明或官方导出,可信度中等。
- inferred:AI 或工具自动推测出来的,可信度低,需要人工验证。
分级后,审计报告可以按“风险等级过滤”。在 CI 里,inferred级别的问题只提示,不阻塞;verified级别不匹配的问题直接失败。这样可以减少噪音,同时保证高优先级幻觉能够被拦住。
8.4 接入 CI,让幻觉在合并前暴露
最理想的接入方式是让这个审计工具跑在 CI 的 pre-merge 检查中,而不是等代码合并后再去人工看报告。
# 文件路径:.github/workflows/ai-hallucination-audit.yml(示例) name: AI Hallucination Audit on: pull_request: types: [opened, synchronize] jobs: audit: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - uses: actions/setup-python@v4 with: python-version: '3.10' - uses: actions/setup-node@v3 with: node-version: '18' - run: npm install - run: python audit.py trust-base.yaml package.json ./src注意:上面的 CI 配置是一个演示骨架,实际目录结构和命令需要按你的项目调整。关键点是“在 PR 阶段就把可疑代码标记出来”,而不是把责任全放到 review 环节。
8.5 定期更新信任清单,避免清单腐化
任何一个“白名单”机制都会遇到“腐化”问题:当你半年没更新信任清单,最新代码里已经用了大量新 API,而清单还停留在过去时,审计工具就变成了一个只会误报的噪音源。
解决思路有两个:
- 每周固定任务:人工检查并合并“建议加入信任清单”的 PR。
- 自动化生成基础清单:用工具解析 node_modules 里每个包的类型声明,自动生成一份“库声明级”信任清单,然后在上面做增量验证。
第二种思路可以把信任清单的维护成本降到最低,同时保留人工验证的监控位。
8.6 安全边界:审计脚本不联网,数据不出本地
这一点对团队尤其重要。很多 AI 辅助编程工具默认会把代码片段上传到云端,但生产代码和企业内部项目往往对代码出境有严格限制。Ledgerful 这类本地工具的优势就在于:所有分析逻辑都在本地完成,代码不需要发往第三方服务。
在设计工具时,请严格遵守以下约束:
- 审计脚本禁止任何外部 HTTP 请求。
- 不把目标源码发送给外部 AI 或代码检查服务。
- 不把信任清单上传到公共仓库(除非你的项目本来就是开源的)。
- 所有日志和报告只保存在本地工作目录。
如果将来需要共享报告,要对报告做脱敏处理,删掉源码内容和文件路径,只保留“文件 hash + 可疑类型 + 风险级别”。
9. 总结与后续学习方向
AI 代码幻觉不会因为模型变强就自动消失。模型越强,生成的代码越像模像样,反而给人工审查带来更大的认知负担。因为一个看起来“太合理”的错误,比一个一眼就能看出的低级错误,危害大得多。
Ledgerful 这个项目给我最大启发是:应对 AI 幻觉,不一定需要更聪明的模型,更务实的路径是建立一个“本地事实账本”,把代码和项目之间的信任关系变成可审计的工程资产。
这篇文章的实践部分,给出了一个最简版本的三段式工具:
- Node.js 符号采集脚本,负责把 JS 文件解析成可审计的符号集合。
- YAML 信任清单,负责沉淀团队对 API 的共识。
- Python 审计主程序,负责交叉比对并生成结构化报告。
如果你正在使用 AI 辅助编码,我建议下一步做三件事:
- 找一个最近 AI 生成的文件,手动把它跑进这个审计流程,看能发现多少可疑项。
- 把信任清单纳入版本管理,让团队达成共识。
- 在 CI 里接入一个最小审计步骤,从“只提示”开始,逐步过渡到“关键项阻塞合并”。
比“相信 AI”更重要的,是“能验证 AI”。账本思维不复杂,但它能把“感觉不太对”变成“这里有问题,证据如下”。这一点,在当前这个 AI 生成代码越来越像真实代码的时代,值得每个开发团队认真落地。