最近在 Hacker News 上看到一个很有意思的实践:有人用 38 道柏林地方选举相关问题,横向对比了 8 个大语言模型(LLM),从回答准确性、完整性、一致性等多个维度做了评测。这个思路看起来很朴素,但真正上手之后会发现,LLM 评测远没有想象中那么简单:问题设计、评分标准、模型版本、采样温度、甚至同一问题多次提问的稳定性,都会直接影响最终结论。
这篇文章不讨论具体选举本身,而是把“用 38 道题对比 8 个 LLM”这件事拆成一个可复现的评测项目来做。我会先讲清楚 LLM 评测的核心概念,再给出完整的 Python 评测脚本、评分方案、常见坑点以及工程化建议。无论你是想评估大模型选型,还是想给自己的 RAG 应用做回归测试,这套流程都能直接改造复用。
1. 背景与核心概念
1.1 为什么要做 LLM 对比评测
大模型发展到现在,已经不再是一个“能不能用”的问题,而是“在具体场景下选哪个、怎么用”的问题。团队在选型时通常会面临几个现实困惑:
- 同一个问题,不同模型回答质量差异很大。
- 同一个模型,连续问两次结果可能不一样。
- 有些模型回答“看起来完整”,但关键事实是错的。
- 有些模型遇到超出知识范围的问题,会一本正经地编造答案,也就是学界常说的“幻觉”(hallucination)。
这些问题靠阅读模型介绍文档、看几个演示视频是没法解决的,必须自己构建评测集,设计评分规则,用统一流程跑完所有候选模型,最后用数据说话。
文章标题里的“38 道柏林选举问题”,本质是一个小规模的领域评测集。选举相关问题有一个很适合评测的特点:答案具有明确的“事实锚点”,比如投票时间、投票方式、候选人背景、政策主张等,这些信息可以通过官方渠道核验对错,也能测试模型在处理时效性信息时的准确性。
1.2 评测的核心维度
一次完整的 LLM 评测,通常至少覆盖以下维度:
| 维度 | 说明 | 评测难点 |
|---|---|---|
| 准确性 | 回答是否与事实一致 | 需要人工或外部知识源核对 |
| 完整性 | 是否覆盖了问题的所有关键点 | 需要对答案做要点拆分 |
| 一致性 | 多次提问结果是否稳定 | 需要设置温度参数并重复采样 |
| 简洁性 | 是否包含无关信息或重复内容 | 需要定义“冗余”的判定标准 |
| 安全性 | 是否拒绝回答有害内容 | 需要构建对抗样本集 |
| 时效性 | 是否掌握最新信息 | 知识截止日期不同,需标注 |
上面提到的 HN 项目,重点在前三个维度:先定义每道题的标准答案,再用人工或辅助模型打分,最后多次运行统计稳定性。这个思路很值得借鉴,因为它没有引入复杂的自动评估框架,却能把“哪个模型更好”这个问题变成可量化的数据。
1.3 评测集的设计原则
评测集是整件事的地基。38 道题虽然不算多,但设计质量直接决定评测结果有没有参考价值。好的评测题应该满足:
- 事实可核验。每道题都有官方来源。例如投票站开放时间、邮寄选票申请截止日期等。
- 难度有梯度。一部分是简单事实题,一部分需要综合多个信息源才能回答。
- 覆盖多类型。包括单选题式事实题、列举题、对比题、时效性判断题。
- 避免问法歧义。同一问题不同人理解应该一致。
- 标注标准答案与评分要点。没有标准答案的题,至少要有明确的“评分维度”。
如果你的场景不是选举问答,也可以套用同样的原则去构建自己的领域评测集,比如公司内部的产品 FAQ、客服话术、技术文档问答等。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 环境准备与版本说明
2.1 技术选型与依赖
这个评测项目的核心是写一个 Python 脚本,统一调用多个 LLM 的 API,把返回结果保存下来,再做评分分析。
环境方面,以下组合是笔者实际使用的参考配置:
- 操作系统:Windows 10 / macOS 均可,脚本是跨平台的。
- Python:3.10 或更高版本。
- 核心依赖:
openai、anthropic、google-generativeai、pandas、pyyaml、tqdm、requests。 - 评测框架:不必须引入 LangChain,直接使用各家官方 SDK 更直观,也能减少框架升级带来的兼容问题。
版本需要根据你的项目实际情况调整,本文示例以常见环境为例,重点演示配置思路。
2.2 API Key 与配额准备
调用大模型 API 前,需要先到各家平台申请 API Key。这里有几个通用注意事项:
- 不同平台的 API 计费方式、速率限制不同,评测脚本里要加错误重试和限速逻辑。
- 同一个模型可能有多个版本,比如
gpt-4o和gpt-4o-mini差异很大,必须把“模型版本”和“模型名称”一起记录下来。 - 评测完结果要保留原始日志,否则后续复盘时无法确认结果对应的具体配置。
建议在项目根目录下建一个 .env 文件,用 python-dotenv 加载各类 Key,不要把密钥写进代码里:
dotenv复制# 文件路径:.env
OPENAI_API_KEY=sk-xxxxxx
ANTHROPIC_API_KEY=sk-ant-xxxxxx
GOOGLE_API_KEY=AIzaxxxxxx
DEEPSEEK_API_KEY=sk-xxxxxx
2.3 项目结构规划
推荐使用下面的目录结构,把问题集、代码、结果和评分逻辑分开:
code复制llm_eval/
├── .env
├── requirements.txt
├── config.yaml
├── questions/
│ └── election_38.json
├── scripts/
│ ├── 01_run_models.py
│ ├── 02_score_results.py
│ └── 03_generate_report.py
└── results/
├── raw/
└── scored/
这样做的原因是:评测是一个“可追溯”的过程,问题集、模型输出、评分结果分文件存放,后续调整任何一个环节都不会污染其他数据。
3. 评测问题集与标准答案设计
3.1 问题集 JSON 结构
先定义问题集的格式。每一个问题包含编号、问题文本、类型、标准答案要点、以及额外的参考来源。这里以“柏林地方选举”的公共信息类问题为样例,但结构完全通用。
json复制[
{
"id": 1,
"question": "柏林地方选举的投票日是哪一天?",
"type": "fact",
"answer_points": ["投票日具体日期"],
"source": "官方公告",
"note": "简单事实题,检验模型知识时效性"
},
{
"id": 2,
"question": "在柏林,哪些人有资格参与地方选举投票?",
"type": "list",
"answer_points": ["德国公民", "在柏林居住的欧盟公民", "年满16周岁"],
"source": "官方选民指南",
"note": "列举题,检验完整性"
},
{
"id": 3,
"question": "比较主要政党在住房政策上的核心主张差异。",
"type": "comparison",
"answer_points": ["至少覆盖三个政党", "每党给出1条核心主张", "不做价值评判"],
"source": "各政党竞选纲领",
"note": "对比题,检验模型客观性与条理性"
}
]
answer_points 是评分时的关键参考。对于事实题,它是对错判断依据;对于列举题和对比题,它是完整性打分的参考框架。
3.2 提示词模板
同一套问题发给不同模型时,提示词必须完全一致,否则比较没有意义。可以设计一个统一的系统提示词和用户提示词模板:
python复制SYSTEM_PROMPT = (
"你是一个严谨的公共信息问答助手。"
"请基于你掌握的事实信息回答问题。"
"如果不知道答案,请明确说'信息不足',不要编造。"
"回答使用中文,控制在300字以内。"
)
def build_user_prompt(question_item):
return f"问题:{question_item['question']}\n请给出准确、完整的回答。"
这里有两个要点:
- 系统提示词里明确“不知道就说不知道”,可以有效降低幻觉率,这是评测时要主动引导的。
- 所有模型都使用同一套提示词模板,保证控制变量。
3.3 模型注册表
下一步是定义候选模型列表。以 8 个模型为例,可以维护一个“模型注册表”,包含模型名称、供应商、API 调用函数、以及这次运行时指定的版本。
python复制MODEL_REGISTRY = [
{"id": "openai_gpt4o", "provider": "openai", "model": "gpt-4o"},
{"id": "openai_gpt4omini", "provider": "openai", "model": "gpt-4o-mini"},
{"id": "anthropic_claude", "provider": "anthropic", "model": "claude-3-5-sonnet-latest"},
{"id": "google_gemini", "provider": "google", "model": "gemini-1.5-pro"},
{"id": "deepseek_chat", "provider": "deepseek", "model": "deepseek-chat"},
{"id": "qwen_max", "provider": "dashscope", "model": "qwen-max"},
{"id": "moonshot_kimi", "provider": "moonshot", "model": "moonshot-v1-32k"},
{"id": "local_llama", "provider": "ollama", "model": "llama3.1:70b"},
]
注意:以上模型名称只是示例,实际接入时请以各平台官方文档为准。模型更新很快,claude-3-5-sonnet-latest 这种别名在不同时期可能对应不同版本,评测报告里要记录实际的快照时间。
4. 完整实战:8 个 LLM 对比评测流水线
4.1 创建项目与安装依赖
先创建虚拟环境并安装依赖:
bash复制mkdir llm_eval && cd llm_eval
python -m venv venv
source venv/bin/activate # Windows 下是 venv\Scripts\activate
pip install openai anthropic google-generativeai pandas pyyaml tqdm python-dotenv
requirements.txt 可以按实际锁定的版本生成:
bash复制pip freeze > requirements.txt
这一步很建议做,因为 LLM SDK 升级频繁,接口签名变动可能导致评测脚本突然不可用。
4.2 编写统一调用层
不同模型往往使用不同的 SDK,但可以封装成一个统一的调用函数。核心思路是:暴露一个 ask_model(model_info, question) 接口,内部按供应商分发到具体 SDK。
python复制# 文件路径:scripts/llm_clients.py
import os
import time
from openai import OpenAI
from anthropic import Anthropic
import google.generativeai as genai
from dotenv import load_dotenv
load_dotenv()
def _call_openai(model_name, system_prompt, user_prompt, temperature=0.0):
client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
resp = client.chat.completions.create(
model=model_name,
temperature=temperature,
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
)
return resp.choices[0].message.content
def _call_anthropic(model_name, system_prompt, user_prompt, temperature=0.0):
client = Anthropic(api_key=os.getenv("ANTHROPIC_API_KEY"))
resp = client.messages.create(
model=model_name,
max_tokens=1024,
temperature=temperature,
system=system_prompt,
messages=[{"role": "user", "content": user_prompt}],
)
return resp.content[0].text
def _call_google(model_name, system_prompt, user_prompt, temperature=0.0):
genai.configure(api_key=os.getenv("GOOGLE_API_KEY"))
model = genai.GenerativeModel(model_name, system_instruction=system_prompt)
resp = model.generate_content(
user_prompt,
generation_config=genai.types.GenerationConfig(temperature=temperature),
)
return resp.text
def ask_model(model_info, system_prompt, user_prompt, temperature=0.0):
provider = model_info["provider"]
model_name = model_info["model"]
if provider == "openai":
return _call_openai(model_name, system_prompt, user_prompt, temperature)
elif provider == "anthropic":
return _call_anthropic(model_name, system_prompt, user_prompt, temperature)
elif provider == "google":
return _call_google(model_name, system_prompt, user_prompt, temperature)
else:
raise ValueError(f"Unsupported provider: {provider}")
这段代码的关键点在于:
temperature=0.0用于第一轮准确率评测,让输出尽量确定。- 后续做一致性评测时,再单独用
temperature=0.7多跑几轮。 - 每个供应商的函数独立,方便后续替换模型版本。
4.3 运行批量评测主脚本
接下来编写主脚本 01_run_models.py,功能是遍历“问题 x 模型”组合,调用模型并保存结果。
python复制# 文件路径:scripts/01_run_models.py
import json
import time
from pathlib import Path
from tqdm import tqdm
from llm_clients import ask_model
from llm_clients import SYSTEM_PROMPT
BASE_DIR = Path(__file__).resolve().parent.parent
QUESTIONS_PATH = BASE_DIR / "questions" / "election_38.json"
RAW_DIR = BASE_DIR / "results" / "raw"
# 定义8个候选模型(示例)
MODEL_REGISTRY = [
{"id": "openai_gpt4o", "provider": "openai", "model": "gpt-4o"},
{"id": "openai_gpt4omini", "provider": "openai", "model": "gpt-4o-mini"},
{"id": "anthropic_claude", "provider": "anthropic", "model": "claude-3-5-sonnet-latest"},
{"id": "google_gemini", "provider": "google", "model": "gemini-1.5-pro"},
# 其他模型可按同样格式追加
]
def load_questions():
with open(QUESTIONS_PATH, "r", encoding="utf-8") as f:
return json.load(f)
def run_evaluation(questions, models, repeat_n=2):
RAW_DIR.mkdir(parents=True, exist_ok=True)
total_queries = len(questions) * len(models) * repeat_n
pbar = tqdm(total=total_queries, desc="Evaluating")
for model in models:
for q in questions:
for attempt in range(repeat_n):
user_prompt = f"问题:{q['question']}\n请给出准确、完整的回答。"
try:
answer = ask_model(model, SYSTEM_PROMPT, user_prompt, temperature=0.0)
except Exception as exc:
answer = f"ERROR: {exc}"
time.sleep(1)
record = {
"model_id": model["id"],
"model_name": model["model"],
"provider": model["provider"],
"question_id": q["id"],
"question": q["question"],
"attempt": attempt,
"answer": answer,
}
out_file = RAW_DIR / f"{model['id']}_q{q['id']}_r{attempt}.json"
with open(out_file, "w", encoding="utf-8") as f:
json.dump(record, f, ensure_ascii=False, indent=2)
pbar.update(1)
time.sleep(0.3) # 避免触发限流
pbar.close()
if __name__ == "__main__":
questions = load_questions()
run_evaluation(questions, MODEL_REGISTRY, repeat_n=2)
运行方式:
bash复制python scripts/01_run_models.py
输出预期:在 results/raw/ 下生成若干 JSON 文件,每个文件包含一个问题、一个模型、一次尝试的完整问答记录。
这里做了几个工程化设计:
- 每个请求单独存一个文件,中断后可断点续跑,不需要整体重来。
attempt字段支持多次重复采样,用于一致性分析。- 异常被捕获后记录到 answer 字段,而不是直接中断脚本。
4.4 评分脚本的设计
有了原始回答,下一步是打分。打分方式有两种:人工评分和 LLM 辅助评分。38 题 x 8 模型规模不大,完全可以用人工打分表;但如果后续扩展到几百题,建议用辅助模型预打分,再人工抽检。
这里写一个“规则 + 人工辅助”的混合评分脚本:
python复制# 文件路径:scripts/02_score_results.py
import json
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent
RAW_DIR = BASE_DIR / "results" / "raw"
SCORED_DIR = BASE_DIR / "results" / "scored"
# 简化的评分维度:accuracy / completeness / consistency
# 实际使用时,建议导出CSV交给人工逐条标注
def load_all_raw():
records = []
for f in RAW_DIR.glob("*.json"):
with open(f, "r", encoding="utf-8") as fp:
records.append(json.load(fp))
return records
def build_grouped_records(records):
grouped = {}
for rec in records:
key = (rec["model_id"], rec["question_id"])
grouped.setdefault(key, []).append(rec)
return grouped
def export_scoring_sheet(records, output_csv):
import csv
grouped = build_grouped_records(records)
with open(output_csv, "w", newline="", encoding="utf-8-sig") as f:
writer = csv.writer(f)
writer.writerow(["model_id", "question_id", "question", "attempt1", "attempt2", "accuracy", "completeness", "note"])
for (model_id, qid), recs in sorted(grouped.items()):
question = recs[0]["question"]
answers = [r["answer"] for r in recs]
writer.writerow([model_id, qid, question, answers[0], answers[1] if len(answers) > 1 else "", "", "", ""])
if __name__ == "__main__":
records = load_all_raw()
SCORED_DIR.mkdir(parents=True, exist_ok=True)
export_scoring_sheet(records, SCORED_DIR / "scoring_sheet.csv")
这样生成的 CSV 可以直接用 Excel 打开,人工对照标准答案逐行打分。打分列包括准确率和完整率,之后再用 pandas 聚合出每个模型的平均分。
python复制# 文件路径:scripts/03_generate_report.py
import pandas as pd
def generate_summary(scored_csv):
df = pd.read_csv(scored_csv)
summary = df.groupby("model_id").agg(
avg_accuracy=("accuracy", "mean"),
avg_completeness=("completeness", "mean"),
answer_count=("question_id", "count"),
).sort_values("avg_accuracy", ascending=False)
print(summary.to_string())
summary.to_csv("results/scored/summary.csv", encoding="utf-8-sig")
输出示例(数据仅为演示):
code复制 avg_accuracy avg_completeness answer_count
model_id
openai_gpt4o 0.92 0.88 38
anthropic_claude 0.89 0.91 38
google_gemini 0.86 0.84 38
deepseek_chat 0.81 0.79 38
4.5 一致性分析
一致性是很容易被忽略的评测维度。同一个问题,同一个模型,用相同参数问两次,答案可能完全不同。对于生产环境,这是不可接受的。
一致性分析的常见做法是:设置 temperature=0.7,把同一问题重复 3 到 5 次,然后用文本相似度(如编辑距离、ROUGE 分数)比较回答之间的差异。
python复制# 简易一致性计算:使用difflib计算字符级相似度
import difflib
def consistency_score(text1, text2):
if text1 == text2:
return 1.0
seq = difflib.SequenceMatcher(None, text1, text2)
return seq.ratio()
# 对同一问题同一模型的多次回答两两计算
def model_consistency(records):
scores = []
for i in range(len(records)):
for j in range(i + 1, len(records)):
scores.append(consistency_score(records[i]["answer"], records[j]["answer"]))
return sum(scores) / len(scores) if scores else 1.0
注意:文本相似度高不代表语义一致,更严谨的做法是用另一个更强的 LLM 做语义等价判断。把“相似度”和“语义等价”结合,才能更接近真实一致性水平。
5. 常见问题与排查思路
5.1 请求频繁报 429 限流错误
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| 脚本运行一段时间后大量报错 | 超过平台 QPS 或 Token 限额 | 在请求间增加 sleep;实现指数退避重试 |
| 同一问题不同时间运行结果差异大 | 模型厂商侧迭代了后端版本 | 记录评测时间窗口;使用带日期快照的模型别名 |
| 部分模型返回空字符串 | 输入触发了内容过滤或系统提示冲突 | 单独检查问题文本,调整提示词措辞 |
示例重试逻辑:
python复制import time
import random
def request_with_retry(func, retries=3):
for i in range(retries):
try:
return func()
except Exception as exc:
wait = 2 ** i + random.uniform(0, 1)
print(f"Request failed: {exc}, retry in {wait:.1f}s")
time.sleep(wait)
raise RuntimeError("Max retries exceeded")
5.2 模型版本漂移导致评测不可复现
很多模型 API 的“默认版本”会随时间变化。今天测的 gpt-4o 和三个月后测的 gpt-4o 可能不是同一个版本。解决方案:
- 尽量指定带日期后缀的模型版本标识。
- 在评测报告里记录运行日期、模型标识、SDK 版本。
- 如果无法固定版本,至少保留原始响应日志,便于事后归因。
5.3 标准答案本身过时
选举类问题时效性极强,今天正确的事实,选举结束后可能不再适用。因此评测集需要增加“知识截止日期”标注,并定期修订标准答案。
json复制{
"id": 1,
"question": "本次投票日的具体时间是?",
"answer_points": ["2025年某月某日"],
"knowledge_cutoff": "2025-01-15",
"validate_after": "2025-01-20"
}
这样后续做回归评测时,可以清楚看到哪些题目的标准答案需要更新,避免用旧答案误判新模型。
5.4 评分主观性太强
不同人打分,完整率和准确率的标准可能不一致。建议:
- 先抽 10 条回答做双人试打,统一评分标准。
- 使用 0/1/2 三档评分,减少模糊地带。
- 将争议样本单独标记,讨论后纳入最终结果。
6. 最佳实践与工程建议
6.1 从“一次性评测”变成“持续评测”
很多人做完一次模型对比就结束了,但 LLM 及应用代码更新频繁,一次评测结果的有效期很短。更推荐把评测流程做成一个小型工具链,接入 CI 或定时任务:
- 每天晚上跑一次评测子集(比如 10 道关键题)。
- 把评分结果写入数据库或 CSV。
- 用图表展示准确率和一致性的变化趋势。
这样既能监控模型侧变化,也能监控提示词修改带来的影响。
6.2 提示词版本也要纳入版本管理
既然模型版本会影响结果,提示词同样会影响结果。建议把提示词模板放到单独的目录,用 Git 管理:
code复制prompts/
├── v1_system_prompt.txt
├── v2_system_prompt.txt
└── README.md
每次修改提示词,都记录变更原因,并在评测报告里标明使用的提示词版本。
6.3 安全性:不要把评测集变成“越狱集”
在构建评测问题时,要区分“测试模型能力”和“测试模型安全边界”。如果评测集里包含诱导模型绕过安全限制的问题,务必只在沙盒环境中运行,并且不要公开传播具体越狱提示词。合法使用的边界是:你拥有测试该模型的权利,且测试不会对第三方造成损害。
对于选举类公共信息评测,问题应该聚焦在“模型能否提供准确、中立的信息”,而不是诱导模型输出政治立场或攻击性内容。设计问题时可以加一条自查规则:如果问题本身带有明显引导倾向,就重新措辞。
6.4 日志与可追溯性
每条模型回答都应该记录:
- 模型标识和实际调用名称。
- 提示词版本。
- 温度等采样参数。
- 请求发起时间。
- 原始响应和错误信息。
推荐把以上信息统一写入 JSON 或 SQLite。很多评测最终结论被质疑,都是因为缺少这些元信息。
6.5 不要迷信单一指标
模型 A 准确率高,不代表它整体更优。一个模型可能在事实题上表现优秀,但在列举题上漏点严重;另一个模型虽然准确率稍低,但回答格式更稳定,更适合直接交付给用户。
更合适的做法是:根据业务场景为每个维度设置权重。比如客服场景,完整性和稳定性权重更高;知识问答场景,准确性权重更高。
6.6 关注成本和延迟
对比评测时,很多人只关注“哪个模型答得好”,忘了成本和延迟同样是选型关键。建议在评测结果表里增加两列:平均单次请求成本、平均响应延迟。
具体做法很简单:在 ask_model 返回时,把 usage(token 消耗)和耗时记录下来:
python复制start = time.time()
answer = func()
latency = time.time() - start
prompt_tokens = resp.usage.prompt_tokens
completion_tokens = resp.usage.completion_tokens
最终用“效果 / 成本”的性价比思维选型,而不是单纯看分数。
7. 总结与学习路线
通过这篇文章,我们从零搭好了一个 8 模型对比评测的最小闭环:设计 38 道带标准答案的评测问题、统一提示词模板、封装多供应商调用层、批量运行并保存原始结果,最后用 CSV 评分表汇总准确率、完整率和一致性。这套流程不限于选举问答,完全可以迁移到产品 FAQ、技术文档校验、客服语料质检等场景。
下一步建议从这几个方向继续深入:
- 引入更成熟的评测框架,例如
promptfoo、LangSmith或DeepEval,把人工评分升级为半自动评分。 - 学习 RAG(检索增强生成)评测,将文档召回率和最终回答质量分开评估。
- 研究“评判模型”(LLM-as-a-judge)的可靠性,理解评判模型自身的偏好偏差。
- 把评测集接入 CI,让提示词和模型版本的任何改动都能第一时间反映到质量数据上。
实际项目中,优先要盯住的是三个风险:模型版本漂移、标准答案过期、评分标准不一致。这三件事任何一个失控,评测结果都会失真。
最后给一个小建议:不要等评测集“完美”了再开始跑。先拿 10 道题、2 个模型跑通全流程,再逐步扩充问题数和模型数,迭代速度比一开始追求大而全重要得多。如果本文对你有帮助,可以收藏备用,后面做 LLM 选型或 RAG 应用评测时直接对照着改就行。
