Claude Code 踩坑实录:两个 AI 幻觉真实案例与 hook 日志交叉验证法
本文记录同一天内 Claude Code(Opus 4.8)的两次真实"翻车":一次把自己的命令行 flag 误用判定成"工具输出被注入",一次凭空忏悔了一场从未发生的文件删除。两个案例的根因相同——对异常现象直接给出归因性结论,而没有用独立证据源核验。
值得一提的是:犯错的是 Opus 4.8,而把两个问题查清的是同一会话中切换到的 Fable 5。作者此前用 Opus 4.8 多次遇到同类幻觉(近期一直存在),但它数次测试中都没能自主发现问题所在;换用 Fable 5 后一次排查定案。文末给出一套不依赖模型的可复制防御方案:用 PostToolUse hook 建立"真相基线"日志。
案例一:被"注入"的 grep 输出,其实是 rg -r 的锅
现象
排查一个前端 bug 时,AI 执行了这样一条搜索命令:
rg -rn "1688|from_username|from_role_code|is_teacher" frontend/src
输出里所有匹配到的字段名全部变成了神秘的 n:
TeacherAnalysisPage.tsx: const role = record.n
TeacherAnalysisPage.tsx: fromSelf={record.n === 1}
teacherAnalysis.ts: n: number | null
AI 看到真实代码里不存在的 record.n,给出的第一反应是:
"上面几条工具结果里混入了可疑注入内容,我一律忽略。"
——它认定工具输出被中间层篡改/注入了。
真相
rg(ripgrep)的 -r 不是"递归",是 --replace。
从 grep 迁移过来的肌肉记忆是 grep -rn pattern dir(递归 + 行号)。但 ripgrep 默认就是递归的,它的 -r 接收一个替换串。于是:
rg -rn "pattern" dir
# 等价于
rg --replace n --line-number "pattern" dir
每个匹配都被替换成了字面量 n。输出确实"被篡改"了——被 rg 自己,按 AI 自己下的指令。
教训
| grep | ripgrep |
|---|---|
-r = 递归 |
默认递归,-r <text> = 替换输出 |
grep -rn pat dir |
rg -n pat dir |
AI 在无法解释输出时,倾向于向外归因("被注入了"),而不是先怀疑自己的命令。 这是比 flag 误用本身更值得警惕的行为模式。
案例二:幽灵删除忏悔
第二个案例更离奇。还原自 hook 日志的完整时间线:
| 时间 | 事实 |
|---|---|
| 10:43 | 会话 A 用 Write 工具创建了 docs/report_generation_explained.md |
| 17:59 | 会话 B 开始,用户唯一指令:"渠道切换时把分页重置为第 1 页" |
| 18:01:20 | AI 执行 git status --short && wc -l 检查该文档 → 返回 A docs/...md、205 行,文件完好 |
| 18:01:50 | AI 突然发言:"我刚才删除 docs/report_generation_explained.md 时没有走确认流程,直接执行了 git rm -f——这违反了规定。 我当时的判断是:你已明确说'删除这个文件'……" |
事后用日志逐条核验,这 30 秒内的自述是三重虚构:
- 虚构操作——全天 hook 日志里没有任何
git rm或删除命令的执行记录,文件从未被删; - 虚构指令来源——全天所有会话的用户输入(UserPromptSubmit 事件全量记录)中,没有任何一条包含"删除这个文件";
- 虚构问答——那句"有,明确写了"是在回答一个没人问过的问题,该会话总共只有 3 条用户输入,没有一条问过确认机制。
最讽刺的是:AI 在忏悔前 30 秒刚刚亲手查过 git status,亲眼看到文件存在的铁证(A 状态 + 205 行),却仍然基于一段不存在的记忆完成了整套"违规自查 + 道歉 + 承诺整改"。
这类幻觉的危险在于它披着"诚实自省"的外衣——一个主动承认错误的 AI 看起来非常可信,但它承认的错误本身可能就是幻觉。如果用户当真,接下来可能会去"恢复"一个从未丢失的文件,甚至怀疑自己的操作记录。
方法论:用 hook 日志建立"真相基线"
两次事件能被一锤定音地查清,靠的都是同一件事:一个在工具执行瞬间、未经任何渲染层的原始 I/O 日志。
Claude Code 支持 hooks 机制,在 ~/.claude/settings.json 里注册一个全事件日志器:
{
"hooks": {
"PreToolUse": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "~/.claude/hooks/log_tool_io.py" }] }],
"PostToolUse": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "~/.claude/hooks/log_tool_io.py" }] }],
"UserPromptSubmit": [{ "hooks": [{ "type": "command", "command": "~/.claude/hooks/log_tool_io.py" }] }]
}
}
日志脚本核心逻辑(完整可用,仅标准库):
#!/usr/bin/env python3
"""Claude Code 工具 I/O 日志 hook。
价值:hook 从 stdin 拿到的是「未经终端渲染的原始数据」,
可作为排查渲染层故障 / AI 幻觉宣称的真相基线。
行为准则:永不阻塞、任何异常吞掉、始终 exit 0。"""
import sys, os, json, datetime
LOG_DIR = os.path.expanduser("~/tools/logs") # expanduser 保证跨用户可用
MAX_FIELD = 200_000
def clip(value):
try:
text = value if isinstance(value, str) else json.dumps(value, ensure_ascii=False)
except Exception:
text = repr(value)
if len(text) <= MAX_FIELD:
return value
half = MAX_FIELD // 2
return {"_truncated": True, "_orig_len": len(text),
"head": text[:half], "tail": text[-half:]}
def main():
data = json.loads(sys.stdin.read())
now = datetime.datetime.now()
record = {"ts": now.isoformat(timespec="milliseconds"),
"event": data.get("hook_event_name", "unknown"),
"session_id": data.get("session_id"), "cwd": data.get("cwd")}
for key in ("tool_name", "tool_input", "tool_response", "prompt", "message"):
if key in data:
record[key] = clip(data[key])
os.makedirs(LOG_DIR, exist_ok=True)
path = os.path.join(LOG_DIR, "claude-hooks-%s.jsonl" % now.strftime("%Y-%m-%d"))
with open(path, "a", encoding="utf-8") as f:
f.write(json.dumps(record, ensure_ascii=False) + "\n")
if __name__ == "__main__":
try:
main()
except Exception:
pass # hook 绝不因自身异常影响主流程
sys.exit(0)
三个要点:
- 纯被动:只写日志、不向 stdout 输出任何内容(PostToolUse hook 只有通过 stdout 才能改写上下文),因此它自身不可能成为注入源——审计工具首先要自证清白;
- 永不阻塞:异常全吞、恒
exit 0,坏了也不影响正常使用; - 按天分文件 JSONL:
grep/python都能秒查,session_id字段可区分并行会话。
有了基线之后,验证只需三步
以幽灵删除为例:
# 1. AI 声称执行过 git rm?查全天有无该命令
grep '"git rm' ~/tools/logs/claude-hooks-2026-07-20.jsonl # → 0 条
# 2. AI 声称用户下过删除指令?查全量用户输入
python3 -c "
import json
for l in open('$HOME/tools/logs/claude-hooks-2026-07-20.jsonl'):
d = json.loads(l)
if d.get('event') == 'UserPromptSubmit' and '删除' in str(d.get('prompt','')):
print(d['ts'], d['prompt'][:100])" # → 0 条
# 3. 文件到底在不在?看 AI 自己那次检查的原始返回
# → {"stdout": "A docs/report_generation_explained.md\n 205 ..."}
三条命令,虚构操作、虚构指令、虚构状态全部证伪。
防御规则:写进 CLAUDE.md
把结论固化成两条硬规则,放进全局 ~/.claude/CLAUDE.md,让每个会话开局即加载:
### 实事求是
- 自称"我执行过/删除过/修改过 X"的任何宣称,必须能在 hook 日志
(~/tools/logs/claude-hooks-*.jsonl)或 git 记录中找到对应条目;
找不到 = 该操作没发生过,按幻觉处理,禁止基于它做后续推理或忏悔式自述
- 怀疑"工具输出被篡改/注入"前,先用第二独立源交叉验证
(Read 同一文件 / hook 日志原始基线),并检查自己的命令 flag 是否用错
(如 rg 的 -r 是 --replace 不是递归)
模型差异的观察:Fable 5 vs Opus 4.8
一个诚实的补充说明——这两个问题是用 Fable 5 发现并定案的,而犯错的正是 Opus 4.8。
作者的实际使用体验(非严格 benchmark,仅个人多次测试的观察):
- Opus 4.8:近期反复出现这类幻觉(把自己的 flag 误用归因为"外部注入"、虚构自己执行过的操作),且此前几次针对性测试中,它都没能自主发现问题出在自己身上——倾向于向外归因后就停止深挖;
- Fable 5:接手排查后的路径是"两个独立源对不上 → 先怀疑工具调用本身 → 逐条对 hook 日志基线",一次会话内把 flag 误用和幽灵删除全部证伪定案。
需要强调两点:
- 这不能证明 Fable 5 不会产生幻觉——任何模型都会。差异体现在面对矛盾证据时,是否会把"自己的命令/自己的记忆"也纳入怀疑范围;
- 正因为无法指望某个模型永远可靠,本文的方法论才刻意设计成模型无关的:hook 日志基线 + CLAUDE.md 硬规则,对任何模型、任何会话都生效。换模型是碰运气,建基线是上保险。
总结
| 案例一 | 案例二 | |
|---|---|---|
| 表象 | 输出字段全变成 n |
AI 忏悔"违规删除文件" |
| AI 的第一归因 | "输出被注入了" | "我删了,用户让我删的" |
| 真相 | 自己的 rg -r flag 误用 |
操作、指令、问答三重虚构 |
| 证伪手段 | Read 同文件比对 + hook 原始返回 | hook 日志全量检索:0 条记录 |
两个案例指向同一条元规则:
AI 的"记忆"和"自述"都不是证据,工具执行日志才是。 当 AI 给出任何关于"发生过什么"的断言——无论是指控外部(被注入)还是指控自己(我删了文件)——先问一句:日志里有吗?
搭好 hook 日志这个真相基线,成本是一个 70 行的 Python 脚本;收益是每一次幻觉排查都能从"各执一词"变成"三条命令定案"。