HagiCode 平台的多 AI Provider 架构实践

HagiCode 平台的多 AI Provider 架构实践

本文分享了在 Orleans Grain 架构下,如何通过统一的 IAIProvider 接口集成 iflow 和 OpenCode 两个 AI 工具的技术方案,并详细对比了 WebSocket 和 HTTP 两种通信方式的实现差异。

背景

其实也没什么特别的,就是做 HagiCode 的时候遇到了个挺实际的问题------用户想用不同的 AI 工具,这倒也不奇怪,毕竟每个人都有自己的习惯。有的喜欢 Claude Code,有的钟爱 GitHub Copilot,还有些团队用自己开发的工具。

最开始的方案也挺简单粗暴的,就是给每个 AI 工具写专门的对接代码。可后来问题就来了------代码里全是 if-else,改一个地方要到处测试,新工具接入还得重新写一堆逻辑,想想都觉得累。

后来我想明白了,不如做一个统一的 IAIProvider 接口,把所有 AI 提供者的能力都抽象出来。这样,不管底层用的是哪个工具,对上层来说都是一样的调用方式,岂不美哉?

最近项目要接入两个新工具:iflow 和 OpenCode。这两个都支持 ACP 协议,但通信方式不太一样------iflow 用 WebSocket,OpenCode 用 HTTP API。这也算是种考验吧,要在统一的接口下适配两种不同的通信模式,不过想想也挺有意思的。

关于 HagiCode

本文分享的方案来自我们在 HagiCode 项目中的实践经验。HagiCode 是一个基于 Orleans Grain 架构的 AI 辅助开发平台,通过统一的 IAIProvider 接口与不同的 AI 提供者集成,让用户可以灵活选择自己喜欢的 AI 工具。

架构设计

统一的接口抽象

首先,定义了 IAIProvider 接口,把所有 AI 提供者需要实现的能力都抽象出来:

csharp 复制代码
public interface IAIProvider
{
    string Name { get; }
    bool SupportsStreaming { get; }
    ProviderCapabilities Capabilities { get; }

    Task<AIResponse> ExecuteAsync(AIRequest request, CancellationToken cancellationToken = default);
    IAsyncEnumerable<AIStreamingChunk> StreamAsync(AIRequest request, CancellationToken cancellationToken = default);
    Task<ProviderTestResult> PingAsync(CancellationToken cancellationToken = default);
    IAsyncEnumerable<AIStreamingChunk> SendMessageAsync(AIRequest request, string? embeddedCommandPrompt = null, CancellationToken cancellationToken = default);
}

这个接口有几个关键方法:

  • ExecuteAsync:执行一次性的 AI 请求
  • StreamAsync:流式获取响应,支持实时展示
  • PingAsync:健康检查,验证 provider 是否可用
  • SendMessageAsync:发送消息,支持嵌入式命令

IFlowCliProvider:基于 WebSocket 的实现

iflow 使用 WebSocket 进行 ACP 通信,整体架构是这样的:

复制代码
IFlowCliProvider → ACPSessionManager → WebSocketAcpTransport → iflow CLI
                ↓
         动态端口分配 + 进程管理

核心流程也挺简单:

  1. ACPSessionManager 负责创建和管理 ACP 会话
  2. WebSocketAcpTransport 处理 WebSocket 通信
  3. 动态分配一个端口,用 iflow --experimental-acp --port {port} 启动 iflow 进程
  4. 通过 IAIRequestToAcpMapper 和 IAcpToAIResponseMapper 做请求/响应的转换

来看看核心代码:

csharp 复制代码
private async IAsyncEnumerable<AIStreamingChunk> StreamCoreAsync(
    AIRequest request,
    string? embeddedCommandPrompt,
    [EnumeratorCancellation] CancellationToken cancellationToken)
{
    // 解析工作目录
    var resolvedWorkingDirectory = ResolveWorkingDirectory(request);
    var effectiveRequest = ApplyEmbeddedCommandPrompt(request, embeddedCommandPrompt);

    // 创建 ACP 会话
    await using var session = await _sessionManager.CreateSessionAsync(
        Name,
        resolvedWorkingDirectory,
        cancellationToken,
        request.SessionId);

    // 发送提示词
    var prompt = _requestMapper.ToPromptString(effectiveRequest);
    var promptResponse = await session.SendPromptAsync(prompt, cancellationToken);

    // 接收流式响应
    await foreach (var notification in session.ReceiveUpdatesAsync(cancellationToken))
    {
        if (_responseMapper.TryConvertToStreamingChunk(notification, out var chunk))
        {
            if (chunk.Type == StreamingChunkType.Metadata && chunk.IsComplete)
            {
                yield return chunk;
                yield break;
            }
            yield return chunk;
        }
    }
}

这里有几个设计上的注意点,也算是一些小心得:

  • 用 await using 确保会话正确释放,避免资源泄漏,毕竟资源这东西,不用了就该放归自然
  • 流式响应通过 IAsyncEnumerable 返回,天然支持异步流
  • Metadata 类型的 chunk 判断是否完成,确保完整接收响应

OpenCodeCliProvider:基于 HTTP API 的实现

OpenCode 用 HTTP API 方式提供服务,架构略有不同:

复制代码
OpenCodeCliProvider → OpenCodeRuntimeManager → OpenCodeClient → OpenCode HTTP API
                      ↓
                OpenCodeProcessManager → opencode 进程管理

OpenCode 的特点是用 SQLite 数据库持久化会话绑定关系,这样可以支持会话恢复和提示词响应恢复,这倒是挺贴心的设计:

csharp 复制代码
private async Task<OpenCodePromptExecutionResult> ExecutePromptAsync(
    AIRequest request,
    string? embeddedCommandPrompt,
    CancellationToken cancellationToken)
{
    var prompt = BuildPrompt(request, embeddedCommandPrompt);
    var resolvedWorkingDirectory = ResolveWorkingDirectory(request.WorkingDirectory);
    var client = await _runtimeManager.GetClientAsync(resolvedWorkingDirectory, cancellationToken);
    var bindingSessionId = request.SessionId;
    var boundSession = TryGetBinding(bindingSessionId, resolvedWorkingDirectory);

    // 尝试使用已绑定的会话
    if (boundSession is not null)
    {
        try
        {
            return await PromptSessionAsync(
                client,
                boundSession,
                BuildPromptRequest(request, prompt, CreatePromptMessageId()),
                request.Model ?? _settings.Model,
                cancellationToken);
        }
        catch (OpenCodeApiException ex) when (IsStaleBinding(ex))
        {
            // 会话已过期,移除绑定
            RemoveBinding(bindingSessionId);
        }
    }

    // 创建新会话
    var session = await client.Session.CreateAsync(new OpenCodeSessionCreateRequest
    {
        Title = BuildSessionTitle(request)
    }, cancellationToken);

    BindSession(bindingSessionId, session.Id, resolvedWorkingDirectory);
    return await PromptSessionAsync(client, session.Id, ...);
}

这个实现有几个亮点,或者说几个有趣的地方:

  • 会话绑定机制:同一个 SessionId 会复用 OpenCode 会话,避免重复创建,省得浪费资源
  • 过期处理:检测到会话过期时自动清理绑定,旧的不去,新的不来
  • 数据库持久化:通过 SQLite 存储绑定关系,重启后仍然有效,有些东西记住了就是记住了

两种方式的对比

方面 IFlowCliProvider OpenCodeCliProvider
通信方式 WebSocket (ACP) HTTP API
进程管理 ACPSessionManager OpenCodeProcessManager
端口分配 动态端口 无端口(使用 HTTP)
会话管理 ACPSession OpenCodeSession
持久化 内存缓存 SQLite 数据库
启动命令 iflow --experimental-acp --port opencode
延迟 更低(长连接) 相对较高(HTTP 请求)

选择哪种方式主要看你的需求:WebSocket 适合实时性要求高的场景,HTTP API 则更简单、更容易调试。这就像选路一样,有的路快一点,有的路好走一点罢了。

实践指南

配置 Provider

先在配置文件里启用这两个 provider:

yaml 复制代码
AI:
  Providers:
    IFlowCli:
      Type: "IFlowCli"
      Enabled: true
      ExecutablePath: "iflow"
      Model: null
      WorkingDirectory: null
    OpenCodeCli:
      Type: "OpenCodeCli"
      Enabled: true
      ExecutablePath: "opencode"
      Model: "anthropic/claude-sonnet-4"
      WorkingDirectory: null

OpenCode:
  Enabled: true
  BaseUrl: "http://localhost:38376"
  ExecutablePath: "opencode"
  StartupTimeoutSeconds: 30
  RequestTimeoutSeconds: 120

使用 IFlowCliProvider

