evaluate_agent_runs.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237
  1. import argparse
  2. import asyncio
  3. import hashlib
  4. import json
  5. from collections import Counter, defaultdict
  6. from pathlib import Path
  7. from time import time
  8. from uuid import UUID
  9. from sqlalchemy import select
  10. from zhifu.app import create_app
  11. from zhifu.evaluation import AgentManifest, Dataset, Judgment, digest, source_digest, write_report
  12. from zhifu.modules.agent_runtime.budget_callback import CallAllowance
  13. from zhifu.modules.agent_runtime.config import AgentSettings
  14. from zhifu.modules.agent_runtime.models import AgentRun, ModelAttempt
  15. from zhifu.modules.agent_runtime.worker import execute_one
  16. from zhifu.modules.integrations.models import ExternalOperation
  17. class PersistentAllowance(CallAllowance):
  18. def __init__(self, manifest, report, output, state):
  19. self.manifest, self.report, self.output, self.state = manifest, report, output, state
  20. super().__init__(manifest.max_calls - report["reserved_calls"])
  21. async def take(self):
  22. # 在模型调用前保存预留,未知结果及进程中断均消耗额度,重启不会增加限额。
  23. maximum = min(self.manifest.max_calls,
  24. self.manifest.max_total_output_tokens // self.manifest.max_output_tokens)
  25. reserved = await self.state.eval(
  26. "local calls = tonumber(redis.call('HGET', KEYS[1], 'calls')); "
  27. "local deadline = tonumber(redis.call('HGET', KEYS[1], 'deadline')); "
  28. "if not calls or not deadline then return -3 end; "
  29. "if tonumber(redis.call('TIME')[1]) >= deadline then return -2 end; "
  30. "if calls >= tonumber(ARGV[1]) then return -1 end; "
  31. "return redis.call('HINCRBY', KEYS[1], 'calls', 1)",
  32. 1, "evaluation-budget:" + self.report["manifest_sha256"], maximum,
  33. )
  34. if reserved < 0:
  35. raise ValueError("评测调用或时间限额已用完,或持久额度记录缺失")
  36. self.started += 1
  37. self.report["reserved_calls"] = reserved
  38. await asyncio.to_thread(write_report, self.output, self.report)
  39. async def initialize_report(args, dataset, manifest, cases, state):
  40. manifest_hash = digest(manifest.model_dump(mode="json"))
  41. budget_key = "evaluation-budget:" + manifest_hash
  42. output_identity = str(args.output.resolve())
  43. # 同一清单绑定唯一输出位置,Redis 额度不设置过期时间,修改路径不能获得新额度。
  44. binding = await state.hget(budget_key, "output")
  45. if binding is not None and binding.decode() != output_identity:
  46. raise ValueError("同一评测清单已经绑定其他输出文件")
  47. if args.output.exists():
  48. report = json.loads(await asyncio.to_thread(args.output.read_text, encoding="utf-8"))
  49. if report["manifest_sha256"] != manifest_hash:
  50. raise ValueError("恢复执行时清单必须保持一致")
  51. if binding is None:
  52. raise ValueError("恢复报告缺少 Redis 持久额度记录")
  53. else:
  54. if binding is not None:
  55. raise ValueError("已登记评测的报告文件缺失,需要从备份恢复")
  56. report = {"manifest_sha256": manifest_hash, "dataset_version": dataset.version,
  57. "generation_mode": "bounded" if args.execute else "read_only",
  58. "reserved_calls": 0, "deadline": (
  59. time() + manifest.total_timeout_seconds if args.execute else 0),
  60. "cases": {key: {**case, "status": "NOT_RUN"} for key, case in cases.items()}}
  61. await state.hset(budget_key, mapping={"output": output_identity, "calls": 0,
  62. "deadline": report["deadline"],
  63. "mode": report["generation_mode"]})
  64. mode = (await state.hget(budget_key, "mode")).decode()
  65. if args.execute and mode == "read_only":
  66. mode = "bounded"
  67. await state.hset(budget_key, mapping={"mode": mode,
  68. "deadline": time() + manifest.total_timeout_seconds})
  69. report["generation_mode"] = mode
  70. report["reserved_calls"] = int(await state.hget(budget_key, "calls"))
  71. report["deadline"] = float(await state.hget(budget_key, "deadline"))
  72. return report
  73. def load_inputs(dataset_path, manifest_path):
  74. content = dataset_path.read_bytes()
  75. dataset = Dataset.model_validate_json(content)
  76. manifest = AgentManifest.model_validate_json(manifest_path.read_bytes())
  77. if hashlib.sha256(content).hexdigest() != manifest.dataset_sha256:
  78. raise ValueError("评测资料摘要不一致")
  79. if source_digest() != manifest.source_sha256:
  80. raise ValueError("评测固定代码版本摘要不一致")
  81. cases = {case["id"]: case for case in dataset.cases()}
  82. for binding in manifest.cases:
  83. if binding.case_id not in cases or cases[binding.case_id]["split"] != manifest.split:
  84. raise ValueError("运行清单引用错误集合或未知评测编号")
  85. binding.review.verify()
  86. binding.environment_evidence.verify()
  87. return dataset, manifest, cases
  88. def category_report(rows):
  89. groups = defaultdict(Counter)
  90. for row in rows.values():
  91. groups[f"{row['split']}/{row['category']}"][row["status"]] += 1
  92. return {key: dict(value) for key, value in groups.items()}
  93. async def observe(sessions, binding, manifest, case):
  94. async with sessions() as session:
  95. run = await session.get(AgentRun, binding.run_id.bytes)
  96. if run is None or run.tenant_id != binding.tenant_id.bytes:
  97. raise ValueError("评测运行不存在或租户不一致")
  98. if run.graph_version != manifest.graph_version or run.model_name != manifest.model_name:
  99. raise ValueError("运行图版本或模型版本不一致")
  100. if not run.release_snapshot or digest(run.release_snapshot) != manifest.release_sha256:
  101. raise ValueError("运行发布参数摘要不一致")
  102. if run.release_snapshot.get("max_output_tokens") != manifest.max_output_tokens:
  103. raise ValueError("发布参数的单次输出上限不一致")
  104. prompts = [message["data"]["content"] for message in run.messages
  105. if message["type"] == "human"]
  106. if not prompts or prompts[0] != case["prompt"]:
  107. raise ValueError("运行的初始问题与评测资料不一致")
  108. attempts = (await session.scalars(select(ModelAttempt).where(
  109. ModelAttempt.run_id == run.id
  110. ).order_by(ModelAttempt.created_at, ModelAttempt.id))).all()
  111. observation = {"run_id": str(binding.run_id), "run_status": run.status,
  112. "error_code": run.error_code, "messages": run.messages,
  113. "waiting": run.waiting,
  114. "attempts": [{"id": str(UUID(bytes=item.id)), "status": item.status,
  115. "usage": item.usage} for item in attempts]}
  116. operations = (await session.scalars(select(ExternalOperation).where(
  117. ExternalOperation.tenant_id == run.tenant_id,
  118. ExternalOperation.ticket_id == run.business_ticket_id,
  119. ).order_by(ExternalOperation.id))).all() if run.business_ticket_id else []
  120. observation["external_operations"] = [{"id": str(UUID(bytes=item.id)),
  121. "status": item.status, "proposal_version": item.proposal_version}
  122. for item in operations]
  123. return observation
  124. async def evaluate(args, dataset, manifest, cases):
  125. manifest_hash = digest(manifest.model_dump(mode="json"))
  126. output = args.output
  127. judgments = {}
  128. if args.judgments:
  129. for value in json.loads(await asyncio.to_thread(args.judgments.read_bytes)):
  130. judgment = Judgment.model_validate(value)
  131. await asyncio.to_thread(judgment.verify)
  132. if judgment.case_id in judgments:
  133. raise ValueError("结果审核编号重复")
  134. judgments[judgment.case_id] = judgment
  135. if not judgments.keys() <= {binding.case_id for binding in manifest.cases}:
  136. raise ValueError("结果审核包含清单之外的评测")
  137. app = create_app()
  138. async with app.router.lifespan_context(app):
  139. lock = app.state.state.lock(
  140. "evaluation:" + manifest_hash, timeout=manifest.total_timeout_seconds + 30,
  141. blocking_timeout=0, thread_local=False
  142. )
  143. if not await lock.acquire():
  144. raise RuntimeError("同一评测清单已有执行进程")
  145. try:
  146. report = await initialize_report(args, dataset, manifest, cases, app.state.state)
  147. if args.execute:
  148. for role in ("agent", "memory"):
  149. async for _ in app.state.state.scan_iter(match=f"monitor:worker:{role}:*"):
  150. raise RuntimeError("受限评测要求停止常驻生成工作进程")
  151. allowance = PersistentAllowance(manifest, report, output, app.state.state)
  152. await asyncio.to_thread(write_report, output, report)
  153. for binding in manifest.cases:
  154. row = report["cases"][binding.case_id]
  155. observation = await observe(app.state.sessions, binding, manifest, row)
  156. # 已有终态和业务等待只读取证据;重新执行只允许当前排队状态。
  157. if args.execute and observation["run_status"] == "QUEUED":
  158. remaining = report["deadline"] - time()
  159. if remaining <= 0:
  160. raise TimeoutError("评测总时间已达到上限")
  161. try:
  162. async with asyncio.timeout(remaining):
  163. await execute_one(
  164. app.state.sessions, app.state.state,
  165. AgentSettings(max_output_tokens=manifest.max_output_tokens),
  166. app.state.knowledge.search, allowance=allowance,
  167. run_ids=[binding.run_id.bytes],
  168. )
  169. finally:
  170. observation = await observe(app.state.sessions, binding, manifest, row)
  171. row.update(observation=observation, status=(
  172. "REVIEW_REQUIRED" if observation["attempts"] else "NOT_RUN"))
  173. row["observation_sha256"] = digest(observation)
  174. await asyncio.to_thread(write_report, output, report)
  175. row.update(observation=observation)
  176. row["observation_sha256"] = digest(observation)
  177. row["status"] = "REVIEW_REQUIRED" if observation["attempts"] else "NOT_RUN"
  178. if binding.case_id in judgments:
  179. judgment = judgments[binding.case_id]
  180. if (judgment.observation_sha256 != row["observation_sha256"]
  181. or not observation["attempts"]
  182. or observation["run_status"] in {"QUEUED", "RUNNING"}):
  183. raise ValueError("结果审核必须对应已执行并暂停或结束的当前运行证据")
  184. row["status"] = judgment.verdict
  185. row["judgment"] = judgment.model_dump(mode="json")
  186. elif row.get("judgment"):
  187. previous = Judgment.model_validate(row["judgment"])
  188. await asyncio.to_thread(previous.verify)
  189. if previous.observation_sha256 == row["observation_sha256"]:
  190. row["status"] = previous.verdict
  191. else:
  192. row.pop("judgment")
  193. report["categories"] = category_report(report["cases"])
  194. report["recorded_attempts"] = sum(len(item.get("observation", {}).get(
  195. "attempts", [])) for item in report["cases"].values())
  196. attempts = [attempt for item in report["cases"].values()
  197. for attempt in item.get("observation", {}).get("attempts", [])]
  198. report["usage"] = {key: sum((item["usage"] or {}).get(key, 0)
  199. for item in attempts)
  200. for key in ("input_tokens", "output_tokens", "total_tokens")}
  201. report["attempts_without_usage"] = sum(item["usage"] is None for item in attempts)
  202. await asyncio.to_thread(write_report, output, report)
  203. finally:
  204. await lock.release()
  205. print(json.dumps({"output": str(output), "reserved_calls": report["reserved_calls"],
  206. "categories": report["categories"]}, ensure_ascii=False))
  207. if __name__ == "__main__":
  208. parser = argparse.ArgumentParser(description="读取或受限执行已审核的 Agent 评测清单")
  209. parser.add_argument("--dataset", type=Path, default=Path("evaluations/agent-scenarios.json"))
  210. parser.add_argument("--manifest", type=Path, required=True)
  211. parser.add_argument("--output", type=Path, required=True)
  212. parser.add_argument("--execute", action="store_true", help="显式允许模型请求")
  213. parser.add_argument("--judgments", type=Path, help="人工结果审核 JSON 列表")
  214. parser.add_argument("--validate-only", action="store_true")
  215. args = parser.parse_args()
  216. dataset, manifest, cases = load_inputs(args.dataset, args.manifest)
  217. if args.validate_only:
  218. print(json.dumps({"valid": True, "cases": len(manifest.cases), "generation_attempts": 0}))
  219. else:
  220. asyncio.run(evaluate(args, dataset, manifest, cases))