本章目录In this chapter
- 12 · The Observability System
- 12.1 Instrumentation Density
- 12.2 Event Naming
- 12.3 Query Chain Tracing
- 12.4 Privacy Protection at the Type Level
- 12.5 Cache-Break Detection
- 12.6 Profiling Checkpoints
- 12.7 Slow-Operation Logging
- 12.8 The In-Memory Error Buffer
- 12.9 Loud Logging for Internal Errors
- 12.10 Cost-Awareness in Instrumentation
12 · 可观测体系
这一章讲一个在架构讨论里几乎从不出现、但决定了产品能不能长期演进的东西:系统怎么知道自己在发生什么。
12.1 埋点密度
先看数字:
| 指标 | 数值 |
|---|---|
| 去重的事件名数量 | 660 个 |
| 埋点调用点数量 | 1,093 处 |
| 平均每个源文件 | 约 0.57 处埋点 |
| 相对于主循环 | query.ts 1,730 行里有 十几处埋点,几乎每条决策分支都有 |
660 个不同的事件名意味着什么?
意味着这个系统里几乎每一个「值得区分的情况」都有自己的名字。不是「工具调用成功/失败」这种粗粒度,而是「延迟加载的工具因为说明未发送而参数校验失败」这种细粒度。
这直接决定了排查问题的能力:当用户报告「智能体有时候会卡住」,你可以直接查数据 —— 是哪条恢复路径被触发了?触发频率是多少?哪个模型版本更高发?而不是只能靠复现。
12.2 事件命名
所有事件都以 tengu_ 开头(tengu 是内部代号)。从主循环里出现的事件名可以看出命名规律:
| 事件名 | 记录什么 |
|---|---|
tengu_auto_compact_succeeded | 自动压缩成功,带压缩前后 token 数、压缩本身的花费 |
tengu_post_autocompact_turn | 压缩之后的每一轮(带 turnId 和轮次计数) |
tengu_cached_microcompact | 缓存微压缩执行,带删了几个、剩几个、阈值配置 |
tengu_time_based_microcompact | 时间触发的微压缩,带间隔分钟数、清了几条、省了多少 token |
tengu_model_fallback_triggered | 模型降级,带原模型和备用模型 |
tengu_orphaned_messages_tombstoned | 孤儿消息被打墓碑,带数量 |
tengu_max_tokens_escalate | 输出上限升档,带升到多少 |
tengu_streaming_tool_execution_usedtengu_streaming_tool_execution_not_used | 成对的事件,记录流式执行器有没有被启用,带工具数量 |
tengu_query_before_attachmentstengu_query_after_attachments | 成对的事件,记录附件处理前后的消息数量 |
tengu_token_budget_completed | token 预算用完,带是否是「收益递减」提前停止 |
tengu_query_error | 查询出错,带已产生的消息数、工具调用数 |
tengu_auto_mode_decision | 自动模式的每一个决策,带走的是哪条快速通道 |
tengu_tool_use_error | 工具调用出错,带错误类型和详情 |
tengu_deferred_tool_schema_not_sent | 延迟加载的工具被调用但说明还没发送 |
命名的两个规律
规律一:成对埋点。xxx_used / xxx_not_used、xxx_before / xxx_after —— 这样才能算出比率和差值,而不只是绝对数。
比如 tengu_streaming_tool_execution_used/not_used 这一对,能直接算出「流式执行器的启用率」。如果某次发布后这个比率突然掉了,说明有代码路径意外地绕过了它。
规律二:带上决策的「为什么」。tengu_auto_mode_decision 不只记录「允许还是拒绝」,还记录 fastPath: 'acceptEdits' | 'allowlist' —— 走的是哪条快速通道。这样才能评估「快速通道挡掉了百分之多少的分类器调用」,也就是这个优化到底值不值。
12.3 查询链路追踪
几乎所有埋点都带两个字段:
queryChainId: queryChainIdForAnalytics, // 这次用户请求的唯一 ID
queryDepth: queryTracking.depth, // 智能体嵌套深度(主 0,子 1,孙 2)
生成逻辑在第 3.9 节讲过:
const queryTracking = toolUseContext.queryTracking
? { chainId: toolUseContext.queryTracking.chainId, // 继承父的链路 ID
depth: toolUseContext.queryTracking.depth + 1 } // 深度 +1
: { chainId: deps.uuid(), depth: 0 } // 顶层,新建
有了这两个字段,就能把「一次用户请求引发的所有模型调用」串成一棵树。
能回答的问题包括:
· 一次典型请求平均派生多少个子智能体?
· 子智能体的失败率是不是显著高于主智能体?
· 深度 2 的调用(孙子级)实际发生频率有多高?值不值得支持?
· 某次超长请求的成本,具体花在哪一层?
没有链路 ID,这些问题全都答不了 —— 你只能看到一堆孤立的模型调用记录。
12.4 类型层面的隐私保护
埋点代码里到处是一个奇怪的类型转换:
toolName: sanitizeToolNameForAnalytics(tool.name),
errorDetails: errorContent.slice(0, 2000)
as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
queryChainId: queryTracking.chainId
as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
这个类型的名字直译是:分析元数据_我已确认这不是代码或文件路径。
问题:埋点数据要上报到服务器。而用户的代码和文件路径绝对不能上报 —— 那是隐私和商业机密。
但埋点字段是自由文本,编译器无法自动判断「这个字符串里有没有用户代码」。
解法:让类型系统强制开发者显式声明。埋点函数的参数类型是这个特殊类型,任何字符串都必须显式 as 转换过去。而这个转换的名字长得让人无法忽视 —— 你在写下 I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS 的时候,不可能没意识到自己在声明什么。
这比写一份「埋点规范文档」有效得多。文档没人看,而这个类型名你每次埋点都必须敲一遍。而且在代码评审时,这一行会非常显眼。
配套还有清洗函数:sanitizeToolNameForAnalytics() —— 因为 MCP 工具的名字包含服务名,而服务名可能是用户自定义的、含敏感信息的。
12.5 缓存断裂检测
有一个专门的子系统监控提示词缓存的健康度:services/api/promptCacheBreakDetection.ts,由编译期开关 PROMPT_CACHE_BREAK_DETECTION 控制。
它监控什么
正常情况下,一场会话的缓存读取量应该是逐轮递增的(历史越来越长,命中的缓存越来越多)。如果某一轮突然暴跌,说明缓存被打断了 —— 有代码改动了上下文前缀。
但有些下跌是合法的
所以系统提供了主动通知的接口:
// microCompact.ts,缓存编辑执行后
// Notify cache break detection that cache reads will legitimately drop
if (feature('PROMPT_CACHE_BREAK_DETECTION')) {
notifyCacheDeletion(querySource ?? 'repl_main_thread')
}
// 时间触发的微压缩执行后
// We just changed the prompt content — the next response's cache read will
// be low, but that's us, not a break. Tell the detector to expect a drop.
if (feature('PROMPT_CACHE_BREAK_DETECTION') && querySource) {
notifyCacheDeletion(querySource)
}
译:我们刚刚改了提示词内容 —— 下一次响应的缓存读取量会很低,但这是我们自己造成的,不是断裂。告诉检测器预期会有一次下跌。
缓存命中率是这个系统最重要的成本指标(回顾第 4.6 节:系统提示词的缓存是跨用户共享的,一次排序 bug 能让所有人的缓存全崩)。
但如果监控只会报「缓存掉了」,那么每次正常的压缩都会误报,告警很快就会被忽略。
所以必须区分「预期内的下跌」和「意外的断裂」 —— 做法是让所有会主动改动上下文的代码路径,显式通知检测器。这样剩下的告警才是真信号。
12.6 性能剖析检查点
代码里散布着两套检查点:
// 启动路径
profileCheckpoint('cli_entry')
profileCheckpoint('cli_dump_system_prompt_path')
profileCheckpoint('cli_bridge_path')
...
// 无头模式的延迟追踪
headlessProfilerCheckpoint('before_getSystemPrompt')
headlessProfilerCheckpoint('after_getSystemPrompt')
headlessProfilerCheckpoint('before_skills_plugins')
headlessProfilerCheckpoint('after_skills_plugins')
headlessProfilerCheckpoint('system_message_yielded')
headlessProfilerCheckpoint('query_started')
// 主循环内部
queryCheckpoint('query_fn_entry')
queryCheckpoint('query_snip_start') / queryCheckpoint('query_snip_end')
queryCheckpoint('query_microcompact_start') / ('query_microcompact_end')
queryCheckpoint('query_autocompact_start') / ('query_autocompact_end')
queryCheckpoint('query_setup_start') / ('query_setup_end')
queryCheckpoint('query_api_loop_start')
queryCheckpoint('query_api_streaming_start') / ('query_api_streaming_end')
queryCheckpoint('query_tool_execution_start') / ('query_tool_execution_end')
queryCheckpoint('query_recursive_call')
注意这些检查点的分布:几乎完全对应第 6 章那条五级流水线的每一级。这不是巧合 —— 只有把每一级的耗时单独测出来,才能判断「这一级的优化是不是值得」。
还有一个更重的方案:编译期开关 PERFETTO_TRACING。Perfetto 是 Google 的性能追踪工具,能生成可视化的时间线。
12.7 慢操作日志
编译期开关里有 SLOW_OPERATION_LOGGING,对应的还有一个模块 utils/slowOperations.ts。里面导出了一个函数叫 jsonStringify —— 也就是说,连「把对象转成 JSON 字符串」这个操作都被单独包装并纳入了慢操作监控。
为什么?因为在这个系统里,被序列化的对象可能是几百 MB 的消息历史。JSON.stringify 在这个规模下会阻塞主线程几百毫秒 —— 而主线程同时在渲染流式界面,卡顿会立刻被用户看到。
12.8 内存错误缓冲区
const errorLogWatermark = getInMemoryErrors().at(-1)
...
errors: (() => {
const all = getInMemoryErrors()
const start = errorLogWatermark ? all.lastIndexOf(errorLogWatermark) + 1 : 0
return [ `[ede_diagnostic] ...`, ...all.slice(start).map(_ => _.error) ]
})()
系统在内存里维护一个只保留最近 100 条的环形缓冲区存放错误日志。当一次执行失败时,把「本轮范围内」的错误一起打包进结果。
第 2.7 节讲过这里的水位标记技巧:记住元素的引用而不是数组下标,因为环形缓冲区会移位,下标会滑走。
12.9 内部错误的响亮日志
// To help track down bugs, log loudly for ants
logAntError('Query error', error)
系统区分了两种错误日志:
| 函数 | 行为 |
|---|---|
logError(error) | 常规记录。所有用户都走这条 |
logAntError(msg, error) | 只对内部用户「响亮地」报错 —— 可能是在界面上直接显示、或者上报到内部告警系统 |
这是「用自己的产品」(内部试用)的工程化体现。
外部用户遇到一个内部 bug 时,你不想用一大堆技术细节吓到他们 —— 应该优雅降级。
但内部用户遇到同一个 bug 时,你希望它尽可能刺眼 —— 因为他们是唯一有能力立刻反馈和修复的人。
同一份代码,两种错误响度。这需要一个「用户类型」的概念贯穿全系统(process.env.USER_TYPE === 'ant'),而这个判断在源码里出现了几十次。
12.10 埋点的成本意识
最后值得注意的一点:埋点本身也有成本,而 Claude Code 对此有意识。
// query.ts
const dumpPromptsFetch = config.gates.isAnt
? createDumpPromptsFetch(toolUseContext.agentId ?? config.sessionId)
: undefined
注释解释了为什么这个对象只创建一次:
「Create fetch wrapper once per query session to avoid memory retention. Each call to createDumpPromptsFetch creates a closure that captures the request body. Creating it once means only the latest request body is retained (~700KB), instead of all request bodies from the session (~500MB for long sessions).」
译:每个查询会话只创建一次这个包装器,以避免内存滞留。每次调用都会创建一个捕获了请求体的闭包。只创建一次意味着只保留最新的那个请求体(约 700 KB),而不是整场会话的所有请求体(长会话下约 500 MB)。
一个用于调试的功能,如果实现不当,会让长会话多占 500 MB 内存。这就是为什么可观测性的实现本身也需要被仔细设计。
12 · The Observability System
This chapter covers something that almost never appears in architecture discussions but decides whether a product can keep evolving over the long run: how the system knows what's happening to itself.
12.1 Instrumentation Density
The numbers first:
| Metric | Value |
|---|---|
| Distinct event names | 660 |
| Instrumentation call sites | 1,093 |
| Per source file, on average | About 0.57 call sites |
| Relative to the main loop | query.ts has a dozen-plus call sites in 1,730 lines; nearly every decision branch has one |
What do 660 distinct event names mean?
They mean that nearly every “situation worth distinguishing” in this system has its own name. Not coarse-grained like “tool call succeeded/failed,” but fine-grained like “a deferred tool's parameter validation failed because its schema hadn't been sent.”
This directly determines your ability to troubleshoot: when a user reports “the agent sometimes gets stuck,” you can go straight to the data — which recovery path fired? How often? Which model version sees it most? Rather than relying on reproduction alone.
12.2 Event Naming
Every event starts with tengu_ (tengu is an internal codename). The event names that appear in the main loop show the naming pattern:
| Event name | What it records |
|---|---|
tengu_auto_compact_succeeded | Auto-compaction succeeded, with token counts before and after and the cost of the compaction itself |
tengu_post_autocompact_turn | Every turn after a compaction (with turnId and turn count) |
tengu_cached_microcompact | Cached microcompaction ran, with how many were removed, how many remain, and the threshold config |
tengu_time_based_microcompact | Time-triggered microcompaction, with the interval in minutes, how many were cleared, and how many tokens were saved |
tengu_model_fallback_triggered | Model fallback, with the original and fallback models |
tengu_orphaned_messages_tombstoned | Orphaned messages tombstoned, with the count |
tengu_max_tokens_escalate | Output cap escalated, with the new cap |
tengu_streaming_tool_execution_usedtengu_streaming_tool_execution_not_used | A paired event recording whether the streaming executor was enabled, with the tool count |
tengu_query_before_attachmentstengu_query_after_attachments | A paired event recording the message count before and after attachment processing |
tengu_token_budget_completed | Token budget exhausted, with whether it was a “diminishing returns” early stop |
tengu_query_error | Query error, with the number of messages and tool calls produced so far |
tengu_auto_mode_decision | Every auto-mode decision, with which fast path it took |
tengu_tool_use_error | Tool call error, with error type and details |
tengu_deferred_tool_schema_not_sent | A deferred tool was called but its schema hadn't been sent yet |
Two patterns in the naming
Pattern one: paired instrumentation. xxx_used / xxx_not_used, xxx_before / xxx_after — that's what lets you compute ratios and deltas, not just absolute counts.
The pair tengu_streaming_tool_execution_used/not_used, for instance, gives you “the streaming executor's enablement rate” directly. If that ratio suddenly drops after a release, some code path is unexpectedly bypassing it.
Pattern two: carry the decision's “why.” tengu_auto_mode_decision records not just “allowed or denied” but also fastPath: 'acceptEdits' | 'allowlist' — which fast path was taken. That's what lets you evaluate “what percentage of classifier calls did the fast paths intercept,” in other words, whether the optimization is worth it at all.
12.3 Query Chain Tracing
Nearly every instrumentation call carries two fields:
queryChainId: queryChainIdForAnalytics, // unique ID for this user request
queryDepth: queryTracking.depth, // agent nesting depth (main 0, child 1, grandchild 2)
The generation logic was covered in section 3.9:
const queryTracking = toolUseContext.queryTracking
? { chainId: toolUseContext.queryTracking.chainId, // inherit the parent's chain ID
depth: toolUseContext.queryTracking.depth + 1 } // depth +1
: { chainId: deps.uuid(), depth: 0 } // top level: create a new one
With these two fields, “every model call triggered by one user request” can be strung into a tree.
Questions it can answer include:
· How many subagents does a typical request spawn on average?
· Is the subagent failure rate significantly higher than the main agent's?
· How often do depth-2 (grandchild) calls actually happen? Are they worth supporting?
· For one extremely long request, which level did the cost actually go to?
Without a chain ID, none of these questions can be answered — all you see is a pile of isolated model-call records.
12.4 Privacy Protection at the Type Level
The instrumentation code is full of an odd type cast:
toolName: sanitizeToolNameForAnalytics(tool.name),
errorDetails: errorContent.slice(0, 2000)
as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
queryChainId: queryTracking.chainId
as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
Read the type name as plain words: analytics metadata: I verified this is not code or file paths.
The problem: instrumentation data gets reported to a server. And the user's code and file paths must never be reported — that's privacy and trade secrets.
But instrumentation fields are free text, and the compiler can't automatically tell “whether this string contains user code.”
The solution: make the type system force the developer to declare it explicitly. The instrumentation function's parameter type is this special type, and any string has to be explicitly cast to it with as. And the cast's name is too long to ignore — when you type out I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, you can't fail to realize what you're declaring.
That's far more effective than writing an “instrumentation guidelines” document. Nobody reads documents, but you have to type this type name every time you add an event. And in code review, the line stands out.
There's a companion sanitizer too: sanitizeToolNameForAnalytics() — because MCP tool names include the server name, and server names can be user-defined and contain sensitive information.
12.5 Cache-Break Detection
A dedicated subsystem monitors the health of the prompt cache: services/api/promptCacheBreakDetection.ts, controlled by the compile-time flag PROMPT_CACHE_BREAK_DETECTION.
What it monitors
Normally, a session's cache-read volume should grow turn over turn (the history keeps getting longer, so more and more of it hits the cache). If it suddenly plunges on some turn, the cache was broken — some code changed the context prefix.
But some drops are legitimate
So the system provides an interface for proactive notification:
// microCompact.ts, after a cache edit runs
// Notify cache break detection that cache reads will legitimately drop
if (feature('PROMPT_CACHE_BREAK_DETECTION')) {
notifyCacheDeletion(querySource ?? 'repl_main_thread')
}
// after time-based microcompaction runs
// We just changed the prompt content — the next response's cache read will
// be low, but that's us, not a break. Tell the detector to expect a drop.
if (feature('PROMPT_CACHE_BREAK_DETECTION') && querySource) {
notifyCacheDeletion(querySource)
}
Put plainly: we just changed the prompt content — the next response's cache read will be low, but that's us, not a break. Tell the detector to expect a drop.
Cache hit rate is this system's most important cost metric (recall section 4.6: the system prompt's cache is shared across users, and one ordering bug can wipe out everyone's cache).
But if the monitor could only report “the cache dropped,” every normal compaction would be a false alarm, and the alert would soon be ignored.
So it has to distinguish “expected drops” from “unexpected breaks” — by having every code path that deliberately changes the context notify the detector explicitly. Only then are the remaining alerts real signal.
12.6 Profiling Checkpoints
Two sets of checkpoints are scattered through the code:
// startup path
profileCheckpoint('cli_entry')
profileCheckpoint('cli_dump_system_prompt_path')
profileCheckpoint('cli_bridge_path')
...
// latency tracking in headless mode
headlessProfilerCheckpoint('before_getSystemPrompt')
headlessProfilerCheckpoint('after_getSystemPrompt')
headlessProfilerCheckpoint('before_skills_plugins')
headlessProfilerCheckpoint('after_skills_plugins')
headlessProfilerCheckpoint('system_message_yielded')
headlessProfilerCheckpoint('query_started')
// inside the main loop
queryCheckpoint('query_fn_entry')
queryCheckpoint('query_snip_start') / queryCheckpoint('query_snip_end')
queryCheckpoint('query_microcompact_start') / ('query_microcompact_end')
queryCheckpoint('query_autocompact_start') / ('query_autocompact_end')
queryCheckpoint('query_setup_start') / ('query_setup_end')
queryCheckpoint('query_api_loop_start')
queryCheckpoint('query_api_streaming_start') / ('query_api_streaming_end')
queryCheckpoint('query_tool_execution_start') / ('query_tool_execution_end')
queryCheckpoint('query_recursive_call')
Note how these checkpoints are distributed: they map almost exactly onto each stage of the five-stage pipeline from chapter 6. That's no coincidence — only by measuring each stage's time on its own can you judge “whether optimizing this stage is worth it.”
There's a heavier option too: the compile-time flag PERFETTO_TRACING. Perfetto is Google's performance tracing tool, which produces a visual timeline.
12.7 Slow-Operation Logging
The compile-time flags include SLOW_OPERATION_LOGGING, with a matching module utils/slowOperations.ts. It exports a function called jsonStringify — meaning even “turn an object into a JSON string” has been wrapped on its own and brought under slow-operation monitoring.
Why? Because in this system, the object being serialized may be hundreds of MB of message history. At that scale, JSON.stringify blocks the main thread for hundreds of milliseconds — and the main thread is simultaneously rendering the streaming UI, so the stutter is visible to the user immediately.
12.8 The In-Memory Error Buffer
const errorLogWatermark = getInMemoryErrors().at(-1)
...
errors: (() => {
const all = getInMemoryErrors()
const start = errorLogWatermark ? all.lastIndexOf(errorLogWatermark) + 1 : 0
return [ `[ede_diagnostic] ...`, ...all.slice(start).map(_ => _.error) ]
})()
The system keeps a ring buffer holding only the most recent 100 entries of error logs in memory. When an execution fails, the errors “within this turn's range” get packaged into the result together.
Section 2.7 covered the watermark trick here: remember a reference to the element, not an array index, because the ring buffer shifts and indices slide away.
12.9 Loud Logging for Internal Errors
// To help track down bugs, log loudly for ants
logAntError('Query error', error)
The system distinguishes two kinds of error logging:
| Function | Behavior |
|---|---|
logError(error) | Regular logging. Every user goes through this |
logAntError(msg, error) | Reports the error “loudly,” for internal users only — possibly displayed directly in the UI, or sent to an internal alerting system |
This is “dogfooding” (internal trial use) made concrete in engineering.
When an external user hits an internal bug, you don't want to scare them with a pile of technical detail — degrade gracefully.
But when an internal user hits the same bug, you want it as glaring as possible — because they're the only ones able to report and fix it right away.
Same code, two error volumes. That requires a “user type” concept running through the whole system (process.env.USER_TYPE === 'ant'), and that check appears dozens of times in the source.
12.10 Cost-Awareness in Instrumentation
One last point worth noting: instrumentation has costs of its own, and Claude Code is aware of it.
// query.ts
const dumpPromptsFetch = config.gates.isAnt
? createDumpPromptsFetch(toolUseContext.agentId ?? config.sessionId)
: undefined
The comment explains why this object is created only once:
“Create fetch wrapper once per query session to avoid memory retention. Each call to createDumpPromptsFetch creates a closure that captures the request body. Creating it once means only the latest request body is retained (~700KB), instead of all request bodies from the session (~500MB for long sessions).”
Put plainly: create this wrapper once per query session to avoid memory retention. Every call creates a closure that captures the request body. Creating it once means only the latest request body is retained (about 700 KB), instead of every request body from the whole session (about 500 MB for long sessions).
A debugging feature, implemented poorly, can cost a long session an extra 500 MB of memory. That's why the implementation of observability itself needs careful design.