如果你做过线上 LLM 应用,大概率遇到过这种场景:
用户点了一个 👎,备注里写着"答非所问"。客服系统里多了一条工单,产品同学截图发到群里,研发看了一眼 trace,发现 prompt、检索、工具调用、模型返回都"看起来没报错"。最后大家讨论半小时,结论是:这个问题不好复现,先观察。
两周后,同一类问题又出现了。只是用户换了一个说法,知识库换了一批文档,调用链多了一次工具超时。于是团队又从头排查一次。
这不是"模型不稳定"那么简单。真正的问题是:很多团队把 Bad Case 当成客服反馈处理,而不是当成生产数据资产处理。
传统软件里,一个线上 bug 至少会进入 bug tracker,有版本、优先级、复现步骤、修复记录和回归测试。可到了 LLM 应用里,最宝贵的失败样本经常只停留在聊天截图、运营表格、用户评价后台,和工程系统完全断开。结果就是:
- 你知道用户不满意,但不知道是哪一层错了;
- 你修了 prompt,但不知道有没有修掉那类问题;
- 你上线了新模型,但不知道旧 Bad Case 会不会复燃;
- 你积累了大量 trace,却没有形成可回放的评测集。
这篇文章不讲"如何做一个漂亮的评价按钮",也不做工具清单。我们聊一个更工程化的问题:如何把线上 Bad Case 变成一条持续运转的反馈闭环,让它真的参与下一次迭代和发布门禁。
1. Bad Case 的本质:不是一条差评,而是一组缺失的上下文
先定义一下本文里的 Bad Case。
Bad Case 不是所有用户差评。它至少要包含三部分信息:
- 用户当时想完成什么任务:不是原始输入文本,而是可归因的意图。
- 系统当时基于什么上下文做了决策:检索结果、工具返回、历史对话、权限、配置、模型参数。
- 哪里偏离了预期:错答、漏答、幻觉、拒答、格式错误、工具误用、兜底过早、兜底过晚。
如果只保存用户输入和模型输出,它只是"聊天记录"。如果只保存 thumbs up/down,它只是"满意度"。只有当它能被复现、归因、修复、回放时,它才是工程意义上的 Bad Case。
我更推荐把 Bad Case 看成一份最小事故报告,只不过它的粒度比 P0/P1 事故小得多。一个成熟的 LLM 应用应该允许每一次失败样本被追问:
- 这次失败属于哪一类?
- 下次遇到相似输入,我们希望系统做什么?
- 修复应该发生在知识库、检索、prompt、工具、路由、后处理,还是产品交互?
- 修复后,有没有自动化样本能防止它回归?
很多团队卡住,是因为他们把这些问题放在群聊里讨论,而不是放进数据结构里。
2. 一条可运行的反馈闭环长什么样
我在生产环境里更偏向把闭环拆成 7 层:
| 层级 | 目标 | 典型产物 |
|---|---|---|
| Capture 捕获 | 把用户反馈和运行 trace 绑定 | feedback_event |
| Normalize 规范化 | 统一多端、多业务反馈字段 | normalized_case |
| Redact 脱敏 | 去掉 PII 和敏感业务数据 | redacted_case |
| Triage 分诊 | 判断问题类型、影响面、优先级 | case_label / owner |
| Replay 回放 | 构造最小可复现样本 | eval_case |
| Fix 修复 | 进入 prompt/知识/工具/策略改动 | change_request |
| Gate 门禁 | 发布前回放,防止回归 | eval_report |
这 7 层里,最容易被忽略的是 Replay 和 Gate。很多团队能做到 Capture,也能做到人工分诊,但最后只是"知道了一个问题",没有把它变成下一次发布时必须通过的测试样本。
我的判断标准很简单:
一个 Bad Case 如果不能在 CI 或灰度环境里被回放,它就还没有真正进入工程闭环。
当然,不是所有样本都适合自动化。有些问题涉及复杂人类偏好,无法用单一断言表达。但至少应该做到:关键高频问题能被结构化保存;修复前后能对比;发布前能抽样回放;回放结果能影响上线决策。
3. 事件模型:别让 trace_id 和 feedback_id 分家
最小可用的反馈系统,不需要一上来接入庞大的观测平台。先把事件模型设计对。
下面是一份我会在早期项目里采用的字段结构:
ts
type FeedbackEvent = {
feedback_id: string;
trace_id: string;
session_id: string;
turn_id: string;
user_id_hash: string;
app: string;
env: 'prod' | 'gray' | 'staging';
route: string;
model_alias: string;
prompt_version: string;
retrieval_version?: string;
tool_schema_version?: string;
rating: 'good' | 'bad' | 'neutral';
reason_code?:
| 'wrong_answer'
| 'missing_context'
| 'hallucination'
| 'format_error'
| 'tool_error'
| 'too_slow'
| 'unsafe'
| 'other';
user_comment?: string;
input_snapshot_ref: string;
output_snapshot_ref: string;
retrieved_docs_ref?: string;
tool_calls_ref?: string;
fallback_event_ref?: string;
created_at: string;
};
这份结构里最重要的不是字段多,而是 feedback_event 必须能反查完整 trace。否则你只能看到"用户说不好",看不到系统当时为什么那样回答。
尤其是下面这些字段,经常决定后续能不能归因:
prompt_version:否则你不知道差评对应哪版 prompt。retrieval_version:否则你不知道召回策略是否已经变过。tool_schema_version:否则工具参数错误无法和 schema 变更关联。model_alias:不要只存具体模型名,业务上应该存路由后的别名,例如reasoning-high、chat-fast,便于对比策略变化。fallback_event_ref:很多 Bad Case 不是主链路失败,而是兜底策略把问题掩盖了。
反过来,只保存"用户问题 + 模型回答 + 评价"是不够的。LLM 应用的失败往往不在最后一段文本里,而在中间某一次检索、某个工具参数、某个上下文裁剪决策里。
4. 前端反馈:不要只问"好不好",要问"哪里不好"
很多产品喜欢在回答下面放 👍 / 👎。这当然比没有强,但对工程团队来说信号太弱。
更好的反馈入口应该分两层:
第一层保留轻量操作:
- 有帮助
- 没帮助
- 需要人工
- 答案过时
第二层在用户愿意继续反馈时,提供可归因选项:
- 没回答我的问题
- 引用了错误资料
- 编造了不存在的信息
- 格式不符合要求
- 操作失败 / 工具没执行
- 太慢了
- 涉及敏感/不合适内容
注意,这些选项不是给用户做精确诊断,而是给后端分诊一个初始 prior。用户选择"引用了错误资料",不代表一定是检索问题,但它能提高 triage 队列中 RAG 类问题的优先级。
还有一个细节:不要把反馈入口只放在最终回答下面。Agent 类应用里,用户可能对中间步骤不满:
- 检索到的资料明显不相关;
- 工具执行计划看起来危险;
- 自动生成的 SQL 不可信;
- 任务执行太久想中断;
- 某一步需要人工确认却没有停下来。
如果你的产品展示了中间步骤,就应该允许用户对中间步骤反馈。否则所有问题都会挤到最终回答上,归因会变得很脏。
5. 后端采集:异步、脱敏、可追溯
反馈采集链路有三个要求:不要影响主请求、不要泄露敏感数据、不要丢失关键索引。
一个简单的实现可以这样写。下面用 Node.js + SQLite 演示,生产环境可以换成 Postgres、ClickHouse 或事件队列。
ts
import crypto from 'node:crypto';
import Database from 'better-sqlite3';
const db = new Database('badcases.db');
db.exec(`
CREATE TABLE IF NOT EXISTS feedback_events (
feedback_id TEXT PRIMARY KEY,
trace_id TEXT NOT NULL,
turn_id TEXT NOT NULL,
user_id_hash TEXT NOT NULL,
rating TEXT NOT NULL,
reason_code TEXT,
user_comment TEXT,
prompt_version TEXT NOT NULL,
retrieval_version TEXT,
model_alias TEXT NOT NULL,
created_at TEXT NOT NULL
);
`);
function hashUserId(userId: string) {
return crypto.createHash('sha256').update(userId).digest('hex').slice(0, 24);
}
function redactComment(comment: string) {
return comment
.replace(/[\w.-]+@[\w.-]+\.\w+/g, '[EMAIL]')
.replace(/1[3-9]\d{9}/g, '[PHONE]')
.replace(/\b\d{15,18}\b/g, '[ID]');
}
export function recordFeedback(input: {
traceId: string;
turnId: string;
userId: string;
rating: 'good' | 'bad' | 'neutral';
reasonCode?: string;
userComment?: string;
promptVersion: string;
retrievalVersion?: string;
modelAlias: string;
}) {
const event = {
feedback_id: crypto.randomUUID(),
trace_id: input.traceId,
turn_id: input.turnId,
user_id_hash: hashUserId(input.userId),
rating: input.rating,
reason_code: input.reasonCode ?? null,
user_comment: input.userComment ? redactComment(input.userComment) : null,
prompt_version: input.promptVersion,
retrieval_version: input.retrievalVersion ?? null,
model_alias: input.modelAlias,
created_at: new Date().toISOString()
};
db.prepare(`
INSERT INTO feedback_events
VALUES (@feedback_id, @trace_id, @turn_id, @user_id_hash, @rating,
@reason_code, @user_comment, @prompt_version,
@retrieval_version, @model_alias, @created_at)
`).run(event);
return event.feedback_id;
}
这段代码很朴素,但有几个关键点:
- 用户标识只存 hash,避免反馈表变成隐私表。
- 用户备注先脱敏,再入库。
- trace_id、turn_id、prompt_version 是强制字段。
- 反馈写入和主对话链路解耦,失败不应该影响用户继续使用。
生产环境还要补两类控制:
- 采样和限流:恶意用户可以狂点差评,不要让反馈系统被刷爆。
- 数据保留策略:原始输入、检索文档、工具返回不应该无限期保存,尤其是企业知识库和个人数据场景。
6. 分诊:Bad Case 至少要分到"可修复层"
一个常见反模式是把所有问题都标成"模型效果不好"。这个标签几乎没有工程价值。
我更喜欢把 Bad Case 分到下面这些可修复层:
| 分类 | 典型表现 | 修复位置 |
|---|---|---|
| Knowledge Missing | 文档里没有答案 | 知识库/运营流程 |
| Retrieval Miss | 文档有,但没召回 | query rewrite / rerank / chunk |
| Context Pollution | 历史对话干扰当前任务 | context selection / memory policy |
| Tool Misuse | 工具选错或参数错 | tool schema / planner / validation |
| Format Drift | 输出不符合下游协议 | structured output / parser / retry |
| Policy Mismatch | 该拒答没拒,该执行没执行 | guardrail / approval flow |
| Latency Timeout | 用户等不到或中途失败 | timeout / streaming / async task |
| Product Ambiguity | 用户意图本身没被澄清 | 交互设计 / clarification |
分诊的目标不是一次性做到 100% 准确,而是把问题分配到正确的 owner。比如 Retrieval Miss 应该进入搜索/知识库链路,Tool Misuse 应该进入工具 schema 和 planner,Product Ambiguity 很可能不是模型组能单独解决的。
一个实用做法是先用规则做粗分,再由人工抽样校正:
ts
type CaseLabel =
| 'knowledge_missing'
| 'retrieval_miss'
| 'context_pollution'
| 'tool_misuse'
| 'format_drift'
| 'policy_mismatch'
| 'latency_timeout'
| 'product_ambiguity'
| 'unknown';
function classifyBadCase(c: {
reason_code?: string;
retrieved_doc_count?: number;
top_doc_score?: number;
tool_error_count?: number;
parse_error?: boolean;
fallback_triggered?: boolean;
latency_ms?: number;
}): CaseLabel {
if (c.latency_ms && c.latency_ms > 15000) return 'latency_timeout';
if (c.parse_error) return 'format_drift';
if (c.tool_error_count && c.tool_error_count > 0) return 'tool_misuse';
if (c.reason_code === 'missing_context' && (c.retrieved_doc_count ?? 0) === 0) {
return 'retrieval_miss';
}
if (c.reason_code === 'wrong_answer' && c.top_doc_score && c.top_doc_score < 0.35) {
return 'retrieval_miss';
}
if (c.fallback_triggered) return 'policy_mismatch';
return 'unknown';
}
这段规则不聪明,但它能把一部分问题从"大家看看"变成"这个归检索链路"。LLM 应用工程里,很多改进不是靠一次神奇 prompt,而是靠持续把问题分到能修的人手上。
7. 从 Bad Case 到 eval dataset:只保留最小可回放样本
最关键的一步来了:Bad Case 不能永远躺在反馈表里,它要进入评测集。
但直接把完整 trace 塞进 eval dataset 也不好。完整 trace 太大、太脏、包含隐私,还会绑定当时的线上环境。更好的做法是抽取"最小可回放样本"。
一个 eval case 可以长这样:
json
{
"case_id": "bc_20260621_001",
"category": "retrieval_miss",
"input": "帮我总结一下退款政策里企业客户的例外情况",
"context_fixture": {
"docs": [
{
"doc_id": "refund_policy_v3",
"title": "退款政策",
"content": "企业客户的年度合同不适用 7 天无理由退款,但可在 SLA 未达标时申请服务抵扣。"
}
]
},
"expected_behavior": [
"必须指出企业客户不适用 7 天无理由退款",
"必须提到 SLA 未达标时可申请服务抵扣",
"不得编造现金退款承诺"
],
"assertions": {
"must_include": ["不适用", "SLA", "服务抵扣"],
"must_not_include": ["无条件退款", "现金返还"]
},
"source_feedback_id": "fb_xxx",
"prompt_version_found": "support_agent_v18"
}
这里有几个原则:
- 输入要最小化:保留触发问题所需的输入,不要整个会话全塞进去。
- 上下文要 fixture 化:不要依赖线上知识库实时状态,否则回放不稳定。
- 期望行为要可判定:不要只写"回答要好",要写必须包含/不得包含/必须调用哪个工具。
- 保留来源:case 必须能追溯到原始 feedback_id,但评测集中不放隐私数据。
对 RAG 场景,fixture 特别重要。否则你今天回放失败,明天知识库更新后回放通过,你根本不知道是系统修好了,还是数据变了。
8. 评测不是打分,而是发布门禁
很多团队做 eval 的方式是定期跑一份报告:平均分 82,上周 80,提升 2 分。这个当然有用,但对工程发布来说还不够。
Bad Case 闭环里的 eval 更像回归测试。它应该回答:这次改动会不会让已知问题复发?
一个最小门禁可以分三档:
| 门禁类型 | 样本来源 | 阻断条件 |
|---|---|---|
| Must-pass | 高优先级线上 Bad Case | 任一失败阻断发布 |
| Regression | 最近 30 天修复样本 | 通过率低于阈值阻断 |
| Smoke | 核心业务路径样本 | 关键路径失败阻断 |
伪代码如下:
ts
type EvalResult = {
case_id: string;
passed: boolean;
category: string;
severity: 'p0' | 'p1' | 'p2';
reason?: string;
};
function shouldBlockRelease(results: EvalResult[]) {
const p0Failed = results.some(r => !r.passed && r.severity === 'p0');
if (p0Failed) return { block: true, reason: 'P0 bad case regression' };
const recentRegression = results.filter(r => r.category !== 'smoke');
const passRate = recentRegression.filter(r => r.passed).length / Math.max(1, recentRegression.length);
if (passRate < 0.95) {
return { block: true, reason: `Regression pass rate ${passRate.toFixed(2)} < 0.95` };
}
return { block: false };
}
注意,门禁不一定只跑在 CI。LLM 应用常常依赖外部模型、知识库、工具服务,纯 CI 环境未必足够真实。更合理的组合是:
- CI 跑 fixture 化样本,保证基础行为不回归;
- 灰度环境跑接近真实链路的样本;
- 小流量上线后监控 Bad Case 类别分布是否异常;
- 如果某类反馈突然升高,自动暂停扩大灰度。
发布门禁的价值不是让质量"绝对正确",而是防止团队在已知坑上反复摔倒。
9. 一个完整案例:从"答非所问"到可回放样本
假设我们有一个企业知识库助手。用户问:
我们年度合同客户如果 SLA 没达标,可以退款吗?
系统回答:
可以,所有客户都支持 7 天无理由退款。
用户点了"引用错误资料"。如果没有闭环,这就是一条差评。如果有闭环,它会经历下面的路径。
第一步,系统捕获反馈事件:
- trace_id = t_1001
- prompt_version = support_agent_v18
- retrieval_version = hybrid_v5
- reason_code = wrong_reference
- retrieved_docs = "个人用户退款政策""试用期协议"
- top_doc_score = 0.42
第二步,分诊规则发现:用户问企业客户,但召回结果主要是个人用户退款政策,于是标记为 retrieval_miss。
第三步,人工确认后生成 eval case:
- input 保留原问题;
- fixture 放入企业合同 SLA 条款;
- expected_behavior 要求说明"不适用 7 天无理由退款,但 SLA 未达标可申请服务抵扣";
- must_not_include 包含"所有客户""无理由退款"。
第四步,修复进入检索链路:
- query rewrite 增加"企业客户/年度合同/SLA"实体保留;
- rerank 增加合同类型匹配特征;
- prompt 增加"当政策按客户类型分层时,必须先确认客户类型"。
第五步,发布前回放:
- 旧版本失败;
- 新版本通过;
- 最近 30 天退款政策相关 Bad Case 通过率 97%;
- 灰度后
wrong_reference类反馈没有上升。
这才叫闭环。不是"我们优化了 prompt",而是"某类线上失败被转化为可回放样本,并进入后续发布门禁"。
10. 指标设计:少看平均分,多看流入和复发
Bad Case 系统上线后,团队很容易又掉进指标陷阱:看一个总满意度,看一个平均 eval 分数,然后每周汇报趋势。
我更建议盯下面 6 个指标:
| 指标 | 说明 | 为什么重要 |
|---|---|---|
| Feedback Attach Rate | 有多少反馈能关联到 trace | 关联不上就无法归因 |
| Triage SLA | Bad Case 多久完成分诊 | 防止反馈池堆积 |
| Replay Conversion Rate | 多少有效 Bad Case 进入 eval dataset | 衡量闭环是否真的发生 |
| Regression Escape Rate | 已修复样本线上复发比例 | 衡量门禁有效性 |
| Category Drift | 各类 Bad Case 占比变化 | 发现新版本引入的问题 |
| Fix Lead Time | 从反馈到修复上线耗时 | 衡量工程响应速度 |
尤其是 Replay Conversion Rate。它能一眼看出你的系统是不是只是在"收集差评"。如果一个月有 1000 条负反馈,最后只有 3 条进入可回放评测集,那说明闭环基本没跑起来。
当然,不是所有负反馈都应该进入 eval。低质量反馈、恶意反馈、无法复现反馈可以丢弃。但丢弃也应该有原因,而不是自然沉没。
11. 三个常见反模式
反模式一:把用户反馈直接喂给模型做总结
很多团队会说:"我们让模型每周总结差评,不就知道问题了吗?"
总结有用,但它不能替代工程闭环。因为总结通常丢失了 trace、版本、fixture 和门禁关系。最后你得到的是一份"用户经常觉得答非所问"的周报,却没有可执行的修复路径。
正确做法是:总结用于发现模式,样本仍然要结构化进入 case 系统。
反模式二:只修 prompt,不修系统
Bad Case 归因后,最容易被改的是 prompt,因为它最快。但很多问题根本不在 prompt:
- 检索没召回正确文档;
- 工具 schema 描述不清;
- 用户权限没有进入上下文;
- 下游 parser 太脆;
- 产品没有澄清入口。
如果所有问题都靠 prompt 扛,prompt 会越来越长,行为越来越不可预测,最后变成新的风险源。
反模式三:评测集越攒越大,但没人维护
Bad Case eval dataset 不是垃圾桶。样本需要生命周期:
- 重复样本要合并;
- 过时业务规则要更新;
- 已不支持的产品路径要归档;
- 高价值样本要提升为 must-pass;
- 低价值样本可以降采样。
否则一年后你会拥有几千条没人敢删、没人理解、跑一次要几个小时的 eval。那不是质量资产,是技术债。
12. 落地顺序:不要一口吃成平台
如果你现在还没有 Bad Case 闭环,我建议按 4 周推进。
第一周:打通 trace 和 feedback。
- 每条用户反馈必须带 trace_id、turn_id、prompt_version。
- 先不做复杂分诊,只保证能反查。
- 做基础脱敏和数据保留策略。
第二周:建立人工分诊表。
- 定义 8 个以内的问题类别。
- 每天抽样 20 条负反馈分诊。
- 记录 owner、修复建议和是否适合回放。
第三周:沉淀 eval case。
- 选 20 个高价值 Bad Case 转成 fixture。
- 为每个 case 写 expected_behavior。
- 接入一个最小回放脚本。
第四周:接入发布门禁。
- P0/P1 样本必须通过。
- 最近修复样本作为 regression suite。
- 灰度期间监控类别分布,异常则暂停扩大流量。
这个顺序的好处是,每一步都有独立收益。即使你还没做自动评测,只要 trace 和 feedback 绑定,排查效率就会明显提升。即使门禁还不完善,只要开始把样本 fixture 化,团队对问题的讨论也会从"感觉不好"变成"这个 case 为什么没过"。
13. 给工程团队的一张检查清单
最后给一张可以直接拿去评审的 checklist。
反馈入口
- 用户反馈能绑定 trace_id 和 turn_id
- 支持原因码,而不是只有 👍/👎
- 中间步骤也能反馈,尤其是工具调用和检索结果
数据采集
- 保存 prompt_version、retrieval_version、tool_schema_version
- 用户标识 hash 化
- 原始文本和业务数据有脱敏/保留期限
- 反馈写入不影响主链路
分诊归因
- 有不超过 8 个可修复层分类
- 每类问题有明确 owner
- 低质量反馈有丢弃原因
- 高频类别能按版本趋势观察
评测沉淀
- 高价值 Bad Case 能转成最小可回放样本
- eval case 使用 fixture,不依赖线上数据漂移
- expected_behavior 可判定
- 样本能追溯到原始 feedback_id
发布门禁
- P0/P1 Bad Case 回归失败会阻断发布
- 最近修复样本进入 regression suite
- 灰度期间监控 Bad Case 类别漂移
- 门禁结果能写回发布记录
结语:LLM 应用的质量,不是靠一次上线验收出来的
LLM 应用和传统软件最大的不同之一,是失败分布会不断变化。用户问法会变,知识库会变,工具会变,模型路由会变,产品边界也会变。你不可能靠上线前的一次验收覆盖所有情况。
真正可持续的质量体系,是让线上失败持续回流到工程系统里:每一次 Bad Case 都有机会变成下一次发布的测试样本,每一次修复都有回放证据,每一次模型或 prompt 变更都要面对历史失败样本。
所以,别再把用户差评丢进客服表了。
把它变成 trace,变成 case,变成 eval,变成 gate。
这条链路跑起来以后,LLM 应用才会从"靠感觉调 prompt"进入真正的工程迭代。