跳转到内容

ACP

4 篇包含标签 "ACP" 的文章

Hermes Agent 集成实践:从协议到生产

Hermes Agent 集成实践:从协议到生产

Section titled “Hermes Agent 集成实践:从协议到生产”

分享 HagiCode 集成 Hermes Agent 的完整实践,包括 ACP 协议适配、会话池管理、前后端契约同步等核心经验。

在构建 AI 辅助编码平台 HagiCode 的过程中,团队需要集成一个既能在本地运行又能扩展到云端的 Agent 框架。经过调研,Nous Research 的 Hermes Agent 被选为综合 Agent 的底层引擎。

其实选型这事儿,说难也不难,说简单也不简单。毕竟市面上能打的 Agent 框架也不少,只是 Hermes 那个 ACP 协议和工具系统确实有点东西,刚好契合 HagiCode “既要又要”的需求场景——本地开发、团队协作和云端扩展。但要把 Hermes 真正融入生产系统,还需要解决一系列工程问题,这可不是闹着玩的。

HagiCode 的技术栈基于 Orleans 构建分布式系统,前端使用 React + TypeScript。集成 Hermes 需要在保持现有架构统一性的前提下,让 Hermes 成为与 ClaudeCode、OpenCode 等并行的”一等公民”执行器。说起来容易,做起来嘛,也就那样吧。

本文分享我们在 HagiCode 项目中集成 Hermes Agent 的实践经验,希望能给面临类似需求的团队提供参考。毕竟,踩过的坑,没必要让别人再踩一遍。

本文分享的方案来自我们在 HagiCode 项目中的实践经验。HagiCode 是一个 AI 驱动的编码辅助平台,支持多种 AI Provider 的统一接入和管理。在集成 Hermes Agent 的过程中,我们设计了一套通用的 Provider 抽象层,使得新的 Agent 类型可以无缝接入现有系统。

如果你对 HagiCode 感兴趣,欢迎访问 GitHub 了解更多。多个人看,多份力量罢了。

HagiCode 的 Hermes 集成采用了清晰的分层架构,每层各司其职:

后端核心层

  • HermesCliProvider: 实现 IAIProvider 接口,作为统一的 AI Provider 入口
  • HermesPlatformConfiguration: 管理 Hermes 可执行文件路径、参数、认证等配置
  • ICliProvider<HermesOptions>: HagiCode.Libs 提供的底层 CLI 抽象,处理子进程生命周期

传输层

  • StdioAcpTransport: 通过标准输入输出与 Hermes ACP 子进程通信
  • ACP 协议方法:initializeauthenticatesession/newsession/prompt

运行时层

  • HermesGrain: Orleans Grain 实现,处理分布式会话执行
  • CliAcpSessionPool: 会话池,复用 ACP 子进程,避免频繁启动开销

前端层

  • ExecutorAvatar: Hermes 视觉标识和图标
  • executorTypeAdapter: Provider 类型映射逻辑
  • SignalR 实时消息传递:保持 Hermes 身份在消息流中的一致性

这种分层设计使得各层可以独立演进,比如未来要添加新的传输方式(如 WebSocket),只需修改传输层即可。毕竟,谁愿意因为改一个传输方式就把整个系统都翻一遍呢?多累啊。

所有 AI Provider 都实现 IAIProvider 接口,这是 HagiCode 架构的核心设计:

public interface IAIProvider
{
string Name { get; }
ProviderCapabilities Capabilities { get; }
IAsyncEnumerable<AIStreamingChunk> StreamAsync(
AIRequest request,
CancellationToken cancellationToken = default);
Task<AIResponse> ExecuteAsync(
AIRequest request,
CancellationToken cancellationToken = default);
}

HermesCliProvider 实现了这个接口,与 ClaudeCodeProviderOpenCodeProvider 等处于平等地位。这种设计带来的好处:

  1. 可替换性: 切换 Provider 不影响上层业务逻辑
  2. 可测试性: 可以轻松 Mock Provider 进行单元测试
  3. 可扩展性: 新增 Provider 只需实现接口即可

说到底,接口这东西,就像规矩一样。有了规矩,大家才能和谐共处,各自发挥所长,互不干扰。这难道不是一种美吗?

HermesCliProvider 是整个集成的核心,它负责协调各个组件完成一次 AI 调用:

public sealed class HermesCliProvider : IAIProvider, IVersionedAIProvider
{
private readonly ICliProvider<LibsHermesOptions> _provider;
private readonly ConcurrentDictionary<string, string> _sessionBindings;
public ProviderCapabilities Capabilities { get; } = new()
{
SupportsStreaming = true,
SupportsTools = true,
SupportsSystemMessages = true,
SupportsArtifacts = false
};
public async IAsyncEnumerable<AIStreamingChunk> StreamAsync(
AIRequest request,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
// 1. 解析会话绑定 key
var bindingKey = ResolveBindingKey(request.CessionId);
// 2. 通过会话池获取或创建 Hermes 会话
var options = new HermesOptions
{
ExecutablePath = _platformConfiguration.ExecutablePath,
Arguments = _platformConfiguration.Arguments,
SessionId = _sessionBindings.TryGetValue(bindingKey, out var sessionId) ? sessionId : null,
WorkingDirectory = request.WorkingDirectory,
Model = request.Model
};
// 3. 执行并收集流式响应
await foreach (var message in _provider.ExecuteAsync(options, request.Prompt, cancellationToken))
{
// 4. 映射 ACP 消息到 AIStreamingChunk
if (_responseMapper.TryConvertToStreamingChunk(message, out var chunk))
{
yield return chunk;
}
}
}
}

