Python 智能体开发实战 09:改了提示词,到底是变好还是变坏?搭一套能打分的离线评测

0 阅读15分钟

改提示词不能靠感觉

给「启明英语」的小 E 调批改提示词,是我做这个项目以来最心虚的一段时间。

我改了一句话——"只指出非常确定的错误,拿不准的先别报"。本意是让批改语气温和点,别把刚起步的学生吓跑。改完随手试了三个例子,感觉挺好,就上线了。结果第二天,运营转来一条家长的消息:"小 E 最近是不是出问题了?孩子作文里明摆着的错,它都不指出来了。"

我回去翻,发现这句"拿不准的先别报",让模型把"中英混写""超短句"这类它本来就不确定的句子,通通放过了。而我当初"随手试的那三个例子",恰好都是它最拿手的普通时态题——我等于用三个最好的例子,给自己上了一道障眼法。

从那天起我就明白了一件事:改提示词这件事,没有评测集就是赌博。这一篇,我们把"感觉"换成"分数"。

一、先扫盲:为什么"感觉"靠不住

1. 你试的那几个例子,永远是最好的那几个

这是人类的天性:改完代码,你会下意识挑几个你最熟悉、最可能通过的例子来验证。它们恰恰是模型最不容易出错的那一类。结果就是"试了三个例子全对"——可上线后,出错的全是你没试过的那一类句子。

所以评测集里必须混进反例:本来就没错的句子。如果评测集全是"有错的句子",模型只要无脑报"时态错误"就能拿高分,根本测不出它是不是在乱报。

2. "好"不是一个数字,是好几层

一个批改结果好不好,至少三层,每层一个指标:

层次回答的问题对应指标
结果对不对找没找到错、判没判错exact_match、set_f1
格式合不合规程序能不能解析schema_pass_rate
体验好不好长不长、拒没拒答、语气avg_length、refusal_rate、judge_score

只盯"对错"这一层,你会漏掉最要命的问题:拒答。 模型说一句"抱歉我无法回答",在家长眼里就是"小 E 今天不理我"——它比乱答更伤信任,却常常不在"对错"的统计里。

3. 让模型当裁判,有四个坑

有些维度(解释清不清楚、语气像不像老师)机器算不出来,只能再请一个模型来判——这叫 LLM as judge。它好用,但有四个坑,坑坑都是真金白银换来的:

  1. 评分不稳:同一份答案打两次分不一样 → 裁判的 temperature 压到 0。
  2. 偏袒长答案:写得长就给高分 → rubric 里明写"长不等于好"。
  3. 标准漂移:今天按这个尺度、明天按那个 → rubric 固定成四档,写死在代码里。
  4. 位置偏见:裁判知道这是"新版"就手下留情 → 提示里绝不出现版本名。

4. 结论一句话:评测要的是"可复现、可对比、有反例"

把这三件事想清楚,后面每个模块就有落点了:评测集负责"有反例",指标负责"可复现",版本对比负责"可对比"。

二、工程设计:六个模块,一条流水线

09-评测体系/
├── src/
│   ├── settings.py   # 配置(复用第 1 篇)
│   ├── llm.py        # 模型工厂(复用第 1 篇)
│   ├── dataset.py    # 评测集:样本 + 预测结果的结构
│   ├── metrics.py    # 结果层指标(纯函数)
│   ├── judge.py      # LLM as judge(裁判)
│   ├── runner.py     # 跑批:样本 × 提示词 → 指标
│   ├── report.py     # 把结果写成 markdown 报告
│   └── compare.py    # 版本对比 + 退化判定
├── data/cases.jsonl  # 评测集(JSONL,便于 diff)
├── tests/            # 42 个用例,离线全绿
└── demo.py

核心思想一句话:把"考卷"(评测集)、"阅卷规则"(指标)、"另一位阅卷老师"(裁判)、"出成绩单"(报告)、"比两场考试"(对比)彻底拆开。 改提示词只动"考卷的答卷人",其余一行不改。

