第 18 章 调试与排障实战
第 06 章的可执行闭环里,步骤 5「读取失败」是最容易被低估的一步。本章展开它:调试不是靠灵感,而是靠信息密度和分层排除;排障不是凭记忆下结论,而是用最小可信信号验证。最后用三个真实运维案例把方法论落地。
18.1 结论:调试的本质是信息密度
调试 Claude Code 时最常见的失误不是「问错了问题」,而是「喂错了信息密度」。挤牙膏式提问——「报错了」「还不行」「又有新错误」——每轮只给一句模糊描述,让 Claude 在缺乏事实的前提下推理,得到的必然是幻觉或无关猜测。
正确的姿势是一次性喂足四样东西:
- 完整的错误日志(stderr,不是转述)
- 相关文件的实际内容(
@path/to/file,不是凭记忆描述) - schema 或类型定义(数据库结构、API 契约、配置 schema)
- 最近变更(
git diff或git log -5)
排障则用分层模型——从外到内逐层排除,每层只回答一个问题:这一层是不是故障点?是 → 修;不是 → 进入下一层。不要跳层,不要在没排除外层时就深挖内层。
本章先给通用症状速查与解决路径,再讲三条调试纪律,最后用三个真实运维案例把方法论落地。
18.2 常见症状速查表
| 症状 | 典型原因 | 解决路径 |
|---|---|---|
上下文窗口溢出(Context low) | 长会话累积、大文件多次读取 | /context 查看 → /compact 压缩 → 必要时 /clear 重开 |
| 权限频繁打断,每条命令都问 | settings.json 未配 allow 白名单 | /permissions 或 /fewer-permission-prompts 扫描历史生成白名单 |
| 输出截断,代码写到一半停 | 单次任务过大、输出 token 上限 | 拆成子任务,或用 Subagent 并行(第 19 章) |
| 命令执行失败但 Claude 说「成功」 | Claude 只看了 stdout,没看 stderr | 任务里强制「读 stderr,非零退出码视为失败」 |
| Claude 幻觉:引用不存在的文件/函数 | 凭记忆而非读文件 | 用 @文件 强制读取,或 Read 工具交叉验证 |
| 修改后报错消失但行为更怪 | 为消除报错注释代码 / 加 @ts-ignore | 根因溯源,禁止绕过标记(见 18.4) |
| 测试工具自身被劫持,结果失真 | 测试流量走了被测对象(见 18.7) | 换最小可信信号(ICMP、白名单端口)重新验证 |
| 「按理说应该可以」式推理 | Claude 在假设上叠加假设 | 强制每步读文件/跑命令验证,不接受纯推理结论 |
18.3 通用解决路径
18.3.1 上下文管理三件套
/context # 查看当前上下文占用,定位是哪类内容占了大头
/compact # 有损压缩,保留摘要丢弃细节;适合中期清理
/clear # 完全清空,适合切换任务或上下文被污染预期输出:/context 显示分项占用(系统提示、工具、文件、历史);/compact 显示压缩后 token 数;/clear 清空后只剩系统提示。
验证:压缩后重新提问,如果 Claude 仍记得关键约束 → 压缩有效;如果丢失了关键上下文 → 用 @CLAUDE.md 或 @关键文件 重新投喂,不要靠对话记忆。
失败边界:/compact 是有损的,关键约束(密码学边界、兼容性版本号)可能被摘要丢弃。涉及安全关键约束时,直接 /clear 重开并把约束写进任务 prompt,不要赌压缩保留。
18.3.2 权限白名单
权限频繁打断不是「严谨」,是配置缺失。把只读/验证命令写进 settings.json 的 allow 数组:
{
"permissions": {
"allow": [
"Bash(npm test:*)",
"Bash(npm run lint:*)",
"Bash(git diff:*)",
"Bash(git log:*)",
"Bash(tsc --noEmit)",
"Bash(ss -tlnp)",
"Bash(iptables -t nat -L:*)"
]
}
}或用 bundled skill /fewer-permission-prompts 扫描历史自动生成(第 05 章)。
验证:连续跑 10 条命令,0 次权限打断 → 白名单生效。
失败边界:写操作(rm、git push、git reset --hard、systemctl stop)永远不进 allow,这是 CLAUDE.md 红线之一。
18.3.3 拆步骤或子代理
单次任务过大导致输出截断时,有两个方向:
- 拆步骤:把「修复这个 bug」拆成「定位 bug → 读相关文件 → 提出假设 → 验证假设 → 修复 → 跑测试」六步,每步只产出一个结论,上一步结论作为下一步输入
- 子代理:把「搜遍 50 个文件找根因」委托给 Explore 子代理,主上下文只收结论而非文件 dump(第 19 章)。调试场景特别适合用子代理做广度搜索——主上下文保留推理链,子代理负责把可能的根因列出来
判断拆步骤还是子代理的规则:任务是深度链式(一步步推理)→ 拆步骤;任务是广度搜索(多个文件/多个假设)→ 子代理。
18.3.4 读 stderr,不读转述
Claude 默认可能只看 stdout。任务里强制一句:
验证:运行命令后,读取 stderr,非零退出码视为失败,不要只看 stdout。运维场景尤其重要——systemctl status 显示 active (running) 不代表服务健康,logread / journalctl -u <service> -n 100 才是真实状态(见 18.6 案例)。
18.3.5 读文件交叉验证,不凭记忆
Claude 说「parseCSV 函数在 src/utils/parse.ts 第 42 行」——不要信,用 @src/utils/parse.ts 让它重新读。LLM 对文件名和行号有系统性幻觉,尤其是见过但没刚读过的文件。
运维场景同理:Claude 说「haproxy.lua 里有 tune.max-checks-per-thread」——用 grep 验证:
grep -n 'tune.max-checks-per-thread' /usr/share/passwall2/haproxy.lua有输出 → 事实;无输出 → 幻觉。任何关键结论都要求 Claude 先 Read 或 grep 验证再下判断。
18.4 调试纪律三条
纪律一:信息密度为王
喂 Claude 的信息密度,直接决定排查质量。对比:
# 差(信息密度低)
> 报错了,帮我看看
# 好(信息密度高)
> 运行 `npm run build` 报错,完整 stderr 如下:
> <粘贴 50 行错误日志>
> 相关文件:@src/auth/token.ts @src/auth/refresh.ts
> schema:@prisma/schema.prisma 的 Session 模型
> 最近变更:`git diff HEAD~3` 输出如下:<粘贴 diff>
> 假设:可能是 token 刷新时区不一致,但不要局限于此。后者的信息密度是前者的 50 倍,Claude 能直接定位而不是瞎猜。挤牙膏式提问的本质是把推理成本转嫁给自己——每轮少喂一点,Claude 就多猜一点,你就要多审一点。
纪律二:根因溯源,禁止绕过
CLAUDE.md 工程纪律明确:禁止为消除报错而简单注释代码或添加绕过标记(@ts-ignore、eslint-disable、// @ts-expect-error、systemctl mask)。必须解决根本原因。
绕过的后果:报错消失,但根因仍在;两周后换个地方再爆出来,而且因为绕过标记的存在,更难定位。正确做法:
- 类型报错 → 修类型定义,不要
@ts-ignore - Lint 报错 → 修代码,不要
eslint-disable - 测试失败 → 修实现或修测试预期(确认是测试错的前提下),不要
skip - 服务崩溃 → 查根因并修复,不要
systemctl mask让它别崩
HAProxy 案例里,如果不去查 tune.max-checks-per-thread 根因,而是直接 systemctl mask haproxy 让它别崩——服务「好了」,但负载均衡彻底没了。绕过 = 把可见故障变成隐藏故障。
纪律三:TDD 复现
修 bug 前,先写一个能复现 bug 的测试。这样:
- 测试红了 → 确认复现成功
- 修复后测试绿了 → 确认修复有效
- 未来回归 → 测试会再次报警
没有复现测试的修复,等于「我觉得修好了」,无法验证。运维场景没有单元测试,但同样可以写复现命令——一条能稳定触发故障的命令,修复后再跑一次确认不再触发。
18.5 实战案例一:OrbStack 3x-ui 外网不可达(五层排查模型)
背景
Mac mini 上用 OrbStack 跑 Debian 虚拟机,虚拟机里部署 3x-ui 代理面板。外网访问 http://<公网IP>:2096/ 不通。
排查模型:网络拓扑五层
外网 → 路由器/公网IP → Mac mini (macOS) → OrbStack NAT网关 → Debian VM → 3x-ui
① ② ③ ④ ⑤每层只回答一个问题:这一层是不是故障点?不要跳层。
命令与预期输出
层 ⑤ 先查服务本身(从内向外,最快定位):
orb run debian-vm -- ss -tlnp | grep x-ui预期输出:
LISTEN 0 128 0.0.0.0:2096 0.0.0.0:* users:(("x-ui",pid=1234,fd=3))验证:0.0.0.0:2096 ✅ 监听所有接口;127.0.0.1:2096 ❌ 只监听本地,外部访问不到——这是最常见的服务层根因,改 x-ui 面板监听地址为 0.0.0.0。
层 ④ VM 防火墙:
orb run debian-vm -- sudo ufw status
orb run debian-vm -- sudo iptables -L -n | grep 2096预期:无拦截规则,或规则放行 2096。
层 ③ OrbStack NAT(最常被忽略的一层):
# 从 Mac 宿主机访问虚拟机端口
curl -v http://$(orb ip debian-vm):2096/预期:Connection refused 或超时 → OrbStack NAT 没做端口转发。
关键认知:OrbStack 虚拟机默认是 NAT 模式,虚拟机可以出站,但外部流量无法主动进入。90% 的「外网不可达」卡在这一层。
修复:
# 编辑 ~/.orbstack/config/debian-vm.yaml
# portForwards:
# - hostPort: 2096
# guestPort: 2096
# proto: tcp
orb restart debian-vm层 ② macOS 防火墙:
/usr/libexec/ApplicationFirewall/socketfilterfw --getglobalstate预期:Firewall is disabled 或 enabled;若 enabled,放行 OrbStack:
sudo /usr/libexec/ApplicationFirewall/socketfilterfw --add $(which orbstack)层 ① 公网可达性:
curl ifconfig.me预期:返回 Mac mini 的公网 IP。如果返回的是内网 IP(10.x / 192.168.x),说明 Mac mini 在路由器 NAT 后面,需要在路由器做端口转发,或用 FRP / Cloudflare Tunnel 内网穿透。
一键定位脚本
# 层⑤
orb run debian-vm -- ss -tlnp | grep x-ui
# 层④
orb run debian-vm -- iptables -L -n
# 层③
curl -v http://$(orb ip debian-vm):2096/
# 层②
/usr/libexec/ApplicationFirewall/socketfilterfw --getglobalstate
# 层①
curl ifconfig.me验证
从外网(手机 4G 或海外 VPS)curl 公网 IP:2096,返回 3x-ui 登录页 → 全链路通。
失败边界
- 不要跳层:在层 ① 没确认公网 IP 时就去改 3x-ui 配置,是浪费时间
- 不要在 Mac 本机测公网可达性:本机访问公网 IP 会走 loopback,不代表外网真能访问;必须用手机 4G 或海外 VPS
- OrbStack NAT 是最常被忽略的一层:只查服务层和 macOS 防火墙会漏掉这一层,90% 的 Mac + OrbStack 部署卡在这里
18.6 实战案例二:iStoreOS Passwall2 HAProxy 崩溃(版本不兼容)
背景
R2S 路由器(iStoreOS)上 Passwall2 v26.6.3 配 HAProxy 2.4.26 做负载均衡,8 节点 roundrobin。局域网设备无法访问 http://192.168.100.1:1188/(HA 控制台),1181 代理端口也无监听。
排查过程
从外向内逐层:
# 1. 从 Mac 测 → No route to host
curl -v http://192.168.100.1:1188/
# 2. ping 通,80/443 正常 → 网络层没问题,是服务层
ping 192.168.100.1
# 3. SSH 上路由器,查监听
ssh root@192.168.100.1 'netstat -tlnp | grep 1188'
# 输出:空 → 服务没监听关键转折:读日志,不凭猜测:
ssh root@192.168.100.1 'logread | grep haproxy'预期输出:
haproxy::instance1 in a crash loop, 6 crashes崩溃循环。注意:/etc/init.d/passwall2 status 此时可能显示「running」,但实际进程在崩溃重启——服务状态 ≠ 服务健康,必须看 logread。
决定性一步:手动验证配置:
ssh root@192.168.100.1 'haproxy -c -f /tmp/haproxy.cfg'预期输出:
[ALERT] 119/142531 (1234) : parsing [/tmp/haproxy.cfg:42]:
unknown keyword 'tune.max-checks-per-thread'这一步比看崩溃日志更精确——直接暴露了 unknown keyword 和具体行号。
根因
Passwall2 v26.6.3 的 haproxy.lua 模板包含 tune.max-checks-per-thread 指令,这是 HAProxy 2.6+ 特性;当前安装的是 2.4.26,不支持。配置解析失败 → 进程崩溃循环。
修复
# 删除不兼容的指令
ssh root@192.168.100.1 "sed -i '/tune.max-checks-per-thread/d' /usr/share/passwall2/haproxy.lua"
# 重启
ssh root@192.168.100.1 '/etc/init.d/passwall2 stop && sleep 2 && /etc/init.d/passwall2 start'验证
ssh root@192.168.100.1 'netstat -tlnp | grep 1188'
# 预期:LISTEN 0 128 0.0.0.0:1188 ...
curl -v http://192.168.100.1:1188/
# 预期:返回 HAProxy 控制台失败边界
- 遗留风险:Passwall2 更新后
haproxy.lua会被覆盖,需重新执行 sed;长期方案是升级 HAProxy 到 2.6+,或用uci配置锁定版本 - 不要只看服务状态:
/etc/init.d/passwall2 status可能显示「running」但实际进程在崩溃循环,必须看logread - 手动验证配置是决定性一步:
haproxy -c -f直接暴露 unknown keyword,比看崩溃日志更精确;排查任何「服务起不来」时,优先找「验证配置」的命令 - sed 删除是临时修复,不是根因修复:根因修复是升级 HAProxy;sed 后必须在文档里记一笔,避免更新后复发
18.7 实战案例三:Passwall2 节点不可达(测试工具被劫持)
背景
iStoreOS 旁路由(192.168.8.100,网关 192.168.8.1)跑 passwall2 + sing-box + HAProxy 负载均衡 8 节点。故障:passwall2 进程正常但无法访问谷歌。
第一个假设(错误)
「路由器 passwall2 配置有问题。」
在路由器本机测试:
ssh root@192.168.8.100 'curl -v https://www.baidu.com'
# 输出:Connection failed
ssh root@192.168.8.100 'curl -v https://www.google.com'
# 输出:Connection failed「连国内百度都不通」——看似坐实了 passwall2 配置问题。
关键转折:测试工具自身被劫持
查 nat 表:
ssh root@192.168.8.100 'iptables -t nat -L OUTPUT -n -v --line-numbers'预期输出(关键部分):
Chain OUTPUT (policy ACCEPT)
num target prot opt source destination
1 PSW2_OUTPUT all -- 0.0.0.0/0 0.0.0.0/0再看 PSW2_OUTPUT 链最后一条:
ssh root@192.168.8.100 'iptables -t nat -L PSW2_OUTPUT -n -v --line-numbers'num target prot opt source destination
...
9 REDIRECT tcp -- 0.0.0.0/0 0.0.0.0/0 multiport dports 22,80,443,853 redir ports 1041根因:passwall2 启用「代理本机流量」时,OUTPUT -> PSW2_OUTPUT 链最后一条会把本机所有常见端口(22/80/443/853)TCP REDIRECT 到代理端口 1041。在路由器本机用 curl 测任何 :443/:80 都会被劫持进死代理而失败,产生「连百度都不通」的假象。
这不是 passwall2 坏了,是测试工具被劫持了。 域名解析也被 REDIRECT 到 11400 的 DNS 劫持,所以 curl https://域名 双重失真。
用最小可信信号重新验证
换不被劫持的测试方法:
# 方法1:ICMP(ping)——不被 nat TCP 规则捕获
ssh root@192.168.8.100 'ping -c 3 <节点IP>'
# 预期:64 bytes from ..., time=50ms ✅ 节点 IP 可达
# 方法2:用 passwall2 白名单里的节点端口测
# (PSW2_OUTPUT 对节点IP:代理端口有 RETURN 直连规则)
ssh root@192.168.8.100 'nc -zv <节点IP> 20002'
# 预期:Connection refused 或 timeout → 节点端口不通
# 方法3:看 sing-box 运行日志
# (passwall2 默认 log level=error 且不输出到 logd,需临时调 level=info)
ssh root@192.168.8.100 'uci set passwall2.@global[0].loglevel=info && uci commit passwall2'
ssh root@192.168.8.100 'logread | grep sing-box'真实根因
节点 IP ping 通(ICMP,50ms)但代理端口 TCP 连不上,2 组不同域名多节点同时不通 → GFW 对节点 IP 的精准 TCP 封锁(ICMP 放行是典型特征),或节点服务端停服。
验证
用第三方视角(海外 VPS 或手机 4G)对照测节点端口:
# 海外 VPS 上测节点端口
ssh vps 'nc -zv <节点IP> 20002 && echo OK || echo FAIL'
# 预期:FAIL → 确认节点端口被封或停服海外 VPS 也不通 → 节点端确实不可达,换 IP / 换节点 / 换订阅。海外 VPS 通但国内不通 → GFW 封锁。
失败边界
- 切勿用本机
curl https://域名下结论:域名解析也被 REDIRECT 到 11400 的 DNS 劫持,curl 双重失真 - ping 通 ≠ 服务通:ICMP 放行、TCP 封锁是 GFW 的典型特征,也是节点服务端停服的特征,两者必须用第三方视角区分
- HAProxy 健康检查只测本地 sing-box 端口:显示 UP 是假象,不等于真实节点可达;健康检查要测真实节点,不能只测本地代理端口
- 次要发现:
psw2_lan国内直连 ipset 仅 11 条(geoip.db 未加载,配置指向 github 可能拉取失败),导致国内流量也走代理,放大了故障现象——修这个会让故障现象更清晰
18.8 从三个案例提炼的排障方法论
三个案例覆盖了三类典型故障,也对应四条方法论:
| 案例 | 故障类型 | 方法论 |
|---|---|---|
| OrbStack 3x-ui | 网络链路不通 | 分层排除,从外到内逐层验证 |
| HAProxy 崩溃 | 版本不兼容 | 读日志 + 手动验证配置,定位精确报错 |
| Passwall2 节点不可达 | 测试工具被劫持 | 警惕测试工具失真,用最小可信信号验证 |
方法论一:分层排除,不跳层
网络类故障用分层模型(五层是典型,层数按拓扑定)。每层只回答一个问题:这一层是不是故障点?是 → 修;不是 → 进入下一层。不要在没排除外层时就深挖内层——OrbStack 案例里,如果没确认 OrbStack NAT,改 3x-ui 配置改到天亮也没用。
实践技巧:把分层模型画成一张拓扑图,每层旁边标一条验证命令。验证一条划掉一层,直到定位。这比「东一榔头西一棒子」快 10 倍。
方法论二:读日志,不凭猜测;验证配置,不只看状态
HAProxy 案例里,/etc/init.d/passwall2 status 显示 running,但 logread 暴露了 crash loop。服务状态 ≠ 服务健康,必须看实际日志。手动验证配置(haproxy -c -f)比看崩溃日志更精确——直接暴露了 unknown keyword 和行号。
任何「服务起不来」的故障,优先找「验证配置」的命令:haproxy -c -f、nginx -t、sshd -t、named-checkconf、postfix check。这一步通常直接给出根因。
方法论三:警惕测试工具自身被劫持
这是最反直觉的一条。Passwall2 案例里,curl https://www.baidu.com 失败不是百度不通,是测试流量被 nat 表 REDIRECT 劫持进了死代理。测试工具本身可能被被测对象污染。
识别方法:当测试结果与现象矛盾时(比如「ping 通但 curl 全失败」「服务正常但用户访问不了」),怀疑测试工具。换最小可信信号重新验证:
- ICMP 不被 TCP nat 规则捕获 → 可信
- 白名单端口有 RETURN 直连规则 → 可信
- 第三方视角(海外 VPS、手机 4G)→ 可信
- 服务自身日志(sing-box log level=info)→ 可信
方法论四:用 Claude Code 做运维排障的工作流
把上述方法论落到 Claude Code 任务 prompt:
目标:
<故障现象 + 期望状态>
范围:
<允许读取的日志路径、配置文件、远程命令>
约束:
- 每一步只回答一个问题:这一层是不是故障点
- 读 stderr 和日志,不凭描述下结论
- 测试结果与现象矛盾时,怀疑测试工具
- 用最小可信信号(ICMP、白名单端口、第三方视角)验证
- 关键结论必须先 Read 或 grep 验证,不接受凭记忆
验证:
<每层验证命令 + 预期输出>
完成输出:
<根因 + 修复命令 + 验证结果 + 失败边界>让 Claude 逐层执行,每层产出「层号 + 命令 + 输出 + 结论」四元组,不要一次性跑完所有层。这样你能审查每一层的推理,而不是看一坨结论。
运维排障尤其适合用 Subagent 做广度搜索:主上下文保留分层推理链,Explore 子代理负责「搜遍所有日志找 ERROR」「列所有防火墙规则」这类广度任务,只回传结论。
18.9 失败边界
调试最容易翻车的场景,必须显式防范:
边界一:在错误假设上深挖
OrbStack 案例里,如果一开始假设「3x-ui 配置错了」,会深入改配置文件、改监听地址、改证书——全部无效,因为根因在 OrbStack NAT。
防范:排查前先列分层模型,从最外层逐层排除,不跳层。每层验证后再进入下一层,不要在未验证的假设上深挖。假设必须验证,不验证的假设是幻觉的起点。
边界二:测试工具本身失真
Passwall2 案例里,curl 测试被 nat 劫持,结果完全误导。这不是个例——任何启用代理/VPN/防火墙的环境都可能劫持测试流量。
防范:
- 测试结果与现象矛盾时,第一时间怀疑测试工具
- 用 ICMP、白名单端口、第三方视角(VPS、4G)交叉验证
- 不要在路由器本机测公网可达性,本机流量会被本地规则捕获
- 不要用
curl https://域名测网络,域名解析可能被劫持;用curl --resolve或直接nc -zv IP 端口
边界三:Claude 凭记忆而非读文件下结论
LLM 对文件名、行号、配置项有系统性幻觉。Claude 说「haproxy.lua 里第 42 行有 tune.max-checks-per-thread」——不要信,用 grep 验证:
grep -n 'tune.max-checks-per-thread' /usr/share/passwall2/haproxy.lua有输出 → 事实;无输出 → 幻觉。
防范:
- 关键结论要求 Claude 先
Read或grep验证,再下判断 - 文件路径、行号、函数名一律交叉验证
- Claude 给出的「修复命令」要在执行前先
cat或--dry-run确认 - 不接受「按理说应该可以」式纯推理结论,必须有命令输出支撑
边界四:为消除报错而绕过
这是 CLAUDE.md 红线之一。HAProxy 案例里,如果不去查根因,而是直接 chmod -x haproxy 或 systemctl mask haproxy 让它别崩——服务「好了」,但负载均衡彻底没了。绕过 = 把可见故障变成隐藏故障。
防范:禁止注释代码、加 @ts-ignore、eslint-disable、systemctl mask、systemctl disable 来消除报错。必须定位根因并修复。临时绕过(如 sed 删除不兼容指令)必须记入文档,标注遗留风险与长期方案。
18.10 核心心法
调试与排障可以浓缩成四句:
信息密度为王,分层排除不跳层,警惕测试工具失真,用最小可信信号验证。
喂足错误日志 + 相关文件 + schema + 最近变更,让 Claude 在事实而非猜测上推理;分层模型逐层排除,每层只回答一个问题;当测试结果与现象矛盾时,怀疑测试工具本身被劫持;换 ICMP、白名单端口、第三方视角重新验证。这四条比任何「调试技巧清单」都管用。
落到 Claude Code 工程纪律:挤牙膏式提问是反模式,一次喂足四样东西是正模式;/compact 是有损压缩,关键约束写进任务 prompt;禁止绕过标记,根因溯源是底线;Claude 凭记忆下的结论一律 grep 或 Read 交叉验证。
上一章:Hooks 与自动化 下一章:Subagents 与并行工作流