这一章讲一个在架构讨论里几乎从不出现、但决定了产品能不能长期演进的东西:系统怎么知道自己在发生什么。
先看数字:
| 指标 | 数值 |
|---|---|
| 去重的事件名数量 | 660 个 |
| 埋点调用点数量 | 1,093 处 |
| 平均每个源文件 | 约 0.57 处埋点 |
| 相对于主循环 | query.ts 1,730 行里有 十几处埋点,几乎每条决策分支都有 |
660 个不同的事件名意味着什么?
意味着这个系统里几乎每一个「值得区分的情况」都有自己的名字。不是「工具调用成功/失败」这种粗粒度,而是「延迟加载的工具因为说明未发送而参数校验失败」这种细粒度。
这直接决定了排查问题的能力:当用户报告「智能体有时候会卡住」,你可以直接查数据 —— 是哪条恢复路径被触发了?触发频率是多少?哪个模型版本更高发?而不是只能靠复现。
所有事件都以 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' —— 走的是哪条快速通道。这样才能评估「快速通道挡掉了百分之多少的分类器调用」,也就是这个优化到底值不值。
几乎所有埋点都带两个字段:
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,这些问题全都答不了 —— 你只能看到一堆孤立的模型调用记录。
埋点代码里到处是一个奇怪的类型转换:
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 工具的名字包含服务名,而服务名可能是用户自定义的、含敏感信息的。
有一个专门的子系统监控提示词缓存的健康度: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 能让所有人的缓存全崩)。
但如果监控只会报「缓存掉了」,那么每次正常的压缩都会误报,告警很快就会被忽略。
所以必须区分「预期内的下跌」和「意外的断裂」 —— 做法是让所有会主动改动上下文的代码路径,显式通知检测器。这样剩下的告警才是真信号。
代码里散布着两套检查点:
// 启动路径
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 的性能追踪工具,能生成可视化的时间线。
编译期开关里有 SLOW_OPERATION_LOGGING,对应的还有一个模块 utils/slowOperations.ts。里面导出了一个函数叫 jsonStringify —— 也就是说,连「把对象转成 JSON 字符串」这个操作都被单独包装并纳入了慢操作监控。
为什么?因为在这个系统里,被序列化的对象可能是几百 MB 的消息历史。JSON.stringify 在这个规模下会阻塞主线程几百毫秒 —— 而主线程同时在渲染流式界面,卡顿会立刻被用户看到。
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 节讲过这里的水位标记技巧:记住元素的引用而不是数组下标,因为环形缓冲区会移位,下标会滑走。
// 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'),而这个判断在源码里出现了几十次。
最后值得注意的一点:埋点本身也有成本,而 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 内存。这就是为什么可观测性的实现本身也需要被仔细设计。