From bfad68ac3f4a734df17d667469ac048fe02d9564 Mon Sep 17 00:00:00 2001 From: Wenjie Zhang Date: Thu, 11 Jun 2026 22:48:28 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E8=B0=83=E6=95=B4=E6=99=BA=E8=83=BD?= =?UTF-8?q?=E4=BD=93=E6=A8=A1=E5=9E=8B=E9=BB=98=E8=AE=A4=E8=A7=A3=E6=9E=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/package/yuxi/agents/__init__.py | 3 +- .../yuxi/agents/buildin/chatbot/graph.py | 8 ++- .../yuxi/agents/buildin/subagent/graph.py | 8 ++- backend/package/yuxi/agents/context.py | 5 +- .../yuxi/agents/middlewares/context.py | 4 +- .../yuxi/agents/middlewares/subagent_task.py | 4 ++ backend/package/yuxi/agents/models.py | 18 +++++- .../yuxi/services/agent_run_service.py | 16 ++--- backend/package/yuxi/services/chat_service.py | 1 + .../test_subagent_task_middleware.py | 49 +++++++++++++++ .../repositories/test_agent_repository.py | 1 + .../unit/services/test_agent_run_service.py | 61 +++++++++++++++++++ .../unit/services/test_model_selectors.py | 17 +++++- docs/develop-guides/changelog.md | 1 + 14 files changed, 173 insertions(+), 23 deletions(-) diff --git a/backend/package/yuxi/agents/__init__.py b/backend/package/yuxi/agents/__init__.py index 418871c6..161526bd 100644 --- a/backend/package/yuxi/agents/__init__.py +++ b/backend/package/yuxi/agents/__init__.py @@ -8,7 +8,7 @@ from yuxi.agents.context import BaseContext from yuxi.agents.mcp.service import get_enabled_mcp_tools # Model utilities - 模型加载 -from yuxi.agents.models import load_chat_model +from yuxi.agents.models import load_chat_model, resolve_chat_model_spec from yuxi.agents.state import BaseState # Tools - 核心工具函数 @@ -21,6 +21,7 @@ __all__ = [ "BaseState", # Model utilities "load_chat_model", + "resolve_chat_model_spec", # Core tools "get_tool_info", # Core MCP diff --git a/backend/package/yuxi/agents/buildin/chatbot/graph.py b/backend/package/yuxi/agents/buildin/chatbot/graph.py index e3624513..ff3421b4 100644 --- a/backend/package/yuxi/agents/buildin/chatbot/graph.py +++ b/backend/package/yuxi/agents/buildin/chatbot/graph.py @@ -2,7 +2,7 @@ from deepagents.middleware.patch_tool_calls import PatchToolCallsMiddleware from langchain.agents import create_agent from langchain.agents.middleware import ModelRetryMiddleware, TodoListMiddleware -from yuxi.agents import BaseAgent, load_chat_model +from yuxi.agents import BaseAgent, load_chat_model, resolve_chat_model_spec from yuxi.agents.backends import create_agent_filesystem_middleware from yuxi.agents.context import prepare_agent_runtime_context from yuxi.agents.middlewares import ( @@ -24,8 +24,9 @@ async def _build_middlewares(context): # summary middleware # 主 Agent 上下文优化:默认 100k tokens 触发压缩,保留最近 50% summary_trigger_tokens = getattr(context, "summary_threshold", 100) * 1024 + model_spec = resolve_chat_model_spec(context.model) summary_middleware = create_summary_middleware( - model=load_chat_model(fully_specified_name=context.model), + model=load_chat_model(fully_specified_name=model_spec), trigger=("tokens", summary_trigger_tokens), keep=("tokens", summary_trigger_tokens // 2), trim_tokens_to_summarize=4000, @@ -71,8 +72,9 @@ class ChatbotAgent(BaseAgent): ) # 使用 create_agent 创建智能体 + model_spec = resolve_chat_model_spec(context.model) graph = create_agent( - model=load_chat_model(fully_specified_name=context.model), + model=load_chat_model(fully_specified_name=model_spec), tools=await resolve_configured_runtime_tools(context), system_prompt=build_prompt_with_context(context), middleware=await _build_middlewares(context), diff --git a/backend/package/yuxi/agents/buildin/subagent/graph.py b/backend/package/yuxi/agents/buildin/subagent/graph.py index 77eb5926..61ed804c 100644 --- a/backend/package/yuxi/agents/buildin/subagent/graph.py +++ b/backend/package/yuxi/agents/buildin/subagent/graph.py @@ -5,7 +5,7 @@ from langchain.agents import create_agent from langchain.agents.middleware import ModelRetryMiddleware, TodoListMiddleware from langchain.agents.middleware.types import AgentMiddleware -from yuxi.agents import BaseAgent, BaseState, load_chat_model +from yuxi.agents import BaseAgent, BaseState, load_chat_model, resolve_chat_model_spec from yuxi.agents.backends import create_agent_filesystem_middleware from yuxi.agents.buildin.chatbot.prompt import TODO_MID_PROMPT, build_prompt_with_context from yuxi.agents.buildin.subagent.context import SubAgentContext @@ -40,8 +40,9 @@ class _SubAgentToolFilterMiddleware(AgentMiddleware[Any, Any, Any]): async def _build_middlewares(context): summary_trigger_tokens = getattr(context, "summary_threshold", 100) * 1024 + model_spec = resolve_chat_model_spec(context.model) summary_middleware = create_summary_middleware( - model=load_chat_model(fully_specified_name=context.model), + model=load_chat_model(fully_specified_name=model_spec), trigger=("tokens", summary_trigger_tokens), keep=("tokens", summary_trigger_tokens // 2), trim_tokens_to_summarize=4000, @@ -96,9 +97,10 @@ class SubAgentBackend(BaseAgent): context or self.context_schema(), context_schema=self.context_schema, ) + model_spec = resolve_chat_model_spec(context.model) return create_agent( - model=load_chat_model(fully_specified_name=context.model), + model=load_chat_model(fully_specified_name=model_spec), tools=_filter_disabled_tools(await resolve_configured_runtime_tools(context)), system_prompt=build_prompt_with_context(context), middleware=await _build_middlewares(context), diff --git a/backend/package/yuxi/agents/context.py b/backend/package/yuxi/agents/context.py index db9cf1cb..d263ebc9 100644 --- a/backend/package/yuxi/agents/context.py +++ b/backend/package/yuxi/agents/context.py @@ -5,7 +5,6 @@ import uuid from dataclasses import MISSING, dataclass, field, fields from typing import Any, get_origin -from yuxi import config as sys_config from yuxi.agents.backends.sandbox.paths import sandbox_workspace_agents_prompt_file from yuxi.utils.logging_config import logger @@ -131,11 +130,11 @@ class BaseContext: ) model: str = field( - default=sys_config.default_model, + default="", metadata={ "name": "智能体模型", "options": [], - "description": "智能体的驱动模型,建议选择 Agent 能力较强的模型,不建议使用小参数模型。", + "description": "智能体的驱动模型,留空时使用系统默认模型。", "kind": "llm", }, ) diff --git a/backend/package/yuxi/agents/middlewares/context.py b/backend/package/yuxi/agents/middlewares/context.py index 1a78b558..d9cb930a 100644 --- a/backend/package/yuxi/agents/middlewares/context.py +++ b/backend/package/yuxi/agents/middlewares/context.py @@ -4,7 +4,7 @@ from collections.abc import Callable from langchain.agents.middleware import ModelRequest, ModelResponse, dynamic_prompt, wrap_model_call -from yuxi.agents import load_chat_model +from yuxi.agents import load_chat_model, resolve_chat_model_spec from yuxi.utils import logger @@ -17,7 +17,7 @@ def context_aware_prompt(request: ModelRequest) -> str: @wrap_model_call async def context_based_model(request: ModelRequest, handler: Callable[[ModelRequest], ModelResponse]) -> ModelResponse: """从 runtime context 动态选择模型""" - model_spec = request.runtime.context.model + model_spec = resolve_chat_model_spec(request.runtime.context.model) model = load_chat_model(model_spec) request = request.override(model=model) diff --git a/backend/package/yuxi/agents/middlewares/subagent_task.py b/backend/package/yuxi/agents/middlewares/subagent_task.py index c0e61633..4d225cd3 100644 --- a/backend/package/yuxi/agents/middlewares/subagent_task.py +++ b/backend/package/yuxi/agents/middlewares/subagent_task.py @@ -413,6 +413,10 @@ class YuxiSubAgentMiddleware(AgentMiddleware[Any, ContextT, ResponseT]): run_id=run.id, request_id=run.request_id, ) + if not str(child_input_context.get("model") or "").strip(): + parent_model = str(getattr(self.parent_context, "model", "") or "").strip() + if parent_model: + child_input_context["model"] = parent_model child_context.update_from_dict(child_input_context) child_context.uid = uid child_context.thread_id = child_thread_id diff --git a/backend/package/yuxi/agents/models.py b/backend/package/yuxi/agents/models.py index 94bbdad9..c575d2dc 100644 --- a/backend/package/yuxi/agents/models.py +++ b/backend/package/yuxi/agents/models.py @@ -1,6 +1,7 @@ from langchain.chat_models import BaseChatModel from pydantic import SecretStr +from yuxi import config as sys_config from yuxi.models.providers.cache import model_cache from yuxi.utils import get_docker_safe_url from yuxi.utils.logging_config import logger @@ -19,9 +20,20 @@ def _requires_non_streaming_tool_calls(provider_id: str, model_id: str) -> bool: return provider_id.startswith(_NON_STREAMING_TOOL_CALL_PROVIDERS) -def load_chat_model(fully_specified_name: str, **kwargs) -> BaseChatModel: - if not fully_specified_name: - raise ValueError("model spec 不能为空") +def resolve_chat_model_spec(model_spec: str | None, *, fallback: str | None = None) -> str: + """解析空模型配置,不吞掉已经配置但无效的模型值。 + + 这里仅处理模型为空时的优先级:请求或配置值、调用方 fallback、系统默认模型; + 具体模型是否存在、是否为聊天模型仍由 model_cache 校验。 + """ + for candidate in (model_spec, fallback, sys_config.default_model): + if isinstance(candidate, str) and candidate.strip(): + return candidate.strip() + raise ValueError("model spec 不能为空") + + +def load_chat_model(fully_specified_name: str | None, **kwargs) -> BaseChatModel: + fully_specified_name = resolve_chat_model_spec(fully_specified_name) info = model_cache.get_model_info(fully_specified_name) if not info: diff --git a/backend/package/yuxi/services/agent_run_service.py b/backend/package/yuxi/services/agent_run_service.py index 4dba3d5a..e1723b7b 100644 --- a/backend/package/yuxi/services/agent_run_service.py +++ b/backend/package/yuxi/services/agent_run_service.py @@ -13,6 +13,7 @@ from sqlalchemy import select from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from yuxi.agents.buildin import agent_manager +from yuxi.agents.models import resolve_chat_model_spec from yuxi.models.providers.cache import model_cache from yuxi.repositories.agent_repository import AgentRepository from yuxi.repositories.agent_run_repository import TERMINAL_RUN_STATUSES, AgentRunRepository @@ -37,16 +38,17 @@ SSE_POLL_INTERVAL_SECONDS = float(os.getenv("RUN_SSE_POLL_INTERVAL_SECONDS", "1. def _validate_model_spec(model_spec: str | None) -> str | None: """校验对话级模型覆盖:未提供则返回 None;非法模型直接 422,不静默回退。""" - if not model_spec: + normalized = model_spec.strip() if isinstance(model_spec, str) else None + if not normalized: return None - info = model_cache.get_model_info(model_spec) + info = model_cache.get_model_info(normalized) if not info or info.model_type != "chat": - raise HTTPException(status_code=422, detail=f"未找到可用聊天模型: '{model_spec}'") - return model_spec + raise HTTPException(status_code=422, detail=f"未找到可用聊天模型: '{normalized}'") + return normalized -def _resolve_effective_model_spec(model_spec: str | None, agent_item, agent_backend) -> str | None: - """解析本次 chat run 实际使用的模型:显式覆盖优先,否则快照智能体当前配置。""" +def _resolve_effective_model_spec(model_spec: str | None, agent_item, agent_backend) -> str: + """解析本次 chat run 实际使用的模型:显式覆盖优先,否则配置模型,最后系统默认模型。""" resolved_model_spec = _validate_model_spec(model_spec) if resolved_model_spec: return resolved_model_spec @@ -56,7 +58,7 @@ def _resolve_effective_model_spec(model_spec: str | None, agent_item, agent_back config_context = config_json.get("context") if isinstance(config_json, dict) else {} if isinstance(config_context, dict): context.update_from_dict(config_context) - return getattr(context, "model", None) + return resolve_chat_model_spec(getattr(context, "model", None)) def _build_run_response(run) -> dict: diff --git a/backend/package/yuxi/services/chat_service.py b/backend/package/yuxi/services/chat_service.py index d53548df..3f8f0fe5 100644 --- a/backend/package/yuxi/services/chat_service.py +++ b/backend/package/yuxi/services/chat_service.py @@ -169,6 +169,7 @@ def _json_safe(value: Any) -> Any: def _apply_model_override(input_context: dict, meta: dict | None) -> None: """对话级模型覆盖:meta.model_spec 优先于智能体配置的 model。值已在创建 run 时校验。""" model_spec = (meta or {}).get("model_spec") + model_spec = model_spec.strip() if isinstance(model_spec, str) else model_spec if model_spec: input_context["model"] = model_spec diff --git a/backend/test/unit/middlewares/test_subagent_task_middleware.py b/backend/test/unit/middlewares/test_subagent_task_middleware.py index a00e0fd0..e24553a0 100644 --- a/backend/test/unit/middlewares/test_subagent_task_middleware.py +++ b/backend/test/unit/middlewares/test_subagent_task_middleware.py @@ -263,6 +263,55 @@ async def test_task_tool_invokes_subagent_with_child_scope(monkeypatch) -> None: assert captured["context"].is_subagent_runtime is True +@pytest.mark.asyncio +async def test_task_tool_inherits_parent_model_when_subagent_model_empty(monkeypatch) -> None: + _patch_subagent_run_tracking(monkeypatch) + captured = {} + + class _Graph: + async def ainvoke(self, state, *, config, context): + del state, config + captured["context"] = context + return {"messages": [AIMessage(content="child done")]} + + class _Backend: + context_schema = _ChildContext + + async def get_graph(self, *, context): + captured["graph_context"] = context + return _Graph() + + monkeypatch.setattr( + subagent_task_middleware, + "_get_agent_backend", + lambda backend_id: _Backend() if backend_id == SUB_AGENT_BACKEND_ID else None, + ) + + middleware = YuxiSubAgentMiddleware( + parent_context=SimpleNamespace(thread_id="parent-thread", uid="user-1", model="parent:model"), + subagents=[ + SimpleNamespace( + slug="worker", + name="Worker", + description="work on scoped tasks", + backend_id=SUB_AGENT_BACKEND_ID, + config_json={"context": {"model": ""}}, + ) + ], + ) + runtime = SimpleNamespace(tool_call_id="tool-1", state={}, config={}) + + result = await middleware.tools[0].coroutine( + description="write a report", + subagent_type="worker", + runtime=runtime, + ) + + assert isinstance(result, Command) + assert captured["context"] is captured["graph_context"] + assert captured["context"].model == "parent:model" + + @pytest.mark.asyncio async def test_task_tool_records_failed_subagent_run(monkeypatch) -> None: _patch_subagent_run_tracking(monkeypatch) diff --git a/backend/test/unit/repositories/test_agent_repository.py b/backend/test/unit/repositories/test_agent_repository.py index 7288e4d4..6d2f32ac 100644 --- a/backend/test/unit/repositories/test_agent_repository.py +++ b/backend/test/unit/repositories/test_agent_repository.py @@ -42,6 +42,7 @@ async def test_ensure_default_agent_creates_description(monkeypatch): agent = await repo.ensure_default_agent() assert agent.description == DEFAULT_AGENT_DESCRIPTION + assert agent.config_json == {"context": {}} assert db.added is agent db.commit.assert_awaited_once() db.refresh.assert_awaited_once_with(agent) diff --git a/backend/test/unit/services/test_agent_run_service.py b/backend/test/unit/services/test_agent_run_service.py index 50ff7c6d..d0ef2647 100644 --- a/backend/test/unit/services/test_agent_run_service.py +++ b/backend/test/unit/services/test_agent_run_service.py @@ -642,6 +642,19 @@ def test_validate_model_spec_accepts_chat_model(monkeypatch: pytest.MonkeyPatch) assert agent_run_service._validate_model_spec("gpt-x") == "gpt-x" +def test_validate_model_spec_strips_explicit_model(monkeypatch: pytest.MonkeyPatch): + seen = [] + + def fake_get_model_info(spec): + seen.append(spec) + return SimpleNamespace(model_type="chat") + + monkeypatch.setattr(agent_run_service.model_cache, "get_model_info", fake_get_model_info) + + assert agent_run_service._validate_model_spec(" gpt-x ") == "gpt-x" + assert seen == ["gpt-x"] + + def _patch_common_run_repos( monkeypatch: pytest.MonkeyPatch, run_repo_cls, @@ -796,6 +809,54 @@ async def test_create_chat_run_snapshots_agent_configured_model_spec(monkeypatch assert db.added[0].extra_metadata["model_spec"] == "agent-config-model" +@pytest.mark.asyncio +async def test_create_chat_run_snapshots_system_default_when_agent_model_empty(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr( + agent_run_service, + "resolve_chat_model_spec", + lambda model_spec: str(model_spec).strip() if str(model_spec or "").strip() else "system-default-model", + ) + captured = {} + created_run = SimpleNamespace(id="", thread_id="thread-1", status="pending", request_id="req-1", uid="user-1") + + class RunRepo: + def __init__(self, db_session): + del db_session + + async def get_run_by_request_id(self, request_id: str): + del request_id + return None + + async def create_run(self, **kwargs): + captured["input_payload"] = kwargs["input_payload"] + created_run.id = kwargs["run_id"] + return created_run + + async def set_input_message(self, run_id: str, message_id: int): + del run_id, message_id + return created_run + + db = _patch_common_run_repos( + monkeypatch, + RunRepo, + agent_config_json={"context": {"model": ""}}, + ) + + await agent_run_service.create_agent_run_view( + query="hello", + agent_id="default", + thread_id="thread-1", + meta={"request_id": "req-1"}, + image_content=None, + current_uid="user-1", + db=db, + model_spec=None, + ) + + assert captured["input_payload"]["model_spec"] == "system-default-model" + assert db.added[0].extra_metadata["model_spec"] == "system-default-model" + + @pytest.mark.asyncio async def test_create_resume_run_inherits_parent_model_spec(monkeypatch: pytest.MonkeyPatch): # 即使 resume 入参传了别的模型,也必须沿用父运行的模型 diff --git a/backend/test/unit/services/test_model_selectors.py b/backend/test/unit/services/test_model_selectors.py index 49c17a37..fc18dee3 100644 --- a/backend/test/unit/services/test_model_selectors.py +++ b/backend/test/unit/services/test_model_selectors.py @@ -4,7 +4,7 @@ import httpx import pytest import requests -from yuxi.agents.models import load_chat_model +from yuxi.agents.models import load_chat_model, resolve_chat_model_spec from yuxi.models.chat import select_model from yuxi.models.embed import OtherEmbedding, select_embedding_model from yuxi.models.rerank import OpenAIReranker, get_reranker @@ -64,6 +64,21 @@ def test_selectors_report_unknown_unconfigured_specs(selector, args): selector(**args) +def test_resolve_chat_model_spec_prefers_explicit_then_fallback_then_default(monkeypatch): + monkeypatch.setattr("yuxi.agents.models.sys_config.default_model", "system-default:model") + + assert resolve_chat_model_spec(" explicit:model ", fallback="fallback:model") == "explicit:model" + assert resolve_chat_model_spec("", fallback=" fallback:model ") == "fallback:model" + assert resolve_chat_model_spec(None, fallback="") == "system-default:model" + + +def test_resolve_chat_model_spec_rejects_all_empty(monkeypatch): + monkeypatch.setattr("yuxi.agents.models.sys_config.default_model", "") + + with pytest.raises(ValueError, match="model spec 不能为空"): + resolve_chat_model_spec("", fallback=None) + + def test_select_embedding_model_loads_model_from_cache(monkeypatch): monkeypatch.setattr( "yuxi.models.embed.model_cache.get_model_info", diff --git a/docs/develop-guides/changelog.md b/docs/develop-guides/changelog.md index 473af8f5..560d5da5 100644 --- a/docs/develop-guides/changelog.md +++ b/docs/develop-guides/changelog.md @@ -52,6 +52,7 @@ - 重构知识库详情页布局:`DatabaseInfo` 改为顶部详情 header + 左侧功能 tab 侧边栏 + 右侧内容区,Milvus 默认进入文件管理,并将检索测试、知识图谱、知识导图、检索配置、RAG 评估和评估基准统一纳入侧边栏导航;只读连接器保留检索测试与检索配置。 - 整合知识导图接口:移除独立 mindmap router 与前端 API 模块,思维导图生成、查询和文件列表接口统一收敛到知识库 API 下。 - 收敛独立模型配置模块运行时:运行时 chat / embedding / rerank 均统一从 provider 模块与模型缓存读取 `provider_id:model_id`;旧版静态模型配置、v1 slash spec、旧模型列表接口和 Ollama 适配已移除;内置 provider 模板补充 XiaomiMiMo、XiaomiMiMo Token Plan CN 与 Kimi Code(`kimi-for-coding`)。 +- 调整智能体模型配置默认值:`BaseContext.model` 默认保持为空,运行时按“请求模型 > 智能体配置模型 > 系统默认模型”解析;子智能体未配置模型时继承主智能体当前运行模型,避免把系统默认模型固化进每个智能体配置。 - 调整智能体配置归属与字段权限:`AgentConfig` 从部门共享改为按 `uid` 隔离,所有登录用户可管理自己的配置;`BaseContext` 支持字段级 `auth` 元数据,后端按用户角色过滤可见与可保存的配置项。 - 新增用户级沙盒环境变量:增加 `agent_envs` 表与 `/api/user/agent-env` 接口,设置面板支持当前用户维护 Agent 沙盒环境变量;创建新沙盒时与全局 `sandbox.env` 合并注入,用户变量优先。 - 收敛用户身份命名:原业务登录标识统一改为 `uid`,Agent/LangGraph runtime、conversation、agent_run、sandbox 路径和前端用户态均使用字符串 `uid`;`user_id` 仅保留给外部响应中的数值 `users.id` 或真实外键场景。