可观测:给每一次运行装上行车记录仪
离线评估只覆盖你想得到的任务,线上才有真实用户。这一天按公开的生成式 AI 语义约定给靶子埋点,把一次运行变成一棵可查的链路树,聚合出成本与延迟面板,并把前四天的评估分数本身也当成遥测数据上报。
今日目标
- 能按公开语义约定给一次模型调用和一次工具调用埋点,并说出为什么必须把约定版本钉进代码
- 能从链路数据聚合出按任务维度的成本、延迟与失败率,并指出首块延迟该记在哪个字段
- 能把评估分数作为遥测属性回传,并解释这样做如何把离线评估与线上监控缝成一条链
小白版讲解
航班的每一段行程都有时间戳
你从上海飞到成都,中途在西安转一次机。行程单上不会只写「上海到成都,耗时八小时」,它会写成一串带时间戳的段落:值机几点、第一程几点起飞几点落地、中转等了多久、第二程几点起飞几点落地、行李几点出来。
这串段落有三个性质,正好对应今天要讲的全部内容。
第一,每一段都有自己的开始与结束。 所以晚点八小时的时候,你能指出是哪一段晚的——是第一程延误,还是中转排队排掉了三小时。
第二,段落之间是嵌套的,不是并排的。 第二程的登机在第二程这一段里面,行李托运在值机这一段里面。有了嵌套关系,你才能说「中转这一段总共花了多久」,而不是把一堆平铺的事件按时间硬拼。
第三,每一段都带着一堆属性。 航班号、机型、登机口。属性是给统计用的:航司要回答「这个机型在这个机场的平均滑行时间是多少」,靠的就是它们。
前四天你已经有了「轨迹」——一次试次的完整过程记录。今天要做的事情,是把它变成一份带时间戳、带嵌套、带属性的行程单,而且要用别人也认识的那套字段名来写。
三个词:链路、跨度、属性
术语只有三个,先钉死。
| 词 | 英文 | 是什么 | 对应行程单 |
|---|---|---|---|
| 链路 | trace | 一次完整的运行 | 整张行程单 |
| 跨度 | span | 运行里的一段,有起止时间 | 其中一程 |
| 属性 | attribute | 挂在跨度上的键值对 | 航班号、机型 |
一条跨度长什么样?剥掉所有包装,它就是一个结构体:
{
"traceId": "t0000000000000000000000000000001",
"spanId": "s000000000000003",
"parentSpanId": "s000000000000002",
"name": "chat mock-model",
"startUnixNano": 1767000000000000000,
"endUnixNano": 1767000000252000000,
"attributes": {
"gen_ai.operation.name": "chat",
"gen_ai.provider.name": "openai-compatible-gateway",
"gen_ai.request.model": "mock-model",
"gen_ai.usage.input_tokens": 339,
"gen_ai.usage.output_tokens": 126
},
"status": "ok"
}就这些。今天实验里那个链路记录器只有几十行,做的事情就是造这种结构体、把 parentSpanId 接对、最后一行一条写进文件。
生产环境当然用官方 SDK,它会帮你做上下文传播、批量导出、重试、采样。但先看清一条跨度到底是什么,再去读 SDK 的配置项,那些配置项才有意义——这就是今天不装任何埋点 SDK 的理由。
为什么用公开约定,而不是自己定字段名
你完全可以把输入 token 数记成 prompt_tokens,也可以记成 in_tok,反正是自己的系统。为什么要用别人定的 gen_ai.usage.input_tokens 这么长一串?
三个理由,越往后越重要。
第一,换后端不用重埋。 可观测后端换得很频繁——自建换商业的、商业的换一家、或者双跑做对比。用公开约定,换后端时改的是导出地址;用自定字段,改的是每一处埋点外加每一张面板的查询语句。
第二,现成的面板能直接用。 各家后端都按这套约定做了开箱即用的视图:按模型看花费、按工具看错误率、按会话看链路。字段名对上了这些图不用配就有,对不上就得一张张自己拼。
第三,也是最容易被忽略的——它是一份别人替你想好的清单。 你自己定字段,多半只会记「模型、耗时、token 数」这三样。约定里还有缓存命中的 token 数、推理 token 数、完成原因、工具调用 id。这些字段你不记不会报错,但等到要回答「上个月缓存到底省了多少钱」的时候,历史数据里没有就是没有了,遥测的坑永远是补不回来的。
这套约定的真实状态:搬了家、还没 1.0、页面全标着「已弃用」
这一节请一个字一个字读,因为网上有大量文章在这几处写错,照着抄会得出完全相反的结论。
第一件事:仓库搬家了。 2026 年 6 月的 v1.42.0 起,所有 gen_ai. 开头的约定已经从主语义约定仓库移出,搬到了一个独立仓库 open-telemetry/semantic-conventions-genai,Apache-2.0。那个独立仓库到现在一个发布 tag 都还没有打过。
第二件事,也是最容易误读的:主仓库的属性登记页上,每一条 gen_ai. 属性现在都带着「已弃用,已移至 GenAI 仓库」的红字。
第三件事:它到现在还不是 stable。 截至 2026 年 7 月,没有任何一条生成式 AI 的跨度、属性或指标被标记为 Stable,全部处于 Development。没有 1.0,属性名还可能改。
那还用不用?用,但是要把版本钉死。
这个常量文件长这样:
// 钉死的约定版本。主仓库最后一次带 gen_ai 的版本是 1.42.0;
// 之后的真源是独立仓库,而它还没有 tag,所以只能钉到一个日期。
export const SEMCONV_VERSION = '1.42.0'
export const GENAI_REPO_REF = 'semantic-conventions-genai@2026-07'
export const SEMCONV_STABILITY = 'development'
// 属性名逐字取自约定,不要自己造。
// 旧名 gen_ai.system 已弃用,现在叫 gen_ai.provider.name。
export const GEN_AI = {
OPERATION_NAME: 'gen_ai.operation.name',
PROVIDER_NAME: 'gen_ai.provider.name',
REQUEST_MODEL: 'gen_ai.request.model',
USAGE_INPUT_TOKENS: 'gen_ai.usage.input_tokens',
USAGE_OUTPUT_TOKENS: 'gen_ai.usage.output_tokens',
TOOL_NAME: 'gen_ai.tool.name',
}
// 全部九个合法的操作名。自造一个等于没埋:
// 后端按枚举分面,不认识的值会掉进 other 桶里,永远出不了图。
export const OPERATIONS = [
'chat',
'create_agent',
'embeddings',
'execute_tool',
'generate_content',
'invoke_agent',
'invoke_workflow',
'retrieval',
'text_completion',
]# 钉死的约定版本。主仓库最后一次带 gen_ai 的版本是 1.42.0;
# 之后的真源是独立仓库,而它还没有 tag,所以只能钉到一个日期。
SEMCONV_VERSION = "1.42.0"
GENAI_REPO_REF = "semantic-conventions-genai@2026-07"
SEMCONV_STABILITY = "development"
# 属性名逐字取自约定,不要自己造。
# 旧名 gen_ai.system 已弃用,现在叫 gen_ai.provider.name。
GEN_AI = {
"OPERATION_NAME": "gen_ai.operation.name",
"PROVIDER_NAME": "gen_ai.provider.name",
"REQUEST_MODEL": "gen_ai.request.model",
"USAGE_INPUT_TOKENS": "gen_ai.usage.input_tokens",
"USAGE_OUTPUT_TOKENS": "gen_ai.usage.output_tokens",
"TOOL_NAME": "gen_ai.tool.name",
}
# 全部九个合法的操作名。自造一个等于没埋:
# 后端按枚举分面,不认识的值会掉进 other 桶里,永远出不了图。
OPERATIONS = [
"chat",
"create_agent",
"embeddings",
"execute_tool",
"generate_content",
"invoke_agent",
"invoke_workflow",
"retrieval",
"text_completion",
]该记哪些属性
约定里的属性很多,今天实验用到的是这一批,按用途分组:
| 用途 | 属性名 |
|---|---|
| 这一段在干什么 | gen_ai.operation.name(只能取那九个值之一) |
| 谁在提供服务 | gen_ai.provider.name(旧名 gen_ai.system 已弃用) |
| 请求参数 | gen_ai.request.model、gen_ai.request.temperature、gen_ai.request.max_tokens |
| 响应信息 | gen_ai.response.model、gen_ai.response.id、gen_ai.response.finish_reasons |
| 花了多少 | gen_ai.usage.input_tokens、gen_ai.usage.output_tokens |
| 缓存与推理 | gen_ai.usage.cache_read.input_tokens、gen_ai.usage.cache_creation.input_tokens、gen_ai.usage.reasoning.output_tokens |
| 串起一次会话 | gen_ai.conversation.id |
| 工具调用 | gen_ai.tool.name、gen_ai.tool.type、gen_ai.tool.call.id、gen_ai.tool.call.arguments、gen_ai.tool.call.result |
| 对话内容 | gen_ai.input.messages、gen_ai.output.messages |
| 流式首块延迟 | gen_ai.response.time_to_first_chunk(单位是秒) |
最后一个单独说一句。首块延迟不能从总耗时推算出来,它是一个必须在读到第一个流式分片的那一刻打的点。对流式界面来说,它才是用户感知到的「快不快」——总耗时十秒但半秒就开始出字,体验完全好过总耗时五秒但憋到最后一起吐出来。
今天实验里的离线靶子不是流式的,所以这个字段拿不到。面板上会诚实地写「未采集」,而不是填一个 0。这件事值得单独强调:面板上一个编出来的数字,比一个空着的格子危险得多——空格会让人去补埋点,假数字会让人对着一个不存在的问题优化。
一次运行的链路树:三层
一次试次埋出来的链路是这样一棵树:
· invoke_agent refund-agent (offline) 253ms
· chat mock-model 252ms
· execute_tool lookup_order 20ms
· execute_tool check_policy 22ms
· execute_tool issue_refund 61ms三层:最外面是一次 Agent 调用,里面是这一轮模型调用,再里面是这轮模型决定要调的那几个工具。
为什么工具跨度挂在 chat 下面,而不是和它平级挂在最外层?因为工具是这一轮模型调用决定要调的,挂在它下面才能把工具耗时归因到具体某一轮。多轮的 Agent 会有好几个 chat,平铺之后你就说不清第三次工具调用是第几轮发起的了。
还有一件事今天要立成纪律:埋点做在包裹层,不要改被埋点的对象。
今天实验里的靶子和执行器都是冻结文件,一行都不许改。这不是人为设限,而是真实处境的还原——线上那个 Agent 多半不是你写的,或者它已经在跑,你没有「回去重构一遍再埋点」这个选项。
// 包一个靶子,返回一个行为完全一致、但会吐跨度的新靶子。
export function wrap(inner, tracer, options) {
return {
name: inner.name,
async run(task, world) {
const root = tracer.start('invoke_agent', 'invoke_agent', undefined, {
'gen_ai.operation.name': 'invoke_agent',
'gen_ai.provider.name': options.providerName,
'gen_ai.request.model': options.model,
// 自己的属性一律放进自己的命名空间,不要往 gen_ai 里塞私货
'eval.task.id': task.id,
})
const transcript = await inner.run(task, world)
emitModelTurn(tracer, root, transcript, options)
tracer.end(root, transcript.durationMs)
// 返回值一个字都没变:埋点不能改变被埋点对象的行为
return transcript
},
}
}# 包一个靶子,返回一个行为完全一致、但会吐跨度的新靶子。
def wrap(inner, tracer, options):
class Wrapped:
name = inner.name
async def run(self, task, world):
root = tracer.start(
"invoke_agent",
"invoke_agent",
None,
{
"gen_ai.operation.name": "invoke_agent",
"gen_ai.provider.name": options["provider_name"],
"gen_ai.request.model": options["model"],
# 自己的属性一律放进自己的命名空间,不要往 gen_ai 里塞私货
"eval.task.id": task["id"],
},
)
transcript = await inner.run(task, world)
emit_model_turn(tracer, root, transcript, options)
tracer.end(root, transcript["duration_ms"])
# 返回值一个字都没变:埋点不能改变被埋点对象的行为
return transcript
return Wrapped()最后那句注释是一条硬约束:埋点不能改变被埋点对象的行为,否则你测的就不是线上那个系统了。今天实验的自检里有一条专门验这件事:包上埋点前后,同一个种子跑出来的轨迹必须逐字相同。
从链路到面板
有了跨度,面板就是一次分组求和。但有一条纪律必须先立:面板的每个数字只能从跨度里来。
这话听起来像废话,实际非常容易破功。今天的执行器自己就记着总成本,直接打印出来又快又准,为什么不呢?因为线上没有执行器,线上只有一堆跨度。如果面板偷偷读了内存里的变量,那么埋点漏掉一个字段,你在本地是发现不了的——要等到上线之后打开面板,看见一列空白。
// 一条 chat 跨度的花费:只从属性里读 token,按单价折算。
// 保留两位小数的分,成本要能加总对账。
export function spanCostCents(span, price) {
if (span.attributes['gen_ai.operation.name'] !== 'chat') return 0
const input = span.attributes['gen_ai.usage.input_tokens'] ?? 0
const output = span.attributes['gen_ai.usage.output_tokens'] ?? 0
const cents = (input / 1000) * price.input + (output / 1000) * price.output
return Math.round(cents * 100) / 100
}
// 失败率的来源是评估标签,不是 HTTP 状态码 ——
// 一个「200 OK 但退错了钱」的试次,在状态码上看不出任何问题。
export function failureRate(rootSpans) {
if (rootSpans.length === 0) return 0
const failed = rootSpans.filter(
(s) => s.attributes['gen_ai.evaluation.score.label'] === 'fail'
)
return failed.length / rootSpans.length
}# 一条 chat 跨度的花费:只从属性里读 token,按单价折算。
# 保留两位小数的分,成本要能加总对账。
def span_cost_cents(span, price):
if span["attributes"].get("gen_ai.operation.name") != "chat":
return 0
input_tokens = span["attributes"].get("gen_ai.usage.input_tokens", 0)
output_tokens = span["attributes"].get("gen_ai.usage.output_tokens", 0)
cents = input_tokens / 1000 * price["input"] + output_tokens / 1000 * price["output"]
return round(cents, 2)
# 失败率的来源是评估标签,不是 HTTP 状态码 ——
# 一个「200 OK 但退错了钱」的试次,在状态码上看不出任何问题。
def failure_rate(root_spans):
if not root_spans:
return 0
failed = [
s for s in root_spans
if s["attributes"].get("gen_ai.evaluation.score.label") == "fail"
]
return len(failed) / len(root_spans)那条计价表还藏着一个必须承认的技术债:单价写死在代码里,厂商调价那天你的历史成本面板会被一次性改写。 真正稳的做法是按当时的价把花费算出来、连同跨度一起落盘。今天为了不引第二份配置先简化了,但这是个真问题,不是可以装作没看见的细节。
评估结果也是遥测
到这里为止,今天做的还只是「给 Agent 装监控」,和前四天的评估是两件事。现在把它们缝起来。
约定里已经有这四个属性:
| 属性 | 记什么 |
|---|---|
gen_ai.evaluation.name | 哪个评分器 |
gen_ai.evaluation.score.value | 分数 |
gen_ai.evaluation.score.label | 通过还是不通过 |
gen_ai.evaluation.explanation | 为什么 |
这意味着评估结果本身就是一等公民的遥测数据,不是「另一个系统的数据」。前四天算出来的分数,可以直接作为属性挂在链路上。
顺带说清一个边界:线上不是所有请求都值得跑一遍模型裁判,那太贵。实际做法是分级——能用代码判的(结果态是否一致、格式是否合法)全量判,模型裁判抽样判。
采样:失败一条不落
线上全量记录链路会很贵,贵在存储和后端的写入量。于是要采样。
最省事的做法是头部采样:请求一进来就掷骰子,决定这条链路记不记。它的代价是致命的——一条 1% 命中的故障链路,有 99% 的概率在它出问题之前就已经被丢掉了。线上最需要的那批样本,恰好是最容易被采样掉的那批。
正确的做法是尾部采样:等这条链路跑完、知道结果了,再决定留不留。规则很简单:
- 失败的,一条不落地全留
- 慢的(比如超过 p99)全留
- 成功且不慢的,按比例留一小部分当作基线
有了上一节的评估属性,「失败」这个判据就特别好写——直接看 gen_ai.evaluation.score.label。这又是一处离线与线上缝在一起的地方。
还有一个实现细节:决定留不留要用 traceId 的哈希,不要用随机数。哈希是稳定的,同一条链路在任何一个节点上算出来的结论都一样;随机数会让一条链路的一部分跨度被留下、另一部分被丢掉,拼出来是一棵残树,比没有更糟。
源码导读
今天的三份材料建议按这个顺序读。
OpenTelemetry 的链路概念文档先读,它讲的是与生成式 AI 无关的基础:什么是 trace、什么是 span、上下文怎么传播、采样在哪一层发生。这套东西在后端领域已经跑了很多年,生成式 AI 只是在它上面加了一层字段约定而已。如果你以前没接触过链路追踪,这一篇是唯一必读的。
生成式 AI 语义约定页是今天的字段真源。读的时候注意两件事。第一,每一条属性都标着 Development,没有 Stable,这是现状不是笔误。第二,属性登记页上那一片「已弃用,已移至 GenAI 仓库」的标记说的是页面搬家,不是这些属性被废弃了——这一条网上误传得非常广,看到有人说「这套约定已经废了」,基本可以判断他只看了那个页面的红字。
独立仓库最后看两个东西:一是它到现在还没有任何发布 tag,二是它的 issue 区在讨论哪些字段还要改。后者有直接价值——正在被讨论的字段,就是你最该做好改名准备的那些。
动手实验
今天要写三个模块:属性名常量表、最小链路记录器、聚合面板。加上把埋点包在靶子外面的那一层,一共四处练习。
做完之后跑起来,面板长这样:
—— 成本与延迟面板(只从 span 聚合,不读 runner 的内存) ——
任务 试次 失败率 花费(分) p50(ms) p95(ms) 工具调用
ev-002-expired-order 5 20.0% 1.22 278 323 11
ev-001-fresh-order 5 0.0% 1.28 256 361 13
ev-003-already-refunded 5 0.0% 1.27 302 333 12
ev-004-nagging-user 5 0.0% 2.18 439 473 29
链路条数:20 总花费:5.95 分
首块延迟采集覆盖率:0/20 均值:未采集请盯住 ev-004-nagging-user 那一行。
它的失败率是 0,和第一行一样好。但它的工具调用次数是 29,别人是 11 到 13;花费 2.18 分,别人是 1.2 左右;延迟也高了六成。
这条任务触发的是靶子的缺陷 3:用户说「你再查一次」时,它会反复查订单。**结果态完全正确,所以前四天的评估一个字都不会报。**只有链路面板能看见它——因为它浪费的是钱和时间,不是正确性。
这就是今天这套东西的价值:它抓的是「对但贵」和「对但慢」这两类故障,而这两类恰恰是评估分数永远看不见的。
实验最后还有一个玩具收集端,用 node:http 写的,起在 3145 端口,可以按 traceId 把链路的树形结构查出来。它离真实收集端差得很远(没有批处理、重试、脱敏、多租户),但保住了最本质的一件事:跨度是可以按 traceId 查回来的一棵树。
面试题
今天四道题围绕三件事:为什么要用公开约定、怎么依赖一个还在变的标准、以及采样怎么设计才不会把故障样本丢掉。
第二道题值得特别准备。「一个还在开发中、属性名还会改的标准,你现在就用吗」——这题问的其实不是标准,是你处理不确定依赖的工程习惯。答「等它稳定了再说」会失去三个已经能拿到的好处,答「用,有问题再改」则暴露了你没想过改的成本。真正的答案在中间:用,但要把改动面收敛到一个文件,并且把版本钉进数据本身。
检查清单与明日预告
今天结束时,你应该能做到:
- 说清链路、跨度、属性三个词,并能手画出一次 Agent 运行的三层树
- 说出为什么用公开约定而不是自定字段名,至少讲出三条理由里的两条
- 解释主仓库页面上那一片「已弃用」标记的真实含义,以及为什么不能照着它删埋点
- 说出把语义约定版本钉进常量文件的两个好处
- 指出首块延迟该记在哪个字段、单位是什么,以及为什么它不能从总耗时推算
- 说清评估分数作为遥测属性上报之后,离线与线上是怎么接上的
- 说出头部采样的致命问题,以及尾部采样为什么用哈希而不是随机数
- 让
MOCK=1 pnpm selftest十项全绿 - 在面板上找到那条「结果全对但花了两倍钱」的任务,并解释为什么评估分数看不见它
明天是 D6《回归门禁:让退化在合并之前就被拦住》。今天和前四天做出来的一切——分数、轨迹、链路、面板——都还只是给人看的。明天要把它们变成一道会拦人的闸门:基线快照怎么存、退化到什么程度算退化、噪声怎么和真实退化区分开、以及一个绕不开的问题——一个本来就会抖的系统,门禁该按什么判据挂 CI。
面试题库
为什么给 Agent 埋点要用公开的语义约定,而不是团队自己定一套字段名?Why instrument an agent with a public semantic convention instead of a field-name scheme your team invents?
国内高频海外高频基础#observability#opentelemetry#semantic-conventions分析过程 · 先想清楚再作答
- 这题考的是「有没有真的换过可观测后端」。只答「为了标准化」是一句空话,要给出三条能落到具体成本的理由。
- 第一条是换后端的成本。可观测后端是换起来很频繁的东西——自建换商业的、商业的换一家、或者双跑做对比。用公开约定时,换后端改的是导出地址一处;用自定字段时,改的是每一处埋点,外加每一张面板的查询语句。这个差别在系统大起来之后是数量级的。
- 第二条是现成视图。各家后端都按这套约定做了开箱即用的面板:按模型看花费、按工具看错误率、按会话串链路。字段名对上了这些图不用配就有;对不上就得一张张自己拼,而且新同事看不懂你那套私有字段。
- 第三条最容易被忽略,也是最值钱的:**约定本身是一份别人替你想好的清单**。自己定字段多半只记模型、耗时、token 数三样;约定里还有缓存命中的 token 数、推理 token 数、完成原因、工具调用 id。这些不记不会报错,但等到要回答「上个月缓存省了多少钱」的时候,历史数据里没有就是永远没有了——**遥测的坑是补不回来的**。
- 反过来也要说清边界:不是所有字段都塞进公开命名空间。业务自己的维度(任务 id、租户、实验分组)应该放在自己的命名空间里,不要往 gen_ai 前缀底下塞私货,否则升级约定时你分不清哪些是自己的、哪些是人家的。
- 可预期的追问是「那约定里没有你要的字段怎么办」。答案是先查一遍确认真的没有,然后放进自己的命名空间,并留意上游有没有在讨论同名字段——真加进约定之后做一次改名,比一直自造要划算。
How to reason about it · think before answering
- This tests whether you have ever actually migrated an observability backend. 'For standardization' is an empty answer; give three reasons that map to concrete cost.
- First, migration cost. Backends get swapped often - self-hosted to commercial, one vendor to another, or two running side by side for comparison. With a public convention you change one export endpoint; with private field names you change every instrumentation site plus every dashboard query. At scale that difference is an order of magnitude.
- Second, ready-made views. Backends ship built-in panels keyed on these fields: spend by model, error rate by tool, traces by conversation. Match the names and the charts exist for free; miss them and you rebuild each one, and new teammates cannot read your private schema.
- Third, and most overlooked: the convention is a checklist someone already thought through for you. Left to yourself you record model, latency and token counts. The convention also has cache-read tokens, cache-creation tokens, reasoning tokens, finish reasons, tool call ids. Omitting them raises no error, but when someone asks how much caching saved last month, the history simply is not there - telemetry gaps cannot be backfilled.
- State the boundary too: not everything belongs in the public namespace. Your own dimensions - task id, tenant, experiment arm - go under your own prefix. Do not smuggle private fields under gen_ai, or you will not be able to tell yours from theirs when the convention moves.
- Expected follow-up: what if the field you need is not in the convention? Check carefully that it really is absent, put it in your own namespace, and watch upstream discussions - renaming once after it lands is cheaper than inventing your own forever.
答题要点
- 换后端时只改导出地址,不用改每一处埋点和每一张面板查询。
- 各家后端的开箱即用视图直接可用,不必逐张自己拼图。
- 约定是一份现成的字段清单,缓存 token、推理 token、完成原因这些自己多半想不到。
- 遥测的坑补不回来:当时没记的字段,事后无法从历史数据里恢复。
- 业务自有维度放进自己的命名空间,不要塞进公开前缀底下。
Key points
- Swapping backends changes one export endpoint instead of every instrumentation site and dashboard query.
- Vendor-provided default views work out of the box instead of being rebuilt chart by chart.
- The convention is a ready-made field checklist covering cache tokens, reasoning tokens and finish reasons you would not think of.
- Telemetry gaps are unrecoverable: a field you did not record cannot be reconstructed later.
- Keep business dimensions under your own namespace rather than inside the public prefix.
一个仍在开发中、属性名还会改的标准,你会现在就用吗?怎么控制升级风险?Would you adopt a standard that is still in development and whose attribute names may change? How do you control the upgrade risk?
国内高频海外高频深入#observability#opentelemetry#dependency-risk分析过程 · 先想清楚再作答
- 这题问的表面是标准,实际是**你处理不确定依赖的工程习惯**。两个极端答案都拿不到分:「等它稳定了再说」会白白丢掉现在就能拿到的好处,「用,有问题再改」则暴露你没算过改的成本。
- 先把事实说准,这一步就能拉开差距:生成式 AI 的那套语义约定至今没有任何条目标记为 Stable,全部处于 Development,没有 1.0;而且它已经从主语义约定仓库搬进了一个独立仓库,那个仓库到现在一个发布 tag 都没打过。
- 还要补一句最容易被误读的现状:主仓库的属性登记页上,每一条相关属性现在都带着「已弃用,已移至新仓库」的标记。**那是页面搬家,不是属性被废弃。** 看到红字就把埋点删掉是这一年最常见的误操作,能主动指出这一点,说明你看的是一手页面而不是二手文章。
- 然后给方案,核心是**把改动面收敛**:全部属性名只在一个常量文件里出现一次,别处一律引用;再写一条护栏断言所有用到的名字都来自这张常量表,防止有人图省事手写字符串——手写的那个拼错了不会报错,只会在面板上少一列。
- 第二件事是把版本钉进数据本身:常量文件里写死约定版本,并把这个版本号作为属性写进每一条链路。一年后翻历史数据时,你能立刻知道那批数据是按哪一版记的,而不是靠猜。
- 最后给升级动作:属性改名时做双写过渡(一段时间内新旧名都写),等面板和告警都切到新名再撤掉旧的。可预期的追问是「那要不要在数据管道里做改名映射」——可以,但那是把债转移到了管道上,双写加一个明确的下线日期更干净。
How to reason about it · think before answering
- On the surface this is about a standard; really it is about how you handle an unstable dependency. Both extremes score poorly: 'wait for stable' forfeits benefits available today, while 'just adopt and fix later' shows you never priced the fix.
- Get the facts right first, which alone separates candidates: nothing in the GenAI semantic conventions is marked Stable - everything is Development, there is no 1.0 - and the conventions have moved out of the main semantic-conventions repository into a dedicated one that has not cut a single release tag yet.
- Add the most misread detail: the registry pages in the main repository now mark every one of those attributes as deprecated and moved. That is a page relocation, not a deprecation of the attributes. Deleting instrumentation because of that red text has been a common mistake this year; pointing it out shows you read the primary source.
- Then give the plan, whose core is shrinking the blast radius: every attribute name appears exactly once, in one constants file, and everywhere else imports it. Add a guard asserting that every emitted name comes from that table, so nobody hand-writes a string - a hand-written typo raises no error, it just silently drops a column from the dashboard.
- Second, pin the version into the data itself: freeze the convention version in the constants file and emit it as an attribute on every trace. A year later you can tell which revision a batch of data was recorded under instead of guessing.
- Finally, the upgrade move: on a rename, dual-write both names for a transition window, cut dashboards and alerts over to the new one, then drop the old. Expected follow-up - should the rename live in the data pipeline instead? It can, but that moves the debt into the pipeline; dual-writing with a stated removal date is cleaner.
答题要点
- 先把事实说准:至今无任何条目为 Stable,全部 Development,且已搬进一个尚无发布 tag 的独立仓库。
- 主仓库页面上的「已弃用」是页面搬家,不是属性被废弃,不能照着它删埋点。
- 用,但把改动面收敛到一个常量文件,别处一律引用。
- 加一条护栏断言所有用到的属性名都来自常量表,防止手写字符串拼错后静默少一列。
- 把约定版本钉进常量文件并作为属性写进链路;改名时双写过渡,切完面板再下线旧名。
Key points
- State the facts: nothing is Stable, everything is Development, and it has moved to a repository with no release tag yet.
- The deprecation banners in the main repository mean the pages moved, not that the attributes died - do not delete instrumentation over them.
- Adopt it, but confine changes to a single constants file that everything else imports.
- Add a guard asserting every emitted attribute name comes from that table, since a hand-typed typo silently drops a column.
- Pin the convention version in code and emit it as an attribute; on renames dual-write, migrate dashboards, then retire the old name.
一次 Agent 运行的链路树你会怎么分层?每层各记哪些属性?How would you layer the trace tree for one agent run, and what attributes go on each layer?
国内高频海外高频进阶#observability#tracing#span-design分析过程 · 先想清楚再作答
- 这题考的是「有没有真的画过一棵树」。只说「记一下模型调用」的拿不到分,要给出层级、给出每层的操作名、并解释分层的判据。
- 三层是最常用的骨架:最外层是一次 Agent 调用,操作名 invoke_agent,记提供方、请求模型、会话 id,再加上你自己命名空间里的业务维度;中间层是每一轮模型调用,操作名 chat,记请求参数、响应模型与 id、完成原因、输入输出 token 用量;最里层是每一次工具调用,操作名 execute_tool,记工具名、工具类型、调用 id、参数与结果。
- 操作名不能自己造。约定里它是一个枚举,一共九个合法取值(chat、create_agent、embeddings、execute_tool、generate_content、invoke_agent、invoke_workflow、retrieval、text_completion)。写一个枚举外的值等于没埋:后端按枚举分面,不认识的值会掉进 other 桶,永远出不了图。
- 分层判据要说清楚:工具跨度挂在它所属的那一轮模型调用下面,而不是与模型调用平级。因为工具是那一轮决定要调的,挂进去才能把工具耗时归因到具体某一轮;多轮 Agent 一旦平铺,你就说不清第三次工具调用是第几轮发起的。**两种画法真实世界里都有,重点是全公司统一**,否则按层级做的聚合查询在两个服务之间对不上。
- 还要提两个容易漏的字段。一是流式首块延迟,它必须在读到第一个分片时打点,**不能从总耗时推算**,对流式界面来说它才是用户感知的快慢。二是评估结果四件套,把评分器名字、分数、通过标签与理由挂在根跨度上,这样面板上的失败率才有正确来源。
- 最后是负载控制:输入输出消息和工具参数结果都要截断并考虑脱敏,整轮对话原样进链路是把存储一次性写爆的经典方式,也是泄漏个人信息最常见的路径。可预期的追问是「那出了问题要看全文怎么办」——把全文留在你自己的日志里,链路只留一个能关联回去的 id。
How to reason about it · think before answering
- This tests whether you have actually drawn one. 'Record the model call' earns nothing; give the layers, the operation name per layer, and the rule that decides nesting.
- Three layers is the common skeleton. Outermost is the agent invocation, operation invoke_agent, carrying provider, request model, conversation id, plus your own business dimensions in your own namespace. The middle layer is each model turn, operation chat, carrying request parameters, response model and id, finish reasons, and input/output token usage. Innermost is each tool call, operation execute_tool, carrying tool name, tool type, call id, arguments and result.
- Operation names cannot be invented. The convention defines an enum of exactly nine values: chat, create_agent, embeddings, execute_tool, generate_content, invoke_agent, invoke_workflow, retrieval, text_completion. An off-enum value is equivalent to not instrumenting at all: backends facet on the enum, and unknown values land in an 'other' bucket that never surfaces in a chart.
- Explain the nesting rule: tool spans hang under the model turn that requested them, not as siblings of it, because that is the only way to attribute tool latency to a specific turn. Flatten them in a multi-turn agent and you can no longer say which turn issued the third tool call. Both layouts exist in the wild; what matters is that one company picks one, or hierarchy-based aggregation stops agreeing across services.
- Mention two commonly missed fields. Time to first chunk must be stamped when the first streamed chunk arrives and cannot be derived from total duration - for a streaming UI it is the latency the user actually feels. And the evaluation quartet - grader name, score, pass label, explanation - belongs on the root span so the dashboard's failure rate has a correct source.
- Finally, payload control: truncate and redact messages, tool arguments and results. Dumping whole conversations into spans is the classic way to blow up storage in one afternoon and the most common path for leaking personal data. Expected follow-up - how do you debug without the full text? Keep it in your own logs and put only a correlating id on the span.
答题要点
- 三层:invoke_agent 包 chat,chat 包 execute_tool;工具挂在发起它的那一轮模型调用下面。
- 操作名只能取九个合法值之一,自造值会掉进后端的 other 桶,等于没埋。
- 各层属性:根层记提供方、模型与会话 id;chat 层记请求参数、响应 id 与 token 用量;工具层记工具名、类型、调用 id、参数与结果。
- 首块延迟必须在第一个分片到达时打点,不能从总耗时推算;评估四件套挂在根跨度上。
- 消息与工具参数结果要截断并脱敏,全文留在日志里、链路只放关联 id。
Key points
- Three layers: invoke_agent wraps chat, chat wraps execute_tool, with tools nested under the turn that requested them.
- Operation names must come from the nine-value enum; anything else lands in the backend's 'other' bucket.
- Per layer: root carries provider, model and conversation id; chat carries request parameters, response id and token usage; tool spans carry name, type, call id, arguments and result.
- Time to first chunk must be stamped on arrival of the first chunk, never derived; the evaluation quartet goes on the root span.
- Truncate and redact messages and tool payloads; keep full text in logs and put only a correlating id on the span.
线上全量记录太贵,你怎么设计采样策略才不会把真正的故障样本丢掉?Recording every trace in production is too expensive. How do you design sampling so that you do not throw away the failures you actually need?
国内高频海外高频深入#observability#sampling#production分析过程 · 先想清楚再作答
- 这题的陷阱在于「采样」这个词会让人下意识想到按比例随机丢。真正的考点是**在什么时刻做这个决定**。
- 头部采样是请求一进来就掷骰子决定记不记。它便宜、实现简单,但代价是致命的:这个决定是在你还不知道这条链路会不会出问题的时候做的。于是一条命中率 1% 的故障链路,有 99% 的概率在它出问题之前就已经被丢掉了——**线上最需要的那批样本,恰好是最容易被采样掉的那批**。
- 正确的做法是尾部采样:等链路跑完、结果已知,再决定留不留。规则可以写得很直白——失败的一条不落,超过 p99 的慢链路全留,成功且不慢的按比例留一小部分当基线。留基线很重要,全丢掉的话你手里只剩故障样本,没法算失败率,也看不出正常态是什么样。
- 对 Agent 来说「失败」的判据要特意说清楚:不是 HTTP 状态码。一个退错了钱的请求照样是 200。判据应该来自评估结果属性——把评分器的通过标签挂在根跨度上,采样规则直接读它。这也是离线评估与线上监控缝在一起之后的直接收益。
- 还有一个实现细节能看出有没有真做过:决定留不留要用 traceId 的稳定哈希,不要用随机数。随机数会让一条链路的一部分跨度被留下、另一部分被丢掉,拼出来是一棵残树,比完全没有更糟——它会让人以为某一段根本没发生。
- 代价也要主动说:尾部采样必须先把一条链路的全部跨度缓存到能判定结果为止,所以收集端要扛住内存与乱序到达,长链路还要设超时强制出清。可预期的追问是「那怎么保证成本可控」——给采样后的写入量设预算,超预算时先降成功样本的留存比例,**失败与慢链路的留存率永远不动**。
How to reason about it · think before answering
- The trap is that the word 'sampling' makes people reach for a random percentage drop. The real question is when the decision is made.
- Head-based sampling rolls the dice as the request arrives. It is cheap and simple, but the cost is fatal: the decision happens before you know whether this trace will fail. A failure mode that hits 1% of requests is discarded 99% of the time before it goes wrong - the samples you most need are precisely the ones most likely to be dropped.
- Tail-based sampling is the right shape: wait until the trace finishes and the outcome is known, then decide. The rules can be blunt - keep every failure, keep everything slower than p99, keep a small proportion of fast successes as a baseline. The baseline matters: keep only failures and you cannot compute a failure rate or see what healthy looks like.
- For agents, say explicitly what 'failure' means: not the HTTP status code. A request that refunded the wrong amount still returns 200. The signal should come from the evaluation attributes - put the grader's pass label on the root span and let the sampling rule read it. That is a direct payoff of stitching offline evaluation into online telemetry.
- One implementation detail separates people who have done this: use a stable hash of the trace id, not a random number. Randomness keeps some spans of a trace and drops others, producing a truncated tree that is worse than nothing - it makes a stage look as if it never happened.
- Name the cost too: tail sampling must buffer all spans of a trace until the verdict is known, so the collector has to survive memory pressure and out-of-order arrival, and long traces need a timeout that forces a flush. Expected follow-up - how do you keep spend bounded? Budget the post-sampling write volume and, when over budget, lower the retention of successful traces first; the retention of failures and slow traces never moves.
答题要点
- 关键不是丢多少,而是在什么时刻决定:头部采样在结果未知时就掷骰子。
- 头部采样的致命问题:低命中率的故障链路极大概率在出问题之前已被丢掉。
- 尾部采样规则:失败全留、超 p99 的慢链路全留、成功且不慢的按比例留一小部分当基线。
- Agent 的失败判据来自评估属性而不是 HTTP 状态码,退错钱的请求同样返回 200。
- 用 traceId 的稳定哈希而不是随机数,否则会产出残缺的链路树;预算紧张时只降成功样本留存率。
Key points
- The question is not how much to drop but when to decide: head-based sampling rolls the dice before the outcome is known.
- Its fatal flaw: a low-frequency failure mode is almost always discarded before it goes wrong.
- Tail-based rules: keep all failures, keep everything above p99 latency, keep a small sampled baseline of fast successes.
- Failure for an agent comes from evaluation attributes, not HTTP status - a wrong refund still returns 200.
- Use a stable hash of the trace id, never a random number, or you get truncated trees; under budget pressure lower only the retention of successful traces.