241 lines
11 KiB
Python
241 lines
11 KiB
Python
# -*- coding: utf-8 -*-
|
||||
|
|
"""
|
|||
|
|
V2 自动评估体系
|
|||
|
|
- eval 数据集:用例(input/expected)+ 评分标准(rubric),随任务运行自动沉淀
|
|||
|
|
- 评估运行:Worker 逐条作答 → LLM-as-Judge 按 rubric 打分 → 平均分/排行榜
|
|||
|
|
- 一键沉淀:把已验收通过的任务产出转为 eval 用例
|
|||
|
|
"""
|
|||
|
|
import json
|
|||
|
|
import time
|
|||
|
|
import threading
|
|||
|
|
import traceback
|
|||
|
|
import db
|
|||
|
|
import llm_gateway
|
|||
|
|
import notify
|
|||
|
|
|
|||
|
|
JUDGE_PROMPT = (
|
|||
|
|
'你是严格、公正的评估裁判(LLM-as-Judge)。请对执行结果按评分标准打分。\n'
|
|||
|
|
'输出严格 JSON:{{"score": 0到100的整数, "judgment": "评分理由(50字内)"}}\n'
|
|||
|
|
'只输出 JSON。\n\n'
|
|||
|
|
'评分标准:{rubric}\n'
|
|||
|
|
'测试输入:{input}\n'
|
|||
|
|
'参考答案(预期):{expected}\n'
|
|||
|
|
'执行结果:{output}'
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
DEFAULT_RUBRIC = (
|
|||
|
|
'内容准确性 40%(与参考答案一致性、事实正确性),'
|
|||
|
|
'完整性 30%(是否覆盖输入要求的所有要点),'
|
|||
|
|
'清晰度与格式 30%(结构清晰、语言通顺)。'
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
BUILTIN_DATASETS = [
|
|||
|
|
{
|
|||
|
|
'name': '通用指令遵循',
|
|||
|
|
'description': '基础指令遵循与回答质量基准集(内置)',
|
|||
|
|
'rubric': '指令遵循度 50%(是否按要求格式/要点作答),准确性 30%,简洁性 20%。',
|
|||
|
|
'tags': ['基础', '指令遵循'],
|
|||
|
|
'cases': [
|
|||
|
|
{'input': '用 3 句话向非技术人员解释什么是"大语言模型(LLM)"。',
|
|||
|
|
'expected': '包含:基于海量文本训练、预测下一个词/生成文本、可对话问答等要点'},
|
|||
|
|
{'input': '列出数据库事务的 ACID 四个特性,并各用一句话解释。',
|
|||
|
|
'expected': '原子性、一致性、隔离性、持久性,且各有解释'},
|
|||
|
|
{'input': '写一段 50 字以内的产品文案,推广一款 AI 项目管理工具。',
|
|||
|
|
'expected': '≤50字、突出AI提效、有行动号召'},
|
|||
|
|
],
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
'name': '代码生成',
|
|||
|
|
'description': '代码生成正确性基准集(内置)',
|
|||
|
|
'rubric': '功能正确性 60%,代码质量 25%(可读性/健壮性),格式规范 15%。',
|
|||
|
|
'tags': ['代码'],
|
|||
|
|
'cases': [
|
|||
|
|
{'input': '用 Python 写一个函数 fibonacci(n),返回斐波那契数列前 n 项。',
|
|||
|
|
'expected': '正确实现、处理 n<=0、含示例'},
|
|||
|
|
{'input': '写一个 SQL 查询:统计每个部门的员工人数,按人数降序。',
|
|||
|
|
'expected': 'GROUP BY department, ORDER BY count DESC'},
|
|||
|
|
{'input': '用 Python 写一个装饰器 @retry(times=3),失败自动重试。',
|
|||
|
|
'expected': '捕获异常重试、最多 times 次、可传参'},
|
|||
|
|
],
|
|||
|
|
},
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _extract_judge(text):
|
|||
|
|
"""解析裁判 JSON,容错兜底"""
|
|||
|
|
try:
|
|||
|
|
t = text.strip()
|
|||
|
|
if t.startswith('```'):
|
|||
|
|
t = t.strip('`')
|
|||
|
|
if t.startswith('json'):
|
|||
|
|
t = t[4:]
|
|||
|
|
t = t.strip()
|
|||
|
|
start = t.find('{')
|
|||
|
|
end = t.rfind('}') + 1
|
|||
|
|
data = json.loads(t[start:end]) if end > start else {}
|
|||
|
|
score = float(data.get('score', data.get('总分', 0)))
|
|||
|
|
judgment = data.get('judgment') or data.get('评分理由') or ''
|
|||
|
|
return max(0.0, min(100.0, score)), str(judgment)[:500]
|
|||
|
|
except Exception:
|
|||
|
|
import re
|
|||
|
|
m = re.search(r'(\d{1,3})\s*[//]\s*100', text)
|
|||
|
|
if m:
|
|||
|
|
return float(m.group(1)), text[:300]
|
|||
|
|
m = re.search(r'"score"\s*:\s*(\d{1,3})', text)
|
|||
|
|
if m:
|
|||
|
|
return float(m.group(1)), text[:300]
|
|||
|
|
return 0.0, '裁判输出解析失败'
|
|||
|
|
|
|||
|
|
|
|||
|
|
def create_builtin_datasets():
|
|||
|
|
"""首次启动注入内置数据集"""
|
|||
|
|
c = db.q('SELECT COUNT(*) c FROM eval_datasets WHERE is_builtin=1')[0]['c']
|
|||
|
|
if c > 0:
|
|||
|
|
return
|
|||
|
|
for ds in BUILTIN_DATASETS:
|
|||
|
|
did = db.w(
|
|||
|
|
'INSERT INTO eval_datasets (name, description, rubric, tags, is_builtin, created_at, updated_at) '
|
|||
|
|
'VALUES (?,?,?,?,1,?,?)',
|
|||
|
|
(ds['name'], ds['description'], ds['rubric'], json.dumps(ds['tags']), db.now(), db.now()))
|
|||
|
|
for case in ds['cases']:
|
|||
|
|
db.w('INSERT INTO eval_cases (dataset_id, input, expected, tags, created_at) VALUES (?,?,?,?,?)',
|
|||
|
|
(did, case['input'], case['expected'], json.dumps(ds['tags']), db.now()))
|
|||
|
|
|
|||
|
|
|
|||
|
|
def sink_done_tasks():
|
|||
|
|
"""沉淀:把最近验收通过(done 且无打回)且尚未沉淀的任务转为 eval 用例(幂等)"""
|
|||
|
|
# 幂等标记:记录在 settings
|
|||
|
|
sunk = set(json.loads(db.get_setting('eval_sunk_task_ids', '[]')))
|
|||
|
|
rows = db.q(
|
|||
|
|
'SELECT id, title, description, output_text, updated_at FROM tasks '
|
|||
|
|
'WHERE status="done" AND rejection_count=0 AND output_text!="" ORDER BY id DESC LIMIT 200')
|
|||
|
|
created = 0
|
|||
|
|
for t in rows:
|
|||
|
|
if t['id'] in sunk:
|
|||
|
|
continue
|
|||
|
|
# 找到/创建"任务沉淀"数据集
|
|||
|
|
ds = db.q('SELECT id FROM eval_datasets WHERE name=?', ('任务产出沉淀',), one=True)
|
|||
|
|
if not ds:
|
|||
|
|
ds_id = db.w(
|
|||
|
|
'INSERT INTO eval_datasets (name, description, rubric, tags, is_builtin, created_at, updated_at) '
|
|||
|
|
'VALUES (?,?,?,?,0,?,?)',
|
|||
|
|
('任务产出沉淀', '从已验收任务自动沉淀的高质量问答对', DEFAULT_RUBRIC,
|
|||
|
|
json.dumps(['沉淀']), db.now(), db.now()))
|
|||
|
|
else:
|
|||
|
|
ds_id = ds['id']
|
|||
|
|
# 避免重复用例
|
|||
|
|
dup = db.q('SELECT id FROM eval_cases WHERE dataset_id=? AND input=?', (ds_id, t['description'] or t['title']), one=True)
|
|||
|
|
if dup:
|
|||
|
|
sunk.add(t['id'])
|
|||
|
|
continue
|
|||
|
|
db.w('INSERT INTO eval_cases (dataset_id, input, expected, tags, created_at) VALUES (?,?,?,?,?)',
|
|||
|
|
(ds_id, (t['description'] or t['title'])[:2000], t['output_text'][:4000],
|
|||
|
|
json.dumps(['沉淀', f'task#{t["id"]}']), db.now()))
|
|||
|
|
sunk.add(t['id'])
|
|||
|
|
created += 1
|
|||
|
|
db.set_setting('eval_sunk_task_ids', json.dumps(list(sunk)[-2000:]))
|
|||
|
|
return created
|
|||
|
|
|
|||
|
|
|
|||
|
|
def evaluate_case(dataset, case, worker):
|
|||
|
|
"""单条用例:Worker 作答 + Judge 打分。返回 (output, score, judgment, latency, cost, tokens)"""
|
|||
|
|
t0 = time.time()
|
|||
|
|
# 1) Worker 作答
|
|||
|
|
r = llm_gateway.chat(
|
|||
|
|
worker['provider'], worker['model'],
|
|||
|
|
[{'role': 'system', 'content': worker['system_prompt'] or '你是待评估的执行 Agent,请直接回答问题。'},
|
|||
|
|
{'role': 'user', 'content': case['input']}],
|
|||
|
|
temperature=worker['temperature'], max_tokens=worker['max_tokens'] or 2000,
|
|||
|
|
base_url=worker['base_url'] or None, api_key=worker['api_key'] or None)
|
|||
|
|
output = r['text']
|
|||
|
|
latency = int((time.time() - t0) * 1000)
|
|||
|
|
tokens = r['total_tokens']
|
|||
|
|
|
|||
|
|
# 2) Judge 打分
|
|||
|
|
judge_cost = 0.0
|
|||
|
|
try:
|
|||
|
|
j = llm_gateway.chat(
|
|||
|
|
worker['provider'], worker['model'],
|
|||
|
|
[{'role': 'system', 'content': '你只输出 JSON。'},
|
|||
|
|
{'role': 'user', 'content': JUDGE_PROMPT.format(
|
|||
|
|
rubric=dataset['rubric'] or DEFAULT_RUBRIC,
|
|||
|
|
input=case['input'][:2000],
|
|||
|
|
expected=(case['expected'] or '无参考答案,凭专业判断')[:3000],
|
|||
|
|
output=output[:4000])}],
|
|||
|
|
temperature=0.1, max_tokens=600,
|
|||
|
|
base_url=worker['base_url'] or None, api_key=worker['api_key'] or None)
|
|||
|
|
score, judgment = _extract_judge(j['text'])
|
|||
|
|
judge_cost = j['cost']
|
|||
|
|
tokens += j['total_tokens']
|
|||
|
|
except Exception as e:
|
|||
|
|
score, judgment = 0.0, f'裁判调用失败: {e}'
|
|||
|
|
total_cost = round(r['cost'] + judge_cost, 6)
|
|||
|
|
return output, score, judgment, latency, total_cost, tokens
|
|||
|
|
|
|||
|
|
|
|||
|
|
def run_eval(run_id):
|
|||
|
|
"""后台线程:执行一次完整评估"""
|
|||
|
|
run = db.q('SELECT * FROM eval_runs WHERE id=?', (run_id,), one=True)
|
|||
|
|
if not run:
|
|||
|
|
return
|
|||
|
|
ds = db.q('SELECT * FROM eval_datasets WHERE id=?', (run['dataset_id'],), one=True)
|
|||
|
|
worker = db.q('SELECT * FROM workers WHERE id=?', (run['worker_id'],), one=True)
|
|||
|
|
if not ds or not worker:
|
|||
|
|
db.w('UPDATE eval_runs SET status="failed", finished_at=? WHERE id=?', (db.now(), run_id))
|
|||
|
|
return
|
|||
|
|
cases = db.q('SELECT * FROM eval_cases WHERE dataset_id=? ORDER BY id', (ds['id'],))
|
|||
|
|
total_tokens, total_cost, scores, done = 0, 0.0, [], 0
|
|||
|
|
try:
|
|||
|
|
for case in cases:
|
|||
|
|
try:
|
|||
|
|
output, score, judgment, latency, cost, tokens = evaluate_case(ds, case, worker)
|
|||
|
|
db.w(
|
|||
|
|
'INSERT INTO eval_results (run_id, case_id, worker_id, output, score, judgment, '
|
|||
|
|
'latency_ms, cost, created_at) VALUES (?,?,?,?,?,?,?,?,?)',
|
|||
|
|
(run_id, case['id'], worker['id'], output, score, judgment, latency, cost, db.now()))
|
|||
|
|
total_cost += cost
|
|||
|
|
total_tokens += tokens
|
|||
|
|
scores.append(score)
|
|||
|
|
done += 1
|
|||
|
|
db.w('UPDATE eval_runs SET cases_done=? WHERE id=?', (done, run_id))
|
|||
|
|
except Exception as e:
|
|||
|
|
db.w(
|
|||
|
|
'INSERT INTO eval_results (run_id, case_id, worker_id, output, score, judgment, '
|
|||
|
|
'latency_ms, cost, created_at) VALUES (?,?,?,?,?,?,?,?,?)',
|
|||
|
|
(run_id, case['id'], worker['id'], '', 0, f'执行失败: {str(e)[:200]}', 0, 0, db.now()))
|
|||
|
|
done += 1
|
|||
|
|
db.w('UPDATE eval_runs SET cases_done=? WHERE id=?', (done, run_id))
|
|||
|
|
avg = round(sum(scores) / len(scores), 2) if scores else 0
|
|||
|
|
db.w('UPDATE eval_runs SET status="done", score=?, total_tokens=?, cost=?, cases_done=?, finished_at=? '
|
|||
|
|
'WHERE id=?', (avg, total_tokens, round(total_cost, 6), done, db.now(), run_id))
|
|||
|
|
try:
|
|||
|
|
notify.notify('task_done', f'评估完成:{ds["name"]}',
|
|||
|
|
f'Worker「{worker["name"]}」在数据集「{ds["name"]}」上平均分 {avg}/100,'
|
|||
|
|
f'共 {done}/{len(cases)} 条用例,成本 ¥{total_cost:.4f}',
|
|||
|
|
save_alert=True, level='info', atype='eval_done')
|
|||
|
|
except Exception:
|
|||
|
|
pass
|
|||
|
|
except Exception as e:
|
|||
|
|
db.w('UPDATE eval_runs SET status="failed", error=?, finished_at=? WHERE id=?',
|
|||
|
|
(str(e)[:300], db.now(), run_id))
|
|||
|
|
_trace(e)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _trace(e):
|
|||
|
|
print('[eval]', traceback.format_exc())
|
|||
|
|
|
|||
|
|
|
|||
|
|
class EvalRunner:
|
|||
|
|
def __init__(self):
|
|||
|
|
self._threads = {}
|
|||
|
|
|
|||
|
|
def submit(self, run_id):
|
|||
|
|
if run_id in self._threads and self._threads[run_id].is_alive():
|
|||
|
|
return False
|
|||
|
|
t = threading.Thread(target=run_eval, args=(run_id,), daemon=True)
|
|||
|
|
self._threads[run_id] = t
|
|||
|
|
t.start()
|
|||
|
|
return True
|
|||
|
|
|
|||
|
|
|
|||
|
|
runner = EvalRunner()
|