[控场AI]
· 12 分钟阅读· 6,207 字

Agent追踪采样:以Run为粒度保留失败链路

Agent追踪采样:以Run为粒度保留失败链路

将追踪采样的单元从Span提升到Run,无条件钉住异常运行,避免丢掉最需要的故障现场。

文章指出,在Span粒度做独立采样会破坏追踪的因果关系,产生孤儿Span导致链路无法重放;而故障本身稀有且相关,5%采样率通常只能捕获故障运行的5%内容,恰是诊断价值最高的那批Span被状态过滤器标记后仍被随机丢弃。解决方案是把采样单元从Span提升到Run,采用Full/Summary/Anomaly-pinned三种存储模式,配合基于run_id哈希的确定性选择和不可猜测的SALT,保证运行要么完整记录要么完整摘要。核心不变式是"异常运行永不被摘要化",由CI单元测试保障,并要求检测采样率退化为0%或100%的沉默Bug。作者建议先用一天归档数据测量pinned_runs/total_runs(<2%)和kept_bytes/total_bytes(<15%)两项比率再采纳。

采样级别的错误:你丢掉的正是你最需要的

凌晨09:12,值班页面响了。一个结算Agent连续四十分钟返回HTTP 500。

打开追踪存储,里面有两周的Span数据。出问题的那次运行,不在其中。

采集器把无聊的Span留下了,把有价值的Span丢了。这是经典的采样反模式,本质是数据留存策略的问题,而非链路追踪能力的问题。

Agent追踪采样策略示意

大多数团队在Span粒度做采样,用一枚硬币独立决定每个Span的去留。这直接破坏了因果关系——工具调用被保留了,它的父级LLM调用却没有,你无法在有空洞的链路上做重放。

更糟的是,失败是稀有且相关的。5%的Span采样率大约只能捕获故障运行的5%内容,而那些真正有诊断价值的叶子Span,恰好是状态过滤器已经标记出来的那批。你付了5%的存储成本,却丢掉了95%的可调试性。你最关心的事故,恰恰是你存得最少的那次。

Span与Run的概念区分

在分布式追踪中,Trace代表一次完整的端到端请求流程,Run通常指Agent执行的一次完整任务生命周期(例如一次用户会话或一次工具编排周期),而Span是Trace内部的最小工作单元,对应一次具体的操作调用(如一次LLM推理、一次工具调用、一次数据库查询)。一个Run往往包含数十甚至数百个Span,它们通过parent_id构成树状的因果关系。

关键的工程含义在于:采样的粒度决定了可重放性的边界。如果以Span为单元独立采样,父Span被丢弃而子Span被保留时,因果链会出现空洞——你看到了一次工具调用,却看不到触发它的那次LLM决策。而Run是天然的原子单位:它有明确的起止、清晰的状态、稳定的标识符(run_id)。把Run当作采样单元,意味着留存决策在语义上是一个整体判断,而不是对每个操作抛硬币。这正是后文"以Run为整体"设计的前提。

改变采样单元:以Run为整体

解决方案是把采样单元从Span提升到Run。一次运行要么被完整记录,要么被完整摘要,不存在部分运行,也不存在孤儿Span。

作者提出三种存储模式已足够覆盖场景:

  • Full(完整):每个Span、每个载荷、精确字节、序列号完整保留。
  • Summary(摘要):每个Span一条有损记录,包含ID、类型、状态、耗时、Token数、字节数,不保留载荷。
  • Anomaly-pinned(异常钉住):与Full相同,但永久豁免于采样,因为该运行已经证明自己会出问题。

在此基础上加一条覆盖随机性的规则:任何触及已知异常的运行,无论采样率如何,都保留完整细节。

三种存储模式的工程定位

这三种模式并非互斥的选项,而是同一策略在不同运行状态下的分层输出。

Full模式承担的是"法医证据"角色——它必须保留精确字节和序列号,因为事故复盘时需要逐字节比对载荷、还原执行顺序、验证时间戳因果性。任何有损压缩都会让重放失去意义。

Summary模式承担的是"统计与趋势"角色。它保留ID、类型、状态、耗时、Token数和字节数这些标量字段,足以回答"这段时间延迟是否上升""哪个工具的调用量激增""成本分布如何变化",但无法回答"那次调用的具体参数是什么"。它便宜,因此可以覆盖绝大多数干净运行并长期保留。

