跳转到内容

博客

HagiCode Desktop 混合分发架构解析:如何用 P2P 加速大文件下载

HagiCode Desktop 混合分发架构解析:如何用 P2P 加速大文件下载

Section titled “HagiCode Desktop 混合分发架构解析:如何用 P2P 加速大文件下载”

其实这篇文章憋了很久才写出来,也不知道写得好不好,毕竟技术文章这东西,写出来容易,写得有味道难。不过想想算了,反正也不是什么大文豪,无神来笔,写尽此粗文罢了。

做桌面应用开发的团队,或早或晚都会遇到一个让人头疼的问题:大文件怎么分发?

这事儿说起来也是无奈。传统的 HTTP/HTTPS 直链下载,在文件体积小、用户量不多的时候,其实也还能 hold 住——就像年少时的感情,简单纯粹,没什么波澜。可是啊,时光这东西最是无情,随着项目不断发展,安装包越来越大:Desktop 端 ZIP 包、便携式包(portable package)、Web 部署归档……问题就慢慢浮现出来了:

  • 下载速度受限于源站带宽:单一服务器带宽再高,也架不住大家同时下载。这就像什么呢?就像你喜欢一个人,可她的心就那么大,早就住满了别人,你再怎么努力,也挤不进去。
  • 断点续传能力基本为零:HTTP 下载要是断了,就得从头来过,浪费时间不说,还浪费带宽。美又何必在乎天晴阴呢?可惜天不遂人愿。
  • 源站承压严重:所有流量都涌向中心服务器,带宽成本蹭蹭往上涨,扩展性也成了问题。这大概就是所谓的中心化的无奈吧——什么都压在一个点上,迟早要崩。

HagiCode Desktop 项目也不例外。咱在设计分发系统的时候,就琢磨着:能不能在不改变现有 index.json 控制面的前提下,搞一套混合分发方案?既能利用 P2P 网络的分布式特性加速下载,又能保留 HTTP 回源兜底,确保企业网络这种受限环境下的可用性。

这个决定带来的变化,可能比你想象的还要大——别急,下面我会细细道来。毕竟有些事情,说出来才能被理解。

本文分享的方案来自我们在 HagiCode 项目中的实践经验。HagiCode 是一个开源的 AI 代码助手项目,致力于帮助开发团队提升研发效率。项目涵盖了前端、后端、桌面端启动器、文档、构建和服务器部署等多个子系统。

Desktop 端的混合分发架构,正是 HagiCode 在实际运营中踩坑、优化出来的方案。或许有人会问,写这些有什么意义呢?其实也没什么意义,只是觉得如果这套方案有价值,说明我们在工程实践上还是有点心得的——那么 HagiCode 本身也值得关注一下罢了。

项目的 GitHub 地址是 HagiCode-org/site,有兴趣的可以先点个 Star 收藏起来。毕竟美好的东西,值得被收藏。

核心设计思想:P2P 优先,HTTP 回源

Section titled “核心设计思想:P2P 优先,HTTP 回源”

说白了,混合分发的核心思想就一句话:P2P 优先、HTTP 回源

这方案的关键在于「混合」二字。不是简单地把 BitTorrent 扔上来就完事了,而是要让两种下载方式协同工作、取长补短:

  • P2P 网络提供分布式加速,下载的人越多,节点越多,速度越快。这就像什么呢?就像你我都曾是少年时的那个ta,心中有光,便觉得世界都会亮起来。
  • WebSeed/HTTP 回源保障可用性,企业防火墙、内网环境也能正常下载。毕竟有些地方,不是你想进就能进的。
  • 控制面保持简单,不用改 index.json 的核心逻辑,只是增加可选的元数据字段。简单有什么不好呢?复杂的事情做多了,偶尔简单一下,也挺好的。

这样做的好处是啥呢?用户体验到的是「下载更快」,而技术团队不需要为 P2P 的复杂性买单太多——毕竟 BT 协议本身就已经很成熟了,我们也懒得重复造轮子。

先上一张整体架构图,让大家有个宏观印象:

┌─────────────────────────────────────┐
│ Renderer (UI 层) │
├─────────────────────────────────────┤
│ IPC/Preload (桥接层) │
├─────────────────────────────────────┤
│ VersionManager (版本管理) │
├─────────────────────────────────────┤
│ HybridDownloadCoordinator (协调层) │
│ ├── DistributionPolicyEvaluator │
│ ├── DownloadEngineAdapter │
│ ├── CacheRetentionManager │
│ └── SHA256 Verifier │
├─────────────────────────────────────┤
│ WebTorrent (下载引擎) │
└─────────────────────────────────────┘

从这张图可以看出,整个系统是分层设计的。为什么要分这么细呢?主要是为了可测试性和可替换性。其实做人也是这个道理——把事情分清楚,各司其职,世界也就简单了。

  • UI 层负责展示下载进度、共享加速开关——这是门面
  • 协调层是核心,包含策略评估、引擎适配、缓存管理、完整性校验——这是内核
  • 引擎层封装具体的下载实现,目前用的是 WebTorrent——这是工具

引擎层抽象成 DownloadEngineAdapter 接口,以后要是想换成别的 BT 引擎,或者搞个 sidecar 进程,跑起来也不费劲。毕竟谁也不想在一棵树上吊死,代码世界也是如此。

HagiCode Desktop 保持 index.json 作为唯一的控制面,这个设计非常关键。控制面负责版本发现、渠道选择、中心化策略,而数据面才是真正下载文件的地方。

index.json 新增的字段是可选的:

{
"asset": {
"torrentUrl": "https://cdn.example.com/app.torrent",
"infoHash": "abc123...",
"webSeeds": [
"https://cdn.example.com/app.zip",
"https://backup.example.com/app.zip"
],
"sha256": "def456...",
"directUrl": "https://cdn.example.com/app.zip"
}
}

这些字段都是可选的,缺失了就回退到传统的 HTTP 下载模式。这样设计的好处是向后兼容,老版本的客户端完全不受影响。毕竟世界在变,可有些东西不能变——变了就回不去了。

不是所有文件都值得用 P2P 分发。其实这世间的事大抵如此——不是什么都要争一把,有些东西,不适合就是不适合,退一步海阔天空。

DistributionPolicyEvaluator 负责评估策略,只有满足以下条件的文件才会启用混合下载:

  1. 来源类型必须是 HTTP index:GitHub 直接下载或本地文件夹源,不走这套。毕竟不是所有的路都适合 P2P。
  2. 文件大小必须 ≥ 100MB:小文件用 P2P 的开销反而得不偿失。感情也是如此,有些事情太小了,不值得大费周章。
  3. 必须具备完整的混合元数据:torrentUrl、webSeeds、sha256 缺一不可。缺一样都不行,这就是规矩。
  4. 仅限 latest desktop 包和 web 部署包:历史版本用传统方式就行。新人笑,旧人哭,何必呢?
class DistributionPolicyEvaluator {
evaluate(version: Version, settings: SharingAccelerationSettings): HybridDownloadPolicy {
// 检查来源类型
if (version.sourceType !== 'http-index') {
return { useHybrid: false, reason: 'not-http-index' };
}
// 检查元数据完整性
if (!version.hybrid) {
return { useHybrid: false, reason: 'not-eligible' };
}
// 检查是否启用
if (!settings.enabled) {
return { useHybrid: false, reason: 'shared-disabled' };
}
// 检查资产类型(仅 latest desktop/web 包)
if (!version.hybrid.isLatestDesktopAsset && !version.hybrid.isLatestWebAsset) {
return { useHybrid: false, reason: 'latest-only' };
}
return { useHybrid: true, reason: 'shared-enabled' };
}
}

这样做的好处是,系统行为可预测。不管是开发者还是用户,都能清楚地知道哪些文件会走 P2P、哪些不会。毕竟预期管理好了,人心也就稳了。

先来看看类型定义,这是整个系统的基础。其实类型定义这东西,就像给事物定性——一旦定好了,后面的路就好走了。

// 混合分发元数据
interface HybridDistributionMetadata {
torrentUrl?: string; // 种子文件 URL
infoHash?: string; // InfoHash
webSeeds: string[]; // WebSeed 列表
sha256?: string; // 文件哈希
directUrl?: string; // HTTP 直链(回源用)
eligible: boolean; // 是否符合混合分发条件
thresholdBytes: number; // 阈值(字节)
assetKind: VersionAssetKind;
isLatestDesktopAsset: boolean;
isLatestWebAsset: boolean;
}
// 共享加速设置
interface SharingAccelerationSettings {
enabled: boolean; // 总开关
uploadLimitMbps: number; // 上传限速
cacheLimitGb: number; // 缓存上限
retentionDays: number; // 保留天数
hybridThresholdMb: number; // 混合分发阈值
onboardingChoiceRecorded: boolean;
}
// 下载进度
interface VersionDownloadProgress {
current: number;
total: number;
percentage: number;
stage: VersionInstallStage; // queued, downloading, backfilling, verifying, extracting, completed, error
mode: VersionDownloadMode; // http-direct, shared-acceleration, source-fallback
peers?: number; // 连接的节点数
p2pBytes?: number; // P2P 获取字节数
fallbackBytes?: number; // 回源获取字节数
verified?: boolean; // 是否已校验
}

类型定义清楚了,后面的实现就顺理成章了。或许这就是所谓的「好的开始是成功的一半」吧,虽然这话俗了点。

HybridDownloadCoordinator 是整个下载流程的编排者,它协调策略评估、引擎执行、SHA256 校验和缓存管理。说起来挺复杂的,但其实核心逻辑也就那么几步,像极了人生——看似纷繁复杂,抽丝剥茧之后,不过尔尔。

class HybridDownloadCoordinator {
async download(
version: Version,
cachePath: string,
packageSource: PackageSource,
onProgress?: DownloadProgressCallback,
): Promise<HybridDownloadResult> {
// 1. 评估策略:是否使用混合下载
const policy = this.policyEvaluator.evaluate(version, settings);
// 2. 执行下载
if (policy.useHybrid) {
await this.engine.download(version, cachePath, settings, onProgress);
} else {
await packageSource.downloadPackage(version, cachePath, onProgress);
}
// 3. SHA256 校验(硬门槛)
const verified = await this.verify(version, cachePath, onProgress);
if (!verified) {
await this.cacheRetentionManager.discard(version.id, cachePath);
throw new Error(`sha256 verification failed for ${version.id}`);
}
// 4. 标记为可信缓存,开始受控做种
await this.cacheRetentionManager.markTrusted({
versionId: version.id,
cachePath,
cacheSize,
}, settings);
return { cachePath, policy, verified };
}
}

这里有一个关键点:SHA256 校验是硬门槛。下载的文件必须校验通过,才能进入安装流程。校验失败就丢弃缓存,保证不会出现「下载了错误文件导致安装出问题」的情况。

这像什么呢?就像信任这件事——一旦被辜负,再想重建就难了。所以从一开始,就把门槛立好。

DownloadEngineAdapter 是一个抽象接口,定义了引擎必须实现的方法:

interface DownloadEngineAdapter {
download(
version: Version,
destinationPath: string,
settings: SharingAccelerationSettings,
onProgress?: (progress: VersionDownloadProgress) => void,
): Promise<void>;
stopAll(): Promise<void>;
}

V1 实现基于 WebTorrent,封装在 InProcessTorrentEngineAdapter 中:

class InProcessTorrentEngineAdapter implements DownloadEngineAdapter {
async download(...) {
const client = this.getClient(settings); // 应用上传限速
const torrent = client.add(torrentId, {
path: path.dirname(destinationPath),
destroyStoreOnDestroy: false,
maxWebConns: 8,
});
// 添加 WebSeed
torrent.on('ready', () => {
for (const seed of hybrid.webSeeds) {
torrent.addWebSeed(seed);
}
if (hybrid.directUrl) {
torrent.addWebSeed(hybrid.directUrl);
}
});
// 进度报告 - 区分 P2P 和回源
torrent.on('download', () => {
const hasP2PPeer = torrent.wires.some(w => w.type !== 'webSeed');
const mode = hasP2PPeer ? 'shared-acceleration' : 'source-fallback';
// ... 报告进度
});
}
}

引擎可插拔的设计,让未来的优化变得简单。比如 V2 可以把引擎跑在 helper process 里,避免主进程崩溃的风险。毕竟谁也不想一颗老鼠屎坏了一锅粥,代码世界如此,人生亦然。

在 UI 层,用户最关心的是「我现在是 P2P 下载还是 HTTP 回源」?InProcessTorrentEngineAdapter 通过检查 torrent.wires 的类型来判断:

const hasP2PPeer = torrent.wires.some((wire) => wire.type !== 'webSeed');
const hasFallbackWire = torrent.wires.some((wire) => wire.type === 'webSeed');
const mode = hasP2PPeer ? 'shared-acceleration'
: hasFallbackWire ? 'source-fallback'
: 'shared-acceleration';
const stage = hasP2PPeer ? 'downloading'
: hasFallbackWire ? 'backfilling'
: 'downloading';

这个逻辑看起来简单,但它是用户体验的关键。用户能清楚地看到当前是「共享加速」还是「回源补块」,心里有底。其实人和人之间也是如此——透明一点,大家都安心。

完整性校验使用 Node.js 的 crypto 模块,进行流式哈希计算,避免把整个文件加载到内存:

private async computeSha256(filePath: string): Promise<string> {
const hash = createHash('sha256');
await new Promise<void>((resolve, reject) => {
const stream = fs.createReadStream(filePath);
stream.on('data', (chunk) => hash.update(chunk));
stream.on('error', reject);
stream.on('end', resolve);
});
return hash.digest('hex').toLowerCase();
}

这个实现对大文件特别友好。想想看,要是下载了一个 2GB 的安装包,然后要把整个文件读入内存校验,那内存占用得多恐怖?流式处理就能完美解决这个问题。

这像不像感情?有些东西,不必一次性全部拥有,一点一点来,反而更好。

完整的数据流是这样的:

┌────────────────────────────────────────────────────────────────────┐
│ 用户点击安装大文件版本 │
└────────────────────────────────────────────────────────────────────┘
┌────────────────────────────────────────────────────────────────────┐
│ VersionManager 调用协调器 │
│ HybridDownloadCoordinator.download() │
└────────────────────────────────────────────────────────────────────┘
┌────────────────────────────────────────────────────────────────────┐
│ DistributionPolicyEvaluator.evaluate() │
│ 检查:来源、元数据、开关、资产类型 │
└────────────────────────────────────────────────────────────────────┘
┌───────────┴───────────┐
│ useHybrid? │
└───────────┬───────────┘
是 │ │ 否
▼ ▼
┌──────────────────┐ ┌─────────────────────┐
│ P2P + WebSeed │ │ HTTP 直链下载 │
│ 混合下载 │ │ (兼容路径) │
└──────────────────┘ └─────────────────────┘
┌──────────────────┐
│ SHA256 校验 │
│ (硬门槛) │
└────────┬─────────┘
┌────────┴─────────┐
│ 通过? │
└────────┬─────────┘
是 │ │ 否
▼ ▼
┌────────────┐ ┌────────────────┐
│ 解压安装 │ │ 丢弃缓存+报错 │
│ +受控做种 │ └────────────────┘
└────────────┘

整个流程非常清晰,每个步骤都有明确的职责。出了什么问题,也能快速定位是哪个环节出了问题。毕竟事情就怕糊涂,糊涂了就难办了。

技术方案再好,如果用户体验不好,那也是白搭。HagiCode Desktop 在产品化上做了不少工作。毕竟技术是骨子里的事,产品是皮囊,皮囊不好看,骨头再好也没人愿意多看一眼。

大多数用户不懂什么是 BitTorrent、什么是 InfoHash。所以产品层面用了「共享加速」这个语义:

  • 功能叫「共享加速」,不叫 P2P 下载
  • 设置项叫「上传限速」,不说做种
  • 进度显示「回源补块」,不说 WebSeed 回退

这样一来,术语的认知负担就小了。其实说话也是一门艺术,说得简单点,大家都轻松。

新用户第一次使用桌面端,会看到一个向导页面,其中有一页介绍共享加速功能:

为了加快下载速度,我们会在您下载时与其他用户共享已下载的部分文件。这个过程是完全可选的,您随时可以在设置中关闭。

默认是开启的,但提供明确的取消入口。企业用户如果不需要,大可以在向导里关掉。毕竟选择权在用户手里,没人喜欢被强迫。

设置页面提供三个可调整的参数:

参数默认值说明
上传限速2 MB/s防止占用过多上行带宽
缓存上限10 GB控制磁盘空间占用
保留天数7 天超过这个时间自动清理缓存

这些参数都有合理的默认值,普通用户不用改,高级用户可以根据自己的网络环境调整。毕竟众口难调,给点自由度总是好的。

回顾整个方案,有几个关键决策值得说一说:

为什么不一开始就搞 sidecar/helper process?原因很简单:快速上线。主进程内方案开发周期短、调试方便,先把功能跑起来,再考虑稳定性优化。

当然,这个决策是有代价的:引擎崩溃会影响主进程。所以通过适配器边界和超时控制来缓解这个问题。同时预留了迁移路径,V2 可以轻松迁移到独立进程。

这像不像年轻时的我们?先上车再说,后面的事情后面再想办法。毕竟有些时候,想太多反而迈不开步子。

不用 MD5 或 CRC32,而用 SHA256,是因为 SHA256 更安全。MD5 和 CRC32 的碰撞成本太低了,万一有人恶意构造假的安装包,后果不堪设想。SHA256 的计算开销虽然大一些,但安全性值得这个代价。

信任这东西,建立起来难,崩塌起来却是一瞬间的事。所以在能选安全的时候,就别省那点成本。

GitHub 下载、本地文件夹源等场景,不走混合分发。这不是技术限制,而是避免复杂化。BT 协议在私有网络里的价值本来就不大,而且会增加不必要的代码复杂度。

有些圈子,不必强融。道理就是这么简单。

在 SharingAccelerationSettingsStore 中,所有数值都要做边界检查和规范化:

private normalize(settings: SharingAccelerationSettings): SharingAccelerationSettings {
return {
enabled: Boolean(settings.enabled),
uploadLimitMbps: this.clampNumber(settings.uploadLimitMbps, 1, 200, DEFAULT_SETTINGS.uploadLimitMbps),
cacheLimitGb: this.clampNumber(settings.cacheLimitGb, 1, 500, DEFAULT_SETTINGS.cacheLimitGb),
retentionDays: this.clampNumber(settings.retentionDays, 1, 90, DEFAULT_SETTINGS.retentionDays),
hybridThresholdMb: DEFAULT_SETTINGS.hybridThresholdMb, // 固定值,不让用户改
onboardingChoiceRecorded: Boolean(settings.onboardingChoiceRecorded),
};
}
private clampNumber(value: number, min: number, max: number, fallback: number): number {
if (!Number.isFinite(value)) {
return fallback;
}
return Math.min(max, Math.max(min, Math.round(value)));
}

这样可以防止用户手动改配置文件导致异常值。毕竟你永远不知道用户会输入什么奇怪的数字,我也不想看见那张配置的截图,可是没辙。

CacheRetentionManager.prune() 方法负责清理过期和超限的缓存。清理策略是 LRU(最近最少使用):

const records = [...this.listRecords()]
.sort((left, right) =>
new Date(left.lastUsedAt).getTime() - new Date(right.lastUsedAt).getTime()
);
// 清理超限时,从最久未使用的开始删除
while (totalBytes > maxBytes && retainedEntries.length > 0) {
const evicted = records.find((record) => retainedEntries.includes(record.versionId));
retainedEntries.splice(retainedEntries.indexOf(evicted.versionId), 1);
removedEntries.push(evicted.versionId);
totalBytes -= evicted.cacheSize;
await fs.rm(evicted.cachePath, { force: true });
}

这个逻辑确保磁盘空间被合理使用,同时保留用户可能还需要的历史版本。毕竟有些东西虽然不常用,但丢了又觉得可惜,人嘛,都是念旧的。

用户关闭共享加速开关时,需要立即停止做种和销毁 torrent 客户端:

async disableSharingAcceleration(): Promise<void> {
this.settingsStore.updateSettings({ enabled: false });
await this.cacheRetentionManager.stopAllSeeding(); // 停止做种
await this.engine.stopAll(); // 销毁 torrent 客户端
}

用户关掉功能,就不应该再占用任何 P2P 资源,这是基本的产品礼仪。既然不爱了,那就痛快放手,别拖泥带水。

世上没有完美的方案,混合分发也不例外。以下是主要的权衡点:

崩溃隔离弱于 sidecar:V1 使用主进程内引擎,引擎崩溃会影响主进程。这通过适配器边界和超时控制来缓解,但不是根本解决方案。V2 规划了 helper process 迁移路径。毕竟新手上路,总得交点学费。

默认开启带来资源占用:默认 2 MB/s 上传、10 GB 缓存、7 天保留,对用户机器有一定资源消耗。通过向导说明和设置透明度来管理用户预期。毕竟天下没有免费的午餐,有所得必有所舍。

企业网络兼容性:WebSeed/HTTPS 自动回退保障了企业网络下的可用性,但 P2P 加速效果会打折扣。这是设计上的取舍,优先保障可用性。毕竟有些事情,比快更重要,比如稳定。

元数据向后兼容:所有新字段都是可选的,缺失时回退到 HTTP 模式。老版本客户端完全不受影响,升级路径平滑。毕竟谁也不想升级一次就炸一次,那也太刺激了点。

本文详细解析了 HagiCode Desktop 项目的混合分发架构,总结下来有以下几个关键点:

  1. 架构分层:控制面与数据面分离,引擎抽象为可插拔接口,便于测试和扩展。毕竟分工明确,效率才高。

  2. 策略驱动:不是所有文件都走 P2P,仅对满足条件的大文件启用混合分发。毕竟强扭的瓜不甜,合适最重要。

  3. 完整性校验:SHA256 作为硬门槛,流式计算避免内存问题。毕竟信任建立不易,且用且珍惜。

  4. 产品化包装:隐藏 BT 术语,使用「共享加速」语义,首向默认开启。毕竟说话也是艺术,简单点大家都轻松。

  5. 用户可控:提供上传限速、缓存上限、保留天数等可调整参数。毕竟选择权在用户手里,谁也不喜欢被强迫。

这套方案已经在 HagiCode Desktop 项目中落地实施,实际效果如何,欢迎大家安装体验后反馈。毕竟理论归理论,实践才是检验真理的唯一标准。


如果本文对你有帮助:

或许我们都是在技术路上摸爬滚打的普通人罢了,可那又怎样呢?普通人也有普通人的坚持。毕竟「竹子本来没有嘴,可也还在拔节生长」,人总得有点追求才是…

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

Docker 容器中运行 AI CLI 工具:用户隔离与持久化卷实战指南

Docker 容器中运行 AI CLI 工具:用户隔离与持久化卷实战指南

Section titled “Docker 容器中运行 AI CLI 工具:用户隔离与持久化卷实战指南”

在容器化环境中集成 Claude Code、Codex、OpenCode 等 AI 编程工具,听起来简单,实则暗藏玄机。本文将深入解析 HagiCode 项目在 Docker 部署中如何解决用户权限、配置持久化、版本管理等核心挑战,带你避坑避雷。

当我们决定在 Docker 容器内运行 AI 编程 CLI 工具时,最直觉的想法可能是:“容器不就是 root 吗,直接装不就完事了?“其实啊,这想法看似简单,背后却藏着几个必须解决的核心问题。

首先,安全限制是第一道坎。以 Claude CLI 为例,它明确禁止以 root 用户运行——这是强制性的安全检查,检测到 root 直接拒绝启动。你可能会想,那我用 USER 指令切换一下不就行了?事情没那么简单,容器的非 root 用户和宿主机的用户权限之间还存在映射问题。毕竟,这世间的事,哪有那么简单的呢?

其次,状态持久化是第二个坑。Claude Code 需要登录,Codex 有自己的配置,OpenCode 也有缓存目录。如果每次容器重启都重新配置,那这个”自动化”就毫无意义了。我们需要让这些配置在容器生命周期之外持久存在。配置这东西,就像记忆一样,说没就没,那也挺让人郁闷的。

第三个问题就是权限一致性。宿主机用户创建的配置文件,容器内的进程能不能访问?UID/GID 不匹配会导致文件权限报错,这在实际部署中非常常见。这问题说起来也挺无奈的,可是没辙。

这些问题看似独立,实际上环环相扣。HagiCode 项目在开发过程中逐步摸索出了一套可行的解决方案,接下来我会详细分享其中的技术细节和踩坑经历。

本文分享的方案来自我们在 HagiCode 项目中的实践经验。HagiCode 是一个开源的 AI 辅助编程平台,集成了多个主流的 AI 代码助手,包括 Claude Code、Codex、OpenCode 等。作为一个需要跨平台、高可用部署的项目,HagiCode 必须解决容器化部署的各种挑战。

如果你觉得本文分享的技术方案有价值,说明 HagiCode 在工程实践上还是有点东西的——那么 HagiCode 官网GitHub 仓库 值得关注关注。毕竟,好东西值得分享,不是吗?

这里有个常见的误解:Docker 容器默认以 root 运行,那我就直接用 root 装工具呗。这么想的话,Claude CLI 会毫不客气地给你一个下马威。

Terminal window
# 直接以 root 运行 Claude CLI?不行
docker run --rm -it --user root myimage claude
# 输出: Error: This command cannot be run as root user

这是 Claude CLI 的硬性安全限制。原因很简单:这些 CLI 工具会读写用户的敏感配置,包括 API Token、本地缓存、甚至可能执行用户编写的脚本。以 root 权限运行这些工具,潜在风险太大。毕竟,安全这东西,怎么谨慎都不为过。

那么问题来了:怎么才能既满足 CLI 的安全要求,又保持容器管理的灵活性?我们需要换个思路——不是在运行时切换用户,而是从镜像构建阶段就创建专用用户。有时候啊,换个角度看问题,答案就自然浮现了。

