fix: back off evaluation rate limits
This commit is contained in:
+128
-28
@@ -22,6 +22,7 @@ from src.agent_runtime import LocalCodingAgent
|
|||||||
from src.agent_tools import default_tool_registry
|
from src.agent_tools import default_tool_registry
|
||||||
from src.agent_types import (
|
from src.agent_types import (
|
||||||
AgentPermissions,
|
AgentPermissions,
|
||||||
|
AgentRunResult,
|
||||||
AgentRuntimeConfig,
|
AgentRuntimeConfig,
|
||||||
ModelConfig,
|
ModelConfig,
|
||||||
OutputSchemaConfig,
|
OutputSchemaConfig,
|
||||||
@@ -36,6 +37,7 @@ DEFAULT_CONCURRENCY = 8
|
|||||||
MAX_CONCURRENCY = 32
|
MAX_CONCURRENCY = 32
|
||||||
MAX_DATASET_ROWS = 20_000
|
MAX_DATASET_ROWS = 20_000
|
||||||
MAX_LABEL_MAPPING_VALUES = 200
|
MAX_LABEL_MAPPING_VALUES = 200
|
||||||
|
TRANSIENT_MODEL_RETRY_DELAYS_SECONDS = (5.0, 15.0, 30.0, 60.0)
|
||||||
VISIBLE_SKILL_FILE_SUFFIXES = {'.json', '.md', '.py', '.txt', '.yaml', '.yml'}
|
VISIBLE_SKILL_FILE_SUFFIXES = {'.json', '.md', '.py', '.txt', '.yaml', '.yml'}
|
||||||
EDITABLE_SKILL_FILE_SUFFIXES = {'.json', '.md'}
|
EDITABLE_SKILL_FILE_SUFFIXES = {'.json', '.md'}
|
||||||
MAX_SKILL_FILE_BYTES = 512 * 1024
|
MAX_SKILL_FILE_BYTES = 512 * 1024
|
||||||
@@ -349,6 +351,8 @@ class EvaluationRuntime:
|
|||||||
thread_name_prefix='evaluation-case',
|
thread_name_prefix='evaluation-case',
|
||||||
)
|
)
|
||||||
self._lock = threading.RLock()
|
self._lock = threading.RLock()
|
||||||
|
self._model_cooldown_lock = threading.RLock()
|
||||||
|
self._model_cooldown_until = 0.0
|
||||||
self._controls: dict[str, ExperimentControl] = {}
|
self._controls: dict[str, ExperimentControl] = {}
|
||||||
self._scheduler_futures: dict[str, Future[None]] = {}
|
self._scheduler_futures: dict[str, Future[None]] = {}
|
||||||
|
|
||||||
@@ -565,11 +569,16 @@ class EvaluationRuntime:
|
|||||||
snapshot_root=snapshot_root,
|
snapshot_root=snapshot_root,
|
||||||
raw_labels=raw_labels,
|
raw_labels=raw_labels,
|
||||||
)
|
)
|
||||||
run_result = agent.run(
|
run_result = self._run_agent_with_transient_backoff(
|
||||||
|
agent,
|
||||||
prompt,
|
prompt,
|
||||||
session_id=f'{run_id}_{case_row_id}',
|
session_id=f'{run_id}_{case_row_id}',
|
||||||
runtime_context=runtime_context,
|
runtime_context=runtime_context,
|
||||||
|
cancel_event=cancel_event,
|
||||||
)
|
)
|
||||||
|
transient_error = transient_model_error_message(run_result.final_output)
|
||||||
|
if transient_error:
|
||||||
|
raise EvaluationError(transient_error)
|
||||||
result = normalize_label_mapping_output(
|
result = normalize_label_mapping_output(
|
||||||
run_result.final_output,
|
run_result.final_output,
|
||||||
raw_labels=raw_labels,
|
raw_labels=raw_labels,
|
||||||
@@ -1607,6 +1616,75 @@ class EvaluationRuntime:
|
|||||||
with self._lock:
|
with self._lock:
|
||||||
self._controls.pop(experiment_id, None)
|
self._controls.pop(experiment_id, None)
|
||||||
|
|
||||||
|
def _run_agent_with_transient_backoff(
|
||||||
|
self,
|
||||||
|
agent: LocalCodingAgent,
|
||||||
|
prompt: str,
|
||||||
|
*,
|
||||||
|
session_id: str,
|
||||||
|
runtime_context: str,
|
||||||
|
cancel_event: threading.Event,
|
||||||
|
) -> AgentRunResult:
|
||||||
|
last_result: AgentRunResult | None = None
|
||||||
|
attempts = len(TRANSIENT_MODEL_RETRY_DELAYS_SECONDS) + 1
|
||||||
|
for attempt in range(attempts):
|
||||||
|
self._wait_for_model_cooldown(cancel_event)
|
||||||
|
try:
|
||||||
|
result = agent.run(
|
||||||
|
prompt,
|
||||||
|
session_id=session_id,
|
||||||
|
runtime_context=runtime_context,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
transient_error = transient_model_error_message(str(exc))
|
||||||
|
if not transient_error or attempt >= attempts - 1:
|
||||||
|
if transient_error:
|
||||||
|
raise EvaluationError(transient_error) from exc
|
||||||
|
raise
|
||||||
|
self._extend_model_cooldown(
|
||||||
|
TRANSIENT_MODEL_RETRY_DELAYS_SECONDS[attempt],
|
||||||
|
session_id=session_id,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
last_result = result
|
||||||
|
if not transient_model_error_message(result.final_output):
|
||||||
|
return result
|
||||||
|
if attempt >= attempts - 1:
|
||||||
|
return result
|
||||||
|
self._extend_model_cooldown(
|
||||||
|
TRANSIENT_MODEL_RETRY_DELAYS_SECONDS[attempt],
|
||||||
|
session_id=session_id,
|
||||||
|
)
|
||||||
|
assert last_result is not None
|
||||||
|
return last_result
|
||||||
|
|
||||||
|
def _wait_for_model_cooldown(
|
||||||
|
self,
|
||||||
|
cancel_event: threading.Event,
|
||||||
|
) -> None:
|
||||||
|
while True:
|
||||||
|
if cancel_event.is_set():
|
||||||
|
raise EvaluationError('评测任务已取消')
|
||||||
|
with self._model_cooldown_lock:
|
||||||
|
remaining = self._model_cooldown_until - time.monotonic()
|
||||||
|
if remaining <= 0:
|
||||||
|
return
|
||||||
|
cancel_event.wait(min(remaining, 1.0))
|
||||||
|
|
||||||
|
def _extend_model_cooldown(
|
||||||
|
self,
|
||||||
|
delay_seconds: float,
|
||||||
|
*,
|
||||||
|
session_id: str,
|
||||||
|
) -> None:
|
||||||
|
jitter = (sum(session_id.encode('utf-8')) % 3000) / 1000
|
||||||
|
cooldown_until = time.monotonic() + delay_seconds + jitter
|
||||||
|
with self._model_cooldown_lock:
|
||||||
|
self._model_cooldown_until = max(
|
||||||
|
self._model_cooldown_until,
|
||||||
|
cooldown_until,
|
||||||
|
)
|
||||||
|
|
||||||
def _execute_case_analysis(
|
def _execute_case_analysis(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@@ -1642,11 +1720,18 @@ class EvaluationRuntime:
|
|||||||
canonical_case=canonical,
|
canonical_case=canonical,
|
||||||
)
|
)
|
||||||
evaluation_session_id = f'eval_{run_id}_{case_row_id}'
|
evaluation_session_id = f'eval_{run_id}_{case_row_id}'
|
||||||
run_result = agent.run(
|
run_result = self._run_agent_with_transient_backoff(
|
||||||
|
agent,
|
||||||
prompt,
|
prompt,
|
||||||
session_id=evaluation_session_id,
|
session_id=evaluation_session_id,
|
||||||
runtime_context=runtime_context,
|
runtime_context=runtime_context,
|
||||||
|
cancel_event=cancel_event,
|
||||||
)
|
)
|
||||||
|
transient_error = transient_model_error_message(
|
||||||
|
run_result.final_output
|
||||||
|
)
|
||||||
|
if transient_error:
|
||||||
|
raise EvaluationError(transient_error)
|
||||||
accessed_refs = extract_accessed_refs(
|
accessed_refs = extract_accessed_refs(
|
||||||
run_result.transcript,
|
run_result.transcript,
|
||||||
snapshot_root=snapshot_skill_dir,
|
snapshot_root=snapshot_skill_dir,
|
||||||
@@ -1666,7 +1751,8 @@ class EvaluationRuntime:
|
|||||||
f"prediction `{normalized['prediction']}` 不在 Skill 标签目录中"
|
f"prediction `{normalized['prediction']}` 不在 Skill 标签目录中"
|
||||||
)
|
)
|
||||||
if retry_reason:
|
if retry_reason:
|
||||||
run_result = agent.run(
|
run_result = self._run_agent_with_transient_backoff(
|
||||||
|
agent,
|
||||||
build_evidence_retry_prompt(
|
build_evidence_retry_prompt(
|
||||||
skill.name,
|
skill.name,
|
||||||
retry_reason,
|
retry_reason,
|
||||||
@@ -1674,7 +1760,13 @@ class EvaluationRuntime:
|
|||||||
),
|
),
|
||||||
session_id=evaluation_session_id,
|
session_id=evaluation_session_id,
|
||||||
runtime_context=runtime_context,
|
runtime_context=runtime_context,
|
||||||
|
cancel_event=cancel_event,
|
||||||
)
|
)
|
||||||
|
transient_error = transient_model_error_message(
|
||||||
|
run_result.final_output
|
||||||
|
)
|
||||||
|
if transient_error:
|
||||||
|
raise EvaluationError(transient_error)
|
||||||
accessed_refs = extract_accessed_refs(
|
accessed_refs = extract_accessed_refs(
|
||||||
run_result.transcript,
|
run_result.transcript,
|
||||||
snapshot_root=snapshot_skill_dir,
|
snapshot_root=snapshot_skill_dir,
|
||||||
@@ -2812,6 +2904,23 @@ def try_normalize_evaluation_output(raw_output: str) -> dict[str, Any] | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def transient_model_error_message(value: Any) -> str:
|
||||||
|
text = str(value or '')
|
||||||
|
normalized = text.lower()
|
||||||
|
if 'http 429' in normalized or 'too many requests' in normalized:
|
||||||
|
return (
|
||||||
|
'模型服务持续限流(HTTP 429 Too many requests),'
|
||||||
|
'已自动退避重试,请稍后重试失败项'
|
||||||
|
)
|
||||||
|
for status in ('http 502', 'http 503', 'http 504'):
|
||||||
|
if status in normalized:
|
||||||
|
return (
|
||||||
|
f'模型服务暂时不可用({status.upper()}),'
|
||||||
|
'已自动退避重试,请稍后重试失败项'
|
||||||
|
)
|
||||||
|
return ''
|
||||||
|
|
||||||
|
|
||||||
def prediction_is_allowed(
|
def prediction_is_allowed(
|
||||||
skill_name: str,
|
skill_name: str,
|
||||||
prediction: str,
|
prediction: str,
|
||||||
@@ -2880,22 +2989,10 @@ def normalize_label_master_result(
|
|||||||
|
|
||||||
|
|
||||||
def normalize_evaluation_output(raw_output: str) -> dict[str, Any]:
|
def normalize_evaluation_output(raw_output: str) -> dict[str, Any]:
|
||||||
text = raw_output.strip()
|
payload = parse_json_object_output(
|
||||||
if text.startswith('```'):
|
raw_output,
|
||||||
text = re.sub(r'^```(?:json)?\s*', '', text)
|
error_message='模型返回的评测结果不是合法 JSON',
|
||||||
text = re.sub(r'\s*```$', '', text)
|
)
|
||||||
try:
|
|
||||||
payload = json.loads(text)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
match = re.search(r'\{.*\}', text, re.DOTALL)
|
|
||||||
if not match:
|
|
||||||
raise EvaluationError('模型没有返回合法 JSON')
|
|
||||||
try:
|
|
||||||
payload = json.loads(match.group(0))
|
|
||||||
except json.JSONDecodeError as exc:
|
|
||||||
raise EvaluationError('模型返回的评测结果不是合法 JSON') from exc
|
|
||||||
if not isinstance(payload, dict):
|
|
||||||
raise EvaluationError('模型评测结果必须是 JSON 对象')
|
|
||||||
prediction = str(
|
prediction = str(
|
||||||
payload.get('prediction')
|
payload.get('prediction')
|
||||||
or payload.get('label')
|
or payload.get('label')
|
||||||
@@ -2973,17 +3070,20 @@ def parse_json_object_output(
|
|||||||
text = re.sub(r'\s*```$', '', text)
|
text = re.sub(r'\s*```$', '', text)
|
||||||
try:
|
try:
|
||||||
payload = json.loads(text)
|
payload = json.loads(text)
|
||||||
|
if isinstance(payload, dict):
|
||||||
|
return payload
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
match = re.search(r'\{.*\}', text, re.DOTALL)
|
pass
|
||||||
if not match:
|
|
||||||
raise EvaluationError(error_message)
|
decoder = json.JSONDecoder()
|
||||||
|
for match in re.finditer(r'\{', text):
|
||||||
try:
|
try:
|
||||||
payload = json.loads(match.group(0))
|
payload, _end = decoder.raw_decode(text[match.start() :])
|
||||||
except json.JSONDecodeError as exc:
|
except json.JSONDecodeError:
|
||||||
raise EvaluationError(error_message) from exc
|
continue
|
||||||
if not isinstance(payload, dict):
|
if isinstance(payload, dict):
|
||||||
raise EvaluationError(error_message)
|
return payload
|
||||||
return payload
|
raise EvaluationError(error_message)
|
||||||
|
|
||||||
|
|
||||||
def deterministic_label_mapping(
|
def deterministic_label_mapping(
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
import base64
|
import base64
|
||||||
import json
|
import json
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import threading
|
||||||
import time
|
import time
|
||||||
import unittest
|
import unittest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -21,6 +22,7 @@ from src.evaluation_runtime import (
|
|||||||
normalize_evaluation_output,
|
normalize_evaluation_output,
|
||||||
parse_dataset_bytes,
|
parse_dataset_bytes,
|
||||||
prediction_is_allowed,
|
prediction_is_allowed,
|
||||||
|
transient_model_error_message,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -282,6 +284,11 @@ class EvaluationRuntimeTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(result['prediction'], '地图导航')
|
self.assertEqual(result['prediction'], '地图导航')
|
||||||
self.assertEqual(result['confidence'], 1.0)
|
self.assertEqual(result['confidence'], 1.0)
|
||||||
|
duplicated = normalize_evaluation_output(
|
||||||
|
'{"prediction":"慢","complex":false}'
|
||||||
|
'{"prediction":"慢","complex":false}'
|
||||||
|
)
|
||||||
|
self.assertEqual(duplicated['prediction'], '慢')
|
||||||
self.assertTrue(labels_equivalent('Agent(tag="地图导航")', '地图导航'))
|
self.assertTrue(labels_equivalent('Agent(tag="地图导航")', '地图导航'))
|
||||||
self.assertTrue(
|
self.assertTrue(
|
||||||
labels_equivalent(
|
labels_equivalent(
|
||||||
@@ -375,6 +382,61 @@ class EvaluationRuntimeTests(unittest.TestCase):
|
|||||||
self.assertEqual(mapping['label_map']['慢系统'], '慢')
|
self.assertEqual(mapping['label_map']['慢系统'], '慢')
|
||||||
self.assertEqual(mapping['label_map']['complex=Ture'], 'complex=true')
|
self.assertEqual(mapping['label_map']['complex=Ture'], 'complex=true')
|
||||||
self.assertEqual(mapping['unmapped'], ['未知'])
|
self.assertEqual(mapping['unmapped'], ['未知'])
|
||||||
|
self.assertIn(
|
||||||
|
'HTTP 429',
|
||||||
|
transient_model_error_message(
|
||||||
|
'HTTP 429 from local model backend: Too many requests'
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_transient_model_failure_retries_before_validation(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
runtime = EvaluationRuntime(
|
||||||
|
root=root / 'evaluations',
|
||||||
|
cwd_for_account=lambda _account: root,
|
||||||
|
model_config_for=lambda _account: ModelConfig(model='test-model'),
|
||||||
|
account_paths_for=lambda _account: {'python_env': root / '.venv'},
|
||||||
|
max_workers=1,
|
||||||
|
)
|
||||||
|
transient = AgentRunResult(
|
||||||
|
final_output='HTTP 429: Too many requests',
|
||||||
|
turns=0,
|
||||||
|
tool_calls=0,
|
||||||
|
transcript=(),
|
||||||
|
usage=UsageStats(),
|
||||||
|
)
|
||||||
|
success = AgentRunResult(
|
||||||
|
final_output='{"prediction":"快"}',
|
||||||
|
turns=1,
|
||||||
|
tool_calls=0,
|
||||||
|
transcript=(),
|
||||||
|
usage=UsageStats(),
|
||||||
|
)
|
||||||
|
|
||||||
|
class StubAgent:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.calls = 0
|
||||||
|
|
||||||
|
def run(self, *_args: object, **_kwargs: object) -> AgentRunResult:
|
||||||
|
self.calls += 1
|
||||||
|
return transient if self.calls == 1 else success
|
||||||
|
|
||||||
|
agent = StubAgent()
|
||||||
|
try:
|
||||||
|
with patch.object(runtime, '_extend_model_cooldown') as cooldown:
|
||||||
|
result = runtime._run_agent_with_transient_backoff(
|
||||||
|
agent, # type: ignore[arg-type]
|
||||||
|
'prompt',
|
||||||
|
session_id='session',
|
||||||
|
runtime_context='context',
|
||||||
|
cancel_event=threading.Event(),
|
||||||
|
)
|
||||||
|
self.assertIs(result, success)
|
||||||
|
self.assertEqual(agent.calls, 2)
|
||||||
|
cooldown.assert_called_once()
|
||||||
|
finally:
|
||||||
|
runtime.shutdown()
|
||||||
|
|
||||||
def test_fast_slow_prompt_uses_skill_contract_and_relative_paths(self) -> None:
|
def test_fast_slow_prompt_uses_skill_contract_and_relative_paths(self) -> None:
|
||||||
skill = BundledSkill(
|
skill = BundledSkill(
|
||||||
|
|||||||
Reference in New Issue
Block a user