Hagicode 多 AI 提供者切换与互操作实现方案

背景

问题域

hagicode 项目面临的核心挑战是在同一平台中支持多种 AI CLI,让用户能够:

  1. 根据需求灵活切换不同的 AI 提供者
  2. 在切换过程中保持会话状态的连续性
  3. 统一抽象不同 CLI 的 API 差异
  4. 为未来添加新的 AI 提供者预留扩展空间

技术挑战

  1. 接口差异统一:Claude Code CLI 通过命令行调用,Codex CLI 使用 JSON 事件流
  2. 流式响应处理:两种提供者都支持流式响应,但数据格式不同
  3. 工具调用语义:Claude 和 Codex 对工具调用的表示和生命周期管理不同
  4. 会话生命周期:需要正确管理每个提供者的会话创建、恢复和终止

分析

架构设计思路

hagicode 采用了提供者模式(Provider Pattern) 结合工厂模式来抽象 AI 服务的调用。这种设计的核心思想是:

  1. 统一接口抽象 :定义 IAIProvider 接口作为所有 AI 提供者的统一抽象
  2. 工厂创建实例 :通过 AIProviderFactory 根据类型动态创建对应的提供者实例
  3. 智能选择逻辑 :使用 AIProviderSelector 根据场景和配置自动选择最合适的提供者
  4. 会话状态管理:通过数据库持久化会话与 CLI 线程的绑定关系

关键组件

组件 职责 语言
IAIProvider 统一提供者接口 C#
AIProviderFactory 创建和管理提供者实例 C#
AIProviderSelector 智能选择提供者 C#
ClaudeCodeCliProvider Claude Code CLI 实现 C#
CodexCliProvider Codex CLI 实现 C#
AgentCliManager 桌面端 CLI 管理 TypeScript

解决

1. 核心接口设计

IAIProvider 接口 定义了统一的提供者抽象:

复制代码

|---|---------------------------------------------------------------------------------------------------------------------|
| | public interface IAIProvider |
| | { |
| | /// <summary> |
| | /// 提供者显示名称 |
| | /// </summary> |
| | string Name { get; } |
| | |
| | /// <summary> |
| | /// 是否支持流式响应 |
| | /// </summary> |
| | bool SupportsStreaming { get; } |
| | |
| | /// <summary> |
| | /// 提供者能力描述 |
| | /// </summary> |
| | ProviderCapabilities Capabilities { get; } |
| | |
| | /// <summary> |
| | /// 执行单个 AI 请求 |
| | /// </summary> |
| | Task<AIResponse> ExecuteAsync(AIRequest request, CancellationToken cancellationToken = default); |
| | |
| | /// <summary> |
| | /// 执行流式 AI 请求 |
| | /// </summary> |
| | IAsyncEnumerable<AIStreamingChunk> StreamAsync(AIRequest request, CancellationToken cancellationToken = default); |
| | |
| | /// <summary> |
| | /// 检查提供者连接性和响应速度 |
| | /// </summary> |
| | Task<ProviderTestResult> PingAsync(CancellationToken cancellationToken = default); |
| | |
| | /// <summary> |
| | /// 发送带嵌入式命令的消息 |
| | /// </summary> |
| | IAsyncEnumerable<AIStreamingChunk> SendMessageAsync( |
| | AIRequest request, |
| | string? embeddedCommandPrompt = null, |
| | CancellationToken cancellationToken = default); |
| | } |

接口设计的关键特性:

  • 统一的请求/响应模型 :所有提供者使用相同的 AIRequestAIResponse 类型
  • 流式支持 :通过 IAsyncEnumerable<AIStreamingChunk> 统一流式输出
  • 能力描述ProviderCapabilities 描述提供者支持的功能(流式、工具、最大 token 等)
  • 嵌入式命令SendMessageAsync 支持将 OpenSpec 命令嵌入到提示中

2. 提供者类型枚举

复制代码

|---|-------------------------------------------|
| | public enum AIProviderType |
| | { |
| | ClaudeCodeCli, // Anthropic Claude Code |
| | OpenCodeCli, // 其他 CLI(可扩展) |
| | GitHubCopilot, // GitHub Copilot |
| | CodebuddyCli, // Codebuddy |
| | CodexCli // OpenAI Codex |
| | } |

这个枚举为系统支持的所有提供者提供了类型安全的表示。

3. 工厂模式实现

AIProviderFactory 负责创建和管理提供者实例:

复制代码

