ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

AI代码幻觉的本地审计:基于Ledgerful的防幻觉工具实践

AI代码幻觉的本地审计:基于Ledgerful的防幻觉工具实践 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.xJedisPooled根本不存在。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, encodingutf-8) as f: self.trust_base yaml.safe_load(f) with open(package_json_path, r, encodingutf-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_outputTrue, textTrue, checkTrue, ) 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_pathPath(sys.argv[1]), package_json_pathPath(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_asciiFalse, indent2))这个程序的核心逻辑不难理解先用 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无法处理 TypeScriptBabel 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 声明或官方导出可信度中等。inferredAI 或工具自动推测出来的可信度低需要人工验证。分级后审计报告可以按“风险等级过滤”。在 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/checkoutv3 - uses: actions/setup-pythonv4 with: python-version: 3.10 - uses: actions/setup-nodev3 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 生成代码越来越像真实代码的时代值得每个开发团队认真落地。
返回列表