fix: 调整智能体模型默认解析
This commit is contained in:
parent
d59756c167
commit
bfad68ac3f
@ -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
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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",
|
||||
},
|
||||
)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 入参传了别的模型,也必须沿用父运行的模型
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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` 或真实外键场景。
|
||||
|
||||
Loading…
Reference in New Issue
Block a user