用户点踩后怎么变成回归用例:请求快照、反馈聚类与复现率验收

上线三个月,运营后台攒了一千多条点踩,研发的回归集只增加了 27 条。周会上产品问了一句"用户都点踩了,为什么测试还是绿的",会议室安静了几秒。

散会后我们决定"把点踩全部转成回归用例"。两周后,回归集从 140 条涨到 520 条,CI 从 6 分钟变成 19 分钟,然后问题开始反向冒出来。

520 条里有三分之一在本地根本跑不出原来的效果。原因很杂:线上那一次召回的文档已经被重新切分;工具返回的是当天缓存里的价格,现在接口字段改过了;还有一批是模型侧静默升级了小版本------系统提示词版本号没变,但生成行为变了。更难受的是新来的两次线上事故,回归集一条都没拦住,因为它们对应的点踩在当时就被归因成了"用户不会用",压根没进候选池。

这篇复盘的是从那之后重建的一条链路:把每条负反馈绑定到一次完整的调用快照,脱敏后做聚类去重,只有能在固定条件下稳定复现的样本才进入回归集,再用复现率、误报率、回归捕获率三个指标决定它的优先级和去留。

范围先划清楚:这里只处理"点踩之后怎么变成一个可信的、能挡住回归的自动化用例"。不讨论模型能力提升,不讨论产品需求优先级,也不展开一整套评测集该怎么设计。那两条是另外的线。

这套机制不解决什么

第一条,它不提高模型回答的质量。回归集只能说明"这里以前错过,现在别再错",它守住的是下限。很多团队把点踩治理当成质量提升项目来做,最后指标好看、用户满意度不动,就是因为这类工作本质上是在防止退化,不是在制造进步。

第二条,它不替代人工判断。点踩是成本极低的信号,用户可能因为语气、长度、格式点踩,也可能因为自己理解错而点踩。把点踩直接当缺陷,是把主观感受误当成事实。整条链路里唯一不能省的人工环节,是对聚类后的样本做一次"这条反馈是否成立"的复核。这个环节可以抽样、可以降优先级,但不能取消。

第三条,它不解决"没有点踩但同样错"的问题。沉默的失败比点踩更常见:用户看一眼觉得不对,直接关掉页面,既不点踩也不追问。反馈驱动的闭环天生有选择偏差,它补的是已知的洞,不是所有的洞。覆盖面还得靠合成评测集、红队题库和线上抽检来补。

第四条,它不保证跨版本一致。今天钉住的模型 revision,供应商三个月后可能下线;今天的检索索引,明天可能重建。快照能留下"当时的现场",留不住"当时的环境"。复现率这个指标真正的意义,是把"环境已经变了"量化出来,而不是假装它没变。

反馈、缺陷、回归用例、复现:四个常被混在一起的东西

这四个词在日常讨论里几乎被当同义词用,但在工程上它们对应四种完全不同的证据强度。混用会直接导致回归集被污染。

反馈是一次用户行为,可信度最低。一条点踩只说明"这个用户在这一刻不满意",它不携带任何关于原因的信息。它唯一的价值是:指向一次具体的调用。

缺陷是一次可归因的错误。从反馈到缺陷,中间必须有一次归因动作------是检索没召回、是工具返回过期、是提示词漏了约束、还是用户本身问错了。归因之前,任何"这是 bug"的说法都只是猜测。

回归用例是一个可自动执行的断言。它必须同时满足三条:输入可重建、执行可重复、判定可自动化。缺任何一条,它就只是"一份记录",不是用例。这一步是把缺陷变成资产的关键,也是绝大多数团队卡住的地方------他们停在了"我们有这个 bad case 的截图"这一层。

复现是在受控条件下重新触发同一次失败。复现不等于重跑。重跑是"再问一遍",复现是"在输入、证据、工具返回、模型版本全部对齐的前提下再问一遍"。两者的区别在工程上是决定性的:重跑成功只能说明"这次碰巧又错了",复现成功才能说明"我们知道它为什么错"。

排成一条链就是:反馈 → 归因 → 缺陷 → 快照 → 复现 → 断言 → 回归用例。链上任何一环缺失,后面的东西都不成立。绝大多数"点踩转回归"的失败,不是败在最后一环的自动化,而是败在第二环的归因和第四环的快照。

快照的四个不变量

快照不是"把请求体存下来"。存一份 JSON 很容易,但存下来的东西十有八九跑不起来。一个能支撑复现的快照,要同时满足四个不变量。

完整性:重跑所需的最小闭包。 决定一次模型输出的输入,至少包括:系统提示词模板及其版本、模板变量、注入的检索证据(原文,不是 doc_id)、工具调用的入参与返回、模型标识与采样参数,以及必要的会话前文。少任何一项,复现就从"确定性重放"退化成"碰运气"。最常见的偷懒是只存 doc_id 不存文本------索引一重建,证据就变了,这条快照立刻作废。