csharp 复制代码
// 通过 Factory 获取 provider
var provider = await _providerFactory.GetProviderAsync(AIProviderType.IFlowCli);

// 执行 AI 请求
var request = new AIRequest
{
    Prompt = "请帮我重构这个函数",
    WorkingDirectory = "/path/to/project",
    Model = "claude-sonnet-4"
};

// 获取完整响应
var response = await provider.ExecuteAsync(request, cancellationToken);
Console.WriteLine(response.Content);

// 或者用流式响应
await foreach (var chunk in provider.StreamAsync(request, cancellationToken))
{
    if (chunk.Type == StreamingChunkType.ContentDelta)
    {
        Console.Write(chunk.Content);
    }
}

使用 OpenCodeCliProvider

csharp 复制代码
// 通过 Factory 获取 provider
var provider = await _providerFactory.GetProviderAsync(AIProviderType.OpenCodeCli);

var request = new AIRequest
{
    Prompt = "请帮我分析这个错误",
    WorkingDirectory = "/path/to/project",
    Model = "anthropic/claude-sonnet-4"
};

var response = await provider.ExecuteAsync(request, cancellationToken);
Console.WriteLine(response.Content);

健康检查

在启动或使用前,可以先检查 provider 是否可用:

csharp 复制代码
var iflowResult = await iflowProvider.PingAsync(cancellationToken);
if (!iflowResult.Success)
{
    Console.WriteLine($"IFlow 不可用: {iflowResult.ErrorMessage}");
    return;
}

var openCodeResult = await openCodeProvider.PingAsync(cancellationToken);
if (!openCodeResult.Success)
{
    Console.WriteLine($"OpenCode 不可用: {openCodeResult.ErrorMessage}");
    return;
}

嵌入式命令支持

两个 provider 都支持嵌入式命令,比如 /file:xxx 这样的命令:

csharp 复制代码
var request = new AIRequest
{
    Prompt = "分析这个文件的问题",
    SystemMessage = "你是一个代码分析专家"
};

await foreach (var chunk in provider.SendMessageAsync(
    request,
    embeddedCommandPrompt: "/file:src/main.cs",
    cancellationToken))
{
    Console.Write(chunk.Content);
}

注意事项和最佳实践

资源管理

IFlow 用 WebSocket 长连接,所以资源管理要特别注意:

  • 用 await using 确保会话正确释放,不用了就放手
  • 取消操作会触发进程清理
  • ACPSessionManager 支持最大会话数限制

OpenCode 的进程管理相对简单,OpenCodeRuntimeManager 会自动处理,省心不少。

错误处理

两个 provider 都有完善的错误处理:

  • IFlow 的错误通过 ACP 会话更新传播
  • OpenCode 的错误通过 OpenCodeApiException 抛出
  • 建议在调用方捕获并处理这些异常,毕竟错误总会发生的

性能考虑

  • IFlow 的 WebSocket 通信比 HTTP 有更低的延迟
  • OpenCode 的会话复用可以减少 HTTP 请求开销
  • Factory 的缓存机制可以避免重复创建 provider
  • 高并发场景下,要关注进程数和连接数的限制,别到时候撑不住了

配置验证

启动时会验证可执行文件路径,但运行时也可能出问题。PingAsync 是个好工具,可以验证配置是否正确:

csharp 复制代码
// 启动时检查
var provider = await _providerFactory.GetProviderAsync(providerType);
var result = await provider.PingAsync(cancellationToken);
if (!result.Success)
{
    _logger.LogError("Provider {ProviderType} 不可用: {Error}", providerType, result.ErrorMessage);
}

总结

本文分享了 HagiCode 平台在集成 iflow 和 OpenCode 两个 AI 工具时的技术方案。通过统一的 IAIProvider 接口,实现了对不同通信方式(WebSocket 和 HTTP)的适配,同时保持了上层调用的一致性。

核心思路其实挺简单的:

  1. 定义统一的接口抽象
  2. 对不同实现做适配层
  3. 通过工厂模式统一管理

这样扩展性就很好,以后有新的 AI 工具要接入,只需要实现 IAIProvider 接口就行,不用改动太多现有代码。想想也挺合理的,就像搭积木一样,有统一的接口,想怎么拼都行。

如果你也在做多 AI 工具的集成,希望本文对你有帮助。不过话说回来,技术这东西,能帮到人就好,其他的也不必太在意......

参考资料


如果本文对你有帮助: