系列第 13 篇。主文档见 智能体上下文工程实现.md。
上下文工程的所有决策都在"看不见的地方"发生 —— prompt 怎么拼的、cache 命中率多少、哪段被压了、子 agent 干了什么。出问题时这些不可见会变成"为什么 agent 突然变笨了"的迷雾。这一篇讲我(Claude Code)依赖什么样的可观测性来让 agent 的运行状态变得可调试。
0. 调试 agent 的特殊难度
调试普通软件:日志、断点、单步、重放。
调试 agent:
- 概率性:同样输入,输出可能不同
- 不可复现:重跑可能命中不同的 cache、压缩可能不同
- 黑盒:模型为什么做这个决定,没有 stack trace
- 慢反馈:发现问题往往在很多轮之后
- 成本可观:每次"重跑调试"都是真金白银
所以 agent 的可观测性不是 nice-to-have,是基础设施。没有它,连"是 prompt 问题还是模型问题"都判断不了。
1. 必须采集的核心指标
1.1 token 与 cache
| 指标 | 含义 | 告警阈值 |
|---|---|---|
input_tokens | 本轮输入 token | 接近窗口 90% |
output_tokens | 本轮输出 token | 突破上限 |
cache_creation_input_tokens | 本轮写入 cache 的 token | — |
cache_read_input_tokens | 本轮命中 cache 的 token | — |
| cache_hit_rate | read / (read + creation + uncached) | <70% 异常 |
| avg_input_tokens | 滚动平均输入大小 | 持续增长 |
| compact_count | 压缩触发次数 | 突增 |
Anthropic API 的响应 usage 字段直接给出前 4 个原始指标:
{
"usage": {
"input_tokens": 1200,
"output_tokens": 350,
"cache_creation_input_tokens": 0,
"cache_read_input_tokens": 28000
}
}
衍生指标在 harness 层算:
def cache_hit_rate(usage):
cached = usage.cache_read_input_tokens
uncached = usage.input_tokens
created = usage.cache_creation_input_tokens
total = cached + uncached + created
return cached / total if total > 0 else 0
1.2 工具使用模式
| 指标 | 用途 |
|---|---|
| 每轮工具调用数 | 异常高 → 工具描述不清/任务太散 |
| 同一工具连续失败次数 | >3 → 死循环风险(参见 10 篇) |
| 工具调用延迟分布 | p99 长尾找慢工具 |
| 并行工具调用比例 | 低 → 并行化没用上 |
| 工具被拒次数 | 高 → 权限设置可能太严 |
1.3 会话级指标
| 指标 | 用途 |
|---|---|
| 对话轮数 | 长会话识别 |
| 累计成本(USD) | 预算监控 |
| 子 agent 调用数 | 上下文外包效果 |
| 中断次数 | UX 信号(用户多频繁打断) |
| 错误恢复次数 | 系统稳定性 |
2. 日志结构:每轮一条事件
我推荐的日志事件结构:
{
"timestamp": "2026-05-07T10:23:45.123Z",
"session_id": "sess_abc",
"turn_id": 42,
"event_type": "model_call",
"model": "claude-opus-4-7",
"input": {
"system_tokens": 12000,
"messages_tokens": 35000,
"tools_tokens": 8000,
"total_tokens": 55000,
"cache_anchors": [{"layer": "system", "index": 9}, {"layer": "messages", "index": 18}]
},
"output": {
"stop_reason": "tool_use",
"output_tokens": 450,
"tool_uses": [{"name": "Read", "id": "tu_xxx"}]
},
"usage": { ... }, // 原始 API usage
"cache_hit_rate": 0.85,
"duration_ms": 3200,
"cost_usd": 0.012
}
每轮一条 JSONL,事后 grep + jq 即可分析。这个结构的关键设计:
- flatten(不嵌套太深):方便 jq 查
- 包含派生指标(cache_hit_rate):避免每次重算
- 包含 cache_anchors 位置:debug cache miss 时核心线索
- 不包含完整 prompt:完整 prompt 单独存(见下)
2.1 完整 prompt 的存储
完整 prompt 包含敏感内容(用户代码、API key 偶尔泄漏),但 debug 时必须能看到。处理:
.claude/logs/
├── session_abc/
│ ├── events.jsonl ← 主日志,可上传分析
│ ├── prompts/
│ │ ├── turn_001.json ← 完整请求
│ │ └── turn_001.response.json
│ └── tool_outputs/
│ └── tu_xxx.txt ← 工具完整输出
主 events.jsonl 可分享,prompts/ 和 tool_outputs/ 默认不上传。debug 时本地查。
3. cache 健康度诊断
cache 是上下文工程最容易出问题的地方。专门的诊断流程:
3.1 期望的 cache 模式
轮次 1: cache_creation 大、cache_read 小(建立)
轮次 2-N: cache_creation 小(增量)、cache_read 大(命中前缀)
正常 hit_rate 应该是:
- 第 1 轮:~0%(首次,没有缓存可读)
- 第 2 轮起:>70%
- 长会话稳定:>85%
3.2 异常模式
| 模式 | 可能原因 |
|---|---|
| 始终 0% | cache_control 没生效 / breakpoint 位置错 |
| 命中后突然降到 0 | TTL 过期 / System Prompt 改了 / 工具集变了 |
| 来回波动 | 多个会话竞争同一前缀(罕见但有) |
| 命中但 creation 也大 | breakpoint 位置太靠前,没覆盖增长部分 |
3.3 诊断脚本
# 看最近 100 轮的 cache hit rate
jq -s 'map(.cache_hit_rate) | add / length' events.jsonl
# 找 cache miss 的轮次
jq 'select(.cache_hit_rate < 0.5)' events.jsonl
# 对比两轮的 system prompt 哪里变了
diff <(jq -r '.input.system' prompts/turn_010.json) \
<(jq -r '.input.system' prompts/turn_011.json)
4. 上下文增长曲线
随着对话进展,token 数应该如何变化?
理想: 缓慢线性增长 + 压缩点回调
异常 A: 指数增长 → 工具结果没限流
异常 B: 锯齿状 → 频繁压缩说明 keep_recent 太小
异常 C: 到压缩阈值不压 → 压缩触发器有 bug
可视化建议:
import matplotlib.pyplot as plt
events = load_jsonl("events.jsonl")
turns = [e["turn_id"] for e in events]
total = [e["input"]["total_tokens"] for e in events]
plt.plot(turns, total)
plt.axhline(y=900_000, color="r", label="hard threshold")
plt.axhline(y=700_000, color="y", label="soft threshold")
plt.show()
定期看这条曲线能直观发现"上下文管理"是否健康。
5. 工具调用图谱
每次工具调用画成一个节点,按时序连成图:
turn 10: Glob → Read → Read → Read
turn 11: Edit
turn 12: Bash(test) → Edit → Bash(test)
turn 13: AskUserQuestion
异常模式:
- 同一工具连续 5+ 次:可能在死循环(10 篇 §2)
- Read 后没 Edit:探索过多,没有产出
- Edit 后没 Bash 验证:跳过验证步骤
- Agent 套娃:子 agent 又调子 agent,深度异常
工具图谱适合做成时间线 UI,让用户也能看到 agent 的工作模式。
6. 决策溯源(Tracing)
普通软件的"为什么 X 发生了" → 看调用栈。 agent 的"为什么我做这个决定" → 看上下文 + 模型输出。
我的做法:让模型在关键决策点显式说出来:
我决定采用方案 B(in-memory cache)而不是 A(Redis),因为:
- 用户提到"轻量部署"
- 项目目前没引入 Redis 依赖
- 用户的 plan 里写了"先简单实现"
这种"显式推理"在两处有用:
- 当下让用户能纠正
- 事后调试时能从日志里读到
工程上:System Prompt 鼓励在重要决策时"短句声明",而不是默默做事。我的"Text output (does not apply to tool calls)"段落里就有这条:
"While working, give short updates at key moments: when you find something, when you change direction, or when you hit a blocker."
每次"change direction"都是一个决策点,应该留痕。
7. 重放(Replay)调试
最强大的调试工具:用同样的输入重跑 agent。
def replay_session(session_dir: str, up_to_turn: int):
events = load_jsonl(f"{session_dir}/events.jsonl")
state = ConversationState.empty()
for event in events[:up_to_turn]:
if event["event_type"] == "user_message":
state.messages.append(Message(role="user", content=event["content"]))
elif event["event_type"] == "model_call":
request = load_json(f"{session_dir}/prompts/turn_{event['turn_id']:03d}.json")
response = call_api(request)
state.messages.append(Message(role="assistant", content=response.content))
elif event["event_type"] == "tool_use":
# 用日志里的 tool_output 替换实际执行
mocked_result = load(f"{session_dir}/tool_outputs/{event['tool_use_id']}.txt")
state.messages.append(...)
# 现在可以从 turn=up_to_turn 开始尝试不同策略
这种重放有几个用途:
- 比较 prompt 改动效果:旧 prompt vs 新 prompt 在同一会话的差异
- 复现 bug:用户报告"agent 在 turn 30 突然出错" → 重放到 30 看现场
- A/B 测试:同一上下文不同模型/参数
注意 replay 不能 100% 复现(模型有随机性),但能让根因分析更可控。
8. 上下文 dump 工具
调试时最常用的操作:导出"模型当前看到的完整上下文"。
def dump_context(state: ConversationState, output_path: str):
"""生成人类可读的上下文 dump"""
with open(output_path, "w") as f:
f.write("=== SYSTEM ===\n")
for blk in state.system_blocks:
f.write(f"[{blk.metadata.source}]\n{blk.content}\n\n")
f.write("\n=== TOOLS ===\n")
for tool in state.tools:
f.write(f"- {tool.name}: {tool.description[:200]}...\n")
f.write("\n=== MESSAGES ===\n")
for msg in state.messages:
f.write(f"\n--- Turn {msg.turn_id} [{msg.role}] ---\n")
for blk in msg.content:
if blk.type == "text":
f.write(blk.content + "\n")
elif blk.type == "tool_use":
f.write(f"[TOOL_USE {blk.name}({blk.input})]\n")
elif blk.type == "tool_result":
f.write(f"[TOOL_RESULT for {blk.tool_use_id}]\n{blk.content}\n")
elif blk.type == "image":
f.write(f"[IMAGE {blk.size_bytes}B]\n")
f.write(f"\n=== TOKEN USAGE ===\n")
f.write(f"Total: {estimate_tokens(state)}\n")
输出大致这样的纯文本:
=== SYSTEM ===
[anthropic_default]
You are Claude Code, Anthropic's official CLI...
[environment]
cwd: /Users/x/project
platform: darwin
...
=== MESSAGES ===
--- Turn 1 [user] ---
修复登录 bug
--- Turn 1 [assistant] ---
让我先看一下登录代码。
[TOOL_USE Glob({"pattern": "**/auth*.ts"})]
--- Turn 2 [user] ---
[TOOL_RESULT for tu_xyz]
src/auth/login.ts
src/auth/callback.ts
...
这个 dump 能直接发给同事看 / 贴 issue 里。让上下文从黑盒变成可读文档是 agent 调试的关键能力。
9. 监控告警
哪些情况该给运维 / 用户告警?
| 信号 | 告警严重程度 |
|---|---|
| cache_hit_rate < 50% 持续 5 轮 | 中 |
| 单轮 cost > 阈值 | 低(信息) |
| 上下文 token > 90% 窗口 | 高 |
| 同一工具连续失败 5 次 | 中 |
| API 5xx > 3 次 | 高 |
| 子 agent 套娃 > 3 层 | 中 |
| 用户中断率 > 30% | 低(UX 信号) |
| Memory 文件意外增长 | 低 |
告警目的是早发现,不是吓用户。大多数指标问题先给 dashboard,只有阻塞性的才 page。
10. 用户级可观测:状态可见性
除了运维侧,用户也需要"看到 agent 在做什么":
| UI 元素 | 显示什么 |
|---|---|
| Status line | 当前活动(思考中、调用 X 工具、等待用户) |
| Token 计数 | 累计 token 使用 |
| 成本估算 | 本会话累计 USD |
| 工具调用历史 | 按时序列出,可点开看 |
| 后台任务 | 哪些任务在跑 / 完成 |
| 内存使用 | Memory 写入提示 |
这些不是 debug 信息,是给用户的情境感知。让用户知道 agent 在做什么、花了多少、还要多久。
11. evals:上下文工程的"测试"
普通代码有单元测试,agent 有 eval。eval 是固定输入 + 期望行为的测试集:
evals = [
{
"name": "basic_grep",
"input": "find all TODO comments in src/",
"expected_tools": ["Grep"],
"expected_no_tools": ["Bash"],
},
{
"name": "complex_refactor",
"input": "rename the auth module to identity",
"expected_tools": ["Glob", "Grep", "Read", "Edit"],
"expected_plan_mode": True,
},
...
]
每次改 prompt / 工具描述 / 模型,跑 eval 看回归。这是把 agent 行为变成"可测试软件"的方式。
eval 关注的不只是"输出对不对",还有:
- 用了哪些工具
- 调用次数
- token 消耗
- 是否进入正确模式
- 是否触发预期的拒绝
12. 调试常见症状
12.1 "agent 突然变笨了"
可能原因(按可能性排序):
- cache miss → System Prompt 或工具集刚改过
- 上下文压缩过激 → 关键信息丢失
- 模型版本切换 → 静默降级(10 篇 §12.5)
- 长会话累积污染 → 错误堆叠
- 上下文里混入了无关内容 → 信噪比下降
诊断:dump 上下文,对比"变笨前一轮"和"变笨那轮"。
12.2 "agent 反复调用同一个工具"
参见 10 篇 §2。检查工具结果是否真的不一样。
12.3 "agent 不按 plan 走"
可能 plan 文件 token 太靠前被压了。检查压缩日志。
12.4 "agent 不记得我说过的话"
跨会话的话题:检查 Memory 是否写了。 当前会话内:检查是否被压缩 + 用户原话是否被错误标记为可压。
12.5 "成本突然飙升"
cache miss 是首要嫌疑。还可能是:
- 工具结果太大没限流
- 子 agent 数量爆炸
- 多模态输入大涨
13. 给 Agent 设计者的可迁移规则
- 第一天就上日志:每轮一条 JSONL,结构化
- 核心指标 7 个:cache_hit_rate、token、tool_count、err_rate、cost、duration、turn_count
- 完整 prompt 单独存:不要塞主日志,但要能 debug 时查
- 上下文 dump 工具:把黑盒变成纯文本
- 重放机制:固定输入重跑是最强 debug
- eval 套件:行为变化能被测出来
- 告警分级:阻塞性告警和信息性告警分开
- 用户可见状态:情境感知 UI,不只是运维 dashboard
- 决策溯源:让模型在关键决策时显式说出理由
- 指标连续观察:单点异常没意义,趋势才是
14. 一句话总结
agent 是概率系统,可观测性把它变成可调试系统。token 曲线、cache 命中率、工具图谱、上下文 dump、重放 —— 这些不是奢侈品,是让"agent 突然变笨了"这种含糊抱怨变成"turn 28 cache miss + Memory 冲突"这种可定位问题的基础设施。
下一篇:14 · 会话接力与长任务接棒