契约性:schema 必须带版本。 快照结构会演进,字段会加会删。没有 schema_version 的快照,半年后就是一堆无法解析的历史垃圾。版本号要跟着读取代码一起走:读 v2 的代码永远读得懂 v2 的快照,即使系统已经演进到 v5。这条约束看起来笨,但它是唯一能让历史数据保值的办法。

可判别性:能区分"坏版本"和"好版本"。 一条快照只有在 v_bad(点踩发生时的版本)上失败、在 v_fixed(修复后的版本)上通过,才具备作为回归用例的价值。只在 v_bad 上失败、在 v_fixed 上也失败的样本,说明问题根本没修好;两个版本都通过的样本,说明你抓错了现场。这个性质要在入库前验证,不能指望入库后靠 CI 慢慢发现。

最小留存:脱敏不可逆、留存有期限。 快照里必然有用户输入,可能有手机号、地址、订单号、身份证。原则是:能脱敏的字段在入库前脱敏,脱敏后原文不落盘;不能脱敏的字段单独标注,并在留存期结束后删除。脱敏必须不可逆------用可逆加密"以后可能要还原"是给自己埋雷。

还有一条常被忽略的:快照必须绑定 trace_id 和 turn_index。一次多轮对话里,用户点踩的可能是第三轮的回答,但前两轮决定了上下文。只存最后一轮,复现出来的上下文是断的,模型的表现自然对不上。

从点踩到门禁的数据血缘

下面这张图画的是数据在每一层的形态变化,以及两条明确的失败分支------不是步骤罗列。
#mermaid-svg-RVUpjZptbGenBqHj{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-RVUpjZptbGenBqHj .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-RVUpjZptbGenBqHj .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-RVUpjZptbGenBqHj .error-icon{fill:#552222;}#mermaid-svg-RVUpjZptbGenBqHj .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-RVUpjZptbGenBqHj .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-RVUpjZptbGenBqHj .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-RVUpjZptbGenBqHj .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-RVUpjZptbGenBqHj .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-RVUpjZptbGenBqHj .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-RVUpjZptbGenBqHj .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-RVUpjZptbGenBqHj .marker{fill:#333333;stroke:#333333;}#mermaid-svg-RVUpjZptbGenBqHj .marker.cross{stroke:#333333;}#mermaid-svg-RVUpjZptbGenBqHj svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-RVUpjZptbGenBqHj p{margin:0;}#mermaid-svg-RVUpjZptbGenBqHj .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-RVUpjZptbGenBqHj .cluster-label text{fill:#333;}#mermaid-svg-RVUpjZptbGenBqHj .cluster-label span{color:#333;}#mermaid-svg-RVUpjZptbGenBqHj .cluster-label span p{background-color:transparent;}#mermaid-svg-RVUpjZptbGenBqHj .label text,#mermaid-svg-RVUpjZptbGenBqHj span{fill:#333;color:#333;}#mermaid-svg-RVUpjZptbGenBqHj .node rect,#mermaid-svg-RVUpjZptbGenBqHj .node circle,#mermaid-svg-RVUpjZptbGenBqHj .node ellipse,#mermaid-svg-RVUpjZptbGenBqHj .node polygon,#mermaid-svg-RVUpjZptbGenBqHj .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-RVUpjZptbGenBqHj .rough-node .label text,#mermaid-svg-RVUpjZptbGenBqHj .node .label text,#mermaid-svg-RVUpjZptbGenBqHj .image-shape .label,#mermaid-svg-RVUpjZptbGenBqHj .icon-shape .label{text-anchor:middle;}#mermaid-svg-RVUpjZptbGenBqHj .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-RVUpjZptbGenBqHj .rough-node .label,#mermaid-svg-RVUpjZptbGenBqHj .node .label,#mermaid-svg-RVUpjZptbGenBqHj .image-shape .label,#mermaid-svg-RVUpjZptbGenBqHj .icon-shape .label{text-align:center;}#mermaid-svg-RVUpjZptbGenBqHj .node.clickable{cursor:pointer;}#mermaid-svg-RVUpjZptbGenBqHj .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-RVUpjZptbGenBqHj .arrowheadPath{fill:#333333;}#mermaid-svg-RVUpjZptbGenBqHj .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-RVUpjZptbGenBqHj .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-RVUpjZptbGenBqHj .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-RVUpjZptbGenBqHj .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-RVUpjZptbGenBqHj .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-RVUpjZptbGenBqHj .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-RVUpjZptbGenBqHj .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-RVUpjZptbGenBqHj .cluster text{fill:#333;}#mermaid-svg-RVUpjZptbGenBqHj .cluster span{color:#333;}#mermaid-svg-RVUpjZptbGenBqHj div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-RVUpjZptbGenBqHj .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-RVUpjZptbGenBqHj rect.text{fill:none;stroke-width:0;}#mermaid-svg-RVUpjZptbGenBqHj .icon-shape,#mermaid-svg-RVUpjZptbGenBqHj .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-RVUpjZptbGenBqHj .icon-shape p,#mermaid-svg-RVUpjZptbGenBqHj .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-RVUpjZptbGenBqHj .icon-shape .label rect,#mermaid-svg-RVUpjZptbGenBqHj .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-RVUpjZptbGenBqHj .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-RVUpjZptbGenBqHj .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-RVUpjZptbGenBqHj :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 否
是
否
是
复现率低于阈值
复现率达标
区分度不足
区分度达标
反馈不成立
反馈成立
失败
用户点踩
反馈事件 feedback_id
trace_id 与 turn_index 是否可解析
进入观察池

不参与回归
绑定调用快照
schema 版本可读且字段完整
脱敏 redact
指纹 fingerprint
SimHash 分桶聚类
候选用例
固定环境回放 N 次
归档为不可复现

保留 90 天
v_bad 与 v_fixed 双版本验证
人工复核
标记误报

计入误报率
写入回归集
CI 门禁
阻断发布并关联原始 feedback_id

两条失败分支值得单独说。

第一条是左上角那个"trace 解析失败"。线上真实的点踩事件里,有相当一部分根本关联不到完整调用记录:客户端上报时 trace 已经过期、采样率把它丢掉了、或者这个请求走的是另一条没打点的链路。这类反馈的唯一出路是进观察池,用它反向推动埋点补全,而不是硬塞进回归集。把没有快照的反馈塞进回归集,等于给 CI 增加了一条永远不知道自己在测什么的用例。

第二条是"复现率不达标"。这条分支容易被当成失败,其实它是有价值的输出。一条复现不出来的点踩,通常意味着三件事之一:当时的证据已经变了、当时的行为是偶发的、或者用户描述的问题和我们理解的不是同一件事。三种情况都值得记录,但都不该占用 CI 时间。

第一段实现:快照契约、脱敏与指纹

先写数据结构。这里刻意不用任何框架,因为快照的价值全在字段设计上,框架只会把字段藏起来。

关键设计决定有三个。

第一,ModelPin 里的 revision 不是模型名。模型名是 chat-pro,revision 是供应商侧的不可变快照标识------有的供应商叫 snapshot,有的叫 version。只钉模型名等于没钉,因为同一个名字背后的权重随时可能换。如果供应商不提供 revision,这一项必须显式记为 "unpinned",并且这类快照不能进入回归集,因为它的失败无法归因到具体权重。

第二,ToolCall.result_digest 存的是返回内容的摘要而不是全文。工具返回可能很大,也可能含敏感数据;摘要的作用是"检测返回是否漂移"------复现时重新调用工具,算出的 digest 和快照里的不一致,说明外部依赖已经变了,这次复现应当作废而不是判失败。

第三,指纹只覆盖影响输出的字段,不覆盖 trace_id 和时间。指纹是去重键:同一个问题被十个用户点踩,指纹相同,聚成一条,点击数累加。如果指纹里混进了 trace_id,十条会变成十个桶,去重完全失效。

python 复制代码
from __future__ import annotations

import hashlib
import json
import re
from dataclasses import dataclass, replace
from typing import Any

SNAPSHOT_SCHEMA_VERSION = 3


@dataclass(frozen=True)
class RetrievalHit:
    doc_id: str
    chunk_id: str
    score: float
    text: str


@dataclass(frozen=True)
class ToolCall:
    name: str
    schema_version: str
    args: dict[str, Any]
    result_digest: str
    ok: bool


@dataclass(frozen=True)
class ModelPin:
    provider: str
    model: str
    revision: str          # 供应商侧不可变快照标识;不可得时写 "unpinned"
    temperature: float
    top_p: float
    seed: int | None


@dataclass(frozen=True)
class CallSnapshot:
    schema_version: int
    trace_id: str
    turn_index: int
    captured_at: str
    prompt_template: str
    prompt_vars: dict[str, str]
    retrieval: tuple[RetrievalHit, ...]
    tools: tuple[ToolCall, ...]
    model: ModelPin


PII_PATTERNS = (
    (re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}"), "<EMAIL>"),
    (re.compile(r"(?<!\d)1[3-9]\d{9}(?!\d)"), "<PHONE_CN>"),
    (re.compile(r"(?<!\d)\d{17}[\dXx](?!\d)"), "<ID_CN>"),
)