Anomaly-pinned模式则是这套策略的安全网。它不依赖采样率,而是由运行自身的历史证据(出现了error或retry等状态)触发永久豁免。这里"永久"是相对于采样规则而言的——运行本身仍受保留期约束(如决策表中的30天),但不会被采样率随机丢弃。理解这一点很重要:钉住解决的是"是否被选中",保留期解决的是"存多久",两者是正交的维度。

一张可以争论的决策表

Span或Run类别决策保留期原因
Run含任意error Spanfull30天根因证据
任意Span状态为retryfull30天重试风暴隐藏真实Bug
Span带截断标记full14天载荷已被裁剪
Span超出延迟p99预算full14天回归证据
干净Run,哈希选中full7天用于Diff的基线
干净Run,未选中summary90天趋势线与成本核算
健康检查Span不存0纯噪声

表中有两个反直觉之处。干净运行以摘要形式保留最久,因为它们便宜,且能回答趋势问题;故障运行以完整形式保留最短,因为修复会很快到来。

采样器实现:约四十行代码

以下是一个提案,并非在你的环境中基准测试过的结果。在生产流量上使用前,请先对归档追踪数据跑一遍。


**哈希采样与SALT的作用**

`selected_for_full`函数的核心是`hashlib.sha256((SALT + run_id).encode())`再做取模分桶。这种确定性哈希采样有两个不可替代的性质。

第一是**幂等性**:同一个run_id在任意进程、任意重试、任意时间点都会得到相同的判定结果。如果用`random.random()`做独立随机采样,那么同一次运行在采集器进程崩溃重启后,可能第一次被判定保留、第二次被判定丢弃,最终导致运行被部分存储——这正是策略要消灭的"孤儿Span"根源。

第二是**不可预测性**,而这正是SALT存在的意义。没有SALT时,任何人都可以用公开的哈希算法对某个已知run_id计算出它是否被保留。在共享存储、多租户或对外的调试平台上,这会让攻击者或误操作的工程师有针对性地判断"这次运行值不值得保留",甚至逆向推算出哪些运行进入了完整模式。SALT把一个可公开验证的算法变成只有运维方知道的判定函数,而哈希的均匀性保证它不会引入采样偏差。注意SALT必须通过环境变量注入而非硬编码,否则它同样会被源码泄露,安全性与可复现性将同时失效。
# sampler.py — 提案代码;请先在自己的归档上测量
import hashlib
import os

SALT = os.environ.get("TRACE_SAMPLE_SALT", "change-me")
ANOMALY_STATUSES = {"error", "retry", "truncated", "budget_exceeded"}

def is_anomalous_span(span: dict) -> bool:
    if span.get("status") in ANOMALY_STATUSES:
        return True
    return bool(span.get("truncated"))

def run_is_pinned(run: dict) -> bool:
    return run.get("anomaly_count", 0) > 0

def selected_for_full(run_id: str, success_sample_rate: float = 0.05) -> bool:
    """基于run_id的确定性选择。采样整个Run,而非单个Span。"""
    digest = hashlib.sha256((SALT + run_id).encode()).hexdigest()
    bucket = int(digest[:8], 16) % 10_000
    return (bucket / 10_000.0) < success_sample_rate

def store_mode(run: dict) -> str:
    if run_is_pinned(run):
        return "full"
    if selected_for_full(run["run_id"]):
        return "full"
    return "summary"

两个设计选择值得注意。

一是基于run_id哈希,而非随机抽签。同一次运行在每个进程、每次重试、每次重启后都得到相同判定。随机抽签会让留存策略不可复现。

二是从环境变量读取SALT。没有它,任何人都可以算出你保留了哪些运行;有了它,选择既稳定又不可猜测。

唯一重要的测试

一条不变式保护整个策略:异常运行永远不会被摘要掉。


**为什么第三个测试最微妙**

`test_selection_is_not_all_or_nothing`断言一万个run_id中约有5%(区间300至700)被选中。它的价值不在于验证采样率的精确数值,而在于检测**沉默的策略失效**。

一个常见的Bug形态是:SALT变更导致所有哈希落在同一个桶区间,或者取模基数从10000被误改为1,或者`bucket / 10_000.0`里的浮点除法被整型除法替换——这些改动都不会抛异常,都不会让任何其他测试失败,却会让采样率悄悄变成0%或100%。0%意味着你丢掉了所有基线,Diff工具无数据可用;100%意味着存储成本瞬间翻二十倍。两种极端都要等到下一次事故复盘、或者账单到来时才被发现。