创建专用用户:不止是换个名字

Section titled “创建专用用户:不止是换个名字”

你可能会想,那我直接在 Dockerfile 里加一行 USER 指令不就得了?这确实是最简单的方案,但不够健壮。简单的东西往往不够优雅,不是吗?

HagiCode 的方案是创建一个 UID 1000 的 hagicode 用户,这个 UID 通常匹配大多数宿主机的默认用户:

RUN groupadd -o -g 1000 hagicode && \
useradd -o -u 1000 -g 1000 -s /bin/bash -m hagicode && \
mkdir -p /home/hagicode/.claude && \
chown -R hagicode:hagicode /home/hagicode

但这只解决了镜像内置用户的问题。如果宿主机用户是 UID 1001 呢?容器启动时还需要支持动态映射。

docker-entrypoint.sh 中的关键逻辑:

Terminal window
if [ -n "$PUID" ] && [ -n "$PGID" ]; then
if ! id hagicode >/dev/null 2>&1; then
groupadd -g "$PGID" hagicode
useradd -u "$PUID" -g "$PGID" -s /bin/bash -m hagicode
fi
fi

这样设计的好处是:镜像构建时使用默认的 UID 1000,运行时可以通过环境变量 PUID/PGID 动态调整。无论宿主机用户是什么 UID,配置文件的所有权都不会出问题。这设计说起来也挺自然的,毕竟,灵活性和默认值之间需要找到一个平衡点罢了。

每个 AI CLI 工具都有自己偏好的配置目录,这需要一一对应:

CLI 工具容器内路径命名卷
Claude/home/hagicode/.claudeclaude-data
Codex/home/hagicode/.codexcodex-data
OpenCode/home/hagicode/.config/opencodeopencode-config-data

为什么用命名卷而不是绑定挂载?三个原因:

  1. 简化管理:命名卷由 Docker 自动管理生命周期,不需要手动创建宿主机目录
  2. 权限隔离:卷的初始内容由容器内用户创建,避免宿主机权限冲突
  3. 独立迁移:卷可以独立于容器存在,升级镜像时数据不会丢失

docker-compose-builder-web 会自动生成对应的卷配置:

volumes:
claude-data:
codex-data:
opencode-config-data:
services:
hagicode:
volumes:
- claude-data:/home/hagicode/.claude
- codex-data:/home/hagicode/.codex
- opencode-config-data:/home/hagicode/.config/opencode
user: "${PUID:-1000}:${PGID:-1000}"

注意这里的 user 字段:通过环境变量注入 PUID/PGID,确保容器进程以匹配宿主机的用户身份运行。这细节说起来挺重要的,毕竟,权限问题一旦出现,排查起来也挺让人头疼的。

版本管理:烘焙版本与运行时覆盖

Section titled “版本管理:烘焙版本与运行时覆盖”

Docker 镜像的版本固定是保证可重现性的关键。但在实际开发中,我们经常需要测试新版本,或者紧急修复一个 bug。如果每次都要重新构建镜像,那效率也太低了。

HagiCode 的策略是固定版本作为默认值,运行时覆盖作为扩展能力。这也算是工程实践中的一种妥协吧,稳定性和灵活性之间总要有个取舍。

Dockerfile.template 中固定版本:

USER hagicode
WORKDIR /home/hagicode
# 配置 npm 全局安装路径
RUN mkdir -p /home/hagicode/.npm-global && \
npm config set prefix '/home/hagicode/.npm-global'
# 安装 CLI 工具(使用固定版本)
RUN npm install -g @anthropic-ai/claude-code@2.1.71 && \
npm install -g @openai/codex@0.112.0 && \
npm install -g opencode-ai@1.2.25 && \
npm cache clean --force

docker-entrypoint.sh 中支持运行时覆盖:

Terminal window
install_cli_override_if_needed() {
local package_name="$2"
local override_version="$5"
if [ -n "$override_version" ]; then
gosu hagicode npm install -g "${package_name}@${override_version}"
fi
}
# 使用示例
install_cli_override_if_needed "" "@anthropic-ai/claude-code" "" "" "${CLAUDE_CODE_CLI_VERSION}"

这样,在不重新构建镜像的情况下,可以通过环境变量测试新版本:

Terminal window
docker run -e CLAUDE_CODE_CLI_VERSION=2.2.0 myimage

这设计说起来也挺实用的,毕竟,谁愿意每次测试新功能都要重新构建镜像呢?

除了手动配置 CLI 工具,有些场景下还需要自动注入配置。最典型的就是 API Token。

Terminal window
if [ -n "$ANTHROPIC_AUTH_TOKEN" ]; then
mkdir -p /home/hagicode/.claude
cat > /home/hagicode/.claude/settings.json <<EOF
{
"env": {
"ANTHROPIC_AUTH_TOKEN": "${ANTHROPIC_AUTH_TOKEN}"
}
}
EOF
chown -R hagicode:hagicode /home/hagicode/.claude
fi

这里需要注意两点:敏感信息通过环境变量传入,不要硬编码到镜像中;配置文件的所有权要正确设置,否则 CLI 工具无法读取。这事儿说起来挺基础的,可是做错的人还真不少。

这是最容易踩的坑。宿主机用户的 UID 是 1001,容器内是 1000,创建的文件互相访问不了。

Terminal window
# 正确做法:让容器匹配宿主机用户
docker run \
-e PUID=$(id -u) \
-e PGID=$(id -g) \
myimage

这问题说起来也挺常见的,可是第一次遇到的时候,还是挺让人郁闷的。

如果你发现每次重启都要重新登录,检查一下是不是忘记挂载持久化卷了:

volumes:
- claude-data:/home/hagicode/.claude

配置这东西,辛辛苦苦设置好了,说没就没了,那感觉,怎么说呢,挺让人崩溃的。

不要直接在运行的容器里执行 npm install -g。正确做法是:

  1. 设置环境变量触发覆盖安装
  2. 或者重新构建镜像
Terminal window
# 方式一:运行时覆盖
docker run -e CLAUDE_CODE_CLI_VERSION=2.2.0 myimage
# 方式二:重新构建
docker build -t myimage:v2 .

条条大路通罗马,只是有的路好走一点,有的路稍微曲折一点罢了。

  • API Token 通过环境变量传入,不写入镜像
  • 配置文件设置 600 权限
  • 始终以非 root 用户运行应用
  • 定期更新 CLI 版本,修复安全漏洞

安全这东西,说起来挺重要,可是真正落实的时候,又有多少人能做得好呢?

如果以后要支持新的 CLI 工具,只需要三步:

  1. Dockerfile.template:添加安装步骤
  2. docker-entrypoint.sh:添加版本覆盖逻辑
  3. docker-compose-builder-web:添加持久化卷映射

模板化的设计让扩展变得简单,不需要改动核心逻辑。这也算是过来人的一点心得,不是什么大道理,只是踩过的坑罢了。

Docker 容器中运行 AI CLI 工具,核心挑战在于用户权限、配置持久化、版本管理三个维度。HagiCode 项目通过创建专用用户、命名卷隔离、环境变量覆盖的组合方案,实现了既安全又灵活的部署架构。

关键设计要点:

  • 用户隔离:从镜像构建阶段创建专用用户,运行时支持 PUID/PGID 动态映射
  • 持久化策略:每个 CLI 工具对应独立的命名卷,容器重启不影响配置
  • 版本灵活性:固定默认值确保可重现性,运行时覆盖提供测试能力
  • 自动化配置:支持通过环境变量自动注入敏感配置

这套方案在 HagiCode 项目中已经稳定运行了一段时间,希望能给有类似需求的开发者一些参考。其实也没那么复杂,不过是些工程实践罢了。

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

HagiCode Soul 平台技术解析:从需求萌发到独立平台的演进之路

HagiCode Soul 平台技术解析:从需求萌发到独立平台的演进之路

Section titled “HagiCode Soul 平台技术解析:从需求萌发到独立平台的演进之路”

其实写技术文章这事儿,也没什么了不起的,不过是把一些趟过的坑、绕过的弯路整理出来罢了。毕竟谁还没年轻过呢,对吧?本文将深入解析 HagiCode 项目中 Soul(AI Agent 人格配置系统)的设计理念、架构演进和核心技术实现,探讨如何通过独立平台提供更聚焦的 Agent 人格创建与分享体验。

在 AI Agent 的开发实践中,我们经常会遇到一个看似简单却极其重要的问题:如何让不同的 Agent 拥有稳定且独特的语言风格和人格特征?

这问题说起来也挺无奈的。早期 HagiCode 的 Hero 体系中,不同英雄(Agent 实例)主要依赖职业配置和通用提示词来区分表达方式。这种方式带来了一些明显的痛点,或许做过的朋友都有同感。

首先,语言风格难以保持一致。同样是”开发工程师”角色,今天的回复可能专业严谨,明天的输出又变得随意散漫。这不是模型本身的问题,而是缺乏一个独立的人格配置层来约束和引导输出风格罢了。

其次,角色感普遍较弱。当我们描述一个 Agent 的特征时,往往只能用”友好”、“专业”、“幽默”这样模糊的形容词,却没有具体的语言规则来支撑这些抽象的描述。说白了,就是说起来挺美好,做起来却没辙。

第三,人格配置的复用性几乎为零。假设我们精心设计了一个”猫娘服务员”的说话风格,想要在另一个业务场景中复用这套表达方式,几乎需要从头开始配置。美的事物或人,不一定要占用,只是想复用一下罢了…可是真的难。

正是为了解决这些实际问题,我们引入了 Soul 机制——一个独立于装备和描述的语言风格配置层。Soul 可以定义 Agent 的说话习惯、语气偏好和用词边界,可以在多个英雄间共享复用,还能在 Session 首次调用时自动注入系统提示词。

或许有人会觉得这也罢了,不就是配置几个提示词吗?可是有时候啊,问题的关键不在于能不能做,而在于怎么做更优雅。随着 Soul 能力的逐步成熟,我们意识到它已经具备了独立发展的潜力。一个专门的 Soul 平台可以让用户更聚焦地创建、分享和浏览各种有趣的人格配置,而不必被 Hero 系统的其他功能所干扰。于是,soul.hagicode.com 独立平台应运而生。

HagiCode 是一个开源的 AI 代码助手项目,采用现代化的技术栈构建,致力于为开发者提供流畅的智能编程体验。本文分享的 Soul 平台方案,正是我们在开发 HagiCode 过程中,为了解决 Agent 人格管理这一实际问题而探索出来的实践经验。如果你觉得这套方案有价值,说明我们在工程实践中积累了一定的技术判断力——那么 HagiCode 项目本身也值得关注了解一下。

Soul 平台的发展并非一蹴而就,而是经历了三个清晰的阶段。这故事开始得突然,结束得自然。

最早的 Soul 实现是作为 Hero 工作区的一个功能模块存在的。我们在 Hero 界面中增加了独立的 SOUL 编辑区域,支持预设套用和文本微调两种方式。

预设套用允许用户从一些经典人格模板中选择,比如”专业开发工程师”、“猫娘服务员”等。文本微调则让用户可以在预设基础上进行个性化修改。后端 Hero 实体相应地增加了 Soul 字段,并通过 SoulCatalogId 标识来源。

这个阶段解决了”有没有”的问题,也还算是个孩子,磕磕绊绊地成长着。但随着 Soul 内容越来越丰富,与 Hero 系统耦合在一起的架构开始显现出局限性。

为了提供更好的 Soul 发现和复用体验,我们构建了 SOUL Marketplace 目录页,支持浏览、搜索、详情查看和收藏功能。

在这个阶段,我们引入了 50 组主 Catalog(基础角色)10 组正交规则(表达方式) 的组合设计。主 Catalog 定义了 Agent 的核心人设,比如”雾港旅人”、“夜航猎手”这类抽象的角色设定;正交规则则定义了表达的方式,比如”简洁干练”、“啰嗦亲切”等语言风格特征。

50 × 10 = 500 个组合可能性,为用户提供了丰富的人格配置空间。这数量说多不多,说少不少,怎么说呢,条条大路通罗马,只是有的路好走一点罢了。后端通过 catalog-sources.json 生成完整的 SOUL 目录,前端则负责将这些目录项呈现为可交互的卡片列表。

站内 Marketplace 是一个很好的过渡方案,但也只是过渡而已。它仍然依附于主系统,对于只想使用 Soul 功能的用户来说,访问路径还是太深了。毕竟谁愿意绕一大圈才能做一件简单的事呢?

最终,我们决定将 Soul 能力迁移到独立仓库(repos/soul),原主系统的 Marketplace 改为外部跳转引导,新平台采用 Builder-first 设计理念——默认首页即为创建工作台,用户打开网站的第一时间就可以开始创建自己的人格配置。

这个阶段的技术栈也进行了全面升级:采用 Vite 8 + React 19 + TypeScript 5.9 组合,使用 shadcn/ui 组件系统统一设计语言,引入 Tailwind CSS 4 的主题变量系统。前端工程化水平的提升,为后续的功能迭代打下了坚实基础。

一切都淡了…不,一切才刚刚开始。

Soul 平台的一个核心设计理念是本地优先。这意味着首页必须在无后端情况下可完全运行,远端素材失败时不得阻断页面进入。

其实这也没什么了不起的,只是在设计系统时多考虑了一步罢了。本地快照作为基线,远端作为增强,这种思路让产品在任何网络条件下都能提供基本的可用性。具体实现上,我们采用了两层素材架构:

export async function loadBuilderMaterials(): Promise<BuilderMaterials> {
const localMaterials = createLocalMaterials(snapshot) // 本地基线
try {
const inspirationFragments = await fetchMarketplaceItems() // 远程增强
return { ...localMaterials, inspirationFragments, remoteState: "ready" }
} catch (error) {
return { ...localMaterials, remoteState: "fallback" } // 优雅降级
}
}

本地素材来自主系统文档的构建期快照,包含 50 组基础角色和 10 组表达规则的完整数据。远端素材则来自用户发布的 Soul,通过 Marketplace API 获取。两者的结合,为用户提供了从官方模板到社区创意的完整素材光谱。想笑来伪装自己掉下的泪…不,其实没什么,就是本地加远程罢了。

Soul 的核心数据抽象是 SoulFragment(灵魂碎片):

export type SoulFragment = {
fragmentId: string
group: "main-catalog" | "expression-rule" | "published-soul"
title: string
summary: string
content: string
keywords: string[]
localized?: Partial<Record<AppLocale, LocalizedFragmentContent>>
sourceRef: SoulFragmentSourceRef
meta: SoulFragmentMeta
}

group 字段区分了碎片的类型:主目录定义角色内核,正交规则定义表达方式,用户发布的 Soul 则标记为 published-soullocalized 字段支持多语言,让同一个碎片可以在不同语言环境下呈现不同的标题和描述。国际化设计要趁早,这话我们也算是用上了。

Builder 草稿状态则封装了用户当前的编辑状态:

export type SoulBuilderDraft = {
draftId: string
name: string
selectedMainFragmentId: string | null
selectedRuleFragmentId: string | null
inspirationSoulId: string | null
mainSlotText: string
ruleSlotText: string
customPrompt: string
previewText: string
updatedAt: string
}

用户在编辑器中选择的每个碎片,其内容都会被拼接到对应的 slot(槽位)中,形成最终的预览文本。mainSlotText 对应主角色内容,ruleSlotText 对应表达规则内容,customPrompt 则是用户的额外补充指令。

预览编译是 Soul Builder 的核心功能,它将用户选择的碎片和自定义文本组装成可复制的系统提示词:

export function compilePreview(
draft: Pick<SoulBuilderDraft, "mainSlotText" | "ruleSlotText" | "customPrompt">,
fragments: {
mainFragment: SoulFragment | null
ruleFragment: SoulFragment | null
inspirationFragment: SoulFragment | null
}
): PreviewCompilation {
// 组装逻辑:主角色 + 表达规则 + 灵感参考 + 自定义内容
}

编译结果会展示在中央预览面板中,用户可以实时看到最终效果,并一键复制到剪贴板。这功能说起来也挺简单的,不是吗?可是简单的东西往往最实用。

Soul Builder 的前端状态管理遵循一个重要原则:状态边界清晰划分。具体来说,抽屉状态不持久化,不直接写入草稿;只有明确的 Builder 操作才会触发状态变更。

// 领域状态(useSoulBuilder)
export function useSoulBuilder() {
// 素材加载与缓存
// 槽位聚合与预览编译
// 复制行为与反馈消息
// Locale 安全的描述符
}
// 呈现状态(useHomeEditorState)
export function useHomeEditorState() {
// activeSlot, drawerSide, drawerOpen
// 默认焦点行为
}

这种分离确保了编辑状态的安全性和界面的响应速度。抽屉的打开关闭是纯粹的 UI 交互,不需要触发复杂的持久化逻辑。这无异于废话了!不,其实很重要——界面状态和业务状态要明确区分,避免 UI 交互污染核心数据模型。

Soul Builder 采用单抽屉模式:同时只允许一个槽位抽屉打开。点击遮罩层、按 ESC 键或切换槽位都会自动关闭当前抽屉。这个设计简化了状态管理,也符合移动端抽屉交互的常见模式。

抽屉关闭不会清空当前编辑内容,用户切换回来时,上下文得以保留。这种”轻量级”的抽屉设计,避免了用户操作的中断感。毕竟谁愿意辛辛苦苦写的东西,因为不小心点错就全没了吗?

国际化是 Soul 平台的重要特性。系统文案完全支持双语切换,而用户草稿文本则永远不会因语言切换而被重写——因为草稿文本本身就是用户自由输入的内容,不涉及系统翻译。

官方灵感卡(Marketplace Soul)保持上游显示名称,但提供最佳努力的英文摘要。对于中文名称的 Soul,我们通过预定义的映射规则生成英文版本:

// 主角色英文名映射
const mainNameEnglishMap = {
"雾港旅人": "Mistport Traveler",
"夜航猎手": "Night Hunter",
// ...
}
// 正交规则英文名映射
const ruleNameEnglishMap = {
"简洁干练": "Concise & Professional",
"啰嗦亲切": "Verbose & Friendly",
// ...
}

这映射表看起来也挺简单的,可是要维护好它,也得花不少心思。毕竟有 50 组主角色和 10 组正交规则,乘起来就是 500 个组合,这数量说大不大,说小也不小。

Soul Catalog 的批量生成在后端完成,使用 C# 实现了 50 × 10 = 500 个组合的自动化创建:

foreach (var main in source.MainCatalogs)
{
foreach (var orthogonal in source.OrthogonalCatalogs)
{
var catalogId = $"soul-{main.Index:00}-{orthogonal.Index:00}";
var displayName = BuildNickname(main, orthogonal);
var soulSnapshot = BuildSoulSnapshot(main, orthogonal);
// 写入数据库...
}
}

昵称生成算法将主角色名和表达规则名组合在一起,创造出富有想象力的 Agent 代号:

private static readonly string[] MainHandleRoots = [
"雾港", "夜航", "零帧", "星渊", "霓虹", "断云", ...
];
private static readonly string[] OrthogonalHandleSuffixes = [
"旅人", "猎手", "术师", "行者", "星使", ...
];
// 组合示例:雾港旅人、夜航猎手、零帧术师...

Soul 快照的拼装则按照固定的模板格式,将主角色核心、标志特征、表达规则核心和输出约束组合在一起:

private static string BuildSoulSnapshot(main, orthogonal) => string.Join('\n', [
$"你的人设内核来自「{main.Name}」:{main.Core}",
$"保持以下标志性语言特征:{main.Signature}",
$"你的表达规则来自「{orthogonal.Name}」:{orthogonal.Core}",
$"必须遵循这些输出约束:{orthogonal.Signature}"
]);

这模板拼装说起来也是无聊透顶的活儿,可是没有这些无聊的工作,哪来有趣的产品呢?

Soul 从主系统拆分到独立平台后,我们面临的一个重要挑战是如何处理已有用户数据。这问题说起来也挺常见的——拆分容易,迁移难。我们采取了三项保障措施:

向后兼容保障。已保存的 Hero SOUL 快照保持可见,历史快照即使失去 Marketplace 来源 ID 仍可预览。这意味着用户之前的所有配置都不会丢失,只是展示位置发生了变化。毕竟谁也不想辛辛苦苦的配置,说没就没了。

主系统接口弃用。站内 Marketplace API 返回 410 Gone 状态码,并附带迁移提示,引导用户访问 soul.hagicode.com。

Hero SOUL 表单改造。在 Hero Soul 编辑区域新增迁移提示区块,明确告知用户 Soul 平台已经独立,并提供一键跳转按钮:

HeroSoulForm.tsx
<div className="rounded-2xl border border-orange-200/70 bg-orange-50/80 p-4">
<div>{t('hero.soul.migrationTitle')}</div>
<p>{t('hero.soul.migrationDescription')}</p>
<Button onClick={onOpenSoulPlatform}>
{t('hero.soul.openSoulPlatformAction')}
</Button>
</div>

回顾 Soul 平台的整个开发过程,有几点实践经验值得分享。这也算是过来人的一点心得,不是什么大道理,只是踩过的坑罢了。

本地优先的运行时假设。在设计依赖远端数据的特性时,始终假设网络可能不可用。本地快照作为基线,远端作为增强,这种思路让产品在任何网络条件下都能提供基本的可用性。毕竟这年头,网络这东西,说断就断,谁也说不准。

状态边界清晰划分。界面状态和业务状态要明确区分,避免 UI 交互污染核心数据模型。抽屉开关是纯粹的 UI 状态,不需要和草稿持久化混在一起。

国际化设计要趁早。如果你的产品有国际化需求,最好在数据模型设计阶段就考虑进去。localized 字段虽然增加了数据结构的复杂度,但后续维护多语言内容的成本会大大降低。

素材同步工作流要自动化。Soul 平台的本地素材来自主系统文档,当上游文档更新时,需要有机制同步到前端快照。我们设计了 npm run materials:sync 脚本自动化这个过程,确保素材始终和上游保持一致。

基于当前的架构设计,Soul 平台未来可以考虑以下发展方向。这也只是一些粗浅的想法,不一定对,权当抛砖引玉罢了。

社区共享生态。支持用户上传和分享自定义 Soul,增加评分、评论和推荐机制,让优秀的 Soul 配置能够被更多人发现和使用。毕竟独乐乐不如众乐乐。

多模态扩展。除了文字风格,还可以考虑支持语音风格配置、表情符号使用偏好、代码风格与格式化规则等维度。这事儿说起来挺美好,做起来可能就…

智能辅助。基于使用场景自动推荐 Soul,风格迁移与融合,甚至 A/B 测试不同 Soul 的实际效果。美又何必在乎天晴阴呢?试试就知道了。

跨平台同步。支持从其他 AI 平台导入人格配置,提供标准化的 Soul 导出格式,与主流 Agent 框架集成。

本文分享了 HagiCode Soul 平台从需求萌发到独立平台的完整演进过程。我们探讨了为什么需要 Soul 机制(解决 Agent 人格一致性问题),分析了技术架构的三个发展阶段(内嵌配置、站内 Marketplace、独立平台),深入讲解了核心的数据模型、状态管理、预览编译和国际化设计,并分享了平台迁移的实践经验。

Soul 的本质,是一个独立于业务逻辑的人格配置层。它让 AI Agent 的语言风格变得可定义、可复用、可分享。从技术角度看,这个设计并不复杂,但它解决的问题却是真实的、有广泛需求的。

如果你也在开发 AI Agent 产品,不妨思考一下你的人格配置方案是否足够灵活。Soul 平台的实践或许能给你一些启发。

此情可待成追忆,只是当时已惘然。或许有一天,你也会遇到类似的问题,到时候这篇文章能帮上一点忙,那也就够了。


如果你觉得这篇文章有帮助,欢迎来 GitHub 给个项目一颗 Star。公测已经开始了,欢迎安装体验。

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

HagiCode Skill 系统技术解析:如何打造可扩展的 AI 技能管理平台

HagiCode Skill 系统技术解析:如何打造可扩展的 AI 技能管理平台

Section titled “HagiCode Skill 系统技术解析:如何打造可扩展的 AI 技能管理平台”

本文深入解析 HagiCode 项目中 Skill(技能)管理系统的架构设计与实现方案,涵盖本地全局管理、市场搜索、智能推荐、授信提供者管理四大核心功能的技术实现细节。

在 AI 代码助手这个领域,如何扩展 AI 的能力边界,其实一直是个核心课题。Claude Code 本身的代码辅助能力是挺强的,只是不同开发团队、不同技术栈,往往需要针对特定场景的专业能力——比如处理 Docker 部署、数据库优化、前端组件生成之类的。这时候,Skill(技能)系统就显得尤为重要了。

HagiCode 项目在开发过程中也遇到了类似的挑战:怎么让 Claude Code 能够像人一样「学会」新的专业技能,同时保持良好的用户体验和工程可维护性?毕竟这个问题,说难也难,说简单也简单。围绕这个问题,我们设计并实现了一套完整的 Skill 管理系统。

本文将详细解析这个系统的技术架构和核心实现,适合对 AI 扩展性、命令行工具集成感兴趣的开发者阅读。或许对你有用,也或许没用,但总归是写出来了。

本文分享的方案来自我们在 HagiCode 项目中的实践经验。HagiCode 是一个开源的 AI 代码助手项目,旨在帮助开发团队提升研发效率。项目的技术栈涵盖 ASP.NET Core、Orleans 分布式框架、TanStack Start + React 前端,以及本文要介绍的 Skill 管理子系统。

项目的 GitHub 地址是 HagiCode-org/site,如果你觉得本文介绍的技术方案有价值,欢迎给个 Star。毕竟Star多了,心情也会好一些。

Skill 系统采用前后端分离的架构设计,说起来也没什么特别的。

