| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237 |
- import argparse
- import asyncio
- import hashlib
- import json
- from collections import Counter, defaultdict
- from pathlib import Path
- from time import time
- from uuid import UUID
- from sqlalchemy import select
- from zhifu.app import create_app
- from zhifu.evaluation import AgentManifest, Dataset, Judgment, digest, source_digest, write_report
- from zhifu.modules.agent_runtime.budget_callback import CallAllowance
- from zhifu.modules.agent_runtime.config import AgentSettings
- from zhifu.modules.agent_runtime.models import AgentRun, ModelAttempt
- from zhifu.modules.agent_runtime.worker import execute_one
- from zhifu.modules.integrations.models import ExternalOperation
- class PersistentAllowance(CallAllowance):
- def __init__(self, manifest, report, output, state):
- self.manifest, self.report, self.output, self.state = manifest, report, output, state
- super().__init__(manifest.max_calls - report["reserved_calls"])
- async def take(self):
- # 在模型调用前保存预留,未知结果及进程中断均消耗额度,重启不会增加限额。
- maximum = min(self.manifest.max_calls,
- self.manifest.max_total_output_tokens // self.manifest.max_output_tokens)
- reserved = await self.state.eval(
- "local calls = tonumber(redis.call('HGET', KEYS[1], 'calls')); "
- "local deadline = tonumber(redis.call('HGET', KEYS[1], 'deadline')); "
- "if not calls or not deadline then return -3 end; "
- "if tonumber(redis.call('TIME')[1]) >= deadline then return -2 end; "
- "if calls >= tonumber(ARGV[1]) then return -1 end; "
- "return redis.call('HINCRBY', KEYS[1], 'calls', 1)",
- 1, "evaluation-budget:" + self.report["manifest_sha256"], maximum,
- )
- if reserved < 0:
- raise ValueError("评测调用或时间限额已用完,或持久额度记录缺失")
- self.started += 1
- self.report["reserved_calls"] = reserved
- await asyncio.to_thread(write_report, self.output, self.report)
- async def initialize_report(args, dataset, manifest, cases, state):
- manifest_hash = digest(manifest.model_dump(mode="json"))
- budget_key = "evaluation-budget:" + manifest_hash
- output_identity = str(args.output.resolve())
- # 同一清单绑定唯一输出位置,Redis 额度不设置过期时间,修改路径不能获得新额度。
- binding = await state.hget(budget_key, "output")
- if binding is not None and binding.decode() != output_identity:
- raise ValueError("同一评测清单已经绑定其他输出文件")
- if args.output.exists():
- report = json.loads(await asyncio.to_thread(args.output.read_text, encoding="utf-8"))
- if report["manifest_sha256"] != manifest_hash:
- raise ValueError("恢复执行时清单必须保持一致")
- if binding is None:
- raise ValueError("恢复报告缺少 Redis 持久额度记录")
- else:
- if binding is not None:
- raise ValueError("已登记评测的报告文件缺失,需要从备份恢复")
- report = {"manifest_sha256": manifest_hash, "dataset_version": dataset.version,
- "generation_mode": "bounded" if args.execute else "read_only",
- "reserved_calls": 0, "deadline": (
- time() + manifest.total_timeout_seconds if args.execute else 0),
- "cases": {key: {**case, "status": "NOT_RUN"} for key, case in cases.items()}}
- await state.hset(budget_key, mapping={"output": output_identity, "calls": 0,
- "deadline": report["deadline"],
- "mode": report["generation_mode"]})
- mode = (await state.hget(budget_key, "mode")).decode()
- if args.execute and mode == "read_only":
- mode = "bounded"
- await state.hset(budget_key, mapping={"mode": mode,
- "deadline": time() + manifest.total_timeout_seconds})
- report["generation_mode"] = mode
- report["reserved_calls"] = int(await state.hget(budget_key, "calls"))
- report["deadline"] = float(await state.hget(budget_key, "deadline"))
- return report
- def load_inputs(dataset_path, manifest_path):
- content = dataset_path.read_bytes()
- dataset = Dataset.model_validate_json(content)
- manifest = AgentManifest.model_validate_json(manifest_path.read_bytes())
- if hashlib.sha256(content).hexdigest() != manifest.dataset_sha256:
- raise ValueError("评测资料摘要不一致")
- if source_digest() != manifest.source_sha256:
- raise ValueError("评测固定代码版本摘要不一致")
- cases = {case["id"]: case for case in dataset.cases()}
- for binding in manifest.cases:
- if binding.case_id not in cases or cases[binding.case_id]["split"] != manifest.split:
- raise ValueError("运行清单引用错误集合或未知评测编号")
- binding.review.verify()
- binding.environment_evidence.verify()
- return dataset, manifest, cases
- def category_report(rows):
- groups = defaultdict(Counter)
- for row in rows.values():
- groups[f"{row['split']}/{row['category']}"][row["status"]] += 1
- return {key: dict(value) for key, value in groups.items()}
- async def observe(sessions, binding, manifest, case):
- async with sessions() as session:
- run = await session.get(AgentRun, binding.run_id.bytes)
- if run is None or run.tenant_id != binding.tenant_id.bytes:
- raise ValueError("评测运行不存在或租户不一致")
- if run.graph_version != manifest.graph_version or run.model_name != manifest.model_name:
- raise ValueError("运行图版本或模型版本不一致")
- if not run.release_snapshot or digest(run.release_snapshot) != manifest.release_sha256:
- raise ValueError("运行发布参数摘要不一致")
- if run.release_snapshot.get("max_output_tokens") != manifest.max_output_tokens:
- raise ValueError("发布参数的单次输出上限不一致")
- prompts = [message["data"]["content"] for message in run.messages
- if message["type"] == "human"]
- if not prompts or prompts[0] != case["prompt"]:
- raise ValueError("运行的初始问题与评测资料不一致")
- attempts = (await session.scalars(select(ModelAttempt).where(
- ModelAttempt.run_id == run.id
- ).order_by(ModelAttempt.created_at, ModelAttempt.id))).all()
- observation = {"run_id": str(binding.run_id), "run_status": run.status,
- "error_code": run.error_code, "messages": run.messages,
- "waiting": run.waiting,
- "attempts": [{"id": str(UUID(bytes=item.id)), "status": item.status,
- "usage": item.usage} for item in attempts]}
- operations = (await session.scalars(select(ExternalOperation).where(
- ExternalOperation.tenant_id == run.tenant_id,
- ExternalOperation.ticket_id == run.business_ticket_id,
- ).order_by(ExternalOperation.id))).all() if run.business_ticket_id else []
- observation["external_operations"] = [{"id": str(UUID(bytes=item.id)),
- "status": item.status, "proposal_version": item.proposal_version}
- for item in operations]
- return observation
- async def evaluate(args, dataset, manifest, cases):
- manifest_hash = digest(manifest.model_dump(mode="json"))
- output = args.output
- judgments = {}
- if args.judgments:
- for value in json.loads(await asyncio.to_thread(args.judgments.read_bytes)):
- judgment = Judgment.model_validate(value)
- await asyncio.to_thread(judgment.verify)
- if judgment.case_id in judgments:
- raise ValueError("结果审核编号重复")
- judgments[judgment.case_id] = judgment
- if not judgments.keys() <= {binding.case_id for binding in manifest.cases}:
- raise ValueError("结果审核包含清单之外的评测")
- app = create_app()
- async with app.router.lifespan_context(app):
- lock = app.state.state.lock(
- "evaluation:" + manifest_hash, timeout=manifest.total_timeout_seconds + 30,
- blocking_timeout=0, thread_local=False
- )
- if not await lock.acquire():
- raise RuntimeError("同一评测清单已有执行进程")
- try:
- report = await initialize_report(args, dataset, manifest, cases, app.state.state)
- if args.execute:
- for role in ("agent", "memory"):
- async for _ in app.state.state.scan_iter(match=f"monitor:worker:{role}:*"):
- raise RuntimeError("受限评测要求停止常驻生成工作进程")
- allowance = PersistentAllowance(manifest, report, output, app.state.state)
- await asyncio.to_thread(write_report, output, report)
- for binding in manifest.cases:
- row = report["cases"][binding.case_id]
- observation = await observe(app.state.sessions, binding, manifest, row)
- # 已有终态和业务等待只读取证据;重新执行只允许当前排队状态。
- if args.execute and observation["run_status"] == "QUEUED":
- remaining = report["deadline"] - time()
- if remaining <= 0:
- raise TimeoutError("评测总时间已达到上限")
- try:
- async with asyncio.timeout(remaining):
- await execute_one(
- app.state.sessions, app.state.state,
- AgentSettings(max_output_tokens=manifest.max_output_tokens),
- app.state.knowledge.search, allowance=allowance,
- run_ids=[binding.run_id.bytes],
- )
- finally:
- observation = await observe(app.state.sessions, binding, manifest, row)
- row.update(observation=observation, status=(
- "REVIEW_REQUIRED" if observation["attempts"] else "NOT_RUN"))
- row["observation_sha256"] = digest(observation)
- await asyncio.to_thread(write_report, output, report)
- row.update(observation=observation)
- row["observation_sha256"] = digest(observation)
- row["status"] = "REVIEW_REQUIRED" if observation["attempts"] else "NOT_RUN"
- if binding.case_id in judgments:
- judgment = judgments[binding.case_id]
- if (judgment.observation_sha256 != row["observation_sha256"]
- or not observation["attempts"]
- or observation["run_status"] in {"QUEUED", "RUNNING"}):
- raise ValueError("结果审核必须对应已执行并暂停或结束的当前运行证据")
- row["status"] = judgment.verdict
- row["judgment"] = judgment.model_dump(mode="json")
- elif row.get("judgment"):
- previous = Judgment.model_validate(row["judgment"])
- await asyncio.to_thread(previous.verify)
- if previous.observation_sha256 == row["observation_sha256"]:
- row["status"] = previous.verdict
- else:
- row.pop("judgment")
- report["categories"] = category_report(report["cases"])
- report["recorded_attempts"] = sum(len(item.get("observation", {}).get(
- "attempts", [])) for item in report["cases"].values())
- attempts = [attempt for item in report["cases"].values()
- for attempt in item.get("observation", {}).get("attempts", [])]
- report["usage"] = {key: sum((item["usage"] or {}).get(key, 0)
- for item in attempts)
- for key in ("input_tokens", "output_tokens", "total_tokens")}
- report["attempts_without_usage"] = sum(item["usage"] is None for item in attempts)
- await asyncio.to_thread(write_report, output, report)
- finally:
- await lock.release()
- print(json.dumps({"output": str(output), "reserved_calls": report["reserved_calls"],
- "categories": report["categories"]}, ensure_ascii=False))
- if __name__ == "__main__":
- parser = argparse.ArgumentParser(description="读取或受限执行已审核的 Agent 评测清单")
- parser.add_argument("--dataset", type=Path, default=Path("evaluations/agent-scenarios.json"))
- parser.add_argument("--manifest", type=Path, required=True)
- parser.add_argument("--output", type=Path, required=True)
- parser.add_argument("--execute", action="store_true", help="显式允许模型请求")
- parser.add_argument("--judgments", type=Path, help="人工结果审核 JSON 列表")
- parser.add_argument("--validate-only", action="store_true")
- args = parser.parse_args()
- dataset, manifest, cases = load_inputs(args.dataset, args.manifest)
- if args.validate_only:
- print(json.dumps({"valid": True, "cases": len(manifest.cases), "generation_attempts": 0}))
- else:
- asyncio.run(evaluate(args, dataset, manifest, cases))
|