`test_selection_is_stable_across_calls`则是确定性哈希的守卫:它对同一个run_id调用100次并断言结果集合大小为1。如果将来有人把哈希替换成`random`,这个测试会立刻失败。三个测试组合起来,覆盖了策略的三个关键性质——钉住、可复现、分布正确,而它们加起来的代码量不到二十行,这正是把它们放进CI的理由。
# test_retention.py — 提案;接入CI
def test_anomalous_runs_are_never_summarized():
    for status in ("error", "retry", "truncated", "budget_exceeded"):
        run = {"run_id": "r-1", "anomaly_count": 1, "status": status}
        assert store_mode(run) == "full"

def test_selection_is_stable_across_calls():
    a = [selected_for_full("r-42") for _ in range(100)]
    assert len(set(a)) == 1

def test_selection_is_not_all_or_nothing():
    kept = sum(selected_for_full(f"r-{i}") for i in range(10_000))
    assert 300 < kept < 700  # 约5%,方差合理

第三个测试最微妙。一个Salt或模数Bug可以悄无声息地把采样率推到0%或100%,而没人会注意到,直到一次事故复盘。

流水线运行在哪里

采集器和摘要器都很小、周期性运行、CPU密集。这很适合免费服务器方案,因为不用为任务间隙的空闲容量付费。

作者在文中披露,该文章是MonkeyCode产品推广活动的一部分。MonkeyCode是一个开源项目,提供免费模型访问,运营方列出了1000万Token免费额度以及免费服务器选项。建议在使用前核实当前条款。

模型访问在这里有实际作用。完整载荷存储昂贵,一行运行摘要则不贵。因此为每次运行都生成摘要,而不只是被采样的那些,并永久存储在摘要记录旁边。大致如下:

输入:run r-8891 的 40 条摘要记录
输出:一句话——该运行尝试了什么、什么发生了变化、什么失败了

这条摘要就是值班工程师在09:12首先读到的东西。每次运行花费几百Token,这正是免费额度能改变你负担能力的原因。

两个警告:不要把无法分享的载荷内容放进第三方模型调用;不要把免费层当作记录系统。请自行检查当前限制和留存条款。

信任策略之前先测量

不要凭感觉采纳这套方案。对一天归档追踪数据计算比率:

# 总字节数和运行数
wc -c < traces.jsonl
wc -l < traces.jsonl

# 异常运行占用的字节数(近似:含error行的运行)
jq -c 'select(.status == "error") | .run_id' traces.jsonl \
  | sort -u > pinned.txt
rg -F -f pinned.txt traces.jsonl | wc -c

然后报告两个数字:pinned_runs / total_runs和kept_bytes / total_bytes。

作者建议的验收标准如下,你可以替换为自己的:

  • 稳态下pinned_runs / total_runs低于2%。
  • 摘要化后kept_bytes / total_bytes低于15%。
  • 零个运行缺少已存储的摘要。

如果pinned_runs远高于错误率,说明异常规则太宽泛。先修规则,再降存储账单。

局限性与适用边界

  • 摘要无法回答未预料的问题。没记录的字段以后查不到。只有在能证明字节数合理时才向摘要Schema添加字段。
  • 采样破坏了未选中运行的精确重放。采样率要足够高,确保Diff基线存在。
  • 哈希采样可能与ID方案相关。如果run_id以租户前缀开头,某些租户会被采样更多。仅靠Salt无法解决,需要对完整ID哈希或重新分桶。
  • 延迟预算需要热基线。在休眠的免费服务器上,空闲后首个请求会拉高耗时。排除冷启动Span,否则p99规则会钉住一切。
  • 不适用于审计或法律保留。采样策略是工程选择,不是合规记录。

如果你的Agent每天运行不到几千次,存储便宜,全量追踪更简单,跳过采样器。如果你受留存义务约束,保留一切,从别处解决成本。如果你的失败恰好是会被采样掉的那类——比如状态干净的静默错误答案——应基于结果信号而非状态码来钉住。

从一天流量开始

采样运行,而非Span。无条件钉住异常。为每次运行存储模型生成的摘要。

取一天归档追踪数据,在其上运行采样器,报告两个比率和三条不变式。如果数字成立,你就在不丢失09:12所需那次运行的前提下,缩小了存储。

#Agent追踪采样#链路追踪存储优化#分布式追踪#Span采样#AI Agent可观测性#异常钉住采样#LLM调用链追踪
分享:

相关推荐