文章用免费模型为运行轨迹片段分类,并与人工标注样本核对,通过验证后才用于调整调试优先级。标签应来自实际事故复盘,同时保留未知类别,观察输入证据是否充分。
大多数 trace 工具能告诉你发生了什么,却几乎没有工具能回答:这次包含 3,000 个 span 的运行中,哪 40 个 span 最值得你接下来花一小时查看?一个低成本分类器可以回答这个问题,前提是先评估,再信任。下面这套流程会构建大小受限的 span 卡片,通过免费模型端点为它们打标签,再用一组人工标注的样本检验这些标签。只有通过检验,标签才能用于调整调试队列的顺序。
本周的 AI 资讯大多在讨论能力。与之相对,验证没那么光鲜:一个未经测试的标签分类器,不过是个彬彬有礼的随机数生成器。
不要照搬文章里的标签集,包括本文。回顾你最近五次故障复盘,从中提炼分类。在我的 trace 中,反复出现的问题有:工具契约不匹配(工具返回的数据结构不符合 prompt 的预期)、prompt 偏移(模型在运行中途不再遵守某项约束)、被静默重试的超时,以及交接后的上下文丢失。四个类别,再加一个兜底的 unknown,就足以进行初步排查。
unknown 不是一种故障类型。当卡片没有提供证据时,它就是诚实的回答,而它的占比应该是你首先关注的数字。如果分类器有 60% 的时候都回答 unknown,它是在告诉你卡片的信息太少,而不是你的 Agent 神秘莫测。
一条 span 记录通常是 2–20 KB 的 JSON。把它原封不动地发送给任何模型都很浪费,而且容易让模型去总结,而不是分类。卡片则是一份受固定字节预算约束的精简记录:标识、类型、状态、耗时,以及截断后的输入到输出文本片段。
import json
CARD_TEXT_BYTES = 320
def card(span):
text = (span.get("input_summary") or "") + " -> " + (span.get("output_summary") or "")
text = text.encode("utf-8")[:CARD_TEXT_BYTES].decode("utf-8", "ignore")
return {
"id": span["span_id"],
"kind": span["kind"], # "llm" | "tool" | "handoff"
"tool": span.get("tool_name"),
"status": span["status"], # "ok" | "error" | "timeout"
"dur_ms": span["dur_ms"],
"text": text,
}
截断是有意为之,而且必须明确可见。如果某个 span 被错误分类,是因为证据在第 320 字节处被截掉了,那么增加字节预算应该能解决问题。这是一个可证伪的假设,而大多数 prompt 修改连这一点都做不到。
客户端是一个可调用对象,而不是一次 SDK 导入。这样,无论接入哪个端点,评估程序都能保持可移植,重试策略也留在你自己的代码里,方便测试。
LABELS = ["tool_contract", "prompt_drift", "timeout", "context_loss", "unknown"]
PROMPT = (
"You label one agent span. Reply with JSON only: "
'{"label": <one of ' + ", ".join(LABELS) + '>, "why": "<=12 words"}. '
"Use evidence in the card only. If the card lacks evidence, answer unknown."
)
def label(llm, span, retries=2):
c = card(span)
msgs = [{"role": "user", "content": PROMPT + "\n\nCARD:\n" + json.dumps(c)}]
for _ in range(retries + 1):
try:
raw = llm(msgs) # your client, one function
obj = json.loads(raw[raw.index("{"): raw.rindex("}") + 1])
if obj.get("label") in LABELS:
return {"span_id": c["id"], "label": obj["label"], "why": obj.get("why", "")}
except Exception:
continue
return {"span_id": c["id"], "label": "unknown", "why": "parse_fail"}
解析失败时,有意统一归入 unknown。与其静默兜底、假装完成了分类,不如把它放进一个可以统计的类别。先对样本运行,而不是处理整份 trace:python -m triage.run --trace run.jsonl --sample 200 --out labels.jsonl。
从已经调试过的 span 中选出 40–60 个,人工标注一次。然后以它们为基准评估分类器,要逐类衡量,不能只看整体表现。
def gate(gold, pred, floor=0.70):
by = {}
for sid, g in gold.items():
p = pred.get(sid, "unknown")
slot = by.setdefault(g, [0, 0])
slot[0] += 1
slot[1] += int(p == g)
recall = {k: round(v[1] / v[0], 2) for k, v in by.items()}
overall = round(sum(v[1] for v in by.values()) / len(gold), 2)
return {"overall": overall, "recall": recall, "pass": overall >= floor}
门槛是需要你校准的参数,不是什么定律。关键在于错误会把你引向哪里:漏掉 timeout,会让你跑去追查 prompt;过度判定 tool_contract,则会让队列塞满错误线索。先看各类别的召回率,再看平均值,并固定人工标注的基准集,让后续的 prompt 修改可以相互比较。这里的代码片段只是示例,我没有公布过它们的评测数据;在跑过自己的基准集之前,你也不该直接采用任何人给出的阈值。
打标签的特点是量大、风险低,输出只是线索,并非定论。这类工作很适合交给免费模型端点,也是我把标签分类放在这里、而不是放到昂贵调用路径上的原因。MonkeyCode 提供免费模型访问和免费服务器选项;截至撰稿时,运营方宣称提供千万级的免费 token 额度。
利益披露:本文是作为 MonkeyCode 产品推广活动的一部分撰写的。
配额、模型阵容和服务器条款都会变化,因此在接入 CI 之前,请核实当前页面的信息,也永远不要把免费额度当作容量保证。发送前先脱敏:在写入 input_summary 和 output_summary 时,就移除 bearer tokens、客户标识符和文件内容,不要等到生成卡片时才处理。一个从未存储秘密的 span,就不可能把秘密泄露给分类器。
最后一项正是免费服务器能发挥价值的地方:重放筛选出的 span,往往需要再次访问同一批工具,这适合交给一台用完即可丢弃的机器。把重放程序的凭证权限限定在 sandbox 内,并单独记录重放产生的 span,否则你会把两次运行合并到同一份 trace 中,失去比较的依据。
如果你每周调试的 span 还不到几百个,直接阅读就好。维护这套评估程序的成本会超过它节省的时间,而且人工阅读本来就能培养建立分类体系所需的直觉。如果 span 包含不能离开内网的受监管数据,标签分类就必须在本地完成。如果工具 schema 每周都在变,分类器也会随之漂移,检验门槛会频繁失败,最终变成噪声。
一致不等于正确。分类器与基准集一致,只能说明它在这部分样本上符合你的判断,仅此而已。把它当作队列排序器:它决定你先读什么,绝不能决定你得出什么结论。
如果你想试试这套流程,又不想搭建基础设施,免费模型端点和免费服务器选项是成本最低的起点:让 llm 可调用对象接入前者,在本地运行检验,等候选清单稳定后,再把重放迁到后者。
如需采取进一步措施,你可以考虑屏蔽此人和/或举报滥用行为。