这里有几个关键设计点:

  1. 会话绑定: 通过 CessionId 将多个请求绑定到同一个 Hermes 子进程,实现多轮对话的上下文连续性
  2. 响应映射: 将 Hermes ACP 消息格式转换为统一的 AIStreamingChunk 格式
  3. 流式处理: 使用 IAsyncEnumerable 支持真正的流式响应

其实会话绑定这事儿,就像人和人之间的关系一样。一旦建立了联系,后续的交流就有了上下文,不用每次都从头开始。只是这关系要维护好,不然断了就断了。

Hermes 使用 ACP(Agent Communication Protocol)协议,与传统的 HTTP API 不同。ACP 是基于标准输入输出的协议,有几个特点:

  1. 启动标记: Hermes 进程启动后会输出 //ready 标记
  2. 动态认证: 认证方法不是固定的,需要通过协议协商
  3. 会话复用: 通过 SessionId 复用已建立的会话
  4. 响应分散: 完整响应可能分散在多个 session/update 通知中

HagiCode 通过 StdioAcpTransport 处理这些特性:

public class StdioAcpTransport
{
public async Task InitializeAsync(CancellationToken cancellationToken)
{
// 等待 //ready 标记
var readyLine = await _outputReader.ReadLineAsync(cancellationToken);
if (readyLine != "//ready")
{
throw new InvalidOperationException("Hermes did not send ready signal");
}
// 发送 initialize 请求
await SendRequestAsync(new
{
jsonrpc = "2.0",
id = 1,
method = "initialize",
@params = new
{
protocolVersion = "2024-11-05",
capabilities = new { },
clientInfo = new { name = "HagiCode", version = "1.0.0" }
}
}, cancellationToken);
}
}

协议这东西,就像人与人之间的默契。有了默契,交流就会顺畅很多。只是建立默契需要时间,磨合嘛,谁都免不了。

频繁启动 Hermes 子进程的开销很大,因此我们实现了会话池机制:

services.AddSingleton(static _ =>
{
var registry = new CliProviderPoolConfigurationRegistry();
registry.Register("hermes", new CliPoolSettings
{
MaxActiveSessions = 50,
IdleTimeout = TimeSpan.FromMinutes(10)
});
return registry;
});

会话池的关键参数:

  • MaxActiveSessions: 控制并发上限,避免资源耗尽
  • IdleTimeout: 空闲超时,平衡启动成本和内存占用

实践中我们发现:

  1. 空闲超时设置太短会导致频繁重启,设置太长会占用内存
  2. 并发上限需要根据实际负载调整,过大可能导致系统卡顿
  3. 需要监控会话池的使用情况,以便及时调整参数

这就好比人生中的许多选择,太激进容易出问题,太保守又错失机会。找个平衡点罢了。

前端需要正确识别 Hermes Provider 并显示对应的视觉元素:

executorTypeAdapter.ts
export const resolveExecutorVisualTypeFromProviderType = (
providerType: PCode_Models_AIProviderType | null | undefined
): ExecutorVisualType => {
switch (providerType) {
case PCode_Models_AIProviderType.HERMES_CLI:
return 'Hermes';
default:
return 'Unknown';
}
};

Hermes 有专属的图标和颜色标识:

ExecutorAvatar.tsx
const renderExecutorGlyph = (executorType: ExecutorVisualType, iconSize: number) => {
switch (executorType) {
case 'Hermes':
return (
<svg viewBox="0 0 24 24" fill="none" className="h-4 w-4">
<rect x="4" y="4" width="16" height="16" rx="4" fill="currentColor" opacity="0.16" />
<path d="M8 7v10M16 7v10M8 12h8" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
);
default:
return <DefaultAvatar />;
}
};

毕竟,美的东西也要有美的呈现。只是这美要被人看见,还得靠前端同学的努力。

前后端通过 OpenAPI 生成保持契约一致。后端定义了 AIProviderType 枚举:

public enum AIProviderType
{
Unknown,
ClaudeCode,
OpenCode,
HermesCli // 新增
}

前端通过 OpenAPI 生成对应的 TypeScript 类型,确保枚举值一致。这是避免前端显示 “Unknown” 的关键。

契约这东西,就像承诺一样。说好了就要做到,不然就会出现”Unknown”这种尴尬的局面。

Hermes 的配置通过 appsettings.json 管理:

{
"Providers": {
"HermesCli": {
"ExecutablePath": "hermes",
"Arguments": "acp",
"StartupTimeoutMs": 10000,
"ClientName": "HagiCode",
"Authentication": {
"PreferredMethodId": "api-key",
"MethodInfo": {
"api-key": "your-api-key-here"
}
},
"SessionDefaults": {
"Model": "claude-sonnet-4-20250514",
"ModeId": "default"
}
}
}
}

这种配置驱动的设计带来了灵活性:

  • 可以覆盖可执行文件路径,方便开发测试
  • 可以自定义启动参数,适配不同版本的 Hermes
  • 可以配置认证信息,支持多种认证方式

配置这东西,就像人生的选择题。给足了选项,总能找到适合自己的那一个。只是有时候选项太多,也会让人犯选择困难症。

实现一个可靠的 Provider 需要完善的健康检查:

public async Task<ProviderTestResult> PingAsync(CancellationToken cancellationToken = default)
{
var response = await ExecuteAsync(new AIRequest
{
Prompt = "Reply with exactly PONG.",
CessionId = null,
AllowedTools = Array.Empty<string>(),
WorkingDirectory = ResolveWorkingDirectory(null)
}, cancellationToken);
var success = string.Equals(response.Content.Trim(), "PONG", StringComparison.OrdinalIgnoreCase);
return new ProviderTestResult
{
ProviderName = Name,
Success = success,
ResponseTimeMs = stopwatch.ElapsedMilliseconds,
ErrorMessage = success ? null : $"Unexpected Hermes ping response: '{response.Content}'."
};
}

健康检查需要注意:

  1. 使用简单的测试用例,避免复杂场景
  2. 设置合理的超时时间
  3. 记录响应时间,便于性能分析

就像人需要体检一样,系统也需要健康检查。早发现早治疗,省得到时候出大问题。

HagiCode 提供专用控制台用于验证 Hermes 集成:

Terminal window
# 基础验证
HagiCode.Libs.Hermes.Console --test-provider
# 完整套件(含仓库分析)
HagiCode.Libs.Hermes.Console --test-provider-full --repo .
# 自定义可执行文件
HagiCode.Libs.Hermes.Console --test-provider-full --executable /path/to/hermes

这个工具在开发过程中非常有用,可以快速验证集成是否正确。毕竟,谁愿意在发现问题的时候才想起来去测试呢?

认证失败

  • 检查 Authentication.PreferredMethodId 与 Hermes 实际支持的认证方法是否匹配
  • 确认认证信息格式正确(API Key、Bearer Token 等)

会话超时

  • 增加 StartupTimeoutMs
  • 检查 MCP 服务器可达性
  • 查看系统资源使用情况

响应不完整

  • 确保正确聚合 session/update 通知和最终结果
  • 检查流式处理的取消逻辑
  • 验证错误处理是否完整

前端显示 Unknown

  • 确认 OpenAPI 生成已包含 HermesCli 枚举值
  • 检查类型映射是否正确
  • 清除浏览器缓存重新生成类型

问题嘛,总会有的。只是遇到问题的时候,别慌,慢慢找原因,总能解决的。毕竟,办法总比困难多。

  1. 使用会话池: 复用 ACP 子进程,减少启动开销
  2. 合理设置超时: 平衡内存和启动成本
  3. 复用会话 ID: 批量任务使用同一个 CessionId
  4. 按需配置 MCP: 避免不必要的工具调用

性能这东西,就像生活中的效率。做对了,事半功倍;做错了,事倍功半。只是找到那个”对”的点,需要经验和运气。

集成 Hermes Agent 到生产系统需要考虑多个层面的问题:

  1. 架构层面: 设计统一的 Provider 接口,实现可替换的组件架构
  2. 协议层面: 正确处理 ACP 协议的特殊性,如启动标记、动态认证等
  3. 性能层面: 通过会话池复用资源,平衡启动成本和内存占用
  4. 前端层面: 确保契约同步,提供一致的视觉体验

HagiCode 的实践表明,通过良好的分层设计和配置驱动,可以将复杂的 Agent 系统无缝集成到现有架构中。

其实这些道理说起来都挺简单的,只是真正做起来的时候,总会遇到各种各样的问题。不过没关系,问题解决了就是经验,解决不了就是教训,都是有价值的东西。

美的事物或人,不一定要占有,只要她还是美的,自己好好看着她的美就好了。技术也是如此,只要能让系统变得更好,用什么框架、什么协议,其实都没那么重要…

感谢您的阅读,如果您觉得本文有用,欢迎点赞、收藏和分享支持。 本内容采用人工智能辅助协作,最终内容由作者审核并确认。

HagiCode 为什么选择 Hermes 作为综合 Agent 核心

HagiCode 为什么选择 Hermes 作为综合 Agent 核心

Section titled “HagiCode 为什么选择 Hermes 作为综合 Agent 核心”

在构建 AI 辅助编码平台时,选择合适的 Agent 核心直接决定了系统能力的天花板。毕竟有些事情,勉强不来——选错了框架,怎么折腾都不得劲。本文分享 HagiCode 在技术选型中的思考,以及 Hermes Agent 的集成实践。

做 AI 辅助编码这事儿,最头疼的莫过于选择底层 Agent 框架了。其实市面上可选的方案也挺多的,只是吧——有的功能太简单,有的部署太复杂,有的扩展性又不够看。我们要的是一个既能跑在 5 美元 VPS 上,又能接入 GPU 集群的方案,这要求说高也不高,说低吧,也不少人被劝退了。

但实际情况是,很多所谓的”全能 Agent” 要么只能跑在云端,要么本地部署要求高得离谱。花了两周时间调研各种方案后,我们做了一个大胆的决定:整个 Agent 核心推倒重来,采用 Hermes 作为综合 Agent 的底层引擎。

这决定带来的一切,或许都是冥冥之中罢。

本文分享的方案来自 HagiCode 项目中的实践经验。HagiCode 是一个 AI 辅助编码平台,通过 VSCode 扩展、桌面客户端和 Web 服务,为开发者提供智能编码助手。或许你也用过类似的工具,只是总觉得差了那么一口气——这我们也理解。

在详细介绍 Hermes 之前,先说说 HagiCode 为什么会有这样的需求。这世上的事情啊,往往不是你想怎么样就能怎么样的,总得找个合适的由头。

作为一个 AI 代码助手,HagiCode 需要同时支持多种使用场景:

  • 本地开发环境:开发者希望在自gu电脑上运行,数据不出本地——这年头,数据安全这事说大不大,说小也不小
  • 团队协作环境:小团队可以共享部署在服务器上的 Agent——省钱嘛,大家都不容易
  • 云端弹力扩展:处理复杂任务时,能自动扩展到 GPU 集群——有备无患

这种”既要又要”的需求,让我们把目光投向了 Hermes。这选择对不对我不知道,只是当时也没别的更好的办法了。

Hermes Agent 是由 Nous Research 创建的自主 AI Agent。可能有人对 Nous Research 不熟悉——他们就是开发了 Hermes、Nomos 和 Psyché 等开源大模型的实验室。说起来他们也挺不容易的,做了这么多好东西,知道的人却不多。

跟传统的 IDE 编程助手或者简单的 API 聊天包装器不同,Hermes 有一个特点:运行时间越长,能力越强。它不是一次性完成任务就完事,而是能在长时间运行中持续学习和积累经验。这点也挺像人的,是不是?

Hermes 的几个核心特性,正好契合了 HagiCode 的需求。你说巧不巧?

这意味着 HagiCode 可以根据用户场景,选择最合适的部署方式。个人用户本地跑,团队用户服务器部署,复杂任务上 GPU——一套代码搞定。这世道,能省一事算一事罢。

多平台消息网关 Hermes 原生支持 Telegram、Discord、Slack、WhatsApp 等平台。对 HagiCode 来说,这意味着未来可以轻松支持这些渠道的 AI 助手。毕竟谁不想多几条路呢?

丰富的工具系统 40+ 内置工具,加上 MCP(Model Context Protocol)扩展能力。这对于代码助手来说太重要了——执行 shell 命令、操作文件系统、调用 Git,这些都需要工具支持。没有工具的 Agent,就像没有翅膀的鸟——想飞也飞不起来。

跨会话记忆 Hermes 有持久记忆系统,用 FTS5 全文检索召回历史对话。这让 Agent 能记住之前的上下文,不会每次都”失忆”。有时候我也想失忆一下,什么都不想,可就是做不到。

说完了”为什么”,接下来看看”怎么做”。有些事情想明白了,就得动手,光想不做也不是个事儿。

在 HagiCode 的架构中,所有 AI Provider 都实现统一的 IAIProvider 接口:

public sealed class HermesCliProvider : IAIProvider, IVersionedAIProvider
{
public ProviderCapabilities Capabilities { get; } = new ProviderCapabilities
{
SupportsStreaming = true, // 支持流式输出
SupportsTools = true, // 支持工具调用
SupportsSystemMessages = true, // 支持系统提示
SupportsArtifacts = false
};
}

这个抽象层让 HagiCode 可以无缝切换不同的 AI Provider,无论是 OpenAI、Claude 还是 Hermes,上层调用方式完全一致。说白了,就是省事儿。

Hermes 使用 ACP (Agent Communication Protocol) 进行通信。这是一个专门为 Agent 通信设计的协议,主要方法包括:

方法说明
initialize初始化连接,获取协议版本和客户端能力
authenticate处理认证,支持多种认证方法
session/new创建新会话,设置工作目录和 MCP 服务器
session/prompt发送提示并获取响应

HagiCode 通过 StdioAcpTransport 实现 ACP 传输层,启动 Hermes 子进程并通过标准输入输出进行通信。这事儿听起来复杂,做起来也还行——主要是要有耐心。

通过 HermesPlatformConfiguration 类管理配置:

public sealed class HermesPlatformConfiguration : IAcpPlatformConfiguration
{
public string ExecutablePath { get; set; } = "hermes";
public string Arguments { get; set; } = "acp";
public int StartupTimeoutMs { get; set; } = 5000;
public string ClientName { get; set; } = "HagiCode";
public HermesAuthenticationConfiguration Authentication { get; set; }
public HermesSessionDefaultsConfiguration SessionDefaults { get; set; }
}

appsettings.json 中配置 Hermes:

{
"Providers": {
"HermesCli": {
"ExecutablePath": "hermes",
"Arguments": "acp",
"StartupTimeoutMs": 10000,
"ClientName": "HagiCode",
"Authentication": {
"PreferredMethodId": "api-key",
"MethodInfo": {
"api-key": "your-api-key-here"
}
},
"SessionDefaults": {
"Model": "claude-sonnet-4-20250514",
"ModeId": "default"
}
}
}
}

配置这东西吧,看着简单,真要调对了也得费些功夫。

HagiCode 使用 Orleans 构建分布式系统,Hermes 集成通过以下组件实现:

  • HermesGrain:Orleans Grain 实现,处理会话执行
  • HermesPlatformConfiguration:平台特定配置
  • HermesAcpSessionAdapter:ACP 会话适配器
  • HermesConsole:专用的验证控制台

Orleans 这名字起得挺好听的,传说中的阿里巴巴——虽然此 Orleans 非彼 Orleans,但名字好听总是加分的。

以下是 Hermes Provider 的核心执行逻辑:

private async IAsyncEnumerable<AIStreamingChunk> StreamCoreAsync(
AIRequest request,
string? embeddedCommandPrompt,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
// 1. 创建传输层,启动 Hermes 子进程
await using var transport = new StdioAcpTransport(
platformConfiguration.GetExecutablePath(),
platformConfiguration.GetArguments(),
platformConfiguration.GetEnvironmentVariables(),
platformConfiguration.GetStartupTimeout(),
_loggerFactory.CreateLogger<StdioAcpTransport>());
await transport.ConnectAsync(cancellationToken);
// 2. 初始化,获取协议版本和认证方法
var initializeResult = await SendHermesRequestAsync(
transport, nextRequestId++, "initialize",
BuildInitializeParameters(platformConfiguration), cancellationToken);
// 3. 处理认证
var authMethods = ParseAuthMethods(initializeResult);
if (!isAuthenticated)
{
var methodId = platformConfiguration.Authentication.ResolveMethodId(authMethods);
await SendHermesRequestAsync(transport, nextRequestId++, "authenticate", ...);
}
// 4. 创建会话
var newSessionResult = await SendHermesRequestAsync(
transport, nextRequestId++, "session/new",
BuildNewSessionParameters(platformConfiguration, workingDirectory, model), cancellationToken);
var sessionId = ParseSessionId(newSessionResult);
// 5. 执行提示并收集流式响应
await foreach (var payload in transport.ReceiveMessagesAsync(cancellationToken))
{
// 处理 session/update 通知,转换为流式块
if (TryParseSessionNotification(root, out var notification))
{
if (_responseMapper.TryConvertToStreamingChunk(notification, out var chunk))
{
yield return chunk;
}
}
}
}

代码嘛,看多了也就那么回事。重要的是思路,对吧?

为了保证 Hermes 服务的可用性,HagiCode 实现了健康检查机制:

public async Task<ProviderTestResult> PingAsync(CancellationToken cancellationToken = default)
{
var response = await ExecuteAsync(
new AIRequest
{
Prompt = "Reply with exactly PONG.",
CessionId = null,
AllowedTools = Array.Empty<string>(),
WorkingDirectory = ResolveWorkingDirectory(null)
},
cancellationToken);
var success = string.Equals(response.Content.Trim(), "PONG", StringComparison.OrdinalIgnoreCase);
return new ProviderTestResult
{
ProviderName = Name,
Success = success,
ResponseTimeMs = stopwatch.ElapsedMilliseconds,
ErrorMessage = success ? null : $"Unexpected Hermes ping response: '{response.Content}'."
};
}

这大概就是所谓的”健康检查”了罢。其实人也一样,总要时不时检查一下自己——只是通常没人告诉我们应该检查什么。

集成 Hermes 过程中,有一些坑值得提前了解。这年头,谁还没踩过几个坑呢?

Hermes 支持多种认证方法(API Key、Token 等),需要根据实际部署情况选择。配置错误会导致连接失败,但错误信息可能不够直观。有时候报错信息跟实际原因差了十万八千里,得慢慢排查。

创建会话时可以配置 MCP 服务器列表,让 Hermes 调用外部工具。但要注意:

  • MCP 服务器地址必须可访问
  • 超时时间要合理设置
  • 服务器不可用时的降级处理

这世道,防不胜防啊。

每个会话都需要指定工作目录,确保 Hermes 能正确访问项目文件。对于多项目场景,需要动态切换工作目录。说起来简单,做起来要考虑的情况也挺多的。

Hermes 的响应可能分散在 session/update 通知和最终结果中,需要正确合并处理,否则会出现内容丢失。这事儿我也没少吃亏,慢慢就好了。

运行时错误应该明确返回,而不是静默回退到其他 Provider。这样用户才知道是 Hermes 出了问题,而不是莫名其妙换了别的模型。毕竟糊弄事儿也不是这么个糊弄法。

HagiCode 选择 Hermes 作为综合 Agent 核心,不是拍脑袋的决定,而是基于实际需求和技术特点的慎重选择。这选择对不对,现在说也为时过早,只是目前用起来还算顺手。

Hermes 提供的灵活部署能力,让 HagiCode 可以适应各种使用场景;强大的工具系统和 MCP 支持,让 AI 助手能真正干实事;而 ACP 协议和 Provider 抽象层,则让整个集成过程清晰可控。

如果你正在为你的 AI 项目选择 Agent 框架,希望这篇文章能提供一些参考。毕竟,选对底层架构,后续开发会轻松很多…

感谢您的阅读,如果您觉得本文有用,欢迎点赞、收藏和分享支持。 本内容采用人工智能辅助协作,最终内容由作者审核并确认。

C# 后端集成 CodeBuddy CLI 实战指南

C# 后端集成 CodeBuddy CLI 实战指南

Section titled “C# 后端集成 CodeBuddy CLI 实战指南”

本文将详细介绍如何在 C# 后端项目中集成 CodeBuddy CLI,实现 AI 编程助手能力的完整方案。

在现代 AI 代码助手开发中,单一 AI Provider 往往无法满足复杂多变的开发场景。这就像,人生路远,总不能只认一个方向吧?HagiCode 作为一款多功能 AI 编程助手,需要支持多种 AI Provider 以提供更好的用户体验。毕竟,用户的选择权还是要给够的。在 2026 年初,项目面临一个关键决策:如何在 C# 后端中恢复 CodeBuddy 的 ACP(Agent Communication Protocol)集成能力。

此前项目中曾实现过 CodeBuddy 对接,但相关代码在一次重构中被移除了。其实也没什么好抱怨的,代码迭代嘛,总有东西要被遗忘。本次技术方案的目标是完整恢复这一能力,并优化架构使其更加健壮和可维护。

如果你也在考虑为自己的项目接入多种 AI 编程助手,下面的方案或许能给你一些启发——这可是我们踩了无数坑之后总结出来的经验。或许能让你少走点弯路,也算是我做过的一点好事吧。

本文分享的方案来自我们在 HagiCode 项目中的实践经验。HagiCode 是一个开源的 AI 代码助手项目,支持多种 AI Provider 和跨平台运行。为了满足不同用户的偏好,我们需要能够灵活切换各种 AI 编程助手,这就有了本文要介绍的 CodeBuddy 集成方案。

HagiCode 采用模块化设计,AI Provider 作为可插拔的组件,这种架构让我们可以轻松添加新的 AI 支持,而不影响现有功能。这也罢了,设计这种东西,当初做得好,后面省心不少。如果你对我们的技术架构感兴趣,可以在 GitHub 上查看完整源码。

C# 与 CodeBuddy 的对接采用清晰的分层架构,这种设计让代码职责分明,后期维护起来也更加方便:

┌─────────────────────────────────────────────┐
│ Provider 契约层 │
│ AIProviderType 枚举 + 扩展方法 │
├─────────────────────────────────────────────┤
│ Provider 工厂层 │
│ AIProviderFactory 依赖注入工厂 │
├─────────────────────────────────────────────┤
│ Provider 实现层 │
│ CodebuddyCliProvider 具体实现 │
├─────────────────────────────────────────────┤
│ ACP 基础设施层 │
│ ACPSessionManager / StdioAcpTransport │
│ AcpRpcClient / AcpAgentClient │
└─────────────────────────────────────────────┘

这种分层的好处是什么呢?简单说就是各层之间互不打扰。假设以后要换一种通信方式(比如从 stdio 改成 WebSocket),你只需要改最下面那一层,上面的业务代码完全不用动。毕竟,谁也不想牵一发而动全身,改个通信方式还要改半天业务代码,那也太惨了。

Provider 契约层 是整个架构的基石。我们定义了 AIProviderType 枚举,其中 CodebuddyCli = 3 作为枚举值,通过扩展方法实现字符串与枚举的双向映射。这样配置文件中的字符串可以很方便地转成枚举,调试时枚举也能转成字符串输出。这也罢了,其实就是个映射关系,但做好了就是省心。

Provider 工厂层 负责根据配置创建对应的 Provider 实例。这里使用了 .NET 的依赖注入机制,配合 ActivatorUtilities.CreateInstance 实现动态创建。工厂模式的好处在于,新增一个 Provider 时只需要添加创建逻辑,不用修改已有的代码。这和写文章差不多,想加个新章节,就加个新章节,不用把前面的都重写一遍。

Provider 实现层 是真正干活的地方。CodebuddyCliProvider 实现了 IAIProvider 接口,提供 ExecuteAsync(非流式)和 StreamAsync(流式)两种调用方式。

ACP 基础设施层 则是通信的底层支撑。这一层处理所有的协议细节,包括进程管理、消息序列化、响应解析等。就像房子的地基,上面盖得再漂亮,底下的东西得稳才行。

CodeBuddy 使用 Stdio(标准输入输出) 方式与外部进程通信。启动命令很简单:

Terminal window
codebuddy --acp

然后通过标准输入输出进行 JSON-RPC 消息交换。这种方式的优势在于:

  1. 启动迅速:本地进程通信没有网络延迟
  2. 配置简单:只需要指定可执行文件路径
  3. 环境隔离:每个会话独立进程,互不影响

通信过程中支持环境变量注入,常用的包括:

  • CODEBUDDY_API_KEY:API 密钥认证
  • CODEBUDDY_INTERNET_ENVIRONMENT:网络环境配置

这就像,人与人之间的沟通,找个方便的方式,才能说得上话。

ACP 基于 JSON-RPC 2.0 协议,消息格式大概是酱紫的:

// 请求消息
{
"jsonrpc": "2.0",
"id": 1,
"method": "agent/prompt",
"params": {
"prompt": "帮我写一个排序算法",
"sessionId": "session-123"
}
}
// 响应消息
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"content": "这里是 AI 的回复..."
}
}

实际实现中,我们把这些协议细节都封装好了,上层业务代码只需要关注 prompt 和 response 就行。这也罢了,封装得好,后面的人用起来就舒服点。

首先在枚举文件中恢复 CodeBuddy 类型:

PCode.Models/AIProviderType.cs
public enum AIProviderType
{
ClaudeCodeCli = 0,
CodexCli = 1,
GitHubCopilot = 2,
CodebuddyCli = 3, // 恢复这个枚举值
OpenCodeCli = 4,
IFlowCli = 5,
}

然后在扩展方法中添加字符串映射,这样配置文件就可以用字符串指定 Provider:

AIProviderTypeExtensions.cs
private static readonly Dictionary<string, AIProviderType> _typeMap = new(
StringComparer.OrdinalIgnoreCase)
{
["CodebuddyCli"] = AIProviderType.CodebuddyCli,
["Codebuddy"] = AIProviderType.CodebuddyCli,
["codebuddy"] = AIProviderType.CodebuddyCli,
// ... 其他 provider 的映射
};

在工厂类中添加 CodeBuddy 的创建分支:

AIProviderFactory.cs
private IAIProvider? CreateProvider(AIProviderType providerType, ProviderConfiguration config)
{
return providerType switch
{
AIProviderType.CodebuddyCli =>
ActivatorUtilities.CreateInstance<CodebuddyCliProvider>(
_serviceProvider,
Options.Create(config)),
// ... 其他 provider
_ => throw new NotSupportedException($"Provider {providerType} not supported")
};
}

这里用了依赖注入的 ActivatorUtilities,它会自动处理构造函数的参数注入,非常方便。这也罢了,.NET 的东西,用对了就是省心。

下面是 CodebuddyCliProvider 的核心实现,包含了流式和非流式两种调用方式:

public class CodebuddyCliProvider : IAIProvider
{
private readonly ILogger<CodebuddyCliProvider> _logger;
private readonly IACPSessionManager _sessionManager;
private readonly ProviderConfiguration _config;
public string Name => "CodebuddyCli";
public bool SupportsStreaming => true;
public ProviderCapabilities Capabilities { get; }
public CodebuddyCliProvider(
ILogger<CodebuddyCliProvider> logger,
IACPSessionManager sessionManager,
IOptions<ProviderConfiguration> config)
{
_logger = logger;
_sessionManager = sessionManager;
_config = config.Value;
// 定义当前 Provider 的能力
Capabilities = new ProviderCapabilities
{
SupportsStreaming = true,
SupportsTools = true,
SupportsSystemMessages = true,
SupportsArtifacts = false,
MaxTokens = 8192
};
}
// 非流式调用:等所有结果一起返回
public async Task<AIResponse> ExecuteAsync(
AIRequest request,
CancellationToken cancellationToken = default)
{
// 为请求创建独立会话
var session = await _sessionManager.CreateSessionAsync(
"CodebuddyCli",
request.WorkingDirectory,
cancellationToken,
request.SessionId);
try
{
var fullPrompt = BuildPrompt(request);
await session.SendPromptAsync(fullPrompt, cancellationToken);
var responseBuilder = new StringBuilder();
var toolCalls = new List<AIToolCall>();
// 收集所有响应块
await foreach (var chunk in StreamFromSession(session, cancellationToken))
{
if (!string.IsNullOrEmpty(chunk.Content))
{
responseBuilder.Append(chunk.Content);
}
// 处理工具调用...
}
return new AIResponse
{
Content = AIResultContentSanitizer.SanitizeResultContent(
responseBuilder.ToString()),
ToolCalls = toolCalls,
Provider = Name,
Model = string.Empty
};
}
finally
{
// 释放会话资源
await session.DisposeAsync();
}
}
// 流式调用:实时返回响应块
public async IAsyncEnumerable<AIStreamingChunk> StreamAsync(
AIRequest request,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
var session = await _sessionManager.CreateSessionAsync(
"CodebuddyCli",
request.WorkingDirectory,
cancellationToken);
try
{
var fullPrompt = BuildPrompt(request);
await session.SendPromptAsync(fullPrompt, cancellationToken);
await foreach (var chunk in StreamFromSession(session, cancellationToken))
{
yield return chunk;
}
}
finally
{
await session.DisposeAsync();
}
}
private async IAsyncEnumerable<AIStreamingChunk> StreamFromSession(
IACPSession session,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
// 遍历会话中的所有更新
await foreach (var notification in session.ReceiveUpdatesAsync(cancellationToken))
{
switch (notification.Update)
{
case AgentMessageChunkSessionUpdate agentMessage:
// 处理文本内容块
if (agentMessage.Content is AcpImp.TextContentBlock textContent)
{
yield return new AIStreamingChunk
{
Content = textContent.Text,
Type = StreamingChunkType.ContentDelta,
IsComplete = false
};
}
break;
case ToolCallSessionUpdate toolCall:
// 处理工具调用
yield return new AIStreamingChunk
{
Content = string.Empty,
Type = StreamingChunkType.ToolCallDelta,
ToolCallDelta = new AIToolCallDelta
{
Id = toolCall.ToolCallId,
Name = toolCall.Kind.ToString(),
Arguments = toolCall.RawInput?.ToString()
}
};
break;
case AcpImp.PromptCompletedSessionUpdate:
// 响应完成
yield break;
}
}
}
// 构建完整的提示词
private string BuildPrompt(AIRequest request, string? embeddedCommandPrompt = null)
{
var sb = new StringBuilder();
// 嵌入命令提示词(如果有)
if (!string.IsNullOrEmpty(embeddedCommandPrompt))
{
sb.AppendLine(embeddedCommandPrompt);
sb.AppendLine();
}
// 系统消息
if (!string.IsNullOrEmpty(request.SystemMessage))
{
sb.AppendLine(request.SystemMessage);
sb.AppendLine();
}
// 用户 prompt
sb.Append(request.Prompt);
return sb.ToString();
}
}

这段代码有几个关键点:

  1. 会话管理:每个请求创建独立会话,请求完成后释放资源。这是坑踩出来的经验——如果会话复用做得不好,很容易出现状态污染的问题。毕竟,用过就得收拾干净,不然下次用的人就麻烦了。

  2. 流式处理IAsyncEnumerable 让响应可以边生成边返回,不用等全部内容生成完。这对于长文本场景特别重要,用户体验会好很多。就像,等结果的人也不想一直干等着不是。

  3. 工具调用:CodeBuddy 支持工具调用(Function Calling),通过 ToolCallSessionUpdate 处理。这个能力对于复杂的代码编辑任务很关键。

  4. 内容过滤:使用 AIResultContentSanitizer 过滤 Think 块内容,保持输出干净。

在模块注册中添加相关服务:

PCodeClaudeHelperModule.cs
public void ConfigureModule(IServiceCollection context)
{
// 注册 Provider
context.Services.AddTransient<CodebuddyCliProvider>();
// 注册 ACP 基础设施
context.Services.AddSingleton<IACPSessionManager, ACPSessionManager>();
context.Services.AddSingleton<IAcpPlatformConfigurationResolver, AcpPlatformConfigurationResolver>();
context.Services.AddSingleton<IAIRequestToAcpMapper, AIRequestToAcpMapper>();
context.Services.AddSingleton<IAcpToAIResponseMapper, AcpToAIResponseMapper>();
}

appsettings.json 中添加 CodeBuddy 相关配置:

AI:
# 默认使用的 Provider
DefaultProvider: "CodebuddyCli"
# Provider 配置
Providers:
CodebuddyCli:
Type: "CodebuddyCli"
WorkingDirectory: "C:/projects/my-app"
ExecutablePath: "C:/tools/codebuddy.cmd"
# 平台相关配置
PlatformConfigurations:
CodebuddyCli:
ExecutablePath: "C:/tools/codebuddy.cmd"
Arguments: "--acp"
StartupTimeoutMs: 5000
EnvironmentVariables:
CODEBUDDY_API_KEY: "${CODEBUDDY_API_KEY}"
CODEBUDDY_INTERNET_ENVIRONMENT: "production"

对应的配置模型定义:

public class CodebuddyPlatformConfiguration : IAcpPlatformConfiguration
{
public string ProviderName => "CodebuddyCli";
public AcpTransportType TransportType => AcpTransportType.Stdio;
public string ExecutablePath { get; set; } = "codebuddy";
public string Arguments { get; set; } = "--acp";
public int StartupTimeoutMs { get; set; } = 5000;
public Dictionary<string, string?>? EnvironmentVariables { get; set; }
}

我们在实现过程中遇到了几个典型的坑,分享出来让大家少走弯路。毕竟,别人的坑,自己能避开就是好事:

  1. 会话泄漏问题:一开始没有正确释放会话,导致进程资源耗尽。解决方法是使用 try-finally 确保每次请求都会释放资源。这也罢了,用过的东西得放回去,不然后面的人用什么。

  2. 环境变量传递:Windows 和 Linux 的环境变量语法不同,后来统一使用 Dictionary<string, string?> 来处理。跨平台这种事,一开始就统一规范,后面就省心。

  3. 超时配置:CLI 启动需要时间,设置了 5 秒的启动超时,避免快速请求失败。凡事都得有个度,太急了反而办不成事。

  4. 编码问题:Windows 上默认编码可能导致中文乱码,在启动进程时显式指定 UTF-8 编码。中文显示不出来,那多难受。

  1. 会话池:对于频繁的短请求,可以考虑实现会话池来复用进程
  2. 连接缓存:工厂类已经支持 Provider 实例缓存
  3. 异步优先:全程使用异步编程,避免阻塞线程

性能这种事,能优化就优化,毕竟用户等的越久,体验就越差。

本文详细介绍了 C# 后端集成 CodeBuddy CLI 的完整方案,涵盖了从架构设计到具体实现的全过程。通过分层架构设计,我们将协议细节与业务逻辑分离,使得代码更加清晰和可维护。

核心要点回顾:

  • 采用 Provider 契约层、工厂层、实现层、基础设施层的分层架构
  • 使用 JSON-RPC over Stdio 方式进行进程间通信
  • 通过依赖注入实现灵活的配置和扩展
  • 提供流式和非流式两种调用方式

这套方案不仅适用于 CodeBuddy,添加新的 AI Provider 也遵循同样的模式。如果你也在做类似的多 AI Provider 集成,希望这篇文章能给你一些参考。其实,写文章和写代码一样,分享出来,能帮到别人就算没白写。



如果本文对你有帮助:

HagiCode 平台的多 AI Provider 架构实践

HagiCode 平台的多 AI Provider 架构实践

Section titled “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 是一个基于 Orleans Grain 架构的 AI 辅助开发平台,通过统一的 IAIProvider 接口与不同的 AI 提供者集成,让用户可以灵活选择自己喜欢的 AI 工具。

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

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 的实现

Section titled “IFlowCliProvider:基于 WebSocket 的实现”

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

IFlowCliProvider → ACPSessionManager → WebSocketAcpTransport → iflow CLI
动态端口分配 + 进程管理

核心流程也挺简单:

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

来看看核心代码:

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 的实现

Section titled “OpenCodeCliProvider:基于 HTTP API 的实现”

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

OpenCodeCliProvider → OpenCodeRuntimeManager → OpenCodeClient → OpenCode HTTP API
OpenCodeProcessManager → opencode 进程管理

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

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 存储绑定关系,重启后仍然有效,有些东西记住了就是记住了
方面IFlowCliProviderOpenCodeCliProvider
通信方式WebSocket (ACP)HTTP API
进程管理ACPSessionManagerOpenCodeProcessManager
端口分配动态端口无端口(使用 HTTP)
会话管理ACPSessionOpenCodeSession
持久化内存缓存SQLite 数据库
启动命令iflow —experimental-acp —portopencode
延迟更低(长连接)相对较高(HTTP 请求)

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

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

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
// 通过 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);
}
}
// 通过 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 是否可用:

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 这样的命令:

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 是个好工具,可以验证配置是否正确:

// 启动时检查
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 工具的集成,希望本文对你有帮助。不过话说回来,技术这东西,能帮到人就好,其他的也不必太在意…


如果本文对你有帮助: