status: done created: 2026-07-11 complexity: 🔴复杂
spring-ai-alibaba-provider-adapter 已完成 Spring AI 统一接口、OpenAI Compatible 与 DashScope Native 显式适配、默认模型权威来源和供应商连接安全边界。当前调用仍只能按“请求显式模型 → Agent 固定模型 → 供应商默认模型”解析,系统不知道模型是否支持推理、Tool Calling、视觉或结构化输出,也无法说明一次调用为什么选择某个模型、消耗多少 Token、耗时多久。
本变更在不引入 Nacos、MCP Registry/Admin、Agent Framework,不改变 AiClient.call/stream 公共入口的前提下,完成一个确定性、可审计的模型治理闭环:
AiModelRouter、AiModelHealthRegistry SPI 为后续 Redis/Nacos、MCP 与 Agent Runtime 保留替换点。providerId/modelName 和历史 Agent 的固定模型行为保持兼容;priority ASC → target.id ASC,同样输入和健康快照得到同样结果;forge-plugin-ai/client/AiClient.java 和 AiClientImpl#call/#stream;两条链路都先调用 AiInvocationResolver#resolve,再通过 ChatClientCache 创建会话客户端。AiInvocationResolver#resolveModel 当前优先使用请求 modelName、AiAgent.modelName,最后调用 AiModelService#requireEnabledDefaultModelId;没有路由策略、能力校验或模型实体级决策结果。AiModelService#requireEnabledDefaultModelId 与 AiModelMapper.xml#selectEnabledDefaultModelId 已把 ai_model 中启用且默认的模型作为权威来源,可作为固定模式兼容兜底。AiProviderAdapterRegistry#createChatModel 已是 ChatModel 创建的唯一入口;本变更只决定“选哪个 AiModel”,不把路由逻辑下沉到供应商 Adapter。forge-admin-ui/src/views/ai/provider-model.vue 和 agent.vue;前者维护供应商/模型,后者当前直接选择 providerId 与 modelName。model/domain/AiModel.java 只有 providerId、modelType、modelId、maxTokens、默认标志和状态,没有能力、上下文窗口或价格字段。agent/domain/AiAgent.java 只有 providerId、modelName、temperature、maxTokens 和 extraConfig,没有模型选择模式或路由策略引用。client/CircuitBreaker.java 使用 ConcurrentHashMap,阈值固定为 3 次、恢复时间固定 5 分钟,调用方传入的 key 是 agentCode;同一 Agent 切换模型会共享熔断状态,不同 Agent 调用同一故障模型又互不感知。AiClientImpl#call 使用 ChatClient.CallResponseSpec#content(),无法取得 ChatResponseMetadata.getUsage();流式链路虽然消费 ChatResponse,但没有聚合最终 Usage。1.1.2 的 ChatResponse#getMetadata、ChatResponseMetadata#getUsage 和 Usage#getPromptTokens/#getCompletionTokens/#getTotalTokens 已提供统一 Token 元数据;本地 Maven 坐标为 org.springframework.ai:spring-ai-model:1.1.2。chat/domain/AiChatRecord.java 只有单个 tokenUsage 字段,且按 user/assistant 消息保存,不能表达路由来源、模型、输入/输出 Token、耗时和错误,因此不能承担调用治理日志。AiClientImpl 当前 INFO 日志输出 systemPrompt、userPrompt 和截断后的 assistantContent;这与治理层“不持久化或日志打印业务 Prompt/响应正文”的安全目标冲突。AiModelController、AiAgentController 和部分 Service 仍使用 LambdaQueryWrapper;本变更新增的候选查询、统计与分页 SQL 必须写入 Mapper XML,不扩散 Java 动态复杂 SQL。provider/adapter/AiProviderAdapterRegistry、AiModelRuntimeOptions、ChatClientCache 已把模型创建与上层调用解耦,路由结果只需输出 provider、modelId 和运行参数。AiProviderFailureDiagnostics 已能安全提取 HTTP 状态和白名单错误码,可复用于调用记录和健康失败分类。forge-starter-job 提供 @ScheduledJob,可用于调用日志保留清理;该任务只物理删除超期技术日志,不删除模型、策略或业务配置。AiProviderService#getDefaultProvider 当前委托 AiProviderMapper.xml#selectDefaultProvider,查询会受 TenantLineInnerInterceptor 限制并过滤 is_default='1'/status='0'/del_flag='0',但通过 ORDER BY create_time DESC LIMIT 1 隐藏多默认脏数据;本变更将其收敛为当前租户恰好一个启用默认供应商的失败关闭契约。usageAvailable=false,禁止把字符数伪装成官方 Token;streaming/reasoning/tool_calling/vision/structured_output。PINNED 固定模型或 POLICY 路由策略;历史 null 值按 PINNED 解析。modelSelectionMode=PINNED 保存时必须把 routePolicyId 归一化为 null;POLICY 必须配置 routePolicyId,历史 providerId/modelName 可以保留用于切回固定模式,但运行时忽略。BusinessException("没有满足路由策略的可用模型"),不回退供应商默认模型。显式请求按以下完整决策表解析,任何显式字段都会绕过 Agent POLICY,但仍接受模型归属、租户、启停和健康校验:
| 请求 providerId | 请求 modelName | 最终 provider | 最终 model | source / reason |
|---|---|---|---|---|
| 有 | 有 | 请求 provider | 请求 modelName,必须属于该 provider | REQUEST / REQUEST_EXPLICIT_PAIR |
| 有 | 无 | 请求 provider | 该 provider 的权威默认模型;不得拼接 Agent model | REQUEST / REQUEST_PROVIDER_DEFAULT |
| 无 | 有 | PINNED Agent provider;没有则系统默认 provider;POLICY Agent 直接使用系统默认 provider | 请求 modelName,必须属于最终 provider | REQUEST / REQUEST_MODEL_WITH_RESOLVED_PROVIDER |
| 无 | 无 | PINNED Agent provider | PINNED Agent model;为空则 provider 权威默认模型 | PINNED 或 PROVIDER_DEFAULT |
| 无 | 无 | POLICY 路由决定 | POLICY 路由决定 | POLICY / POLICY_PRIORITY |
providerId 指供应商数据库主键;请求/Agent 的 modelName 对应 AiModel.modelId 的供应商模型字符串,不是 ai_model.id。
“系统默认 provider”的权威来源固定为 AiProviderService#requireEnabledDefaultProvider:Mapper XML 在当前租户内查询最多两条 is_default='1' AND status='0' AND del_flag='0' 记录;恰好一条时返回,零条提示“未配置可用的默认 AI 供应商”,多条提示“当前租户存在多个默认 AI 供应商”。停用、已删除和其他租户记录都不参与;禁止继续通过 ORDER BY/LIMIT 静默选择一条。
AiModelHealthLease:Router 的正式 route 原子获取 Lease,成功调用 success() 恢复 HEALTHY,dispatched 后失败调用 failure() 重新 OPEN,取消调用 cancel(),模型创建/缓存/Adapter 准备阶段失败调用 abort() 释放试探权且不增加失败计数。RouteDecision 必须包含选择来源 REQUEST/PINNED/PROVIDER_DEFAULT/POLICY、AiModelRouteReason、policyId 和被跳过候选原因;正式调用使用 RoutedInvocation(RouteDecision, AiModelHealthLease),preview 只返回 RouteDecision。AiInvocationPhase 固定为 RESOLUTION/PREPARATION/DISPATCHED/STREAMING/COMPLETED;只有 DISPATCHED/STREAMING 后的连接超时、网络异常、429、供应商 5xx、鉴权失败、模型不存在和 UNKNOWN 异常影响健康。RESOLUTION/PREPARATION 失败必须 abort Lease,不增加失败计数。Σ(promptTokens×inputPrice + completionTokens×outputPrice),再统一除以 1,000,000 并按 HALF_UP 舍入到整数分,禁止逐调用先舍入;SQL 乘法先转 DECIMAL(38,0) 防止 long 溢出。resetProvider(tenantId, providerPk);模型配置更新只 reset 对应 AiModelHealthKey,不通过 Service 互相注入枚举模型。ceil(0.95×N);空集合返回 NULL,单条记录返回自身值。AiProviderFailureDiagnostics 白名单化结果;未知值统一为 null/UNKNOWN。@ApiDecrypt,响应按项目策略使用 @ApiEncrypt。AiInvocationObservation 中 requestId、tenantId、agentCode、phase、dispatched、outcome、latencyMillis 必填;userId/sessionId/routeSource/routeReason/policyId/providerPk/modelPk/providerModelId/adapterCode/errorCategory/httpStatus/errorCode/Token/价格快照可按解析阶段为空。该类型不接收 Throwable,并且从数据结构上不提供 Prompt、响应、Header、API Key 或 nativeUsage 字段。迁移脚本固定为 forge-server/db/migration/V1.0.18__add_ai_model_routing_governance.sql,所有 DDL/字典/资源使用 information_schema 或 NOT EXISTS 防重复保护,业务内置数据 tenantId 为 1。
| 操作 | 表名 | 字段/索引 | 说明 |
|---|---|---|---|
| 新增字段 | ai_model |
context_window int、input_price_per_million_cent bigint、output_price_per_million_cent bigint |
数值型模型治理信息,价格单位为分/百万 Token |
| 新增表 | ai_model_capability |
model_id、capability_code、config_json、status、标准审计字段、del_flag、logic_delete_active | 模型能力关系;唯一键 (tenant_id, model_id, capability_code, logic_delete_active) |
| 新增表 | ai_model_route_policy |
policy_code、policy_name、required_capabilities(JSON)、status、标准审计字段、del_flag、logic_delete_active | 可复用路由策略;租户内未逻辑删除记录 policyCode 唯一,status 不改变唯一性 |
| 新增表 | ai_model_route_target |
policy_id、model_id(ai_model.id 内部主键)、priority、status、标准审计字段、del_flag、logic_delete_active |
显式候选列表;唯一键 (tenant_id, policy_id, model_id, logic_delete_active) |
| 新增字段 | ai_agent |
model_selection_mode varchar(16) DEFAULT 'PINNED'、route_policy_id bigint |
历史记录保持 PINNED;POLICY 模式绑定策略 |
| 新增表 | ai_model_invocation_log |
request_id、user_id、agent_code、session_id、phase、dispatched、route_source、route_reason、route_policy_id、provider_id(供应商 PK)、model_id(模型 PK)、provider_model_id(AiModel.modelId 字符串)、adapter_code、outcome、error_category、http_status、error_code、latency_ms、prompt/completion/total_tokens、usage_available、cost_available、价格快照、标准审计字段 |
路由失败时 provider/model 允许 NULL;追加型技术日志;requestId 唯一;按 tenant/time、model/time、agent/time 建索引 |
| 新增字典 | sys_dict_type/data |
ai_model_capability_type、ai_agent_model_selection_mode、ai_model_health_status、ai_invocation_outcome |
前端禁止硬编码状态与选项 |
| 新增资源 | sys_resource |
模型治理菜单、策略 CRUD、预览、调用记录查询权限 | NOT EXISTS 防重复,tenantId=1 |
ai_model_invocation_log 属于运行技术日志,不提供普通行级删除;保留任务按时间物理删除超期记录,符合项目日志留存例外。其余新增设计态配置表全部逻辑删除。
| 操作 | 接口 | 方法 | 变更内容 |
|---|---|---|---|
| 扩展 | /ai/model |
POST/PUT/GET | 模型 DTO/VO 增加能力、上下文窗口、价格和只读健康状态;能力关系在 Service 事务内保存 |
| 新增 | /ai/model/{id}/test |
POST | 服务端加载模型与供应商凭据执行低 Token 手动测试,更新模型健康状态 |
| 新增 | /ai/model-routing/policy/page |
GET | 路由策略分页 |
| 新增 | /ai/model-routing/policy/{id} |
GET/DELETE | 策略详情与逻辑删除 |
| 新增 | /ai/model-routing/policy |
POST/PUT | 新增/修改策略及显式候选,事务内校验租户、模型和能力 |
| 新增 | /ai/model-routing/policy/preview |
POST | 只读取路由与健康快照,不获取 HALF_OPEN 令牌、不调用模型;返回选中项和跳过原因 |
| 新增 | /ai/model-routing/invocation/page |
GET | 按时间、Agent、供应商、模型、结果分页查询安全调用记录 |
| 新增 | /ai/model-routing/invocation/summary |
GET | 返回调用数、成功率、Token、P95 延迟和估算成本(分) |
| 扩展 | /ai/agent |
POST/PUT/GET | 增加 modelSelectionMode、routePolicyId,保存前校验固定/策略模式互斥 |
| 兼容 | /ai/client/call、/ai/client/stream |
POST | 请求协议不删除字段;响应保持兼容,内部增加 requestId 和路由/计量记录 |
forge-plugin-ai 的 model、agent、client、provider、routing、invocation 包;AI 插件 POM 增加 forge-starter-job;provider-model.vue、agent.vue、新增 model-routing.vue、src/api/ai.js;test-spec.md;spring-ai-alibaba-provider-adapter 的 51 个 AI 插件测试必须继续通过。无。以下决策已由用户确认:
| 决策 | 选择 | 放弃方案 | 原因 |
|---|---|---|---|
| 路由算法 | 显式候选 + 能力全包含 + priority 确定性排序 | 全库扫描、权重随机、AI 自主选择 | 可解释、可测试、避免隐式跨供应商 |
| 路由来源 | AiModelRouteSource 枚举 |
任意 String | 防止审计值拼写漂移 |
| 降级时机 | 仅调用前跳过 OPEN 候选 | 失败后自动请求下一模型 | 避免重复计费和 Tool 副作用 |
| 健康来源 | 真实调用 + 手动测试 | 周期付费探测 | 不产生无业务费用 |
| 健康存储 | AiModelHealthRegistry SPI + 默认内存实现 |
直接绑定 Redis/Nacos | 当前单体可用,后续替换不改 Router |
| 能力模型 | 字典代码 + ai_model_capability 关系表 |
多个 boolean、单 JSON 字段 | 新增能力不改主表,便于约束和查询 |
| 计量来源 | Spring AI ChatResponse Usage | 字符数估算 | 只使用供应商/框架真实元数据 |
| 成本单位 | 分/百万 Token + 调用价格快照 | 浮点金额、调用级小数金额 | 符合 Forge long/分金额规则 |
| 调用审计 | 独立 append-only 技术日志 | 复用聊天消息表 | 聊天消息无法表达路由和错误元数据 |
| Agent 兼容 | null 选择模式视为 PINNED | 批量切换现有 Agent | 不改变上线行为 |
| Task | 状态 | 实际改动文件 | 备注 |
|---|---|---|---|
| Proposal Research | 完成 | 本 Spec、execution-log.md |
已核对模型、Agent、Resolver、AiClient、CircuitBreaker、Spring AI Usage API 和战略边界 |
| Task 1–9 | 完成 | 见 tasks.md 与 execution-log.md |
数据契约、路由、健康、审计、Agent 和最小管理端已实现 |
| Task 10 | 完成(条件项除外) | 测试、装配与静态扫描 | Review 阻断已修复;实库 Flyway 和浏览器交互继续作为条件项保留 |
done,已归档、未推送。/apply ai-model-routing-governancecode-copilot/changes/archive/2026-07-11-ai-model-routing-governance/code-copilot/memory/decisions.md 第 17 条、code-copilot/memory/pitfalls.md 第 105、106 条