Ctrl K
ComunidadGuías…

docs/plan/2026-06-08-session-model-decoupling-design.md

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# 模型与 Agent 解耦 — 会话级模型选择技术方案

> 将模型选择从 Agent 维度迁移到 Session 维度,支持创建会话时选择模型、会话进行中随时切换模型。

## 1. 背景与目标

**现状**:模型通过 `Agent.modelId` 绑定在 Agent 上,同一 Agent 的所有会话只能使用同一个模型。

**目标**:
- Agent 不再绑定模型,模型选择下沉到 Session 维度
- `llm_model` 表增加 `is_default` 字段,作为新建会话的默认模型
- 会话进行中支持随时切换模型(暂不处理上下文窗口差异)
- Admin 端移除 Agent 的模型选择,增加模型默认标记管理

## 2. 数据模型变更

### 2.1 Flyway 迁移 — `V034__session_model_decoupling.sql`

```sql
-- 1. session 表增加 model_id(可 NULL,NULL 表示使用默认模型)
ALTER TABLE `session` ADD COLUMN `model_id` BIGINT NULL COMMENT '当前会话使用的模型 ID,NULL 表示使用默认模型';

-- 2. llm_model 表增加 is_default
ALTER TABLE `llm_model` ADD COLUMN `is_default` TINYINT NOT NULL DEFAULT 0 COMMENT '是否默认模型:0=否 1=是';

-- 3. 将当前 Agent 绑定的模型设为默认(取第一个有 modelId 的 Agent 关联的模型)
UPDATE `llm_model` SET `is_default` = 1
WHERE `id` = (SELECT `model_id` FROM `agent` WHERE `model_id` IS NOT NULL LIMIT 1);

-- 4. 删除 agent 表的 model_id 列
ALTER TABLE `agent` DROP COLUMN `model_id`;
```

> 初版开发阶段,无需数据迁移兼容。直接 DROP agent.model_id 即可。

### 2.2 实体变更

| 实体 | 文件 | 变更 |
|---|---|---|
| `Session` | `backend/.../session/entity/Session.java` | 新增 `private Long modelId;` |
| `Agent` | `backend/.../agent/entity/Agent.java` | 删除 `private Long modelId;` |
| `LlmModel` | `backend/.../model/entity/LlmModel.java` | 新增 `private Integer isDefault;` |

### 2.3 SessionVO / AgentVO 变更

**SessionVO**(`SessionController.java` 内部类)新增:
```java
private Long modelId;
private String modelName;   // 关联查询填充
private Integer modelSupportsVision;  // 用于前端判断是否可发图片
```

**AgentVO**(`AgentController.java` 内部类)移除:
```java
// 删除以下两个字段
private Long modelId;
private String modelName;
```

## 3. 后端改动

### 3.1 HarnessService — 模型解析链路变更

**文件**:`backend/.../harness/core/HarnessService.java` 第 115-142 行

**改动前**:
```java
LlmModel llmModel = llmModelMapper.selectById(agent.getModelId());
```

**改动后**:
```java
// 优先使用 Session 级别的 modelId,fallback 到默认模型
Long modelId = session.getModelId();
LlmModel llmModel;
if (modelId != null) {
    llmModel = llmModelMapper.selectById(modelId);
} else {
    // 查找默认模型
    llmModel = llmModelMapper.selectOne(
        new QueryWrapper<LlmModel>().eq("is_default", 1).eq("status", 1));
}
if (llmModel == null) {
    throw new BusinessException(ErrorCode.MODEL_NOT_FOUND);
}
```

### 3.2 StreamingWsHandler — Vision 检查变更

**文件**:`backend/.../session/ws/StreamingWsHandler.java` 第 221-231 行、第 516-526 行

两处 vision 检查都需要改为从 Session 获取模型:

**改动前**(两处相同逻辑):
```java
Agent agent = agentMapper.selectById(session.getAgentId());
if (agent != null) {
    LlmModel model = llmModelMapper.selectById(agent.getModelId());
    // ...
}
```