def _scrub(text: str) -> str:
    for pattern, token in PII_PATTERNS:
        text = pattern.sub(token, text)
    return text


def redact(snapshot: CallSnapshot) -> CallSnapshot:
    return replace(
        snapshot,
        prompt_vars={k: _scrub(v) for k, v in snapshot.prompt_vars.items()},
        retrieval=tuple(replace(hit, text=_scrub(hit.text)) for hit in snapshot.retrieval),
        tools=tuple(
            replace(call, args={k: _scrub(str(v)) for k, v in call.args.items()})
            for call in snapshot.tools
        ),
    )


def fingerprint(snapshot: CallSnapshot) -> str:
    """去重键:只覆盖会影响模型输出的字段。调用前先 redact。"""
    payload = {
        "schema_version": snapshot.schema_version,
        "prompt_template": snapshot.prompt_template,
        "prompt_vars": dict(sorted(snapshot.prompt_vars.items())),
        "retrieval": [[h.doc_id, h.chunk_id, h.text] for h in snapshot.retrieval],
        "tools": [
            [c.name, c.schema_version, dict(sorted(c.args.items())), c.ok]
            for c in snapshot.tools
        ],
        "model": [
            snapshot.model.provider,
            snapshot.model.model,
            snapshot.model.revision,
            snapshot.model.temperature,
            snapshot.model.top_p,
            snapshot.model.seed,
        ],
    }
    blob = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
    return hashlib.sha256(blob.encode("utf-8")).hexdigest()


def demo() -> None:
    snap = CallSnapshot(
        schema_version=SNAPSHOT_SCHEMA_VERSION,
        trace_id="t-001",
        turn_index=2,
        captured_at="2026-10-08T09:00:00Z",
        prompt_template="rag.answer.v7",
        prompt_vars={
            "user_question": "我的订单 13812345678 什么时候发货",
            "user_id": "u-42",
        },
        retrieval=(RetrievalHit("doc-7", "c-3", 0.81, "发货时效说明"),),
        tools=(ToolCall("order_query", "2026-09-01", {"order_id": "A1"}, "blake2b:9f2c", True),),
        model=ModelPin("acme", "chat-pro", "rev-2026-09-30", 0.2, 0.9, 7),
    )

    red = redact(snap)
    assert "<PHONE_CN>" in red.prompt_vars["user_question"]
    assert "13812345678" not in red.prompt_vars["user_question"]
    assert red.trace_id == snap.trace_id  # 脱敏不动关联字段

    a = fingerprint(red)
    shifted = replace(red, trace_id="t-999", captured_at="2026-10-09T00:00:00Z")
    assert fingerprint(shifted) == a      # 时间与 trace 不参与指纹

    upgraded = replace(red, model=replace(red.model, revision="rev-2026-10-07"))
    assert fingerprint(upgraded) != a     # 权重版本必须改变指纹

    print("ok", a[:12])


if __name__ == "__main__":
    demo()

这段代码里最容易被跳过的是 demo() 里的三条断言。它们分别验证:脱敏确实替换了原始号码、指纹对 trace_id 和时间不敏感、指纹对模型 revision 敏感。第三条尤其重要------如果 revision 变化不改变指纹,模型升级导致的点踩会被误判成"重复反馈"而被压掉,那正是最难排查的一类线上退化。

还有一个实现细节:指纹应当在脱敏之后计算。脱敏前后文本不同,如果一半快照按原文算、一半按脱敏后算,同一个问题会分裂成两个桶。管线里要写死顺序:redact → fingerprint,不能颠倒。

最后,SNAPSHOT_SCHEMA_VERSION 这个常量要真的用起来。写快照时写入、读快照时校验,遇到更高的主版本号就直接拒绝,而不是尝试"尽量解析"。一个能解析一半的快照,比一个解析失败的快照危险得多。

聚类:为什么不能按"相似问题"直接合并

聚类这一步的目标不是"把相似的问题归类",而是"把重复的反馈压成一条有统计意义的样本"。这两件事的差别决定了算法选择。

如果目标是归类,你会想要语义聚类:embedding 加 HDBSCAN,把"发货慢"和"物流太久"归到一起。如果目标是去重,你要的恰恰相反------你要保证"同一份输入"被合并,"不同输入但语义相近"不被合并。原因很简单:回归用例的输入必须精确重建,把两条语义相近但输入不同的反馈合成一条,你只能保留其中一条的输入,另一条的现场就丢了。