三、考卷:评测集(dataset.py)

先说结构。一个样本是"作文 + 人工标注的期望结果 + 标签":

class EvalCase(BaseModel):
    id: str
    essay: str                                  # 学生写的原句
    expect_error_types: list[str]                # 人工标注的错误类型;无错给 []
    expect_needs_rewrite: bool                   # 整句要不要重写
    tags: list[str]                              # 按标签分组,看薄弱项

错误类型词表和上一篇结构化输出保持一致——评测集、预测、指标三方必须说同一种语言,否则 F1 永远算不对。

真正的门道在构造原则,这四条比样本数量重要得多:

  1. 正例要有:真有错的句子,覆盖各个错误类型。
  2. 反例要有:本来就对的句子,测模型是不是在乱报。
  3. 边界要有:a/an、arrive at/in、完成时配 yesterday 这种容易判错的。
  4. 脏数据要有:超短句、中英混写——线上真实占比不低,评测集没有就是盲区。

内置集我造了 16 条,从"普通时态题"到"昨天我和同学去了公园 I go to park with my classmate"这种混写句都覆盖。存成 JSONL 而不是一整块 JSON,理由是review 评测集是家常便饭:git diff 里能一眼看清"加了哪一条"。

读写和过滤都是独立的小函数,方便按需取用:

def filter_by_tag(cases: Iterable[EvalCase], tag: str) -> list[EvalCase]:
    """按标签筛样本。filter_by_tag(builtin_cases(), "反例") 就能单独跑反例。"""
    return [case for case in cases if tag in case.tags]


def load_cases(path: str | Path = DEFAULT_CASES_PATH) -> list[EvalCase]:
    """从 JSONL 读评测集。空行直接跳过——有人用编辑器在末尾留了换行,不该因此崩。"""
    file_path = Path(path)
    cases: list[EvalCase] = []
    for line_no, line in enumerate(file_path.read_text(encoding="utf-8").splitlines(), start=1):
        if not line.strip():
            continue
        try:
            cases.append(EvalCase.model_validate_json(line))
        except ValueError as exc:
            raise ValueError(f"{file_path} 第 {line_no} 行不是合法的样本:{exc}") from exc
    return cases

两个细节:filter_by_tag 让你能单独跑"反例"这一类——排查"模型是不是在乱报"时特别有用;load_cases 遇到坏行报出具体行号而不是整份解析失败,坏数据只污染一行。

四、阅卷规则:结果层指标(metrics.py)

这一层是全项目最该被单测钉死的地方——指标错了,后面所有结论全错,而且错得特别安静。所以这里的每个函数都是纯函数:输入是数据,输出是数字,不产生副作用,空输入有明确定义。

先看两个最核心的:

def exact_match(case: EvalCase, pred: Prediction) -> float:
    """错误类型集合相等,且"要不要重写"也一致,才算 1.0。"""
    if pred.refused:
        return 0.0
    types_ok = set(case.expect_error_types) == set(pred.error_types)
    rewrite_ok = case.expect_needs_rewrite == pred.needs_rewrite
    return 1.0 if types_ok and rewrite_ok else 0.0

为什么这么苛刻?因为"类型错"和"重写判断错"讲的是两件事:前者让学生改错地方,后者让前端要么不给改写、要么白给一次改写。宽松的指标会掩盖"一半对一半错",那种版本上线最危险。

再看一个我在线上踩出来的坑,就藏在 set_f1 里:

def set_f1(expected, predicted) -> float:
    exp, got = set(expected), set(predicted)
    if not exp and not got:
        return 1.0   # "本来就没错、模型也说没错"是判对了,不是"什么都没判"
    ...

两个空集返回 1.0 是刻意的定义。早期我把它定义成 0,结果反例一多,评测分数集体跳水,我还以为模型变差了——其实是指标定义反了,把"正确识别无错"当成了"没干活"。如果不堵住这个,模型会学会"宁可乱报"。

