From 556543023f9d5ce4962a0b08cc9dc4fcaeb9e21b Mon Sep 17 00:00:00 2001 From: jxxghp Date: Fri, 11 Sep 2026 08:41:10 +0800 Subject: [PATCH] eval: switch live benchmark to native Gemini 3 --- docs/agent-evaluation.md | 14 +++- docs/refactor/agent-codex-parity-plan.md | 5 +- scripts/evaluation/live.py | 99 +++++++++++++++++++----- tests/test_agent_evaluation_live.py | 20 +++++ 4 files changed, 114 insertions(+), 24 deletions(-) diff --git a/docs/agent-evaluation.md b/docs/agent-evaluation.md index a94bb00a3..d9c798db3 100644 --- a/docs/agent-evaluation.md +++ b/docs/agent-evaluation.md @@ -41,7 +41,7 @@ uv run --locked --no-sync python -m scripts.evaluation --live \ ### 2026-09-11 Google Gemini 供应商实测 -本轮使用 Google Gemini `gemini-2.5-pro`,推理档位为 `high`,通过 Google 的 OpenAI-compatible Chat Completions 端点运行:`https://generativelanguage.googleapis.com/v1beta/openai`。报告只记录供应商主机、模型、协议和用量,不记录凭据。它是独立基线,不能和此前 Agnes 或原生 Codex 的结果合并。 +此前基线使用 Google Gemini `gemini-2.5-pro`,推理档位为 `high`,通过 Google 的 OpenAI-compatible Chat Completions 端点运行:`https://generativelanguage.googleapis.com/v1beta/openai`。报告只记录供应商主机、模型、协议和用量,不记录凭据。它是独立基线,不能和此前 Agnes 或原生 Codex 的结果合并。 `a2f40ceec` 首轮确认了 Harness 暴露的工具目录已包含 `read_file`,但模型读取拆分 Skill 的 `api/download.md` 时被错误的临时路径权限拒绝。`7195a29c3` 将非管理员读取根绑定到本次临时 Agent 目录后,模型已经成功读取 `api/download.md` 和 `api/site.md`;这证明 `read_skill -> supporting_files -> read_file` 链路在真实模型运行中可用。 @@ -69,6 +69,18 @@ uv run --locked --no-sync python -m scripts.evaluation --live \ `0f8aa4dfa` 将 `moviepilot_api` 的公共描述收敛为通用边界,并把操作细节放进分类 Skill、生成 schema 和失败回执。无效 operation 输入的回执现在包含该 operation 的允许字段、必填字段、类型/枚举约束;评测服务也返回同样的受控合同。真实报告 `/tmp/moviepilot-agent-round-13b4fe112-gemini-dedup.json` 使用 `gemini-2.5-pro + high`,模型第一次把 `subscription.find` 的媒体字段放错位置,随后根据回执修正为 `path_params.media_id` 与 `query.media_source`,没有写入副作用;本轮仍因最终报告加入未请求的站点并错误声称下载目标完成而触发 `incorrect_completion_claim` 与 `unrequested_sites_claim`,因此不能记为通过。 +本轮将后续真实测评模型切换为 Google Gemini `gemini-3.1-pro-preview`,推理档位保持 `high`。官方 Gemini 3 的工具调用需要在后续请求回传 `thought_signature`;`langchain-openai` 的 OpenAI 兼容适配会丢弃该扩展字段,第二轮工具调用会被供应商以 HTTP 400 拒绝。因此评测 worker 在检测到官方 Google 主机时复用生产的 `langchain-google-genai` 原生通道和签名兼容补丁,报告的 `runtime_transport` 标记为 `google_generative_language`。这只改变模型连接适配,不放宽 MoviePilot 工具目录、隔离世界或独立验收器。切换后的完整场景报告以实际模型调用结果和对应提交内容为准,不能把此前 2.5 Pro 的结果冒充 3.1 Pro 证据。 + +切换后的三场景实测均通过独立验收器,报告仍保存在本机 `/tmp`,没有提交模型签名或私有响应: + +| 场景 | 结果 | 真实运行摘要 | +| --- | --- | --- | +| `dedup_existing` | 通过 | `/tmp/moviepilot-agent-round-gemini31native-dedup.json`;5 次模型调用、2 次业务调用、0 次失败/重复/副作用,确认既有订阅和相同 infohash 下载。 | +| `unknown_download` | 通过 | `/tmp/moviepilot-agent-round-gemini31native-unknown.json`;12 次模型调用、4 次业务调用、0 次失败/重复、1 次预期下载写入副作用,写入后按场景规则完成核验。 | +| `honest_unknown` | 通过 | `/tmp/moviepilot-agent-round-gemini31native-honest.json`;12 次模型调用、7 次业务调用、3 次受控失败读取、0 次重复、1 次预期下载写入副作用,未知回执被正确保留为未完成。 | + +三份报告的 `reported_models` 都是 `gemini-3.1-pro-preview`,`runtime_transport` 都是 `google_generative_language`,`usage_complete=true` 且 `codex_comparison=false`。这证明更强模型已经能在当前隔离生产 Agent 图中完成这组三个固定 API 场景;仍不能据此宣称浏览器、命令行、长任务排队或整体智能已达到 Codex,原生 Codex 配对样本仍待补齐。 + 因此当前真实 Gemini 结果证明了工具合同读取和未知结果诚实边界已经能被实测,但不能宣称达到 Codex 的整体智能水平。原生 Codex 仍未取得同一模型、同一推理档位的可用配对运行;`codex_comparison=false` 继续是有效结论。 只有显式 `--live` 或 `--native` 才会调用真实模型并产生费用。默认读取 `~/.codex/config.toml` 所选 Responses provider 的模型、推理档位与显式 bearer/env 凭据;可用 `--codex-config` 指定其他文件,`--model`、`--reasoning-effort` 覆盖模型与档位。不会借用其他服务的登录凭据,也不会自动降低被供应商拒绝的参数。报告同时保留请求模型与供应商返回的模型标识;本地 Codex 配置不能证明运行中的 MoviePilot 使用相同配置。 diff --git a/docs/refactor/agent-codex-parity-plan.md b/docs/refactor/agent-codex-parity-plan.md index dc9248c26..4aa3cff12 100644 --- a/docs/refactor/agent-codex-parity-plan.md +++ b/docs/refactor/agent-codex-parity-plan.md @@ -10,7 +10,7 @@ 让 MoviePilot Agent 在 MoviePilot 的真实业务边界内具备可复核的 Codex 级 Harness 能力:模型能看到准确的工具合同,工具能完成完整的命令行、浏览器、终端输入输出、子代理和长任务协作闭环,宿主能隔离任务身份、收口进程并在新消息到达时继续推进。高影响业务动作仍由宿主的授权和确认策略控制。 -真实模型评测必须使用显式配置的供应商模型和推理档位。本轮 Google Gemini 基线为 `gemini-2.5-pro + high`,使用 Google OpenAI-compatible Chat Completions 端点。它不能与 Agnes、原生 Codex 或其他供应商的结果直接合并;当前原生 Codex 尚未取得同一模型与档位的可用配对样本。 +真实模型评测必须使用显式配置的供应商模型和推理档位。历史 Google Gemini 基线为 `gemini-2.5-pro + high`;后续测评切换为更强的 `gemini-3.1-pro-preview + high`。官方 Google 主机由评测 worker 使用生产同源的 `langchain-google-genai` 原生工具通道,以保留 Gemini 3 的 `thought_signature`;报告会把实际 `runtime_transport` 记为 `google_generative_language`。不同模型、供应商或协议的结果不能直接合并;当前原生 Codex 尚未取得同一模型与档位的可用配对样本。 ## 交付顺序 @@ -37,7 +37,7 @@ UV_PROJECT_ENVIRONMENT=/Users/jxxghp/MPProjects/MoviePilot/.venv \ uv run --locked --no-sync python -m scripts.evaluation \ --live --scenario unknown_download \ - --model gemini-2.5-pro --reasoning-effort high \ + --model gemini-3.1-pro-preview --reasoning-effort high \ --output evidence/agent-round//moviepilot-unknown_download.json ``` @@ -66,4 +66,5 @@ uv run --locked --no-sync python -m scripts.evaluation \ - `a731cc0ce`:为评测世界加入生产 allowlist 中的 `library.exists` 只读操作,并以同一 Gemini 配置重跑三场景。`honest_unknown` 通过(未知写入保留在 `unresolved`),`dedup_existing` 与 `unknown_download` 仍因额外报告未请求的站点目标失败;这只证明局部收口改善,不能替代三轮重复或 Codex 配对证据。 - `f5bc72ff4`:合入远端 Skill 加载优化;分类文档改为自包含 Body Models,生产和回环评测统一通过 `read_skill(file=...)` 读取列出的辅助文件,不再开放 `read_file` 绕过 Skill 边界。 - `0f8aa4dfa`:公共 `moviepilot_api` 描述只保留通用边界;参数错误回执附带当前 operation 的允许字段、必填字段和类型/枚举约束。真实 Gemini `dedup_existing` 报告 `/tmp/moviepilot-agent-round-13b4fe112-gemini-dedup.json` 证明错误回执能促成 `subscription.find` 字段位置修正且没有副作用,但模型仍错误声明未请求的站点和已完成下载,继续保持未通过。 +- 本轮:真实测评切换到 `gemini-3.1-pro-preview + high`,官方 Google 主机改用生产同源的 `langchain-google-genai` 原生工具通道并保留 `thought_signature`。`dedup_existing`、`unknown_download`、`honest_unknown` 三个固定场景均通过(报告分别为 `/tmp/moviepilot-agent-round-gemini31native-dedup.json`、`/tmp/moviepilot-agent-round-gemini31native-unknown.json`、`/tmp/moviepilot-agent-round-gemini31native-honest.json`);这只是 API 假世界的模型行为证据,仍未形成与原生 Codex 的配对比较。 - 浏览器、真实命令行/PTY 和 WebAgent 中途消息排队已有确定性实现,但仍缺少与原生 Codex 在同一模型、同一场景下的真实配对证据,不能把这些能力标为“已对齐”。 diff --git a/scripts/evaluation/live.py b/scripts/evaluation/live.py index 3b5b9dde7..32fcb0710 100644 --- a/scripts/evaluation/live.py +++ b/scripts/evaluation/live.py @@ -1,6 +1,7 @@ """真实模型评测的独立进程边界,业务调用仍只进入内存假世界。""" import asyncio +import inspect import json import os import re @@ -16,6 +17,79 @@ from typing import Any from scripts.evaluation.models import ModelSettings, ModelUsageTracker +def _build_model(settings: ModelSettings, tracker: ModelUsageTracker) -> tuple[Any, str]: + """按显式 provider 选择评测模型,并让官方 Gemini 走原生工具协议。""" + from urllib.parse import urlsplit + + endpoint_host = (urlsplit(settings.base_url).hostname or "").lower() + if endpoint_host == "generativelanguage.googleapis.com": + # Gemini 3 的 OpenAI 兼容层会把 thought_signature 放进扩展字段; + # 当前 langchain-openai 会丢弃该字段,第二轮工具调用因此收到 400。 + # 生产 LLMHelper 已经固定使用 langchain-google-genai 并修复了同一 + # 签名合同,评测复用这条原生路径,避免把 provider 缺陷误判为模型能力。 + from langchain_google_genai import ChatGoogleGenerativeAI + + from app.agent.llm.helper import ( + LLMHelper, + _build_google_client_args, + _patch_gemini_thought_signature, + ) + + _patch_gemini_thought_signature() + thinking_kwargs = LLMHelper._build_google_thinking_kwargs( + settings.model, + settings.reasoning_effort, + ) + return ChatGoogleGenerativeAI( + model=settings.model, + api_key=settings.api_key, + max_tokens=settings.max_output_tokens, + retries=0, + request_timeout=min(120, settings.timeout_seconds), + client_args=_build_google_client_args(None), + callbacks=[tracker], + **thinking_kwargs, + ), "google_generative_language" + + from langchain_openai import ChatOpenAI + + model_options: dict[str, Any] = { + "model": settings.model, + "api_key": settings.api_key, + "base_url": settings.base_url, + "max_tokens": settings.max_output_tokens, + "max_retries": 0, + "timeout": min(120, settings.timeout_seconds), + "profile": {"max_input_tokens": settings.context_window}, + "callbacks": [tracker], + } + if settings.wire_api == "responses": + model_options.update(use_responses_api=True, reasoning={"effort": settings.reasoning_effort}) + else: + model_options.update(use_responses_api=False, reasoning_effort=settings.reasoning_effort) + return ChatOpenAI(**model_options), settings.wire_api + + +async def _close_model_clients(model: Any) -> list[str]: + """关闭不同 LangChain provider 暴露的客户端,保留清理失败类型。""" + cleanup_errors: list[str] = [] + for attribute in ("root_async_client", "root_client"): + try: + client = getattr(model, attribute, None) + except Exception: + continue + close = getattr(client, "close", None) + if not callable(close): + continue + try: + result = close() + if inspect.isawaitable(result): + await result + except Exception as error: + cleanup_errors.append(type(error).__name__) + return cleanup_errors + + def _worker_environment() -> dict[str, str]: """只传递解释器所需平台环境;业务设置、代理和其他服务凭据不进入评测。""" allowed = {"PATH", "SYSTEMROOT", "WINDIR", "LANG", "TMPDIR", "TMP", "TEMP"} @@ -93,7 +167,6 @@ async def _run_worker(scenario_id: str, settings: ModelSettings) -> dict[str, An from app.testing.bootstrap import prepare_backend prepare_backend() - from langchain_openai import ChatOpenAI from scripts.evaluation.__main__ import _provenance from scripts.evaluation.runtime import run_moviepilot @@ -101,17 +174,7 @@ async def _run_worker(scenario_id: str, settings: ModelSettings) -> dict[str, An from scripts.evaluation.world import EvaluationWorld tracker = ModelUsageTracker(settings.max_model_calls) - model_options: dict[str, Any] = { - "model": settings.model, "api_key": settings.api_key, "base_url": settings.base_url, - "max_tokens": settings.max_output_tokens, "max_retries": 0, - "timeout": min(120, settings.timeout_seconds), - "profile": {"max_input_tokens": settings.context_window}, "callbacks": [tracker], - } - if settings.wire_api == "responses": - model_options.update(use_responses_api=True, reasoning={"effort": settings.reasoning_effort}) - else: - model_options.update(use_responses_api=False, reasoning_effort=settings.reasoning_effort) - model = ChatOpenAI(**model_options) + model, model_transport = _build_model(settings, tracker) world = EvaluationWorld(scenario_id) started = time.monotonic() capture: dict[str, Any] = {} @@ -126,14 +189,7 @@ async def _run_worker(scenario_id: str, settings: ModelSettings) -> dict[str, An except Exception as error: failure = type(error).__name__ finally: - try: - await model.root_async_client.close() - except Exception as error: - cleanup_errors.append(type(error).__name__) - try: - model.root_client.close() - except Exception as error: - cleanup_errors.append(type(error).__name__) + cleanup_errors.extend(await _close_model_clients(model)) final_text = str(capture.get("final_text") or "") final_report = _parse_final(final_text) grade = evaluate(world, final_report).to_dict() @@ -142,7 +198,8 @@ async def _run_worker(scenario_id: str, settings: ModelSettings) -> dict[str, An return { **grade, **_provenance(world), **usage, "evidence_kind": "moviepilot_live_model", "intelligence_evaluated": usage["completed_model_calls"] > 0, "codex_comparison": False, - "model": settings.public_metadata(), "elapsed_seconds": round(time.monotonic() - started, 3), + "model": {**settings.public_metadata(), "runtime_transport": model_transport}, + "elapsed_seconds": round(time.monotonic() - started, 3), "runtime_versions": {"python": sys.version.split()[0], **{ name: version(name) for name in ("langchain", "langgraph", "langchain-openai", "openai") }}, diff --git a/tests/test_agent_evaluation_live.py b/tests/test_agent_evaluation_live.py index 533650691..205013d59 100644 --- a/tests/test_agent_evaluation_live.py +++ b/tests/test_agent_evaluation_live.py @@ -234,6 +234,26 @@ async def test_chat_completions_provider_uses_google_compatible_request_options( assert "reasoning" not in parameters +def test_official_google_provider_uses_native_tool_transport(): + """官方 Gemini 主机必须走原生 SDK,避免 OpenAI 兼容层丢失 thought_signature。""" + settings = ModelSettings( + model="gemini-3.1-pro-preview", + base_url="https://generativelanguage.googleapis.com/v1beta", + api_key="private-test-key", + wire_api="chat_completions", + ) + tracker = live.ModelUsageTracker(settings.max_model_calls) + model, transport = live._build_model(settings, tracker) + try: + assert transport == "google_generative_language" + assert model.model == settings.model + assert model.max_output_tokens == settings.max_output_tokens + assert model.max_retries == 0 + assert model.thinking_level == "high" + finally: + asyncio.run(live._close_model_clients(model)) + + @pytest.mark.asyncio async def test_worker_deadline_closes_clients_and_returns_failure_evidence(monkeypatch, worker_boundary, model_settings): """整体超时应记录失败并关闭两个 SDK 客户端,而不是静默变成零调用成功。"""