前端部分 使用 TanStack Start + React 构建用户界面,通过 Redux Toolkit 管理状态,四个主要功能分别对应四个 Tab 组件:本地技能、市场画廊、智能推荐、授信提供者。这样设计,其实也是为了用户体验罢了。

后端部分 基于 ASP.NET Core + ABP Framework,使用 Orleans Grain 实现分布式状态管理。在线 API 客户端封装了 IOnlineApiClient 接口,用于与远程技能目录服务通信。

整体架构的设计原则是「命令执行与业务逻辑分离」,通过适配器模式将 npm/npx 命令执行的细节屏蔽在独立模块中。毕竟谁愿意看到一堆命令行散落在代码各处呢?

本地全局管理是最基础的功能模块,负责列出已安装的技能并支持卸载操作。也没什么复杂的,就是把事情做好而已。

实现位置在 LocalSkillsTab.tsxLocalSkillCommandAdapter.cs。核心思路是封装 npx skills 命令,解析其 JSON 输出,转换为内部数据结构。说起来简单,做起来其实也简单。

public async Task<IReadOnlyList<LocalSkillInventoryResponseDto>> GetLocalSkillsAsync(
CancellationToken cancellationToken = default)
{
var result = await _commandAdapter.ListGlobalSkillsAsync(cancellationToken);
return result.Skills.Select(skill => new LocalSkillInventoryResponseDto
{
Name = skill.Name,
Version = skill.Version,
Source = skill.Source,
InstalledPath = skill.InstalledPath,
Description = skill.Description
}).ToList();
}

数据流非常清晰:前端发起请求 → SkillGalleryAppService 接收 → LocalSkillCommandAdapter 执行 npx 命令 → 解析 JSON 结果 → 返回 DTO 对象。一环扣一环,也没什么好说的。

卸载技能使用 npx skills remove -g <skillName> -y 命令,系统会自动处理依赖关系和清理工作。安装元数据存储在技能目录的 managed-install.json 中,记录了安装时间、来源版本等信息,便于后续更新和审计。毕竟有些东西,记下来总是好的。

技能安装涉及多个步骤的协调,怎么说呢,其实也不算太复杂:

public async Task<SkillInstallResultDto> InstallAsync(
SkillInstallRequestDto request,
CancellationToken cancellationToken = default)
{
// 1. 规范化安装引用
var normalized = _referenceNormalizer.Normalize(
request.SkillId,
request.Source,
request.SkillSlug,
request.Version);
// 2. 检查先决条件
await _prerequisiteChecker.CheckAsync(cancellationToken);
// 3. 获取安装锁
using var installLock = await _lockProvider.AcquireAsync(normalized.SkillId);
// 4. 执行安装命令
var result = await _installCommandRunner.ExecuteAsync(
new SkillInstallCommandExecutionRequest
{
Command = $"npx skills add {normalized.FullReference} -g -y",
Timeout = TimeSpan.FromMinutes(4)
},
cancellationToken);
// 5. 持久化安装元数据
await _metadataStore.WriteAsync(normalized.SkillPath, request);
return new SkillInstallResultDto { Success = result.Success };
}

这里用到了几个关键的设计模式:引用规范化器 负责将各种输入格式(如 tanweai/pua@opencode/docker-skill)转换为统一的内部表示;安装锁机制 确保同一技能同时只有一个安装操作在进行;流式输出 通过 Server-Sent Events 向前端实时推送安装进度,用户可以看到类似终端的实时日志。

这些设计模式,说到底,也还是为了让事情变得简单罢了。

市场搜索让用户能够发现和安装来自社区的技能。毕竟一个人的能力是有限的,众人的智慧才是无穷的。

搜索功能依赖在线 API https://api.hagicode.com/v1/skills/search。为了提升响应速度,系统实现了缓存机制。缓存这东西,就像记忆一样,有些东西记住了,下次就不用再费劲去想。

private async Task<IReadOnlyList<SkillGallerySkillDto>> SearchCatalogAsync(
string query,
CancellationToken cancellationToken,
IReadOnlySet<string>? allowedSources = null)
{
var cacheKey = $"skill_search:{query}:{string.Join(",", allowedSources ?? Array.Empty<string>())}";
if (_memoryCache.TryGetValue(cacheKey, out var cached))
return (IReadOnlyList<SkillGallerySkillDto>)cached!;
var response = await _onlineApiClient.SearchAsync(
new SearchSkillsRequest
{
Query = query,
Limit = _options.LimitPerQuery,
},
cancellationToken);
var results = response.Skills
.Where(skill => allowedSources is null || allowedSources.Contains(skill.Source))
.Select(skill => new SkillGallerySkillDto { ... })
.ToList();
_memoryCache.Set(cacheKey, results, TimeSpan.FromMinutes(10));
return results;
}

搜索结果支持按授信来源过滤,只显示用户信任的技能源。预置的种子查询用于初始化目录,比如「popular」、「recent」等,让用户在首次打开时就能看到推荐的热门技能。毕竟第一印象还是重要的。

智能推荐是系统中最复杂的功能,它能根据用户当前项目的情况,自动推荐最适合的技能。复杂归复杂,但做出来还是值得的。

整个推荐流程分为五个阶段:

1. 构建项目上下文
2. AI 生成搜索查询
3. 并行搜索在线目录
4. AI 对候选进行排名
5. 返回推荐列表

首先,系统分析项目的技术栈、编程语言、域名结构等特征,构建一个「项目画像」。这个画像,就像一个人的简历一样,记录着所有的特征。

然后,使用 AI Grain 生成针对性的搜索查询。这里的设计其实挺有意思——不是直接问 AI「推荐什么技能」,而是让它先思考「什么样的搜索词能找到相关技能」。毕竟有时候,问问题的方法比答案本身更重要:

var queryGeneration = await aiGrain.GenerateSkillRecommendationQueriesAsync(
projectContext, // 项目上下文
locale, // 用户语言偏好
maxQueries, // 最大查询数量
effectiveSearchHero); // AI 模型选择

接着,并行执行这些搜索查询,获取候选技能列表。并行处理,说到底也是为了节省时间罢了。

最后,使用另一个 AI Grain 对候选技能进行排名。这一步会综合考虑技能与项目的相关性、授信状态、用户历史偏好等因素:

var ranking = await aiGrain.RankSkillRecommendationsAsync(
projectContext,
candidates,
installedSkillNames,
locale,
maxRecommendations,
effectiveRankingHero);
response.Items = MergeRecommendations(projectContext, candidates, ranking, maxRecommendations);

AI 模型可能出现响应慢或暂时不可用的情况。毕竟再好的系统,也有掉链子的时候。为此,系统设计了确定性回退机制:当 AI 服务不可用时,使用基于规则启发式算法生成推荐,比如根据 package.json 中的依赖推断可能需要的技能。

这个回退机制,说穿了,也就是给系统留了一条后路罢了。

授信提供者管理允许用户控制哪些技能源是可信的。毕竟信任这东西,还是要自己把握的。

授信提供者支持两种匹配规则:精确匹配(exact)和前缀匹配(prefix)。

public static TrustedSkillProviderResolutionSnapshot Resolve(
TrustedSkillProviderSnapshot snapshot,
string source)
{
var normalizedSource = Normalize(source);
foreach (var entry in snapshot.Entries.OrderBy(e => e.SortOrder))
{
if (!entry.IsEnabled) continue;
foreach (var rule in entry.MatchRules)
{
bool isMatch = rule.MatchType switch
{
TrustedSkillProviderMatchRuleType.Exact
=> string.Equals(normalizedSource, Normalize(rule.Value),
StringComparison.OrdinalIgnoreCase),
TrustedSkillProviderMatchRuleType.Prefix
=> normalizedSource.StartsWith(Normalize(rule.Value) + "/",
StringComparison.OrdinalIgnoreCase),
_ => false
};
if (isMatch)
return new TrustedSkillProviderResolutionSnapshot
{
IsTrustedSource = true,
ProviderId = entry.ProviderId,
DisplayName = entry.DisplayName
};
}
}
return new TrustedSkillProviderResolutionSnapshot { IsTrustedSource = false };
}

预置的授信提供者包括 Vercel、Azure、anthropics、Microsoft、browser-use 等知名组织和项目。自定义提供者可以通过配置文件添加,指定提供者 ID、显示名称、徽章标签、匹配规则等。毕竟世界那么大,不可能只有几家是可信的。

授信配置使用 Orleans Grain 持久化存储:

public class TrustedSkillProviderGrain : Grain<TrustedSkillProviderState>,
ITrustedSkillProviderGrain
{
public async Task UpdateConfigurationAsync(TrustedSkillProviderSnapshot snapshot)
{
State.Snapshot = snapshot;
await WriteStateAsync();
}
public Task<TrustedSkillProviderSnapshot> GetConfigurationAsync()
{
return Task.FromResult(State.Snapshot);
}
}

这种方式的好处是配置变更会自动同步到所有节点,无需手动刷新缓存。毕竟自动化,说到底也是为了让人少操心罢了。

Skill 系统需要执行各种 npx 命令,如果把这些逻辑散落在各处,代码会变得难以维护。因此我们设计了适配器接口。设计模式这东西,说到底,也还是为了让代码更好维护而已:

public interface ISkillInstallCommandRunner
{
Task<SkillInstallCommandExecutionResult> ExecuteAsync(
SkillInstallCommandExecutionRequest request,
CancellationToken cancellationToken = default);
}

不同的命令有不同的执行器实现,全部实现同一个接口,便于测试和替换。

安装进度通过 Server-Sent Events 实时推送到前端:

public async Task InstallWithProgressAsync(
SkillInstallRequestDto request,
IServerStreamWriter<SkillInstallProgressEventDto> stream,
CancellationToken cancellationToken)
{
var process = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = "npx",
Arguments = $"skills add {request.FullReference} -g -y",
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false
}
};
process.OutputDataReceived += async (sender, e) =>
{
await stream.WriteAsync(new SkillInstallProgressEventDto
{
EventType = "output",
Data = e.Data ?? string.Empty
});
};
process.Start();
process.BeginOutputReadLine();
await process.WaitForExitAsync(cancellationToken);
}

用户在前端可以看到类似终端的实时输出,体验非常直观。毕竟实时反馈,让人安心。

以安装 pua 技能为例(这是一个流行的社区技能):

  1. 打开 Skills 抽屉,切换到「Skill Gallery」标签
  2. 输入「pua」进行搜索
  3. 点击搜索结果查看技能详情
  4. 点击「Install」按钮安装
  5. 切换到「Local Skills」标签确认安装成功

安装命令是 npx skills add tanweai/pua -g -y,系统会自动处理所有细节。其实也没那么多步骤,一步步来就是了。

如果你的团队有自己的技能仓库,可以添加为授信来源:

providerId: "my-team"
displayName: "My Team Skills"
badgeLabel: "MyTeam"
isEnabled: true
sortOrder: 100
matchRules:
- matchType: "prefix"
value: "my-team/"
- matchType: "exact"
value: "my-team/special-skill"

这样来自你团队的所有技能都会显示授信徽章,用户可以更放心地安装。毕竟有标记的东西,总是让人安心一些。

创建自定义技能需要遵循以下结构:

my-skill/
├── SKILL.md # 技能元数据(YAML front matter)
├── index.ts # 技能入口
├── agents/ # 支持的代理配置
└── references/ # 参考资源

SKILL.md 的格式示例:

---
name: my-skill
description: A brief description of what this skill does
---
# My Skill
Detailed documentation...
  1. 网络要求:技能搜索和安装需要能访问 api.hagicode.com 和 npm registry
  2. Node.js 版本:建议使用 Node.js 18 或更高版本
  3. 权限要求:需要全局 npm 安装权限
  4. 并发控制:同一技能同时只能有一个安装或卸载操作在执行
  5. 超时设置:安装操作默认超时时间为 4 分钟,复杂场景可能需要调整

这些注意事项,说到底,也还是为了让事情顺利进行罢了。

本文介绍了 HagiCode 项目中 Skill 管理系统的完整实现。这个系统通过前后端分离的架构、适配器模式、Orleans 分布式状态管理等技术手段,实现了:

  • 本地全局管理:通过封装 npx skills 命令,提供统一的技能管理接口
  • 市场搜索:利用在线 API 和缓存机制,快速发现社区技能
  • 智能推荐:结合 AI 能力,根据项目上下文推荐最合适的技能
  • 授信管理:灵活的配置系统,让用户掌控信任边界

这套设计思路不仅适用于 Skill 管理,对于任何需要集成命令行工具、兼顾本地存储和在线服务的场景,都有参考价值。

如果本文对你有帮助,欢迎来 GitHub 给个 Star:github.com/HagiCode-org/site。也可以访问官网了解更多:hagicode.com

或许你也会觉得,这套系统设计得还行,或许你不会。但这都罢了,毕竟代码写出来,总有人会用,也总有人不会用…

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

我好像会被 Agent 淘汰,我用数据算了一算

我好像会被 Agent 淘汰,我用数据算了一算

Section titled “我好像会被 Agent 淘汰,我用数据算了一算”

用数据量化 AI 替代风险:深入解析 HagiCode 团队如何用 6 个核心公式,重新定义知识工作者的竞争力评估标准。

在 AI 技术飞速发展的今天,每一个知识工作者都面临一个紧迫的问题:在 AI 时代,我是否会被淘汰?

这个问题听起来有点危言耸听,但其实很多人心里都在打鼓。前脚刚学会一个框架,后脚 AI 就说你这个岗位要被替代了;好不容易精通了一门语言,结果发现用 AI 的人产出是你的三倍——这种焦虑感,我相信屏幕前的你多少都能体会。

其实吧,这种焦虑也不是没有道理。毕竟谁也不愿意承认,自己奋斗多年的技能,可能被一个 ChatGPT 就给超越了。只是焦虑归焦虑,日子还得过不是?

传统观点往往从”AI 能做什么”出发讨论替代风险,但这种方法忽略了两个关键维度:

  1. 企业视角:企业是否愿意为一个员工配备 AI 工具,取决于 AI 成本相对于人力成本的性价比。不是说 AI 能取代这个岗位,企业就会立刻换人,还要算算经济账。毕竟资本家也不是慈善家,每一分钱都要花在刀刃上。

  2. 效率视角:AI 带来的效率提升需要被量化,而不是简单地认为”用了 AI 就更强”。你用 AI 效率提升了 2 倍,但他用 AI 提升了 5 倍,这里面的差距可不小。就像学生时代,都在听课,有的考 90 分,有的才及格——差距就是这么拉开的。

所以关键问题是:怎么把这种模糊的焦虑,变成可以量化的指标?

毕竟知道自己的位置在哪,总比在黑暗中摸索要好一些。这就是我们今天要聊的——HagiCode 团队开发的 AI 人效计算器背后的设计逻辑。

于是我做了一个 https://cost.hagicode.com 的网站。

HagiCode 是一个开源的 AI 代码助手项目,旨在帮助开发者更高效地完成编码工作。

有意思的是,HagiCode 团队在开发自己的产品过程中,积累了大量关于 AI 使用效率的实践经验。他们发现:AI 工具本身的价值,不能脱离企业的用工成本来单独评估。基于这个洞察,团队决定开发一个人效计算器,帮助知识工作者科学地评估自己在 AI 时代的竞争力。

其实这种东西,很多人都能做,只是很少有人愿意认真去做了。HagiCode 团队花时间做这个,也算是给开发者社区的一点回馈吧。

本文分享的设计方案,正是 HagiCode 在 AI 应用实践中的经验总结。如果你觉得这套评估体系有价值,说明 HagiCode 在工程实践上还是有点东西的——那么 HagiCode 项目本身 也值得关注一下。

企业为员工付出的真实成本远不止工资。这一点很多人跳槽的时候才发现——明明谈的是 2 万月薪,到手怎么就 1 万 4?公司那边可不止出 2 万,社保、公积金、培训、招聘成本都要算进去。

根据 calculate-ai-risk.ts 中的实现:

年度全用工成本 = 年薪 × (1 + 城市系数) + 年薪 / 12

城市系数反映的是不同城市的人才招募和保留成本:

城市层级代表城市系数
一线北京/上海/深圳/广州0.4
新一线杭州/成都/苏州/南京0.3
二线武汉/西安/天津/郑州0.2
其他宜昌/洛阳等0.1

一线城市系数是 0.4,意思是企业需要额外支付约 40% 的招募、培训、社保等附加成本。在北京招一个人的综合成本,确实比在二线城市高不少。

毕竟在大城市生存,生活成本也高,这算是另一种形式的”漂泊者税”了吧。

不同 AI 模型有 Input 和 Output 两种价格,而且差异巨大。代码场景下输入输出比例大约是 3:1——你给 AI 一段代码让它 review,输出的分析文字通常比输入的代码短很多。

综合单价计算公式:

综合单价 = (输入输出比例 × 输入单价 + 输出单价) / (输入输出比例 + 1)

拿 GPT-5 举个例子:

  • 输入:$2.5/1M tokens
  • 输出:$15/1M tokens
  • 综合 = (3 × 2.5 + 15) / 4 = $5.625/1M tokens

对于 USD 定价的模型,还需要按汇率转换。这个汇率 HagiCode 团队设定为 7.25,会随市场波动更新。

汇率这东西,就像股市一样,谁也猜不准。只能跟着走,罢了。

日均 AI 成本 = 日均 Token 需求 (M) × 综合单价 (CNY/1M)
年 AI 成本 = 日均 AI 成本 × 264 个工作日

264 = 22 天/月 × 12 月,这是标准工作制下的年度工作日数量。为什么不用 365 天?因为你要考虑周末、节假日、病假等因素。

毕竟咱们也不是机器人,该休息的时候还是要休息的。虽说 AI 可能不需要休息,但咱们还是要给自己留点喘息的空间。

这是整个评估体系的核心,也是 HagiCode 团队最有洞察力的地方。

可负担工作流份数 = 年度全用工成本 / AI 年成本
可负担比例 = min(可负担工作流份数, 1)
等效人力 = 1 + (效率倍数 - 1) × 可负担比例

等等,这个公式有点绕,让我解释一下:

传统观点会直接说”你的效率提升了 2 倍”,但这个公式考虑了一个关键约束:企业的 AI 预算是否可持续?

举个例子:小明效率提升了 3 倍,但他的 AI 消耗成本每年要 30 万;而公司给他的年薪才 20 万。这种情况下,虽然小明个人效率很高,但他实际上是不可持续的——公司不可能为了让他维持高效率而亏本。

可负担比例就是这个意思:如果企业只能负担 0.5 份 AI 工作流,那小明的等效人力 = 1 + (3-1) × 0.5 = 2 人,而不是 3 人。

核心洞察:不是你的效率倍数有多高,而是企业能否负担得起你维持这个效率所需的 AI 投入。

其实这个道理也挺简单的,只是很多人没往这方面想罢了。毕竟咱们习惯了从自己的角度看问题,很少站在老板的角度考虑一下——他们的钱也不是大风刮来的。

AI 成本占比 = AI 年成本 / 年度全用工成本
效率增幅 = 效率倍数 - 1
成本效益比 = 效率增幅 / AI 成本占比
  • 成本效益比 < 1:AI 投入不划算,效率提升抵不上成本
  • 成本效益比 1-2:刚好划算
  • 成本效益比 > 2:高收益,强烈推荐

这个指标对于企业管理者特别有用,可以快速评估某个岗位是否值得投入 AI 工具。

毕竟 ROI 才是王道,你说自己效率提升再多,成本爆炸也没人买账。

根据等效人力划分风险:

等效人力风险等级结论
>= 2.0高危同事一旦具备同等条件,对你威胁很高
1.5 - 2.0警示同事已开始形成明显效率优势
< 1.5安全暂时还能保持差距

看到这个表格,你心里大概也有个数了吧。只是别太焦虑,毕竟焦虑也解决不了问题——不如想想怎么提升自己的效率倍数。

为了让评估结果更有趣味性,计算器引入了 7 种特殊称号系统。称号通过 localStorage 持久化,用户可以解锁并展示自己的”成就”。

称号 ID名称获取条件
craftsman-spirit匠人精神日均 Token = 0
prompt-alchemist提示炼金术师日 Token <= 20M 且效率倍数 >= 6
all-in-operator全押操盘手日 Token >= 150M 且效率倍数 >= 3
minimalist-runner极简跑者日 Token <= 5M 且效率倍数 >= 2
cost-tamer成本驯兽师成本效益比 >= 2.5 且 AI 占比 <= 15%
danger-oracle危险预言家等效人力 >= 2.5 或进入高危区
budget-coordinator预算协调官可负担工作流份数 >= 8

每个称号背后都有隐藏含义:

称号隐藏含义
匠人精神不用 AI 也能活得很好,但需要独特竞争力
提示炼金术师用少量 Token 达到高产出,极客型用户
全押操盘手高投入高产出,适合高频场景
极简跑者轻量级 AI 使用,适合轻度辅助场景
成本驯兽师ROI 极高,企业最喜欢的员工类型
危险预言家你已经是或即将是高危群体
预算协调官你能同时运营多个 AI 工作流

其实游戏化这东西,说白了就是给枯燥的数据加点趣味性罢了。毕竟谁不喜欢收集成就呢?就像游戏里的徽章,虽然没啥实际用处,但看着心里就是舒服。

计算器的定价数据来自多个官方 API 定价页面,确保计算结果的权威性和时效性:

这些数据会定期更新,最近更新于 2026-03-19。

毕竟数据这东西,过时了就没意义了。HagiCode 团队还是挺负责的,会及时更新。

假设你是一个北京的开发者,年薪 40 万,使用 Claude Sonnet 4.6,日均 Token 消耗 50M,自评效率提升 3 倍。模拟输入:

const input = {
annualIncomeCny: 400000,
cityTier: "tier1", // 北京
modelId: "claude-sonnet-4-6",
performanceMultiplier: 3.0,
dailyTokenUsageM: 50,
}
// 计算过程
// 年度全用工成本 = 40万 × (1 + 0.4) + 40万/12 ≈ 60.33万
// AI 年成本 ≈ 50 × 7.125 × 264 ≈ 9.4万
// 可负担工作流份数 ≈ 60.33 / 9.4 ≈ 6.4 份
// 等效人力 = 1 + (3 - 1) × 1 = 3 人

结论:你的同事如果具备相同条件,能相当于 3 个人的产能,你已经处于高危区。

如果你发现自己的 AI 用法”不划算”(成本效益比 < 1),可以考虑:

  1. 降低 Token 消耗:使用更高效的 prompt,减少无效请求
  2. 选择性价比模型:如 DeepSeek-V3(人民币计价,更便宜)
  3. 提升效率倍数:学习高级 Agent 使用技巧,真正把 AI 变成生产力

其实这些问题,归根结底就是一个平衡的艺术罢了。用多了浪费钱,用少了没效果——找到那个刚刚好的点,才是关键。

HagiCode 团队在设计这个计算器时,有几个值得借鉴的工程决策:

  1. 纯前端计算:所有计算都在浏览器完成,不依赖后端 API,保护用户隐私
  2. 配置驱动:所有公式、定价、岗位数据都集中在配置文件中,未来更新无需修改核心代码逻辑
  3. 多语言支持:支持中文和英文
  4. 即时反馈:用户输入参数后,结果实时更新
  5. 详细公式展示:每个结果都附带完整的计算公式,帮助用户理解

这种设计让计算器易于维护和扩展,也为类似的数据驱动型应用提供了参考模板。

毕竟好的架构,就像好的代码一样,是需要时间沉淀的。HagiCode 团队在这方面还是挺用心的。

AI 人效计算器的核心价值,在于它把”AI 替代威胁”这个模糊的焦虑,转化为了可以量化、可以比较的指标。

等效人力公式 1 + (效率倍数 - 1) × 可负担比例 是整个评估体系的核心创新。它不仅考虑效率提升,还考虑企业能否负担 AI 成本,使评估结果更贴近现实。

这套评估体系告诉我们:在 AI 时代不知道自己处于什么位置,才是最危险的位置。

与其焦虑,不如用数据说话。

其实很多时候,恐惧源于未知。当你把一切量化之后,就会发现事情也没那么可怕。大不了就提升自己,或者换个赛道罢了。毕竟人生还长,没必要在一棵树上吊死。


现在就访问 cost.hagicode.com,完成你的 AI 人效评估。



数据来源:cost.hagicode.com | Powered by HagiCode

写到最后,想起一句诗:“此情可待成追忆,只是当时已惘然。” 其实 AI 时代也是一样,与其等到被淘汰时追悔莫及,不如现在就开始行动吧…

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

Hagicode.Libs:统一集成多个 AI 编程助手 CLI 的工程实践

Hagicode.Libs:统一集成多个 AI 编程助手 CLI 的工程实践

Section titled “Hagicode.Libs:统一集成多个 AI 编程助手 CLI 的工程实践”

在开发 HagiCode 项目的过程中,我们需要同时集成 Claude Code、Codex、CodeBuddy 等多个 AI 编程助手 CLI。每个 CLI 的接口、参数、输出格式都不一样,重复的集成代码让项目越来越难以维护。本文分享我们如何通过 HagiCode.Libs 库构建统一抽象层,解决这个工程痛点——也算是我们踩过的坑,积累下来的一点经验罢了。

现在的 AI 编程助手市场挺热闹的,除了 Claude Code,还有 OpenAI 的 Codex、智谱的 CodeBuddy 等等。作为一个 AI 代码助手项目,HagiCode 需要在多个子项目(桌面端、后端、Web 等)中集成这些不同的 CLI 工具。

一开始问题还不大,集成一个 CLI 也就是几百行代码的事儿。只是随着要支持的 CLI 越来越多,事情就开始变得麻烦了——

每个 CLI 有不同的命令行参数格式,环境变量的要求也不一样,输出格式更是五花八门——有的输出 JSON,有的流式 JSON,有的就是纯文本。再加上跨平台兼容性问题,Windows 和 Unix 系统的可执行文件发现和进程管理机制完全不同,代码重复度越来越高。其实这也无非就是 Ctrl+C、Ctrl+V 多了一点,但维护起来可就头疼了。