拒答的判定也值得单独说。这里用了一张"活的"词表:

REFUSAL_MARKERS = ("抱歉", "不好意思", "我不能", "我无法", "没法", "无法回答", "作为一个AI", "作为AI")

线上看到新的拒答话术,就往里加,并且加的时候顺手补一条测试,别让它变成没人敢动的黑箱。

最后还有一张"方向表",它是指标和"退化判定"之间的桥梁——每个指标到底越大越好、越小越好,还是只看不判:

METRIC_DIRECTION = {
    "exact_match": 1,          # 越大越好
    "set_f1": 1,
    "schema_pass_rate": 1,
    "judge_score": 1,
    "refusal_rate": -1,        # 越小越好
    "avg_latency_ms": -1,
    "avg_length": 0,           # 只看不判
    "judge_parse_fail_rate": -1,
}

avg_length 为什么"只看不判"?因为批改不是越长越好,但也可能是你故意加了一句"请详细解释"。它单独看没有好坏,要看的是两个版本之间的差值。所以方向表里它不进判定,只在报告里展示。

三层指标各管一层

五、另一位阅卷老师:LLM as judge(judge.py)

结果层只能判"类型对不对",判不了"解释说清没有""有没有编一个不存在的错"。这部分交给裁判模型。

裁判的 rubric 写死在代码里,四档(1-4 分),档位再多裁判自己都分不清 6 和 7 的区别:

4 分 · 完全可用:错误类型与参考要点一致,没有编造,解释一句话说清
3 分 · 基本可用:主要错误抓到,但漏一处次要错误,或解释偏啰嗦
2 分 · 有问题:漏掉参考要点里的错误,或多报了没有的错误
1 分 · 不可用:没找到错误、把有错判成无错、答非所问、或干脆拒答

四条硬性要求里,最关键的是这条——"写得多不等于写得好",不要因为文本长就给高分。这是对"偏袒长答案"这个坑的直接回应。

位置偏见怎么防?看这个函数的签名:

def build_judge_messages(case: EvalCase, pred: Prediction) -> list:
    """渲染给裁判看的消息。
    注意签名里没有版本号这个参数——这是刻意的。
    想给裁判塞"这是新版",你得先改这个函数签名,
    而改动会撞上测试里那条"提示里不许出现版本名"。
    """

用函数签名挡住位置偏见,比靠自觉靠谱。裁判解析还要做三级兜底,一级比一级松:

def parse_judge_reply(text: str) -> JudgeResult:
    raw = _strip_fence(text or "")          # 先去掉 ```json 围栏
    try:
        data = json.loads(raw)              # 1. 整段就是 JSON
    except (json.JSONDecodeError, TypeError):
        data = None

    if isinstance(data, dict):
        score = data.get("score")
        if isinstance(score, (int, float)) and 1 <= int(score) <= 4:
            return JudgeResult(score=int(score), reason=str(data.get("reason") or ""), ok=True)

    hit = re.search(r'"score"\s*[::]\s*([1-4])', raw)   # 2. 散文里抠分数
    if not hit:
        hit = re.search(r"[给打]?\s*分?\s*[::]?\s*([1-4])\s*分", raw)
    if hit:
        return JudgeResult(score=int(hit.group(1)), reason="格式不合规但分值可用", ok=True)

    return JudgeResult(score=0, reason="裁判输出无法解析,已按 0 分计入", ok=False)  # 3. 放弃

裁判自己说不清楚时,不静默丢掉,而是给 0 分并单独立一个 judge_parse_fail_rate。裁判不稳定,这批分数就不该被信任。

六、跑批:一次评测怎么落地(runner.py)

跑批逻辑里有两个设计决定,都写在注释里:

  1. 并发固定为 1。 离线评测要的是可复现,不是快。并发会打乱顺序(顺序假模型就错位了)、让耗时失真、让失败难定位。
  2. prompt_builder 是入参。 跑批逻辑不该知道提示词长什么样。传进来一个"作文 → 消息列表"的函数,同一套代码就能跑 A 版和 B 版。