所以这里用的是 SimHash 加汉明距离,而不是向量聚类。SimHash 的性质是:文本的微小改动(加个"请问"、换个标点、补一个语气词)几乎不改变指纹,而实质性的内容变化会大幅改变指纹。这正好匹配"同一次调用被多次点踩"这个场景------那些差异是用户表达的噪声,不是输入的差异。

阈值的选择是工程判断,不是调参。汉明距离阈值定得太小,同一问题的不同表述分不进一个桶,去重率低;定得太大,不相关的问题被硬合并,会丢掉真实缺陷。可行做法是先用一批人工标注过的重复对,画出两类距离的分布,取分界点。这件事做一次就够,之后阈值跟着数据漂移定期复核。

聚类之后还要处理一个常见陷阱:桶的中心会漂移。如果每来一个新样本就更新中心,早期成员和后加入的成员可能离得很远,桶会慢慢变成一个"什么都装"的口袋。稳妥的做法是中心一旦确定就不再移动,新样本要么落进最近的桶,要么开新桶。桶数量会增加,但可解释性保住了------每个桶都能追溯到它的第一条样本,人工复核时有据可查。

还有一个必须处理的细节:单次点踩的样本不应该直接进入候选池。一条只被点过一次、且没有人工标注的反馈,信息量太低,聚类结果也不稳定。实践中常用"点击数达到 2 或人工明确标注"作为进入候选池的门槛,把大量噪声挡在外面。

第二段实现:SimHash 分桶与复现评分

这一段代码做两件事:把候选反馈分桶去重,以及给每个候选算一个"值不值得进回归集"的分。

评分里用 clicks ** 0.5 是为了压住点踩次数的支配性。一条被 200 人点踩但复现率只有 10% 的样本,不应该排在一条 20 人点踩但稳定复现的样本前面。开方是最简单的压缩方式,比引入一个魔法系数更容易向别人解释。

真正的门禁在 priority() 里的三个提前返回:人工复核明确判为误报的直接归零;有效回放次数不足的一律 hold;复现率不达标的也 hold。第二条是这套系统里最重要的一条纪律------样本少的时候,复现率是极不稳定的估计:跑 1 次失败,复现率是 100%;跑 2 次失败 1 次,复现率是 50%。用这种数字排序,等于用噪声排序。

python 复制代码
from __future__ import annotations

import hashlib
import re
from dataclasses import dataclass
from typing import Sequence


def _normalize(text: str) -> str:
    text = text.lower()
    text = re.sub(r"<[a-z_]+>", "", text)   # 去掉脱敏占位符,避免影响相似度
    return re.sub(r"\s+", "", text)


def _stable_hash(gram: str) -> int:
    # 内置 hash() 对 str 加盐,跨进程不稳定,不能用于指纹
    return int.from_bytes(
        hashlib.blake2b(gram.encode("utf-8"), digest_size=8).digest(), "big"
    )


def simhash(text: str, bits: int = 64) -> int:
    s = _normalize(text)
    if not s:
        return 0
    grams = [s[i:i + 2] for i in range(len(s) - 1)] if len(s) >= 2 else [s]
    vector = [0] * bits
    for gram in grams:
        h = _stable_hash(gram)
        for i in range(bits):
            vector[i] += 1 if (h >> i) & 1 else -1
    out = 0
    for i in range(bits):
        if vector[i] > 0:
            out |= 1 << i
    return out


def cluster(
    items: Sequence[tuple[str, int, str]], threshold: int = 6
) -> tuple[dict[str, int], list[tuple[int, list[str]]]]:
    """items: (case_id, clicks, text)。返回 (case_id -> 桶号, 桶列表)。"""
    centers: list[int] = []
    members: list[list[str]] = []
    assignment: dict[str, int] = {}
    for case_id, _clicks, text in items:
        h = simhash(text)
        for idx, center in enumerate(centers):
            if bin(h ^ center).count("1") <= threshold:
                members[idx].append(case_id)
                assignment[case_id] = idx
                break
        else:
            centers.append(h)          # 中心一经确定不再移动
            members.append([case_id])
            assignment[case_id] = len(centers) - 1
    return assignment, list(zip(centers, members))


MIN_RUNS = 3
MIN_REPRO = 0.67


@dataclass
class Candidate:
    case_id: str
    fingerprint: str
    clicks: int
    runs: int = 0               # 有效回放次数,不含 infra_error
    repro_failures: int = 0     # 回放中重现原始问题的次数
    v_bad_fail: int = 0         # 在 v_bad 上失败的次数
    v_fixed_pass: int = 0       # 在 v_fixed 上通过的次数
    human_confirmed: bool | None = None

    @property
    def repro_rate(self) -> float:
        return self.repro_failures / self.runs if self.runs else 0.0

    @property
    def catch_rate(self) -> float:
        """区分度:v_bad 失败与 v_fixed 通过的较小者占比。"""
        if not self.runs:
            return 0.0
        return min(self.v_bad_fail, self.v_fixed_pass) / self.runs


