第 38 章:调试的艺术

源码验证日期:2026-05-15,基于 commit
0d81bb6
上一章,你学会了写测试。测试告诉你”出错了”——但它通常不会告诉你”为什么出错”。从”测试失败了”到”找到 bug 的根因”,中间有一段路要走。这段路,就是调试。
调试是一门手艺。好的调试不是靠运气瞎猜,而是系统性地缩小问题范围。这一章,我们学习 Claude Code 内置的调试工具,以及如何运用它们追踪问题。
路线图
1 | graph LR |
认识内置的 debug 日志系统
Claude Code 有一个精心设计的调试日志系统,核心在 src/utils/debug.ts。它不是一个简单的 console.log 包装器——它有日志级别、过滤机制、缓冲写入、文件轮转。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22// 文件:src/utils/debug.ts
export function logForDebugging(
message: string,
{ level }: { level: DebugLogLevel } = { level: 'debug' },
): void {
// 检查日志级别
if (LEVEL_ORDER[level] < LEVEL_ORDER[getMinDebugLogLevel()]) {
return
}
// 检查是否应该记录这条消息
if (!shouldLogDebugMessage(message)) {
return
}
// 格式化并写入
const timestamp = new Date().toISOString()
const output = `${timestamp} [${level.toUpperCase()}] ${message.trim()}\n`
if (isDebugToStderr()) {
writeToStderr(output)
return
}
getDebugWriter().write(output)
}
这个函数做了三件事:
- 级别过滤——低于最低级别的消息直接丢弃。日志级别从低到高是:
verbose<debug<info<warn<error - 消息过滤——根据 debug 过滤器决定是否记录
- 输出路由——要么写到 stderr(实时),要么写到缓冲写入器(后台异步写入文件)
日志写到哪里?默认位置是:1
~/.claude/debug/<session-id>.txt
同时维护一个 latest 符号链接,指向当前会话的日志文件。你可以随时用 tail -f ~/.claude/debug/latest 实时查看日志。
启动调试模式
有几种方式启用调试日志:
--debug 或 -d——最简单的方式。启用调试日志并写入文件:1
claude --debug
--debug=pattern——带过滤器的调试。只记录匹配特定分类的消息:1
2claude --debug=api,mcp # 只看 api 和 mcp 相关的日志
claude --debug=!1p,!file # 排除 1p 和 file 类别的日志
过滤器的实现很有意思。src/utils/debugFilter.ts 定义了分类提取逻辑:1
2
3
4
5
6
7
8
9
10
11
12
13
14// 文件:src/utils/debugFilter.ts
export function extractDebugCategories(message: string): string[] {
const categories: string[] = []
// "category: message" → 提取 category
const prefixMatch = message.match(/^([^:[]+):/)
if (prefixMatch) categories.push(prefixMatch[1].trim().toLowerCase())
// "[CATEGORY] message" → 提取 CATEGORY
const bracketMatch = message.match(/^\[([^\]]+)]/)
if (bracketMatch) categories.push(bracketMatch[1].trim().toLowerCase())
// "MCP server \"name\"" → 提取 mcp 和 name
const mcpMatch = message.match(/^MCP server ["']([^"']+)["']/)
if (mcpMatch) { categories.push('mcp'); categories.push(mcpMatch[1].toLowerCase()) }
return Array.from(new Set(categories))
}
日志消息的分类是根据消息格式自动提取的。比如 MCP server "github": connection established 会被分类为 mcp 和 github。
--debug-to-stderr 或 -d2e——把日志直接写到标准错误输出:1
claude --debug-to-stderr 2>&1 | tee debug-output.log
环境变量 DEBUG——经典的环境变量开关:1
DEBUG=1 claude
用 logForDebugging 添加自定义日志
当你在开发自己的工具或插件时,可以导入并使用这个日志系统:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15import { logForDebugging } from '../utils/debug.js'
// 在你的工具逻辑中
async call(input) {
logForDebugging(`MyTool called with input: ${JSON.stringify(input)}`)
// ... 核心逻辑 ...
logForDebugging(`MyTool result: ${result}`, { level: 'info' })
// 出错时
logForDebugging(`MyTool error: ${error.message}`, { level: 'error' })
return { data: result }
}
注意几个要点:
- 不要在非 debug 模式下依赖日志——
shouldLogDebugMessage在非 debug 模式下返回false,日志不会被写入。logForDebugging不会影响生产性能。 - 使用合适的级别——
verbose用于高频诊断信息,debug用于常规调试,info用于重要事件,warn用于异常但可恢复的情况,error用于真正的错误。 - 用
CLAUDE_CODE_DEBUG_LOG_LEVEL=verbose启用最高级别——默认级别是debug,verbose级别的消息会被过滤掉。
使用内置的 /debug 命令
Claude Code 有一个内建的 /debug 命令,用于调试当前会话的问题:1
/debug [问题描述]
它做了以下事情:
- 如果你之前没有启用 debug 日志,它会立即启用(但之前的日志已经丢失了)
- 读取当前会话的 debug 日志的最后 20 行
- 分析日志中的
[ERROR]和[WARN]条目 - 给出诊断结论和修复建议
源码在 src/skills/bundled/debug.ts:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22// 文件:src/skills/bundled/debug.ts
export function registerDebugSkill(): void {
registerBundledSkill({
name: 'debug',
description: 'Enable debug logging for this session and help diagnose issues',
allowedTools: ['Read', 'Grep', 'Glob'],
async getPromptForCommand(args) {
const wasAlreadyLogging = enableDebugLogging()
const debugLogPath = getDebugLogPath()
// 读取日志文件的最后 64KB
const stats = await stat(debugLogPath)
const readSize = Math.min(stats.size, TAIL_READ_BYTES)
// ... 读取并格式化最后 20 行 ...
return [{
type: 'text',
text: `# Debug Skill\n\n日志路径: ${debugLogPath}\n\n${logInfo}\n\n## 问题\n${args || '请描述你遇到的问题'}`
}]
},
})
}
这是一个很好的调试流程参考:先启用日志,让用户重现问题,然后分析日志。
性能分析——启动性能
当你觉得 Claude Code 启动太慢时,可以使用内置的启动性能分析器:1
CLAUDE_CODE_PROFILE_STARTUP=1 claude
分析器会在 src/utils/startupProfiler.ts 中记录一系列检查点:1
2
3
4
5
6
7// 文件:src/utils/startupProfiler.ts
const PHASE_DEFINITIONS = {
import_time: ['cli_entry', 'main_tsx_imports_loaded'],
init_time: ['init_function_start', 'init_function_end'],
settings_time: ['eagerLoadSettings_start', 'eagerLoadSettings_end'],
total_time: ['cli_entry', 'main_after_run'],
}
分析结果输出到 ~/.claude/startup-perf/<session-id>.txt,报告长这样:1
2
3
4
5
6
7
8
9STARTUP PROFILING REPORT
0.0ms +0.0ms cli_entry heap: 12.3MB
45.2ms +45.2ms cli_dump_system_prompt_path
89.7ms +44.5ms main_tsx_imports_loaded heap: 28.1MB
...
312.5ms +12.3ms main_after_run heap: 45.6MB
Total startup time: 312.5ms
每一行显示了:相对起始时间、与上一个检查点的差值、检查点名称、堆内存使用量。如果某个步骤的耗时异常高,你一眼就能看到。
性能分析——查询性能
运行时的查询性能是另一回事。Claude Code 有一个专门的查询分析器:1
CLAUDE_CODE_PROFILE_QUERY=1 claude
这个分析器追踪了完整的查询生命周期:1
2
3
4
5
6
7
8query_user_input_received # 用户输入开始
query_context_loading_start/end # 系统提示词加载
query_tool_schema_build_start/end # 工具 schema 构建
query_api_request_sent # HTTP 请求发出
query_first_chunk_received # 第一个流式数据块(TTFT)
query_api_streaming_end # 流式传输完成
query_tool_execution_start/end # 工具执行
query_end # 查询结束
报告会计算关键指标:1
2
3
4
5
6
7
8
9Total TTFT: 1234ms
- Pre-request overhead: 234ms (19.0%)
- Network latency: 1000ms (81.0%)
PHASE BREAKDOWN:
Context loading 45ms
Tool schemas 120ms
Message normalization 35ms
Network TTFB 1000ms
如果 “Tool schemas” 占了很大比例,可能是工具数量太多。如果 “Network TTFB” 占主导,瓶颈在 API 调用。
慢操作检测
除了主动的性能分析,Claude Code 还有一个被动的慢操作检测系统:1
2
3
4
5// 文件:src/utils/slowOperations.ts
export function jsonStringify(value, replacer?, space?) {
using _ = slowLogging`JSON.stringify(${value})`
return JSON.stringify(value, replacer, space)
}
using _ = slowLogging\…` 是一个精巧的 JavaScript 模式——using 是 ECMAScript 的资源管理语法,slowLogging返回一个Disposable` 对象,在代码块结束时计算耗时并决定是否记录。1
2
3
4
5
6
7
8
9
10
11
12
13
14class AntSlowLogger {
constructor(args) {
this.startTime = performance.now()
this.err = new Error() // 捕获调用栈
}
[Symbol.dispose]() {
const duration = performance.now() - this.startTime
if (duration > SLOW_OPERATION_THRESHOLD_MS) {
logForDebugging(
`[SLOW OPERATION DETECTED] ${description} (${duration.toFixed(1)}ms)`
)
}
}
}
阈值通过环境变量控制:1
2# 开发环境默认 20ms
CLAUDE_CODE_SLOW_OPERATION_THRESHOLD_MS=50 claude --debug
这个系统帮你捕捉到你可能根本不知道存在的性能问题。
VS Code 断点调试
日志和性能分析是”事后分析”。断点调试是”现场分析”——在代码执行的时候暂停,查看变量状态。
回忆第 29 章的 .vscode/launch.json 配置:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25{
"version": "0.2.0",
"configurations": [
{
"name": "Debug CLI (interactive)",
"type": "node",
"request": "launch",
"program": "${workspaceFolder}/cli.js",
"args": [],
"console": "integratedTerminal"
},
{
"name": "Debug CLI (query profiling)",
"type": "node",
"request": "launch",
"program": "${workspaceFolder}/cli.js",
"args": [],
"console": "integratedTerminal",
"env": {
"CLAUDE_CODE_PROFILE_QUERY": "1",
"CLAUDE_CODE_DEBUG_LOG_LEVEL": "verbose"
}
}
]
}
在 VS Code 里按 F5,选择对应的配置,就能启动调试。因为 cli.js.map 存在,VS Code 可能会把断点映射回 TypeScript 源码。试试在 src/tools/GlobTool/GlobTool.ts 的 call() 方法里设断点。
调试策略——系统化缩小范围
工具千千万,思路只一条:系统化地缩小问题范围。
第一步:复现。 你必须能稳定地复现问题。用 --debug-to-stderr 启动,记录完整的会话日志。
第二步:定位层级。 问题发生在哪一层?
- 输入层——AI 传过来的参数不对?检查
inputSchema.parse()的输出 - 权限层——操作被拒绝了?检查
checkPermissions()的返回值 - 执行层——
call()内部出错了?在call()里加日志 - 输出层——结果格式不对?检查
mapToolResultToToolResultBlockParam()
第三步:加日志。 在问题层的入口和出口各加一行 logForDebugging:1
2
3
4
5
6async call(input) {
logForDebugging(`[MyTool] enter call, input=${JSON.stringify(input)}`)
// ... 核心逻辑 ...
logForDebugging(`[MyTool] exit call, result=${JSON.stringify(result)}`)
return result
}
如果 enter 出现了但 exit 没有,说明中间抛了异常。如果 exit 的 result 和期望不一样,说明逻辑有 bug。
第四步:二分查找。 如果问题在一段长逻辑中,用二分法缩小范围:在中间加日志,看前半段还是后半段有问题。
第五步:固定和验证。 找到问题后,先写一个测试复现它(防止回归),然后修复,最后确认测试通过。
实战:调试那个贯穿全书的死循环 Bug
咱们把五步策略用在那个从卷零就缠着我们的 bug 上——Agent 反复读 src/auth.ts 25 次。
第一步:复现。 启动 debug 模式,输入”帮我修复 src/auth.ts 中的 login 问题”,观察输出:1
2claude --debug-to-stderr
> 帮我修复 src/auth.ts 中的 login 问题
从日志中提取每次工具调用:1
2grep "tool_use" ~/.claude/debug/latest | grep -c "Read"
# 输出: 25
确认了:25 次 Read,0 次 Edit。
第二步:定位层级。 发生在这个 bug 中涉及的层级:
- 执行层(
toolExecution.ts):Read 工具每次执行正确,返回文件内容 → 不是这里 - API 层(
claude.ts):API 正常返回内容,模型收到完整上下文 → 不是这里 - 模型决策层:模型每轮都输出
tool_use: Read而不是tool_use: Edit→ 是这里
第三步:加日志。 在 queryLoop 中给每轮的工具调用计数:1
2
3
4
5
6
7
8
9
10
11// → 在 queryLoop 中添加
const toolCallHistory = new Map<string, number>()
// 每轮记录
for (const block of response.content) {
if (block.type === "tool_use") {
const key = `${block.name}:${JSON.stringify(block.input)}`
const count = (toolCallHistory.get(key) ?? 0) + 1
toolCallHistory.set(key, count)
logForDebugging(`[Loop] tool_call #${turnCount}: ${key} (第${count}次)`)
}
}
重新运行,看到:1
2
3
4[Loop] tool_call #1: Read:{"file_path":"src/auth.ts"} (第1次)
[Loop] tool_call #2: Read:{"file_path":"src/auth.ts"} (第2次)
[Loop] tool_call #3: Read:{"file_path":"src/auth.ts"} (第3次)
...完全相同直到第25次
第四步:二分查找。 问题不在代码层面(代码正确执行了),而在模型行为层面。二分法不适用——我们需要改分析方向。
第五步:固定和验证。 Root cause 找到了:模型没有”意识到”自己在重复。解决不是修代码,而是在 system prompt 中加一条指令,并在卷五 ch63 实现代码级的循环检测。等 ch63 实现后,用这个 bug 做回归测试——确保连续 3 次相同工具调用后 Agent 会停下来问用户。
当调试不够用 — 结构化可观测性
调试是”手动调查一个问题”。但当你把 Agent 跑在服务器上、处理成百上千个任务时,手动调试不够用了。你需要可观测性——系统在运行时的自我报告能力。
结构化日志
console.log 的问题在于不可搜索、不可聚合。结构化日志用统一的格式记录,让你能按字段查询和统计:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31// → 结构化 Logger(最小实现 ~25 行)
type LogLevel = "debug" | "info" | "warn" | "error"
interface LogEntry {
timestamp: string
level: LogLevel
sessionId: string
message: string
context?: Record<string, unknown>
}
class StructuredLogger {
constructor(private sessionId: string) {}
log(level: LogLevel, message: string, context?: Record<string, unknown>) {
const entry: LogEntry = {
timestamp: new Date().toISOString(),
level,
sessionId: this.sessionId,
message,
context,
}
// 输出为 JSON Lines(每行一个 JSON,方便 grep + jq 处理)
process.stderr.write(JSON.stringify(entry) + "\n")
}
debug(msg: string, ctx?: Record<string, unknown>) { this.log("debug", msg, ctx) }
info(msg: string, ctx?: Record<string, unknown>) { this.log("info", msg, ctx) }
warn(msg: string, ctx?: Record<string, unknown>) { this.log("warn", msg, ctx) }
error(msg: string, ctx?: Record<string, unknown>) { this.log("error", msg, ctx) }
}
与 console.log 的对比:1
2
3
4
5
6
7
8❌ console.log:
"查询失败: tool_use timeout"
→ 哪个会话?哪一轮?哪个工具?不知道。
✅ 结构化日志:
{"timestamp":"2026-05-17T10:30:00Z","level":"error","sessionId":"sess-42",
"message":"tool execution timeout","context":{"tool":"Bash","turn":3,"durationMs":30001}}
→ 按 sessionId 聚合、按 tool 统计、按 turn 过滤。
在 Agent Loop 中埋点
Agent 的每一步都应该产生日志:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55// → 在 AgentLoop 中埋点
class AgentLoop {
private logger: StructuredLogger
async *run(input: string): AsyncGenerator<AgentEvent> {
this.logger.info("agent.start", { input: input.substring(0, 100) })
for (let turn = 1; turn <= this.config.maxTurns; turn++) {
const turnStart = Date.now()
this.logger.debug("turn.start", { turn })
const response = await this.client.createMessage({ /*...*/ })
this.logger.debug("turn.api_response", {
turn,
stopReason: response.stop_reason,
tokens: response.usage,
durationMs: Date.now() - turnStart,
})
if (response.stop_reason === "tool_use") {
for (const block of response.content) {
if (block.type === "tool_use") {
const toolStart = Date.now()
try {
const result = await this.toolExecutor.execute(block)
this.logger.debug("turn.tool_success", {
turn,
tool: block.name,
durationMs: Date.now() - toolStart,
})
yield { type: "tool_result", data: result }
} catch (err) {
this.logger.error("turn.tool_error", {
turn,
tool: block.name,
error: err.message,
})
}
}
}
continue
}
this.logger.info("agent.complete", {
turns: turn,
totalTokens: this.totalTokens,
totalDurationMs: Date.now() - this.sessionStart,
})
yield { type: "response", data: response }
return
}
this.logger.warn("agent.max_turns", { maxTurns: this.config.maxTurns })
}
}
这一层埋点之后,你可以回答以下问题,而无需手动检查任何代码:
- “平均一个任务消耗几轮 loop?”
- “哪个工具最容易超时?”
- “API 调用的 P50/P95/P99 延迟是多少?”
OpenTelemetry 入门
结构化日志是第一步。当你的 Agent 框架被多个服务使用,你需要分布式追踪——把一个请求在多个系统之间的完整路径串起来。
OpenTelemetry(OTel)是分布式追踪的开放标准。Claude Code 使用了 OTel(src/services/analytics/),但被抽象在内部。你不需要一个完整的 OTel 集群来开始——最小集成只需 20 行:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35// → 最简 OTel 集成(控制台输出)
import { trace, SpanStatusCode } from "@opentelemetry/api"
import { BasicTracerProvider, SimpleSpanProcessor } from "@opentelemetry/sdk-trace-base"
import { ConsoleSpanExporter } from "@opentelemetry/sdk-trace-node"
// 初始化
const provider = new BasicTracerProvider()
provider.addSpanProcessor(new SimpleSpanProcessor(new ConsoleSpanExporter()))
provider.register()
const tracer = trace.getTracer("my-agent")
// 使用:包裹 Agent run
async function* runWithTrace(agent: AgentLoop, input: string) {
return tracer.startActiveSpan("agent.run", async (span) => {
span.setAttribute("agent.model", agent.model)
span.setAttribute("agent.input", input.substring(0, 200))
try {
for await (const event of agent.run(input)) {
if (event.type === "response") {
span.setAttribute("agent.turns", agent.turnCount)
span.setAttribute("agent.tokens", agent.totalTokens)
span.setStatus({ code: SpanStatusCode.OK })
}
yield event
}
} catch (err) {
span.setStatus({ code: SpanStatusCode.ERROR, message: err.message })
throw err
} finally {
span.end()
}
})
}
Agent 专属监控面板
当日志和追踪就绪后,你需要的是一套能快速发现问题的问题指标面板。
核心指标
| 指标 | 类型 | 含义 | 告警阈值建议 |
|---|---|---|---|
| Turn Count | Histogram | 每任务的平均轮次 | > 15 轮告警(可能陷入循环) |
| Token / Turn | Gauge | 每轮消耗的 token | > 32K 告警(接近窗口上限) |
| Tool Latency (P95) | Histogram | 95% 的工具调用在多久内完成 | > 10s 告警 |
| API Latency (P95) | Histogram | 95% 的 API 调用在多久内完成 | > 5s 告警 |
| Error Rate | Gauge | 工具调用失败率 | > 5% 告警 |
| Cache Hit Rate | Gauge | Prompt Cache 的命中率 | < 60% 告警(缓存配置有问题) |
| Cost / Task | Gauge | 每任务平均成本 | > 预设上限告警 |
最简指标收集器
1 | // → 最简指标收集器(Counter + Histogram,~30 行) |
常见错误
| 常见错误 | 检查方法 |
|---|---|
| debug 日志文件太大 | 定期清理:find ~/.claude/debug/ -name "*.txt" -mtime +7 -delete |
| source map 断点不生效 | 确认 cli.js.map 和 cli.js 在同一目录,检查 VS Code 的 source map 设置 |
--debug 模式下性能下降 | 用 --debug=pattern 过滤,只看特定类别的日志 |
| 慢操作日志没出现 | 必须同时启用 debug 模式:CLAUDE_CODE_SLOW_OPERATION_THRESHOLD_MS=10 claude --debug |
| 结构化日志太多导致性能下降 | 生产环境只开 warn + error 级别;debug 只用于开发 |
| OTel Span 未导出 | 检查 SpanProcessor 和 Exporter 是否正确注册;确认 provider.register() 已调用 |
| 指标 P95 为空 | 需要至少 20 个数据点才能计算 P95 |
试试看
- 启用并查看日志。 用
claude --debug启动一个会话,执行几条命令,然后检查~/.claude/debug/latest文件。 - 过滤日志。 用
claude --debug=mcp启动,触发一个 MCP 工具调用。日志里应该只包含 MCP 相关的消息。 - 查询性能分析。 用
CLAUDE_CODE_PROFILE_QUERY=1 claude --debug启动,发送一条查询,在 debug 日志中找到QUERY PROFILING REPORT。 - 添加自定义日志。 在你创建的工具里加一行
logForDebugging,用--debug模式运行,确认你的日志出现在输出中。 - 实现结构化 Logger。 在你的 Agent 框架中实现
StructuredLogger类,在 AgentLoop 的关键点埋日志。 - 构建指标面板。 用
MetricsCollector收集 10 次 Agent 运行的指标,生成一份报告。
检查点
- 日志系统——
logForDebugging是核心入口,支持五个级别、分类过滤、缓冲写入 - 启动方式——
--debug、--debug=pattern、--debug-to-stderr、DEBUG=1四种方式 - 日志过滤——
--debug=pattern支持正向和反向过滤,分类从消息格式自动提取 - 内置命令——
/debug可以中途启用日志并自动分析问题 - 启动性能——
CLAUDE_CODE_PROFILE_STARTUP=1记录完整启动时间线 - 查询性能——
CLAUDE_CODE_PROFILE_QUERY=1记录从输入到首字返回的每个阶段 - 慢操作检测——
slowLogging包装常见操作,超过阈值自动报告 - 断点调试——VS Code +
cli.js.map实现源码级断点 - 调试策略——复现、定位层级、加日志、二分、写测试、修复
- 结构化可观测性——JSON Lines 格式日志 + sessionId/turn/tool 埋点,可搜索聚合
- OpenTelemetry——最简 20 行集成,Span 包裹 Agent.run,自动导出到控制台
- 监控面板——七个核心指标(Turn Count / Token / Latency / Error Rate / Cache Hit / Cost),P50/P95 统计
调试的核心不是工具,是思维方式:不要猜,要观察。不要一次改多处,要一次只改一处。每一步都要有验证。
可观测性扩展了这条原则:不只是一个人观察一个会话,而是让系统在所有会话中持续观察自己。