1. 为什么我要在本地搭一套最小评测闭环
大语言模型评测这件事,听起来像是大厂才需要做的重活,但实际落地时你会发现,真正卡住普通开发者的不是算力,而是「不知道从哪一步开始」。我最初想验证一个本地 qwen 模型在中文问答上的表现,翻了一圈资料,要么是直接甩一个榜单分数,要么是讲一堆指标公式却不给能跑的脚本。于是我自己动手搭了一套最小闭环:用 ollama 拉起 qwen,围绕 F1 值设计一个小评测集,写一个能复用的评测脚本,最后跑一次完整流程看结果。
这套流程适合谁?适合手上有本地模型、想快速验证效果、又不想引入复杂评测框架的人。它不追求刷榜,追求的是「今天下午就能跑通」。核心检索词就三个:大语言模型、模型评测、F1 值。F1 值在这里的作用是把精确率和召回率合成一个数,让你一眼看出模型答得准不准、全不全。ollama 负责把模型跑起来,qwen 是待测对象,评测集和脚本是你要自己掌控的部分。
我试过用现成的评测平台,配置项多到让人分心,最后还是回到「一个脚本 + 一个配置文件」的路子。下面按顺序讲:先准备环境和模型,再设计评测集和 config.toml,然后写脚本骨架,接着跑一次完整验证,最后把常见报错列出来。每一步都给可复制的命令和代码,你跟着改路径就能用。
2. TaoToken 前置:把模型调用和评测解耦
在本地跑评测,最容易被忽略的是「模型调用层」和「评测逻辑层」的耦合。如果你把 ollama 的调用直接写死在评测脚本里,换一个模型或换一个接口就要改一堆代码。我的做法是加一层轻量的 API 网关,把模型对话统一成 OpenAI 兼容格式,这样评测脚本只认接口,不认底层是 ollama 还是别的。
TaoToken 在这里的角色就是这层网关。它的 API 地址是 https://taotoken.net/api,兼容常见的 chat completions 协议。你可以在本地 ollama 和评测脚本之间插一个配置项,让脚本既能直连 ollama,也能走统一接口。这样做的好处是:评测逻辑不变,模型来源可切换。
如果你只是本地验证,ollama 直连就够了;但如果你想把评测结果和线上模型对比,或者团队里有人用不同模型,统一接口会省很多事。模型对话入口在 https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_content=model_chat&utm_campaign=rewrite,接入文档在 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite,API Keys 管理在 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api_keys&utm_campaign=rewrite。这些链接先记着,后面配置里会用到。
注意:评测脚本里不要把密钥硬编码进代码,用环境变量或单独的 .env 文件,提交代码前检查一遍。
3. 可复制配置:ollama 拉起 qwen 与 config.toml
3.1 安装 ollama 并拉取 qwen
先确认本机已经装好 ollama。Linux 和 macOS 用官方脚本,Windows 用安装包。装完后验证版本:
ollama --version拉取 qwen 模型。这里选 qwen2.5:7b,体积和效果比较平衡;如果你的机器内存小,可以换 qwen2.5:3b:
ollama pull qwen2.5:7b拉完后确认模型在列表里:
ollama list启动服务,默认监听 11434 端口:
ollama serve另开一个终端,测试模型能不能正常对话:
curl http://localhost:11434/api/chat -d '{ "model": "qwen2.5:7b", "messages": [{"role": "user", "content": "用一句话解释什么是F1值"}], "stream": false }'如果返回里有 message.content,说明模型通了。这一步很关键,很多人后面评测报错,其实是模型根本没拉下来或服务没起。
3.2 设计最小评测集
评测集不用大,20 到 50 条就够验证流程。格式用 jsonl,一行一条,字段包括 input、context、answers。answers 用列表,方便以后扩展多参考答案。示例:
{"input": "《孔子世家谱》的编修工作历时多久?", "context": "《孔子世家谱》第五次大修后裔资料收集工作历时10年完成。", "answers": ["10年。"]} {"input": "鹤壁市经济社会发展面临哪些困难?", "context": "鹤壁市面临经济下行压力加大、转型任务艰巨、风险隐患不容忽视等困难。", "answers": ["经济下行压力加大、转型任务艰巨、风险隐患不容忽视。"]}把文件存成eval_set.jsonl。注意 context 不要过长,本地模型上下文有限,建议单条控制在 2000 字以内。如果你的原始数据 context 很长,可以在脚本里做截断,后面会讲。
3.3 config.toml 配置片段
用一个 config.toml 把模型、路径、评测参数集中管理,避免脚本里到处是魔法数字:
[model] provider = "ollama" base_url = "http://localhost:11434" model_name = "qwen2.5:7b" max_tokens = 256 temperature = 0.0 [eval] dataset_path = "./eval_set.jsonl" output_path = "./predictions.jsonl" max_context_length = 2000 prompt_template = "阅读以下文字并用中文简短回答:\n\n{context}\n\n现在请基于上面的文章回答下面的问题,只告诉我答案,不要输出任何其他字词。\n\n问题:{input}\n回答:" [scoring] use_jieba = true remove_punctuation = truetemperature 设 0.0 是为了让评测结果可复现。max_tokens 控制生成长度,太长会拖慢评测。prompt_template 里的占位符要和脚本里的 format 字段对应。
如果你要走统一接口而不是本地 ollama,把 provider 改成openai_compatible,base_url 改成https://taotoken.net/api,再补一个 api_key 字段从环境变量读。这样同一套评测脚本可以切换模型来源。
4. 评测脚本骨架:从预测到 F1 计算
4.1 加载配置和数据集
脚本用 Python,依赖 requests、jieba、tomli(Python 3.11 以下需要装 tomli 读 toml)。先写加载部分:
import json import os import requests import jieba from collections import Counter import tomli def load_config(path="./config.toml"): with open(path, "rb") as f: return tomli.load(f) def load_jsonl(path): data = [] with open(path, "r", encoding="utf-8") as f: for line in f: line = line.strip() if line: data.append(json.loads(line)) return data4.2 调用模型生成预测
核心函数是 chat,兼容 ollama 和 OpenAI 兼容接口:
def chat(config, prompt): model_cfg = config["model"] if model_cfg["provider"] == "ollama": url = f"{model_cfg['base_url']}/api/chat" payload = { "model": model_cfg["model_name"], "messages": [{"role": "user", "content": prompt}], "stream": False, "options": {"temperature": model_cfg["temperature"]} } resp = requests.post(url, json=payload, timeout=120) resp.raise_for_status() return resp.json()["message"]["content"].strip() else: url = f"{model_cfg['base_url']}/v1/chat/completions" headers = {"Authorization": f"Bearer {os.environ.get('TAOTOKEN_API_KEY', '')}"} payload = { "model": model_cfg["model_name"], "messages": [{"role": "user", "content": prompt}], "temperature": model_cfg["temperature"], "max_tokens": model_cfg["max_tokens"] } resp = requests.post(url, json=payload, headers=headers, timeout=120) resp.raise_for_status() return resp.json()["choices"][0]["message"]["content"].strip()生成预测的主循环,注意 context 截断逻辑:
def generate_predictions(config, dataset): template = config["eval"]["prompt_template"] max_len = config["eval"]["max_context_length"] out_path = config["eval"]["output_path"] results = [] for item in dataset: context = item.get("context", "") if len(context) > max_len: half = max_len // 2 context = context[:half] + context[-half:] prompt = template.format(context=context, input=item["input"]) pred = chat(config, prompt) record = {"pred": pred, "answers": item["answers"]} results.append(record) with open(out_path, "a", encoding="utf-8") as f: f.write(json.dumps(record, ensure_ascii=False) + "\n") return results4.3 数据清洗与 F1 计算
中文评测必须做标点清洗,否则标点会拉低分数。清洗函数:
import string def normalize_zh_answer(s): def white_space_fix(text): return "".join(text.split()) def remove_punc(text): cn_punc = "!?。。"#$%&'()*+,-/:;<=>@[\]^_`{|}~⦅⦆「」、、〃》「」『』【】〔〕〖〗〘〙〚〛〜〝〞〟〰〾〿–—‘’‛“”„‟…‧﹏." all_punc = set(string.punctuation + cn_punc) return "".join(ch for ch in text if ch not in all_punc) return white_space_fix(remove_punc(s.lower()))F1 计算函数:
def f1_score(prediction, ground_truth): pred_tokens = list(jieba.cut(normalize_zh_answer(prediction))) gt_tokens = list(jieba.cut(normalize_zh_answer(ground_truth))) common = Counter(pred_tokens) & Counter(gt_tokens) num_same = sum(common.values()) if num_same == 0: return 0.0 precision = num_same / len(pred_tokens) recall = num_same / len(gt_tokens) return (2 * precision * recall) / (precision + recall)整体评测,取每条最高分再平均:
def evaluate(results): total = 0.0 for r in results: pred = r["pred"] answers = r["answers"] best = max(f1_score(pred, gt) for gt in answers) total += best return round(100 * total / len(results), 2)把上面拼起来,主入口:
if __name__ == "__main__": cfg = load_config() ds = load_jsonl(cfg["eval"]["dataset_path"]) preds = generate_predictions(cfg, ds) score = evaluate(preds) print(f"F1 score: {score}")5. 验证请求与成功结果
先跑一条数据验证链路通不通。把评测集只留一条,执行:
python eval.py如果看到类似输出:
F1 score: 62.5说明预测和打分都跑通了。再跑完整评测集,观察 predictions.jsonl 里的内容:
{"pred": "历时10年。", "answers": ["10年。"]} {"pred": "经济下行压力加大、转型任务艰巨、风险隐患不容忽视。", "answers": ["经济下行压力加大、转型任务艰巨、风险隐患不容忽视。"]}第一条 pred 和 answers 分词后交集是「10」「年」,F1 会比较高;第二条几乎完全匹配,F1 接近 1。整体分数在 40 到 70 之间都算正常,取决于评测集难度和模型大小。如果你用的是 3b 模型,分数低一些不用慌,重点是流程跑通。
想验证模型对话本身是否正常,可以单独发一条请求:
curl https://taotoken.net/api/v1/chat/completions \ -H "Authorization: Bearer $TAOTOKEN_API_KEY" \ -H "Content-Type: application/json" \ -d '{"model":"qwen2.5:7b","messages":[{"role":"user","content":"你好"}]}'返回正常说明接口层没问题。模型对话入口在 https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_content=model_chat&utm_campaign=rewrite,可以对照返回格式检查字段。
6. 本篇常见错排查
6.1 ollama 连接被拒绝
报错Connection refused或Max retries exceeded,先确认 ollama serve 是否在跑:
curl http://localhost:11434/api/tags如果这条也失败,说明服务没起。检查端口是否被占用,或者 ollama 是否装在了别的用户下。Windows 上有时需要手动启动 ollama 应用。
6.2 模型名写错导致 404
ollama 返回model not found,用ollama list看实际模型名。注意 qwen2.5:7b 和 qwen:7b 是两个不同的 tag,config.toml 里要和 list 输出完全一致。
6.3 F1 分数异常低
如果分数低于 10,先检查分词和清洗。常见原因是 pred 里带了大量解释性文字,而 answers 很短,交集自然少。可以在 prompt 里强调「只告诉我答案,不要输出任何其他字词」,或者在后处理里截取第一句。另一个原因是 context 截断把关键信息切掉了,适当调大 max_context_length。
6.4 jieba 分词结果不符合预期
jieba 对专有名词的分词可能和你的预期不同。如果评测集里有很多领域术语,可以加载自定义词典:
jieba.load_userdict("user_dict.txt")每行一个词,格式为「词语 词频 词性」,词频和词性可省略。
6.5 统一接口返回 401
走 TaoToken 接口时如果报 401,检查环境变量 TAOTOKEN_API_KEY 是否设置,以及请求头格式是否为Bearer <key>。API Keys 管理在 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api_keys&utm_campaign=rewrite,确认 key 没有过期或被禁用。接入细节看 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite。
6.6 评测跑得太慢
本地 7b 模型单条生成可能要几秒到十几秒,50 条就是几分钟。可以先把 max_tokens 调小,或者用 3b 模型先验证流程。如果要做长期编码或 Agent 场景的评测,考虑用 Coding Plan 把调用层独立出来,入口在 https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding_plan&utm_campaign=rewrite。
7. 把评测闭环用起来
这套最小闭环跑通后,你可以做几件事:换模型对比同一评测集的 F1 分数;调整 prompt 模板看分数变化;扩充评测集覆盖更多场景。关键是脚本和配置分离,换模型只改 config.toml,评测逻辑不动。
如果你想把评测接入 CI,可以在每次模型更新后自动跑一遍,把分数写进日志。如果团队用 Claude Code 做开发,也可以把评测脚本挂到 Anthropic 兼容的调用层上,入口在 https://taotoken.net/claude-code-anthropic?utm_source=taotoken_aicg_blog_end&utm_content=claude_code&utm_campaign=rewrite。控制台在 https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_content=console&utm_campaign=rewrite,可以看调用记录和用量。
最后提醒一句:评测集的质量比数量重要。20 条覆盖不同题型的数据,比 200 条同质化数据更能暴露问题。F1 值只是起点,真正有价值的是你从错误案例里看到的模型短板。