AI输出质量问题的根源是缺乏可量化的评估体系而非prompt不够好;建立eval后能追踪效果回归,避免重复调优。
如果无法自动判断输出是否正确,每次改 Prompt 都是凭感觉来。以下是这套最小化 Eval 配置,它真正值得投入。
以下是我打赌你一定认识的流程。
输出不太对。调调 Prompt。看起来好了。上线。两周后又有别的问题,再调一版——你根本不知道是解决了新问题还是把老问题又改回来了,因为根本没有记录当初"正常"是什么样子的。
这不是 Prompt 问题。这是软件开发没有测试时的样子。
问题一直存在的原因是,LLM 输出看起来无边无际、无法测试。其实不是。大部分输出远比人们以为的更容易测试,而有用的断言都是些平淡无奇的东西,一个下午就能写出来。
先说经济账,因为这决定了什么值得投入。
我上个月对 [NUMBER] 个自己手头的任务分别计时——用 AI 和不用 AI。其中 [NUMBER] 个任务 AI 输了,原因从来不是模型质量,而是验证——人类在输出可以使用之前花的时间去检查它。
net_per_run = manual − (generation + verification + retries)
如果验证时间接近手动时间,无论生成多快,净收益都趋近于零。更好的模型也无济于事。这经常被误诊为能力问题。
Eval 就是一台替你完成部分验证工作的机器。这是它全部的价值主张,也是为什么它比你能搭的几乎任何东西都回报更快:它攻击的是公式里更好的模型根本触碰不到的那一项。
直觉是直接上 LLM-as-judge。再忍住一天。大多数你需要知道的东西用普通代码就能检查,而普通代码是确定性的、瞬时的、免费的。
from dataclasses import dataclass
from typing import Callable
@dataclass
class Case:
name: str
inputs: dict
checks: list[Callable[[str], bool | str]] # True,或一条失败消息
def run_evals(cases, generate):
failures = []
for c in cases:
out = generate(**c.inputs)
for check in c.checks:
r = check(out)
if r is not True:
failures.append(f"{c.name}: {r or check.name}")
return failures
然后是各种 checks。这些毫无 glamour,但它们能捕捉到真实回归中占比惊人的一部分:
import json, re
def is_valid_json(out):
try:
json.loads(out)
return True
except json.JSONDecodeError as e:
return f"invalid JSON: {e.msg}"
def has_required_keys(*keys):
def check(out):
try:
d = json.loads(out)
except Exception:
return "not JSON"
missing = [k for k in keys if k not in d]
return True if not missing else f"missing keys: {missing}"
check.name = f"has_keys{keys}"
return check
def no_hedging(out):
banned = ["as an ai", "i cannot", "it's important to note", "delve into"]
hit = [p for p in banned if p in out.lower()]
return True if not hit else f"hedging: {hit}"
def within_length(lo, hi):
def check(out):
n = len(out.split())
return True if lo <= n <= hi else f"length {n} outside [{lo},{hi}]"
return check
def cites_only(allowed_urls):
def check(out):
found = set(re.findall(r"https?://[^\s)]]+", out))
extra = found - set(allowed_urls)
return True if not extra else f"invented URLs: {extra}"
return check
最后一个——检查输出中出现了源材料里没有的 URL——帮我抓出的真实问题比任何复杂方法都多。虚构引用很常见、尴尬程度很高、而且检测起来非常 trivial。
你的 eval 集合不是随机样本。它是一座博物馆,收藏着所有已经出过的错。
规则:每次在生产中逮到一个坏输出,它就成为一个 case。这就是全部的纪律。这样收集来的十个 case 胜过一百个合成的,因为它们来自你真实失败的实际分布,而不是你想象出来的。
CASES = [
Case("empty input", {"text": ""}, [is_valid_json]),
Case("very long input", {"text": LONG_SAMPLE}, [within_length(50, 300)]),
Case("adversarial", {"text": INJECTION_SAMPLE},[no_leaked_instructions]),
Case("the one from [DATE] that fabricated a source", {"text": THAT_INPUT}, [cites_only(THAT_SOURCES)]),
]
Case 要用出问题的原因来命名。"the one from March that fabricated a source" 是比 test_citation_accuracy_3 更好的测试名,因为六个月后你仍然知道它为什么存在。
然后,也只有到那时,才上 LLM Judge。
三条规则,决定了你的 Judge 是有用的工具还是昂贵的随机数生成器:
二元,不是评分。 "给 1-10 分" 产生的是噪音。问一个是否问题,并给出明确标准。
每次只测一个维度。 同时问准确度、语气和完整性的 Judge 会把它们坍缩成一个整体的 vibe。
如果有参考对象,就做对比判断。 比较判断比绝对判断稳定得多。
JUDGE = """You are checking one property of a piece of text.
Answer with exactly one word: PASS or FAIL. If the property does not clearly hold, answer FAIL."""
def judge(property_desc):
def check(out):
v = call_model(JUDGE.format(property=property_desc, text=out)).strip().upper()
return True if v.startswith("PASS") else f"judge failed: {property_desc}"
check.name = "judge"
return check
在使用 Judge 之前先验证它。手动标注二十个输出,跑一遍 Judge,对比结果。如果它和你的判断出入超过几个,说明 Judge 的 prompt 有问题,先修好再用它评估任何其他东西。一个你没验证过的 Judge 不过是循环里第二个未经检验的模型——这和你要构建的东西正好相反。
价值不在于一个分数。价值在于:这次改动让情况变好了还是变坏了。
def compare(cases, old_gen, new_gen):
before = set(run_evals(cases, old_gen))
after = set(run_evals(cases, new_gen))
return {
"fixed": sorted(before - after),
"broken": sorted(after - before), # 这是唯一重要的列
"still": sorted(before & after),
}
每次改 Prompt 和每次换模型都跑一遍。broken 是应该阻止部署的那一列。模型升级是这里被低估的场景——"更新的模型"不等于"对你的任务更好",用这个方法你可以在四秒内知道答案,而不是等四个星期。
保持它足够快、足够便宜,每次都能跑。一个需要十分钟并且花真钱的 eval 套件恰好在你赶时间的时候被跳过——而赶时间的时候恰恰是最容易出问题的时候。
对边界保持诚实。Eval 能捕捉你已经见过的失败模式上的回归。捕捉不了全新的失败模式,捕捉不了工作流是否值得拥有,绿色的套件也不是输出良好的保证。
它们把验证从"每次运行"转移到"每次改动",这才是杠杆所在。剩下的人工检查变短了,但没有消失。
如果搭完这些之后验证仍然占你大部分运行时间,这是关于任务本身的一个真实信号——有些工作确实检查起来和做起来一样耗时,正确的做法是保持人工,而不是围绕它搭更多工具。这一点我另外写了决策框架。
is_valid_json / has_required_keys,如果你解析输出的话within_lengthcites_only(sources),如果输出会做声明的话五个 checks,一个下午。它能抓到一个月仔细阅读都发现不了的东西,更重要的是,它能在引入问题的当下就抓到——而那才是修复成本最低的唯一时机。
我测试 AI 工具和工作流是认真的,包括那些不值得存在的位置:AiStackGuru。