|---|---------------------------------------------------------------------------------------------|
| | public class AIProviderFactory : IAIProviderFactory |
| | { |
| | private readonly ConcurrentDictionary<AIProviderType, IAIProvider> _cache; |
| | private readonly IOptions<AIProviderOptions> _options; |
| | private readonly IServiceProvider _serviceProvider; |
| | |
| | public Task<IAIProvider?> GetProviderAsync(AIProviderType providerType) |
| | { |
| | // 使用缓存避免重复创建 |
| | if (_cache.TryGetValue(providerType, out var cached)) |
| | return Task.FromResult<IAIProvider?>(cached); |
| | |
| | // 从配置中获取提供者配置 |
| | var aiOptions = _options.Value; |
| | if (!aiOptions.Providers.TryGetValue(providerType, out var config)) |
| | { |
| | _logger.LogWarning("Provider '{ProviderType}' not found in configuration", providerType); |
| | return Task.FromResult<IAIProvider?>(null); |
| | } |
| | |
| | // 根据类型创建提供者 |
| | var provider = providerType switch |
| | { |
| | AIProviderType.ClaudeCodeCli => |
| | _serviceProvider.GetService(typeof(ClaudeCodeCliProvider)) as IAIProvider, |
| | AIProviderType.CodexCli => |
| | _serviceProvider.GetService(typeof(CodexCliProvider)) as IAIProvider, |
| | AIProviderType.GitHubCopilot => |
| | _serviceProvider.GetService(typeof(CopilotAIProvider)) as IAIProvider, |
| | _ => null |
| | }; |
| | |
| | if (provider != null) |
| | { |
| | _cache[providerType] = provider; |
| | } |
| | |
| | return Task.FromResult<IAIProvider?>(provider); |
| | } |
| | } |

工厂模式的优势:

  • 实例缓存:避免重复创建相同类型的提供者
  • 依赖注入 :通过 IServiceProvider 创建实例,支持依赖注入
  • 配置驱动:从配置文件读取提供者配置
  • 异常处理:创建失败时返回 null,便于上层处理

4. 智能选择器

AIProviderSelector 实现提供者选择策略:

复制代码

|---|-----------------------------------------------------------------------------------------------------|
| | public class AIProviderSelector : IAIProviderSelector |
| | { |
| | private readonly BusinessLayerConfiguration _configuration; |
| | private readonly IAIProviderFactory _providerFactory; |
| | private readonly IMemoryCache _cache; |
| | |
| | public async Task<AIProviderType> SelectProviderAsync( |
| | BusinessScenario scenario, |
| | CancellationToken cancellationToken = default) |
| | { |
| | // 1. 尝试从场景映射获取提供者 |
| | if (_configuration.ScenarioProviderMapping.TryGetValue(scenario, out var providerType)) |
| | { |
| | if (await IsProviderAvailableAsync(providerType, cancellationToken)) |
| | { |
| | _logger.LogDebug("Selected provider '{Provider}' for scenario '{Scenario}'", |
| | providerType, scenario); |
| | return providerType; |
| | } |
| | |
| | _logger.LogWarning("Configured provider '{Provider}' for scenario '{Scenario}' is not available", |
| | providerType, scenario); |
| | } |
| | |
| | // 2. 尝试使用默认提供者 |
| | if (await IsProviderAvailableAsync(_configuration.DefaultProvider, cancellationToken)) |
| | { |
| | _logger.LogDebug("Using default provider '{Provider}' for scenario '{Scenario}'", |
| | _configuration.DefaultProvider, scenario); |
| | return _configuration.DefaultProvider; |
| | } |
| | |
| | // 3. 尝试回退链 |
| | foreach (var fallbackProvider in _configuration.FallbackChain) |
| | { |
| | if (await IsProviderAvailableAsync(fallbackProvider, cancellationToken)) |
| | { |
| | _logger.LogInformation("Using fallback provider '{Provider}' for scenario '{Scenario}'", |
| | fallbackProvider, scenario); |
| | return fallbackProvider; |
| | } |
| | } |
| | |
| | // 4. 无法找到可用提供者 |
| | throw new InvalidOperationException( |
| | $"No available AI provider found for scenario '{scenario}'"); |
| | } |
| | |
| | public async Task<bool> IsProviderAvailableAsync( |
| | AIProviderType providerType, |
| | CancellationToken cancellationToken = default) |
| | { |
| | var cacheKey = $"provider_available_{providerType}"; |
| | |
| | // 使用缓存减少 Ping 调用 |
| | if (_configuration.EnableCache && |
| | _cache.TryGetValue<bool>(cacheKey, out var cached)) |
| | { |
| | return cached; |
| | } |
| | |
| | var provider = await _providerFactory.GetProviderAsync(providerType); |
| | var isAvailable = provider != null; |
| | |
| | if (_configuration.EnableCache && isAvailable) |
| | { |
| | _cache.Set(cacheKey, isAvailable, |
| | TimeSpan.FromSeconds(_configuration.CacheExpirationSeconds)); |
| | } |
| | |
| | return isAvailable; |
| | } |
| | } |

选择器策略:

  • 场景映射优先:首先检查业务场景是否有特定的提供者映射
  • 默认提供者回退:场景映射失败时使用默认提供者
  • 回退链兜底:逐个尝试回退链中的提供者
  • 可用性缓存:缓存提供者可用性检查结果,减少 Ping 调用

5. Claude Code CLI 提供者实现

复制代码