**改动后**:
```java
LlmModel model = resolveSessionModel(session);
if (model == null || model.getSupportsVision() == null || model.getSupportsVision() != 1) {
    registry.send(userId, WsEvent.of("error", sessionId,
            Map.of("message", "当前模型不支持图片输入,请切换支持视觉的模型")));
    return;
}
```

抽取公共方法 `resolveSessionModel(Session session)` 复用 3.1 的逻辑。

### 3.3 AgentController — 移除模型相关逻辑

**文件**:`backend/.../agent/controller/AgentController.java`

- `CreateAgentRequest`:移除 `modelId` 字段
- `UpdateAgentRequest`:移除 `modelId` 字段
- `AgentVO`:移除 `modelId`、`modelName` 字段
- `toVO()` 方法:移除 `modelId` 和 `modelName` 的填充逻辑(第 87-97 行)
- `createAgent()` / `updateAgent()`:移除 `request.getModelId()` 参数传递

### 3.4 AgentService — 移除模型参数

**文件**:`backend/.../agent/service/AgentService.java`

- `createAgent()` 方法签名移除 `modelId` 参数
- `updateAgent()` 方法签名移除 `modelId` 参数

### 3.5 SessionController — 新增模型相关 API

**文件**:`backend/.../session/controller/SessionController.java`

#### 3.5.1 CreateSessionRequest 增加 modelId

```java
@Data
public static class CreateSessionRequest {
    private Long agentId;
    private String title;
    private String executionMode;
    private String workspace;
    private String permissionLevel;
    private Long modelId;       // 新增,可选
}
```

#### 3.5.2 UpdateSessionRequest 增加 modelId

```java
@Data
public static class UpdateSessionRequest {
    private String title;
    private String summary;
    private String projectKey;
    private String permissionLevel;
    private Long modelId;       // 新增,支持随时切换
}
```

#### 3.5.3 SessionVO 增加模型信息

```java
@Data
public static class SessionVO {
    // ... 现有字段 ...
    private Long modelId;
    private String modelName;
    private Integer modelSupportsVision;
}
```

#### 3.5.4 toVO() 方法增加模型信息填充

```java
private SessionVO toVO(Session session) {
    // ... 现有逻辑 ...
    if (session.getModelId() != null) {
        LlmModel model = llmModelMapper.selectById(session.getModelId());
        if (model != null) {
            vo.setModelId(model.getId());
            vo.setModelName(model.getName());
            vo.setModelSupportsVision(model.getSupportsVision());
        }
    } else {
        // 使用默认模型
        LlmModel defaultModel = getDefaultModel();
        if (defaultModel != null) {
            vo.setModelId(defaultModel.getId());
            vo.setModelName(defaultModel.getName());
            vo.setModelSupportsVision(defaultModel.getSupportsVision());
        }
    }
    return vo;
}
```

### 3.6 ModelController — 支持默认标记管理

**文件**:`backend/.../model/controller/ModelController.java`

- `CreateModelRequest` / `UpdateModelRequest` 增加 `isDefault` 字段
- `ModelVO` 增加 `isDefault` 字段
- 设置默认模型时,需先将其他模型的 `is_default` 置 0(应用层保证唯一性)

### 3.7 ModelService — 默认模型管理逻辑

**文件**:`backend/.../model/service/ModelService.java`

`createModel()` / `updateModel()` 中增加默认标记处理:
```java
if (isDefault != null && isDefault == 1) {
    // 清除其他默认标记
    LlmModel current = new LlmModel();
    current.setIsDefault(0);
    llmModelMapper.update(current, new QueryWrapper<LlmModel>().eq("is_default", 1));
}
```

## 4. 前端改动(Desktop)

### 4.1 Session Store — 类型与方法变更

**文件**:`desktop/src/stores/session.ts`

Session 接口新增:
```typescript
export interface Session {
  // ... 现有字段 ...
  modelId?: number
  modelName?: string
  modelSupportsVision?: number
}
```

