生产环境中的拒答监控

10 分钟读完

E23
运维 · 评估与可观测性

生产环境中的拒答监控。

拒答是你系统里唯一一种会产出 200 状态码、正常令牌数、没有异常、没有重试、也没有投诉的质量失败——用户读到「这个我帮不了」,关掉页面,而你的看板记下了一次成功请求。它同时也是一个已部署模型最易变的性质,能在版本号没变的情况下朝任一方向移动——这意味着拒答率既是最可能坏掉的那样东西,又是最不可能被发现的那样东西。在智能体循环里更糟:一次拒答不是一句看得见的「不行」,而是一次压根没发生的工具调用和一个悄悄消失的计划步骤,而那次运行报告成功。测量它是一天的工作量,而本季度你能建的任何其他东西,都没有比它更好的「成本换掉盲区」之比。

STEP 1

为什么现有的每一个信号都对此失明。

把你的监控栈从头走一遍,然后问:如果明天早上你的模型开始拒掉某一类此前一直好好的请求里的 8%,每一层会显示什么?

  • HTTP 状态码:200。一次拒答就是一次成功完成。传输层没有任何东西把它与一个答案区分开。
  • 延迟:要说有变化,是更快。拒答很短。你的 p95 改善了,读起来像一场胜利。
  • 令牌数:下降。输出令牌变少,于是每请求成本下降。这是最残酷的一个——指标朝所有人会庆祝的方向动了。
  • 错误率:不变。没有异常、没有失败的工具调用、没有非零退出。
  • 点踩率:几乎不动。被拒的用户多半是走掉而不是去评分,而真去评分的那些,是「生气的人」这个有偏样本。
  • 评测集:全绿。你的离线集是从模型回答过的请求里建起来的,所以它几乎不含现在正被拒掉的那些提示。

于是一次实质的质量回退表现为:成本下降、延迟下降、错误持平、评测全绿。这个组合与一次优化胜利无从区分,而不止一支团队为一次「其实是策略收紧」的降本给自己鼓过掌。原因是结构性的而非疏忽:上面每一个信号都是为检测「会产出一件物证的失败」而设计的,而拒答恰恰是物证的缺席。

现有信号里唯一有点力道的是弃用率:在某个助手轮之后立刻结束、且没有后续的会话。它噪声大、会把「被拒了」和「答得完美」混在一起,但如果你本来就有,它仍然胜过没有。请把它当作一个「告诉你该去看一眼」的烟雾报警器,而不是那项测量本身。

STEP 2

有四样东西看着像拒答,而只有一样是。

在造检测器之前,先把分类定下来,因为这四种情形归属不同的责任人,而其中三种压根不是模型的策略问题。把每一个「智能体没把事做掉」的终止轮,恰好归入一个桶。

# Terminal non-completions, by cause and owner

POLICY REFUSAL      model declines on safety/policy grounds
                    owner: vendor policy + your system prompt
                    signal: hedging language, no tool call attempted

GUARDRAIL BLOCK     your own classifier or policy engine denied it
                    owner: you
                    signal: a logged decision with a rule id

TOOL DENIAL         the action was attempted and refused downstream
                    owner: your authz layer
                    signal: a 403 in the tool span

CAPABILITY MISS     the model tried and could not
                    owner: model choice / scaffold
                    signal: attempts, retries, then a concession

只有第一种是看不见的,而这恰恰是它占主导的原因。护栏拦截与工具拒绝都会产出一条带标识符的决策记录——如果你听了结构化拒绝与理由链里的建议,这两种本来就已被计数且可归因。能力未及会留下一串尝试的痕迹。而策略拒答除了散文什么都不留。

把分类做对,也正是让这个数字可据以行动的原因。「拒答涨了 3%」只会催生一场会议。「策略拒答涨了 3%,而护栏拦截与工具拒绝持平」则告诉你变化来自你的部署之外,而那是另一桩调查、另一位责任人,而且往往是一张给供应商的工单。

STEP 3

在智能体循环里,要在步骤上测量,而不是在回复上。

对一个聊天产品来说,给最终助手轮分类是正确的起点;对一个智能体来说,这大约只是故事的一半。智能体的拒答大多不会以一句终止的「我不能」浮上来——它们浮现为「一次本来完成得好好的、并且报告了成功的运行里,有一个步骤悄悄没发生」。

那个特征形状是:一个五步的计划、四次工具调用,以及一份照着「五件事都做了」来写的总结。什么都没失败。智能体拒掉了一个动作、继续往下走,产出了一份自信但带洞的报告——而这严格比一次看得见的拒答更糟,因为下游的自动化会去消费这份报告。这是结果评测与轨迹评测里那个问题在循环层面的表亲:结果看着没问题,而缺陷住在轨迹里。

有三个检测器能抓到其中大部分,按工作量递增。计划与执行的差分:如果你的智能体会输出计划,就把声明的步骤数与执行的工具调用数对一对、把差额标出来——便宜,而且能抓到常见情形。按步骤分类:把拒答分类器跑在链路里的每一个助手轮上,而不是只跑最后那一个,这能找出被总结糊过去的「运行中段拒答」。工具调用期望:对于那些「某个特定工具理应总会触发」的任务类型,对「完成了却没触发它」的运行告警。第三个最精确,也是唯一需要按任务类型配置的,所以请为你那两三个风险最高的任务类型建它,而不是为所有类型都建。

给你的链路结构加一个字段:在每个助手跨度上加 refusal_class,取值是分类里那四个再加一个 none。事后在归档链路上回填是可行的,但很贵;在摄入时就写下来,每个跨度只花一次分类器调用,而它把本页之后每一个问题都变成了一次 group-by。

STEP 4

造检测器:一个便宜的分类器,加一套固定的金丝雀集。

你需要两件仪器,而它们回答不同的问题。分类器告诉你真实流量上正在发生什么;金丝雀集告诉你模型是否在你脚下变了。少了任何一件,另一件都不成立——因为生产流量会漂移,而一套固定探针集并不是你的分布。

分类器可以很小,而且就该很小。一个紧凑模型,甚至第一版用一套调好的模式匹配,把终止的与中间的助手轮分进那四个桶。有两条实现上的注意事项比选哪个模型更要紧。第一,在信它之前,先在你自己的几百个轮次上标注一遍——拒答的措辞极度依赖领域,而一个通用检测器在任何搜索类产品里都会把一句正当的「未找到结果」误判成策略拒答。第二,为了成本,在抽样上跑它;而对你风险最高的那些任务类型,在每一轮上跑它——这与链路追踪与可观测性里其他一切的抽样逻辑相同。

金丝雀集是 200 到 400 条提示,取自你自己被回答过的流量,冻结下来,每天对着生产配置重放一遍。它唯一的职责是把输入分布按住不动,这样拒答率的任何移动都能归因于技术栈,而不是归因于用户恰好问了什么。要刻意覆盖一个跨度:明显无害的;在你的领域里确实敏感但应当被回答的;以及少数几条本应被拒的——最后这一组正是你检测「放松」的手段,而那是一个真实且被严重欠监控的失败方向。

已发表的过度拒答基准对选模型有用,对做生产监控则几乎无用。XSTest 大约是 250 条手工撰写的提示,把安全与不安全的措辞成对配好;OR-Bench 带着约八万条过度拒答提示,其中硬子集接近一千条,另有约六百条有毒对照。它们测的是一个被设计来激出夸张拒答的通用分布。而你的生产拒答是聚在你所在领域的词汇里的——一个病历产品、一个安全工具和一个儿童教育应用,对「哪些词有风险」意见不一,而且它们都不长得像一个基准。用这些基准去选模型;用你自己那套冻结集去盯住它。

STEP 5

对变化量告警,并且清楚是哪三样东西在推它。

绝对水平是无法解读的。不存在一个正确的拒答率——一个儿童产品理应比一个渗透测试工具拒得多得多,而 4% 这个数字是健康还是灾难,取决于那 4% 是什么。所以请对变化告警:金丝雀集拒答率、或按任务类型的生产拒答率,相对一条滚动基线发生了统计上有意义的移动。

它移动时,原因恰好只有三种,而那两件仪器一眼就能把它们分开。

  • 供应商改了策略。金丝雀率动了,你的部署没变,输入构成稳定。这就是本页存在的全部理由,因为这是那个「没有任何发布可供关联」的情形——拒答行为在一次次不带版本号的服务端更新之间漂移,这个现象在未固定的供应商默认值里有记录。抓一对例子、报给供应商,并决定要不要在有带日期别名可用时把模型钉住。
  • 你自己改了什么。金丝雀率动了,而且与你自己的发布相关。通常是一次系统提示词改动:为了解决一起事故而加进去的安全措辞,例行地会在互不相干的请求类别上付掉好几个点的拒答率,而没人量过这笔交易,因为没人在量拒答。请在 CI 里抓住它——合并前跑一遍金丝雀集,做法见评测驱动的智能体开发。
  • 流量变了。生产率动了,金丝雀率持平。模型没出问题;是新用户或一个新功能在问不一样的问题。在这种情形下,正确的反应往往是对技术栈什么都不做,而改去看看是谁来了。

请在率的旁边再握住一个数字,因为单看率会把你往错的方向推:误拒占比——在抽样的拒答里,有多少被人类复核者判为本应回答。把拒答率压下去不是目标;把误拒占比压下去才是。已发表的研究发现,「拒掉真正有害的提示」与「拒掉无害的提示」之间存在强正相关,所以这两者是一起动的,而盲目优化一边会让另一边变差。每周复核二十条抽样拒答,就足以让这个占比保持诚实,而这是你会建起来的最便宜的标注任务。

STEP 6

那张看板,以及第一周该做什么。

五块面板,就这些。超出这些的,是为某次调查服务的,不是为一面墙服务的。

# Refusal panel — five things, nothing else

1  policy refusal rate, by task type, trailing 28 days
   (the four classes stacked, so causes stay separable)

2  canary set refusal rate, daily, with deploy markers
   (your own deploys AND the dates vendor behaviour shifted)

3  plan-vs-execution gap: runs completing with missing steps
   (the agent-specific signal; chat products can skip it)

4  false-refusal share, weekly, from 20 sampled reviews
   (the only quality number here; the rate is just volume)

5  should-have-refused count on the canary controls
   (loosening detection — low-frequency, high-consequence)

# Alarm thresholds that work in practice

canary rate       +/- 2 percentage points vs 14-day baseline
production rate   +/- 25% relative, per task type
missing steps     any run in a tier-1 task type

第一周,按顺序做这四件事。把 refusal_class 加进链路结构并开始写它,哪怕一开始那个分类器只是个正则——字段才是贵的那部分,分类器是可替换的。从上个月被模型成功回答过的流量里冻结一套金丝雀集,并排一个每日重放。把分类器回溯跑在三十天的归档链路上,这样你不必等一个月就有了基线。然后手工复核二十条抽样拒答,因为正是那次复核会告诉你:你刚建起来的这个数字,到底有没有在测量你在意的东西。

有一件不该做的事:不要用「往系统提示词里加几句安抚」来回应拒答率上升。这是条件反射、偶尔奏效,而且不可测量——你为了修一个特定类别而改动了一个全局输入,而副作用落在你没有在看的那些请求类型上。请用一件具体的仪器去修那个具体的情形:一个更窄的工具权限、一句针对那类正当敏感情形的显式上下文说明,或者给那个任务类型换一个模型。然后盯住金丝雀集,确认你没有在别处把这笔账付掉。

今天就做:从上周随机抽出一百个助手轮,读一遍,数数其中有多少是拒答。如果答案多于两三个,那你就有一个活着的质量问题,而你现有监控里的任何东西都永远不会把它浮上来。接着去读拒答与能力门控,看这个率一开始为什么就这么易变;以及质量回退检测,看怎么把这件事接进你盯着的其他每一类回退所用的同一条告警路径。