|---|----------------------------------------------------------------------------------------------------------------|
| | public class ClaudeCodeCliProvider : IAIProvider |
| | { |
| | private readonly ILogger<ClaudeCodeCliProvider> _logger; |
| | private readonly IClaudeStreamManager _streamManager; |
| | private readonly ProviderConfiguration _config; |
| | |
| | public string Name => "ClaudeCodeCli"; |
| | public bool SupportsStreaming => true; |
| | |
| | public ProviderCapabilities Capabilities { get; } |
| | |
| | public async Task<AIResponse> ExecuteAsync(AIRequest request, CancellationToken cancellationToken = default) |
| | { |
| | _logger.LogInformation("Executing AI request with provider: {Provider}", Name); |
| | |
| | var sessionOptions = ClaudeRequestMapper.MapToSessionOptions(request, _config); |
| | |
| | var messages = _streamManager.SendMessageAsync(request.Prompt, sessionOptions, cancellationToken); |
| | |
| | var responseBuilder = new StringBuilder(); |
| | ResultMessage? finalResult = null; |
| | |
| | await foreach (var streamMessage in messages) |
| | { |
| | switch (streamMessage.Message) |
| | { |
| | case ResultMessage result: |
| | finalResult = result; |
| | responseBuilder.Append(result.Result); |
| | break; |
| | } |
| | } |
| | |
| | if (finalResult != null) |
| | { |
| | return ClaudeResponseMapper.MapToAIResponse(finalResult, Name); |
| | } |
| | |
| | return new AIResponse |
| | { |
| | Content = responseBuilder.ToString(), |
| | FinishReason = FinishReason.Unknown, |
| | Provider = Name |
| | }; |
| | } |
| | } |

Claude Code CLI 提供者的特点:

  • 流式管理器集成 :使用 IClaudeStreamManager 与 Claude CLI 通信
  • CessionId 会话隔离 :使用 CessionId 作为会话唯一标识,与系统 sessionId 区分
  • 工作目录配置:支持配置工作目录、权限模式等
  • 工具支持:支持 AllowedTools、DisallowedTools 等工具权限配置

6. Codex CLI 提供者实现

复制代码

|---|------------------------------------------------------------------------------------------------------------|
| | public class CodexCliProvider : IAIProvider |
| | { |
| | private readonly ILogger<CodexCliProvider> _logger; |
| | private readonly CodexSettings _settings; |
| | private readonly ConcurrentDictionary<string, string> _sessionThreadBindings; |
| | |
| | public string Name => "CodexCli"; |
| | public bool SupportsStreaming => true; |
| | |
| | public ProviderCapabilities Capabilities { get; } |
| | |
| | public async IAsyncEnumerable<AIStreamingChunk> StreamAsync( |
| | AIRequest request, |
| | [EnumeratorCancellation] CancellationToken cancellationToken = default) |
| | { |
| | _logger.LogInformation("Executing streaming AI request with provider: {Provider}", Name); |
| | |
| | var codex = CreateCodexClient(); |
| | var thread = ResolveThread(codex, request); |
| | |
| | var currentTurn = 0; |
| | var activeToolCalls = new Dictionary<string, AIToolCallDelta>(); |
| | |
| | await foreach (var threadEvent in thread.RunStreamedAsync(BuildPrompt(request), cancellationToken)) |
| | { |
| | if (threadEvent is TurnStartedEvent) |
| | { |
| | currentTurn++; |
| | } |
| | |
| | switch (threadEvent) |
| | { |
| | case ItemCompletedEvent { Item: AgentMessageItem message }: |
| | var messageText = message.Text ?? string.Empty; |
| | yield return new AIStreamingChunk |
| | { |
| | Content = messageText, |
| | Type = StreamingChunkType.ContentDelta, |
| | IsComplete = false |
| | }; |
| | break; |
| | |
| | case ItemStartedEvent or ItemUpdatedEvent or ItemCompletedEvent: |
| | var toolChunk = BuildToolChunk(threadEvent, currentTurn); |
| | if (toolChunk?.ToolCallDelta != null) |
| | { |
| | yield return toolChunk; |
| | } |
| | break; |
| | |
| | case TurnCompletedEvent turnCompleted: |
| | activeToolCalls.Clear(); |
| | yield return new AIStreamingChunk |
| | { |
| | Content = string.Empty, |
| | Type = StreamingChunkType.Metadata, |
| | IsComplete = true, |
| | Usage = MapUsage(turnCompleted.Usage) |
| | }; |
| | break; |
| | } |
| | } |
| | |
| | BindSessionThread(request.SessionId, thread.Id); |
| | } |
| | |
| | private CodexThread ResolveThread(Codex codex, AIRequest request) |
| | { |
| | var sessionId = request.SessionId; |
| | |
| | // 检查是否已有绑定的线程 |
| | if (!string.IsNullOrWhiteSpace(sessionId) && |
| | _sessionThreadBindings.TryGetValue(sessionId, out var threadId) && |
| | !string.IsNullOrWhiteSpace(threadId)) |
| | { |
| | _logger.LogInformation("Resuming Codex thread {ThreadId} for session {SessionId}", threadId, sessionId); |
| | return codex.ResumeThread(threadId, threadOptions); |
| | } |
| | |
| | _logger.LogInformation("Starting new Codex thread for session {SessionId}", sessionId ?? "(none)"); |
| | return codex.StartThread(threadOptions); |
| | } |
| | } |

Codex CLI 提供者的特点:

  • JSON 事件流处理:解析 Codex 的 JSON 事件流(TurnStarted、ItemStarted、TurnCompleted 等)
  • 会话线程绑定:使用 SQLite 数据库持久化会话与线程的绑定关系
  • 线程复用:支持恢复已有线程,保持会话连续性
  • 工具调用追踪:追踪活动工具调用状态,正确处理工具生命周期

7. 会话线程绑定机制

Codex CLI 使用 SQLite 数据库持久化会话与线程的绑定:

复制代码

|---|-----------------------------------------------------------------------------------------------|
| | public class CodexCliProvider : IAIProvider |
| | { |
| | private const int SessionThreadBindingRetentionDays = 30; |
| | private readonly ConcurrentDictionary<string, string> _sessionThreadBindings; |
| | private readonly string _sessionThreadBindingDatabaseConnectionString; |
| | private readonly string _sessionThreadBindingDatabasePath; |
| | |
| | private void BindSessionThread(string? sessionId, string? threadId) |
| | { |
| | if (string.IsNullOrWhiteSpace(sessionId) || string.IsNullOrWhiteSpace(threadId)) |
| | { |
| | return; |
| | } |
| | |
| | // 内存缓存 |
| | _sessionThreadBindings.AddOrUpdate(sessionId, threadId, (_, _) => threadId); |
| | |
| | // 持久化到 SQLite |
| | PersistSessionThreadBinding(sessionId, threadId); |
| | } |
| | |
| | private void PersistSessionThreadBinding(string sessionId, string threadId) |
| | { |
| | try |
| | { |
| | using var connection = new SqliteConnection(_sessionThreadBindingDatabaseConnectionString); |
| | connection.Open(); |
| | |
| | using var upsertCommand = connection.CreateCommand(); |
| | upsertCommand.CommandText = |
| | """ |
| | INSERT INTO SessionThreadBindings (SessionId, ThreadId, CreatedAtUtc, UpdatedAtUtc) |
| | VALUES ($sessionId, $threadId, $createdAtUtc, $updatedAtUtc) |
| | ON CONFLICT(SessionId) DO UPDATE SET |
| | ThreadId = excluded.ThreadId, |
| | UpdatedAtUtc = excluded.UpdatedAtUtc; |
| | """; |
| | var nowUtc = DateTimeOffset.UtcNow.ToString("O"); |
| | upsertCommand.Parameters.AddWithValue("$sessionId", sessionId); |
| | upsertCommand.Parameters.AddWithValue("$threadId", threadId); |
| | upsertCommand.Parameters.AddWithValue("$createdAtUtc", nowUtc); |
| | upsertCommand.Parameters.AddWithValue("$updatedAtUtc", nowUtc); |
| | upsertCommand.ExecuteNonQuery(); |
| | } |
| | catch (Exception ex) |
| | { |
| | _logger.LogWarning( |
| | ex, |
| | "Failed to persist Codex session-thread binding for session {SessionId} to {DatabasePath}", |
| | sessionId, |
| | _sessionThreadBindingDatabasePath); |
| | } |
| | } |
| | |
| | private void LoadPersistedSessionThreadBindings() |
| | { |
| | using var connection = new SqliteConnection(_sessionThreadBindingDatabaseConnectionString); |
| | connection.Open(); |
| | |
| | using var loadCommand = connection.CreateCommand(); |
| | loadCommand.CommandText = "SELECT SessionId, ThreadId FROM SessionThreadBindings;"; |
| | using var reader = loadCommand.ExecuteReader(); |
| | while (reader.Read()) |
| | { |
| | var sessionId = reader.GetString(0); |
| | var threadId = reader.GetString(1); |
| | _sessionThreadBindings[sessionId] = threadId; |
| | } |
| | } |
| | } |

会话线程绑定的优势:

  • 会话恢复:系统重启后可以恢复之前的会话
  • 线程复用:同一会话可以复用已有的 Codex 线程
  • 自动清理:超过 30 天的绑定会被自动清理

8. 桌面端 CLI 管理

hagicode-desktop 通过 AgentCliManager 管理 CLI 选择:

复制代码

|---|-------------------------------------------------------------------------------|
| | export enum AgentCliType { |
| | ClaudeCode = 'claude-code', |
| | Codex = 'codex', |
| | // 未来可扩展: Aider, Cursor 等其他 CLI |
| | } |
| | |
| | export class AgentCliManager { |
| | private static readonly STORE_KEY = 'agentCliSelection'; |
| | private static readonly EXECUTOR_TYPE_MAP: Record<AgentCliType, string> = { |
| | [AgentCliType.ClaudeCode]: 'ClaudeCodeCli', |
| | [AgentCliType.Codex]: 'CodexCli', |
| | }; |
| | |
| | constructor(private store: any) {} |
| | |
| | async saveSelection(cliType: AgentCliType): Promise<void> { |
| | const selection: StoredAgentCliSelection = { |
| | cliType, |
| | isSkipped: false, |
| | selectedAt: new Date().toISOString(), |
| | }; |
| | |
| | this.store.set(AgentCliManager.STORE_KEY, selection); |
| | } |
| | |
| | loadSelection(): StoredAgentCliSelection { |
| | return this.store.get(AgentCliManager.STORE_KEY, { |
| | cliType: null, |
| | isSkipped: false, |
| | selectedAt: null, |
| | }); |
| | } |
| | |
| | getCommandName(cliType: AgentCliType): string { |
| | switch (cliType) { |
| | case AgentCliType.ClaudeCode: |
| | return 'claude'; |
| | case AgentCliType.Codex: |
| | return 'codex'; |
| | default: |
| | return 'claude'; |
| | } |
| | } |
| | |
| | getExecutorType(cliType: AgentCliType | null): string { |
| | if (!cliType) return 'ClaudeCodeCli'; |
| | return this.EXECUTOR_TYPE_MAP[cliType] || 'ClaudeCodeCli'; |
| | } |
| | } |

