"""GEPA - Genetic-Pareto Prompt Evolution 基于遗传算法的 Prompt 进化框架,支持: - 种群管理(Population) - 交叉算子(Crossover) - 变异算子(Mutation) - Pareto 多目标选择 - 精英保留(Elitism) - 代际进化 参考:GEPA: Reflective Prompt Evolution Can Outperform Reinforcement Learning (2025) """ from __future__ import annotations import copy import logging import random import uuid from dataclasses import dataclass, field from typing import Any from agentkit.evolution.prompt_optimizer import Module, Signature logger = logging.getLogger(__name__) @dataclass class FitnessScore: """多目标适应度评分""" accuracy: float = 0.0 # 0-1, 任务成功率 latency_ms: float = 0.0 # 越低越好 cost_tokens: float = 0.0 # 越低越好 custom: float = 0.0 # 自定义指标 @property def normalized(self) -> dict[str, float]: """归一化到 [0, 1],latency 和 cost 越低越好所以取反""" return { "accuracy": self.accuracy, "latency": 1.0 - min(self.latency_ms / 10000.0, 1.0), # 10s 为上限 "cost": 1.0 - min(self.cost_tokens / 10000.0, 1.0), # 10k tokens 为上限 "custom": self.custom, } def dominates(self, other: FitnessScore) -> bool: """Pareto 支配判断:self 在所有维度 >= other 且至少一个维度 > other""" n_self = self.normalized n_other = other.normalized all_geq = all(v >= n_other[k] for k, v in n_self.items()) any_gt = any(v > n_other[k] for k, v in n_self.items()) return all_geq and any_gt @dataclass class PromptChromosome: """Prompt 染色体 — 一个完整的 Prompt 变体 由三段可独立进化的基因组成: - instructions: 指令段 - demos: few-shot 示例 - constraints: 约束条件 """ id: str = field(default_factory=lambda: str(uuid.uuid4())[:8]) instructions: str = "" demos: list[dict[str, Any]] = field(default_factory=list) constraints: list[str] = field(default_factory=list) fitness: FitnessScore = field(default_factory=FitnessScore) generation: int = 0 parent_ids: list[str] = field(default_factory=list) def to_module(self, name: str = "") -> Module: """转换为 Module 格式""" return Module( name=name or f"chromosome_{self.id}", signature=Signature( input_fields={}, output_fields={}, instruction=self.instructions, ), demos=self.demos, ) @classmethod def from_module(cls, module: Module) -> PromptChromosome: """从 Module 创建染色体""" # Extract constraints from instruction (lines starting with -) constraints = [] instructions_lines = [] if module.signature.instruction: for line in module.signature.instruction.split("\n"): stripped = line.strip() if stripped.startswith("- ") and any( kw in stripped.lower() for kw in ["must", "should", "never", "avoid", "do not", "always"] ): constraints.append(stripped[2:]) else: instructions_lines.append(line) return cls( instructions="\n".join(instructions_lines), demos=list(module.demos), constraints=constraints, ) class CrossoverOperator: """交叉算子 从两个父代 Prompt 生成子代,支持: - instructions 交叉:交换指令段落 - demos 交叉:交换 few-shot 示例 - constraints 交叉:交换约束条件 """ def crossover( self, parent_a: PromptChromosome, parent_b: PromptChromosome, crossover_rate: float = 0.5, ) -> PromptChromosome: """执行交叉操作 Args: parent_a: 父代 A parent_b: 父代 B crossover_rate: 每个基因段的交叉概率 Returns: 子代染色体 """ child_instructions = self._crossover_text( parent_a.instructions, parent_b.instructions, crossover_rate ) child_demos = self._crossover_demos( parent_a.demos, parent_b.demos, crossover_rate ) child_constraints = self._crossover_constraints( parent_a.constraints, parent_b.constraints, crossover_rate ) return PromptChromosome( instructions=child_instructions, demos=child_demos, constraints=child_constraints, generation=max(parent_a.generation, parent_b.generation) + 1, parent_ids=[parent_a.id, parent_b.id], ) def _crossover_text( self, text_a: str, text_b: str, rate: float ) -> str: """文本段落交叉:按段落交换""" if not text_a or not text_b: return text_a if random.random() < 0.5 else text_b paragraphs_a = [p.strip() for p in text_a.split("\n\n") if p.strip()] paragraphs_b = [p.strip() for p in text_b.split("\n\n") if p.strip()] if not paragraphs_a or not paragraphs_b: return text_a if random.random() < 0.5 else text_b # Interleave paragraphs from both parents result = [] max_len = max(len(paragraphs_a), len(paragraphs_b)) for i in range(max_len): if random.random() < rate: # Take from B if i < len(paragraphs_b): result.append(paragraphs_b[i]) elif i < len(paragraphs_a): result.append(paragraphs_a[i]) else: # Take from A if i < len(paragraphs_a): result.append(paragraphs_a[i]) elif i < len(paragraphs_b): result.append(paragraphs_b[i]) return "\n\n".join(result) def _crossover_demos( self, demos_a: list[dict], demos_b: list[dict], rate: float, ) -> list[dict]: """Demo 交叉:混合两个父代的示例""" if not demos_a: return list(demos_b) if random.random() < 0.5 else [] if not demos_b: return list(demos_a) if random.random() < 0.5 else [] # Take some from each parent result = [] used_inputs: set[str] = set() for demo in demos_a + demos_b: demo_key = str(demo.get("input", ""))[:50] if demo_key not in used_inputs and random.random() < (1 - rate): result.append(copy.deepcopy(demo)) used_inputs.add(demo_key) return result[:5] # Limit to 5 demos def _crossover_constraints( self, constraints_a: list[str], constraints_b: list[str], rate: float, ) -> list[str]: """约束交叉:合并两个父代的约束""" all_constraints = set(constraints_a) | set(constraints_b) result = [] for c in all_constraints: if random.random() < (1 - rate * 0.5): result.append(c) return result class MutationOperator: """变异算子 基于 LLM 反思的结构化变异: - 指令变异:LLM 重写指令段落 - Demo 变异:替换/重排 few-shot 示例 - 约束变异:增删约束条件 """ def __init__(self, llm_gateway: Any = None): self._llm_gateway = llm_gateway async def mutate( self, chromosome: PromptChromosome, mutation_rate: float = 0.3, ) -> PromptChromosome: """执行变异操作 Args: chromosome: 待变异的染色体 mutation_rate: 变异概率 Returns: 变异后的新染色体 """ new_instructions = chromosome.instructions new_demos = list(chromosome.demos) new_constraints = list(chromosome.constraints) # Instructions mutation if random.random() < mutation_rate: new_instructions = await self._mutate_instructions( chromosome.instructions ) # Demo mutation if random.random() < mutation_rate and new_demos: new_demos = self._mutate_demos(new_demos) # Constraint mutation if random.random() < mutation_rate: new_constraints = self._mutate_constraints(new_constraints) return PromptChromosome( instructions=new_instructions, demos=new_demos, constraints=new_constraints, generation=chromosome.generation, parent_ids=[chromosome.id], ) async def _mutate_instructions(self, instructions: str) -> str: """指令变异""" if self._llm_gateway: try: response = await self._llm_gateway.chat( messages=[ { "role": "system", "content": ( "You are a prompt mutation assistant. Slightly modify the " "given instruction to improve clarity and effectiveness. " "Keep the core intent unchanged. Output ONLY the modified instruction." ), }, {"role": "user", "content": instructions}, ], model="default", ) return response.content.strip() or instructions except Exception as e: logger.warning(f"LLM instruction mutation failed: {e}") # Fallback: simple text mutation (shuffle paragraphs) paragraphs = [p.strip() for p in instructions.split("\n\n") if p.strip()] if len(paragraphs) > 1: random.shuffle(paragraphs) return "\n\n".join(paragraphs) def _mutate_demos(self, demos: list[dict]) -> list[dict]: """Demo 变异:重排或随机删除一个""" mutated = list(demos) if random.random() < 0.5 and len(mutated) > 1: # Shuffle random.shuffle(mutated) elif len(mutated) > 2: # Remove a random demo idx = random.randint(0, len(mutated) - 1) mutated.pop(idx) return mutated def _mutate_constraints(self, constraints: list[str]) -> list[str]: """约束变异:随机增删约束""" mutated = list(constraints) if random.random() < 0.5 and mutated: # Remove a random constraint idx = random.randint(0, len(mutated) - 1) mutated.pop(idx) else: # Add a generic constraint generic_constraints = [ "Always verify the output before responding", "Keep responses concise and focused", "Prioritize accuracy over completeness", "Consider edge cases in your analysis", ] new_constraint = random.choice(generic_constraints) if new_constraint not in mutated: mutated.append(new_constraint) return mutated class GEPAPopulation: """GEPA 种群管理 维护一组 PromptChromosome,支持: - 初始化(从种子 Prompt 或随机生成) - 添加/淘汰个体 - Pareto 前沿维护 - 精英保留 - 代际进化 """ def __init__( self, population_size: int = 10, elite_size: int = 2, tournament_size: int = 3, ): self._population_size = population_size self._elite_size = min(elite_size, population_size) self._tournament_size = tournament_size self._individuals: list[PromptChromosome] = [] self._generation = 0 @property def generation(self) -> int: return self._generation @property def individuals(self) -> list[PromptChromosome]: return list(self._individuals) @property def size(self) -> int: return len(self._individuals) def initialize(self, seed: PromptChromosome | None = None) -> None: """初始化种群 Args: seed: 种子染色体,所有个体基于种子变异生成 """ if seed is None: seed = PromptChromosome(instructions="You are a helpful assistant.") self._individuals = [seed] # Generate variants from seed for i in range(self._population_size - 1): variant = PromptChromosome( id=str(uuid.uuid4())[:8], instructions=seed.instructions, demos=list(seed.demos), constraints=list(seed.constraints), generation=0, ) self._individuals.append(variant) self._generation = 0 def add(self, chromosome: PromptChromosome) -> None: """添加个体到种群""" self._individuals.append(chromosome) def get_elite(self) -> list[PromptChromosome]: """获取精英个体(适应度最高的 top-k)""" sorted_individuals = sorted( self._individuals, key=lambda c: c.fitness.accuracy, reverse=True, ) return sorted_individuals[: self._elite_size] def get_pareto_front(self) -> list[PromptChromosome]: """获取 Pareto 前沿(不被任何其他个体支配的个体)""" front: list[PromptChromosome] = [] for individual in self._individuals: dominated = False for other in self._individuals: if other.id != individual.id and other.fitness.dominates(individual.fitness): dominated = True break if not dominated: front.append(individual) return front def tournament_select(self) -> PromptChromosome: """锦标赛选择:随机选 k 个个体,返回适应度最高的""" if not self._individuals: raise ValueError("Population is empty") candidates = random.sample( self._individuals, min(self._tournament_size, len(self._individuals)), ) return max(candidates, key=lambda c: c.fitness.accuracy) def evolve( self, crossover: CrossoverOperator, mutation: MutationOperator, crossover_rate: float = 0.7, mutation_rate: float = 0.3, ) -> list[PromptChromosome]: """执行一代进化 1. 保留精英 2. 锦标赛选择父代 3. 交叉生成子代 4. 变异子代 5. 替换种群(保留精英 + 新子代) Returns: 新一代个体列表 """ import asyncio self._generation += 1 # 1. Preserve elite elite = self.get_elite() new_generation = list(elite) # 2-4. Generate offspring offspring_tasks = [] while len(new_generation) + len(offspring_tasks) < self._population_size: parent_a = self.tournament_select() parent_b = self.tournament_select() if random.random() < crossover_rate: child = crossover.crossover(parent_a, parent_b) else: child = copy.deepcopy(parent_a) offspring_tasks.append((child, mutation_rate)) # Execute mutations (sync for simplicity, async for LLM mutations) for child, m_rate in offspring_tasks: try: # Try async mutation loop = asyncio.get_event_loop() if loop.is_running(): # We're in an async context — use sync fallback mutated = PromptChromosome( instructions=child.instructions, demos=child.demos, constraints=child.constraints, generation=self._generation, parent_ids=child.parent_ids, ) else: mutated = loop.run_until_complete(mutation.mutate(child, m_rate)) except RuntimeError: mutated = PromptChromosome( instructions=child.instructions, demos=child.demos, constraints=child.constraints, generation=self._generation, parent_ids=child.parent_ids, ) new_generation.append(mutated) # 5. Replace population self._individuals = new_generation[: self._population_size] logger.info( f"Generation {self._generation}: " f"population={len(self._individuals)}, " f"elite={len(elite)}, " f"best_accuracy={max(c.fitness.accuracy for c in self._individuals):.2f}" ) return list(self._individuals) def get_best(self) -> PromptChromosome: """获取适应度最高的个体""" if not self._individuals: raise ValueError("Population is empty") return max(self._individuals, key=lambda c: c.fitness.accuracy) def get_statistics(self) -> dict[str, Any]: """获取种群统计信息""" if not self._individuals: return {"generation": self._generation, "size": 0} accuracies = [c.fitness.accuracy for c in self._individuals] return { "generation": self._generation, "size": len(self._individuals), "best_accuracy": max(accuracies), "avg_accuracy": sum(accuracies) / len(accuracies), "worst_accuracy": min(accuracies), "pareto_front_size": len(self.get_pareto_front()), }