feat(chat): per-conversation model selection (#150)

This commit is contained in:
matevip 2026-05-18 16:27:27 +08:00
parent 0ff8da0caa
commit d53d66abe3
13 changed files with 275 additions and 40 deletions

View File

@ -126,9 +126,41 @@ public class AgentGraphBuilder {
}
/**
* 根据 AgentEntity 构建完整的 Agent 实例
* 根据 AgentEntity 构建完整的 Agent 实例沿用 Agent / 全局默认模型
*/
public BaseAgent build(AgentEntity entity) {
return build(entity, null, null);
}
/**
* Resolve the model the runtime should use, honouring the precedence
* <em>conversation pin &gt; Agent model override &gt; global default</em>.
* A conversation pin that no longer resolves to an enabled model (the model
* was disabled or deleted after it was picked) silently degrades to the
* Agent / global default rather than failing the chat.
*/
private ModelConfigEntity resolveRuntimeBaseModel(String modelProvider, String modelName,
String agentModelName) {
if (modelProvider != null && !modelProvider.isBlank()
&& modelName != null && !modelName.isBlank()) {
ModelConfigEntity pinned = modelConfigService.findEnabledModel(modelProvider, modelName);
if (pinned != null) {
return pinned;
}
log.info("Conversation model pin {}/{} is no longer an enabled model — "
+ "falling back to the Agent / global default", modelProvider, modelName);
}
return modelConfigService.resolveModel(agentModelName);
}
/**
* 根据 AgentEntity 构建完整的 Agent 实例
*
* <p>{@code modelProvider} / {@code modelName} carry an optional
* per-conversation model pin; when both are blank the build falls back to
* the Agent's model override, then the global default.</p>
*/
public BaseAgent build(AgentEntity entity, String modelProvider, String modelName) {
AgentToolSet toolSet = toolRegistry.getEnabledToolSet();
// 过滤掉 denied 工具使模型完全看不到它们防止 prompt injection 利用 schema
@ -142,17 +174,16 @@ public class AgentGraphBuilder {
Set<String> boundTools = agentBindingService.getEffectiveToolNames(entity.getId());
toolSet = toolSet.withAllowedToolsOnly(boundTools); // null = 全局默认
// RFC-090 §9.2 调整 C pick a primary model that satisfies
// the agent's bound-skill requires-model. Falls back to the
// global default when no preferred provider satisfies, so the
// existing "no default model" error path stays intact.
// Honor per-Agent model override when set.
// resolveModel() looks up entity.modelName in enabled-only models;
// null / blank / unmatched silently fall back to getDefaultModel(),
// preserving the legacy behavior for Agents without an override.
// Resolve the base model with the precedence: per-conversation pin >
// per-Agent model override > global default. resolveRuntimeBaseModel
// looks up enabled-only models and silently degrades an unmatched pin /
// override to the global default, preserving the legacy behaviour for
// Agents and conversations without an explicit choice.
// providerRouter.selectPrimary below may still swap this for a model
// that satisfies a bound skill's requires-model constraint.
ModelConfigEntity globalDefault;
try {
globalDefault = modelConfigService.resolveModel(entity.getModelName());
globalDefault = resolveRuntimeBaseModel(modelProvider, modelName, entity.getModelName());
} catch (Exception e) {
throw new MateClawException("err.agent.no_default_model", "无法构建 Agent请先在「设置 → 模型」中配置并启用默认模型");
}

View File

@ -21,6 +21,8 @@ import vip.mate.memory.MemoryProperties;
import vip.mate.memory.lifecycle.MemoryLifecycleMediator;
import vip.mate.memory.lifecycle.TurnContext;
import vip.mate.memory.service.MemoryRecallTracker;
import vip.mate.workspace.conversation.model.ConversationEntity;
import vip.mate.workspace.conversation.repository.ConversationMapper;
import java.util.List;
import java.util.Map;
@ -46,14 +48,22 @@ public class AgentService {
private final MemoryRecallTracker memoryRecallTracker;
private final MemoryLifecycleMediator lifecycleMediator;
private final MemoryProperties memoryProperties;
/** Read-only lookup of a conversation's pinned model. Mapper (not service)
* to keep this a leaf dependency with no risk of a bean cycle. */
private final ConversationMapper conversationMapper;
/** Field-injected publisher for agent_lifecycle trigger events; the
* trigger module's bridge listens and forwards into ingest. */
@Autowired(required = false)
private ApplicationEventPublisher events;
/** 运行时 Agent 实例缓存agentId -> BaseAgent */
private final Map<Long, BaseAgent> agentInstances = new ConcurrentHashMap<>();
/**
* Runtime Agent instance cache. Keyed first by agentId, then by a model
* key, so a conversation that pins a non-default model gets its own graph
* variant instead of mutating the one every other conversation shares.
* The model key is {@code ""} for the Agent / global-default model.
*/
private final Map<Long, Map<String, BaseAgent>> agentInstances = new ConcurrentHashMap<>();
// ==================== CRUD ====================
@ -217,7 +227,7 @@ public class AgentService {
*/
public String chat(Long agentId, String message, String conversationId, ChatOrigin origin) {
memoryRecallTracker.trackRecalls(agentId, message);
BaseAgent agent = getOrBuildAgent(agentId);
BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId);
ChatOriginHolder.set(origin != null ? origin : ChatOrigin.EMPTY);
try {
return withLifecycleSync(agentId, message, conversationId,
@ -233,7 +243,7 @@ public class AgentService {
public Flux<String> chatStream(Long agentId, String message, String conversationId, ChatOrigin origin) {
memoryRecallTracker.trackRecalls(agentId, message);
BaseAgent agent = getOrBuildAgent(agentId);
BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId);
// Capture the origin into a request-scoped holder; cleared on Flux
// termination so the next reactive subscriber doesn't inherit stale state.
ChatOrigin captured = origin != null ? origin : ChatOrigin.EMPTY;
@ -269,7 +279,7 @@ public class AgentService {
String requesterId, String thinkingLevel,
ChatOrigin origin) {
memoryRecallTracker.trackRecalls(agentId, message);
BaseAgent agent = getOrBuildAgent(agentId);
BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId);
// 设置请求级思考深度通过 ThreadLocal 传递到 StateGraph 执行
if (thinkingLevel != null && !thinkingLevel.isBlank()) {
@ -315,7 +325,7 @@ public class AgentService {
public String execute(Long agentId, String goal, String conversationId, ChatOrigin origin) {
memoryRecallTracker.trackRecalls(agentId, goal);
BaseAgent agent = getOrBuildAgent(agentId);
BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId);
ChatOriginHolder.set(origin != null ? origin : ChatOrigin.EMPTY);
try {
return withLifecycleSync(agentId, goal, conversationId,
@ -342,7 +352,7 @@ public class AgentService {
public String chatWithReplay(Long agentId, String userMessage, String conversationId,
String toolCallPayload, ChatOrigin origin) {
memoryRecallTracker.trackRecalls(agentId, userMessage);
BaseAgent agent = getOrBuildAgent(agentId);
BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId);
ChatOriginHolder.set(origin != null ? origin : ChatOrigin.EMPTY);
try {
return withLifecycleSync(agentId, userMessage, conversationId,
@ -370,7 +380,7 @@ public class AgentService {
String toolCallPayload, String requesterId,
ChatOrigin origin) {
memoryRecallTracker.trackRecalls(agentId, userMessage);
BaseAgent agent = getOrBuildAgent(agentId);
BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId);
ChatOrigin captured = origin != null ? origin : ChatOrigin.EMPTY;
return Flux.defer(() -> {
ChatOriginHolder.set(captured);
@ -383,8 +393,20 @@ public class AgentService {
}
public AgentState getAgentState(Long agentId) {
BaseAgent agent = agentInstances.get(agentId);
return agent != null ? agent.getState() : AgentState.IDLE;
Map<String, BaseAgent> variants = agentInstances.get(agentId);
if (variants == null || variants.isEmpty()) {
return AgentState.IDLE;
}
// An Agent may have several cached graph variants (one per pinned
// model). Report the first non-IDLE state so a turn running on any
// variant stays visible.
for (BaseAgent agent : variants.values()) {
AgentState state = agent.getState();
if (state != AgentState.IDLE) {
return state;
}
}
return AgentState.IDLE;
}
// ==================== 缓存管理 ====================
@ -473,14 +495,44 @@ public class AgentService {
// ==================== 内部方法 ====================
private BaseAgent getOrBuildAgent(Long agentId) {
return agentInstances.computeIfAbsent(agentId, id -> {
AgentEntity entity = getAgent(id);
if (!Boolean.TRUE.equals(entity.getEnabled())) {
throw new MateClawException("err.agent.disabled", "Agent 已禁用: " + entity.getName());
/**
* Resolve (and cache) the Agent graph for a conversation, honouring the
* conversation's pinned model. Conversations with no pin IM channels,
* cron, sub-tasks, or rows not yet created resolve to the shared Agent /
* global-default graph.
*/
private BaseAgent getOrBuildAgentForConversation(Long agentId, String conversationId) {
String provider = null;
String modelName = null;
if (conversationId != null && !conversationId.isBlank()) {
ConversationEntity conv = conversationMapper.selectOne(
new LambdaQueryWrapper<ConversationEntity>()
.eq(ConversationEntity::getConversationId, conversationId));
if (conv != null) {
provider = conv.getModelProvider();
modelName = conv.getModelName();
}
return agentGraphBuilder.build(entity);
});
}
return getOrBuildAgent(agentId, provider, modelName);
}
private BaseAgent getOrBuildAgent(Long agentId) {
return getOrBuildAgent(agentId, null, null);
}
private BaseAgent getOrBuildAgent(Long agentId, String modelProvider, String modelName) {
boolean pinned = modelProvider != null && !modelProvider.isBlank()
&& modelName != null && !modelName.isBlank();
String modelKey = pinned ? modelProvider + "::" + modelName : "";
return agentInstances
.computeIfAbsent(agentId, id -> new ConcurrentHashMap<>())
.computeIfAbsent(modelKey, key -> {
AgentEntity entity = getAgent(agentId);
if (!Boolean.TRUE.equals(entity.getEnabled())) {
throw new MateClawException("err.agent.disabled", "Agent 已禁用: " + entity.getName());
}
return agentGraphBuilder.build(entity, modelProvider, modelName);
});
}
// ==================== StreamDelta ====================

View File

@ -520,6 +520,11 @@ public class ChatController {
AtomicBoolean finalized = new AtomicBoolean(false);
try {
conversationService.getOrCreateConversation(conversationId, agentId, username, workspaceId);
// Pin the model the user picked for this conversation so later
// turns (and the runtime model resolver) honour it independently
// of every other conversation.
conversationService.updateConversationModel(conversationId,
request.getModelProvider(), request.getModelName());
List<MessageContentPart> requestParts = normalizeRequestParts(request);
String promptText = buildPromptText(message, requestParts);
conversationService.saveMessage(conversationId, "user", message, requestParts);
@ -1151,6 +1156,14 @@ public class ChatController {
private Long lastEventId;
/** 思考深度off / low / medium / high / maxnull 表示跟随 Agent 默认 */
private String thinkingLevel;
/**
* Provider id of the model the user picked for this conversation.
* Paired with {@link #modelName}; null means "no per-conversation
* override use the agent / global default".
*/
private String modelProvider;
/** Model id the user picked for this conversation. See {@link #modelProvider}. */
private String modelName;
}
/**

View File

@ -323,6 +323,24 @@ public class ModelConfigService {
return getDefaultModel();
}
/**
* Resolve an enabled model by its exact (provider, modelName) pair. Unlike
* {@link #resolveModel(String)} this does NOT fall back to the default
* it returns {@code null} when nothing matches, leaving the fallback
* decision to the caller. Used to honour a per-conversation model pin while
* still degrading gracefully when that model was later disabled or deleted.
*/
public ModelConfigEntity findEnabledModel(String provider, String modelName) {
if (!StringUtils.hasText(provider) || !StringUtils.hasText(modelName)) {
return null;
}
return modelConfigMapper.selectOne(new LambdaQueryWrapper<ModelConfigEntity>()
.eq(ModelConfigEntity::getProvider, provider)
.eq(ModelConfigEntity::getModelName, modelName)
.eq(ModelConfigEntity::getEnabled, true)
.last("LIMIT 1"));
}
private void validateModel(ModelConfigEntity entity, Long currentId) {
if (!StringUtils.hasText(entity.getName())) {
throw new MateClawException("err.llm.name_required", "模型名称不能为空");

View File

@ -349,6 +349,32 @@ public class ConversationService {
}
}
/**
* Pin the model a conversation uses. A blank provider or model id is a
* no-op (no override supplied the conversation keeps inheriting the
* agent / global default). The write is skipped when the stored value
* already matches, so persisting the same model on every turn costs only
* a SELECT.
*/
@Transactional
public void updateConversationModel(String conversationId, String modelProvider, String modelName) {
if (modelProvider == null || modelProvider.isBlank()
|| modelName == null || modelName.isBlank()) {
return;
}
ConversationEntity conv = conversationMapper.selectOne(new LambdaQueryWrapper<ConversationEntity>()
.eq(ConversationEntity::getConversationId, conversationId));
if (conv == null) {
return;
}
if (modelProvider.equals(conv.getModelProvider()) && modelName.equals(conv.getModelName())) {
return;
}
conv.setModelProvider(modelProvider);
conv.setModelName(modelName);
conversationMapper.updateById(conv);
}
/**
* Persist an assistant placeholder marker only when the last message is a
* user turn (i.e., the assistant never got to reply). Used by the admin

View File

@ -51,6 +51,16 @@ public class ConversationEntity {
/** Pin flag: 0 = normal, 1 = pinned to the top of the sidebar list */
private Integer pinned;
/**
* Provider id of the model this conversation is pinned to. NULL means
* "inherit" fall back to the agent's model override, then the global
* default. Paired with {@link #modelName}.
*/
private String modelProvider;
/** Model id this conversation is pinned to. See {@link #modelProvider}. */
private String modelName;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;

View File

@ -66,6 +66,8 @@ public class ConversationVO extends ConversationEntity {
vo.setLastActiveTime(entity.getLastActiveTime());
vo.setWorkspaceId(entity.getWorkspaceId());
vo.setPinned(entity.getPinned() != null ? entity.getPinned() : 0);
vo.setModelProvider(entity.getModelProvider());
vo.setModelName(entity.getModelName());
vo.setCreateTime(entity.getCreateTime());
vo.setUpdateTime(entity.getUpdateTime());
// 补充关联字段

View File

@ -0,0 +1,7 @@
-- Per-conversation model selection. Each conversation pins the LLM it uses, so
-- switching the model in one chat no longer changes every other conversation.
-- NULL means "inherit": fall back to the agent's model override, then to the
-- global default model.
ALTER TABLE mate_conversation ADD COLUMN IF NOT EXISTS model_provider VARCHAR(64);
ALTER TABLE mate_conversation ADD COLUMN IF NOT EXISTS model_name VARCHAR(128);

View File

@ -0,0 +1,29 @@
-- See the H2 file for context. MySQL 8.0 doesn't support
-- `ADD COLUMN IF NOT EXISTS`, so the existence check goes through
-- INFORMATION_SCHEMA + a prepared statement.
SET @col_exists := (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'mate_conversation'
AND COLUMN_NAME = 'model_provider'
);
SET @ddl := IF(@col_exists = 0,
'ALTER TABLE mate_conversation ADD COLUMN model_provider VARCHAR(64)',
'SELECT 1');
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @col_exists := (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'mate_conversation'
AND COLUMN_NAME = 'model_name'
);
SET @ddl := IF(@col_exists = 0,
'ALTER TABLE mate_conversation ADD COLUMN model_name VARCHAR(128)',
'SELECT 1');
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;

View File

@ -15,6 +15,7 @@ import vip.mate.agent.repository.AgentMapper;
import vip.mate.memory.MemoryProperties;
import vip.mate.memory.service.MemoryRecallTracker;
import vip.mate.memory.spi.MemoryManager;
import vip.mate.workspace.conversation.repository.ConversationMapper;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
@ -41,6 +42,7 @@ class LifecycleRecallCountIT {
@Mock private MemoryManager memoryManager;
@Mock private ApplicationEventPublisher eventPublisher;
@Mock private BaseAgent mockAgent;
@Mock private ConversationMapper conversationMapper;
private MemoryProperties props;
private AgentService agentService;
@ -50,14 +52,14 @@ class LifecycleRecallCountIT {
props = new MemoryProperties();
MemoryLifecycleMediator mediator = new MemoryLifecycleMediator(memoryManager, eventPublisher);
agentService = new AgentService(agentMapper, agentGraphBuilder,
memoryRecallTracker, mediator, props);
memoryRecallTracker, mediator, props, conversationMapper);
// Stub agent resolution (lenient for structural-only tests)
AgentEntity entity = new AgentEntity();
entity.setId(1L);
entity.setEnabled(true);
lenient().when(agentMapper.selectById(1L)).thenReturn(entity);
lenient().when(agentGraphBuilder.build(any(AgentEntity.class))).thenReturn(mockAgent);
lenient().when(agentGraphBuilder.build(any(AgentEntity.class), any(), any())).thenReturn(mockAgent);
lenient().when(mockAgent.chat(any(), any())).thenReturn("reply");
}

View File

@ -147,6 +147,10 @@ export interface SendMessageOptions {
contentParts?: MessageContentPart[]
/** Thinking depth: off / low / medium / high / max */
thinkingLevel?: string
/** Provider id of the model picked for this conversation. */
modelProvider?: string
/** Model id picked for this conversation. Paired with modelProvider. */
modelName?: string
}
export function useChat(options: UseChatOptions): UseChatReturn {
@ -1644,6 +1648,12 @@ export function useChat(options: UseChatOptions): UseChatReturn {
if (options.thinkingLevel) {
body.thinkingLevel = options.thinkingLevel
}
// Per-conversation model: the backend pins it onto the conversation row
// so switching the model here never leaks into other conversations.
if (options.modelProvider && options.modelName) {
body.modelProvider = options.modelProvider
body.modelName = options.modelName
}
await stream.connect(body)
} catch (e) {
error.value = e instanceof Error ? e : new Error(String(e))

View File

@ -66,6 +66,10 @@ export interface Conversation {
streamStatus?: 'idle' | 'running'
source?: string
pinned?: number
/** Provider id of the model this conversation is pinned to (per-conversation model). */
modelProvider?: string
/** Model id this conversation is pinned to. Paired with modelProvider. */
modelName?: string
lastActiveTime?: string
updateTime?: string
createTime?: string

View File

@ -342,7 +342,11 @@ const providersUnavailable = ref(false)
// otherwise the model selector trigger would show its fallback even
// though there IS an active model.
const enabledModels = ref<ModelConfig[]>([])
// The model the CURRENT conversation uses. Per-conversation switching it
// never leaks into other conversations (see selectModel / applyConversationModel).
const activeModels = ref<ActiveModelsInfo | null>(null)
// Global default model seeds the selector for conversations with no pin yet.
const globalDefaultModel = ref<{ providerId: string; model: string } | null>(null)
const pendingAttachments = ref<ChatAttachment[]>([])
const uploadingAttachment = ref(false)
@ -379,18 +383,30 @@ function onAgentPicked(value: string | number | null) {
}
}
async function selectModel(value: string) {
function selectModel(value: string) {
const [providerId, model] = value.split('::')
if (!providerId || !model) return
modelSaving.value = true
try {
const res: any = await modelApi.setActive({ providerId, model })
activeModels.value = res.data || { activeLlm: { providerId, model } }
await loadModelState()
} catch (e) {
mcToast.error(t('chat.switchModelFailed'))
} finally {
modelSaving.value = false
// Per-conversation model: switching here only affects THIS conversation.
// The backend pins it onto the conversation row when the next message is
// sent (see sendChatMessage payload); we also patch the local list entry so
// re-opening the conversation restores the choice without a round-trip.
activeModels.value = { activeLlm: { providerId, model } }
const conv = conversations.value.find(c => c.conversationId === currentConversationId.value)
if (conv) {
conv.modelProvider = providerId
conv.modelName = model
}
}
/**
* Point the model selector at a conversation's pinned model, or the global
* default when the conversation has no pin yet (fresh chat, IM, cron).
*/
function applyConversationModel(conv?: Conversation | null) {
if (conv?.modelProvider && conv?.modelName) {
activeModels.value = { activeLlm: { providerId: conv.modelProvider, model: conv.modelName } }
} else if (globalDefaultModel.value) {
activeModels.value = { activeLlm: { ...globalDefaultModel.value } }
}
}
@ -1013,7 +1029,16 @@ async function loadModelState() {
modelApi.listEnabled(),
])
defaultModel.value = defaultRes.data || null
activeModels.value = activeRes.data || null
const ga = activeRes.data?.activeLlm
globalDefaultModel.value = ga?.providerId && ga?.model
? { providerId: ga.providerId, model: ga.model }
: null
// Seed the selector when no conversation has set it yet (fresh chat, or
// before a conversation is selected). A conversation that already has a
// model keeps it selectConversation/applyConversationModel own that.
if (!activeModels.value && globalDefaultModel.value) {
activeModels.value = { activeLlm: { ...globalDefaultModel.value } }
}
enabledModels.value = enabledRes.data || []
} catch (e) {
mcToast.error(t('chat.loadModelFailed'))
@ -1177,6 +1202,8 @@ async function selectConversation(conv: Conversation) {
}
currentConversationId.value = conv.conversationId
selectedAgentId.value = conv.agentId || selectedAgentId.value
// Restore this conversation's pinned model into the selector.
applyConversationModel(conv)
// Reset cron placeholder state up front; the immediate fetch below repopulates
// it for cron conversations so the user doesn't wait up to 4s for the next tick.
activeCronRuns.value = []
@ -1308,6 +1335,8 @@ function newConversation() {
resetForNewConversation()
currentConversationId.value = `conv_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
messages.value = []
// A fresh conversation starts on the global default model.
applyConversationModel()
}
// The sidebar performs the delete API call(s) and emits the removed ids.
@ -1475,6 +1504,8 @@ async function handleSendMessage(content: string) {
agentId: selectedAgentId.value,
contentParts,
thinkingLevel: thinkingLevel.value,
modelProvider: activeModels.value?.activeLlm?.providerId,
modelName: activeModels.value?.activeLlm?.model,
attachments: outgoingAttachments.map(a => ({
type: 'file' as const,
fileUrl: a.url,