桌面端 IPC 处理器示例:

复制代码

|---|---------------------------------------------------------------------------------------|
| | ipcMain.handle('llm:call-api', async (event, manifestPath, region) => { |
| | if (!state.llmInstallationManager) { |
| | return { success: false, error: 'LLM Installation Manager not initialized' }; |
| | } |
| | |
| | try { |
| | const prompt = await state.llmInstallationManager.loadPrompt(manifestPath, region); |
| | |
| | // 根据用户选择确定 CLI 命令 |
| | let commandName = 'claude'; |
| | if (state.agentCliManager) { |
| | const selectedCliType = state.agentCliManager.getSelectedCliType(); |
| | if (selectedCliType) { |
| | commandName = state.agentCliManager.getCommandName(selectedCliType); |
| | } |
| | } |
| | |
| | // 使用对应的 CLI 执行 |
| | const result = await state.llmInstallationManager.callApi( |
| | prompt.filePath, |
| | event.sender, |
| | commandName |
| | ); |
| | |
| | return result; |
| | } catch (error) { |
| | return { |
| | success: false, |
| | error: error instanceof Error ? error.message : 'Unknown error' |
| | }; |
| | } |
| | }); |

9. Codex 内部的模型提供者系统

Codex 本身也支持多种模型提供者,通过 ModelProviderInfo 配置:

复制代码

|---|-----------------------------------------------------------------------------------------------|
| | pub const OPENAI_PROVIDER_NAME: &str = "OpenAI"; |
| | pub const OLLAMA_OSS_PROVIDER_ID: &str = "ollama"; |
| | pub const LMSTUDIO_OSS_PROVIDER_ID: &str = "lmstudio"; |
| | |
| | pub fn built_in_model_providers() -> HashMap<String, ModelProviderInfo> { |
| | use ModelProviderInfo as P; |
| | |
| | [ |
| | ("openai", P::create_openai_provider()), |
| | (OLLAMA_OSS_PROVIDER_ID, create_oss_provider(DEFAULT_OLLAMA_PORT, WireApi::Responses)), |
| | (LMSTUDIO_OSS_PROVIDER_ID, create_oss_provider(DEFAULT_LMSTUDIO_PORT, WireApi::Responses)), |
| | ] |
| | .into_iter() |
| | .map(|(k, v)| (k.to_string(), v)) |
| | .collect() |
| | } |
| | |
| | pub struct ModelProviderInfo { |
| | pub name: String, |
| | pub base_url: Option<String>, |
| | pub env_key: Option<String>, |
| | pub query_params: Option<HashMap<String, String>>, |
| | pub http_headers: Option<HashMap<String, String>>, |
| | pub request_max_retries: Option<u64>, |
| | pub stream_max_retries: Option<u64>, |
| | pub stream_idle_timeout_ms: Option<u64>, |
| | pub requires_openai_auth: bool, |
| | pub supports_websockets: bool, |
| | } |

Codex 的模型提供者支持:

  • 内置提供者:OpenAI、Ollama、LM Studio
  • 自定义提供者:用户可在 config.toml 中添加自定义提供者
  • 重试策略:可配置请求和流的重试次数
  • WebSocket 支持:部分提供者支持 WebSocket 传输

实践

配置示例

appsettings.json 配置多个提供者:

复制代码

|---|--------------------------------------------------------|
| | { |
| | "AI": { |
| | "Providers": { |
| | "DefaultProvider": "ClaudeCodeCli", |
| | "Providers": { |
| | "ClaudeCodeCli": { |
| | "Type": "ClaudeCodeCli", |
| | "Model": "claude-sonnet-4-20250514", |
| | "WorkingDirectory": "/path/to/workspace", |
| | "PermissionMode": "acceptEdits", |
| | "AllowedTools": ["file-edit", "command-run", "bash"] |
| | }, |
| | "CodexCli": { |
| | "Type": "CodexCli", |
| | "Model": "gpt-4.1", |
| | "ExecutablePath": "codex", |
| | "SandboxMode": "enabled", |
| | "WebSearchMode": "auto", |
| | "NetworkAccessEnabled": false |
| | } |
| | }, |
| | "ScenarioProviderMapping": { |
| | "CodeAnalysis": "ClaudeCodeCli", |
| | "CodeGeneration": "CodexCli", |
| | "Refactoring": "ClaudeCodeCli", |
| | "Debugging": "CodexCli" |
| | }, |
| | "FallbackChain": ["CodexCli", "ClaudeCodeCli"] |
| | }, |
| | "Selector": { |
| | "EnableCache": true, |
| | "CacheExpirationSeconds": 300 |
| | } |
| | } |
| | } |

使用示例 - 后端服务

复制代码

|---|--------------------------------------------------------------------------------------------------------|
| | public class AIOrchestrator |
| | { |
| | private readonly IAIProviderFactory _providerFactory; |
| | private readonly IAIProviderSelector _providerSelector; |
| | private readonly ILogger<AIOrchestrator> _logger; |
| | |
| | public AIOrchestrator( |
| | IAIProviderFactory providerFactory, |
| | IAIProviderSelector providerSelector, |
| | ILogger<AIOrchestrator> logger) |
| | { |
| | _providerFactory = providerFactory; |
| | _providerSelector = providerSelector; |
| | _logger = logger; |
| | } |
| | |
| | public async Task<AIResponse> ProcessRequestAsync( |
| | AIRequest request, |
| | BusinessScenario scenario) |
| | { |
| | _logger.LogInformation("Processing request for scenario: {Scenario}", scenario); |
| | |
| | try |
| | { |
| | // 智能选择提供者 |
| | var providerType = await _providerSelector.SelectProviderAsync(scenario, request.CancellationToken); |
| | |
| | // 获取提供者实例 |
| | var provider = await _providerFactory.GetProviderAsync(providerType); |
| | if (provider == null) |
| | { |
| | throw new InvalidOperationException($"Provider {providerType} not available"); |
| | } |
| | |
| | _logger.LogInformation("Using provider: {Provider} for request", provider.Name); |
| | |
| | // 执行请求 |
| | var response = await provider.ExecuteAsync(request, request.CancellationToken); |
| | |
| | _logger.LogInformation("Request completed with provider: {Provider}, tokens used: {Tokens}", |
| | provider.Name, |
| | response.Usage?.TotalTokens ?? 0); |
| | |
| | return response; |
| | } |
| | catch (Exception ex) |
| | { |
| | _logger.LogError(ex, "Failed to process request for scenario: {Scenario}", scenario); |
| | throw; |
| | } |
| | } |
| | } |

使用示例 - 流式响应

复制代码

|---|----------------------------------------------------------------------------------|
| | public async IAsyncEnumerable<AIStreamingChunk> StreamResponseAsync( |
| | AIRequest request, |
| | BusinessScenario scenario) |
| | { |
| | var providerType = await _providerSelector.SelectProviderAsync(scenario); |
| | var provider = await _providerFactory.GetProviderAsync(providerType); |
| | |
| | if (provider == null) |
| | { |
| | throw new InvalidOperationException($"Provider {providerType} not available"); |
| | } |
| | |
| | await foreach (var chunk in provider.StreamAsync(request)) |
| | { |
| | // 处理流式块 |
| | switch (chunk.Type) |
| | { |
| | case StreamingChunkType.ContentDelta: |
| | // 实时显示文本内容 |
| | await SendToClientAsync(chunk.Content); |
| | break; |
| | |
| | case StreamingChunkType.ToolCallDelta: |
| | // 处理工具调用 |
| | await HandleToolCallAsync(chunk.ToolCallDelta); |
| | break; |
| | |
| | case StreamingChunkType.Metadata: |
| | // 处理完成事件和统计 |
| | if (chunk.IsComplete) |
| | { |
| | _logger.LogInformation("Stream completed, usage: {@Usage}", chunk.Usage); |
| | } |
| | break; |
| | |
| | case StreamingChunkType.Error: |
| | // 处理错误 |
| | _logger.LogError("Stream error: {Error}", chunk.ErrorMessage); |
| | throw new InvalidOperationException(chunk.ErrorMessage); |
| | } |
| | } |
| | } |

使用示例 - OpenSpec 命令

复制代码

|---|-----------------------------------------------------------------------------|
| | public async Task<string> ExecuteOpenSpecCommandAsync( |
| | string command, |
| | string arguments, |
| | BusinessScenario scenario) |
| | { |
| | var providerType = await _providerSelector.SelectProviderAsync(scenario); |
| | var provider = await _providerFactory.GetProviderAsync(providerType); |
| | |
| | // 构建嵌入式命令提示 |
| | var commandPrompt = $""" |
| | Execute the following OpenSpec command: |
| | Command: {command} |
| | Arguments: {arguments} |
| | |
| | Please execute this command and return the results. |
| | """; |
| | |
| | var request = new AIRequest |
| | { |
| | Prompt = "Process this command request", |
| | EmbeddedCommandPrompt = commandPrompt, |
| | WorkingDirectory = Directory.GetCurrentDirectory() |
| | }; |
| | |
| | var response = await provider.SendMessageAsync(request, commandPrompt); |
| | |
| | return response.Content; |
| | } |

注意事项

1. 提供者健康检查

在切换提供者前,建议先调用 PingAsync 确保目标提供者可用:

复制代码

|---|-------------------------------------------------------------------------------|
| | public async Task<bool> IsProviderHealthyAsync(AIProviderType providerType) |
| | { |
| | var provider = await _providerFactory.GetProviderAsync(providerType); |
| | if (provider == null) return false; |
| | |
| | var testResult = await provider.PingAsync(); |
| | |
| | return testResult.Success && |
| | testResult.ResponseTimeMs < 5000; // 5 秒内响应视为健康 |
| | } |

2. 会话隔离

使用 CessionId(Claude)或 ThreadId(Codex)确保会话隔离:

  • Claude Code CLI:使用 CessionId 作为会话唯一标识
  • Codex CLI:使用 ThreadId 作为会话标识
复制代码

|---|-------------------------------------------------------|
| | // Claude Code CLI 会话选项 |
| | var claudeSessionOptions = new ClaudeSessionOptions |
| | { |
| | CessionId = CessionId.New(), // 生成唯一 ID |
| | WorkingDirectory = workspacePath, |
| | AllowedTools = allowedTools, |
| | PermissionMode = PermissionMode.acceptEdits |
| | }; |
| | |
| | // Codex 线程选项 |
| | var codexThreadOptions = new ThreadOptions |
| | { |
| | Model = "gpt-4.1", |
| | SandboxMode = "enabled", |
| | WorkingDirectory = workspacePath |
| | }; |

3. 错误处理

提供者不可用时的回退机制要健壮,确保至少有一个可用提供者:

复制代码

|---|----------------------------------------------------------------------------------------|
| | public async Task<AIResponse> ExecuteWithFallbackAsync( |
| | AIRequest request, |
| | List<AIProviderType> preferredProviders) |
| | { |
| | Exception? lastException = null; |
| | |
| | foreach (var providerType in preferredProviders) |
| | { |
| | try |
| | { |
| | var provider = await _providerFactory.GetProviderAsync(providerType); |
| | if (provider == null) continue; |
| | |
| | // 尝试执行 |
| | return await provider.ExecuteAsync(request); |
| | } |
| | catch (Exception ex) |
| | { |
| | _logger.LogWarning(ex, "Provider {ProviderType} failed, trying next", providerType); |
| | lastException = ex; |
| | } |
| | } |
| | |
| | // 所有提供者都失败 |
| | throw new InvalidOperationException( |
| | "All preferred providers failed. Last error: " + lastException?.Message, |
| | lastException); |
| | } |

4. 配置验证

启动时验证所有配置的提供者设置,避免运行时错误:

复制代码

|---|----------------------------------------------------------------------------------------------|
| | public void ValidateConfiguration(AIProviderOptions options) |
| | { |
| | foreach (var (providerType, config) in options.Providers) |
| | { |
| | // 验证可执行文件路径(CLI 类型提供者) |
| | if (IsCliBasedProvider(providerType)) |
| | { |
| | if (string.IsNullOrWhiteSpace(config.ExecutablePath)) |
| | { |
| | throw new ConfigurationException( |
| | $"Provider {providerType} requires ExecutablePath"); |
| | } |
| | |
| | if (!File.Exists(config.ExecutablePath)) |
| | { |
| | throw new ConfigurationException( |
| | $"Executable not found for {providerType}: {config.ExecutablePath}"); |
| | } |
| | } |
| | |
| | // 验证 API 密钥(API 类型提供者) |
| | if (IsApiBasedProvider(providerType)) |
| | { |
| | if (string.IsNullOrWhiteSpace(config.ApiKey)) |
| | { |
| | throw new ConfigurationException( |
| | $"Provider {providerType} requires ApiKey"); |
| | } |
| | } |
| | |
| | // 验证模型名称 |
| | if (string.IsNullOrWhiteSpace(config.Model)) |
| | { |
| | _logger.LogWarning("No model configured for {ProviderType}, using default", providerType); |
| | } |
| | } |
| | } |

5. 缓存管理

提供者实例会被缓存,注意生命周期管理和内存使用:

复制代码

|---|-------------------------------------------------------------------------|
| | // 定期清理缓存 |
| | public void ClearInactiveProviders(TimeSpan inactiveThreshold) |
| | { |
| | var now = DateTimeOffset.UtcNow; |
| | var keysToRemove = new List<AIProviderType>(); |
| | |
| | foreach (var (type, instance) in _cache) |
| | { |
| | // 假设提供者有 LastUsedTime 属性 |
| | if (instance.LastUsedTime.HasValue && |
| | now - instance.LastUsedTime.Value > inactiveThreshold) |
| | { |
| | keysToRemove.Add(type); |
| | } |
| | } |
| | |
| | foreach (var key in keysToRemove) |
| | { |
| | _cache.TryRemove(key, out _); |
| | _logger.LogInformation("Cleared inactive provider: {Provider}", key); |
| | } |
| | } |

6. 日志记录

详细记录提供者选择、切换和执行过程,便于调试:

复制代码

|---|------------------------------------------------------------------------------------|
| | public class AIProviderLogging |
| | { |
| | private readonly ILogger _logger; |
| | |
| | public void LogProviderSelection( |
| | BusinessScenario scenario, |
| | AIProviderType selectedProvider, |
| | SelectionReason reason) |
| | { |
| | _logger.LogInformation( |
| | "[ProviderSelection] Scenario={Scenario}, Provider={Provider}, Reason={Reason}", |
| | scenario, |
| | selectedProvider, |
| | reason); |
| | } |
| | |
| | public void LogProviderSwitch( |
| | AIProviderType fromProvider, |
| | AIProviderType toProvider, |
| | string reason) |
| | { |
| | _logger.LogWarning( |
| | "[ProviderSwitch] From={FromProvider} To={ToProvider}, Reason={Reason}", |
| | fromProvider, |
| | toProvider, |
| | reason); |
| | } |
| | |
| | public void LogProviderError( |
| | AIProviderType provider, |
| | Exception error, |
| | AIRequest request) |
| | { |
| | _logger.LogError(error, |
| | "[ProviderError] Provider={Provider}, RequestLength={Length}, Error={Message}", |
| | provider, |
| | request.Prompt.Length, |
| | error.Message); |
| | } |
| | } |

7. 线程安全

ConcurrentDictionary 等并发集合的使用确保线程安全:

复制代码

|---|------------------------------------------------------------------------------|
| | public class ThreadSafeProviderCache |
| | { |
| | private readonly ConcurrentDictionary<AIProviderType, IAIProvider> _cache; |
| | private readonly ReaderWriterLockSlim _lock = new(); |
| | |
| | public IAIProvider? GetProvider(AIProviderType type) |
| | { |
| | // 读取操作无需锁 |
| | if (_cache.TryGetValue(type, out var provider)) |
| | return provider; |
| | |
| | // 创建需要写锁 |
| | _lock.EnterWriteLock(); |
| | try |
| | { |
| | // 双重检查 |
| | if (_cache.TryGetValue(type, out provider)) |
| | return provider; |
| | |
| | var newProvider = CreateProvider(type); |
| | if (newProvider != null) |
| | { |
| | _cache[type] = newProvider; |
| | } |
| | return newProvider; |
| | } |
| | finally |
| | { |
| | _lock.ExitWriteLock(); |
| | } |
| | } |
| | } |

8. 数据库迁移

会话线程绑定数据库结构变更时需要考虑数据迁移:

复制代码

|---|-----------------------------------------------------------------------------------------------------|
| | public class SessionThreadMigration |
| | { |
| | public async Task MigrateAsync(string dbPath) |
| | { |
| | var version = await GetSchemaVersionAsync(dbPath); |
| | |
| | if (version >= 2) return; // 已是最新版本 |
| | |
| | using var connection = new SqliteConnection(dbPath); |
| | connection.Open(); |
| | |
| | // 迁移到 v2:添加 CreatedAtUtc 列 |
| | if (version < 2) |
| | { |
| | _logger.LogInformation("Migrating SessionThreadBindings to v2..."); |
| | |
| | using var addColumnCommand = connection.CreateCommand(); |
| | addColumnCommand.CommandText = "ALTER TABLE SessionThreadBindings ADD COLUMN CreatedAtUtc TEXT;"; |
| | addColumnCommand.ExecuteNonQuery(); |
| | |
| | using var backfillCommand = connection.CreateCommand(); |
| | backfillCommand.CommandText = |
| | """ |
| | UPDATE SessionThreadBindings |
| | SET CreatedAtUtc = COALESCE(NULLIF(UpdatedAtUtc, ''), $nowUtc) |
| | WHERE CreatedAtUtc IS NULL OR CreatedAtUtc = ''; |
| | """; |
| | backfillCommand.Parameters.AddWithValue("$nowUtc", DateTimeOffset.UtcNow.ToString("O")); |
| | backfillCommand.ExecuteNonQuery(); |
| | } |
| | |
| | await UpdateSchemaVersionAsync(dbPath, 2); |
| | _logger.LogInformation("Migration to v2 completed"); |
| | } |
| | } |

总结

hagicode 通过提供者模式、工厂模式和选择器模式的组合,实现了一个灵活、可扩展的多 AI 提供者架构:

  • 统一接口抽象IAIProvider 接口屏蔽了不同 CLI 的差异
  • 动态实例创建AIProviderFactory 支持运行时创建提供者实例
  • 智能选择策略AIProviderSelector 实现场景驱动的提供者选择
  • 会话状态持久化:通过数据库绑定确保会话连续性
  • 桌面端集成AgentCliManager 支持用户选择和配置

这种架构设计的优势在于:

  1. 可扩展性 :添加新的 AI 提供者只需实现 IAIProvider 接口
  2. 可测试性:提供者可以独立测试和模拟
  3. 可维护性:每个提供者的实现独立,职责单一
  4. 用户友好:支持场景自动选择和手动切换
相关推荐
数智化管理手记1 小时前
精益生产合理化建议核心解读:本质、价值与提报规范
大数据·网络·人工智能·低代码·制造
SUNNY_SHUN2 小时前
VLM走进农田:AgriChat覆盖3000+作物品类,607K农业视觉问答基准开源
论文阅读·人工智能·算法·开源
黎阳之光2 小时前
视频孪生赋能车路云一体化,领跑智慧高速新征程
人工智能·算法·安全·数字孪生
cxr8282 小时前
GPU 加速声场求解器 CUDA Kernel 实现细节 —— 高频超声传播仿真并行计算引擎
人工智能·python·目标跟踪
空空潍2 小时前
Spring AI 实战系列(十一):MCP实战 —— 接入第三方 MCP生态
人工智能·spring ai
赶路人儿2 小时前
Claude Code 泄露源码 - 本地可运行版本
人工智能
一 铭2 小时前
Claude Code实现原理分析-架构设计
人工智能·大模型
蔡俊锋2 小时前
AI提示词零基础入门:从“无效提问”到“精准输出”,核心方法论全拆解
人工智能·ai提示词·ai工程·ai沟通
LaughingZhu2 小时前
移动端 AI 的价值重估:设备端智能的拐点
大数据·人工智能·经验分享·搜索引擎·语音识别