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丢了。这是经典的采样反模式,本质是数据留存策略的问题,而非链路追踪能力的问题。

大多数团队在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 Span | full | 30天 | 根因证据 |
任意Span状态为retry | full | 30天 | 重试风暴隐藏真实Bug |
| Span带截断标记 | full | 14天 | 载荷已被裁剪 |
| Span超出延迟p99预算 | full | 14天 | 回归证据 |
| 干净Run,哈希选中 | full | 7天 | 用于Diff的基线 |
| 干净Run,未选中 | summary | 90天 | 趋势线与成本核算 |
| 健康检查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所需那次运行的前提下,缩小了存储。
相关推荐

LLaMA-Factory实战:零代码微调大模型全流程
本文介绍LLaMA-Factory开源微调工具,涵盖其60.1K Star项目背景、支持的模型与算法、本地与云服务器部署选择,以及CUDA环境安装步骤,帮助零基础开发者快速上手大模型微调。

曾鸣深度复盘:OpenAI为何难成终局赢家?公司制度将消亡
曾鸣在张晓骏商业访谈录中深度复盘AI终局:模型公司只是基础设施,OpenAI、Anthropic难成最终赢家;下一战役是Agent入口标准之争;公司制度将因AI重构而消亡,只有使命驱动的企业能穿越周期。

OpenHands v1.28.0:OpenCode与PI成内置Agent,密钥可对话申请
OpenHands 21天连推5个版本,v1.27.0将OpenCode与PI写入内置Agent列表,v1.28.0支持对话内申请密钥。本文梳理ACP协议接入方式、凭证复用规则、模型列表变更及升级建议。