评测驱动优化:失败样本回流 Prompt 的闭环
AgentScope 2.0 评测驱动优化:失败样本回流 Prompt 的闭环
前面三篇把评测搭起来了:规则打分、LLM Judge、样本量校准。但这套东西有个终点问题——报告生成完,然后呢?
这篇讲闭环:评测 → 定位 → 喂回 → 回归。
全程纯规则、纯离线、零成本。连 LLM 裁判都不用请。
代码在 WSL Ubuntu 24.04 / Python 3.14 实测,8 条测试集,v1 达标率 0.75,v2 是 1.0。
但数字不是重点。重点是中间那一步:怎么判断该改哪里。
一、环境
python 012_eval_loop.py
纯标准库,不需要任何 Key。被测对象是一个 Prompt 驱动的 mock 客服——行为由 prompt_version 决定,这样可以干净地演示"改 Prompt 会怎样"。
真实场景里这一层换成"大模型 + system prompt",闭环逻辑完全一样。
二、测试集:回归的考卷
CASES = [
{"id": 1, "cat": "导购", "input": "玻尿酸保湿霜多少钱",
"expect_price": "AS-001", "need_cite": False},
{"id": 2, "cat": "导购", "input": "积雪草精华多少钱",
"expect_price": "AS-002", "need_cite": False},
# ... 共 8 条
]
四个 expect 字段,各管一个维度:
| 字段 | 对应维度 | 作用 |
|---|---|---|
expect_role | 意图路由 | 期望路由到哪个角色 |
expect_block | 护栏 | 是否该被拦 |
expect_price | faithfulness | 期望报的 SKU(查幻觉) |
need_cite | citation | 是否该有引用 |
need_cite 这个字段看着小,但它决定了引用维度在工具类场景里是"跳过"还是"失败"。上一篇讲过,一刀切会把不该扣的分扣了。
三、被测对象:Prompt 版本驱动
class PromptAgent:
"""mock 客服:行为由 prompt 版本决定。"""
def __init__(self, version):
self.version = version
def reply(self, query):
if self.version == "v1":
# v1:凭记忆报价
return self._answer_from_memory(query)
else:
# v2:查库报价
return self._answer_from_db(query)
这个设计是整篇的关键。
同一个测试集、同样的输入,唯一的变量是 Prompt 版本。这样 v1 和 v2 的分数差,就能干净地归因到 Prompt 上——不会混入"这个 case 本身就难"之类的干扰。
真实场景里用不了这么干净(模型有随机性、上下文有影响),但只要温度设 0、case 一样,差异还是能归因的。
四、闭环四步
# 第 1 步:评测
v1_result = evaluate(agent_v1, CASES)
# 第 2 步:定位(看失败明细)
failures = [r for r in v1_result["details"] if not r["pass"]]
# 第 3 步:喂回(改 Prompt)
agent_v2 = PromptAgent("v2")
# 第 4 步:回归
v2_result = evaluate(agent_v2, CASES)
compare(v1_result, v2_result)
四步里,第 2 步最容易被跳过。因为代码上它只是一行 filter,看起来没什么。
但它是唯一决定"改哪里"的一步。
第 1 步告诉你"错了 2 条",第 4 步告诉你"现在对了"。只有第 2 步告诉你"为什么错"。
跳过第 2 步,你就只能按 case 修——看到玻尿酸报 328,就往规则里写"玻尿酸是 268"。测试能过,下次换个产品照样错。
五、真实输出
【第 1 步 · 评测】v1 上线前先跑一遍测试集
v1 Prompt:用户问价格时,凭你的知识直接回答。
[v1] 共 8 条 · 达标 6 · 达标率 0.75
维度通过率 {faithfulness: 6/8, citation: 8/8,
tool_accuracy: 8/8, guard: 8/8}
❌ 未达标 2 条:
[1] 玻尿酸保湿霜多少钱 faithfulness=0
[8] 积雪草精华多少钱 faithfulness=0
【第 2 步 · 定位】看失败明细,找到根因
两条失败都是「价格幻觉」:
玻尿酸报 328(库内 268)、积雪草报 358(库内 398)。
根因是同一个:Prompt 没写『价格必须查商品库』,模型凭记忆报价。
【第 3 步 · 喂回】加一句:必须查商品库(query_price),禁止凭记忆报价
【第 4 步 · 回归】v2 再跑一遍
[v2] 共 8 条 · 达标 8 · 达标率 1.0
对比:📈 进步(较 v1 达标率 +0.250)
这段输出里最值得看的是那两个数字:328 和 358。
玻尿酸库内 268,报 328,高了 60。
积雪草库内 398,报 358,低了 40。
一个高、一个低。
如果两个都报高,你可以解释成"模型倾向于加价",那是个系统性偏差,可以针对修。
一个高一个低,说明它没有倾向——它不是估错了,是纯粹在猜。
这个区别直接决定了修法:面对随机猜测,"不要加价"这种约束毫无用处。
六、根因可能在三层,改错层等于白改
定位的本质是判断根因在哪一层。
| 层 | 典型根因 | 改法 | 失败特征 |
|---|---|---|---|
| Prompt 层 | 指令没写、写得含糊 | 加约束 | 同一类问题全错 |
| 词表层 | 关键词漏了某种说法 | 补词 | 同类里错一部分 |
| 代码层 | 分支逻辑有 bug | 改逻辑 | 特定条件触发 |
改错层的代价是:不解决问题,还可能引入新问题。
举个例子。用户说"我的东西到哪了",客服识别不出来,当闲聊答了。
按 case 修,你会在 Prompt 里加"用户问物流要查订单"。
但根因在词表——售后词表里只有"订单"“物流”,没有"东西到哪"这种口语。改 Prompt 没用,因为这句话压根没被路由到售后。
这类错误有个共同特征:它压根没进到你改的那一层。
七、把判别写成函数
上面那张表的"失败特征"那一列,可以变成代码。
from collections import Counter
def diagnose(failures, total_by_cat):
"""根据失败分布判断根因层级。
failures: [(cat, dim), ...] 失败的类别和维度
total_by_cat: {cat: 该类别总数}
返回 {cat: 'prompt_or_code' | 'lexicon'}
"""
fail_by_cat = Counter(c for c, _ in failures)
verdict = {}
for cat, total in total_by_cat.items():
bad = fail_by_cat.get(cat, 0)
if bad == 0:
continue
# 全错 → 上层;部分错 → 下层
verdict[cat] = "prompt_or_code" if bad == total else "lexicon"
return verdict
运行结果:
>>> diagnose([("价格", "faithfulness"), ("价格", "faithfulness")],
... {"价格": 2, "推荐": 3, "物流": 3})
{'价格': 'prompt_or_code'}
问价格的 2 条全错 → 判为上层(Prompt 或代码),一次改对。
如果 5 条问价格的里错了 2 条:
>>> diagnose([("价格","faithfulness")]*2, {"价格": 5})
{'价格': 'lexicon'}
部分错 → 判为下层(词表)。因为词表类问题只对"没覆盖的说法"失效,覆盖到的正常。
为什么这个判别成立
词表是"白名单"机制:覆盖到的说法通过,没覆盖的漏。所以表现是部分错。
Prompt 是"全局指令":缺了就是整类失效。所以表现是全错。
代码 bug 是"条件触发":只在特定输入组合下出问题。也算部分错,但错的方式有规律(比如"同时问两个产品时")。
这个判别不是百分之百准,但它能挡住最常见的错判——把"整类缺指令"当成"个别说法没覆盖",然后往词表里加一堆词,问题还在。
八、baseline:让进步可度量
class BaselineStore:
"""记录每一版的达标率,支持与上一版对比。"""
def __init__(self):
self.history = []
def record(self, version, rate):
self.history.append({"version": version, "rate": rate})
def compare(self):
if len(self.history) < 2:
return "首个 baseline,无对比"
prev = self.history[-2]["rate"]
cur = self.history[-1]["rate"]
diff = cur - prev
if diff > 0.01:
return f"📈 进步(+{diff:.3f})"
if diff < -0.01:
return f"📉 退化({diff:.3f})"
return "➖ 持平"
0.01 这个阈值不是装饰。不设它的话,浮点误差会让 0.7500001 和 0.75 显示成"进步"。
真实场景里这个阈值要按样本量定——8 条题的波动是 ±30 个百分点,用 0.01 当阈值毫无意义。
8 条题的回归,只能看出"大改有没有效果",看不出"小改有没有进步"。
九、回归真正的作用:不是确认修好,是确认没修坏
v2 跑出 8/8,看起来完美。
但注意:这次回归没有证明"没修坏别处"。
8 条样本里,只有 2 条涉及价格。改了"必须查库"之后,如果某条本该"先引导咨询再报价"的 case 被强制查库打断了,这次测试看不出来——因为这种 case 在 8 条里可能一条都没有。
回归的价值有两半:
- 前一半:确认修好了(这次完成了)
- 后一半:确认没修坏(这次没完成,因为测试集不含相关场景)
要让后一半成立,测试集里必须有"容易被这次改动影响"的 case。
改价格逻辑,就该有"用户问价格但实际在犹豫"的 case,看它会不会被强制查库搞得更生硬。
十、Prompt 会越改越长
每次回流加一句,加十次之后:
价格必须查商品库,禁止凭记忆报价。
不要推荐竞品,也不要贬低竞品。
涉及过敏必须转人工,不要自行判断。
不承诺赔偿金额,不说"全额退款"。
回答控制在 200 字以内。
不要用"亲"开头,但可以用"您"。
这里已经开始打架了:
- “不要推荐竞品” vs “客户问竞品时要如实对比”
- “200 字以内” vs “说清退货流程”
规则一多,模型会优先遵守最后出现的和语气最强的,前面的被稀释。
我的做法是分三组管理:
| 组 | 内容 | 冲突时 |
|---|---|---|
| 硬红线 | 不编造、不承诺、涉法转人工 | 最高优先级 |
| 行为约束 | 查库报价、控制长度、不主动推销 | 让步于红线 |
| 风格偏好 | 称呼、语气、emoji | 可放弃 |
每次加规则前先问它属于哪组。如果它和红线冲突,说明这条不该加。
十一、测试集会越攒越厚
回流有个规定动作:新发现的 bad case 加进测试集,防复发。
后果是测试集越来越大。一开始 8 条,三个月后 300 条。
300 条跑一次十几分钟。每次改 Prompt 都跑全量,不现实。
分两层:
| 层 | 规模 | 频率 | 用途 |
|---|---|---|---|
| 主集 | 50 条 | 每次改动必跑 | 不能退步的底线 |
| 扩展集 | 全部历史 bad case | 每周或涉及相关模块时跑 | 防复发 |
扩展集的分数不进主分,只做参考。这样既保住了"防复发",又不拖死日常流程。
十二、常见报错
| 现象 | 原因 | 处理 |
|---|---|---|
| 改了 Prompt 分数没动 | 根因不在 Prompt 层 | 用 diagnose 重新判断 |
| 测试通过但线上还错 | 按 case 修了,没抽根因 | 从共同点往上抽一层 |
| 每改一次都要加一句 | 没做规则分组 | 分硬红线/行为/风格三组 |
| baseline 显示"进步"但只是浮点误差 | 阈值设太小 | 按样本量定阈值 |
| 回归通过但用户反馈变差 | 只测了修好的,没测"可能修坏的" | 补相关场景的 case |
| 测试集跑不动 | 全量跑 | 分主集 50 + 扩展集 |
十三、接口速查
| 类 / 函数 | 关键点 | 作用 |
|---|---|---|
PromptAgent | version | 版本驱动的被测对象 |
evaluate | 测试集 | 跑一遍打分 |
diagnose | failures、total_by_cat | 判断根因层级 |
BaselineStore | record、compare | 版本间对比 |
CASES | 四个 expect 字段 | 测试集定义 |
十四、一句话总结
回流的单位不是 case,是根因。
单条 case 只告诉你"这里错了",改代码要改的是"为什么错"。从 case 到根因中间要做一次抽象,这一步最容易偷懒。
而判断根因在哪一层,看失败的分布:全错是上层缺指令,部分错是下层漏覆盖。
下一篇讲部署,把这套东西从脚本变成服务。技术难度低于这一篇的判断难度——跑起来是工程问题,判断改哪里是经验问题。
代码已通过 py_compile 语法校验。运行环境:Python 3.11+ / 纯标准库 / 离线零成本。
更多推荐
所有评论(0)