解析模型输出时,有一条特别重要的纪律:解析失败不算异常,它是一条信息量极高的结果——schema_pass_rate 就是靠它算出来的。所以 parse_prediction 返回 schema_ok=False,绝不抛异常中断跑批:

try:
    data = json.loads(payload)
except (json.JSONDecodeError, TypeError):
    return Prediction(case_id=case_id, raw=raw, schema_ok=False, ...)

此外,错误类型必须落在词表内——模型自创一个"语法错误"这种类型,前端拿去分组统计就会多出一类,属于静默污染。

跑批的主循环很朴素,就三步:预测 → 算结果指标 → 可选地请裁判打分:

for case in selected:
    started = time.perf_counter()
    reply = llm.invoke(prompt_builder(case.essay))   # 1) 预测
    latency_ms = int((time.perf_counter() - started) * 1000)
    raw = getattr(reply, "content", None)

    pred = parse_prediction(case.id, raw, latency_ms=latency_ms)

    result = CaseResult(                              # 2) 结果层指标
        case=case, pred=pred,
        exact=exact_match(case, pred),
        f1=set_f1(case.expect_error_types, pred.error_types),
    )
    if judge_llm is not None:                         # 3) 裁判(可选)
        result.judge_result = judge(judge_llm, case, pred)
    results.append(result)

注意 latency_ms 从预测前后掐表得来,离线假模型下它接近 0,真机跑才有意义——所以报告里它会如实显示,不假装测出了性能。

七、版本对比:改动算不算退化(compare.py)

这是整套体系里最"狠"、也最值钱的一环。判定规则只有一句话:

任一核心指标朝坏的方向掉过阈值,就判退化。

不是"总分掉了才算",不是"三项里两项掉了才算"。为什么这么硬?因为提示词改动最常见的失败模式就是拆东墙补西墙——准确率涨 2 个点,拒答率涨 5 个点;总分好看,家长那边炸了。

CORE_METRICS = ("exact_match", "set_f1", "schema_pass_rate", "judge_score", "refusal_rate")

def _delta_is_bad(delta, direction, threshold) -> bool:
    if direction > 0:    # 越大越好
        return delta < -threshold
    return delta > threshold   # 越小越好

还有一处让我省过很多事的:按标签看薄弱项。总分没掉,不代表每类句子都没掉。对比结果里专门算每个标签的差值,挑出"掉得最狠的标签"——它往往就是家长投诉的那类句子,只看总分很容易把它放过去。挑"最狠"时还有个反直觉的细节:差值相同时按样本数多的算,因为 2 条样本的标签掉 1.0,比 1 条样本的更有说服力,只按差值排会先指给你一个只有一条样本的类目,你照着改半天,真实问题还在别处。

版本对比

八、出成绩单:报告(report.py)

指标算出来,还得变成人能看的东西。报告模块把一次评测渲染成 markdown:总体指标一张表,失败样例一张清单,按标签分组的薄弱项一段。跑批结束顺手落一份文件到 reports/,文件名带上日期和版本号,评测结果就天然带上了时间线——三个月后想回看"当时这个版本是多少分",翻目录就行。

这里也守着一个诚实原则:没跑裁判就不要假装打过分。aggregate 只在真的传了裁判时,才输出 judge_score 和 judge_parse_fail_rate 两项,宁可缺一项,也不填 0 骗人。评测体系最怕的不是"分数不好看",而是"分数是假的"。

九、跑起来:真实对比

python demo.py,全程离线。两个版本共用同一批 16 条用例,唯一的区别是 system prompt 里那句"拿不准的先别报":

【基线报告】严格版
| 严格一致率            | 1.000 |
| 错误类型集合 F1       | 1.000 |
| 结构化通过率          | 1.000 |
| 裁判均分(1-4 分)    | 3.938 |
| 拒答率                | 0.000 |

【候选报告】宽松版
| 严格一致率            | 0.812 |
| 错误类型集合 F1       | 0.875 |
| 结构化通过率          | 0.938 |
| 裁判均分(1-4 分)    | 3.188 |
| 拒答率                | 0.062 |

版本对比的结论一针见血:

| exact_match       | 1.000 | 0.812 | -0.188 | 退化 ⚠️ |
| set_f1            | 1.000 | 0.875 | -0.125 | 退化 ⚠️ |
| schema_pass_rate  | 1.000 | 0.938 | -0.062 | 退化 ⚠️ |
| refusal_rate      | 0.000 | 0.062 | +0.062 | 退化 ⚠️ |
| judge_score       | 3.938 | 3.188 | -0.750 | 退化 ⚠️ |

结论:退化,本次改动不建议上线。

按标签看薄弱项:
| 中文混写 | 2  | -1.000 |
| 超短句   | 1  | -1.000 |
| 边界     | 7  | -0.429 |
掉得最狠的是「中文混写」——只看总分很容易放它过去,这正是家长投诉的那类句子。

如果当初我有这套东西,"拿不准的先别报"这句,上线前就会被这一张表拦下来。它不是我事后诸葛,而是事前就能给结论。

测试结果,42 个用例全绿,全程离线:

tests/test_dataset.py   内置集含反例、tag 过滤、JSONL 落读一致
tests/test_metrics.py    每个指标、拒答词表、F1 空集定义
tests/test_judge.py      rubric 四档、解析三级兜底、提示无版本名
tests/test_runner.py     跑批、schema 容错、limit 生效
tests/test_report.py     报告行数、指标落表
tests/test_compare.py    退化判定、阈值、worst_tag 排序

42 passed in 2.01s

这组测试里我最在意两条:test_judge 里"裁判提示里不许出现版本名",和 test_metrics 里"两个空集的 F1 等于 1"。前者守住评测的公正,后者守住指标的定义——评测体系里最贵的 bug,不是代码报错,而是定义悄悄反了。

十、踩坑清单

  1. 评测集没有反例:全是错句,模型无脑报错也能拿高分。必须混进"本来就对"的句子。
  2. "两个空集 F1"定义反了:把"正确识别无错"当成"没干活",模型会被逼着乱报。
  3. 拒答被当成"体面失败":它比乱答更伤信任,还常常不在对错统计里。exact_match 里拒答直接判 0。
  4. 裁判 temperature 没压 0:同一份答案打两次分不一样,评测退化成掷骰子。
  5. 裁判知道版本名:手下留情。用函数签名挡住位置偏见,别靠自觉。
  6. 只看总分不看标签:拆东墙补西墙被总分掩盖。按标签看差值,挑出掉得最狠的那类。
  7. 并发跑离线评测:顺序乱、耗得失真、失败难定位。离线评测并发固定为 1。

十一、本篇收尾

现在你有了:一份有反例的考卷,一排能被单测钉死的阅卷规则,一位不带偏见的裁判,一张能自动判定"退化"的成绩单。改提示词这件事,从"我试了三个例子感觉不错",变成了"跑一遍 16 条,看一张表,两分钟出结论"。

最后留一条实操建议:什么时候往评测集里加样本? 每一次线上出问题,就把那条"漏网的句子"补进去。评测集不是一次性建好就完事的资产,它是靠每一次事故喂大的——补得越勤,它越像你真正的线上流量。我的习惯是:凡是出现在家长投诉里的句子,当天必进评测集。

这也是本系列倒数第二篇。下一篇收官,我们把前八篇攒下的所有能力——结构化输出、上下文管理、工具调用、稳定性兜底——装进一个 FastAPI 服务,加上流式输出,真正推到线上。


最后一句:评测集是你写给三个月后自己的信——那时候你早忘了今天改了什么,但分数还记得。