Coverage for agentos/experiments/runner.py: 42%

143 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-07-08 01:44 +0800

1""" 

2AgentOS v0.40 Experiments — A/B测试与Prompt实验框架。 

3支持:Prompt变体对比、A/B/n测试、结果统计显著性分析、实验报告生成。 

4""" 

5 

6from __future__ import annotations 

7 

8import time 

9import uuid 

10from dataclasses import dataclass, field 

11 

12 

13@dataclass 

14class PromptVariant: 

15 """Prompt变体。""" 

16 

17 name: str 

18 system_prompt: str 

19 user_template: str = "" 

20 model: str = "auto" 

21 temperature: float = 0.7 

22 max_tokens: int = 2048 

23 metadata: dict = field(default_factory=dict) 

24 

25 

26@dataclass 

27class TrialResult: 

28 """单次试验结果。""" 

29 

30 variant_name: str 

31 input: str 

32 output: str 

33 latency_ms: float = 0 

34 tokens_used: int = 0 

35 cost: float = 0.0 

36 error: str = "" 

37 score: float = 0.0 # evaluator评分 

38 judged_by: str = "" 

39 

40 

41@dataclass 

42class ExperimentConfig: 

43 """实验配置。""" 

44 

45 name: str 

46 variants: list[PromptVariant] 

47 test_inputs: list[str] 

48 evaluator: str = "auto" # auto | llm_judge | human | custom 

49 trials_per_variant: int = 3 

50 shuffle: bool = True 

51 metric: str = "accuracy" # accuracy | relevance | creativity | custom 

52 

53 

54@dataclass 

55class ExperimentReport: 

56 """实验报告。""" 

57 

58 id: str 

59 config: ExperimentConfig 

60 results: list[TrialResult] 

61 winner: str = "" 

62 significance: float = 0.0 

63 summary: dict = field(default_factory=dict) 

64 created_at: float = field(default_factory=time.time) 

65 

66 

67class Evaluator: 

68 """评估器 — 自动评分模型输出。""" 

69 

70 @staticmethod 

71 def llm_judge(output: str, expected: str, criteria: str = "accuracy") -> float: 

72 """使用LLM评判输出质量(占位符,实际调用模型)。""" 

73 # 生产环境会调用router进行评判 

74 # 当前返回启发式分 

75 if not expected: 

76 return 0.5 

77 

78 output_lower = output.lower() 

79 expected_lower = expected.lower() 

80 

81 # 简单重叠度 

82 out_words = set(output_lower.split()) 

83 exp_words = set(expected_lower.split()) 

84 if not exp_words: 

85 return 0.5 

86 overlap = len(out_words & exp_words) / len(exp_words) 

87 

88 # 长度惩罚 

89 length_ratio = min(len(output_lower), len(expected_lower)) / max( 

90 len(output_lower), len(expected_lower), 1 

91 ) 

92 

93 return overlap * 0.7 + length_ratio * 0.3 

94 

95 @staticmethod 

96 def exact_match(output: str, expected: str) -> float: 

97 return 1.0 if output.strip() == expected.strip() else 0.0 

98 

99 @staticmethod 

100 def contains_all(output: str, keywords: list[str]) -> float: 

101 output_lower = output.lower() 

102 matches = sum(1 for kw in keywords if kw.lower() in output_lower) 

103 return matches / len(keywords) if keywords else 0.5 

104 

105 

106class ExperimentRunner: 

107 """实验执行器。""" 

108 

109 def __init__(self, router=None, cache=None): 

110 self.router = router 

111 self.cache = cache or None 

112 self._reports: dict[str, ExperimentReport] = {} 

113 

114 async def run(self, config: ExperimentConfig) -> ExperimentReport: 

115 """执行A/B实验。""" 

116 import random 

117 

118 all_results: list[TrialResult] = [] 

119 

120 # 构建所有 (variant, input) 组合 

121 trials = [] 

122 for variant in config.variants: 

123 for inp in config.test_inputs: 

124 for _ in range(config.trials_per_variant): 

125 trials.append((variant, inp)) 

126 

127 if config.shuffle: 

128 random.shuffle(trials) 

129 

130 for variant, inp in trials: 

131 start = time.time() 

132 try: 

133 if self.router: 

134 messages = [ 

135 {"role": "system", "content": variant.system_prompt}, 

136 { 

137 "role": "user", 

138 "content": ( 

139 variant.user_template.format(input=inp) 

140 if variant.user_template 

141 else inp 

142 ), 

143 }, 

144 ] 

145 output = await self.router.call_chat(messages) 

146 else: 

147 output = f"[模拟输出] 变体 '{variant.name}' 对输入 '{inp[:30]}...' 的响应" 

148 

149 latency = (time.time() - start) * 1000 

150 score = Evaluator.llm_judge(output, inp) # 可自定义evaluator 

151 

152 all_results.append( 

153 TrialResult( 

154 variant_name=variant.name, 

155 input=inp, 

156 output=output, 

157 latency_ms=latency, 

158 score=score, 

159 judged_by="auto", 

160 ) 

161 ) 

162 except Exception as e: 

163 all_results.append( 

164 TrialResult( 

165 variant_name=variant.name, 

166 input=inp, 

167 output="", 

168 error=str(e), 

169 score=0.0, 

170 ) 

171 ) 

172 

173 # 汇总分析 

174 summary = self._analyze(all_results, config) 

175 winner = self._determine_winner(summary) 

176 

177 report = ExperimentReport( 

178 id=f"exp_{uuid.uuid4().hex[:8]}", 

179 config=config, 

180 results=all_results, 

181 winner=winner, 

182 summary=summary, 

183 ) 

184 self._reports[report.id] = report 

185 return report 

186 

187 def _analyze(self, results: list[TrialResult], config: ExperimentConfig) -> dict: 

188 """统计分析。""" 

189 variant_stats = {} 

190 for r in results: 

191 if r.variant_name not in variant_stats: 

192 variant_stats[r.variant_name] = { 

193 "scores": [], 

194 "latencies": [], 

195 "errors": 0, 

196 "trials": 0, 

197 } 

198 vs = variant_stats[r.variant_name] 

199 if r.error: 

200 vs["errors"] += 1 

201 else: 

202 vs["scores"].append(r.score) 

203 vs["latencies"].append(r.latency_ms) 

204 vs["trials"] += 1 

205 

206 summary = {} 

207 for name, vs in variant_stats.items(): 

208 scores = vs["scores"] 

209 latencies = vs["latencies"] 

210 summary[name] = { 

211 "avg_score": sum(scores) / len(scores) if scores else 0, 

212 "max_score": max(scores) if scores else 0, 

213 "min_score": min(scores) if scores else 0, 

214 "std_score": self._std(scores) if scores else 0, 

215 "avg_latency_ms": sum(latencies) / len(latencies) if latencies else 0, 

216 "error_rate": vs["errors"] / vs["trials"] if vs["trials"] else 0, 

217 "trials": vs["trials"], 

218 } 

219 return summary 

220 

221 @staticmethod 

222 def _determine_winner(summary: dict) -> str: 

223 best_name = "" 

224 best_score = -1.0 

225 for name, stats in summary.items(): 

226 penalty = stats["error_rate"] * 0.5 

227 adjusted = stats["avg_score"] * (1 - penalty) 

228 if adjusted > best_score: 

229 best_score = adjusted 

230 best_name = name 

231 return best_name 

232 

233 @staticmethod 

234 def _std(values: list[float]) -> float: 

235 if len(values) < 2: 

236 return 0.0 

237 mean = sum(values) / len(values) 

238 return (sum((v - mean) ** 2 for v in values) / len(values)) ** 0.5 

239 

240 def get_report(self, report_id: str) -> ExperimentReport | None: 

241 return self._reports.get(report_id) 

242 

243 def list_reports(self) -> list[dict]: 

244 return [ 

245 { 

246 "id": rid, 

247 "name": r.config.name, 

248 "winner": r.winner, 

249 "variants": len(r.config.variants), 

250 } 

251 for rid, r in self._reports.items() 

252 ] 

253 

254 def generate_markdown_report(self, report: ExperimentReport) -> str: 

255 """生成Markdown格式实验报告。""" 

256 lines = [ 

257 f"# 实验报告: {report.config.name}", 

258 f"**实验ID**: {report.id}", 

259 f"**变体数**: {len(report.config.variants)}", 

260 f"**测试输入数**: {len(report.config.test_inputs)}", 

261 f"**每变体试验次数**: {report.config.trials_per_variant}", 

262 f"**胜出变体**: **{report.winner}**", 

263 "", 

264 "## 统计摘要", 

265 "", 

266 "| 变体 | 平均分 | 最高分 | 最低分 | 标准差 | 平均延迟(ms) | 错误率 | 试验数 |", 

267 "|------|--------|--------|--------|--------|-------------|--------|--------|", 

268 ] 

269 for name, stats in report.summary.items(): 

270 marker = " **← 胜出**" if name == report.winner else "" 

271 lines.append( 

272 f"| {name}{marker} | {stats['avg_score']:.3f} | {stats['max_score']:.3f} | " 

273 f"{stats['min_score']:.3f} | {stats['std_score']:.3f} | {stats['avg_latency_ms']:.0f} | " 

274 f"{stats['error_rate']:.1%} | {stats['trials']} |" 

275 ) 

276 

277 lines += ["", "## 变体配置", ""] 

278 for v in report.config.variants: 

279 lines += [ 

280 f"### {v.name}", 

281 f"- 模型: {v.model}", 

282 f"- 温度: {v.temperature}", 

283 f"- Max Tokens: {v.max_tokens}", 

284 f"```\n{v.system_prompt[:200]}...\n```", 

285 "", 

286 ] 

287 

288 return "\n".join(lines)