介绍一套轻量评测框架,通过版本化测试用例和自动评分卡,对任意免费模型端点进行可复现的模型对比。
没人写下来的决策
问一个团队为什么在生产环境跑模型 X,诚实的答案通常是"有人在聊天窗口里试了一下,看起来还不错"。这在输入不像演示场景时就不管用了:边界情况出现,模型不是选择回避而是胡编,糟糕的输出直接流向下游的消费者。
代码没有这种奢侈。行为重要时,我们把预期写成测试,每次改动都重新跑一遍。模型选择值得同样的对待,因为在 LLM 领域"有东西变了" constantly:换了候选模型、改了 prompt,或者供应商在模型名称不变的情况下悄无声息地做了修订。
接下来的内容是一个轻量级评测框架:版本化的标注输入集、确定性的评分器、以及可以随时重新生成的记分牌。它对接任何免费模型端点,运行在任何小型常开机器上——整个对比阶段可以零成本,只要你还处于决策阶段。
为什么评测阶段是免费阶段
在确定模型前需要回答的问题,廉价到随便问:
市场上有任何模型能处理这种任务形态吗,即使不完美?
最先出现的失败是什么——JSON 损坏、凭空编造字段、该回答时拒绝?
是模型弱,还是我的 prompt 本身就模糊?
免费端点能回答以上所有。还有一个被低估的结果:如果所有候选模型都出现相同的失败,说明瓶颈在任务设计而不是模型质量——这让你避免用升级模型的方式去解决一个 prompt 问题。
披露:本文是 MonkeyCode 产品推广的一部分。
在我的设置中,候选模型通过 MonkeyCode 的免费模型访问服务提供,框架运行在其免费服务器选项上,同时它也充当过往记分牌的存档。两者都不是必选项:下面的脚本从环境变量读取基础 URL、凭证和模型名称,所以任何 OpenAI 兼容端点和任何小型 VM 都以相同方式工作。我有意不引用模型目录、速率限额或免费层时长——这些条款会变化,所以在依赖它之前请自行核实当前的 offering。
产物:回执、标准答案、和一个不信任置信度的评分器
任务演示:从不规范的回执文本中提取总金额和货币。这是一个好的评测对象,因为输出很小、结构化、且可以通过纯函数检查——不需要靠感觉。
三个设计承诺保持框架的有效性:
用例是文件,不是对话日志。它们存在 git 里,像代码一样做 diff,每次生产环境出现意外都会增加。
确定性检查优先。数值比较带容差、枚举成员、JSON 可解析性。人类判断仅保留给函数真正无法决定的部分。
"我无法判断"是有效答案,带有分数。模型从不明确文本中编造总数,比选择拒绝更危险——评分器必须反映这种不对称。
{"id": "r-001", "text": "COFFEE NORTHSIDE\\n2x latte 9.00\\nTOTAL $9.00\\nthank you", "gold": {"total": 9.00, "currency": "USD"}}
{"id": "r-002", "text": "Boulangerie Marie\\nCroissant 2,40 EUR\\nTotal: 2,40 EUR", "gold": {"total": 2.40, "currency": "EUR"}}
{"id": "r-003", "text": "TAXI RECEIPT\\nfare ...... 18.50\\ntip ....... 3.00\\nCARD ****4412", "gold": null}
第三行是地雷:回执上有数字但没有明确的总计。诚实的答案是弃权。一个把各行加起来报 21.50 的模型产生了看起来合理但实为垃圾的结果——这正是腐蚀下游账本的那种东西。
receipt_eval.py(一个起点,不是教条):
import json, os, time, urllib.request, urllib.error
BASE = os.environ["EVAL_BASE_URL"] # any OpenAI-compatible endpoint
TOKEN = os.environ["EVAL_API_KEY"]
MODEL = os.environ["EVAL_MODEL"]
SYSTEM = (
"Extract the grand total from the receipt. Respond with a single JSON object "
'{"total": <number>, "currency": "USD|EUR|GBP"}. '
"If no explicit grand total is printed, respond with: unclear"
)
def ask(receipt_text):
payload = json.dumps({
"model": MODEL,
"temperature": 0,
"messages": [
{"role": "system", "content": SYSTEM},
{"role": "user", "content": receipt_text},
],
}).encode()
req = urllib.request.Request(
BASE.rstrip("/") + "/chat/completions",
data=payload,
headers={"Authorization": "Bearer " + TOKEN,
"Content-Type": "application/json"},
)
with urllib.request.urlopen(req, timeout=60) as resp:
return json.load(resp)["choices"][0]["message"]["content"].strip()
def judge(case, raw):
"""Return (points, tag). Fabrication is penalized below zero."""
if raw.strip().lower() == "unclear":
pred = None
else:
try:
pred = json.loads(raw)
except json.JSONDecodeError:
return (-1, "not_json")
gold = case["gold"]
if gold is None:
return (1, "abstained_ok") if pred is None else (-2, "fabricated_total")
if pred is None:
return (0, "gave_up")
total_ok = isinstance(pred.get("total"), (int, float)) and \
abs(pred["total"] - gold["total"]) < 0.01
curr_ok = pred.get("currency") == gold["currency"]
if total_ok and curr_ok:
return (2, "full_match")
return (0, "wrong_value" if not total_ok else "wrong_currency")
run = {"model": MODEL, "ts": int(time.time()), "cases": []}
earned = possible = 0
with open("receipts.jsonl") as fh:
for line in fh:
case = json.loads(line)
points, tag = judge(case, ask(case["text"]))
earned += points
possible += 2 if case["gold"] else 1
run["cases"].append({"id": case["id"], "tag": tag, "points": points})
print(f"{case['id']:>7} {tag:<16} {points:+d}")
run["score"] = round(100 * max(earned, 0) / possible, 1)
print(f"\nscore: {run['score']} ({MODEL})")
with open("scoreboard.jsonl", "a") as out:
out.write(json.dumps(run) + "\n")
无 SDK、无框架——一种标准 HTTP 形态,所以指向不同提供商只需改个环境变量:
EVAL_BASE_URL="https://your-endpoint.example/v1" \
EVAL_API_KEY="..." \
EVAL_MODEL="candidate-a" \
python receipt_eval.py
两个细节在这里默默起作用。加权标签编码了你实际的风险模型:fabricated_total 得分低于 giving_up,因为在账务处理 pipeline 中,一个自信的错误数字比人工审核队列代价更高。结果追加到 scoreboard.jsonl 带时间戳,所以每次运行都成为一条历史记录,以后可以做 diff 对比。
看记分牌而不自欺
聚合是分析的开始,不是终点。几个让数据保持诚实的习惯:
因为每次运行都落入同一个只追加文件,"候选模型 B 真的击败了候选模型 A 吗,在哪些输入上?"变成了一个 jq 查询而不是靠记忆。
无聊的机器才是重点
框架里没有任何需要编排的东西。有用的技巧很简单:晚点再跑一遍,这就是常开免费服务器的用途:
42 5 * * 1 cd /srv/receipt-eval && . ./env.sh && python receipt_eval.py >> weekly.log 2>&1
用相同的模型名称每周重新跑一次,能捕获演示永远展示不了的那种失败模式:供应商悄无声息地修订了模型,提取行为发生漂移,你的 pipeline 降级了但没有触发任何错误。有了带日期的记分牌记录,漂移变成一个可见的事件,有前后的 diff,而不是一个让你花整个周末调试的谜。一台执行一个小脚本的小盒子,正是这里正确的基础设施量级——框架保持运行但不会变成第二份工作。
这个设计的局限
免费容量用于探索,不是规模化。免费层级上速率上限、上下文限制和条款演变是正常的。几十个用例适合这个世界;一千个用例的每日 gate 不适合——当你到达那一步时计划好付费容量,并把那视为成功信号。
函数评分只覆盖可验证的输出。提取、分类、格式合规:非常适合。摘要、代码审查评论、创意改写:无法通过相等性检查,强行塞进这个模子会产生虚假信心。那些需要采样人工审核或 judge 模型,并带有其固有的主观性。
你的用例文件是一份关于生产的理论。每次全绿运行意味着"在这些输入上全绿"。持续将消毒后的真实生产意外反馈到 JSONL,否则即使一直全绿,套件也会 drift into irrelevance。
弃权评分假设你的领域允许它。如果你的产品必须永远回答,重新调整评分规则——但要让这个权衡显式而不是意外。
……如果你每周只交互调用几次模型且下游没有自动化——在这个规模上框架就是仪式。如果你手头没有一个真正镜像你任务的维护中的公共基准,也跳过从头构建用例;如果有,先跑那个。另外,如果你的输出根本无法机械检查,你要的是采样-审核工作流,而不是这个。
但如果你准备基于 playground 印象把模型接入 pipeline,一个 JSONL 文件和一个评分脚本就是你和证据驱动决策之间的全部差距。如果你想要一个零预算的方式来建立这个循环,MonkeyCode 的免费模型访问配合其免费服务器是一个具体选项——端点按设计可交换,这正是一个评测框架对待其基础设施的正确方式。对用例做版本控制,保留记分牌历史,让下一次静默的模型更新成为你测量的东西,而不是你追逐的东西。