`createSession` 方法增加 `modelId` 参数:
```typescript
async function createSession(agentId: string, executionMode: string, workspace?: string, modelId?: number) {
    const { data } = await api.post('/sessions', {
      agentId,
      executionMode,
      workspace: workspace || undefined,
      modelId: modelId || undefined
    })
    // ...
}
```

新增 `updateSessionModel` 方法:
```typescript
async function updateSessionModel(sessionId: string, modelId: number) {
    await api.patch(`/sessions/${sessionId}`, { modelId })
    // 更新本地 store
    const sid = String(sessionId)
    const idx = sessions.value.findIndex(s => String(s.id) === sid)
    if (idx !== -1) {
      sessions.value[idx].modelId = modelId
    }
}
```

### 4.2 Agent Store — 移除模型字段

**文件**:`desktop/src/stores/agent.ts`

Agent 接口移除:
```typescript
export interface Agent {
  // 删除以下两个字段
  // modelId?: number
  // modelName?: string
}
```

### 4.3 ChatInput — 模型名改为可交互选择器

**文件**:`desktop/src/components/chat/ChatInput.vue`

将第 94 行的纯展示 `<span>` 改为可点击的选择器:

```html
<div class="toolbar-right">
  <el-popover trigger="click" :width="280" v-model:visible="modelPopoverVisible">
    <template #reference>
      <span class="model-name clickable">{{ currentModelName }}</span>
    </template>
    <div class="model-selector">
      <div
        v-for="m in availableModels"
        :key="m.id"
        class="model-option"
        :class="{ active: m.id === currentModelId }"
        @click="handleModelSwitch(m.id)"
      >
        <span class="model-option-name">{{ m.name }}</span>
        <el-tag v-if="m.supportsVision" size="small" type="success">视觉</el-tag>
      </div>
    </div>
  </el-popover>
</div>
```

Props 变更:
```typescript
const props = defineProps<{
  // ... 现有 props ...
  modelId?: number
  modelName?: string
  modelSupportsVision?: number
}>()

const emit = defineEmits<{
  // ... 现有 events ...
  'update:modelId': [modelId: number]
}>()
```

新增逻辑:
- 加载可用模型列表(调用 `GET /v1/models?status=1`)
- 切换模型时 emit `update:modelId` 事件

### 4.4 TaskView — 模型数据源变更

**文件**:`desktop/src/views/task/TaskView.vue`

将模型数据源从 `agentStore.activeAgent` 改为 `sessionStore`:

```html
<!-- 改动前 -->
<ChatInput
  :model-name="agentStore.activeAgent?.modelName || ''"
/>

<!-- 改动后 -->
<ChatInput
  :model-id="currentSession?.modelId"
  :model-name="currentSession?.modelName || ''"
  :model-supports-vision="currentSession?.modelSupportsVision"
  @update:model-id="handleModelSwitch"
/>
```

`handleModelSwitch` 方法:
```typescript
async function handleModelSwitch(modelId: number) {
  if (!currentSession.value) return
  await sessionStore.updateSessionModel(currentSession.value.id, modelId)
  // 重新获取 session 详情以更新 modelName 等信息
  await sessionStore.fetchSession(currentSession.value.id)
}
```

### 4.5 新建会话流程 — 增加模型选择

在新建会话的 UI 流程中(`NewTaskFlow` 或类似组件),增加模型选择步骤:

- 展示可用模型列表(`GET /v1/models?status=1`)
- 默认选中 `isDefault=1` 的模型
- 用户可切换,选择结果传给 `sessionStore.createSession(..., modelId)`

## 5. 前端改动(Admin)

### 5.1 AgentFormDialog — 移除模型选择

**文件**:`admin/src/views/agent/AgentFormDialog.vue`

删除第 24-33 行的模型选择 `<el-form-item>`:
```html
<!-- 删除 -->
<el-form-item label="模型" prop="modelId">
  <el-select v-model="form.modelId" ...>
    ...
  </el-select>
</el-form-item>
```

同时清理 `form` 中的 `modelId` 字段、`loadOptions` 中的模型加载(如果仅用于 Agent 表单)、`resetForm` 中的 `modelId` 重置。

### 5.2 ModelListView — 增加默认标记列

