LLM 应用的 Bad Case 反馈闭环工程:别再把用户差评丢进客服表了

如果你做过线上 LLM 应用,大概率遇到过这种场景:

用户点了一个 👎,备注里写着"答非所问"。客服系统里多了一条工单,产品同学截图发到群里,研发看了一眼 trace,发现 prompt、检索、工具调用、模型返回都"看起来没报错"。最后大家讨论半小时,结论是:这个问题不好复现,先观察。

两周后,同一类问题又出现了。只是用户换了一个说法,知识库换了一批文档,调用链多了一次工具超时。于是团队又从头排查一次。

这不是"模型不稳定"那么简单。真正的问题是:很多团队把 Bad Case 当成客服反馈处理,而不是当成生产数据资产处理。

传统软件里,一个线上 bug 至少会进入 bug tracker,有版本、优先级、复现步骤、修复记录和回归测试。可到了 LLM 应用里,最宝贵的失败样本经常只停留在聊天截图、运营表格、用户评价后台,和工程系统完全断开。结果就是:

  • 你知道用户不满意,但不知道是哪一层错了;
  • 你修了 prompt,但不知道有没有修掉那类问题;
  • 你上线了新模型,但不知道旧 Bad Case 会不会复燃;
  • 你积累了大量 trace,却没有形成可回放的评测集。

这篇文章不讲"如何做一个漂亮的评价按钮",也不做工具清单。我们聊一个更工程化的问题:如何把线上 Bad Case 变成一条持续运转的反馈闭环,让它真的参与下一次迭代和发布门禁。

1. Bad Case 的本质:不是一条差评,而是一组缺失的上下文

先定义一下本文里的 Bad Case。

Bad Case 不是所有用户差评。它至少要包含三部分信息:

  1. 用户当时想完成什么任务:不是原始输入文本,而是可归因的意图。
  2. 系统当时基于什么上下文做了决策:检索结果、工具返回、历史对话、权限、配置、模型参数。
  3. 哪里偏离了预期:错答、漏答、幻觉、拒答、格式错误、工具误用、兜底过早、兜底过晚。

如果只保存用户输入和模型输出,它只是"聊天记录"。如果只保存 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-highchat-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 是强制字段。
  • 反馈写入和主对话链路解耦,失败不应该影响用户继续使用。

生产环境还要补两类控制:

  1. 采样和限流:恶意用户可以狂点差评,不要让反馈系统被刷爆。
  2. 数据保留策略:原始输入、检索文档、工具返回不应该无限期保存,尤其是企业知识库和个人数据场景。

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"进入真正的工程迭代。

相关推荐
HjhIron1 小时前
🤖 一文搞懂 AI Agent 核心概念:从 LLM 到 Tools,手写一个“股票查询 Agent”
agent
贵慜_Derek2 小时前
《从零实现 Agent 系统》连载 32|闭集 IE 与小模型:分类、意图与字段抽取
人工智能·架构·agent
葫芦和十三3 小时前
图解 MongoDB 03|CRUD 全链路:一条 find 怎么穿过 WiredTiger
后端·mongodb·agent
葫芦和十三11 小时前
图解 MongoDB 04|索引模型:每建一个索引,就是在 B+-tree 森林里多栽一棵
后端·mongodb·agent
冬奇Lab14 小时前
AI Workflow 定义的四次演进:从 Markdown 到 JS 脚本,再到分布式多 Agent
javascript·人工智能·agent
潘锦17 小时前
聊聊 Harness:从 Agent 到组织
agent
leeyi21 小时前
Prompt 模板:用变量组装发给 AI 的消息
aigc·agent·ai编程