拆分顺序:数据契约 → 模型能力 → 路由策略 → 路由器 → 健康状态 → 调用审计 → 调用链 → 管理端 → 验证 每个 Task 都先补 Red 测试,再做最小实现并回填
execution-log.md;没有 HARD-GATE 不进入编码。
/apply ai-model-routing-governance,Spec 状态从 propose 切换为 apply;AGENTS.md、当前四份变更文档、长期记忆和自动化测试标准;v20.19.0;forge-plugin-ai 现有 51 tests 基线,确认测试没有被 Maven skip;| Task | 状态 | 结果 |
|---|---|---|
| 1–3 | 完成 | V1.0.18、能力目录、策略/候选 CRUD、批量查询与逻辑删除已实现 |
| 4–5 | 完成 | 确定性 Router、租户校验、模型级 HealthRegistry、Lease 和安全失败分类已实现 |
| 6–7 | 完成 | 调用审计、Token/成本/P95、90 天保留、同步/流式接入且无失败后换模型 |
| 8–9 | 完成 | Agent PINNED/POLICY、模型配置、路由策略与调用记录最小界面已实现 |
| 10 | 完成(条件项除外) | 63 tests、Admin package、ESLint/build、XML/SQL/安全扫描通过;实库与浏览器人工验收待 review |
下方粒度更细的未勾选项表示尚无独立自动化或人工验证证据,不等同于对应功能未实现;review 时按风险优先补齐。
AiModelFailureClassifier 识别 cause chain、内容安全错误码和调用取消,内容安全/取消不计入模型故障;AiClientRoutingGovernanceTest、AiModelRoutePolicyServiceTest、AiAgentServiceTest、AiInvocationLogRetentionJobTest,并扩充 Router、Health、Provider 和失败分类回归。forge-server/db/migration/V1.0.18__add_ai_model_routing_governance.sql — 新建迁移;forge-plugin-ai/src/main/java/com/mdframe/forge/plugin/ai/model/domain/AiModel.java — 增加 contextWindow 和价格字段;forge-plugin-ai/src/main/java/com/mdframe/forge/plugin/ai/agent/domain/AiAgent.java — 增加 modelSelectionMode、routePolicyId;forge-plugin-ai/src/main/java/com/mdframe/forge/plugin/ai/routing/constant/AiModelCapabilityCode.java — 稳定能力代码;forge-plugin-ai/src/main/java/com/mdframe/forge/plugin/ai/routing/constant/AiModelSelectionMode.java — PINNED/POLICY。forge-plugin-ai/src/main/java/com/mdframe/forge/plugin/ai/routing/constant/AiModelRouteSource.java — REQUEST/PINNED/PROVIDER_DEFAULT/POLICY;forge-plugin-ai/src/main/java/com/mdframe/forge/plugin/ai/health/AiModelHealthRegistry.java、AiModelHealthLease.java、AiModelHealthKey.java、AiModelHealthSnapshot.java、AiModelHealthStatus.java、AiModelFailureCategory.java — Task 4 可依赖的完整健康 SPI 契约,默认实现与分类器留到 Task 5。public record AiModelHealthKey(Long tenantId, Long providerPk, Long modelPk) { }
public interface AiModelHealthRegistry {
AiModelHealthSnapshot snapshot(AiModelHealthKey key);
Optional<AiModelHealthLease> tryAcquire(AiModelHealthKey key);
AiModelHealthLease acquireManualProbe(AiModelHealthKey key);
void reset(AiModelHealthKey key);
void resetProvider(Long tenantId, Long providerPk);
}
public interface AiModelHealthLease extends AutoCloseable {
AiModelHealthKey key();
void success();
void failure(AiModelFailureCategory category);
void cancel();
void abort();
@Override default void close() { abort(); }
}
- **实施步骤**:
- [ ] 编写常量/选择模式失败测试,覆盖 null/blank→PINNED、unknown 非空值失败关闭;
- [ ] 执行 testCompile,确认缺少新类型而 Red;
- [ ] 编写 V1.0.18,所有 DDL 使用 information_schema,字典/资源使用 NOT EXISTS,tenantId=1;
- [ ] 新表补齐标准审计字段;模型能力、策略、候选增加 del_flag、logic_delete_active 与活跃唯一键;
- [ ] 调用日志明确为技术日志,增加 requestId 唯一键和 tenant/model/agent 时间索引;
- [ ] 实现枚举/实体字段并运行目标测试 Green;
- [ ] 执行 Flyway placeholder、tenantId、版本唯一性和防重复静态检查。
- **验收标准**:迁移可重复防护,历史 Agent 默认 PINNED,未修改现有供应商/模型选择结果。
## Task 2: 实现模型能力与价格配置
- **目标**:模型保存时事务化维护能力集合和治理数值,查询时返回完整配置。
- **涉及文件**:
- `model/capability/domain/AiModelCapability.java` — 新实体;
- `model/capability/mapper/AiModelCapabilityMapper.java`;
- `model/capability/mapper/AiModelCapabilityMapper.xml` — 按 modelIds 批量查询、逻辑删除/重建;
- `model/dto/AiModelSaveDTO.java` — 替代 Controller 直接接实体;
- `model/vo/AiModelVO.java` — 返回能力、治理字段和只读健康状态;
- `model/service/AiModelService.java`、`model/controller/AiModelController.java`;
- `model/service/AiModelServiceTest.java`。
- **关键签名**:
```java
@Transactional(rollbackFor = Exception.class)
public Long addModel(AiModelSaveDTO dto) { }
@Transactional(rollbackFor = Exception.class)
public void updateModel(AiModelSaveDTO dto) { }
public Map<Long, Set<String>> selectEnabledCapabilityCodes(Collection<Long> modelIds) { }
routing/domain/AiModelRoutePolicy.java、AiModelRouteTarget.java;routing/dto/AiModelRoutePolicySaveDTO.java;routing/vo/AiModelRoutePolicyVO.java;routing/mapper/AiModelRoutePolicyMapper.java/.xml;routing/mapper/AiModelRouteTargetMapper.java/.xml;routing/service/AiModelRoutePolicyService.java;routing/controller/AiModelRoutingController.java;routing/service/AiModelRoutePolicyServiceTest.java。@Transactional(rollbackFor = Exception.class) public void updatePolicy(AiModelRoutePolicySaveDTO dto) { }
public Page pagePolicy(int pageNum, int pageSize, String keyword, String status) { }
- **实施步骤**:
- [ ] 先写 Red 测试:policyCode 在租户内未逻辑删除记录中唯一(status 不影响)、空候选拒绝、重复目标拒绝、跨租户 modelId 拒绝;
- [ ] XML 实现策略分页、详情聚合、候选批量查询和逻辑删除;
- [ ] 保存时校验 requiredCapabilities、目标模型/供应商状态与租户归属;
- [ ] 更新采用“策略主表更新 + 旧候选逻辑删除 + 新候选批量插入”的单事务;
- [ ] 删除被 POLICY Agent 使用的策略时失败关闭;
- [ ] 增加加密 CRUD 与权限注解;
- [ ] 运行服务测试和 XML 语法检查。
- **验收标准**:策略只包含管理员显式候选,跨租户引用和悬空引用均被拒绝。
## Task 4: 实现确定性 AiModelRouter 并接入 Resolver
- **目标**:把模型选择从字符串解析升级为可解释 RouteDecision,同时保持固定模式兼容。
- **涉及文件**:
- `routing/AiModelRouter.java`;
- `routing/PolicyBasedAiModelRouter.java`;
- `routing/RouteRequest.java`、`RouteDecision.java`、`RoutedInvocation.java`、`RouteCandidateSkip.java`;
- `routing/constant/AiModelRouteReason.java` — REQUEST_EXPLICIT_PAIR/REQUEST_PROVIDER_DEFAULT/REQUEST_MODEL_WITH_RESOLVED_PROVIDER/PINNED_MODEL/PROVIDER_DEFAULT/POLICY_PRIORITY;
- `routing/dto/AiModelRoutePreviewDTO.java`、`routing/vo/AiModelRoutePreviewVO.java`;
- `routing/mapper/AiModelRoutingQueryMapper.java/.xml` — 一次查询候选模型、供应商和 Adapter 元数据;
- `routing/controller/AiModelRoutingController.java` — 增加 preview 入口;
- `client/AiInvocationResolver.java`;
- `provider/mapper/AiProviderMapper.java/.xml`、`provider/service/AiProviderService.java` — `requireEnabledDefaultProvider` 恰好一条契约;
- `routing/PolicyBasedAiModelRouterTest.java`;
- `client/AiInvocationResolverTest.java`;
- `provider/service/AiProviderServiceTest.java`。
- **关键签名**:
```java
public interface AiModelRouter {
RoutedInvocation route(RouteRequest request);
RouteDecision preview(RouteRequest request);
}
public record RouteDecision(
AiProvider provider,
AiModel model,
AiModelRouteSource source,
AiModelRouteReason reason,
Long policyId,
List<RouteCandidateSkip> skippedCandidates) { }
public record RoutedInvocation(
RouteDecision decision,
AiModelHealthLease healthLease) implements AutoCloseable {
@Override public void close() { healthLease.close(); }
}
health/AiModelHealthRegistry.java;health/InMemoryAiModelHealthRegistry.java;health/AiModelHealthKey.java、AiModelHealthSnapshot.java、AiModelHealthStatus.java;health/AiModelFailureClassifier.java — 使用 Task 1 的 AiModelFailureCategory 契约实现安全分类;client/CircuitBreaker.java — 删除或变为兼容委托,不保留第二套状态;provider/service/AiProviderService.java、provider/support/AiProviderCacheEvictionScheduler.java;health/AiModelConnectionTestService.java — 服务端加载模型与供应商凭据进行低 Token 测试;model/controller/AiModelController.java — 新增 POST /ai/model/{id}/test;InMemoryAiModelHealthRegistryTest、AiModelFailureClassifierTest、Provider 测试。java
public final class InMemoryAiModelHealthRegistry implements AiModelHealthRegistry { }
resetProvider(tenantId, providerPk);模型更新调用单键 reset,避免 Service 互相注入或循环枚举;invocation/domain/AiModelInvocationLog.java;invocation/dto/AiInvocationPageQuery.java;invocation/vo/AiInvocationLogVO.java、AiInvocationSummaryVO.java;invocation/mapper/AiModelInvocationLogMapper.java/.xml;invocation/service/AiModelInvocationRecorder.java、AiModelInvocationQueryService.java;invocation/AiInvocationObservation.java、AiInvocationPhase.java、AiInvocationOutcome.java — 同步/流式/解析失败共用的不可变记录输入与阶段/结果枚举;invocation/job/AiInvocationLogRetentionJob.java;invocation/controller/AiModelInvocationController.java;forge-plugin-ai/pom.xml — 增加 forge-starter-job;public AiInvocationSummaryVO summarize(AiInvocationPageQuery query) { }
@ScheduledJob(cron = "0 20 2 * * ?", name = "aiInvocationLogRetention") public String cleanExpiredInvocationLogs(String retentionDays) { }
- **Observation 字段契约**:requestId、tenantId、agentCode、phase、dispatched、outcome、latencyMillis 非空;userId/sessionId/routeSource/routeReason/policyId/providerPk/modelPk/providerModelId/adapterCode/errorCategory/httpStatus/errorCode/Token/价格快照按解析阶段和 Usage 可用性允许 NULL;不得接收 Throwable,不得包含 Prompt、响应、Header、API Key 或 nativeUsage。
- **实施步骤**:
- [ ] Red 测试确认 requestId 幂等、Usage null 安全、错误码白名单、价格快照和敏感字段不存在;
- [ ] XML 实现日志插入、分页、汇总、P95 nearest-rank 精确统计和按截止时间批量物理删除;
- [ ] 汇总成本先累计 DECIMAL(38,0) 分子,再除以 1,000,000 并 HALF_UP 到 long 分;覆盖半分边界和溢出边界;
- [ ] Usage 或任一价格快照缺失时 costAvailable=false、不按 0 计费,并汇总 costUnavailableCount;
- [ ] P95 使用 nearest-rank `ceil(0.95*N)`,覆盖空集合、单条和小样本;
- [ ] Retention 默认 90 天,参数非法失败关闭,不接受任意 SQL/表名;只删除早于截止时间的记录,等于边界的记录保留;
- [ ] 查询接口按当前租户过滤并增加独立权限;
- [ ] 运行 Mapper XML、Recorder/Query/Retention 测试。
- **验收标准**:日志不含 Prompt/响应/API Key,分页与汇总租户隔离,保留清理只影响超期技术日志。
## Task 7: 在同步与流式调用链接入路由、健康和计量
- **目标**:让 call/stream 共用一次决策和一次审计,失败后绝不补发第二模型请求。
- **涉及文件**:
- `client/AiClientImpl.java`;
- `client/dto/AiClientResponse.java` — 可选增加 requestId,不删除现有字段;
- `client/AiInvocationResolver.java`;
- `client/AiClientImplTest.java`;
- `client/AiClientRoutingGovernanceTest.java`。
- **关键签名**:
```java
private ChatResponse executeCall(ChatClient chatClient, String systemPrompt, String userPrompt) { }
private AiInvocationObservation buildObservation(
String requestId, RouteDecision decision, ChatResponse response, long latencyMillis) { }
.content() 改用 .chatResponse(),统一提取 content 与 Usage;agent/dto/AiAgentSaveDTO.java、agent/vo/AiAgentVO.java;agent/mapper/AiAgentMapper.java/.xml;agent/service/AiAgentService.java、agent/controller/AiAgentController.java;agent/service/AiAgentServiceTest.java;forge-admin-ui/src/views/ai/agent.vue;forge-admin-ui/src/api/ai.js。forge-admin-ui/src/views/ai/provider-model.vue — 模型能力、上下文、价格配置;forge-admin-ui/src/views/ai/model-routing.vue — “路由策略 / 调用记录”两个页签;forge-admin-ui/src/api/ai.js — 策略、预览、日志、汇总 API;forge-server/db/migration/V1.0.18__add_ai_model_routing_governance.sql — 菜单与权限资源。spec.md、tasks.md、test-spec.md、execution-log.md;docs/Forge-AI中枢战略与技术选型方案.md;code-copilot/memory/decisions.md 或 pitfalls.md。forge-plugin-ai -Penable-tests -am test,归档前基线 51 tests 必须继续通过;forge-admin-server -am package -DskipTests;forge_schema_history 验证;git diff --check,回填实际命令、警告、跳过项和服务 PID;decisions.md 第 17 条;pitfalls.md 第 105、106 条;done,归档到 code-copilot/changes/archive/2026-07-11-ai-model-routing-governance/。