今天要解决的问题
Day 34 给工单 Agent 接上 Langfuse 和 OpenTelemetry 的时候,我们的做法是「全记」:每次运行、每次模型调用、每个工具调用都生成 span,prompt 和回复原文全部带上。一个 Agent、每天几百次运行,这样做没有任何问题,而且非常好用,出了问题打开 trace 就能看到模型当时看到了什么。
现在平台上有 30 个 Agent,每天合计一百万次运行。全记会发生三件事:存储和观测平台的账单比模型费用还高;trace 后端的查询越来越慢,真出事的时候打不开;安全团队发现全公司客户的对话原文都在 trace 里,保留了一年,任何有看板权限的人都能翻。
今天结束时你应该能:
- 估算一个 Agent 平台每天的 trace 数据量,知道钱花在哪个部分。
- 让 trace 一路穿过模型网关和工具中心,形成完整的调用链。
- 用 OpenTelemetry Collector 配置尾部采样:错误、慢请求、转人工、低评分、高成本的 trace 全留,其余按比例抽样。
- 把 prompt 原文和元数据拆成两条管道,分别脱敏、分别设置保留期。
- 定一套 Agent 的核心指标和 SLO,用多窗口燃烧速率告警,并做成本异常检测。
预计用时 5 小时。前置:Day 34(Langfuse 和 OTel 基础,GenAI 语义约定)、Day 52(成本账本)、Day 82–84(平台、网关、工具中心)。
先算账:trace 到底有多大
做任何采样决策之前,先估一下数据量。拿 Day 38 那张退款工单来算:一次运行大约 4 次模型调用,累计输入约 17,000 token、输出约 700 token,再加上检索、几次工具调用、几个工作流节点,大概 30 个 span。
元数据部分(span 名、时间、模型名、token 数、状态、租户、Agent 版本这些属性),每个 span 大约 1 KB,一次运行约 30 KB。
内容部分才是大头。按语义约定,每个模型调用的 span 上可以带 gen_ai.input.messages 和 gen_ai.output.messages,也就是完整的输入和输出。注意每次调用都会带上完整的对话历史,所以 4 次调用的输入加起来就是那 17,000 token。中文按每个 token 约 1.5 个字、每个字 3 字节(UTF-8)算,再加上工具返回结果和 JSON 结构的开销,一次运行的内容部分大约 100 KB。
一百万次运行每天:
元数据: 1,000,000 × 30 KB ≈ 30 GB/天
内容: 1,000,000 × 100 KB ≈ 100 GB/天
保留 30 天:元数据 0.9 TB,内容 3 TB
几个结论很直接:内容比元数据大三倍以上,而且内容是唯一有隐私风险的部分;元数据才是算指标、画看板需要的东西;真正需要回看原文的,只是出了问题的那一小部分运行。这就是今天所有设计的出发点:元数据多留、原文少留;正常的少留、异常的全留。
先把链路连起来
采样之前还要保证 trace 是完整的。平台化之后,一次运行会经过好几个服务:Agent 运行时、模型网关、工具中心、下游的 MCP server。如果每个服务各自起一个 trace,你在后端看到的是四段互不相关的碎片。
OpenTelemetry 用 W3C 的 traceparent 头在服务之间传递 trace 上下文。规则很简单:每个服务收到请求时读出上游的 traceparent,在它下面创建子 span;调用下游时再把自己的上下文写进 traceparent。
- Agent 运行时到模型网关:用 OpenAI SDK 调网关时,
opentelemetry-instrumentation-httpx会自动在 HTTP 头里加上traceparent;网关这边用opentelemetry-instrumentation-fastapi自动读取。昨天写的网关加两行初始化就能接上。 - 网关到模型供应商:供应商不会参与你的 trace,链路在网关这里结束,但网关的 span 里记下供应商、模型、token 数和耗时。
- Agent 到工具中心再到 MCP server:2026-07-28 版 MCP 规范明确了 trace 上下文通过请求
_meta里的traceparent、tracestate、baggage传递,所以即使工具调用走的是 stdio,链路也能接上。
还有一个很实用的做法:在 trace 的根 span 上放几个「业务属性」,比如 agentops.tenant、agentops.agent、agentops.agent_version、agentops.run_id,用 OTel 的 baggage 把它们传给下游,每个服务在自己的 span 上也记一份。这样不管从哪一段开始查,都能按租户和 Agent 版本过滤。
Agent span 统一叫什么
链路连起来以后还有一个问题:平台上的 Agent 不是同一个框架写的。有的团队用 LangGraph,有的用 OpenAI Agents SDK,有的直接手写循环,各家自带的埋点给 span 起的名字和属性都不一样。看板要按「哪个 Agent、调了哪个工具、花了多少 token」聚合,名字不统一,每接一个新团队就要改一遍查询。所以平台要规定一件事:所有 Agent 都按 OTel GenAI 语义约定里的 agent span 打点。
Day 34 那张表列了最常用的五种 span。这份约定在 2026 年 5 月搬进了独立仓库 semantic-conventions-genai,agent 相关的部分单独一个文件 docs/gen-ai/gen-ai-agent-spans.md。我 10 月去读的时候,文件头和每一种 span、每一个 gen_ai.* 属性的状态全都是 Development,没有一项是 Stable。平台规模下有几处 Day 34 没细讲、但会直接影响看板设计的规定:
invoke_agent 分两种 span kind。Agent 就在你的进程里跑(SupportPilot 的运行时、LangGraph 图),用 INTERNAL;调的是远端托管的 Agent(通过 A2A 调别的团队的 Agent,或者调 Bedrock 这类托管服务),用 CLIENT。这一点在平台上很有用:看板按 span kind 一筛,就能把「我们自己跑的」和「调别人的」分开算延迟和成本。远端 Agent 还应该带 gen_ai.agent.id,约定的说法是托管服务给的稳定标识,比如 Bedrock Agent 的 ARN。
版本用标准属性。约定里有 gen_ai.agent.version。上面「先把链路连起来」那一节我们自己定义了 agentops.agent_version,现在可以换成标准名字,或者过渡期两个都写,等看板和告警全切过去再删自定义的。
除了模型调用和工具调用,约定还给 Agent 内部的几个阶段定了操作名:plan(规划、拆任务)、create_agent、invoke_workflow,以及一组记忆操作 search_memory、upsert_memory、update_memory、delete_memory 等。Day 64 的记忆系统每次读写打一个 span,就能在看板上看到记忆检索占了多少延迟。
最新加进去的是 Agent Skills。约定把「加载一个 skill」也算成工具调用,span 名是 execute_tool {gen_ai.tool.name} {gen_ai.skill.name},另外还有读取 skill 资源文件、执行 skill 里脚本的 span,属性 gen_ai.skill.name 直接引用了 agentskills.io 的规范。文档里列举的实现包括 Google ADK 的 load_skill、OpenAI Agents SDK 沙箱的 exec_command、LangChain Deep Agents 的 execute。这部分刚加不久,变的可能性最大。
工具的参数和返回值在约定里是 gen_ai.tool.call.arguments 和 gen_ai.tool.call.result,两个都是 Opt-In,默认不采。这和下面「原文和元数据分开管」那一节是同一件事:它们属于原文,应该走带原文那一路管道,不该出现在元数据里。
下面是一次退款工单运行按约定手动打点的最小例子,不接真模型,只为核对 span 的名字、类型和层级:
# agent-60-lab/w13/obs/agent_spans.py
"""按 OTel GenAI 语义约定给一次 Agent 运行打 span(手动埋点版)。
依赖:uv add opentelemetry-sdk
"""
import json
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import SimpleSpanProcessor, SpanExporter, SpanExportResult
from opentelemetry.trace import SpanKind
class PrintExporter(SpanExporter):
"""只打印名字、类型和 gen_ai.* 属性,方便核对。"""
def export(self, spans):
for s in spans:
attrs = {k: v for k, v in s.attributes.items() if k.startswith("gen_ai.")}
parent = "root" if s.parent is None else "child"
print(f"{s.name:28} {s.kind.name:8} {parent:5} {json.dumps(attrs, ensure_ascii=False)}")
return SpanExportResult.SUCCESS
provider = TracerProvider()
provider.add_span_processor(SimpleSpanProcessor(PrintExporter()))
trace.set_tracer_provider(provider)
tracer = trace.get_tracer("supportpilot")
AGENT = {"gen_ai.agent.name": "refund_agent", "gen_ai.agent.version": "2026-10-20"}
def chat(model: str, turn: int) -> None:
with tracer.start_as_current_span(f"chat {model}", kind=SpanKind.CLIENT) as span:
span.set_attribute("gen_ai.operation.name", "chat")
span.set_attribute("gen_ai.provider.name", "deepseek")
span.set_attribute("gen_ai.request.model", model)
span.set_attribute("gen_ai.usage.input_tokens", 4100 + turn * 300)
span.set_attribute("gen_ai.usage.output_tokens", 120)
def execute_tool(name: str, call_id: str) -> None:
with tracer.start_as_current_span(f"execute_tool {name}", kind=SpanKind.INTERNAL) as span:
span.set_attribute("gen_ai.operation.name", "execute_tool")
span.set_attribute("gen_ai.tool.name", name)
span.set_attribute("gen_ai.tool.call.id", call_id)
span.set_attribute("gen_ai.tool.type", "function")
with tracer.start_as_current_span("invoke_agent refund_agent", kind=SpanKind.INTERNAL) as root:
root.set_attribute("gen_ai.operation.name", "invoke_agent")
root.set_attributes(AGENT)
root.set_attribute("gen_ai.conversation.id", "conv_T-10293")
chat("deepseek-flash", 0)
execute_tool("get_invoice", "call_01")
chat("deepseek-flash", 1)
execute_tool("request_refund_approval", "call_02")
chat("deepseek-flash", 2)
class PrintExporter(SpanExporter):自己写一个导出器,只打印要核对的字段。SDK 自带的ConsoleSpanExporter会把每个 span 打成一大段 JSON,五六个 span 就刷满一屏。attrs = {k: v for k, v in s.attributes.items() if k.startswith("gen_ai.")}只留gen_ai.开头的属性。平台自定义的agentops.*属性不在今天要核对的范围里。parent = "root" if s.parent is None else "child"s.parent是父 span 的上下文,根 span 没有父。用来确认工具和模型调用都挂在invoke_agent下面。provider.add_span_processor(SimpleSpanProcessor(PrintExporter()))Simple 处理器每结束一个 span 就立刻导出,适合本地核对;线上用BatchSpanProcessor攒一批再发。AGENT = {"gen_ai.agent.name": "refund_agent", "gen_ai.agent.version": "2026-10-20"}版本用标准属性gen_ai.agent.version,取值是 Agent 配置的发布日期。约定没规定版本格式,示例里给的就是1.0.0和2025-05-01两种。with tracer.start_as_current_span(f"chat {model}", kind=SpanKind.CLIENT) as span:模型调用是跨网络的请求,kind 是 CLIENT,名字是chat加模型名,结果就是chat deepseek-flash。span.set_attribute("gen_ai.provider.name", "deepseek")deepseek在约定的标准取值表里。span.set_attribute("gen_ai.usage.input_tokens", 4100 + turn * 300)假数据,模拟每轮对话历史变长、输入 token 递增:4100、4400、4700。with tracer.start_as_current_span(f"execute_tool {name}", kind=SpanKind.INTERNAL) as span:工具在本进程里执行,kind 是 INTERNAL。如果工具是调远端 MCP server,MCP 那一侧另有 span,按docs/gen-ai/mcp.md命名。span.set_attribute("gen_ai.tool.call.id", call_id)模型返回的tool_call_id,约定要求「模型给了就必须填」。线上排查时靠它把 trace 里的工具 span 和对话记录里的那一次调用对上。with tracer.start_as_current_span("invoke_agent refund_agent", kind=SpanKind.INTERNAL) as root:Agent 在本进程里跑,所以是 INTERNAL。如果这是网关在调另一个团队的远端 Agent,就要改成 CLIENT 并加上gen_ai.agent.id。root.set_attribute("gen_ai.conversation.id", "conv_T-10293")会话 id,一张工单多次运行用同一个值。看板按它聚合,就能看到一张工单前前后后一共花了多少。
用 opentelemetry-sdk 1.45.1 在本机跑(uv run --no-project --with opentelemetry-sdk python agent_spans.py),输出如下。根 span 最后才打印,因为它最后结束:
chat deepseek-flash CLIENT child {"gen_ai.operation.name": "chat", "gen_ai.provider.name": "deepseek", "gen_ai.request.model": "deepseek-flash", "gen_ai.usage.input_tokens": 4100, "gen_ai.usage.output_tokens": 120}
execute_tool get_invoice INTERNAL child {"gen_ai.operation.name": "execute_tool", "gen_ai.tool.name": "get_invoice", "gen_ai.tool.call.id": "call_01", "gen_ai.tool.type": "function"}
chat deepseek-flash CLIENT child {"gen_ai.operation.name": "chat", "gen_ai.provider.name": "deepseek", "gen_ai.request.model": "deepseek-flash", "gen_ai.usage.input_tokens": 4400, "gen_ai.usage.output_tokens": 120}
execute_tool request_refund_approval INTERNAL child {"gen_ai.operation.name": "execute_tool", "gen_ai.tool.name": "request_refund_approval", "gen_ai.tool.call.id": "call_02", "gen_ai.tool.type": "function"}
chat deepseek-flash CLIENT child {"gen_ai.operation.name": "chat", "gen_ai.provider.name": "deepseek", "gen_ai.request.model": "deepseek-flash", "gen_ai.usage.input_tokens": 4700, "gen_ai.usage.output_tokens": 120}
invoke_agent refund_agent INTERNAL root {"gen_ai.operation.name": "invoke_agent", "gen_ai.agent.name": "refund_agent", "gen_ai.agent.version": "2026-10-20", "gen_ai.conversation.id": "conv_T-10293"}
全部还是 Development 状态,对平台意味着什么?属性名随时可能改,Day 34 提过的 gen_ai.system 改名 gen_ai.provider.name 就是前车之鉴。我的做法是两条。第一,平台提供一个很薄的内部埋点库(比如 agentops-otel),各团队只调它的 agent_span()、tool_span(),不自己拼属性名;约定一改,只改这一个库。第二,在 Collector 里留一个 transform 处理器专门做新旧名字的映射,还没升级埋点库的老服务发出来的旧名字,在这里统一改成新名字再落库。看板和告警永远只认一套名字。
采样:头部还是尾部
采样有两种时机。
头部采样(head sampling):请求一进来就决定记不记,比如随机记 5%。实现简单、开销小,但它在决定时不知道这次运行会不会出错,所以出错的运行有 95% 的概率没被记下来,而那正是你最想看的。
尾部采样(tail sampling):先把一个 trace 的所有 span 收齐,看完整个 trace 再决定留不留。可以按「有没有错误、耗时多长、属性是什么」来决定。代价是 Collector 要把所有进行中的 trace 缓存在内存里等它们结束。
Agent 场景几乎一定要用尾部采样,因为你关心的那些运行(出错、转人工、评分低、特别贵)只有在运行结束后才知道。一套实用的规则是:
| 保留规则 | 依据 |
|---|---|
| 有任何 span 状态是 ERROR | 错误全留 |
| 整个 trace 耗时超过 30 秒 | 慢的全留(阈值按你的 P99 定) |
| 转了人工 | 业务意义上的失败 |
| 线上评测分数低于 0.6 | 质量问题 |
| 单次运行成本超过 0.05 美元 | 成本异常 |
| 指定租户(刚接入、或者在排查问题) | 临时全量 |
| 其余随机 5% | 当基线,用来对比「正常长什么样」 |
尾部采样的规则是「任意一条投了保留票就保留」,所以随机 5% 的那部分里也会包含一部分错误,这没关系。
下面是 Collector 的配置。它用了一个 forward 连接器把一条管道拆成两条:采样后的 trace 一份带原文发给 Langfuse(短保留),一份去掉原文发给长期存储(这里用 Grafana Tempo 举例)。
# agent-60-lab/w13/obs/otelcol.yaml
receivers:
otlp:
protocols:
grpc: {endpoint: 0.0.0.0:4317}
http: {endpoint: 0.0.0.0:4318}
processors:
tail_sampling:
decision_wait: 60s # Agent 运行比普通请求长,等久一点再决定
num_traces: 200000 # 内存里最多同时缓存的 trace 数
expected_new_traces_per_sec: 2000
decision_cache: # 决定做完后才到的晚到 span,按缓存的决定处理
sampled_cache_size: 500000
non_sampled_cache_size: 2000000
policies:
- name: errors
type: status_code
status_code: {status_codes: [ERROR]}
- name: slow
type: latency
latency: {threshold_ms: 30000}
- name: escalated
type: boolean_attribute
boolean_attribute: {key: agentops.escalated, value: true}
- name: low-eval-or-expensive
type: ottl_condition
ottl_condition:
error_mode: ignore
span:
- 'span.attributes["gen_ai.evaluation.score.value"] < 0.6'
- 'span.attributes["agentops.cost_usd"] > 0.05'
- name: debug-tenants
type: string_attribute
string_attribute: {key: agentops.tenant, values: [finance-onboarding]}
- name: baseline
type: probabilistic
probabilistic: {sampling_percentage: 5}
# 长期存储那一路:删掉原文,只留元数据
attributes/strip_content:
actions:
- {key: gen_ai.input.messages, action: delete}
- {key: gen_ai.output.messages, action: delete}
- {key: gen_ai.system_instructions, action: delete}
- {key: gen_ai.tool.call.arguments, action: delete}
- {key: gen_ai.tool.call.result, action: delete}
# 带原文那一路:原文留着,但遮掉手机号、身份证号这类值
redaction/pii:
allow_all_keys: true
blocked_values:
- '1[3-9][0-9]{9}' # 大陆手机号
- '[1-9][0-9]{16}[0-9Xx]' # 身份证号
summary: info
batch: {}
connectors:
forward/sampled: {}
exporters:
otlphttp/langfuse:
endpoint: ${env:LANGFUSE_OTEL_ENDPOINT} # Langfuse 的 OTLP 地址:你的 Langfuse 域名 + /api/public/otel
headers: {Authorization: "Basic ${env:LANGFUSE_AUTH}"}
otlp/tempo:
endpoint: tempo:4317
tls: {insecure: true}
service:
pipelines:
traces/in:
receivers: [otlp]
processors: [tail_sampling]
exporters: [forward/sampled]
traces/with_content:
receivers: [forward/sampled]
processors: [redaction/pii, batch]
exporters: [otlphttp/langfuse]
traces/metadata_only:
receivers: [forward/sampled]
processors: [attributes/strip_content, batch]
exporters: [otlp/tempo]
几个容易踩的坑。
数值条件为什么用 OTTL。 尾部采样的 numeric_attribute 策略只认整数,成本 0.05 美元和评测分数 0.6 都是小数,所以用 ottl_condition,它能比较浮点数。
长时间运行的 Agent。 decision_wait 是从这个 trace 的第一个 span 到达开始计时的。一个需要人工审批的工单可能挂好几个小时,远远超过 60 秒。解决办法有两个:一是 decision_cache,让决定之后才到达的 span 按缓存的决定处理;二是从设计上把长任务拆开,人工审批前后各算一个 trace,用 span link 或者 run_id 关联起来。Day 65 讲持久化执行时提过,断点续跑的任务本来就适合这样切。
多个 Collector 实例。 尾部采样要求同一个 trace 的所有 span 落到同一个 Collector 实例上,否则每个实例只看到一部分,决定就是错的。规模大了要加一层「负载均衡 Collector」,用 loadbalancing 导出器按 trace ID 把 span 分发到后面的采样层。
Langfuse 自己也有采样。 如果你只用 Langfuse 不用 Collector,它的 SDK 支持按比例采样(文档在文末),但那是头部采样,用来控制量可以,用来留住错误不行。
原文和元数据分开管
上面的配置已经把原文和元数据拆成了两路,这里把背后的原则说清楚。
原文默认不采,显式开启。 OTel 的 GenAI 语义约定对 gen_ai.input.messages 这类内容属性的建议是:默认不采集,由使用者明确开启,自动埋点库通常用 OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT 这样的环境变量控制。平台层面应该把这个开关做成按租户配置的,而且开启要有理由、有审批。
两路不同的保留期。 带原文的那一路保留 14 到 30 天就够了,足以覆盖问题的发现和排查周期;只有元数据的那一路可以保留半年到一年,用来看趋势和做容量规划。Day 86 会讲保留期和合规要求的关系。
脱敏在 Collector 里做最后一道。 应用层应该先做脱敏(Day 40 的 PII 处理),Collector 的 redaction 是兜底。正则只能抓格式固定的东西(手机号、身份证号、银行卡号),抓不住「我住在某某小区几号楼」这种。所以兜底归兜底,原文的访问权限和保留期才是主要防线。
看原文要有权限和审计。 带原文的 trace 后端要按租户控制访问权限,谁看了哪条 trace 也要记下来。很多团队在这一点上很松,看板账号全员共享,这在 Day 86 的安全问卷里过不了关。
Agent 的指标体系
trace 是用来查单个问题的,指标是用来看全局和告警的。一个 Agent 平台我建议至少有这几组指标,都要能按租户、Agent、Agent 版本、模型组拆开看:
| 类别 | 指标 | 怎么得到 |
|---|---|---|
| 结果 | 任务成功率、转人工率、用户反馈(赞踩)、线上评测分 | 运行结束时打点;评测分来自 W11 的线上评测 |
| 延迟 | 端到端 P50/P95/P99、首 token 延迟、每步模型调用和工具调用耗时 | span 时长,或直方图指标 |
| 成本 | 单任务成本、每小时总成本、缓存命中率 | 网关账本(Day 83) |
| 可靠性 | 模型调用错误率、降级率、熔断次数、工具错误率 | 网关和工具中心打点 |
| 安全 | 护栏触发率、被拒绝的工具调用、需要审批的次数 | 工具中心审计(Day 84) |
小心标签基数(cardinality)。 Prometheus 这类指标系统里,每一种标签值组合都是一条独立的时间序列。租户 40 个、Agent 25 个、模型 6 个、结果 4 种,一个指标就是 24,000 条序列,还能接受。如果你顺手把 user_id 或 run_id 加成标签,序列数会变成几百万,指标系统会直接扛不住。规则是:指标的标签只放取值有限的维度,高基数的东西(用户、运行、工单号)放在 trace 和日志里。下面的代码里有一个算序列数的小函数,加标签之前先算一下。
SLO 和燃烧速率告警
指标有了,下一步是告警。最常见的做法是固定阈值:「转人工率超过 10% 就报警」。这有两个问题:阈值定低了天天误报,大家很快就不看了;定高了真出事时报得太晚。而且它不区分「突然飙升」和「慢慢变差」这两种完全不同的情况。
Google SRE 的做法是先定 SLO(服务等级目标),再根据错误预算的燃烧速率告警。拿工单 Agent 举例:
- SLO:95% 的工单不需要转人工返工(这就是「好事件」的定义)。
- 错误预算:30 天里允许 5% 的工单出问题。
- 燃烧速率:当前错误率除以允许的错误率。燃烧速率为 1 表示刚好在 30 天末用完预算;为 14.4 表示照这个速度 2 天多就会用完。
SRE Workbook 第 5 章推荐的多窗口告警是:1 小时内燃烧速率超过 14.4(相当于 1 小时花掉月预算的 2%)且最近 5 分钟也超过,就呼人;6 小时内超过 6 且最近 30 分钟也超过,也呼人;3 天内超过 1 且最近 6 小时也超过,开工单慢慢查。长窗口防止一时的抖动造成误报,短窗口保证问题恢复后告警能及时消失,不会挂几个小时。
写成 Prometheus 规则大概是这样(假设你有一个按结果打标签的计数器 agentops_task_total):
# agent-60-lab/w13/obs/alerts.yaml
groups:
- name: agentops-slo
rules:
- alert: AgentTaskSuccessFastBurn
expr: |
(
sum by (tenant, agent) (rate(agentops_task_total{outcome!="success"}[1h]))
/ sum by (tenant, agent) (rate(agentops_task_total[1h]))
) / (1 - 0.95) > 14.4
and
(
sum by (tenant, agent) (rate(agentops_task_total{outcome!="success"}[5m]))
/ sum by (tenant, agent) (rate(agentops_task_total[5m]))
) / (1 - 0.95) > 14.4
labels: {severity: page}
annotations:
summary: "{{ $labels.tenant }}/{{ $labels.agent }} 1 小时内烧掉了 2% 以上的月度错误预算"
另外两条规则(6 小时 / 30 分钟、3 天 / 6 小时)照样写,只改窗口和阈值。
同样的逻辑我用 Python 写了一遍,方便你在没有 Prometheus 的情况下理解和测试,顺带加了成本异常检测和标签基数计算:
# agent-60-lab/w13/obs/slo.py
from dataclasses import dataclass
@dataclass
class Window:
good: int
total: int
@property
def error_rate(self) -> float:
return 0.0 if self.total == 0 else 1 - self.good / self.total
def burn_rate(w: Window, slo: float) -> float:
"""燃烧速率 = 实际错误率 / 允许的错误率。1 表示刚好在 30 天里把错误预算花完。"""
return w.error_rate / (1 - slo)
# SRE Workbook 第 5 章推荐的多窗口组合:(长窗口, 短窗口, 阈值, 级别)
RULES = [
("1h", "5m", 14.4, "page"), # 1 小时烧掉 2% 的月预算
("6h", "30m", 6.0, "page"), # 6 小时烧掉 5%
("3d", "6h", 1.0, "ticket"), # 3 天烧掉 10%
]
def evaluate(windows: dict[str, Window], slo: float) -> list[str]:
"""长短两个窗口都超阈值才报警:长窗口防误报,短窗口让恢复后告警及时消失。"""
fired = []
for long_w, short_w, threshold, severity in RULES:
if (burn_rate(windows[long_w], slo) >= threshold
and burn_rate(windows[short_w], slo) >= threshold):
fired.append(f"{severity}: burn>={threshold} over {long_w}/{short_w}")
return fired
def cost_anomaly(this_hour: float, same_hour_last_4_weeks: list[float], factor: float = 2.0,
floor_usd: float = 5.0) -> bool:
"""成本异常:比过去四周同一小时的中位数高 factor 倍,而且绝对值过了地板价才报。"""
base = sorted(same_hour_last_4_weeks)[len(same_hour_last_4_weeks) // 2]
return this_hour >= floor_usd and this_hour >= factor * max(base, 0.01)
def series_count(label_values: dict[str, int]) -> int:
"""一个指标会产生多少条时间序列:各标签取值个数相乘。"""
n = 1
for v in label_values.values():
n *= v
return n
# agent-60-lab/w13/obs/test_slo.py
from slo import Window, burn_rate, cost_anomaly, evaluate, series_count
SLO = 0.95 # 「任务成功」的 SLO:95% 的工单不用转人工返工
def test_burn_rate_math():
assert abs(burn_rate(Window(good=900, total=1000), SLO) - 2.0) < 1e-9 # 错 10%,允许 5%
def test_fast_burn_pages_only_when_short_window_agrees():
# 1 小时错 80%、最近 5 分钟错 90%:两个窗口的燃烧速率都超过 14.4
bad = {"1h": Window(200, 1000), "5m": Window(10, 100), "6h": Window(5000, 6000),
"30m": Window(400, 500), "3d": Window(90000, 100000)}
fired = evaluate(bad, SLO)
assert any(f.startswith("page") for f in fired)
recovered = dict(bad, **{"5m": Window(99, 100), "30m": Window(495, 500), "6h": Window(5950, 6000)})
assert not any(f.startswith("page") for f in evaluate(recovered, SLO))
def test_cost_anomaly_and_cardinality():
assert cost_anomaly(30.0, [8, 9, 10, 12])
assert not cost_anomaly(3.0, [0.5, 0.5, 0.6, 0.4]) # 翻了好几倍,但没过地板价
assert series_count({"tenant": 40, "agent": 25, "model": 6, "outcome": 4}) == 24000
运行 uv run pytest -q w13/obs,我本机是 3 passed。
成本告警为什么不用燃烧速率。 成本没有「好事件和坏事件」,更适合和历史比:这个小时的花费是过去四周同一个小时中位数的两倍以上,并且绝对值超过一个下限(防止深夜几毛钱的波动也报警),就报警。用「同一个小时」比较是因为 Agent 的流量有明显的日内和周内周期,周一上午十点和周日凌晨三点没有可比性。Day 87 会讲,成本暴涨是 Agent 事故里很常见的一类,通常是某个循环出了问题,早发现一小时就少烧一小时的钱。
质量告警。 线上评测分数的移动平均下降、用户点踩率上升,同样可以套燃烧速率的框架(把「评测分低于阈值」定义为坏事件)。这一类告警最容易漏掉,因为系统没有报错、延迟也正常,只是回答变差了,往往是模型供应商静默更新或者某次 prompt 改动引起的。
看板长什么样
一个平台级的看板,我的建议是分三层,从上往下越来越细:
- 第一屏:平台整体。 今天的运行数、成功率、P95 延迟、总成本,各自和上周同期比;正在触发的告警;按租户的成本排行。值班的人打开看板,五秒钟要能回答「现在有没有事」。
- 第二屏:单个 Agent。 选中一个 Agent,看它的成功率、转人工率、评测分、延迟分布、单任务成本的时间曲线,按 Agent 版本分开画,这样一次发布前后的变化一眼就能看出来。
- 第三屏:下钻。 从曲线上点一个异常的时间点,跳到那段时间被采样下来的 trace 列表(带原文的那一路),再点进单个 trace。
最后这个「从指标跳到 trace」的能力很关键。Grafana 里叫 exemplars:在直方图指标上附带几个对应 trace 的 ID,点一下就过去。没有这个,看板和 trace 是两个孤岛,排查时要手动对时间、对租户。
还有一条:平台自己也要有 SLO。Day 82 说过,平台是所有 Agent 的依赖。模型网关的可用性、额外延迟,工具中心的可用性,trace 管道的数据延迟(从 span 产生到能查到要多久),都要有目标和告警,而且目标要比业务 Agent 的更严。
动手练习
第一步:估算你自己的数据量(30 分钟)。 用 Day 34 接好的 Langfuse,导出 20 条真实 trace,算出每次运行的 span 数、元数据大小、原文大小。按「每天 10 万次运行、原文保留 30 天、元数据保留 180 天」算出存储总量。再算一遍按今天的采样规则(假设 3% 错误、2% 慢、4% 转人工、5% 基线,有重叠,合计约 12%)之后的量。
第二步:跑通 Collector 两路管道(2 小时)。 本机需要 Docker。用今天的 otelcol.yaml,把 Tempo 换成一个 debug 导出器方便观察(exporters: {debug: {verbosity: detailed}}),启动 otel/opentelemetry-collector-contrib 镜像。让你的工单 Agent 把 OTLP 发到 Collector。造几个场景:一次正常运行、一次工具抛异常、一次把 agentops.escalated 设成 true。在 debug 输出里确认:正常运行大多数不出现(只有 5% 的概率);异常和转人工的一定出现;metadata 那一路里没有 gen_ai.input.messages;带原文那一路里的手机号被遮掉了。
第三步:让链路穿过网关(1 小时)。 给 Day 83 的网关加上 FastAPI 和 httpx 的自动埋点,确认在后端看到的是一条完整的链:Agent 的根 span 下面挂着网关的 span。
第四步:写告警规则(1 小时)。 跑通 w13/obs 的 3 个测试。然后补全 alerts.yaml 的另外两条燃烧速率规则,加一条成本异常规则和一条网关自身可用性规则。
验收标准:
- 有一张数据量估算表,包括采样前后的对比。
- Collector 的 debug 输出能证明四件事:异常 trace 全留、正常 trace 按比例留、元数据路没有原文、原文路做了脱敏。
- 后端里能看到一条从 Agent 到网关的完整 trace。
alerts.yaml里有三条燃烧速率规则、一条成本规则、一条平台自身的规则,每条都写清楚了触发后谁处理。
名词解释
| 名词 | 意思 |
|---|---|
| traceparent | W3C 定义的 HTTP 头,在服务之间传递 trace ID 和父 span ID |
| baggage | OTel 在服务间传递业务上下文(如租户、Agent 版本)的机制 |
| 头部采样 / 尾部采样 | 请求开始时就决定记不记 / 等整个 trace 结束后看完再决定 |
| OTel Collector | OpenTelemetry 的数据管道组件,负责接收、处理(采样、脱敏)、转发遥测数据 |
| forward 连接器 | Collector 里把一条管道的输出接到另一条管道输入的组件,用来一分为多 |
| 标签基数(cardinality) | 一个指标所有标签值组合的数量,决定时间序列的条数 |
| SLO | 服务等级目标,例如「95% 的工单不需要转人工返工」 |
| 错误预算 | SLO 允许的失败额度,例如 30 天内 5% |
| 燃烧速率(burn rate) | 实际错误率除以允许错误率,表示错误预算消耗的快慢 |
| exemplar | 附在指标数据点上的 trace ID,用于从指标直接跳到对应 trace |
| agent span | GenAI 语义约定里描述 Agent 行为的 span,如 invoke_agent、plan、execute_tool,状态仍是 Development |
| Opt-In 属性 | 语义约定里默认不采集、需要使用者显式开启的属性,如工具参数和返回值 |
常见误区
全量记录,等出了问题再说。 规模上去之后,存储和观测平台的费用会超过模型费用,而且查询变慢,真出事时反而用不上。按数据量估算提前设计采样。
用头部随机采样控制量。 头部采样在决定时不知道这次会不会出错,错误的运行大部分会被丢掉。Agent 场景用尾部采样,把错误、慢、转人工、低分、高成本的全部留下。
原文和元数据一起保留一年。 原文是存储大头,也是唯一有隐私风险的部分。原文短保留、严格权限,元数据长保留,分开两路处理。
把 user_id、run_id 加成指标标签。 时间序列数会爆炸,指标系统直接被拖垮。高基数的维度放 trace 和日志,指标只放有限取值的标签。
固定阈值告警。 定低了天天误报没人看,定高了报得太晚,还分不清突发和缓慢变差。基于 SLO 用多窗口燃烧速率告警。
只盯错误率和延迟,没有质量告警。 Agent 最隐蔽的故障是「没报错但回答变差了」,要用线上评测分和用户反馈做告警。
学习资料
文档与文章
- OpenTelemetry · GenAI 语义约定:官方文档入口;规范本身已经搬到独立仓库 semantic-conventions-genai,看
docs/registry/attributes/gen-ai.md里内容类属性的采集建议,以及新增的gen_ai.evaluation.*评测属性。1 小时。 - Tail Sampling Processor README:所有策略类型和参数的权威说明,重点看
decision_cache和sampling_strategy。40 分钟。 - OpenTelemetry 博客 · Tail Sampling:为什么要尾部采样、部署上要注意什么(负载均衡层)。15 分钟。
- Redaction Processor README:
allowed_keys的「默认拒绝」设计和blocked_values正则。20 分钟。 - Google SRE Workbook · Alerting on SLOs:燃烧速率告警的出处,从最简单的阈值告警一步步推导到多窗口多燃烧速率,强烈建议完整读一遍。1 小时。
- Google SRE Book · Monitoring Distributed Systems:四个黄金信号和「告警要针对症状而不是原因」。40 分钟。
- Langfuse · Sampling:Langfuse SDK 层面的采样配置。10 分钟。
B 站视频
其他经典视频
上面那个 Liudmila Molkova 的演讲也在这一组。
书籍
- Charity Majors、Liz Fong-Jones、George Miranda《Observability Engineering》(O’Reilly, 2022;2026 年 6 月出了第 2 版,章节号可能不同):第 1 版第 17 章「Cheap and Accurate Enough: Sampling」专讲采样,包括按内容采样和动态采样,直接对应今天的尾部采样。1.5 小时。
- 《SRE:Google 运维解密》(Site Reliability Engineering,O’Reilly 2016;中文版电子工业出版社):第 4 章服务质量目标、第 6 章分布式系统的监控。英文版在 sre.google 免费在线阅读。1.5 小时。
开源项目与代码
- open-telemetry/opentelemetry-collector-contrib:今天用到的
tailsamplingprocessor、redactionprocessor、attributesprocessor都在这里,遇到配置问题直接看对应目录的 README 和测试用例。按需查阅。 - open-telemetry/semantic-conventions-genai:GenAI 和 MCP 的语义约定,
docs/gen-ai/mcp.md讲 MCP 调用的 span 怎么命名。30 分钟。
自测
- 一个平台每天 50 万次运行,每次 40 个 span,元数据每 span 1 KB,原文每次运行 120 KB。原文保留 14 天、元数据保留 180 天,全量记录需要多少存储?如果尾部采样留下 10%,原文那一路省了多少?
答案
元数据:50 万 × 40 KB = 20 GB/天,180 天约 3.6 TB。原文:50 万 × 120 KB = 60 GB/天,14 天约 840 GB。合计约 4.4 TB。采样留 10% 之后(两路都在采样之后),原文降到约 84 GB,省了约 756 GB;元数据也降到约 360 GB。如果元数据想全量保留用于精确统计,可以只对原文那一路采样,元数据走另一条不采样的管道,或者干脆用指标代替。
- 为什么 Agent 场景用尾部采样而不是头部采样?尾部采样在部署上有什么额外要求?
答案
头部采样在请求开始时就决定,那时不知道这次运行会不会出错、转人工或者特别贵,而这些正是最需要保留的 trace,按比例随机采样会丢掉大部分。尾部采样看完整个 trace 再决定。额外要求:Collector 要在内存里缓存进行中的 trace,需要按吞吐量设置 num_traces 等参数;同一个 trace 的所有 span 必须到达同一个采样实例,规模大时要在前面加按 trace ID 分发的负载均衡层;长时间运行的 trace 要用 decision_cache 处理晚到的 span,或者从设计上把长任务拆成多个 trace。
- SLO 是 95% 的工单不需要返工。过去 1 小时有 12% 的工单返工,最近 5 分钟有 15%。燃烧速率是多少?会不会触发呼叫级告警?
答案
允许错误率 5%。1 小时燃烧速率 12% / 5% = 2.4,5 分钟 15% / 5% = 3。都远低于 14.4,不会触发 1 小时窗口的呼叫告警。要看 6 小时窗口:如果 6 小时平均也在 10% 以上(燃烧速率 2),也达不到 6。如果持续 3 天燃烧速率超过 1,会触发工单级告警。这说明问题存在但不紧急,工作时间处理即可。
- 同事想在任务计数器上加一个
customer_id标签,方便按客户看成功率。你怎么回应?
答案
不建议。客户数可能成千上万,每个客户和其他标签组合都会产生新的时间序列,序列数会爆炸,拖垮指标系统。按客户看成功率应该从 trace 或者结构化日志、数据仓库里查,或者只对少数重点客户单独打一个低基数的标签(比如客户等级)。原则是指标标签只放取值有限的维度。
- 带原文的 trace 管道里,Collector 的 redaction 能不能保证没有个人信息泄露?还需要什么措施?
答案
不能。正则只能抓格式固定的东西(手机号、身份证号、银行卡号),地址、姓名、自由描述的病情等都抓不住。还需要:应用层先脱敏(Day 40);原文默认不采集,按租户审批开启;带原文的数据保留期短;后端按租户控制访问权限,看原文要有审计;Day 86 讲的数据分级和删除流程要覆盖 trace。
- 资深面试题:你负责的 Agent 平台每天 200 万次运行,观测费用已经是模型费用的 1.3 倍,老板让你一个月内降到 30% 以内,同时不能影响排查问题。你的方案是什么?
答案
先测量:按服务和属性拆开看数据量,通常原文占大头,重复的完整对话历史又是原文的大头。方案分四块。一是原文和元数据分离:原文默认不采,按租户开启;采了的只保留 14 天;元数据长期保留。二是尾部采样:错误、慢、转人工、低评测分、高成本、指定租户全留,其余 3–5% 基线,预计留下 10% 左右;部署负载均衡层保证同一 trace 落到同一采样实例,处理长任务的晚到 span。三是能用指标的不用 trace:看板和告警全部基于指标(从网关账本和运行结果打点),不再从 trace 里实时聚合。四是存储分层:近期热存储、历史冷存储。验证不影响排查:上线前回放过去一个月的 10 个真实事故,确认用采样后的数据都能定位根因;上线后跟踪排查时间这个指标。要提到风险和取舍:采样会让少量罕见问题的样本不足,用「指定租户临时全量」和动态调整采样率补;原文少了,线上评测要改成在线实时打分并把分数写进元数据,而不是事后从原文里重算。
- 平台上有 A2A 调用:你们的退款 Agent 会调财务团队部署的对账 Agent。两边的
invoke_agentspan 各自应该是什么 kind?看板怎么用这个区别?
答案
退款 Agent 自己在进程里跑,它的 invoke_agent 是 INTERNAL;它调对账 Agent 的那一次是一个 CLIENT 类型的 invoke_agent span,带上对方的 gen_ai.agent.name 和稳定的 gen_ai.agent.id。财务团队那边收到请求后,在自己的进程里再打一个 INTERNAL 的 invoke_agent,通过 traceparent 挂在同一条 trace 下。看板按 kind 筛选,就能把「自己跑的耗时和成本」和「等别的 Agent 的耗时」分开,依赖方变慢时一眼能看出来是谁的问题。
明天预告
Day 86 讲治理与合规:企业客户买你的 Agent 之前会发来一份几十道题的安全问卷,国内面向公众的生成式 AI 服务要过备案或登记,生成的内容要按 2025 年 9 月起施行的办法加标识,调用海外模型还牵涉数据出境,欧盟的 AI Act 在 2026 年 7 月刚刚调整了时间表。明天把这些要求逐条翻译成技术方案:数据分级、保留期、删除请求怎么在所有存储里执行到底、审计留存多久,并动手实现一个覆盖全部存储的数据删除流程。
评论