def priority(c: Candidate) -> tuple[float, str]:
    if c.human_confirmed is False:
        return (0.0, "rejected:false-positive")
    if c.runs < MIN_RUNS:
        return (0.0, "hold:insufficient-runs")
    if c.repro_rate < MIN_REPRO:
        return (0.0, "hold:not-reproducible")
    score = (c.clicks ** 0.5) * c.repro_rate * c.catch_rate
    return (round(score, 4), "ready")


def demo() -> None:
    assignment, _ = cluster(
        [("a", 3, "发票怎么开"), ("b", 2, "发票怎么开"), ("c", 1, "退款进度查询")]
    )
    assert assignment["a"] == assignment["b"]
    assert assignment["c"] != assignment["a"]
    assert _normalize("乱 <EMAIL> 了") == "乱了"
    assert simhash("发票怎么开") == simhash("发票怎么开")

    ready = Candidate(
        "c-100", "fp-a", clicks=9, runs=5,
        repro_failures=4, v_bad_fail=5, v_fixed_pass=4, human_confirmed=True,
    )
    score, status = priority(ready)
    assert status == "ready"
    assert 0 < score < 9

    assert priority(Candidate("c-101", "fp-b", clicks=20, runs=2))[1] == "hold:insufficient-runs"

    flaky = Candidate(
        "c-102", "fp-c", clicks=30, runs=6,
        repro_failures=1, v_bad_fail=1, v_fixed_pass=1, human_confirmed=True,
    )
    assert priority(flaky)[1] == "hold:not-reproducible"

    false_alarm = Candidate(
        "c-103", "fp-d", clicks=50, runs=6,
        repro_failures=6, v_bad_fail=6, v_fixed_pass=6, human_confirmed=False,
    )
    assert priority(false_alarm)[1] == "rejected:false-positive"

    print("ok", score, len(assignment))


if __name__ == "__main__":
    demo()

demo() 里的断言分两类。一类验证聚类行为:完全相同的文本必然落进同一个桶,不同文本必须分开------这是算法正确性的下限,不依赖阈值调参。另一类验证评分规则:复现率达标且复核通过的样本必须拿到 ready,回放次数不足的必须被 hold 住,误报必须归零。这三条覆盖了最容易写错的三个分支。

还有一个指标没有直接体现在代码里,但必须在数据层存下来:v_bad_fail 和 v_fixed_pass 是两个独立计数器,不是同一个。只统计"在 v_bad 上失败"会把修复后仍然失败的样本算成好用例;只统计"在 v_fixed 上通过"会把从来没有真正失败过的样本算进来。两者取 min() 才是真正的区分度。

复现率验收:跑几次、怎么算"重现"

复现率的定义看起来简单------失败次数除以总次数------但落地时有四个必须先定下来的口径,否则不同人算出来的数字没法比。

第一,什么算一次有效回放。 回放过程中如果快照里的外部依赖已经漂移(工具返回的 digest 对不上、检索证据的文档被删改、模型 revision 已下线),这次回放不计入分母。把它算进去会系统性低估复现率,把"环境变了"误报成"问题消失了"。这类回放要单独打 infra_error 标记,并且当同一快照的 infra_error 比例超过一半时,应当先把这条样本退回观察池,等环境恢复再重试。

第二,判定"重现"的标准是什么。 最省事的做法是字符串比对,但它对同义表述零容忍,几乎所有真实回答都过不了。可行的做法是三层:先比结构化输出(如果是 JSON,比关键字段),再比关键实体(订单号、金额、日期、是否给出拒答),最后才用人写的判定器或人工看。前两层是确定性的,可以直接在 CI 里跑;第三层成本高,只对前两层无法判定的样本启用。

第三,跑几次。 采样参数不为 0 时,同一个输入本来就可能出不同结果。这时候"跑 3 次全失败"和"跑 3 次失败 1 次"是完全不同的两件事。前者是稳定缺陷,后者是概率性缺陷。概率性缺陷同样值得进回归集,但它的判定阈值要单独设------比如"在 N 次回放中至少失败 1 次"就算捕获。把两类混进一个指标,会逼着团队把温度调到 0,而那会掩盖真实的线上行为。

第四,用什么环境跑。 回放环境必须和线上尽可能一致:同一份索引快照、同一套工具 mock 或录制的返回、同一个模型 revision。任何一项不一致,复现率的解释力都会打折。实践中建议把回放环境做成一个独立、带版本号的镜像,而不是复用开发机------开发机上永远有某个同事手改过的配置。

把这四条定下来之后,复现率的阈值才有意义。稳定缺陷的复现率通常很快收敛到接近 1,概率性缺陷会在 0.2 到 0.6 之间。阈值定在 0.67 是常见的折中:它要求至少三次里失败两次,既排除了单次偶发,又不会把低概率缺陷全部拒之门外。

最后一条纪律:复现率不达标不等于关掉这条反馈,它只是不进回归集。原始快照仍然要保留,因为它可能是下一次模型升级之后重新变得可复现的样本。把"现在不可复现"和"永远不关心"混为一谈,会丢掉一批潜在的早期信号。

误报率与回归捕获率:两个方向相反的指标

只看复现率会把回归集撑爆,因为复现率高只说明"这个问题能重现",不说明"这个问题值得修"。要同时压住两个方向,得配两个指标。

误报率衡量的是"进来的东西对不对"。 分子是人工复核判定为"反馈不成立"的样本数,分母是进入候选池的样本数。反馈不成立有几种典型情况:用户问的问题本身超出业务范围、用户对正确答案不满意、用户的输入自相矛盾、以及产品的行为其实是合规且经过批准的。最后一种最危险------如果不把它标成误报,回归集会慢慢固化成一个"和产品设计对抗"的集合,最后每一次合规调整都会撞红一堆用例。

误报率的作用不是越低越好,而是设一条红线。超过红线的聚类桶要整体回退重审,因为一个高误报的桶通常意味着聚类把不同性质的问题合并了。

回归捕获率衡量的是"进来的东西有没有用"。 这里把它定义成:回归用例在 v_bad 上失败、同时在 v_fixed 上通过的比例。这个定义的好处是它可以在用例入库前就算出来,不依赖未来的线上事故。

为什么不用"未来挡住了多少次发布"来定义?因为那个数字有严重的幸存者偏差:没被挡住的事故,你根本不知道回归集里有没有相关用例。用 v_bad/v_fixed 双版本回放,是把"这条用例有没有判别力"变成一个当场可测的事实,而不是一个要等三个月才知道的结果。

两个指标一起看,会得到四种组合:

  • 高复现、高捕获:直接进回归集,优先级最高;
  • 高复现、低捕获:问题能重现,但修复前后行为一致------多半是归因错了,实际原因不在你以为的那一层;
  • 低复现、高捕获:少见但存在,通常是采样参数导致的概率性缺陷,需要单独设判定阈值;
  • 低复现、低捕获:归档,不占 CI 资源。

这个四象限比任何单一分数都更能指导决策,因为它把"能不能测出来"和"测出来有没有用"拆成了两个独立维度。

生产加固:留存、权限、成本与回滚

留存。 快照含用户原文,属于个人数据。要有明确的保留期限和删除路径:候选池 30 天、未通过验收的 90 天、进入回归集的长期保留但必须在脱敏后。删除要是真删除,包括备份和索引。这条不做,后面任何合规审查都过不去,而且越晚做成本越高。

权限。 快照的读取权限应当比普通日志更严。能看原文的人应当是一个很小的集合,大多数研发看到的是脱敏版本。回归集在 CI 里跑的时候,断言里也不应该出现原始个人数据------断言的比对对象应该是结构化字段或哈希,不是用户原话。

成本。 每次回放都是一次真实的模型调用。520 条用例、每条跑 3 次、每次平均 2k token,一天跑十轮就是三千万 token 的量级。控制手段有三个:只对变更相关的用例做全量回放(用模块依赖图或标签做选择)、把长期稳定通过的用例降频到每日一次、以及把复现验证放在离线批处理而不是每次 PR 都跑。

幂等与重试。 回放过程本身也会失败:工具超时、模型限流、索引服务抖动。这类失败必须和"用例失败"区分开,否则复现率会被基础设施问题污染。做法是给每次回放打一个明确状态:reproduced / not_reproduced / infra_error,只有前两种计入分母。这条看起来是细节,但它是所有指标可信度的前提。

回滚。 一个新用例进回归集之后,如果连续多次发布都通过,它不应该被删除,但可以降频。反过来,如果一条用例在 v_fixed 上开始失败,说明修复被回退了,这时候要有机制把它重新提回高优先级,并且关联到原始 feedback_id。这条链路的价值在于,它能把"同一个用户投诉了两次"变成一个可追踪的工程事件,而不是两次孤立的人工排查。

怎么验证这套东西本身有效

这套系统自己也需要被评测,否则你只是把不可信的反馈换成了不可信的回归集。三个可执行的检查:

一、回溯验证。 拿过去半年已经确认并修复的缺陷,看这套流程能不能把它们从原始点踩里捞出来。如果十个已知缺陷里只捞得出三个,说明聚类阈值或复现判定有问题。这个检查不需要新数据,成本最低,应该最先做。