最头疼的是,每次要新增一个 CLI 功能支持,就得在好几个项目里改同样的代码。这种方式显然不是长久之计——代码也是有脾气的,重复太多它也会闹别扭。

本文分享的方案来自我们在 HagiCode 项目中的实践经验。HagiCode 是一个开源的 AI 代码助手项目,需要同时维护前端 VSCode 扩展、后端 AI 服务、跨平台桌面客户端等多个子项目。怎么说呢,正是这种多语言、多平台的复杂场景,促成了 HagiCode.Libs 的诞生——算是被逼出来的,也罢。

虽然这些 AI 编程助手 CLI 各有特点,但从技术层面来看,它们存在明显的共同特征:

相似的交互模式:都是启动 CLI 进程,发送提示词,接收流式响应,解析消息,最后会话结束或继续——这一套流程,说到底都是一个模子刻出来的。

相似的配置需求:都需要 API 密钥认证、工作目录设置、模型选择、工具权限控制、会话管理。毕竟大家都是吃 API 这碗饭的,差别无非是口味不同。

跨平台挑战一致:都需要解决可执行文件路径解析(claude vs claude.exe vs /usr/local/bin/claude)、进程启动和环境变量处理、Shell 命令转义和参数构建等问题。这跨平台的事儿,说多了都是泪——Windows 和 Unix 的差异,只有踩过坑的人才知道。

基于这些分析,我们需要一个统一的抽象层来提供一致的接口,封装跨平台的 CLI 发现逻辑,处理流式输出的解析,同时支持依赖注入和非 DI 场景使用。这事儿想想就头大,但还是要面对——毕竟是自己的项目,哭着也要做完。

我们创建了 HagiCode.Libs —— 一个轻量级的 .NET 10 库工作空间,采用 MIT 开源协议,现已发布在 GitHub。虽然不是什么惊天地泣鬼神的大作,但解决实际问题,还是挺香的。

HagiCode.Libs/
├── src/
│ ├── HagiCode.Libs.Core/ # 核心功能
│ │ ├── Discovery/ # CLI 可执行文件发现
│ │ ├── Process/ # 跨平台进程管理
│ │ ├── Transport/ # 流式消息传输
│ │ └── Environment/ # 运行时环境解析
│ ├── HagiCode.Libs.Providers/ # 提供者实现
│ │ ├── ClaudeCode/ # Claude Code 提供者
│ │ ├── Codex/ # Codex 提供者
│ │ └── Codebuddy/ # CodeBuddy 提供者
│ ├── HagiCode.Libs.ConsoleTesting/ # 测试框架
│ ├── HagiCode.Libs.ClaudeCode.Console/
│ ├── HagiCode.Libs.Codex.Console/
│ └── HagiCode.Libs.Codebuddy.Console/
└── tests/ # xUnit 测试

在设计 HagiCode.Libs 时,我们遵循了几个原则——毕竟也都是踩过的坑,总结出来的经验:

零重型框架依赖:不依赖 ABP 或其他大型框架,保持轻量级。这年头,依赖越少,麻烦越少——谁还没被依赖地狱毒打过呢。

跨平台支持:Windows、macOS、Linux 原生支持,不需要针对不同平台写不同的代码。一套代码走天下,也挺好的。

流式处理:使用异步流处理 CLI 输出,更符合现代 .NET 的编程模式。毕竟时代在变,异步才是王道。

灵活集成:既支持依赖注入场景,也允许直接实例化使用。萝卜青菜,各有所爱,怎么方便怎么来。

如果你的项目已经在使用依赖注入(比如 ASP.NET Core 或通用主机),可以直接集成——这也算是个孩子,虽然不大,但挺乖的:

using HagiCode.Libs.Providers;
using Microsoft.Extensions.DependencyInjection;
var services = new ServiceCollection();
services.AddHagiCodeLibs();
await using var provider = services.BuildServiceProvider();
var claude = provider.GetRequiredService<ICliProvider<ClaudeCodeOptions>>();
var options = new ClaudeCodeOptions
{
ApiKey = "your-api-key",
Model = "claude-sonnet-4-20250514"
};
await foreach (var message in claude.ExecuteAsync(options, "Hello, Claude!"))
{
Console.WriteLine($"{message.Type}: {message.Content}");
}

如果是简单的脚本或者不使用 DI 的场景,直接创建实例也行——说白了就是看个人喜好:

var claude = new ClaudeCodeProvider();
var options = new ClaudeCodeOptions
{
ApiKey = "sk-ant-xxx",
Model = "claude-sonnet-4-20250514"
};
await foreach (var message in claude.ExecuteAsync(options, "帮我写一个快速排序"))
{
// 处理消息
}

两种方式使用的是同一套底层实现,你可以根据项目实际情况选择合适的集成方式。这世界本就没有标准答案,适合自己的才是最好的——虽然这话有点老套,但确实是这么个理儿。

每个提供者都有专用的测试控制台项目,方便独立验证集成效果——怎么说呢,测试这件事,要么不做,要做就做到位:

Terminal window
# Claude Code 测试
dotnet run --project src/HagiCode.Libs.ClaudeCode.Console -- --test-provider
dotnet run --project src/HagiCode.Libs.ClaudeCode.Console -- --test-all claude
# CodeBuddy 测试
dotnet run --project src/HagiCode.Libs.Codebuddy.Console -- --test-provider codebuddy-cli
# Codex 测试
dotnet run --project src/HagiCode.Libs.Codex.Console -- --test-provider codex-cli

测试场景覆盖了几个关键场景:

  • Ping:健康检查,确认 CLI 可用
  • Simple Prompt:基本提示测试
  • Complex Prompt:多轮对话测试
  • Session Restore/Resume:会话恢复测试
  • Repository Analysis:代码库分析测试

这种独立的测试控制台设计在调试时特别有用,可以快速定位问题是在 HagiCode.Libs 层还是 CLI 本身。这调试嘛,说白了就是看问题出在哪儿——方向对了,就成功了一半。

跨平台兼容性是 HagiCode.Libs 的核心目标之一。我们配置了 GitHub Actions 工作流 .github/workflows/cli-discovery-cross-platform.yml,在 ubuntu-latestmacos-latestwindows-latest 三个平台上运行真实的 CLI 发现验证。

这确保了每次代码变更都不会破坏跨平台兼容性。本地开发时也可以通过以下命令复现——总不能让 CI 帮你背所有锅,自己本地也要能跑起来:

Terminal window
npm install --global @anthropic-ai/claude-code@2.1.79
HAGICODE_REAL_CLI_TESTS=1 dotnet test --filter "Category=RealCli"

HagiCode.Libs 使用异步流处理 CLI 输出,这种方式比传统的回调或事件模式更符合现代 .NET 的异步编程风格——说到底,这就是技术的进步,谁也挡不住:

public async IAsyncEnumerable<CliMessage> ExecuteAsync(
TOptions options,
string prompt,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
// 启动 CLI 进程
// 解析流式 JSON 输出
// 生成 CliMessage 序列
}

消息类型包括:

  • user:用户消息
  • assistant:助手响应
  • tool_use:工具调用
  • result:会话结束

这种设计让调用方可以灵活地处理流式输出,比如实时显示、缓冲后处理、或者转发到其他服务。美又何必在乎天晴阴呢?重要的是思路打开了,怎么用都成。

HagiCode.Libs.Exploration 模块提供了 Git 仓库发现和状态检查功能,这在分析代码库场景特别有用——这也是被逼出来的功能,谁让 HagiCode 需要分析代码库呢:

// 发现 Git 仓库
var repositories = await GitRepositoryDiscovery.DiscoverAsync("/path/to/search");
// 获取仓库信息
var info = await GitRepository.GetInfoAsync(repoPath);
Console.WriteLine($"Branch: {info.Branch}, Remote: {info.RemoteUrl}");
Console.WriteLine($"Has uncommitted changes: {info.HasUncommittedChanges}");

HagiCode 的代码分析功能就用到了这个模块来识别项目结构和 Git 状态。算是物尽其用,也算是个孩子,没白养。

基于我们在 HagiCode 项目中的实践,有几个地方需要特别注意——都是事儿,事儿,事儿:

API 密钥安全:不要将 API 密钥硬编码到代码中,使用环境变量或配置管理。HagiCode.Libs 支持通过 Options 对象传递配置,方便集成各种配置源。毕竟安全这件事,怎么小心都不为过。

CLI 版本锁定:CI/CD 中我们锁定了特定版本(如 @anthropic-ai/claude-code@2.1.79)以减少版本漂移带来的不确定性。本地开发时建议也使用固定版本。这版本的事儿,说多了都是泪——不固定版本,分分钟教你做人。

测试分类:默认测试使用假提供者保持确定性和速度,真实 CLI 测试需要显式启用。这样既保证了 CI 的快速反馈,又能在需要时进行真实环境验证。这平衡木走起来,也不容易——快和稳,总是需要取舍。

会话管理:不同 CLI 的会话恢复机制不同,Claude Code 使用 .claude/ 目录存储会话,Codex 和 CodeBuddy 各有自己的方式。使用时要注意查看各自文档,了解会话持久化的具体机制。这也罢了,了解清楚总没坏处。

HagiCode.Libs 是我们在开发 HagiCode 过程中,为了解决多 CLI 集成的重复工程问题而构建的统一抽象层。它通过提供一致的接口、封装跨平台细节、支持灵活的集成方式,大大降低了集成多个 AI 编程助手的工程复杂度。一切都淡了——但经验留下了。

如果你也在项目中需要集成多个 AI CLI 工具,或者对跨平台进程管理、流式消息处理感兴趣,欢迎来 GitHub 看看。项目采用 MIT 开源协议,欢迎贡献和反馈。终究是缘分一场,来都来了,不妨交个朋友。

本文分享的方案是 HagiCode 实际踩坑、实际优化出来的。这又有什么办法呢?踩坑嘛,正常——如果你觉得这套方案有价值,说明我们的工程实力也还算过得去。那么 HagiCode 本身,也值得关注一下,说不定有惊喜呢。


如果本文对你有帮助:

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

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 框架,希望这篇文章能提供一些参考。毕竟,选对底层架构,后续开发会轻松很多…

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

.NET 代码保护实战:从混淆到虚拟机保护

.NET 代码保护实战:从混淆到虚拟机保护

Section titled “.NET 代码保护实战:从混淆到虚拟机保护”

本文将介绍如何在 .NET 项目中实施多层次代码保护策略,涵盖从基础混淆到专业虚拟机保护的全部方案。

在 .NET 应用程序开发中,保护核心代码(如许可证验证、业务逻辑、敏感配置等)不被反编译和逆向分析,怎么说呢,这也是个绕不开的话题。随着 .NET 生态系统的成熟,开发者有了多种代码保护手段,从内置的混淆属性到专业的虚拟化保护工具,选择倒是挺多的。

作为一个复杂的多语言 monorepo 项目,HagiCode 包含了桌面应用程序、构建系统和许可证管理功能。代码中不可避免地涉及到许可证验证逻辑、敏感配置(如 API 密钥、产品 ID)以及业务核心逻辑,这些东西还是得好好保护一下,毕竟谁也不想自己的心血轻易被人看了去。

本文将分享我们在 HagiCode 项目中实际采用的代码保护方案,总结从踩坑到优化的完整过程,或许能给你一些启发。

HagiCode 是一个开源的 AI 代码助手项目,致力于为开发者提供智能化的编程辅助体验。项目采用 monorepo 架构,同时维护着 VSCode 扩展、后端 AI 服务、跨平台桌面客户端等多个组件。这种多语言、多平台的复杂度,使得代码保护成为必须面对的工程挑战,也没辙,谁让项目这么复杂呢。

本文分享的方案,正是我们在开发 HagiCode 过程中实际踩坑、实际优化出来的。如果你想了解我们是如何解决这些技术难题的,请继续往下看,或许会有一些意外的收获。

.NET Framework 提供了一个内置的 [ObfuscationAttribute],这是最基础也是最常用的代码混淆标记。该属性位于 System.Reflection 命名空间下,可以在不引入第三方工具的情况下对代码进行基础保护,倒也挺方便的。

核心特性

  • Feature 属性:指定混淆特性,如 "ultra"(高度混淆)、"all"(全部混淆)
  • Exclude 属性:true 表示排除混淆,false 表示应用混淆
  • 可应用于类、方法、属性等类型成员

在 HagiCode 项目中,可以看到实际使用示例:

[Obfuscation(Feature = "ultra", Exclude = false)]
public async Task<LicenseValidationResult?> ValidateLicenseAsync(...)

这种方式的优势还是挺明显的:

  • 无需额外依赖,.NET Framework 内置自带,省了不少事
  • 可被第三方混淆工具识别和处理
  • 不会显著增加编译后的程序集大小

不过它也有局限性:仅是标记作用,实际混淆效果依赖工具实现,无法提供虚拟机保护级别的安全性,这也罢了,毕竟它本来就不是为此而生的。

VMP 是一个专业的代码保护工具,通过将代码编译为虚拟机指令来提供高级别的保护。与简单的名称混淆不同,VMP 真正将代码逻辑转换为无法被常规反编译器还原的形式,这点倒是挺厉害的。

保护级别分类

级别虚拟化变异反调试字符串加密适用场景
HIGHfullhigh启用启用许可证验证、会话并发、敏感常量
MEDIUMpartialmedium启用启用业务逻辑、领域模型
LOWnonelow禁用禁用工具类、非关键代码

HagiCode 项目定义了一套声明式属性系统来标记需要保护的代码:

// 高优先级保护
[VmProtect(VmProtectionPriority.High, Reason = "Contains license verification logic")]
public class KeygenClient { ... }
// 排除保护
[VmExclude(Reason = "Public API that must remain unchanged")]
public class PublicApi { ... }
// 继承保护
[VmProtect(Priority.High, ProtectDerived = true)]
public class BaseLicenseValidator { ... }

VMP 保护不仅在运行时生效,更需要在构建流程中自动化处理,毕竟手动来做也太麻烦了。HagiCode 的构建系统支持多种模式:

  • Windows 原生模式:直接调用 VMProtect 工具
  • Linux Docker 容器模式:在容器中运行 VMP(解决跨平台兼容性问题)
  • Attribute 扫描:自动发现代码中的保护标记
  • 验证机制:确认保护已成功应用

这些功能组合起来,倒也挺省心的。

在代码中直接应用 ObfuscationAttribute

using System.Reflection;
[Obfuscation(Feature = "ultra", Exclude = false)]
public class LicenseService
{
[Obfuscation(Feature = "ultra", Exclude = false)]
public async Task<bool> ValidateLicenseAsync(string key)
{
// 许可证验证逻辑
}
[Obfuscation(Feature = "flow", Exclude = false)]
private string DecryptToken(string encrypted)
{
// 解密逻辑
}
}

有时需要让测试程序集访问内部成员,同时保持生产代码的安全性:

AssemblyInfo.cs
[assembly: InternalsVisibleTo("HagiCode.Application.Tests")]
[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")] // for Moq

这样测试起来方便多了,毕竟代码还是要测试的。

2. VMP 保护的自定义 Attribute 定义

Section titled “2. VMP 保护的自定义 Attribute 定义”

创建自定义保护属性来控制 VMP 的行为:

using System;
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method | AttributeTargets.Property)]
public class VmProtectAttribute : Attribute
{
public VmProtectionPriority Priority { get; set; }
public string? Reason { get; set; }
public bool ProtectDerived { get; set; }
}
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method | AttributeTargets.Property)]
public class VmExcludeAttribute : Attribute
{
public string? Reason { get; set; }
}
public enum VmProtectionPriority
{
None = 0,
Low = 1,
Medium = 2,
High = 3
}

自己定义的属性用起来也更顺手,毕竟了解自己的需求。

vmp_config.yml
protection:
priority_mode: "attribute" # 基于 Attribute 的优先级
default_level: "medium"
tools:
- name: "vmprotect"
path: "C:\\Program Files\\VMProtect Ultimate\\VMProtect.exe"
protection_levels:
high:
virtualization: "full"
mutation: "high"
anti_debug: true
anti_dump: true
encrypt_strings: true
encrypt_resources: true
medium:
virtualization: "partial"
mutation: "medium"
anti_debug: true
encrypt_strings: true
low:
virtualization: "none"
mutation: "low"
anti_debug: false

配置写清楚一点,后面维护起来也方便。

根据 HagiCode 的 code-protection 规范,以下组件必须使用 HIGH 优先级保护:

// 生产环境常量 - 必须加密并受 VMP 保护
[VmProtect(VmProtectionPriority.High, Reason = "Production constants")]
public static class ProductionConstants
{
// 加密字符串访问器,由 VMP 保护
[VmProtect(VmProtectionPriority.High)]
public static string GetLicenseServerUrl(IOptions<LicenseOptions> options) => ...;
}
// 许可证验证逻辑
[VmProtect(VmProtectionPriority.High, Reason = "License verification logic")]
public class KeygenClient : IKeygenClient
{
[Obfuscation(Feature = "ultra", Exclude = false)]
public async Task<LicenseValidationResult?> ValidateLicenseAsync(...) { ... }
}
// 机器指纹服务
[VmProtect(VmProtectionPriority.High)]
public class MachineFingerprintService : IMachineFingerprintService { ... }

关键的代码还是要重点保护,毕竟核心逻辑泄露了就麻烦了。

构建时加密字符串,运行时解密:

public static class StringDecryption
{
[VmProtect(VmProtectionPriority.High, Reason = "CRITICAL SECURITY")]
public static string DecryptString(byte[] encryptedData, byte[] key, byte[] iv)
{
using var aes = Aes.Create();
aes.Key = key;
aes.IV = iv;
using var decryptor = aes.CreateDecryptor();
using var ms = new MemoryStream(encryptedData);
using var cs = new CryptoStream(ms, decryptor, CryptoStreamMode.Read);
using var reader = new StreamReader(cs);
return reader.ReadToEnd();
}
}
// 生产常量访问器(懒加载 + 缓存)
public static class ProductionConstants
{
private static string? _cachedLicenseServerUrl;
public static string GetLicenseServerUrl(IOptions<LicenseOptions> options)
{
if (_cachedLicenseServerUrl == null)
{
var encrypted = GetEncryptedLicenseServerUrl();
#if DEBUG
_cachedLicenseServerUrl = options.Value.PrimaryServer.Url;
#else
_cachedLicenseServerUrl = StringDecryption.DecryptString(
encrypted,
GetEncryptionKey(),
GetEncryptionIV());
#endif
}
return _cachedLicenseServerUrl;
}
}

字符串加密这个环节也挺重要的,毕竟敏感信息不能明文放着。

构建后必须验证保护是否成功应用,不然怎么知道保护生效了呢:

// 验证脚本示例
public bool VerifyProtection(string assemblyPath)
{
// 1. 检查 VMP 签名
var bytes = File.ReadAllBytes(assemblyPath);
var vmpSignature = Encoding.ASCII.GetBytes("VMProtect");
if (bytes.Any(b => vmpSignature.Contains(b)))
{
return true;
}
// 2. 检查文件大小变化(保护后通常会增大)
var originalInfo = new FileInfo(assemblyPath.Replace(".dll", ".bak"));
if (originalInfo.Exists)
{
var sizeRatio = (double)new FileInfo(assemblyPath).Length / originalInfo.Length;
return sizeRatio > 1.1;
}
return false;
}

验证一下总是好的,省得到时候出了问题还不知道。

这里有几个坑需要特别注意,毕竟我们都是踩过来的:

  1. 不要混淆所有代码:公共 API、接口定义、DTO 类通常不需要保护,过度混淆会影响性能和调试,HagiCode 项目在这方面就吃过亏,这点得注意

  2. 保护密钥访问器:加密密钥的获取方法必须与加密数据享受同级或更高级别的保护,否则就是形同虚设,也没什么意义了

  3. 测试与生产的平衡:DEBUG 构建应跳过加密以便于开发调试,RELEASE 构建启用完整保护,记得用条件编译 #if DEBUG 来区分,这样开发起来也方便

  4. Docker 环境考虑:在 Linux 环境下运行 VMP 需要使用容器化方案,确保保护工具的兼容性,HagiCode 使用的是 Wine + VMP 的容器方案,跨平台的问题倒是这样解决的

  5. 验证不可少:构建完成后必须验证保护是否成功,否则可能导致敏感代码暴露,前面的验证代码就是这个作用,还是检查一下比较好

通过这种多层保护策略,HagiCode 实现了从基础混淆到虚拟机保护的全面代码安全体系:

  • 第一层:使用 ObfuscationAttribute 进行基础标记,为第三方工具提供提示
  • 第二层:通过自定义 VmProtectAttribute 声明保护意图和优先级
  • 第三层:VMP 虚拟机保护将关键代码转换为不可逆的虚拟机指令
  • 第四层:构建时自动扫描应用保护,验证保护结果

这套方案既能抵御普通反编译工具,又能对抗高级逆向分析攻击。如果你也在开发需要代码保护的 .NET 应用,希望能给你一些参考,哪怕是一点点启发也够了。


如果本文对你有帮助:

打造 AI 冒险团:HagiCode 多 Agent 协作配置实战

打造 AI 冒险团:HagiCode 多 Agent 协作配置实战

Section titled “打造 AI 冒险团:HagiCode 多 Agent 协作配置实战”

在现代软件开发中,单一 AI Agent 已经难以满足复杂需求。如何让来自不同公司的多个 AI 助手在同一项目中协同工作?本文将分享 HagiCode 项目在实际开发中探索出的多 Agent 协作配置方案。

相信很多开发者都有过这样的经历:项目中引入了 AI 助手辅助编程,效率确实提高了。但随着需求越来越复杂,一个 AI Agent 开始不够用了——你想让它同时处理代码审查、文档生成、单元测试等多个任务,结果往往是顾此失彼,输出质量参差不齐。

更头疼的是,当你尝试引入多个 AI 助手时,问题就变得更复杂了。每个 Agent 有自己的配置方式、API 接口和执行逻辑,彼此之间甚至会产生冲突。这就像一支球队,每个球员都很厉害,但没有人知道该怎么配合,结果踢得乱七八糟。

HagiCode 项目在开发过程中也遇到了同样的困扰。作为一个涉及前端 VSCode 扩展、后端 AI 服务、跨平台桌面客户端的复杂项目,我们需要同时对接来自不同公司的多个 AI 助手:Claude Code、Codex、CodeBuddy、iFlow 等等。如何让它们在同一项目中和谐共处、发挥各自特长,成了必须解决的关键问题。

其实这也罢了,毕竟谁愿意每天跟一群打架的 AI 打交道呢。

本文分享的方案,正是我们在 HagiCode 项目中实际踩坑、实际优化出来的多 Agent 协作配置实践。如果你也在为多 AI 助手协作而头疼,相信这篇文章会给你一些启发。或许吧,毕竟每个人的情况都不一样。

HagiCode 是一个 AI 代码助手项目,采用多 AI 引擎协同工作的”冒险团”模式。项目地址:github.com/HagiCode-org/site

本文分享的多 Agent 配置方案,正是 HagiCode 能够在复杂项目中保持高效开发的核心技术之一。也没什么特别的,就是把一群 AI 变成一支能打配合的冒险团而已。

从”单打独斗”到”团队协作”

Section titled “从”单打独斗”到”团队协作””

在 HagiCode 项目早期,我们也尝试过只用一个 AI Agent 来处理所有任务。很快我们就发现,这种方式存在明显的瓶颈:不同的任务需要不同的能力侧重点,有的任务需要更强的上下文理解能力,有的则需要更精准的代码修改能力。一个 Agent 很难在所有方面都表现出色。

这让我们意识到,必须让多个 Agent 协同工作。但问题是,如何让不同公司的 AI 产品在同一个项目中和平共处?我们需要解决几个核心问题:

  1. 配置管理复杂性:每个 Agent 有不同的配置方式、API 接口和执行模式
  2. 通信协议统一:需要一种标准化的方式让不同 Agent 之间进行数据交换
  3. 任务分工协调:如何合理分配任务,让每个 Agent 发挥特长

带着这些问题,我们开始设计 HagiCode 的多 Agent 架构。其实也没那么复杂,只是想明白了而已。

经过多次迭代,我们最终确定的架构是这样的:

┌─────────────────────────────────────────────────────────────────┐
│ AIProviderFactory │
│ (工厂模式统一管理所有 AI Provider) │
├─────────────────────────────────────────────────────────────────┤
│ ClaudeCodeCli │ CodexCli │ CodebuddyCli │ IFlowCli │
│ (Anthropic) │ (OpenAI) │ (智谱 GLM) │ (智谱) │
└─────────────────────────────────────────────────────────────────┘

核心思路是:通过统一的 Provider 接口,让不同的 AI Agent 可以被同一套代码管理。同时使用工厂模式动态创建和配置这些 Provider,确保系统的扩展性和灵活性。

这就像生活中的分工,每个人都有自己的角色,只是这里把这种分工变成了代码架构而已。

根据 HagiCode 项目的实际使用经验,我们为每个 Agent 分配了不同的职责:

Agent提供商模型主要用途
ClaudeCodeCliAnthropicglm-5-turbo生成技术方案和Proposal
CodexCliOpenAI/Zedgpt-5.4执行精准的代码修改
CodebuddyCli智谱glm-4.7优化提案描述和文档
IFlowCli智谱glm-4.7归档提案和历史记录
OpenCodeCli--通用代码编辑
GitHubCopilotMicrosoft-辅助编程和代码补全

这种分工的背后逻辑是:每个 Agent 都有自己擅长的领域。Claude Code 在理解和分析复杂需求方面表现出色,所以让它负责前期的方案设计;Codex 在代码修改方面更精准,适合处理具体的实现任务;CodeBuddy 性价比高,用来优化文档再合适不过。

毕竟适合自己的才是最好的,条条大路通罗马,只是有的路好走一点,有的路稍微曲折一点罢了。

要让不同的 AI Agent 能够被统一管理,首先需要定义一套统一的接口。HagiCode 中定义了这个接口:

public interface IAIProvider
{
// 统一的 Provider 接口
Task<IAIProvider?> GetProviderAsync(AIProviderType providerType);
Task<IAIProvider?> GetProviderAsync(string providerName, CancellationToken cancellationToken);
}

这个接口看起来很简单,但它是整个多 Agent 系统的基石。通过统一的接口,我们可以无视底层是哪个公司的 AI 产品,都以相同的方式进行调用。

其实这就是把复杂的事情简单化了,毕竟简单才是美。

有了统一的接口,接下来就是如何创建这些 Provider 实例。HagiCode 使用了工厂模式:

private IAIProvider? CreateProvider(AIProviderType providerType, ProviderConfiguration config)
{
return providerType switch
{
AIProviderType.ClaudeCodeCli =>
ActivatorUtilities.CreateInstance<ClaudeCodeCliProvider>(_serviceProvider, Options.Create(config)),
AIProviderType.CodebuddyCli =>
ActivatorUtilities.CreateInstance<CodebuddyCliProvider>(_serviceProvider, Options.Create(config)),
AIProviderType.CodexCli =>
ActivatorUtilities.CreateInstance<CodexCliProvider>(_serviceProvider, Options.Create(config)),
AIProviderType.IFlowCli =>
ActivatorUtilities.CreateInstance<IFlowCliProvider>(_serviceProvider, Options.Create(config)),
_ => null
};
}

这里用到了依赖注入的 ActivatorUtilities.CreateInstance,它可以在运行时动态创建 Provider 实例,并且自动注入依赖项。这种设计的好处是:新增一个 Agent 类型时,只需要添加对应的 Provider 类,然后在工厂方法中加一个 case 分支即可,完全不需要修改现有代码。

这也罢了,毕竟谁愿意每次加新功能都要改一堆旧代码呢。

为了让配置更灵活,我们还实现了类型映射机制:

public static AIProviderTypeExtensions
{
private static readonly Dictionary<string, AIProviderType> _typeMap = new(
StringComparer.OrdinalIgnoreCase)
{
["ClaudeCodeCli"] = AIProviderType.ClaudeCodeCli,
["CodebuddyCli"] = AIProviderType.CodebuddyCli,
["CodexCli"] = AIProviderType.CodexCli,
["IFlowCli"] = AIProviderType.IFlowCli,
// ...更多类型映射
};
}

这个映射表的作用是将字符串形式的 Provider 名称转换为枚举类型。这样一来,配置文件就可以使用直观的字符串名称,而代码内部则使用类型安全的枚举进行处理。

毕竟配置这东西,越直观越好,谁愿意记一堆复杂的代码呢。

实际使用时,只需要在 appsettings.json 中配置即可:

AI:
Providers:
Providers:
ClaudeCodeCli:
Enabled: true
Model: glm-5-turbo
WorkingDirectory: /path/to/project
CodebuddyCli:
Enabled: true
Model: glm-4.7
CodexCli:
Enabled: true
Model: gpt-5.4
IFlowCli:
Enabled: true
Model: glm-4.7

每个 Provider 都可以独立配置开关、模型版本、工作目录等参数。这种设计既保证了灵活性,又便于管理和维护。

其实配置文件就像人生的选项,你可以选择开启或关闭某些功能,只是代码里的选择更容易后悔罢了。

有了统一的技术架构,接下来就是如何让多个 Agent 协同工作了。HagiCode 设计了一套任务流转机制,让不同的 Agent 处理不同阶段的任务:

提案创建 (用户)
[Claude Code] ──生成提案──▶ 提案文档
│ │
│ ▼
│ [Codebuddy] ──优化描述──▶ 优化后提案
│ │
│ ▼
│ [Codex] ──执行修改──▶ 代码变更
│ │
│ ▼
└───────────────▶ [iFlow] ──归档──▶ 历史记录

这种分工的好处是:每个 Agent 只需要专注于自己擅长的任务,不需要”什么都会”。Claude Code 负责从无到有生成提案,Codebuddy 负责把提案描述得更清晰,Codex 负责把提案变成实际的代码变更,iFlow 则负责把这些变更归档保存。

其实这就像生活中的团队合作,每个人都有自己的角色,合起来才能完成一件大事。只是这里的团队成员是 AI 而已。

在实际运行中,我们总结了以下几点经验:

1. Agent 选择策略很重要

不是随便分配任务,而是要根据每个 Agent 的特长来分配:

  • 提案生成:使用 Claude Code,因为它有更强的上下文理解能力
  • 代码执行:使用 Codex,因为它在代码修改方面更精准
  • 提案优化:使用 Codebuddy,因为它的性价比高
  • 归档存储:使用 iFlow,因为它稳定可靠

毕竟让合适的人做合适的事,这是千古不变的道理。

2. 配置隔离确保稳定性

每个 Agent 的配置独立管理,支持环境变量覆盖,工作目录也相互独立。这样一来,一个 Agent 的配置出错不会影响到其他 Agent。

这就像生活中的界限,每个人都有自己的空间,互不干扰才能和谐共处。

3. 错误处理机制

单个 Agent 失败不应该影响整体流程。我们实现了降级策略:当某个 Agent 执行失败时,系统可以自动切换到备用方案,或者直接跳过该步骤继续执行后续任务。同时,完整的日志记录也便于事后排查问题。

毕竟谁也不能保证永远不会出错,关键是怎么处理错误。这就像人生,总会遇到挫折,重要的是怎么走出来。

4. 监控与可观测性

通过 ACP 协议(我们自定义的通信协议,基于 JSON-RPC 2.0),可以追踪每个 Agent 的执行状态。会话隔离确保了并发安全,动态缓存则优化了性能表现。

毕竟看不见的东西最容易出问题,有点监控总好过两眼一抹黑。

采用这套多 Agent 协作配置后,HagiCode 项目的开发效率有了明显提升。具体表现在:

  1. 任务处理能力翻倍:以前一个 Agent 需要同时处理多种任务,现在可以并行处理,吞吐量翻倍不止
  2. 输出质量更稳定:每个 Agent 只专注于自己擅长的任务,输出结果的一致性和质量都更高
  3. 维护成本降低:统一的接口和配置管理,让整个系统更容易维护和扩展
  4. 新增 Agent 简单:如果要接入新的 AI 产品,只需要实现接口、添加配置,不需要修改核心逻辑

这套方案不仅解决了 HagiCode 自身的问题,也证明了多 Agent 协作确实是一种可行的架构选择。

其实效果还挺明显的,只是过程有点折腾罢了。

本文分享了 HagiCode 项目在多 Agent 协作配置方面的实践经验。核心要点包括:

  1. 标准化接口:通过 IAIProvider 统一不同 Agent 的行为,让代码可以无视底层是哪个公司的产品
  2. 工厂模式:使用 ActivatorUtilities.CreateInstance 动态创建 Provider 实例,支持运行时配置和依赖注入
  3. 协议统一:ACP 协议实现 Agent 间的标准化通信,基于 JSON-RPC 2.0 的双向通信机制
  4. 任务分流:合理分配任务给不同的 Agent,让它们各展所长,而不是试图让一个 Agent 做所有事情

这种设计不仅解决了”多 Agent 打架”的问题,还通过冒险团的任务流转机制,实现了开发流程的自动化和专业化。

如果你也在考虑引入多个 AI 劏手,希望本文能给你一些参考。当然,每个项目的情况不同,具体方案还需要根据实际情况调整。毕竟没有放之四海而皆准的方案,适合自己的才是最好的。

美的事物或人,不一定要占有,只要她还是美的,自己好好看着她的美就好了。技术方案也是如此,适合自己的,就是最好的…


如果本文对你有帮助,欢迎来 GitHub 给个 Star,您的支持是我们继续分享的动力。公测已开始,欢迎安装体验。


感谢您的阅读,如果您觉得本文有用,快点击下方点赞按钮,让更多的人看到本文。

本内容采用人工智能辅助协作,经本人审核,符合本人观点与立场。

打造 AI 冒险团:HagiCode 多 Agent 协作配置实战

打造 AI 冒险团:HagiCode 多 Agent 协作配置实战

Section titled “打造 AI 冒险团:HagiCode 多 Agent 协作配置实战”

在现代软件开发中,单一 AI Agent 已经难以满足复杂需求。如何让来自不同公司的多个 AI 助手在同一项目中协同工作?本文将分享 HagiCode 项目在实际开发中探索出的多 Agent 协作配置方案。

相信很多开发者都有过这样的经历:项目中引入了 AI 助手辅助编程,效率确实提高了。但随着需求越来越复杂,一个 AI Agent 开始不够用了——你想让它同时处理代码审查、文档生成、单元测试等多个任务,结果往往是顾此失彼,输出质量参差不齐。

更头疼的是,当你尝试引入多个 AI 助手时,问题就变得更复杂了。每个 Agent 有自己的配置方式、API 接口和执行逻辑,彼此之间甚至会产生冲突。这就像一支球队,每个球员都很厉害,但没有人知道该怎么配合,结果踢得乱七八糟。

HagiCode 项目在开发过程中也遇到了同样的困扰。作为一个涉及前端 VSCode 扩展、后端 AI 服务、跨平台桌面客户端的复杂项目,我们需要同时对接来自不同公司的多个 AI 助手:Claude Code、Codex、CodeBuddy、iFlow 等等。如何让它们在同一项目中和谐共处、发挥各自特长,成了必须解决的关键问题。

其实这也罢了,毕竟谁愿意每天跟一群打架的 AI 打交道呢。

本文分享的方案,正是我们在 HagiCode 项目中实际踩坑、实际优化出来的多 Agent 协作配置实践。如果你也在为多 AI 助手协作而头疼,相信这篇文章会给你一些启发。或许吧,毕竟每个人的情况都不一样。

HagiCode 是一个 AI 代码助手项目,采用多 AI 引擎协同工作的”冒险团”模式。项目地址:github.com/HagiCode-org/site

本文分享的多 Agent 配置方案,正是 HagiCode 能够在复杂项目中保持高效开发的核心技术之一。也没什么特别的,就是把一群 AI 变成一支能打配合的冒险团而已。

从”单打独斗”到”团队协作”

Section titled “从”单打独斗”到”团队协作””

在 HagiCode 项目早期,我们也尝试过只用一个 AI Agent 来处理所有任务。很快我们就发现,这种方式存在明显的瓶颈:不同的任务需要不同的能力侧重点,有的任务需要更强的上下文理解能力,有的则需要更精准的代码修改能力。一个 Agent 很难在所有方面都表现出色。

这让我们意识到,必须让多个 Agent 协同工作。但问题是,如何让不同公司的 AI 产品在同一个项目中和平共处?我们需要解决几个核心问题:

  1. 配置管理复杂性:每个 Agent 有不同的配置方式、API 接口和执行模式
  2. 通信协议统一:需要一种标准化的方式让不同 Agent 之间进行数据交换
  3. 任务分工协调:如何合理分配任务,让每个 Agent 发挥特长

带着这些问题,我们开始设计 HagiCode 的多 Agent 架构。其实也没那么复杂,只是想明白了而已。

经过多次迭代,我们最终确定的架构是这样的:

┌─────────────────────────────────────────────────────────────────┐
│ AIProviderFactory │
│ (工厂模式统一管理所有 AI Provider) │
├─────────────────────────────────────────────────────────────────┤
│ ClaudeCodeCli │ CodexCli │ CodebuddyCli │ IFlowCli │
│ (Anthropic) │ (OpenAI) │ (智谱 GLM) │ (智谱) │
└─────────────────────────────────────────────────────────────────┘

核心思路是:通过统一的 Provider 接口,让不同的 AI Agent 可以被同一套代码管理。同时使用工厂模式动态创建和配置这些 Provider,确保系统的扩展性和灵活性。

这就像生活中的分工,每个人都有自己的角色,只是这里把这种分工变成了代码架构而已。

根据 HagiCode 项目的实际使用经验,我们为每个 Agent 分配了不同的职责:

Agent提供商模型主要用途
ClaudeCodeCliAnthropicglm-5-turbo生成技术方案和Proposal
CodexCliOpenAI/Zedgpt-5.4执行精准的代码修改
CodebuddyCli智谱glm-4.7优化提案描述和文档
IFlowCli智谱glm-4.7归档提案和历史记录
OpenCodeCli--通用代码编辑
GitHubCopilotMicrosoft-辅助编程和代码补全

这种分工的背后逻辑是:每个 Agent 都有自己擅长的领域。Claude Code 在理解和分析复杂需求方面表现出色,所以让它负责前期的方案设计;Codex 在代码修改方面更精准,适合处理具体的实现任务;CodeBuddy 性价比高,用来优化文档再合适不过。

毕竟适合自己的才是最好的,条条大路通罗马,只是有的路好走一点,有的路稍微曲折一点罢了。

要让不同的 AI Agent 能够被统一管理,首先需要定义一套统一的接口。HagiCode 中定义了这个接口:

public interface IAIProvider
{
// 统一的 Provider 接口
Task<IAIProvider?> GetProviderAsync(AIProviderType providerType);
Task<IAIProvider?> GetProviderAsync(string providerName, CancellationToken cancellationToken);
}

这个接口看起来很简单,但它是整个多 Agent 系统的基石。通过统一的接口,我们可以无视底层是哪个公司的 AI 产品,都以相同的方式进行调用。

其实这就是把复杂的事情简单化了,毕竟简单才是美。

有了统一的接口,接下来就是如何创建这些 Provider 实例。HagiCode 使用了工厂模式:

private IAIProvider? CreateProvider(AIProviderType providerType, ProviderConfiguration config)
{
return providerType switch
{
AIProviderType.ClaudeCodeCli =>
ActivatorUtilities.CreateInstance<ClaudeCodeCliProvider>(_serviceProvider, Options.Create(config)),
AIProviderType.CodebuddyCli =>
ActivatorUtilities.CreateInstance<CodebuddyCliProvider>(_serviceProvider, Options.Create(config)),
AIProviderType.CodexCli =>
ActivatorUtilities.CreateInstance<CodexCliProvider>(_serviceProvider, Options.Create(config)),
AIProviderType.IFlowCli =>
ActivatorUtilities.CreateInstance<IFlowCliProvider>(_serviceProvider, Options.Create(config)),
_ => null
};
}

这里用到了依赖注入的 ActivatorUtilities.CreateInstance,它可以在运行时动态创建 Provider 实例,并且自动注入依赖项。这种设计的好处是:新增一个 Agent 类型时,只需要添加对应的 Provider 类,然后在工厂方法中加一个 case 分支即可,完全不需要修改现有代码。

这也罢了,毕竟谁愿意每次加新功能都要改一堆旧代码呢。

为了让配置更灵活,我们还实现了类型映射机制:

public static AIProviderTypeExtensions
{
private static readonly Dictionary<string, AIProviderType> _typeMap = new(
StringComparer.OrdinalIgnoreCase)
{
["ClaudeCodeCli"] = AIProviderType.ClaudeCodeCli,
["CodebuddyCli"] = AIProviderType.CodebuddyCli,
["CodexCli"] = AIProviderType.CodexCli,
["IFlowCli"] = AIProviderType.IFlowCli,
// ...更多类型映射
};
}

这个映射表的作用是将字符串形式的 Provider 名称转换为枚举类型。这样一来,配置文件就可以使用直观的字符串名称,而代码内部则使用类型安全的枚举进行处理。

毕竟配置这东西,越直观越好,谁愿意记一堆复杂的代码呢。

实际使用时,只需要在 appsettings.json 中配置即可:

AI:
Providers:
Providers:
ClaudeCodeCli:
Enabled: true
Model: glm-5-turbo
WorkingDirectory: /path/to/project
CodebuddyCli:
Enabled: true
Model: glm-4.7
CodexCli:
Enabled: true
Model: gpt-5.4
IFlowCli:
Enabled: true
Model: glm-4.7

每个 Provider 都可以独立配置开关、模型版本、工作目录等参数。这种设计既保证了灵活性,又便于管理和维护。

其实配置文件就像人生的选项,你可以选择开启或关闭某些功能,只是代码里的选择更容易后悔罢了。

有了统一的技术架构,接下来就是如何让多个 Agent 协同工作了。HagiCode 设计了一套任务流转机制,让不同的 Agent 处理不同阶段的任务:

提案创建 (用户)
[Claude Code] ──生成提案──▶ 提案文档
│ │
│ ▼
│ [Codebuddy] ──优化描述──▶ 优化后提案
│ │
│ ▼
│ [Codex] ──执行修改──▶ 代码变更
│ │
│ ▼
└───────────────▶ [iFlow] ──归档──▶ 历史记录

这种分工的好处是:每个 Agent 只需要专注于自己擅长的任务,不需要”什么都会”。Claude Code 负责从无到有生成提案,Codebuddy 负责把提案描述得更清晰,Codex 负责把提案变成实际的代码变更,iFlow 则负责把这些变更归档保存。

其实这就像生活中的团队合作,每个人都有自己的角色,合起来才能完成一件大事。只是这里的团队成员是 AI 而已。

在实际运行中,我们总结了以下几点经验:

1. Agent 选择策略很重要

不是随便分配任务,而是要根据每个 Agent 的特长来分配:

  • 提案生成:使用 Claude Code,因为它有更强的上下文理解能力
  • 代码执行:使用 Codex,因为它在代码修改方面更精准
  • 提案优化:使用 Codebuddy,因为它的性价比高
  • 归档存储:使用 iFlow,因为它稳定可靠

毕竟让合适的人做合适的事,这是千古不变的道理。

2. 配置隔离确保稳定性

每个 Agent 的配置独立管理,支持环境变量覆盖,工作目录也相互独立。这样一来,一个 Agent 的配置出错不会影响到其他 Agent。

这就像生活中的界限,每个人都有自己的空间,互不干扰才能和谐共处。

3. 错误处理机制

单个 Agent 失败不应该影响整体流程。我们实现了降级策略:当某个 Agent 执行失败时,系统可以自动切换到备用方案,或者直接跳过该步骤继续执行后续任务。同时,完整的日志记录也便于事后排查问题。

毕竟谁也不能保证永远不会出错,关键是怎么处理错误。这就像人生,总会遇到挫折,重要的是怎么走出来。

4. 监控与可观测性

通过 ACP 协议(我们自定义的通信协议,基于 JSON-RPC 2.0),可以追踪每个 Agent 的执行状态。会话隔离确保了并发安全,动态缓存则优化了性能表现。

毕竟看不见的东西最容易出问题,有点监控总好过两眼一抹黑。

采用这套多 Agent 协作配置后,HagiCode 项目的开发效率有了明显提升。具体表现在:

  1. 任务处理能力翻倍:以前一个 Agent 需要同时处理多种任务,现在可以并行处理,吞吐量翻倍不止
  2. 输出质量更稳定:每个 Agent 只专注于自己擅长的任务,输出结果的一致性和质量都更高
  3. 维护成本降低:统一的接口和配置管理,让整个系统更容易维护和扩展
  4. 新增 Agent 简单:如果要接入新的 AI 产品,只需要实现接口、添加配置,不需要修改核心逻辑

这套方案不仅解决了 HagiCode 自身的问题,也证明了多 Agent 协作确实是一种可行的架构选择。

其实效果还挺明显的,只是过程有点折腾罢了。

本文分享了 HagiCode 项目在多 Agent 协作配置方面的实践经验。核心要点包括:

  1. 标准化接口:通过 IAIProvider 统一不同 Agent 的行为,让代码可以无视底层是哪个公司的产品
  2. 工厂模式:使用 ActivatorUtilities.CreateInstance 动态创建 Provider 实例,支持运行时配置和依赖注入
  3. 协议统一:ACP 协议实现 Agent 间的标准化通信,基于 JSON-RPC 2.0 的双向通信机制
  4. 任务分流:合理分配任务给不同的 Agent,让它们各展所长,而不是试图让一个 Agent 做所有事情

这种设计不仅解决了”多 Agent 打架”的问题,还通过冒险团的任务流转机制,实现了开发流程的自动化和专业化。

如果你也在考虑引入多个 AI 劏手,希望本文能给你一些参考。当然,每个项目的情况不同,具体方案还需要根据实际情况调整。毕竟没有放之四海而皆准的方案,适合自己的才是最好的。

美的事物或人,不一定要占有,只要她还是美的,自己好好看着她的美就好了。技术方案也是如此,适合自己的,就是最好的…


如果本文对你有帮助,欢迎来 GitHub 给个 Star,您的支持是我们继续分享的动力。公测已开始,欢迎安装体验。


感谢您的阅读,如果您觉得本文有用,快点击下方点赞按钮,让更多的人看到本文。

本内容采用人工智能辅助协作,经本人审核,符合本人观点与立场。

如何用游戏化设计让 AI 编程变得更好玩

如何用游戏化设计让 AI 编程变得更好玩

Section titled “如何用游戏化设计让 AI 编程变得更好玩”

其实传统的 AI 编程工具功能挺强大的,就是少了点温度。我们在做 HagiCode 的时候就想,既然都要写代码,为什么不把它变成一场游戏呢?

用过 AI 编程助手的朋友应该都有这种体验:刚开始觉得挺新鲜,用着用着就感觉少了点什么。工具本身功能很强大,代码生成、自动补全、Bug 修复样样都能做,只是……没什么温度,用久了会觉得有些单调乏味。

这也罢了,毕竟谁愿意每天对着冷冰冰的工具呢。

这就好比打游戏,如果只是单纯地完成任务列表,没有角色成长、没有成就感解锁、没有团队配合,那很快就会觉得没意思。美的事物或人,不一定要占有,只是她还是美的,自己好好看着她的美就好了。可编程工具连这种美都没有,难免让人心灰意冷。

我们在开发 HagiCode 的过程中就遇到了这个问题。HagiCode 作为一个多 AI 助手协作平台,需要让用户长期保持使用热情。但现实是,再好的工具,如果缺乏情感连接,用户也很难坚持下去。

为了解决这个痛点,我们做了一个大胆的决定:把编程变成一场游戏。不是那种简单的积分排行榜,而是真正的角色扮演式的游戏化体验。这个决定带来的变化,可能比你想象的还要大。

毕竟,人嘛,总是需要点仪式感的。

本文分享的方案来自我们在 HagiCode 项目中的实践经验。HagiCode 是一个多 AI 助手协作平台,支持 Claude Code、Codex、Copilot、OpenCode 等多种 AI 助手协同工作。如果你对多 AI 协作、游戏化编程感兴趣,可以访问 github.com/HagiCode-org/site 了解更多。

其实也没什么特别的,只是我们把编程变成了一场冒险而已。

游戏化的本质不是”加个积分榜”,而是建立一套完整的激励体系,让用户在做任务的过程中体验到成长感、成就感和社交认同。

HagiCode 的游戏化设计围绕一个核心概念展开:每个 AI 助手都是一名”英雄”,用户就是这支英雄团队的队长。你带领这些英雄去征服各种”地牢”(编程任务),在这个过程中,英雄会获得经验、升级解锁能力,你和你的团队也会获得各种成就。

这不是什么噱头,而是基于人类行为心理学的精心设计。当任务被赋予意义和进度反馈时,人的投入度和坚持程度会显著提升。

就像古人说的,“此情可待成追忆,只是当时已惘然”。我们把这种情感体验融入到工具中,让编程不再只是敲代码,而是一段值得回忆的旅程。

Hero 是 HagiCode 游化系统的核心概念。每个 Hero 代表一个 AI 助手,比如 Claude Code 是一个 Hero,Codex 也是一个 Hero。

Hero 有三个装备槽位,这个设计其实还挺巧妙的:

  1. CLI 槽位(主要职业):决定 Hero 的基础能力,比如是 Claude Code 还是 Codex
  2. Model 槽位(次要职业):决定使用的模型,比如 Claude 4.5 还是 Claude 4.6
  3. Style 槽位(风格):决定 Hero 的行事风格,比如是”风落策略家”还是”其他风格”

三个槽位的组合可以创造出独特的 Hero 配置。就像游戏里配装一样,你需要根据任务特点选择合适的搭配。毕竟适合自己的才是最好的,这和生活选路差不多,条条大路通罗马,只是有的路好走一点,有的路稍微曲折一点罢了。

每个 Hero 都有自己的 XP(经验值)和等级:

type HeroProgressionSnapshot = {
currentLevel: number; // 当前等级
totalExperience: number; // 总经验值
currentLevelStartExperience: number; // 当前等级起始经验
nextLevelExperience: number; // 下一等级所需经验
experienceProgressPercent: number; // 进度百分比
remainingExperienceToNextLevel: number; // 距离下一级还需要多少经验
lastExperienceGain: number; // 最近一次获得的经验
lastExperienceGainAtUtc?: string | null; // 获得经验的时间
};

等级分为四个阶段,每个阶段的命名都很有代入感:

export const resolveHeroProgressionStage = (level?: number | null): HeroProgressionStage => {
const normalizedLevel = Math.max(1, level ?? 1);
if (normalizedLevel <= 100) return 'rookieSprint'; // 新人冲刺
if (normalizedLevel <= 300) return 'growthRun'; // 成长跑
if (normalizedLevel <= 700) return 'veteranClimb'; // 老兵攀登
return 'legendMarathon'; // 传奇马拉松
};

从”新人”到”传奇”,这个成长路径让用户有明确的目标感和成就感。就像人生的成长,总要经历从懵懂到成熟的过程,只是这里把这种过程具象化了而已。

创建 Hero 时需要配置三个槽位:

const heroDraft: HeroDraft = {
name: 'Athena',
icon: 'hero-avatar:storm-03',
description: '智谋过人的策略家',
executorType: AIProviderType.CLAUDE_CODE_CLI,
slots: {
cli: {
id: 'profession-claude-code',
parameters: { /* CLI 相关参数 */ }
},
model: {
id: 'secondary-claude-4-sonnet',
parameters: { /* 模型相关参数 */ }
},
style: {
id: 'fengluo-strategist',
parameters: { /* 风格相关参数 */ }
}
}
};

每个 Hero 都有独特的头像、描述和专业定位,这让冰冷的 AI 助手变得有个性、有温度。毕竟谁愿意跟没有性格的工具打交道呢?

“地牢”是游戏中的经典概念,代表着需要组队攻略的挑战。在 HagiCode 中,每个工作流程就是一个 Dungeon。

Dungeon 将工作流程组织成不同的”地牢”:

  • 提案生成地牢:负责生成技术提案
  • 提案执行地牢:负责执行提案中的任务
  • 提案归档地牢:负责整理和归档完成的提案

每个地牢都有自己的 Captain(队长)Hero,队长自动从启用的 Hero 中选择第一个。

其实这就像生活中的分工,每个人都有自己的角色,只是这里把这种分工变成了游戏机制而已。

你可以为不同的地牢配置不同的 Hero 小队:

const dungeonRoster: HeroDungeonRoster = {
scriptKey: 'proposal.generate',
displayName: '提案生成',
members: [
{ heroId: 'hero-1', name: 'Athena', executorType: 'ClaudeCode' },
{ heroId: 'hero-2', name: 'Apollo', executorType: 'Codex' }
]
};

比如生成提案时用 Athena(擅长策略),执行代码时用 Apollo(擅长实现),这样每个英雄都能发挥自己的专长。就像组建一支乐队,每个人都有自己的乐器,合起来才能奏出动听的旋律。

Dungeon 使用固定的 scriptKey 来标识不同的流程:

// 脚本键值对应不同的工作流程
const dungeonScripts = {
'proposal.generate': '提案生成',
'proposal.execute': '提案执行',
'proposal.archive': '提案归档'
};

任务状态流转是:queued(排队中)→ dispatching(分发中)→ dispatched(已分发)。整个过程自动化,不需要手动干预。这也是我们偷懒的小心思,毕竟谁愿意手动管这些事呢。

XP(经验值)是游戏化系统中最核心的反馈机制。用户通过完成任务获得 XP,XP 让英雄升级,升级解锁新的能力,形成正向循环。

在 HagiCode 中,XP 可以通过以下活动获得:

  • 代码执行完成
  • 工具调用成功
  • 提案生成
  • 会话管理操作
  • 项目操作

每完成一次有效操作,对应的 Hero 就会获得 XP。就像生活中的成长,每一步都算数,只是这里把这种成长量化了而已。

XP 和等级的进度是实时可视化的:

type HeroDungeonMember = {
heroId: string;
name: string;
icon?: string | null;
executorType: PCode_Models_AIProviderType;
currentLevel?: number; // 当前等级
totalExperience?: number; // 总经验值
experienceProgressPercent?: number; // 进度百分比
};

用户可以随时看到每个 Hero 的等级和进度,这种即时反馈是游戏化设计的关键。毕竟人总是需要点反馈,不然怎么知道自己进步了呢?

成就是游戏化中的另一个重要元素,它提供了长期目标和里程碑式的成就感。

HagiCode 支持多种成就类型:

  • 代码生成类:生成 X 行代码、生成 Y 个文件
  • 会话管理类:完成 Z 次对话
  • 项目操作类:在 W 个项目中工作过

其实这些成就就像人生中的里程碑,只是我们把它们变成了一种游戏机制而已。

成就有三种状态:

type AchievementStatus = 'unlocked' | 'in-progress' | 'locked';

三种状态有明显的视觉区分:

  • 已解锁:金色渐变 + 光晕效果
  • 进行中:蓝色脉冲动画
  • 未解锁:灰色,显示解锁条件

每个成就都清晰展示触发条件,让用户知道下一步该做什么。毕竟迷茫的时候,有个指引总是好的。

当成就解锁时,会触发庆祝动画。这种正向强化会让用户产生”我做到了”的满足感,激励他们继续前进。就像生活中小小的奖励,虽然不大,却能让人开心很久。

Battle Report 是 HagiCode 的一个特色功能,每天结束时生成一份全屏展示的战斗风格报告。

Battle Report 显示以下信息:

type HeroBattleReport = {
reportDate: string;
summary: {
totalHeroCount: number; // 总英雄数
activeHeroCount: number; // 活跃英雄数
totalBattleScore: number; // 总战斗分数
mvp: HeroBattleHero; // 最有价值英雄
};
heroes: HeroBattleHero[]; // 所有英雄的详细数据
};
  • 队伍总分数
  • 活跃 Hero 数量
  • 工具调用次数
  • 总工作时长
  • MVP(最有价值英雄)
  • 每个 Hero 的详细卡片

MVP 是当天表现最好的 Hero,会在报告中高亮显示。这不仅是数据统计,更是一种荣誉认可。毕竟谁不希望自己被认可呢?

每个 Hero 的卡片包含:

  • 等级进度
  • XP 获得量
  • 执行次数
  • 使用时长

这些数据让用户清楚地了解团队的工作状态。毕竟了解自己的努力成果,也是一种满足感。

HagiCode 的游戏化系统采用了现代化的技术栈和设计模式。其实也没什么特别的,只是选了一些趁手的工具而已。

// 前端使用 React + TypeScript
import React from 'react';
// 动画使用 Framer Motion
import { AnimatePresence, motion } from 'framer-motion';
// 状态管理使用 Redux Toolkit
import { useAppDispatch, useAppSelector } from '@/store';
// UI 组件使用 shadcn/ui
import { Dialog, DialogContent } from '@/components/ui/dialog';

Framer Motion 负责所有动画效果,shadcn/ui 提供基础的 UI 组件,Redux Toolkit 管理复杂的游戏化状态。毕竟工欲善其事,必先利其器。

HagiCode 采用了 Glassmorphism + Tech Dark 的设计风格:

/* 主渐变色 */
background: linear-gradient(135deg, #22C55E 0%, #25c2a0 50%, #06b6d4 100%);
/* 玻璃态效果 */
backdrop-filter: blur(12px);
/* 光辉效果 */
background: radial-gradient(circle at center, rgba(34, 197, 94, 0.15) 0%, transparent 70%);

绿色系的渐变配合玻璃态效果,营造出科技感和未来感。毕竟视觉上的美感,也是用户体验的一部分。

使用 Framer Motion 实现流畅的进场动画:

<motion.div
animate={{ opacity: 1, y: 0 }}
initial={{ opacity: 0, y: 18 }}
transition={{ duration: 0.35, ease: 'easeOut', delay: index * 0.08 }}
className="card"
>
{/* 卡片内容 */}
</motion.div>

每个卡片依次进场,延迟 0.08 秒,创造出流畅的视觉效果。毕竟流畅的动画能让体验更好,这是毋庸置疑的。

游戏化数据使用 Grain 存储系统,确保状态一致性。即使是 Hero 的 XP 累积这种细粒度数据,也能准确持久化。毕竟谁也不想让辛苦积累的经验丢失。

创建第一个 Hero 其实挺简单的:

  1. 进入 Hero 管理页面
  2. 点击”创建 Hero”按钮
  3. 配置三个槽位(CLI、Model、Style)
  4. 给 Hero 起个名字和描述
  5. 保存,你的第一个 Hero 就诞生了

就像认识新朋友一样,你需要给他一个名字、了解他的特点,然后你们就可以一起冒险了。

组建团队也很简单:

  1. 进入 Dungeon 管理页面
  2. 选择要配置的地牢(如”提案生成”)
  3. 从你的 Hero 列表中选择成员
  4. 系统自动选择第一个启用的 Hero 作为队长
  5. 保存配置

其实这就是一种组队的过程,就像生活中组建一个团队,每个人都有自己的角色。

每天结束后,你可以查看当日的 Battle Report:

  1. 点击”战斗报告”按钮
  2. 全屏展示当天的工作成果
  3. 查看 MVP 和每个 Hero 的详细数据
  4. 分享给团队成员(可选)

这也是一种仪式感,让自己知道今天努力了多少,离目标还有多远。

使用 React.memo 避免不必要的重渲染:

const HeroCard = React.memo(({ hero }: { hero: HeroDungeonMember }) => {
// 组件实现
});

毕竟性能也很重要,谁愿意用卡顿的工具呢?

检测用户的运动偏好设置,为敏感用户提供简化体验:

const prefersReducedMotion = useReducedMotion();
const duration = prefersReducedMotion ? 0 : 0.35;

毕竟不是所有人都喜欢动画,尊重用户的偏好也是设计的一部分。

保留 legacyIds 支持旧版本迁移:

type HeroDungeonMember = {
heroId: string;
legacyIds?: string[]; // 支持旧版本 ID 映射
// ...
};

毕竟谁也不希望因为版本升级就丢失数据。

所有文本使用 i18n 翻译键,方便多语言支持:

const displayName = t(`dungeon.${scriptKey}`, { defaultValue: displayName });

毕竟语言不应该成为使用的障碍。

游戏化不是简单的积分排行榜,而是一套完整的激励体系。HagiCode 通过 Hero 系统、Dungeon 系统、XP/等级系统、成就系统和 Battle Report,将编程工作转化为一场充满冒险精神的英雄之旅。

这套系统的核心价值在于:

  • 情感连接:让冰冷的 AI 助手变得有个性
  • 正向反馈:每次操作都有即时反馈
  • 长期目标:等级和成就提供成长路径
  • 团队认同:Dungeon 团队协作感
  • 荣誉认可:Battle Report 和 MVP 展示

游戏化设计让编程不再枯燥,而是一场有趣的冒险。用户在完成代码任务的同时,体验到角色成长、团队协作和成就解锁的乐趣,从而提高使用粘性和活跃度。

其实编程本身就是一种创造,只是我们把这种创造过程变得更有趣了一点而已。

如果本文对你有帮助:


感谢您的阅读,如果您觉得本文有用,快点击下方点赞按钮,让更多的人看到本文。

本内容采用人工智能辅助协作,经本人审核,符合本人观点与立场。

ImgBin CLI 工具设计:HagiCode 图片资产管理方案

ImgBin CLI 工具设计:HagiCode 图片资产管理方案

Section titled “ImgBin CLI 工具设计:HagiCode 图片资产管理方案”

本文介绍如何从零构建一个可自动化执行的图片资产流水线,包括 CLI 工具设计、Provider Adapter 架构、以及元数据管理策略。

其实也没想到,图片资产管理这事儿也能让我们纠结这么久。

在 HagiCode 项目开发过程中,我们遇到了一个看似简单却十分棘手的问题:图片资产的生成和管理。怎么说呢,就像青春期的那些事儿一样——表面上风平浪静,暗地里波澜起伏。

随着项目文档和营销物料的增多,需要大量配图。这些配图有些需要 AI 生成,有些需要从现有素材库中挑选,还有些需要对现有图片进行 AI 识别并自动标注。问题在于,这些工作长期以来都是用零散的脚本加人工操作来完成的——每次生成一张图片,都需要手动执行脚本、手动整理元数据、手动生成缩略图。这也就罢了,关键是这些零散的东西散落在各处,想找的时候找不到,想用的时候用不了。

具体痛点包括:

  1. 缺乏统一入口:图片生成的逻辑分散在不同脚本中,想批量执行根本没门
  2. 元数据缺失:生成后的图片没有统一的 metadata.json,无法检索和追踪
  3. 人工整理成本高:图片的标题、标签都需要人工一一整理,效率低下
  4. 无法自动化:CI/CD 流程中想要自动生成配图?门都没有

也曾想过干脆不管了,可是毕竟还是要做项目的嘛。既然躲不掉,那就想办法解决呗。于是我们决定,将 ImgBin 从「零散脚本」升级为可自动化执行的图片资产流水线。毕竟有些事儿,逃避也不是办法。

本文分享的方案来自我们在 HagiCode 项目中的实践经验。HagiCode 是一个 AI 代码助手项目,同时维护着 VSCode 扩展、后端 AI 服务、跨平台桌面客户端等多种组件。在这种多语言、多平台的复杂场景下,图片资产的规范管理成了提升开发效率的关键一环。

怎么说呢,这也算是 HagiCode 成长过程中的一个小小烦恼吧。每个项目都会有这样的时候,看起来不起眼的小问题,却能让人折腾半天。

HagiCode 的构建系统采用 TypeScript + Node.js 生态,因此 ImgBin 也顺理成章地选择了相同的技术栈,确保整个项目的技术一致性。毕竟都用习惯了,换别的也嫌麻烦嘛。


ImgBin 采用分层架构,将 CLI 命令、应用服务、第三方 API 适配器和基础设施层清晰分离:

组件层次结构
├── CLI Entry (cli.ts) 全局参数解析、命令路由
├── Commands (commands/*) generate | batch | annotate | thumbnail
├── Application Services job-runner | metadata | thumbnail | asset-writer
├── Provider Adapters image-api-provider | vision-api-provider
└── Infrastructure Layer config | logger | paths | schema

这种分层设计的好处是:每层的职责清晰,测试时可以方便地 mock 掉外部依赖。其实也就是让各干各的,互不打扰,这样出了问题也容易找原因,不是么?

ImgBin 采用了「一个资产一个目录」的模型,每次生成图片时,都会创建如下结构:

library/
└── 2026-03/
└── orange-dashboard/
├── original.png # 原始图片
├── thumbnail.webp # 512x512 缩略图
└── metadata.json # 结构化元数据

这种模型的优势在于:

  1. 自包含:每个资产的所有文件都在同一个目录,迁移、备份都很方便
  2. 可追溯:通过 metadata.json 可以追溯图片的生成时间、使用的 prompt、模型等信息
  3. 可扩展:未来如果需要添加更多变体(比如不同尺寸的缩略图),只需要在同一目录下新增文件即可

美的事物或人,不一定要占有,只要她还是美的,自己好好看着她的美就好了。这话虽然说得有点远了,但理儿是这么个理儿——图片放在一起了,看起来也舒服,找起来也方便。

metadata.json 是整个系统的核心,它采用分层存储策略,区分了三类字段:

{
"schemaVersion": 2,
"assetId": "orange-dashboard",
"slug": "orange-dashboard",
"title": "Orange Dashboard",
"tags": ["dashboard", "hero", "orange"],
"source": { "type": "generated" },
"paths": {
"assetDir": "library/2026-03/orange-dashboard",
"original": "original.png",
"thumbnail": "thumbnail.webp"
},
"generated": {
"prompt": "orange dashboard for docs hero",
"provider": "azure-openai-image-api",
"model": "gpt-image-1.5"
},
"recognized": {
"title": "Orange Dashboard",
"tags": ["dashboard", "ui", "orange"],
"description": "A modern orange dashboard with charts and metrics"
},
"status": {
"generation": "succeeded",
"recognition": "succeeded",
"thumbnail": "succeeded"
},
"timestamps": {
"createdAt": "2026-03-11T04:01:19.570Z",
"updatedAt": "2026-03-11T04:02:09.132Z"
}
}
  • generated:记录图片生成时的原始信息,如使用的 prompt、提供商、模型等
  • recognized:AI 识别结果,如自动生成的标题、标签、描述
  • manual:人工整理的结果,这个区的数据优先级最高,不会被 AI 识别覆盖

这种分层策略解决了我们之前的一个核心矛盾:AI 识别结果和人工整理结果谁优先?答案是人工优先,AI 识别只是辅助。这事儿也想明白了——有些东西嘛,机器终究是机器,终究还是得人来把关。


ImgBin 的另一个核心设计是 Provider Adapter 模式。我们将外部 API 抽象为统一的接口,这样即使更换 AI 服务商,也不需要修改业务逻辑。

怎么说呢,这就跟感情一样——外表怎么变不重要,重要的是内心那套东西不变。接口定好了,内部的实现怎么换都行。

interface ImageGenerationProvider {
// 生成图片,返回图片的 Buffer
generate(options: GenerateOptions): Promise<Buffer>;
// 获取支持的模型列表
getSupportedModels(): Promise<string[]>;
}
interface GenerateOptions {
prompt: string;
model?: string;
size?: '1024x1024' | '1792x1024' | '1024x1792';
quality?: 'standard' | 'hd';
format?: 'png' | 'webp' | 'jpeg';
}
interface VisionRecognitionProvider {
// 识别图片内容,返回结构化的元数据
recognize(imageBuffer: Buffer): Promise<RecognitionResult>;
// 获取支持的模型列表
getSupportedModels(): Promise<string[]>;
}
interface RecognitionResult {
title?: string;
tags: string[];
description?: string;
confidence: number;
}

这种接口设计的优势在于:

  1. 可测试:单元测试时可以传入 mock provider,不需要真正调用外部 API
  2. 可扩展:新增一个 provider 只需要实现接口,不需要修改调用方代码
  3. 可替换:生产环境用 Azure OpenAI,测试环境用本地模型,只需要切换配置

想笑来伪装自己掉下的泪,想哭来试探自己麻痹了没——有时候做项目就是这样,表面上看是换了个 API,实际上内在的那套逻辑一点没变,也就没什么好怕的了。


ImgBin 提供了四个核心命令,满足不同的使用场景:

Terminal window
# 最简单的用法
imgbin generate --prompt "orange dashboard for docs hero"
# 同时生成缩略图和 AI 标注
imgbin generate --prompt "orange dashboard" --annotate --thumbnail
# 指定输出目录
imgbin generate --prompt "orange dashboard" --output ./library

批量任务通过 YAML 或 JSON manifest 文件定义,适合 CI/CD 流程中使用:

assets/jobs/launch.yaml
defaults:
annotate: true
thumbnail: true
libraryRoot: ./library
jobs:
- prompt: "orange dashboard hero"
slug: orange-dashboard
tags: [dashboard, hero, orange]
- prompt: "pricing grid for docs"
slug: pricing-grid
tags: [pricing, grid, docs]

执行命令:

Terminal window
imgbin batch assets/jobs/launch.yaml

批量任务的设计支持失败隔离:manifest 中逐项处理,单项失败不影响其他任务。可以通过 --dry-run 预览而不实际执行。

这也就罢了,关键是它还能告诉你哪儿成功了哪儿失败了,不像某些事儿,失败了都不知道怎么失败的。

对现有图片执行 AI 识别,自动生成标题、标签、描述:

Terminal window
# 标注单张图片
imgbin annotate ./library/2026-03/orange-dashboard
# 批量标注整个目录
imgbin annotate ./library/2026-03/

为既有图片补生成缩略图:

Terminal window
# 生成缩略图
imgbin thumbnail ./library/2026-03/orange-dashboard

批量任务的 manifest 支持灵活的配置,默认值可以统一设置,单个任务也可以覆盖:

# 全局默认值
defaults:
annotate: true # 默认开启 AI 标注
thumbnail: true # 默认生成缩略图
libraryRoot: ./library
model: gpt-image-1.5
jobs:
# 最小配置,只提供 prompt
- prompt: "first image"
# 完整配置
- prompt: "second image"
slug: custom-slug
tags: [tag1, tag2]
annotate: false # 这个任务不执行 AI 标注
model: dall-e-3 # 这个任务用不同的模型

执行时,ImgBin 会逐个处理任务,每个任务的结果会写入对应的 metadata.json,即使某个任务失败,也不会影响其他任务。任务完成后,会输出汇总报告:

✓ orange-dashboard (succeeded)
✓ pricing-grid (succeeded)
✗ hero-banner (failed: API rate limit exceeded)
2/3 succeeded, 1 failed

有些事儿吧,急也急不来,一个一个来,反而踏实。这,或许就是批量任务的哲学吧。


ImgBin 通过环境变量支持灵活的配置:

Terminal window
# ImgBin 工作目录
IMGBIN_WORKDIR=/path/to/imgbin
# 可执行文件路径(用于脚本中调用)
IMGBIN_EXECUTABLE=/path/to/imgbin/dist/cli.js
# 资产库根目录
IMGBIN_LIBRARY_ROOT=./.imgbin-library
# Azure OpenAI 配置(如果使用 Azure provider)
AZURE_OPENAI_ENDPOINT=https://your-resource.openai.azure.com/
AZURE_OPENAI_API_KEY=your-api-key
AZURE_OPENAI_IMAGE_DEPLOYMENT=gpt-image-1

配置这东西,说重要也重要,说不重要也不重要。毕竟怎么舒服怎么来嘛,适合自己的才是最好的。


在实现过程中,我们总结了以下几个关键点:

接口定义要清晰完整,包括输入参数、返回值、错误处理。建议同时提供同步和异步两种调用方式,方便不同场景使用。

这也算是过来人的一点经验吧,毕竟接口这东西,定好了就不想再改,麻烦。

批量任务中某项失败时,应该:

  1. 记录详细错误信息到单独的日志文件
  2. 继续执行其他任务,不中断整个流程
  3. 最终返回非零退出码,表示有任务失败
  4. 在汇总报告中清晰展示每个任务的执行结果

有些事儿失败了就是失败了,逃避也没用,不如大大方方承认,然后想办法解决。这道理,做项目和做人是一样的。

识别结果默认写入 recognized 区,人工修改的字段有 manual 标记。元数据更新时采用「只增不减」策略:除非显式传入 --force 参数,否则不覆盖已有的人工整理结果。

这事儿也想明白了——有些东西啊,错过了就是错过了,覆盖了也就没了。还是保留着比较好,毕竟记录本身也是一种美。

使用 fs.mkdir({ recursive: true }) 确保目录创建原子性,避免并发场景下的竞态条件。

这大概就是所谓的安全感吧——该稳稳该快快,不拖泥带水,也不瞻前顾后。


ImgBin 作为 HagiCode 项目图片资产管理的核心工具,通过以下设计解决了我们面临的问题:

  1. 统一入口:CLI 命令覆盖了生成、标注、缩略图等全部操作
  2. 元数据驱动:每个资产都有完整的 metadata.json,支持检索和追踪
  3. Provider Adapter:灵活的外部 API 抽象,便于测试和扩展
  4. 批量任务支持:CI/CD 流程中可以自动执行批量图片生成

一切都淡了…可这方案啊,还真是用上了。

这套方案不仅提升了 HagiCode 自身的开发效率,也形成了一个可复用的图片资产管理框架。如果你也在开发类似的多组件项目,相信 ImgBin 的设计思路会给你一些启发。

青春嘛,总是要折腾的。不折腾折腾,怎么知道自个儿几斤几两呢?



感谢您的阅读,如果您觉得本文有用,快点击下方点赞按钮,让更多的人看到本文。

本内容采用人工智能辅助协作,经本人审核,符合本人观点与立场。

Primary profession management in hero settings

Primary profession management in hero settings

Section titled “Primary profession management in hero settings”
  • Hero settings now include a dedicated Primary Professions tab to toggle availability.
  • Enablement is persisted at the system level and gates hero availability in dungeon selection and status checks.
  • CLI detection surfaces availability and version; enablement toggles stay locked until the CLI is detected.

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


如果本文对你有帮助:

Codex SDK 控制台消息解析完全指南

Codex SDK 控制台消息解析完全指南

Section titled “Codex SDK 控制台消息解析完全指南”

本文详细介绍 Codex SDK 的事件流机制、消息类型解析、以及在实际项目中的最佳实践,帮助开发者快速掌握 AI 执行服务的核心技能。

其实,在构建基于 Codex SDK 的 AI 执行服务时,我们不得不面对这样一个问题:如何处理 Codex 返回的那些流式事件消息。这些消息里藏着执行状态、输出内容、错误信息这些重要的东西,就像青春里那些说不清道不明的心事,你得好好琢磨琢磨。

作为 HagiCode 项目的一部分,我们需要在 AI 代码助手场景中实现一个靠谱的执行器。这大概就是我们决定深入研究 Codex SDK 事件流机制的原因——毕竟,只有理解了底层消息是怎么运作的,才能构建出真正企业级的 AI 执行平台。这就像恋爱一样,不懂对方的心思,怎么走下去?

Codex SDK 是 OpenAI 推出的编程辅助工具 SDK,它通过事件流(Event Stream)的方式返回执行结果。和传统的请求-响应模式不太一样,Codex 使用流式事件,让我们能够:

  • 实时获取执行进度
  • 及时处理错误情况
  • 获取详细的 token 使用统计
  • 支持长时间运行的复杂任务

理解这些事件类型并正确解析它们,对于实现功能完善的 AI 执行器来说,还是挺重要的。毕竟,谁也不想面对一个黑盒?

本文分享的方案来自我们在 HagiCode 项目中的实践经验。HagiCode 是一个开源的 AI 代码助手项目,致力于为开发者提供智能化的代码辅助能力。在开发过程中,我们需要构建可靠的 AI 执行服务来处理用户的代码执行请求,这正是我们引入 Codex SDK 的直接原因。

作为 AI 代码助手,HagiCode 需要处理各种复杂的代码执行场景:实时获取执行进度、及时处理错误情况、获取详细的 token 使用统计等。通过深入理解 Codex SDK 的事件流机制,我们能够构建出满足生产环境要求的执行器。说到底,代码也好,人生也罢,都需要一点积累和沉淀。

Codex SDK 使用 thread.runStreamed() 方法返回异步事件迭代器:

import { Codex } from '@openai/codex-sdk';
const client = new Codex({
apiKey: process.env.CODEX_API_KEY,
baseUrl: process.env.CODEX_BASE_URL,
});
const thread = client.startThread({
workingDirectory: '/path/to/project',
skipGitRepoCheck: false,
});
const { events } = await thread.runStreamed('your prompt here', {
outputSchema: {
type: 'object',
properties: {
output: { type: 'string' },
status: { type: 'string', enum: ['ok', 'action_required'] },
},
required: ['output', 'status'],
},
});
for await (const event of events) {
// 处理每个事件
}
事件类型说明关键数据
thread.started线程启动成功thread_id
item.updated消息内容更新item.text
item.completed消息完成item.text
turn.completed执行完成usage (token 使用量)
turn.failed执行失败error.message
error错误事件message

在实际项目中,HagiCode 的执行器组件正是基于这些事件类型构建的。我们需要对每种事件进行精细化处理,以确保用户体验的流畅性。这就像对待一段感情,每个细节都需要用心对待,不然怎么可能有好的结果?

消息内容通过事件处理函数提取:

private handleThreadEvent(event: ThreadEvent, onMessage: (content: string) => void): void {
// 只处理消息更新和完成事件
if (event.type !== 'item.updated' && event.type !== 'item.completed') {
return;
}
// 只处理代理消息类型
if (event.item.type !== 'agent_message') {
return;
}
// 提取文本内容
onMessage(event.item.text);
}

关键点:

  • 只处理 item.updateditem.completed 事件
  • 只处理 agent_message 类型的内容
  • 消息内容在 event.item.text 字段中

Codex 支持 JSON 结构化输出,通过 outputSchema 参数指定返回格式:

const DEFAULT_OUTPUT_SCHEMA = {
type: 'object',
properties: {
output: { type: 'string' },
status: { type: 'string', enum: ['ok', 'action_required'] },
},
required: ['output', 'status'],
additionalProperties: false,
} as const;

解析函数会尝试解析 JSON,如果失败则返回原始文本——这就像人生,有时候你想要一个完美的答案,但现实往往给你一个模糊的回应,只能自己慢慢消化罢了。

function toStructuredOutput(raw: string): StructuredOutput {
try {
const parsed = JSON.parse(raw) as Partial<StructuredOutput>;
if (typeof parsed.output === 'string') {
return {
output: parsed.output,
status: parsed.status === 'action_required' ? 'action_required' : 'ok',
};
}
} catch {
// JSON 解析失败,回退到原始文本
}
return {
output: raw,
status: 'ok',
};
}
private async runWithStreaming(
thread: Thread,
input: CodexStageExecutionInput
): Promise<{ output: string; usage: Usage | null }> {
const abortController = new AbortController();
const timeoutHandle = setTimeout(() => {
abortController.abort();
}, Math.max(1000, input.timeoutMs));
let latestMessage = '';
let usage: Usage | null = null;
let emittedLength = 0;
try {
const { events } = await thread.runStreamed(input.prompt, {
outputSchema: DEFAULT_OUTPUT_SCHEMA,
signal: abortController.signal,
});
for await (const event of events) {
// 处理消息内容
this.handleThreadEvent(event, (nextContent) => {
const delta = nextContent.slice(emittedLength);
if (delta.length > 0) {
emittedLength = nextContent.length;
input.callbacks?.onChunk?.(delta); // 流式回调
}
latestMessage = nextContent;
});
// 根据事件类型处理不同数据
if (event.type === 'thread.started') {
this.threadId = event.thread_id;
} else if (event.type === 'turn.completed') {
usage = event.usage;
} else if (event.type === 'turn.failed') {
throw new CodexExecutorError('gateway_unavailable', event.error.message, true);
} else if (event.type === 'error') {
throw new CodexExecutorError('gateway_unavailable', event.message, true);
}
}
} catch (error) {
if (abortController.signal.aborted) {
throw new CodexExecutorError(
'upstream_timeout',
`Codex stage timed out after ${input.timeoutMs}ms`,
true
);
}
throw error;
} finally {
clearTimeout(timeoutHandle);
}
const structured = toStructuredOutput(latestMessage);
return { output: structured.output, usage };
}

根据错误特征映射到具体的错误码,便于上层处理:

function mapError(error: unknown): CodexExecutorError {
if (error instanceof CodexExecutorError) {
return error;
}
const message = error instanceof Error ? error.message : String(error);
const normalized = message.toLowerCase();
// 认证错误 - 不可重试
if (normalized.includes('401') ||
normalized.includes('403') ||
normalized.includes('api key') ||
normalized.includes('auth')) {
return new CodexExecutorError('auth_invalid', message, false);
}
// 速率限制 - 可重试
if (normalized.includes('429') || normalized.includes('rate limit')) {
return new CodexExecutorError('rate_limited', message, true);
}
// 超时错误 - 可重试
if (normalized.includes('timeout') || normalized.includes('aborted')) {
return new CodexExecutorError('upstream_timeout', message, true);
}
// 默认错误
return new CodexExecutorError('gateway_unavailable', message, true);
}
export type CodexErrorCode =
| 'auth_invalid' // 认证失败
| 'upstream_timeout' // 上游超时
| 'rate_limited' // 速率限制
| 'gateway_unavailable'; // 网关不可用
export class CodexExecutorError extends Error {
readonly code: CodexErrorCode;
readonly retryable: boolean;
constructor(code: CodexErrorCode, message: string, retryable: boolean) {
super(message);
this.name = 'CodexExecutorError';
this.code = code;
this.retryable = retryable;
}
}

Codex SDK 要求工作目录必须是有效的 Git 仓库——这就像做人一样,总得有个根,有个出处,不然怎么踏实?

export function validateWorkingDirectory(
workingDirectory: string,
skipGitRepoCheck: boolean
): void {
const resolvedWorkingDirectory = path.resolve(workingDirectory);
if (!existsSync(resolvedWorkingDirectory)) {
throw new CodexExecutorError(
'gateway_unavailable',
'Working directory does not exist.',
false
);
}
if (!statSync(resolvedWorkingDirectory).isDirectory()) {
throw new CodexExecutorError(
'gateway_unavailable',
'Working directory is not a directory.',
false
);
}
if (skipGitRepoCheck) {
return;
}
const gitDir = path.join(resolvedWorkingDirectory, '.git');
if (!existsSync(gitDir)) {
throw new CodexExecutorError(
'gateway_unavailable',
'Working directory is not a git repository.',
false
);
}
}

Codex SDK 需要从登录 Shell 加载环境变量,确保 AI Agent 可以访问系统命令:

function parseEnvironmentOutput(output: Buffer): Record<string, string> {
const parsed: Record<string, string> = {};
for (const entry of output.toString('utf8').split('\0')) {
if (!entry) continue;
const separatorIndex = entry.indexOf('=');
if (separatorIndex <= 0) continue;
const key = entry.slice(0, separatorIndex);
const value = entry.slice(separatorIndex + 1);
if (key.length > 0) {
parsed[key] = value;
}
}
return parsed;
}
function tryLoadEnvironmentFromShell(shellPath: string): Record<string, string> | null {
const result = spawnSync(shellPath, ['-ilc', 'env -0'], {
env: process.env,
stdio: ['ignore', 'pipe', 'pipe'],
timeout: 5000,
});
if (result.error || result.status !== 0) {
return null;
}
return parseEnvironmentOutput(result.stdout);
}
export function createExecutorEnvironment(
envOverrides: Record<string, string> = {}
): Record<string, string> {
// 加载登录 Shell 环境变量
const consoleEnv = loadConsoleEnvironmentFromShell();
return {
...process.env,
...consoleEnv,
...envOverrides,
};
}

在 HagiCode 项目中,我们使用以下方式来初始化 Codex 客户端并执行任务:

import { Codex } from '@openai/codex-sdk';
async function executeWithCodex(prompt: string, workingDir: string) {
const client = new Codex({
apiKey: process.env.CODEX_API_KEY,
env: { PATH: process.env.PATH },
});
const thread = client.startThread({
workingDirectory: workingDir,
});
const { events } = await thread.runStreamed(prompt);
let result = '';
for await (const event of events) {
if (event.type === 'item.updated' && event.item.type === 'agent_message') {
result = event.item.text;
}
if (event.type === 'turn.completed') {
console.log('Token usage:', event.usage);
}
}
// 尝试解析 JSON 输出
try {
const parsed = JSON.parse(result);
return parsed.output;
} catch {
return result;
}
}
export class CodexSdkExecutor {
private readonly config: CodexRuntimeConfig;
private readonly client: Codex;
private threadId: string | null = null;
async executeStage(input: CodexStageExecutionInput): Promise<CodexStageExecutionResult> {
const maxAttempts = Math.max(1, this.config.retryCount + 1);
let attempt = 0;
let lastError: CodexExecutorError | null = null;
while (attempt < maxAttempts) {
attempt += 1;
try {
const thread = this.getThread(input.workingDirectory);
const { output, usage } = await this.runWithStreaming(thread, input);
return {
output,
usage,
threadId: this.threadId!,
attempts: attempt,
latencyMs: Date.now() - startedAt,
};
} catch (error) {
const mappedError = mapError(error);
lastError = mappedError;
// 不可重试错误或已达最大重试次数
if (!mappedError.retryable || attempt >= maxAttempts) {
throw mappedError;
}
// 等待后重试
await new Promise(resolve => setTimeout(resolve, 1000 * attempt));
}
}
throw lastError!;
}
}
  • 确保工作目录是有效的 Git 仓库
  • 使用 PROJECT_ROOT 环境变量显式指定
  • 开发调试时可设置 CODEX_SKIP_GIT_REPO_CHECK=true 跳过检查
  • 通过白名单机制传递必要的环境变量
  • 使用登录 Shell 加载完整环境
  • 避免传递敏感信息
  • 根据任务复杂度设置合理的超时时间
  • 对可重试错误实现指数退避
  • 记录重试次数和原因
  • 区分可重试和不可重试错误
  • 提供清晰的错误信息和建议
  • 统一错误码便于上层处理
  • 实现增量输出回调,提升用户体验
  • 正确处理消息的增量更新
  • 记录 token 使用量用于成本分析

在 HagiCode 项目的实际生产环境中,我们已经验证了上述最佳实践的有效性。这套方案帮助我们构建了稳定可靠的 AI 执行服务。毕竟,实践才是检验真理的唯一标准,纸上谈兵终究没什么用。

Codex SDK 的事件流机制为构建 AI 执行服务提供了强大的能力。通过正确解析各类事件,我们可以:

  • 实时获取执行状态和输出
  • 实现可靠的错误处理和重试机制
  • 获取详细的执行统计信息
  • 构建功能完善的 AI 执行平台

本文介绍的核心概念和代码示例可以直接应用于实际项目中,帮助开发者快速上手 Codex SDK 的集成工作。如果你觉得这套方案有价值,说明 HagiCode 的工程实践还不错——那么 HagiCode 本身也值得关注一下。毕竟,有些东西,错过了就可惜了。


感谢您的阅读,如果您觉得本文有用,快点击下方点赞按钮👍,让更多的人看到本文。

本内容采用人工智能辅助协作,经本人审核,符合本人观点与立场。

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

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

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

在现代开发工具生态中,开发者经常需要使用不同的 AI 编码助手来辅助开发工作。Anthropic 的 Claude Code CLI 和 OpenAI 的 Codex CLI 各有其优势:Claude 以出色的代码理解和长上下文处理能力著称,而 Codex 在代码生成和工具使用方面表现优异。

本文将深入分析 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#
ClaudeCodeCliProviderClaude Code CLI 实现C#
CodexCliProviderCodex CLI 实现C#
AgentCliManager桌面端 CLI 管理TypeScript

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 命令嵌入到提示中
public enum AIProviderType
{
ClaudeCodeCli, // Anthropic Claude Code
OpenCodeCli, // 其他 CLI(可扩展)
GitHubCopilot, // GitHub Copilot
CodebuddyCli, // Codebuddy
CodexCli // OpenAI Codex
}

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

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,便于上层处理

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 调用
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 等工具权限配置
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 数据库持久化会话与线程的绑定关系
  • 线程复用:支持恢复已有线程,保持会话连续性
  • 工具调用追踪:追踪活动工具调用状态,正确处理工具生命周期

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 天的绑定会被自动清理

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'
};
}
});

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);
}
}
}
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;
}

在切换提供者前,建议先调用 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 秒内响应视为健康
}

使用 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
};

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

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);
}

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

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);
}
}
}

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

// 定期清理缓存
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);
}
}

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

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);
}
}

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();
}
}
}

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

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. 用户友好:支持场景自动选择和手动切换

通过这种设计,hagicode 成功实现了 Claude Code CLI 和 Codex CLI 的无缝切换与互操作,为开发者提供了灵活、强大的 AI 编码助手体验。


感谢您的阅读,如果您觉得本文有用,快点击下方点赞按钮👍,让更多的人看到本文。

本内容采用人工智能辅助协作,经本人审核,符合本人观点与立场。

从 TypeScript 到 C#:Codex SDK 的跨语言移植实践

从 TypeScript 到 C#:Codex SDK 的跨语言移植实践

Section titled “从 TypeScript 到 C#:Codex SDK 的跨语言移植实践”

怎么说呢,这篇文章也算是个孩子,记录了我们把官方 TypeScript Codex SDK 完整移植到 C# 的全过程。说是”移植”,其实更像是一场漫长的冒险,毕竟两种语言的脾性不太一样,总得想办法让它们好好相处。

Codex 这东西,是 OpenAI 推出的 AI Agent CLI 工具,确实挺强大的。官方给了 TypeScript SDK,放在 @openai/codex 这个包里。它呢,通过调用 codex exec --experimental-json 命令跟 Codex CLI 交互,解析 JSONL 格式的事件流。

可是吧,我们在 HagiCode 项目里,需要在一个纯 .NET 环境中使用它。具体来说,就是 C# 后端服务和桌面端应用。你说这事闹的,总不能为了调用一个 CLI 工具而在 .NET 项目中引入 Node.js 运行时吧?那也太折腾了。

于是摆在我们面前的就两条路:一是维护一套复杂的 Node.js 桥接层,二是自己动手丰衣足食,实现一个原生 C# SDK。

我们选择了后者。

其实这篇文也是来自我们在 HagiCode 项目里的实践经验。HagiCode 是个开源的 AI 代码助手项目,听起来挺高大上的,但说白了也就是同时维护着前端 VSCode 扩展、后端 AI 服务、跨平台桌面客户端等多种组件。这种多语言、多平台的复杂度,正是我们需要原生 C# SDK 的直接原因——总不能真的在 .NET 项目里跑个 Node.js 吧?那也太魔幻了。

如果你觉得这篇文章有点帮助,欢迎来 GitHub 给个 Star:github.com/HagiCode-org/site,也欢迎访问官网了解更多:hagicode.com。毕竟一个人品无限的项目能得到支持,也是件开心的事。

在开始代码层面的转化之前,我们得先理解两套 SDK 的架构设计。毕竟知己知彼,百战不殆嘛。

TypeScript SDK 的核心架构是这样的:

Codex (入口类)
└── CodexExec (执行器,管理子进程)
└── Thread (对话线程)
├── run() / runStreamed() (同步/异步执行)
└── 事件流解析

C# SDK 呢,保持了相同的架构层次,但在实现细节上做了适配。整体思路是:保持 API 的一致性,但在具体实现上充分利用 C# 语言特性。毕竟语言不同,总得有点区别才行。

这是最基础也是最重要的工作。毕竟万丈高楼平地起,基础打不好,后面全是麻烦。

TypeScript 的类型系统比 C# 更灵活,这是事实。我们需要找到合适的映射方式:

TypeScriptC#说明
interface / typerecordC# 使用 record 实现不可变数据结构
string | nullstring?可空引用类型
boolean | undefinedbool?可空布尔值
AsyncGeneratorIAsyncEnumerable异步迭代器

事件类型系统是一个典型的例子。TypeScript 使用联合类型来定义事件:

export type ThreadEvent =
| ThreadStartedEvent
| TurnStartedEvent
| TurnCompletedEvent
| ...

在 C# 中,我们使用继承层次和模式匹配来实现类似的效果:

public abstract record ThreadEvent(string Type);
public sealed record ThreadStartedEvent(string ThreadId) : ThreadEvent("thread.started");
public sealed record TurnStartedEvent() : ThreadEvent("turn.started");
public sealed record TurnCompletedEvent(Usage Usage) : ThreadEvent("turn.completed");
// ...

使用 record 而不是 class,是因为事件对象应该是不可变的,这和 TypeScript 中使用普通对象是一个道理。而 sealed 关键字则确保不会有额外的子类继承,编译器可以进行优化。其实也就那么回事,习惯就好了。

事件解析是整个 SDK 的核心,毕竟这决定了我们能否正确理解 Codex CLI 返回的每一条信息。解析错了,后面全是白忙活。

TypeScript 版本使用 JSON.parse() 来解析每一行 JSON:

export function parseEvent(line: string): ThreadEvent {
const data = JSON.parse(line);
// 处理各种事件类型...
}

C# 版本则使用 System.Text.Json.JsonDocument

public static ThreadEvent Parse(string line)
{
using var document = JsonDocument.Parse(line);
var root = document.RootElement;
var type = GetRequiredString(root, "type", "event.type");
return type switch
{
"thread.started" => new ThreadStartedEvent(GetRequiredString(root, "thread_id", ...)),
"turn.started" => new TurnStartedEvent(),
"turn.completed" => new TurnCompletedEvent(ParseUsage(...)),
// ...
_ => new UnknownThreadEvent(type, root.Clone()),
};
}

这里有一个小技巧:root.Clone() 是必要的,因为 JsonDocument 的元素在文档释放后就会失效,我们需要保留一份副本给未知的事件类型。这也是没办法的事,毕竟 C# 的 JSON 处理和 JavaScript 不太一样。

这是两个 SDK 差异最大的地方。毕竟 Node.js 和 .NET 的脾性不太一样,总得适应适应。

TypeScript 使用 Node.js 的 spawn() 函数:

const child = spawn(this.executablePath, commandArgs, { env, signal });

C# 使用 .NET 的 System.Diagnostics.Process

using var process = new Process { StartInfo = startInfo };
process.Start();
// 需要手动管理 stdin/stdout/stderr

具体来说,C# 版本需要这样配置进程:

var startInfo = new ProcessStartInfo
{
FileName = _executablePath,
RedirectStandardInput = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
};

最大的区别在于取消机制。TypeScript 使用 AbortSignal,这是 Web API 的一部分,用起来挺顺手的:

const child = spawn(cmd, args, { signal: cancellationSignal });

C# 则使用 CancellationToken

public async IAsyncEnumerable<string> RunAsync(
CodexExecArgs args,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
// 在循环中检查取消状态
while (!cancellationToken.IsCancellationRequested)
{
// 处理输出...
}
// 取消时终止进程
if (cancellationToken.IsCancellationRequested)
{
try { process.Kill(entireProcessTree: true); } catch { }
}
}

这其中的区别,大概就是Web API 和 .NET 生态的差异吧,说到底也就是那么回事。

两套 SDK 都实现了将 JSON 配置转换为 TOML 配置的逻辑,因为 Codex CLI 接受 TOML 格式的配置覆盖。这部分逻辑必须完全保持一致,否则同样的配置在两个 SDK 中会产生不同的行为。

这叫什么?这就叫工匠精神嘛。毕竟细节决定成败,有些事不能将就。

我们创建了这样的项目结构:

CodexSdk/
├── CodexSdk.csproj
├── Codex.cs # 入口类
├── CodexThread.cs # 对话线程
├── CodexExec.cs # 执行器
├── Events.cs # 事件类型定义
├── Items.cs # 项目类型定义
├── EventParser.cs # 事件解析器
├── OutputSchemaTempFile.cs # 临时文件管理
└── ...

看起来也挺整齐的,不是吗?

基本的使用方式和 TypeScript SDK 保持一致:

using CodexSdk;
// 创建 Codex 实例
var codex = new Codex();
var thread = codex.StartThread();
// 执行查询
var result = await thread.RunAsync("Summarize this repository.");
Console.WriteLine(result.FinalResponse);

流式事件处理利用了 C# 的模式匹配能力:

await foreach (var @event in thread.RunStreamedAsync("Analyze the code."))
{
switch (@event)
{
case ItemCompletedEvent itemCompleted
when itemCompleted.Item is AgentMessageItem msg:
Console.WriteLine($"Assistant: {msg.Text}");
break;
case TurnCompletedEvent completed:
Console.WriteLine($"Tokens: in={completed.Usage.InputTokens}");
break;
case CommandExecutionItem command:
Console.WriteLine($"Command: {command.Command}");
break;
}
}

在实现过程中,我们也不算是白忙活,总结点经验如下:

  1. 进程管理:C# 版本需要手动管理进程的生命周期,包括取消时的进程终止。使用 Kill(entireProcessTree: true) 确保子进程也被清理。这叫什么?这就叫有始有终。

  2. 错误处理:我们使用 InvalidOperationException 抛出解析错误,保持与 TypeScript SDK 相似的错误处理方式。毕竟错误处理这事儿,不能太随意。

  3. 资源清理OutputSchemaTempFile 实现 IAsyncDisposable,确保临时文件被正确清理。这也是没办法的事,资源不清理干净,总会有问题。

  4. 环境变量:C# 版本支持通过 CodexOptions.Env 完全覆盖进程环境变量。这功能虽然小,但挺实用的。

  5. 平台差异:C# 版本不包含 TypeScript 版本中自动查找 npm 包中二进制文件的逻辑。这是因为 .NET 项目通常不依赖 npm,所以需要通过 CODEX_EXECUTABLE 环境变量或 CodexPathOverride 指定 codex 可执行文件路径。这叫什么?这就叫因地制宜。

将一个成熟的 TypeScript SDK 移植到 C#,不仅仅是语法层面的转换,更是对两种语言设计哲学的理解。TypeScript 的灵活性和 JavaScript 生态特性(如 AbortSignal)在 C# 中需要找到对应的替代方案。这其中的酸甜苦辣,大概也只有真正做过的人才能体会。

关键体会是:保持 API 的一致性比保持实现细节的一致性更重要。用户关心的是接口是否易用,而不是内部实现是否相同。这话听起来简单,但做起来需要取舍。

如果你也在做类似的跨语言移植工作,我们的经验是:先完整理解原 SDK 的架构设计,然后逐个模块进行转化,最后通过完整的测试用例确保行为一致。毕竟急不得,一口吃不成胖子。

一切都会好的,都会有的…



如果本文对你有帮助


感谢您的阅读,如果您觉得本文有用,快点击下方点赞按钮👍,让更多的人看到本文。

本内容采用人工智能辅助协作,经本人审核,符合本人观点与立场。

豆包语音识别热词功能实现指南

豆包语音识别热词功能实现指南

Section titled “豆包语音识别热词功能实现指南”

本文将详细介绍如何在 HagiCode 项目中实现豆包语音识别的热词支持功能,通过自定义热词和平台热词表两种方式,显著提升特定领域词汇的识别准确率。

语音识别技术发展这么多年了,其实有个问题一直困扰着开发者们。通用语音识别模型虽然能覆盖日常用语,可对于专业术语、产品名称、人名这些词,识别准确率总差那么点意思。想想看,医疗领域的语音助手要准确识别”高血压”、“糖尿病”、“冠心病”;法律系统要精准捕捉”案由”、“答辩”、“举证责任”——这些场景下,通用模型的表现怎么说呢,也算尽力了。

在 HagiCode 项目中,我们也遇到了同样的挑战。作为一个多功能的 AI 代码助手,HagiCode 需要处理各种技术术语的语音识别场景。然而,豆包语音识别 API 在默认情况下,并不能完全满足我们对专业术语准确率的那些要求。其实也不是豆包不够好,只是每个领域都有自己的一套术语体系。经过一番调研和技术探索,我们发现豆包语音识别 API 实际上提供了热词支持功能,只要简单配置一下,就能显著提升特定词汇的识别准确率。这倒是有点像,你告诉它你要注意什么词,它就会更用心去听那些词。

本文要分享的,就是在 HagiCode 项目中实现豆包语音识别热词功能的完整方案。两种模式,自定义热词和平台热词表,都可以用,也都能组合用。通过这套方案,开发者可以根据业务场景灵活配置热词,让语音识别系统”认识”那些专业、罕见但又至关重要的词汇。

本文分享的方案来自我们在 HagiCode 项目中的实践经验。HagiCode 是一个开源的 AI 代码助手项目,技术栈还算现代化,旨在为开发者提供智能化的编程辅助体验。作为一个多语言、多平台的复杂项目,HagiCode 需要处理各种技术术语的语音识别场景,这也推动了我们对热词功能的研究和实现。

如果你对 HagiCode 的技术实现感兴趣,可以访问 GitHub 仓库 了解更多信息,也可以查看我们的 官方文档 了解完整的安装和使用指南。

豆包语音识别 API 为我们提供了两种热词配置方式,每种方式都有其独特的应用场景和优势。

自定义热词模式允许我们通过 corpus.context 字段直接传递热词文本。这种方式非常适合需要快速配置少量热词的场景,比如临时需要识别某个产品名称或者人名。在 HagiCode 的实现中,我们将用户输入的多行热词文本解析为字符串列表,然后按照豆包 API 的要求格式化为 context_data 数组。怎么说呢,这种方式很直接,就像告诉对方”你要注意这些词”,然后它就去注意了。

平台热词表模式则通过 corpus.boosting_table_id 字段引用豆包自学习平台预配置的热词表。这种方式适合需要管理大量热词的场景,我们可以在豆包自学习平台上创建和维护热词表,然后通过 ID 进行引用。对于 HagiCode 这类需要持续更新和维护专业术语的项目来说,这种模式提供了更好的可管理性。毕竟,热词多了之后,找个地方统一管理,总比每次都要手动输入要好。

有意思的是,这两种模式还可以组合使用。豆包 API 支持在同一个请求中同时包含自定义热词和平台热词表 ID,通过 combine_mode 参数控制组合策略。这种灵活性使得 HagiCode 能够应对各种复杂的专业术语识别需求。这也倒是挺好,有时候多种方式组合一下,效果可能更好。

在 HagiCode 的前端实现中,我们定义了一套完整的热词配置类型和验证逻辑。首先是类型定义部分:

export interface HotwordConfig {
contextText: string; // 多行热词文本
boostingTableId: string; // 豆包平台热词表 ID
combineMode: boolean; // 是否组合使用
}

这个简单的接口包含了热词功能的所有配置项。其中 contextText 是用户最直观感受到的部分——我们允许用户每行输入一个热词短语,这种方式非常符合直觉。毕竟,让用户一行一个词,总比让用户理解复杂的配置规则要好。

接下来是验证函数的实现。考虑到豆包 API 的限制,我们制定了严格的验证规则:热词文本最多 100 行,每行最多 50 个字符,总共最多 5000 个字符;boosting_table_id 最多 200 个字符,只允许字母、数字、下划线和连字符。这些限制不是我们凭空想象的,而是基于豆包官方文档的实际要求。毕竟,API 的限制就是 API 的限制,我们也没办法,只能遵守。

export function validateContextText(contextText: string): HotwordValidationResult {
if (!contextText || contextText.trim().length === 0) {
return { isValid: true, errors: [] };
}
const lines = contextText.split('\n').filter(line => line.trim().length > 0);
const errors: string[] = [];
if (lines.length > 100) {
errors.push(`热词行数不能超过 100 行,当前为 ${lines.length}`);
}
const totalChars = contextText.length;
if (totalChars > 5000) {
errors.push(`热词总字符数不能超过 5000,当前为 ${totalChars}`);
}
for (let i = 0; i < lines.length; i++) {
if (lines[i].length > 50) {
errors.push(`${i + 1} 行热词超过 50 个字符限制`);
}
}
return { isValid: errors.length === 0, errors };
}
export function validateBoostingTableId(boostingTableId: string): HotwordValidationResult {
if (!boostingTableId || boostingTableId.trim().length === 0) {
return { isValid: true, errors: [] };
}
const errors: string[] = [];
if (boostingTableId.length > 200) {
errors.push(`boosting_table_id 不能超过 200 个字符,当前为 ${boostingTableId.length}`);
}
if (!/^[a-zA-Z0-9_-]+$/.test(boostingTableId)) {
errors.push('boosting_table_id 只能包含字母、数字、下划线和连字符');
}
return { isValid: errors.length === 0, errors };
}

这些验证函数在用户配置热词时就会立即执行,确保问题在最早阶段被发现。对于用户体验来说,这种即时反馈是非常重要的。毕竟,用户输入的时候就知道哪里错了,总比提交后才发现要好。

在 HagiCode 的前端实现中,我们选择使用浏览器的 localStorage 来存储热词配置。这个设计决策背后有几点考量:首先,热词配置是非常个性化的设置,不同用户可能有不同的专业领域需求;其次,这种方式简化了后端实现,不需要额外的数据库表和 API 接口;最后,用户在浏览器中配置一次后,后续使用都能自动加载,非常方便。其实说白了,就是省事。

const HOTWORD_STORAGE_KEYS = {
contextText: 'hotword-context-text',
boostingTableId: 'hotword-boosting-table-id',
combineMode: 'hotword-combine-mode',
} as const;
export const DEFAULT_HOTWORD_CONFIG: HotwordConfig = {
contextText: '',
boostingTableId: '',
combineMode: false,
};
// 加载热词配置
export function loadHotwordConfig(): HotwordConfig {
const contextText = localStorage.getItem(HOTWORD_STORAGE_KEYS.contextText) || '';
const boostingTableId = localStorage.getItem(HOTWORD_STORAGE_KEYS.boostingTableId) || '';
const combineMode = localStorage.getItem(HOTWORD_STORAGE_KEYS.combineMode) === 'true';
return { contextText, boostingTableId, combineMode };
}
// 保存热词配置
export function saveHotwordConfig(config: HotwordConfig): void {
localStorage.setItem(HOTWORD_STORAGE_KEYS.contextText, config.contextText);
localStorage.setItem(HOTWORD_STORAGE_KEYS.boostingTableId, config.boostingTableId);
localStorage.setItem(HOTWORD_STORAGE_KEYS.combineMode, String(config.combineMode));
}

这段代码的逻辑非常简单清晰。加载配置时从 localStorage 读取,保存配置时写入 localStorage。我们还提供了默认配置,确保在没有任何配置时系统也能正常工作。毕竟,总得有个默认值吧。

在 HagiCode 的后端实现中,我们需要在 SDK 配置类中添加热词相关的属性。考虑到 C# 的语言特性和使用习惯,我们采用了 List<string> 来存储自定义热词上下文:

public class DoubaoVoiceConfig
{
/// <summary>
/// 应用 ID
/// </summary>
public string AppId { get; set; } = string.Empty;
/// <summary>
/// 访问令牌
/// </summary>
public string AccessToken { get; set; } = string.Empty;
/// <summary>
/// 服务 URL
/// </summary>
public string ServiceUrl { get; set; } = string.Empty;
/// <summary>
/// 自定义热词上下文列表
/// </summary>
public List<string>? HotwordContexts { get; set; }
/// <summary>
/// 豆包平台热词表 ID
/// </summary>
public string? BoostingTableId { get; set; }
}

这个配置类的设计遵循了 HagiCode 一贯的简洁风格。HotwordContexts 是可空的列表类型,BoostingTableId 是可空的字符串,这样在没有任何热词配置时,这些属性不会对请求造成任何影响。毕竟,不用的时候就不应该存在,这才叫干净。

Payload 的构建是整个热词功能的核心。当我们有了热词配置后,需要按照豆包 API 的要求格式化为正确的 JSON 结构。这个过程发生在 SDK 发送请求之前:

private void AddCorpusToRequest(Dictionary<string, object> request)
{
var corpus = new Dictionary<string, object>();
// 添加自定义热词
if (Config.HotwordContexts != null && Config.HotwordContexts.Count > 0)
{
corpus["context"] = new Dictionary<string, object>
{
["context_type"] = "dialog_ctx",
["context_data"] = Config.HotwordContexts
.Select(text => new Dictionary<string, object> { ["text"] = text })
.ToList()
};
}
// 添加平台热词表 ID
if (!string.IsNullOrEmpty(Config.BoostingTableId))
{
corpus["boosting_table_id"] = Config.BoostingTableId;
}
// 只有当 corpus 不为空时才添加到请求中
if (corpus.Count > 0)
{
request["corpus"] = corpus;
}
}

这段代码展示了如何根据配置动态构建 corpus 字段。关键点在于:只有当确实存在热词配置时,我们才会添加 corpus 字段。这种设计确保了向后兼容性——没有配置热词时,请求的结构与之前完全一致。毕竟,兼容性很重要,不能因为加个功能就把之前的逻辑搞乱了。

在前端和后端之间,热词参数通过 WebSocket 控制消息进行传递。HagiCode 的设计是:前端在开始录音时从 localStorage 加载热词配置,然后通过 WebSocket 消息发送给后端。

const controlMessage = {
type: 'control',
payload: {
command: 'StartRecognition',
contextText: '高血压\n糖尿病\n冠心病',
boosting_table_id: 'medical_table',
combineMode: false
}
};

这里有一个细节需要注意:前端传递的是多行文本(用换行符分隔),后端需要进行解析。后端的 WebSocket Handler 会解析这些参数并传递给 SDK:

private async Task HandleControlMessageAsync(
string connectionId,
DoubaoSession session,
ControlMessage message)
{
if (message.Payload is SessionControlRequest controlRequest)
{
// 解析热词参数
string? contextText = controlRequest.ContextText;
string? boostingTableId = controlRequest.BoostingTableId;
bool? combineMode = controlRequest.CombineMode;
// 解析多行文本为热词列表
if (!string.IsNullOrEmpty(contextText))
{
var hotwords = contextText
.Split('\n', StringSplitOptions.RemoveEmptyEntries)
.Select(s => s.Trim())
.Where(s => s.Length > 0)
.ToList();
session.HotwordContexts = hotwords;
}
session.BoostingTableId = boostingTableId;
}
}

通过这样的设计,热词配置从前端到后端的传递变得清晰而高效。其实也没什么特别的,就是一层一层传下去而已。

在实际使用中,配置自定义热词非常简单。打开 HagiCode 的语音识别设置页面,找到”热词配置”区域。在”自定义热词文本”输入框中,每行输入一个热词短语。

比如,如果你正在开发一个医疗相关的应用,可以这样配置:

高血压
糖尿病
冠心病
心绞痛
心肌梗死
心力衰竭

保存配置后,每次开始语音识别时,这些热词都会自动传递给豆包 API。实际测试表明,配置热词后,相关专业术语的识别准确率有了明显提升。怎么说呢,效果还是有的,至少比之前好多了。

如果你需要管理大量的热词,或者热词需要频繁更新,那么平台热词表模式更适合你。首先需要在豆包自学习平台上创建热词表,获取生成的 boosting_table_id,然后在 HagiCode 的设置页面中输入这个 ID。

豆包自学习平台提供了热词的批量导入、分类管理等功能,对于需要管理大量专业术语的团队来说非常实用。通过平台管理热词,可以实现热词的集中维护和统一更新。毕竟,热词多了之后,有个地方统一管理,总比每次都要手动输入要好。

在某些复杂场景下,你可能需要同时使用自定义热词和平台热词表。这时只需要在 HagiCode 中同时配置两种热词,并开启”组合模式”开关。

组合模式下,豆包 API 会同时考虑两种热词来源,识别准确率通常比单独使用任意一种更高。不过需要注意的是,组合模式会增加请求的复杂度,建议在实际测试后再决定是否启用。毕竟,复杂度增加了,是不是真的值得,还是得看实际效果。

在 HagiCode 项目中集成热词功能非常简单。以下是一些常用的代码片段:

import {
loadHotwordConfig,
saveHotwordConfig,
validateHotwordConfig,
parseContextText,
getEffectiveHotwordMode,
type HotwordConfig
} from '@/types/hotword';
// 加载并验证配置
const config = loadHotwordConfig();
const validation = validateHotwordConfig(config);
if (!validation.isValid) {
console.error('热词配置验证失败:', validation.errors);
return;
}
// 解析热词文本
const hotwords = parseContextText(config.contextText);
console.log('解析到的热词:', hotwords);
// 获取有效的热词模式
const mode = getEffectiveHotwordMode(config);
console.log('当前热词模式:', mode);

后端的使用同样简洁:

var config = new DoubaoVoiceConfig
{
AppId = "your_app_id",
AccessToken = "your_access_token",
ServiceUrl = "wss://openspeech.bytedance.com/api/v3/sauc/bigmodel_async",
// 配置自定义热词
HotwordContexts = new List<string>
{
"高血压",
"糖尿病",
"冠心病"
},
// 配置平台热词表
BoostingTableId = "medical_table_v1"
};
var client = new DoubaoVoiceClient(config, logger);
await client.ConnectAsync();
await client.SendFullClientRequest();

在实现和使用热词功能时,有几点需要特别注意。

首先是字符限制。豆包 API 对热词有严格的限制,包括行数、每行字符数、总字符数等。如果超出限制,API 会返回错误。在 HagiCode 的前端实现中,我们通过验证函数在用户输入阶段就进行检查,避免将无效配置发送到后端。毕竟,提前发现问题,总比等 API 返回错误要好。

其次是 boosting_table_id 的格式。这个字段只允许字母、数字、下划线和连字符,不允许包含空格或其他特殊字符。在豆包自学习平台上创建热词表时,需要注意命名规范。其实这也难怪,API 对格式的要求总是比较严格的。

第三是向后兼容性。热词参数是完全可选的,不配置热词时,系统的工作方式与之前完全一致。这种设计确保了现有用户不会受到任何影响,也便于逐步迁移和升级。毕竟,不能因为加个功能就把之前的逻辑搞乱了。

最后是错误处理。当热词配置无效时,豆包 API 会返回相应的错误信息。HagiCode 的实现会记录详细的日志,便于开发者排查问题。同时,前端也会在界面上展示验证错误,帮助用户修正配置。错误处理做得好,用户体验自然也就好了。

通过本文的讲解,我们详细介绍了在 HagiCode 项目中实现豆包语音识别热词功能的完整方案。这套方案涵盖了从需求分析、技术选型到代码实现的全部环节,为开发者提供了可参考的实践范例。

核心要点可以归纳为以下几点:第一,豆包 API 支持自定义热词和平台热词表两种模式,可以独立使用也可以组合使用;第二,前端采用 localStorage 存储配置,简单高效;第三,后端通过动态构建 corpus 字段来传递热词参数,保持了良好的向后兼容性;第四,完善的验证逻辑确保了配置的正确性,避免了无效请求。怎么说呢,这套方案也不复杂,就是按照 API 的要求来而已。

热词功能的实现,让 HagiCode 在语音识别领域的能力得到了进一步增强。通过灵活配置业务相关的专业术语,开发者可以让语音识别系统更好地理解特定领域的内容,从而提供更加精准的服务。毕竟,技术最终是要服务业务的,能解决实际问题才是最重要的。

如果你觉得本文对你有帮助,欢迎来 GitHub 给个 Star 支持一下 HagiCode 项目。你的认可,是我们持续分享技术实践的动力。说到底,写文章分享技术,能帮到人,也算是种快乐了。


感谢您的阅读,如果您觉得本文有用,快点击下方点赞按钮👍,让更多的人看到本文。

本内容采用人工智能辅助协作,经本人审核,符合本人观点与立场。

解决浏览器 WebSocket 认证难题:豆包语音识别的代理方案实践

解决浏览器 WebSocket 认证难题:豆包语音识别的代理方案实践

Section titled “解决浏览器 WebSocket 认证难题:豆包语音识别的代理方案实践”

浏览器 WebSocket API 不支持自定义 HTTP header,这给需要通过 header 传递认证信息的语音识别服务带来了挑战。本文分享 HagiCode 项目中如何通过后端代理方案解决这个问题,以及从 playground 到生产环境的实践过程。

其实在做 HagiCode 项目的语音识别功能时,我们也是满怀信心地选择了字节跳动的豆包语音识别服务。刚开始的设计很简单嘛——前端直接连豆包的 WebSocket 服务。这有什么难的?不就是建个连接,传点数据的事儿吗?

可是吧,万万没想到——豆包的 API 要求通过 HTTP header 传递认证信息,什么 accessToken、secretKey 之类的。这下就有点尴尬了,因为浏览器的 WebSocket API 根本不支持设置自定义 header。

你说不支持怎么办嘛?

那时候也是纠结了一阵子的。毕竟摆在面前的两个选择:

  1. 把认证信息塞到 URL 查询参数里——简单粗暴
  2. 在后端做一层代理——看起来麻烦一点

第一种方案吧,凭证就直接暴露在前端代码和本地存储里了。这安全吗?反正我是不太敢苟同的。而且有些 API 必须用 header 验证,根本走不通。

最终想了想,还是选了第二种方案——在后端实现一个 WebSocket 代理。说起来也是巧合,这个方案最初是在我们的 playground 试验场里验证的,后来确认稳定了才应用到生产环境。毕竟谁也不想在生产环境当小白鼠嘛,这点儿道理我还是懂的。

本文分享的方案来自我们在 HagiCode 项目中的实践经验。

HagiCode 是一个 AI 代码助手项目,支持语音交互功能。怎么说呢,也就是因为需要在前端调用语音识别服务,我们才遇到了这个 WebSocket 认证问题,也才有了后面的解决方案。有时候想想吧,困难这东西也不是完全没有好处,至少让我们学会了用代理,不是吗?

标准 WebSocket API 看起来真的很简单:

const ws = new WebSocket('wss://example.com/ws');

但问题就出在”简单”这两个字上——它只在 URL 里传递参数,没法像 HTTP 请求那样设置 headers:

// 这在 WebSocket API 里是不支持的
const ws = new WebSocket('wss://example.com/ws', {
headers: {
'Authorization': 'Bearer token'
}
});

你看看,这找谁说理去?对于豆包语音识别这类需要 header 认证的服务,这个限制简直就是一道迈不过去的坎儿。

罢了罢了,又能怎样呢?

在设计方案的时候,我们也是左思右想,权衡了又权衡。

决策一:代理模式选择

我们比较了两种方案:

方案优点缺点决策
原生 WebSocket轻量、简单、直接转发需手动处理连接管理选择
SignalR自动重连、强类型过度复杂、额外依赖不选

最后选了原生 WebSocket。说实话,也就是因为它最轻量,适合简单的双向二进制流转发。加个 SignalR 吧,确实有点杀鸡用牛刀的感觉,而且会增加延迟——这又何苦呢?

决策二:连接管理策略

我们采用了”每连接单会话”模式——每个前端 WebSocket 连接对应一个独立的豆包后端连接。

这样做的好处也是显而易见的:

  • 实现简单,符合典型使用场景
  • 易于调试和故障排查
  • 资源隔离,避免会话间互相干扰

其实说白了也就是——简单粗暴有时候反而是最好的选择。复杂的方案不一定好,简单的不一定差。

决策三:认证信息存储

凭证存在后端配置文件(appsettings.yml 或环境变量)里,通过依赖注入加载:

  • 配置方式简单,符合现有后端配置模式
  • 敏感信息不暴露给前端
  • 支持多环境配置(开发、测试、生产)

这安全感嘛,总归是要有的。毕竟谁也不想自己的凭证满天飞,不是吗?

整体数据流是这样的:

前端 (浏览器)
│ ws://backend/api/voice/ws
│ WebSocket (二进制)
后端 (代理)
│ wss://openspeech.bytedance.com/
│ (带认证 header)
豆包 API

流程倒也不复杂,也就是这么几步:

  1. 前端通过 WebSocket 连接后端代理
  2. 后端代理接收音频数据,用带 header 的方式连接豆包 API
  3. 豆包 API 返回识别结果,代理转发给前端
  4. 全程异步双向流式传输

一切看起来都是那么自然,不是吗?

app.Map("/ws", async context =>
{
if (context.WebSockets.IsWebSocketRequest)
{
// 从查询参数读取配置
var appId = context.Request.Query["appId"];
var accessToken = context.Request.Query["accessToken"];
// 验证必需参数
if (string.IsNullOrEmpty(appId) || string.IsNullOrEmpty(accessToken))
{
context.Response.StatusCode = 400;
return;
}
// 接受 WebSocket 连接
using var webSocket = await context.WebSockets.AcceptWebSocketAsync();
// 消息处理循环
var buffer = new byte[4096];
while (!webSocket.CloseStatus.HasValue)
{
var result = await webSocket.ReceiveAsync(buffer, CancellationToken.None);
if (result.MessageType == WebSocketMessageType.Close)
{
await webSocket.CloseAsync(
result.CloseStatus.Value,
result.CloseStatusDescription,
CancellationToken.None);
break;
}
// 处理音频数据
await HandleAudioDataAsync(buffer, result.Count);
}
}
});
public class DoubaoSessionManager : IDoubaoSessionManager
{
private readonly ConcurrentDictionary<string, DoubaoSession> _sessions = new();
public DoubaoSession CreateSession(string connectionId)
{
var session = new DoubaoSession(connectionId);
_sessions[connectionId] = session;
return session;
}
public async Task SendAudioAsync(string connectionId, byte[] audioData)
{
if (_sessions.TryGetValue(connectionId, out var session))
{
await session.SendAudioAsync(audioData);
}
}
public void RemoveSession(string connectionId)
{
if (_sessions.TryRemove(connectionId, out var session))
{
session.Dispose();
}
}
}

用 ConcurrentDictionary 管理会话,线程安全也就不用操心了。每个连接进来就创建一个 Session,断开时自动清理——这大概就是所谓的”来也匆匆,去也匆匆”罢。

public class ClientConfigDto
{
public string AppId { get; set; } = null!;
public string Access set; } =Token { get; null!;
public string? ServiceUrl { get; set; }
public string? ResourceId { get; set; }
public int? SampleRate { get; set; }
public int? BitsPerSample { get; set; }
public int? Channels { get; set; }
public void Validate()
{
if (string.IsNullOrWhiteSpace(AppId))
throw new ArgumentException("AppId is required");
if (string.IsNullOrWhiteSpace(AccessToken))
throw new ArgumentException("AccessToken is required");
}
}

配置验证嘛,也就是为了在启动时就发现问题,避免运行时出什么幺蛾子。这点儿保障还是要的。

前端和后端之间用 JSON 格式的文本消息做控制,用二进制消息传音频数据。

控制消息示例:

{
"type": "control",
"messageId": "msg_123",
"timestamp": "2026-03-03T10:00:00Z",
"payload": {
"command": "StartRecognition",
"parameters": {
"hotwordId": "hotword1",
"boosting_table_id": "table123"
}
}
}

识别结果示例:

{
"type": "result",
"timestamp": "2026-03-03T10:00:03Z",
"payload": {
"text": "你好世界",
"confidence": 0.95,
"duration": 1500,
"isFinal": true,
"utterances": [
{
"text": "你好",
"startTime": 0,
"endTime": 800,
"definite": true
}
]
}
}

这种设计把控制信号和音频数据分开,处理起来也是更清晰一些。有时候分而治之确实是个不错的办法。

class DoubaoVoiceClient {
constructor(config) {
this.config = config;
this.ws = null;
}
async connect() {
const url = new URL(this.config.wsUrl);
// 添加查询参数
Object.entries(this.config.params).forEach(([key, value]) => {
url.searchParams.set(key, value);
});
this.ws = new WebSocket(url);
return new Promise((resolve, reject) => {
this.ws.onopen = () => {
console.log('[DoubaoVoice] Connected');
resolve();
};
this.ws.onmessage = (event) => {
this._handleMessage(JSON.parse(event.data));
};
this.ws.onerror = reject;
});
}
_handleMessage(message) {
switch (message.type) {
case 'status':
this._handleStatus(message.payload);
break;
case 'result':
this.onResult?.(message.payload);
break;
case 'error':
console.error('[DoubaoVoice] Error:', message.payload);
break;
}
}
}
// 使用示例
const client = new DoubaoVoiceClient({
wsUrl: 'ws://localhost:5000/ws',
params: {
appId: 'your-app-id',
accessToken: 'your-access-token',
sampleRate: 16000,
bitsPerSample: 16,
channels: 1
}
});

用 AudioWorkletNode 做音频处理,性能也会更好一些:

audio-worklet.js
class AudioProcessorWorklet extends AudioWorkletProcessor {
process(inputs, outputs, parameters) {
const input = inputs[0]?.[0];
if (!input) return true;
// 转换为 16-bit PCM
const pcm = new Int16Array(input.length);
for (let i = 0; i < input.length; i++) {
pcm[i] = Math.max(-32768, Math.min(32767, input[i] * 32767));
}
this.port.postMessage({
type: 'audioData',
data: pcm.buffer
}, [pcm.buffer]);
return true;
}
}
registerProcessor('audio-processor', AudioProcessorWorklet);
// 主线程代码
async function startAudioRecording() {
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
sampleRate: 48000
}
});
const audioContext = new AudioContext();
const audioSource = audioContext.createMediaStreamSource(stream);
await audioContext.audioWorklet.addModule('/audio-worklet.js');
const audioWorkletNode = new AudioWorkletNode(audioContext, 'audio-processor');
audioWorkletNode.port.onmessage = (event) => {
if (event.data.type === 'audioData' && ws?.readyState === WebSocket.OPEN) {
ws.send(event.data.data); // 直接发送二进制数据
}
};
audioSource.connect(audioWorkletNode);
}

AudioWorklet 比 ScriptProcessorNode 性能好很多,不会有音频卡顿的问题。这年代,谁还愿意听那种刺刺拉拉的噪音呢?

{
"Serilog": {
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft": "Warning",
"System": "Warning"
}
},
"WriteTo": [
{ "Name": "Console" },
{
"Name": "File",
"Args": { "path": "logs/log-.txt", "rollingInterval": "Day" }
}
]
},
"Kestrel": {
"Urls": "http://0.0.0.0:5000"
}
}

日志配置很重要,方便排查问题。Serilog 的 File sink 可以按天滚动,日志文件也不会太大。毕竟有些问题嘛,事后诸葛亮总是要容易一点的。

  • 定期输出会话状态日志,方便追踪连接生命周期
  • 监控音频段数量和持续时间,识别异常连接
  • 记录与豆包服务的连接状态和重连情况

这些也就是一些基本的操作罢了。

  • 捕获并记录所有 WebSocket 异常
  • 使用 IAsyncDisposable 确保资源清理
  • 实现优雅的连接关闭和超时处理

总而言之,稳字当头。

  • 采样率:16000 Hz(推荐)或 8000 Hz
  • 位深度:16-bit
  • 声道:单声道
  • 编码:PCM (raw)

格式不对会导致识别失败或者效果很差。这点儿规矩还是要守的。

  • 敏感凭证只存在后端配置里
  • 实施连接数限制防止资源耗尽
  • 生产环境用 HTTPS/WSS

安全无小事,且行且珍惜罢。

  • 用异步操作避免阻塞
  • 适当调整缓冲区大小(默认 4096 字节)
  • 考虑连接池和复用策略

这些优化手段,能用上的就用上罢。

  1. Docker 部署:把代理服务打包成容器,方便扩展和管理
  2. 负载均衡:用 Nginx 或 Envoy 做 WebSocket 反向代理
  3. 健康检查:实现心跳机制监控服务可用性
  4. 日志聚合:把日志发送到集中式日志系统(如 ELK、Loki)

部署这事儿吧,说简单也简单,说复杂也复杂。也就是因人而异,因地制宜罢。

WebSocket 代理方案解决了浏览器 WebSocket API 不支持自定义 header 的根本问题。在 HagiCode 项目中,这个方案从 playground 验证到生产环境部署,证明了它的可行性和稳定性。

关键点总结:

  • 后端代理可以安全地传递认证信息
  • 原生 WebSocket 轻量高效,适合简单场景
  • “每连接单会话”简化了实现和调试
  • 前后端消息协议分离控制信号和音频数据

如果你也在做需要 WebSocket 认证的功能,希望这个方案能给你一些启发。

有什么问题的话,欢迎来讨论。毕竟技术这东西嘛,都是在交流中进步的。


感谢您的阅读,如果您觉得本文有用,快点击下方点赞按钮👍,让更多的人看到本文。

本内容采用人工智能辅助协作,经本人审核,符合本人观点与立场。