ADK 遥测升级后,分析查询为何悄悄失效
作者升级 Google ADK 并切换至遵循 OpenTelemetry GenAI 语义约定的遥测 v2 后,原有 BigQuery 查询因 span 名称变化返回零行。文章梳理迁移中遇到的六类陷阱,并说明 Cloud Trace 到 BigQuery 的查询链路。
AI 深度解读
Google ADK 从 2.7 升到 2.13,部署正常,Trace 也有数据,BigQuery 分析却全部查出零行。作者同时切换了 telemetry schema v2,旧 SQL 依赖的 span 名称和 JSON 属性已经变了,而查询引擎没有报错。
这次事故值得读的地方,是它把一次升级串出了几个常见盲区:配置还在但不再生效、JSON 路径写错却照常执行、测试替身污染其他文件,以及把指标缺失误判成业务没有发生。对依赖 AI 调用记录做排障、成本分析的团队,这些比升级命令更需要检查。
SQL 执行成功,为什么分析已经失效
作者的链路是 Cloud Run 上的 Agent 生成 OpenTelemetry spans,经 SpanProcessor 包装器处理后导出到 Cloud Trace,再通过 google_observability_link 关联 BigQuery。它没有经过 Logging sink。
查询入口 cloud_trace_spans._AllSpans 是 VIEW,没有复制数据,保留期跟随 Cloud Trace,默认只有 30 天。数据集位于 us,执行命令需要带 bq query --location=us,否则会提示数据集不存在。关联配置还给 observability service agent 授予了 roles/bigquery.admin。
真正导致静默故障的是属性存储方式:所有 span attributes 都在一个叫 attributes 的 JSON 列里。普通列名写错,BigQuery 会报错;JSON_VALUE 指向不存在的键,只返回 NULL。
开启 ADK_TELEMETRY_SCHEMA_VERSION_OPT_IN=2 后,schema v2 转向 OpenTelemetry GenAI semantic conventions,原来塞在大 JSON 中的内容按含义拆分,名称也改变了。旧查询的 WHERE name = 'call_llm' 因此不再命中新数据。
迁移期间,要先让查询覆盖两种 LLM span:
WHERE name = 'call_llm' OR STARTS_WITH(name, 'generate_content ')
关键在 STARTS_WITH:v2 的名称还带有后续内容,不能只拿固定名称做等值匹配。
两种记录的内部结构也不同。v1 请求使用 contents[last].parts[].text,v2 使用消息数组里的 parts[].content;助手角色从 model 变成 assistant。
作者提醒,COALESCE 只处理当前行的空值,不能自动解决不同 span 行、不同 JSON 结构的迁移。它可以选出当前行已有的属性,但还得配合 span 筛选和分支解析。工具调用则不同:execute_tool <name> 保持不变,参数字段可以在同一行用 COALESCE 兼容 gcp.vertex.agent.tool_call_args 和 gen_ai.tool.call.arguments。
归属关系也不能随手按 trace_id 关联。一次 trace 里可能同时有 router 和 AgentTool 子 Agent,容易把调用算到另一个 Agent 头上。作者优先读取 gen_ai.agent.name,缺失时沿 parent_span_id 查父节点、祖父节点。其采样中的 v2 generate_content 已直接携带名称,向上遍历主要兼容 v1。
内容采集和大小限制,要分别验证
schema 切换成功,不代表正文采集也成功。v1 的 ADK_CAPTURE_MESSAGE_CONTENT_IN_SPANS=true 对 v2 不再适用,需要配置 OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT。
作者的纯 Trace 链路选择 SPAN_ONLY。看起来更全面的 SPAN_AND_EVENT 涉及事件导出,而 ADK 只有存在 log record processors 时才创建 LoggerProvider。单独调用 get_gcp_exporters(enable_cloud_tracing=True),并没有提供这条事件通道。
内容采集恢复后,还有大小问题。作者遇到的 GCP Telemetry API 限制是单个属性不能超过 64 KiB,超限会让整个批次返回 HTTP 400,而非只丢弃那个属性。v2 拆开了字段,但 gen_ai.system_instructions 和 gen_ai.tool.definitions 仍可能很大。
OTEL_ATTRIBUTE_VALUE_LENGTH_LIMIT 也不能直接解决:它按字符截断,无法保证 UTF-8 字节数,还可能把 JSON 截成无法解析的半段。
作者用 SpanAttributeLimitSpanProcessor 包装现有 processors,再通过 OTelHooks 和 maybe_set_otel_providers 接回 ADK,以保留原生批处理。裁剪优先级是 grounding_metadata、logprobs_result 等附带信息,然后是工具定义、系统指令、最早的对话,并加上 __adk_span_truncated__ 标记。
这套实现有实测,也有明显缺陷。一次 generate_content gemini-3.8-flash 的系统指令原始大小为 63,427 bytes,处理后剩 27,826 字符,保留尾部上下文,作者称此后没有再遇到 400。但在 30 parts × 2.1 KB、总计 63,520 bytes 的复现中,仅超过其处理预算约 3.5 KB,就退化到只剩 47 bytes 的标记。
这里不能混用字节和字符;原文也没有交代内部预算为何低于 64 KiB。它证明结构化裁剪有必要,同时证明当前算法还不够稳。
升级复现失败,也可能与生产代码无关
另外两处问题出在依赖和测试环境。
uv.lock 包含 exclude-newer = "0001-01-01T00:00:00Z" 和 exclude-newer-span = "P1W",限制使用发布时间过新的包。ADK 2.13.0 比截止时间晚约五个半小时,于是安装器提示只能找到 google-adk<=2.12.0。
作者本机的 ~/.config/uv/uv.toml 还有 exclude-newer = "1 week",按其描述覆盖了 PR 的锁文件行为,导致切到分支运行 uv sync 仍安装 2.7.0。排查时还需要清除旧的 VIRTUAL_ENV,避免 uv pip list 查看错误环境。原文用 UV_EXCLUDE_NEWER=2026-12-31T00:00:00Z uv sync 调整截止时间,这属于诊断操作,不宜直接变成团队默认配置。
CI 失败则来自测试脚手架。某测试文件在导入时把伪造的 google.genai、google.genai.types 写进 sys.modules,又调用 _build_stub_modules(),没有清理。后续测试拿到的是替身模块,导入顺序因此影响结果。另一处测试仍读取 contents.types,这个成员在 ADK 2.13 已不存在。
排除有问题的测试文件后,其余 1,243 个测试通过。作者提炼出的线索很实用:python -c 导入正常、pytest 收集阶段失败时,应检查其他测试是否污染了进程级模块缓存。
全是 NULL,不能证明缓存从未工作
作者看到 ADK 2.13 提示 router 没有 context_cache_config,便查询缓存 token,结果全为 NULL。他起初据此认定缓存从未生效,后来发现自己查错了键。
错误路径是 gen_ai.usage.cached_input_tokens,正确路径是 gen_ai.usage.cache_read.input_tokens;缓存创建量对应 gen_ai.usage.cache_creation.input_tokens。改正后发现,Vertex AI 的隐式缓存从第三次调用就已经命中。
在解释指标前,可以先确认路径是否曾经读到值:
SELECT COUNTIF(JSON_VALUE(attributes, '$."gen_ai.usage.cache_read.input_tokens"') IS NOT NULL) AS has_attr,
COUNT(*) AS n
FROM spans WHERE name LIKE 'generate_content%'
先看 has_attr。如果它是 0,应先核对属性名、span 类型和采集逻辑。
ADK 只在值不是 None 时写入该 usage 属性。严格说,NULL 也不能直接等同于数值零:显式写入的 0 可以被读到,字段缺失才会返回 NULL。
补上显式缓存后,作者观察到命中率从 2/4 变成 4/5。准确结论是已有缓存变得更稳定,不能说缓存从此才开始工作。门槛也要结合模型判断:原文引用的 ContextCacheConfig 文档规定,Gemini 2.5 需要 2,048 tokens,Gemini 3 需要 4,096 tokens;router 的 2,875–3,010 tokens 达不到后者。这个长度不能脱离模型单独判断。
我的判断
这次升级最有价值的教训,是把遥测数据也当成需要验收的接口。
作者对 JSON 静默失配、父子 span 归属和测试污染的判断,都有明确故障过程支持。但这里没有完整的 schema 映射表,读者不能把几段兼容 SQL 当成迁移手册。gen_ai.tool.definitions 曾被作者误写成 gen_ai.tool_definitions,也说明属性名应核对 semconv 常量,而非凭记忆抄写。
裁剪方案比普通字符串截断更适合保留可查询的 JSON,不过它的多片段退化已经被作者复现,不能直接视作成熟通用组件。缓存命中率只有 2/4、4/5 两组小样本,没有统一负载、成本和延迟对照,只能支持这次观察。
同样,1,243 个测试通过有助于定位故障范围,却不能代替遥测查询验证。应用运行、SDK 导出、分析 SQL,分别需要证据。
能拿来做什么
- 升级 AI SDK 或遥测 schema 前,保存同一次实际调用的升级前后 spans,对照名称、JSON 路径、角色和父子关系,再修改分析 SQL。
- 给关键查询增加属性存在率检查;发现零行或全 NULL 时,先检查采集配置和字段路径,再判断调用量、缓存量是否真的为零。
- 在 Agent 服务导出遥测前按 UTF-8 字节检查体积,保留结构化裁剪标记,并用多段中等长度内容验证裁剪行为。
- 遇到本地与 CI 结果不同时,同时检查 uv 配置来源、虚拟环境和测试替身清理;只在 pytest 中出现的导入故障,要追查
sys.modules的修改。