二、影子运行。 新用例先进影子集,跟着 CI 跑但不算失败。跑两周之后统计:影子集里失败次数最多的前十条,有多少对应真实的线上问题。这个数字直接反映回归捕获率的估计是否靠谱,也能暴露"只在 v_bad 上失败"的假用例。

三、反向注入。 人为把一个已知会让模型答错的输入注入测试环境------比如把某个关键文档从索引里摘掉------看回归集会不会红。这是唯一能主动验证"门禁真的在拦"的方法,比看通过率有用得多。注入之后记得恢复,并把这次注入记进变更日志。

这三个检查里,第一个是必须做的,后两个建议按季度做。它们的共同点是:验证的对象是流程,不是模型。

总结

回到开头那个会议室。真正的问题不是"点踩没被转成用例",而是"点踩被当成了缺陷"。从反馈到回归用例,中间隔着归因、快照、复现三道关,跳过任何一道,得到的都是一个看起来很大、实际拦不住东西的回归集。

如果只记三件事:

第一,快照的完整性决定了后面一切。少存一份检索原文,半年后这条用例就废了。schema 版本和模型 revision 必须在第一版就设计进去,事后补不回来。

第二,复现率是准入条件,不是排序依据。跑不够次数就不给分,这条纪律比任何调参都重要。

第三,误报率和回归捕获率要一起看。前者防止回归集被用户情绪污染,后者防止它变成一堆没有判别力的记录。

适用的边界也要说清楚:这套流程适合有明确调用链、有 trace 体系、有稳定回放环境的系统。如果连请求级别的 trace 都拿不到,先补可观测性,别急着建回归集------没有快照的回归用例,本质上就是一份手写的猜想。

参考资料

  1. OpenTelemetry, Semantic Conventions for GenAI(含 experimental 标记,字段可能变化,引用时请注意版本): https://opentelemetry.io/docs/specs/semconv/gen-ai/
  2. OpenTelemetry, GenAI Spans: https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-spans/
  3. OpenInference, Semantic Conventions Specification: https://github.com/Arize-ai/openinference/blob/main/spec/semantic_conventions.md
  4. OpenAI, openai/evals 仓库: https://github.com/openai/evals
  5. OpenAI Platform, Evals 指南: https://platform.openai.com/docs/guides/evals
  6. LangChain, LangSmith 文档(数据留存与追踪配置): https://docs.smith.langchain.com/
  7. Langfuse, Documentation(trace 与数据集管理): https://langfuse.com/docs
  8. pytest, How to parametrize fixtures and test functions: https://docs.pytest.org/en/stable/how-to/parametrize.html
  9. Python 文档, hashlib --- Secure hashes and message digests: https://docs.python.org/3/library/hashlib.html
  10. Python 文档, dataclasses --- Data Classes: https://docs.python.org/3/library/dataclasses.html
  11. M. Charikar, Similarity Estimation Techniques from Rounding Algorithms, STOC 2002: https://dl.acm.org/doi/10.1145/509907.509965
  12. scikit-learn, 2.3. Clustering: https://scikit-learn.org/stable/modules/clustering.html
  13. NIST, Privacy Framework: https://www.nist.gov/privacy-framework
  14. NIST, AI Risk Management Framework: https://www.nist.gov/itl/ai-risk-management-framework
相关推荐
小草cys2 小时前
玄工科技XUANGONG TECH ·把科研级技术能力 交付为可落地的产品
人工智能·科技·可信计算技术·玄工科技
码上观世界2 小时前
AI PPT 设计方法以及与dashi ppt 对比
人工智能·powerpoint
悟天特斯2 小时前
边缘计算的“实时决策“:从数据回传到边端即时推理的决策升级
数据库·人工智能·边缘计算
Sailing2 小时前
🚀🚀【封闭结束4】Agent 工具调用工程化:把 Tool Runtime 做扎实
人工智能·面试·agent
浪潮BB机2 小时前
钉钉闪记后续AI处理太弱?2026主流智能工具实测横评
人工智能·钉钉·语音识别·效率工具·ai工具·录音转写·会议纪要
清华kenny2 小时前
中医馆如何参考安徽亳州中医AI健康辨识仪品牌排名前十
大数据·人工智能·安徽晓得养
Jsundoku2 小时前
第七章 机器学习 -- 其他经典算法
人工智能·算法·机器学习
会编程的吕洞宾2 小时前
LangChain4j 流式输出实战:让 AI 回复像打字机一样逐字显示
java·开发语言·人工智能·后端
不脱发的程序猿3 小时前
红利ETF量化策略
人工智能·python·ai·程序员·量化交易·etf·策略回测