From 7dd83acd2c6f24a89919c4cea1e3289c1eddd06a Mon Sep 17 00:00:00 2001 From: SUPR1M3 Date: Sun, 1 Nov 2026 02:59:58 -0500 Subject: [PATCH] fix(llm): vary the request seed per iteration in workers With random_seed set (the default is 42), every LLM request carried the same seed. On providers that honour it (Ollama, vLLM, OpenAI best effort) identical prompts, common early in a run when iterations share a parent, returned identical responses, so those iterations produced duplicate children. In a 10-iteration pilot on Ollama, up to four iterations of one run returned byte-identical programs. After reseed(iteration) (#559), the ensemble now sends a seed derived from the sampled model's seed and the iteration. Runs stay reproducible, an explicit seed kwarg still wins, and ensembles that are never reseeded (the evaluator's LLM feedback) are unchanged. --- openevolve/llm/ensemble.py | 41 +++++++++++++++++++++------- tests/test_ensemble_worker_seed.py | 43 ++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 9 deletions(-) diff --git a/openevolve/llm/ensemble.py b/openevolve/llm/ensemble.py index 6df9dafe1..402afc888 100644 --- a/openevolve/llm/ensemble.py +++ b/openevolve/llm/ensemble.py @@ -3,10 +3,11 @@ """ import asyncio +import hashlib import logging import math import random -from typing import Dict, List, Optional, Tuple +from typing import Any, Dict, List, Optional, Tuple from openevolve.llm.base import LLMInterface from openevolve.llm.openai import OpenAILLM @@ -97,11 +98,14 @@ def __init__(self, models_cfg: List[LLMModelConfig]): logger._ensemble_logged = True self.last_usage: Optional[Dict[str, Any]] = None + # Set by reseed(); gives each iteration's requests their own seed + self._iteration: Optional[int] = None async def generate(self, prompt: str, **kwargs) -> str: """Generate text using a randomly selected model based on weights""" - model = self._sample_model() - res = await model.generate(prompt, **kwargs) + index = self._sample_index() + model = self.models[index] + res = await model.generate(prompt, **self._request_kwargs(index, kwargs)) self.last_usage = getattr(model, "last_usage", None) return res @@ -109,8 +113,11 @@ async def generate_with_context( self, system_message: str, messages: List[Dict[str, str]], **kwargs ) -> str: """Generate text using a system message and conversational context""" - model = self._sample_model() - res = await model.generate_with_context(system_message, messages, **kwargs) + index = self._sample_index() + model = self.models[index] + res = await model.generate_with_context( + system_message, messages, **self._request_kwargs(index, kwargs) + ) self.last_usage = getattr(model, "last_usage", None) return res @@ -120,13 +127,29 @@ def reseed(self, iteration: int) -> None: seed = getattr(self.models_cfg[0], "random_seed", None) if self.models_cfg else None if seed is not None: self.random_state.seed(f"{seed}:{iteration}") + self._iteration = iteration + + def _request_kwargs(self, index: int, kwargs: Dict[str, Any]) -> Dict[str, Any]: + """After reseed(iteration), send a seed derived from the model's seed and the + iteration. A fixed seed would make every identical prompt (common early on, when + iterations share a parent) return the identical response on providers that honour it""" + if self._iteration is None or "seed" in kwargs: + return kwargs + seed = getattr(self.models_cfg[index], "random_seed", None) + if seed is None: + return kwargs + digest = hashlib.sha256(f"{seed}:{self._iteration}:request".encode()).digest() + return {**kwargs, "seed": int.from_bytes(digest[:4], "big") & 0x7FFFFFFF} + + def _sample_index(self) -> int: + """Index of a model sampled from the ensemble based on weights""" + index = self.random_state.choices(range(len(self.models)), weights=self.weights, k=1)[0] + logger.info(f"Sampled model: {self.models_cfg[index].name}") + return index def _sample_model(self) -> LLMInterface: """Sample a model from the ensemble based on weights""" - index = self.random_state.choices(range(len(self.models)), weights=self.weights, k=1)[0] - sampled_model = self.models[index] - logger.info(f"Sampled model: {self.models_cfg[index].name}") - return sampled_model + return self.models[self._sample_index()] async def generate_multiple(self, prompt: str, n: int, **kwargs) -> List[str]: """Generate multiple texts in parallel""" diff --git a/tests/test_ensemble_worker_seed.py b/tests/test_ensemble_worker_seed.py index d5a11b428..608543f22 100644 --- a/tests/test_ensemble_worker_seed.py +++ b/tests/test_ensemble_worker_seed.py @@ -119,6 +119,49 @@ def test_same_iteration_picks_same_model_on_any_worker(self): self.assertEqual(picked_forward, picked_backward) self.assertEqual(picked_late[5], picked_forward[5]) + def _request_seeds(self, config_dict, iterations): + """The seed each iteration's LLM request carries, run in a fresh worker""" + process_parallel_module._worker_init(config_dict, "evaluator.py", None) + process_parallel_module._worker_prompt_sampler = MagicMock() + process_parallel_module._worker_prompt_sampler.build_prompt.return_value = { + "system": "sys", + "user": "usr", + } + process_parallel_module._worker_evaluator = MagicMock() + seeds = [] + + async def record_seed(llm, system_message, messages, **kwargs): + seeds.append(kwargs.get("seed")) + return None + + with patch.object(OpenAILLM, "generate_with_context", record_seed): + for iteration in iterations: + process_parallel_module._run_iteration_worker( + iteration, self.snapshot, "parent", [] + ) + return seeds + + def test_identical_prompts_get_a_different_seed_each_iteration(self): + # Same prompt every iteration: with one fixed request seed, a provider that honours + # seeds returns the same response every time + seeds = self._request_seeds(self._config_dict(seed=123), range(ITERATIONS)) + + self.assertEqual(len(set(seeds)), ITERATIONS) + self.assertTrue(all(isinstance(s, int) and 0 <= s < 2**31 for s in seeds)) + + def test_request_seed_depends_on_iteration_only(self): + config_dict = self._config_dict(seed=123) + forward = self._request_seeds(config_dict, [3, 4, 5]) + alone = self._request_seeds(config_dict, [5]) + + self.assertEqual(alone, forward[-1:]) + self.assertNotEqual( + self._request_seeds(self._config_dict(seed=124), [5]), alone, "seed is used" + ) + + def test_unseeded_requests_carry_no_seed(self): + self.assertEqual(self._request_seeds(self._config_dict(seed=None), [1, 2]), [None, None]) + def test_unseeded_selection_stays_random(self): config_dict = self._config_dict(seed=None) picks = self._worker(config_dict, [7] * 64)