**文件**:`admin/src/views/model/ModelListView.vue`

表格增加"默认"列:
```html
<el-table-column label="默认" width="80" align="center">
  <template #default="{ row }">
    <el-tag :type="row.isDefault ? 'warning' : 'info'" size="small">
      {{ row.isDefault ? '默认' : '' }}
    </el-tag>
  </template>
</el-table-column>
```

### 5.3 ModelFormDialog — 增加默认开关

**文件**:`admin/src/views/model/ModelFormDialog.vue`

表单增加"设为默认"开关:
```html
<el-form-item label="默认模型">
  <el-switch v-model="form.isDefault" />
</el-form-item>
```

`form` 增加 `isDefault: false` 字段,提交时转为 `0/1`。

## 6. 改动文件清单

### 后端(10 个文件)

| 文件 | 改动类型 | 说明 |
|---|---|---|
| `V034__session_model_decoupling.sql` | 新增 | Flyway 迁移脚本 |
| `Session.java` | 修改 | 增加 `modelId` 字段 |
| `Agent.java` | 修改 | 删除 `modelId` 字段 |
| `LlmModel.java` | 修改 | 增加 `isDefault` 字段 |
| `HarnessService.java` | 修改 | 模型解析链路:Session 优先,fallback 默认 |
| `StreamingWsHandler.java` | 修改 | Vision 检查改为从 Session 获取模型 |
| `SessionController.java` | 修改 | Request/VO 增加 modelId,toVO 填充模型信息 |
| `AgentController.java` | 修改 | 移除 modelId 相关字段和逻辑 |
| `AgentService.java` | 修改 | 移除 modelId 参数 |
| `ModelController.java` / `ModelService.java` | 修改 | 增加 isDefault 管理逻辑 |

### 前端 Desktop(5 个文件)

| 文件 | 改动类型 | 说明 |
|---|---|---|
| `desktop/src/stores/session.ts` | 修改 | Session 接口增加模型字段,createSession 增加 modelId 参数,新增 updateSessionModel |
| `desktop/src/stores/agent.ts` | 修改 | Agent 接口移除 modelId/modelName |
| `desktop/src/components/chat/ChatInput.vue` | 修改 | 模型名改为可交互选择器 |
| `desktop/src/views/task/TaskView.vue` | 修改 | 模型数据源改为 session 维度 |
| `desktop/src/components/task/NewTaskFlow.vue`(或类似) | 修改 | 新建会话增加模型选择步骤 |

### 前端 Admin(3 个文件)

| 文件 | 改动类型 | 说明 |
|---|---|---|
| `admin/src/views/agent/AgentFormDialog.vue` | 修改 | 移除模型选择下拉 |
| `admin/src/views/model/ModelListView.vue` | 修改 | 增加默认标记列 |
| `admin/src/views/model/ModelFormDialog.vue` | 修改 | 增加默认模型开关 |

## 7. 不改动的部分

以下模块只需消费 `LlmModelConfig`,不感知模型来源,**无需改动**:

- `AgentLoop.java` — 从 `AgentExecutionContext.getModelConfig()` 取配置
- `PromptEngine.java` — 只构建 messages 和 tools
- `LlmAdapter.java` / `OpenAiLlmAdapter.java` — 只认 `LlmModelConfig`
- `CompactionService.java` — 从 context 取 `modelConfig.getModelId()`

## 8. 注意事项

1. **默认模型唯一性**:应用层保证 `is_default=1` 只有一条记录。设置新默认时先清除旧默认。
2. **Session 无 modelId 的 fallback**:`HarnessService.buildContext()` 中,当 `session.getModelId()` 为 NULL 时,查询 `is_default=1` 的模型。如果没有任何默认模型,抛出 `MODEL_NOT_FOUND` 异常。
3. **切换模型后的即时生效**:切换模型后,下一次 Agent Loop 使用新模型。无需重启会话。
4. **vision 检查的时效性**:切换到不支持 vision 的模型后,前端应根据 `modelSupportsVision` 字段禁用图片上传按钮。