Chapter 10

端到端实战:为客服 Agent 搭一套完整评估

把前面 9 章的内容串起来。场景:一个电商客服 Agent。输入:用户自然语言问题。期望:在知识库里找答案、必要时查订单、友好回复、必要时升级人工。

系统简介

假设我们有一个上线中的客服 Agent,架构如下:

用户消息 │ ▼ 意图识别 (LLM) │ ├──▶ 订单查询 ──▶ tool: get_order(order_id) ├──▶ 退款政策 ──▶ tool: search_kb(query) ├──▶ 商品咨询 ──▶ tool: search_product(query) ├──▶ 投诉 ──▶ tool: create_ticket() + 升级人工 └──▶ 其他 ──▶ LLM 兜底 │ ▼ 响应生成 (LLM,含引用) │ ▼ Guardrail 过滤 │ ▼ 返回用户

目标是为这套系统建立可持续运营的评估体系,能回答 3 个问题:

  1. 新 prompt / 新模型是不是真的变好了?
  2. 线上是不是稳定在合格水位?
  3. 具体哪类问题做得差、为什么?

Step 1:从线上工单采样

我们有 30 天、18 万条真实工单。不能 all-in,先按意图/难度/反馈分层采样:

import pandas as pd
from sklearn.cluster import KMeans
from sklearn.feature_extraction.text import TfidfVectorizer

df = pd.read_parquet("tickets_last_30d.parquet")

# 1) 先按意图分桶
buckets = {
    "order_status":   df[df.intent == "order_status"],
    "refund":         df[df.intent == "refund"],
    "product_q":      df[df.intent == "product_q"],
    "complaint":      df[df.intent == "complaint"],
    "other":          df[df.intent == "other"],
}

# 2) 每桶内按难度/反馈再采 40 条:
#    - 20 条典型(多数 query)
#    - 10 条长尾(KMeans 聚类的小簇)
#    - 10 条历史翻车(👎 / regenerate / human takeover)
def stratified_sample(bucket, n_typical=20, n_tail=10, n_bad=10):
    vec = TfidfVectorizer(max_features=512).fit_transform(bucket.user_input)
    clusters = KMeans(n_clusters=20, random_state=42).fit_predict(vec)
    bucket = bucket.assign(cluster=clusters)

    typical = bucket[bucket.cluster.isin(bucket.cluster.value_counts().head(3).index)].sample(n_typical)
    tail    = bucket[bucket.cluster.isin(bucket.cluster.value_counts().tail(5).index)].sample(n_tail)
    bad     = bucket[bucket.had_issue == True].sample(n_bad)
    return pd.concat([typical, tail, bad])

samples = pd.concat(stratified_sample(b) for b in buckets.values())
# 5 桶 * 40 = 200 条 golden 候选

Step 2:人工标注,产出 golden set

200 条候选由 2 位客服专家独立标注:

完成后跑 Cohen's κ:

from sklearn.metrics import cohen_kappa_score
κ = cohen_kappa_score(expert_A_labels, expert_B_labels, weights="quadratic")
print(f"Inter-annotator κ = {κ:.2f}")
# κ = 0.74  ✅ good agreement
# 低于 0.6 的样本组织讨论,达成共识再入库

Step 3:定义 6 个维度指标

一个客服 Agent 只看"是否正确"远远不够。6 维评估:

维度定义实现
① Task Success问题是否被正确解决LLM Judge 对照 expected_answer
② Tool Correctness工具是否调对、参数对代码匹配 required_tools
③ Faithfulness回复事实是否出自 KB/订单RAGAS Faithfulness
④ Tone语气是否专业友好LLM Judge 1-5 打分
⑤ Constraints硬约束(必含/必不含词)代码检查
⑥ Efficiency步数、token、延迟Trace 统计

Scorer 实现

from braintrust import Score
from autoevals import Factuality

def score_task_success(output, expected):
    # LLM Judge:pointwise 1-5
    result = llm_judge(JUDGE_TASK_SUCCESS.format(
        question=output.question, answer=output.reply, expected=expected,
    ))
    return Score(name="TaskSuccess", score=result["score"] / 5, metadata=result)

def score_tool_correctness(output, expected_tools):
    actual = [(t.name, t.args) for t in output.trajectory]
    # 按工具名 + 关键参数匹配,允许顺序不同
    name_match = set(t[0] for t in actual) >= set(t[0] for t in expected_tools)
    arg_match = all(args_match(a, e) for a, e in zip(actual, expected_tools))
    score = float(name_match and arg_match)
    return Score(name="ToolCorrectness", score=score)

def score_faithfulness(output):
    return Score(
        name="Faithfulness",
        score=ragas_faithfulness(output.question, output.contexts, output.reply),
    )

def score_tone(output):
    result = llm_judge(JUDGE_TONE.format(answer=output.reply))
    return Score(name="Tone", score=result["score"] / 5)

def score_constraints(output, must_contain, must_not_contain):
    ok = all(kw in output.reply for kw in must_contain)
    ok &= all(kw not in output.reply for kw in must_not_contain)
    return Score(name="Constraints", score=float(ok))

def score_efficiency(output):
    n_steps = len(output.trajectory)
    penalty = max(0, (n_steps - 3) / 10)   # 3 步以内是理想
    return Score(name="Efficiency", score=max(0, 1 - penalty),
                 metadata={"n_steps": n_steps, "tokens": output.total_tokens})

Step 4:Judge Prompt 与校准

JUDGE_TASK_SUCCESS = """你是客服质量审核专家。给下面这条回复打分(1-5)。

维度:是否真正解决了用户问题。忽略语气和格式,只看核心问题解决度。

5 — 完整解决,信息准确完整
4 — 基本解决,有小瑕疵但不影响
3 — 部分解决,缺关键信息
2 — 严重偏题或错误
1 — 完全答非所问或有害

## 例子
(标注员共识的 3 个 few-shot 例子)

## 判断
用户问题: {question}
参考回复: {expected}
实际回复: {answer}

先 2 句话分析,再返回 JSON: {{"reasoning": "...", "score": 1-5}}"""

校准流程(第 4 章讲过的闭环):

# 挑 50 条已人工打分的 case,对比 Judge
human = []   # 人工 1-5
judge = []   # Judge 1-5

for case in calibration_set:
    h = case.human_score
    j = judge_task_success(case.output, case.expected)
    human.append(h); judge.append(j)

from sklearn.metrics import cohen_kappa_score
κ = cohen_kappa_score(human, judge, weights="quadratic")
print(f"Judge-Human κ = {κ:.2f}")
# 目标 ≥ 0.6。本例首版 0.48,加了 few-shot 后 0.66 ✅

Step 5:接入 Braintrust 跑评估

from braintrust import Eval, init_dataset

dataset = init_dataset(project="customer-support", name="golden-v1")

def agent_task(case):
    # 跑我们的 agent,返回带 trajectory 的结构化输出
    return run_agent(case["user_input"])

Eval(
    "customer-support",
    data=dataset,
    task=agent_task,
    scores=[
        score_task_success, score_tool_correctness, score_faithfulness,
        score_tone, score_constraints, score_efficiency,
    ],
    experiment_name="v7-sonnet-4-6",
    metadata={
        "model": "claude-sonnet-4-6",
        "prompt_version": "v7",
        "kb_snapshot": "2026-04-28",
    },
    max_concurrency=10,
)

Braintrust UI 里看到:

Step 6:CI 集成,当作"质量门禁"

每个 PR 如果改动了 prompt、tool、agent 代码,自动跑回归集 + Judge,不过线挂红:

# .github/workflows/evals.yml
name: Evals Gate
on:
  pull_request:
    paths:
      - "prompts/**"
      - "agent/**"
      - "tools/**"

jobs:
  evals:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: { python-version: "3.12" }
      - run: pip install -r requirements.txt
      - name: Run golden set
        env:
          OPENAI_API_KEY:     ${{ secrets.OPENAI_API_KEY }}
          ANTHROPIC_API_KEY:  ${{ secrets.ANTHROPIC_API_KEY }}
          BRAINTRUST_API_KEY: ${{ secrets.BRAINTRUST_API_KEY }}
        run: python -m evals.run --dataset golden-v1 --experiment ci-${{ github.sha }}
      - name: Check thresholds
        run: python -m evals.gate --min-task-success 0.80 --min-tool 0.90
# evals/gate.py
import sys, argparse
from braintrust import summarize_experiment

p = argparse.ArgumentParser()
p.add_argument("--min-task-success", type=float, default=0.80)
p.add_argument("--min-tool", type=float, default=0.90)
args = p.parse_args()

summary = summarize_experiment("customer-support", experiment="ci-latest")
ts  = summary.scores["TaskSuccess"].mean
tc  = summary.scores["ToolCorrectness"].mean

if ts < args.min_task_success:
    print(f"❌ TaskSuccess {ts:.2f} < {args.min_task_success}"); sys.exit(1)
if tc < args.min_tool:
    print(f"❌ ToolCorrectness {tc:.2f} < {args.min_tool}"); sys.exit(1)
print(f"✅ TaskSuccess={ts:.2f}  ToolCorrectness={tc:.2f}")
阈值怎么定? 上线前跑 3 次当前 main 分支得到基线,阈值设为"基线 - 0.02"(允许 2pp 抖动,但不允许明显退步)。每季度重校基线,防止门禁变"橡皮图章"。

Step 7:灰度发布

回归过了只是第一关。接下来走第 8 章的流程:

  1. Shadow 1 天:v7 不发给用户,离线跑 Judge 对比 v6,看 Faithfulness / Tone 有无突变
  2. Canary 1%:观察 1 天的真实用户 thumbs / regenerate 率
  3. Canary 5% / 25% / 100%:每档 1-2 天,主指标显著胜出且护栏不破
  4. 任何档发现退步 → 立即回滚
# canary_gate.py — 每次扩量前跑
def can_expand(variant="v7", baseline="v6"):
    m = query_metrics(hours=24)
    checks = {
        "thumbs_down_rate":  m[variant].down_rate <= m[baseline].down_rate * 1.1,
        "regen_rate":        m[variant].regen <= m[baseline].regen * 1.1,
        "p95_latency_ms":    m[variant].p95 <= m[baseline].p95 * 1.2,
        "cost_per_session":  m[variant].cost <= m[baseline].cost * 1.3,
        "judge_sample_score": m[variant].judge >= m[baseline].judge - 0.02,
    }
    failed = [k for k, ok in checks.items() if not ok]
    return (not failed), failed

Step 8:持续运营的日常

上线只是起点。每天/每周/每季度的固定动作:

频率动作
实时(分钟级)Guardrail 告警 + 成本/错误率看板
小时级2% 线上流量采样跑 Judge,结果写回 trace,看曲线
日级Judge 均分 + 指标看板 + 日成本 + 事故回顾
周级线上失败 case 进入 regression set;评估集增长
月级Golden set 版本升级,重新校准 Judge
季级审计指标阈值是否仍合理;模型/prompt 大迭代

最终分数卡示例

Experiment: v7-sonnet-4-6 (2026-04-30)
Dataset: golden-v1 (200 cases)
Model: claude-sonnet-4-6
Prompt: v7 (tone-tweak)

┌─────────────────────┬────────┬─────────┬──────────┐
│ Metric              │  Mean  │ vs v6   │ Baseline │
├─────────────────────┼────────┼─────────┼──────────┤
│ TaskSuccess         │  0.86  │ +0.04 ↑ │  0.82    │
│ ToolCorrectness     │  0.93  │ +0.01   │  0.92    │
│ Faithfulness        │  0.91  │ +0.03 ↑ │  0.88    │
│ Tone                │  0.88  │ +0.11 ↑ │  0.77    │
│ Constraints         │  1.00  │   —     │  1.00    │
│ Efficiency          │  0.79  │ -0.02   │  0.81    │
└─────────────────────┴────────┴─────────┴──────────┘

By intent:
  order_status:  0.91   refund: 0.84   product_q: 0.87
  complaint:     0.78   other:  0.72

By difficulty:
  easy:   0.94   medium: 0.86   hard: 0.68

Cost per session (avg):  $0.011
p95 latency:              2.4s

Gate decision: ✅ PASS (TaskSuccess 0.86 ≥ 0.80, ToolCorrectness 0.93 ≥ 0.90)
Ready for canary rollout.
一个看板就能决策 产品/工程/运营看到这张表都知道:Tone 涨得最猛(本次 prompt 改动的目标),Efficiency 略退但在容忍内,hard 案例还是短板——下一季度优化方向定好了。这就是评估体系给团队的复利。

常见踩坑

坑 1:Golden set 永不更新
半年后业务已变,评估集还在测老问题——全绿不代表线上好。每月必须有线上 case 入库。
坑 2:Judge 用同门模型
用 GPT 评估 GPT,自恋偏差让分数偏高。Judge 必须异源(第 4 章)。
坑 3:只看均分,不看分布
均分 0.85 可能掩盖了 5% 严重翻车。一定要看 tail(最差 5% 做了什么)。
坑 4:CI 阈值是摆设
为了过关偷偷降阈值。要有机制:阈值变更必须 review + 说明原因。
坑 5:只评估最终输出
结果碰巧对,过程全错——工具参数乱填。必须有 trajectory / tool-level 评估。

验收清单:你的评估体系达标了吗?

全书回顾

写在最后 评估体系不是一次性建好的工程,而是和你的 LLM 产品共生的"第二产品"。花 10% 精力维护它,能给另外 90% 精力的价值打上 10 倍杠杆。从一条最简单的 golden set 开始,今天就开始。