跳转到内容

HagiCode

26 篇包含标签 "HagiCode" 的文章

如何实现 Claude Code 和 Codex 等 Agent CLI 的自动重试

如何实现 Claude Code 和 Codex 等 Agent CLI 的自动重试

Section titled “如何实现 Claude Code 和 Codex 等 Agent CLI 的自动重试”

自动重试这个词,看着像是个小开关,真落到工程现场里,完全不是那么回事。全民制作人们大家好,我是 HagiCode 制作人俞坤。今天这篇我们不聊空话,就聊 Claude Code、Codex 这类 Agent CLI 的自动重试到底该怎么做,才能既接得住异常,又不把系统带进无休止的重复执行里。

如果你最近也在折腾 AI 编程,那这类问题你大概率已经碰到过了:任务不是一上来就挂,而是跑到一半断掉。

这事儿放到普通 HTTP 请求里,很多时候就是重发一下,顶多补个指数退避。可是 Agent CLI 不一样。Claude Code、Codex 这类工具通常是流式执行的,输出是一段一段往外推,过程中还会绑定 thread、session 或 resume token。换句话说,它不是“这一请求失败了没有”,而是:

  • 前面已经吐出来的内容还算不算数
  • 当前上下文还能不能接着跑
  • 这次失败该不该自动恢复
  • 如果要恢复,多久再试,试的时候发什么,原上下文还要不要复用

很多团队第一次做这里,都会下意识写一个最朴素的版本:报错了就再试一次。你说得非常正确,这个想法很自然,可是真进项目里,问题就一个接一个冒出来了。

  • 有些错误明明是暂时故障,却被当成最终失败
  • 有些错误根本不值得重试,却被系统反复重放
  • 有 thread 的请求和没有 thread 的请求,被一把梭地一视同仁
  • 退避策略没边界,后台请求自己把自己打爆

HagiCode 在接多种 Agent CLI 的过程中,也踩过这些坑。尤其是 Codex 这一侧,最初暴露出来的问题就是某类 reconnect 报文没有被识别成可重试终态,结果原本已有的恢复机制根本没机会生效。说白了,不是系统没有自动重试,而是系统没把“这次值得重试”认出来。

所以这篇文章想讲的核心点很明确:自动重试不是一个按钮,而是一套分层设计。

本文分享的方案,来自我们在 HagiCode 项目里的真实实践。HagiCode 要做的事情,不是把某一个模型接上就完事,而是把多种 Agent CLI 的流式消息、工具调用、失败恢复、会话上下文,统一成一套能长期维护的执行模型。

我平时最关心的事情之一,就是怎么让 AI 编程这件事真正落到工程现场。写 Demo 不难,难的是把 Demo 变成团队真的愿意长期使用的东西。HagiCode 之所以认真做自动重试,不是因为这个功能看起来高级,而是因为长链路、流式、可续跑的 CLI 执行如果接不稳,用户看到的就不是智能助手,而是一个动不动半路掉线的命令包装器。

如果你想先看看项目入口,这里先放两个:

再往前走一步讲,HagiCode 现在也已经上架 Steam 了,有 Steam 的朋友可以先加个愿望单:

为什么 Agent CLI 的自动重试比普通重试更难

Section titled “为什么 Agent CLI 的自动重试比普通重试更难”

这个问题提得很实在,我们直接上结论:Agent CLI 的自动重试,难点不在“隔几秒再试一次”,而在“还能不能在原上下文里继续”。

你可以把它理解成一次长对话。普通 API 重试,更像电话占线再拨一遍;而 Agent CLI 重试,更像对方刚讲到一半信号断了,你得先判断要不要回拨,回拨以后要不要从头说,对方还记不记得刚刚聊到哪。谁说这两者是一回事呢?它们压根不是一个工程问题。

具体看,有四个难点最典型。

一旦输出已经发给用户,你就不能像处理普通请求那样,把失败偷偷吞掉然后悄悄重来。因为前面那部分内容已经被看到了,再次重放时如果策略不对,前端很容易看到重复文本、错乱状态,工具调用生命周期也会一起乱套。这波不是玄学,是工程。

Codex 这类 provider 会绑定 thread,Claude Code 一类实现也会有 continuation target 或等价的续跑上下文。真正能自动重试的前提,不只是“这个错误长得像暂时故障”,还包括“这次执行还有没有继续下去的载体”。

网络抖动、SSE idle timeout、上游临时故障,这些通常可以试一试。可如果你遇到的是认证失败、上下文已经丢了,或者 provider 根本没有 resume 能力,那继续重试多数不是恢复,而是在制造噪音。

无限自动重试几乎总是错的。技术趋势可以热闹一阵子,工程规律往往会稳定很多年,其中一条就是:失败恢复一定要有边界。系统必须知道自己最多试几次、每次隔多久、什么时候该停手承认这回真不行了。

也正因为这几个特点,HagiCode 最后没有把自动重试写成某个 provider 里的几行 try/catch,而是把它提炼成一层共享能力。说到底,工程问题还是要回到工程方法里解决。

HagiCode 的做法:把重试从 Provider 里拿出来

Section titled “HagiCode 的做法:把重试从 Provider 里拿出来”

HagiCode 当前这套真实实现,可以压缩成一句话:

共享层统一管理重试流程,具体 Provider 只负责回答两个问题:这个终态值不值得重试?当前上下文还能不能继续?

这件事不复杂,可是很关键。因为一旦把职责切开,Claude Code、Codex,甚至其他 Agent CLI 都能复用同一个骨架。模型会说,工具会变,工作流会升级,但工程上的基本盘一直都在那里。

第一层:用统一协调器管理重试循环

Section titled “第一层:用统一协调器管理重试循环”

项目中的核心实现片段大概是下面这样:

internal static class ProviderErrorAutoRetryCoordinator
{
public static async IAsyncEnumerable<CliMessage> ExecuteAsync(
string prompt,
ProviderErrorAutoRetrySettings? settings,
Func<string, IAsyncEnumerable<CliMessage>> executeAttemptAsync,
Func<bool> canRetryInSameContext,
Func<TimeSpan, CancellationToken, Task> delayAsync,
Func<CliMessage, bool> isRetryableTerminalMessage,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
var normalizedSettings = ProviderErrorAutoRetrySettings.Normalize(settings);
var retrySchedule = normalizedSettings.Enabled
? normalizedSettings.GetRetrySchedule()
: [];
for (var attempt = 0; ; attempt++)
{
var attemptPrompt = attempt == 0
? prompt
: ProviderErrorAutoRetrySettings.ContinuationPrompt;
CliMessage? terminalFailure = null;
await foreach (var message in executeAttemptAsync(attemptPrompt)
.WithCancellation(cancellationToken))
{
if (isRetryableTerminalMessage(message))
{
terminalFailure = message;
break;
}
yield return message;
}
if (terminalFailure is null)
{
yield break;
}
if (attempt >= retrySchedule.Count || !canRetryInSameContext())
{
yield return terminalFailure;
yield break;
}
await delayAsync(retrySchedule[attempt], cancellationToken);
}
}
}

这段代码干的事情,其实非常朴素,但很有力。

  • 中间失败先不直接透传,协调器先判断能不能恢复
  • 只有重试预算耗尽,最终失败才真正回到上层
  • 第二轮开始不再发送原始 prompt,而是统一发送 continuation prompt

这也就是为什么我前面一直强调,自动重试不是简单的“再请求一次”。它不是在补一个异常分支,而是在管理一条执行生命周其。听起来有点像产品经理,但工程上确实如此。

另一个很容易被忽略的问题是:谁来决定这次请求是否开启自动重试?

HagiCode 的答案是,不要依赖某个“此刻的全局配置”,而是把策略做成 snapshot,跟着这次请求一起走。这样一来,会话排队、消息持久化、执行转发、provider 适配,都不会把策略弄丢。一次成功不叫体系,持续成功才叫体系。

核心结构可以简化成这样:

public sealed record ProviderErrorAutoRetrySnapshot
{
public const string DefaultStrategy = "default";
public bool Enabled { get; init; }
public string Strategy { get; init; } = DefaultStrategy;
public static ProviderErrorAutoRetrySnapshot Normalize(bool? enabled, string? strategy)
{
return new ProviderErrorAutoRetrySnapshot
{
Enabled = enabled ?? true,
Strategy = string.IsNullOrWhiteSpace(strategy)
? DefaultStrategy
: strategy.Trim()
};
}
}

然后在执行侧再映射成 provider 真正消费的设置对象。这个做法的价值很直接:

  • 业务层决定“该不该重试”
  • 运行时决定“怎么重试”

两边各管一摊,互相不打架。很多问题不是不能做,只是没把代价算明白。把策略快照化,本质上就是在提前把代价算清楚。

第三层:Provider 只做终态判定和上下文判定

Section titled “第三层:Provider 只做终态判定和上下文判定”

到了具体的 Claude Code 或 Codex provider,这里的职责反而很薄。你可以把它理解成增强,不要把它误会成代替。

以 Codex 为例,它最终接入共享协调器时,本质上只需要提供三样东西:

await foreach (var message in ProviderErrorAutoRetryCoordinator.ExecuteAsync(
prompt,
options.ProviderErrorAutoRetry,
retryPrompt => ExecuteCodexAttemptAsync(...),
() => !string.IsNullOrWhiteSpace(resolvedThreadId),
DelayAsync,
IsRetryableTerminalFailure,
cancellationToken))
{
yield return message;
}

你会发现,真正属于 Provider 自己的判断只有两个:

  • IsRetryableTerminalFailure
  • canRetryInSameContext

Codex 看的是 thread 还能不能续上,Claude Code 看的是 continuation target 还在不在。退避策略、重试次数、后续 prompt,这些通通不该让 Provider 自己重新发明一遍。

这一层拆出来以后,HagiCode 接更多 CLI 的成本就低很多了。你不用复制一整套重试状态机,只要把“这个 provider 的边界条件”接进来就行。写得快,不等于写得稳;接得住,不等于接得好;能跑起来,也不等于能长期维护。

一个很容易做错的点:别把所有报错都当可重试

Section titled “一个很容易做错的点:别把所有报错都当可重试”

这次分析里,我觉得最值得单拎出来讲的,不是“怎么实现重试”,而是“怎么避免错误重试”。

最开始的问题切入口,是 Codex 少识别了一条 reconnect 报文。按直觉,很多人会选一个最小修法:往白名单里再加一条字符串前缀。这个思路不能说错,只是它更像 Demo 时期的解法,不太像长期维护的解法。

从当前 HagiCode 的落地来看,系统已经往更稳的方向走了一步。它不再只盯着某个字面字符串,而是把可恢复的终态统一交给共享协调器处理。这样做的好处很明显:

  • 不容易因为某条文案的小改动就彻底失效
  • 测试覆盖可以围绕“终态 envelope”展开,而不是单条硬编码文本
  • 同一个 provider 的重试逻辑会更一致

当然,这里要立一个边界:更通用,不等于更宽松。只要当前上下文不能继续,哪怕报错看起来很像暂时故障,也不应该盲目 replay。

这点很关键。真正让人安心的,不是它偶尔灵一次,而是它大多数时候都靠谱。如果一个流程只能靠高手维持,那它离普及还差得远。

文章写到这里,差不多可以往实践层收一收了。如果你准备在自己的项目里实现类似能力,我最建议先守住下面三条。

HagiCode 当前默认的退避节奏是:

  • 10 秒
  • 20 秒
  • 60 秒

这个节奏不一定适合所有系统,但“有边界”这件事必须保留。要不然,自动重试很快就会从恢复机制变成事故放大器。别急着把名字起得太大,先看看这东西能不能在团队里活过两个迭代。

项目里使用的是固定 continuation prompt,让后续 attempt 明确走“继续当前上下文”的路径,而不是重新发起一轮完整请求。这个能力不花哨,可是你真做项目时离不开。很多能力看起来像魔法,拆开以后不过是一套被打磨过的工程流程。

3. 共享库和适配层都要有镜像测试

Section titled “3. 共享库和适配层都要有镜像测试”

这点我很想多说一句。很多团队会在共享运行时里写一层测试,然后觉得差不多了。其实不够。

HagiCode 这边之所以让我比较放心,是因为两层都补了测试:

  • 共享 Provider 测“是否真的发生了自动续跑”
  • 适配层测“最终错误和流式消息有没有被整理坏”

我这次也额外补跑了两组相关测试,结果都是 31 个用例全部通过。这个结果本身说明不了设计一定完美,可它至少能说明一件事:当前这套自动重试不是纸面方案,而是已经被代码和测试共同约束住的能力。Talk is cheap. Show me the code. 放到这里,恰好合适。

如果把整篇文章压缩成一句话,那就是:

Claude Code、Codex 等 Agent CLI 的自动重试,最好不要做成某个 Provider 内部的局部技巧,而应该做成共享协调器 + 策略快照 + 上下文判定 + 镜像测试的组合。

这样做带来的收益,其实非常实在:

  • 逻辑只写一遍,多个 Provider 都能复用
  • 请求是否允许重试,可以稳定地跟着执行链路走
  • 有上下文时继续跑,没上下文时及时停手
  • 前端最终看到的是稳定的完成态或失败态,而不是一堆半途而废的中间噪音

这套方案,是 HagiCode 在真实接入多种 Agent CLI 的过程中一点点打磨出来的。谁说 AI 辅助编程就不是新时代的结对编程呢?模型帮你起步、补全、发散,可真正决定体验上限的,往往还是上下文、流程和约束。

如果本文对你有帮助,也欢迎顺手看看 HagiCode 的公开入口:

HagiCode 现在已经上架 Steam 了,这不是画饼,链接也给你放这儿了。有 Steam 的朋友可以先加个愿望单,自己点进去看一眼,比我在这儿多说十句都来得直接。

先把这件事讲到这里,剩下的我们继续在真实项目里见。

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

SQLite 分片方案实战:三种分片策略的深度对比

SQLite 分片方案实战:三种分片策略的深度对比

Section titled “SQLite 分片方案实战:三种分片策略的深度对比”

当单文件 SQLite 遇到并发瓶颈,我们该如何破局?本文分享 HagiCode 项目中三种不同场景下的 SQLite 分片方案,帮你理解如何选择合适的分片策略。

全民制作人们大家好,我是 HagiCode 制作人俞坤。

在构建高性能应用时,单文件 SQLite 数据库会碰到很现实的问题。用户量和数据量一上来,这些状况就会排队找上门:

  • 写入操作开始排队,响应时间肉眼可见地变长
  • 查询性能随数据增长往下掉
  • 多线程访问时频繁出现 “database is locked” 错误

很多人第一反应是:要不要直接迁移到 PostgreSQL 或者 MySQL?这波操作虽然能解决问题,但部署复杂度会直线上升。有没有更轻量的方案?

答案是:分片。说到底,工程问题还是要回到工程方法里解决,通过将数据分散到多个 SQLite 文件,可以显著提升并发能力和查询性能,同时保持 SQLite 的轻量级特性。

本文分享的方案来自我们在 HagiCode 项目中的实践经验。作为一个 AI 代码助手项目,HagiCode 需要处理大量的对话消息、状态持久化和事件历史记录。正是在解决这些实际问题的过程中,我们总结出了三种不同场景下的分片方案。

工欲善其事,必先利其器,但这些”器”怎么用,还得看具体的”事”是什么。

我们的代码仓库在 github.com/HagiCode-org/site,欢迎感兴趣的朋友深入了解。

经过对 HagiCode 代码库的分析,我们发现了三种针对不同业务场景的 SQLite 分片方案:

  1. Session Message 分片存储:AI 对话消息存储,特点是高频写入、基于 Session 的隔离查询
  2. Orleans Grain 分片存储:分布式框架状态持久化,特点是跨节点访问、需要确定性路由
  3. Hero History 分片存储:游戏化系统历史事件记录,特点是事件溯源、需要迁移兼容

虽然业务场景不同,但三者都遵循相同的核心设计原则:

  • 确定性路由:直接从业务 ID 计算分片,无需元数据表
  • 透明访问:上层通过统一接口操作,不感知分片存在
  • 独立存储:每个分片是完全独立的 SQLite 文件
  • 并发优化:WAL 模式 + busy_timeout 降低锁竞争

很多人会问:为什么不搞一套通用的分片方案?这个问题问得很实在,我们直接上结论:工程上没有万能方案,只有最贴合当前业务场景的方案。接下来我们深入对比这三种方案的具体实现。

方面Session MessageOrleans GrainHero History
分片数量256 (16²)10010
命名规则16 进制 (00-ff)10 进制 (00-99)10 进制 (0-9)
存储目录DataDir/messages/DataDir/orleans/grains/DataDir/hero-history/
文件名模式{shard}.dbgrains-{shard}.db{shard}.db

为什么分片数量差异这么大?这取决于业务特点。换句话说,模型会说,工具会变,工作流会升级,但工程上的基本盘一直都在那里:你得先搞清楚自己要解决什么问题。

  • Session Message 使用 256 个分片,因为对话消息的写入频率最高,需要更多的分片来分散负载
  • Orleans Grain 使用 100 个分片,平衡了并发性能和管理复杂度
  • Hero History 只用 10 个分片,因为历史事件写入频率较低,且需要考虑迁移成本

路由算法是分片方案的核心,决定了数据如何分布到各个分片。三种方案使用了不同的路由策略:

// Session Message: GUID 后两位 16 进制
var normalized = Guid.Parse(sessionId.Value).ToString("N").ToLowerInvariant();
return normalized[^2..]; // 取末两位 16 进制字符
// Orleans Grain: 提取数字后两位取模
var digits = ExtractDigits(grainId); // 提取所有数字
var lastTwoDigits = (digits[^2] * 10) + digits[^1];
return lastTwoDigits % shardCount;
// Hero History: 末位字符 ASCII 值取模
return heroId[^1] % 10;

设计思路解析

  • Session Message 的 ID 是 GUID,转换为 16 进制后取末两位,可以得到均匀分布的 256 个分片
  • Orleans Grain 的 ID 格式不统一,可能包含字母和数字,所以提取所有数字后取模
  • Hero History 的 ID 是字符串,直接用末位字符的 ASCII 值取模,简单但分布可能不够均匀

关键点:无论使用哪种算法,都必须保证同一 ID 永远映射到同一分片。这是分布式系统中最基本的要求,否则会导致数据不一致。说到底,路由不稳定,一切努力都是零。

方面Session MessageOrleans GrainHero History
初始化时机按需懒加载启动时全量并行初始化按需懒加载
并发控制Lazy<Task> 防重复初始化Parallel.ForEachAsyncLazy<Task> 防重复初始化

为什么 Orleans Grain 选择启动时全量初始化?

因为 Orleans 是分布式框架,Grain 可能被调度到任意节点。如果在运行时才发现分片文件不存在,会导致请求失败。启动时全量初始化虽然会延长启动时间,但能确保运行时的稳定性。能跑起来只是开始,能维护下去才算本事。

懒加载的优势

对于 Session Message 和 Hero History,使用懒加载可以减少启动时间,只有在真正需要访问某个分片时才创建文件和初始化 Schema。使用 Lazy<Task> 可以防止并发初始化时的竞态条件。这个设计看着简单,但在真实项目里能省掉很多不必要的麻烦。

三种方案的 Schema 设计反映了各自的业务特点:

Session Message

  • 支持 Event Sourcing 模式(事件表 + 快照表)
  • 包含消息内容块子表(MessageContentBlocks)
  • 具有压缩和压缩标记字段,支持后续优化

Orleans Grain

  • 最简设计:单表 GrainState
  • JSON 序列化存储状态
  • ETag 乐观并发控制

Hero History

  • 时间线查询优化索引
  • DedupeKey 唯一约束防重复
  • 支持多种事件类型和状态

从这些设计中可以看出,Schema 设计应该紧密贴合业务需求,而不是追求通用性。Orleans Grain 的简单设计正是因为它只需要存储序列化后的状态,不需要复杂的查询能力。这波不是玄学,是工程。别急着把名字起得太大,先看看这东西能不能在团队里活过两个迭代。

三种方案都使用了相同的 SQLite 并发优化配置:

PRAGMA journal_mode=WAL; -- 写前日志模式
PRAGMA synchronous=NORMAL; -- 降低持久化开销
PRAGMA busy_timeout=5000; -- 5秒忙等待
PRAGMA foreign_keys=ON; -- 外键约束

WAL 模式的优势

传统的回滚日志模式在写入时会产生锁竞争,而 WAL 模式允许读写并发进行。这在大数据量场景下可以显著提升性能。很多人不知道这个配置,其实它比你想的要重要得多。

synchronous=NORMAL 的权衡

设置为 FULL 可以保证最高安全性,但会显著降低性能。NORMAL 模式在安全性和性能之间取得了平衡,对于大多数应用来说是合适的选择。这个配置不需要纠结太久,NORMAL 就够了。

基于对 HagiCode 三种方案的分析,我们可以总结出以下决策矩阵:

高吞吐量场景 → 更多分片(如 Message 用 256)
简单维护性 → 较少分片(如 Hero History 用 10)
数字 ID 为主 → 取模算法(Orleans Grain)
GUID 为主 → 16 进制后缀(Session Message)
字符串 ID → ASCII 取模(Hero History)

分片数量选择的经验值

  • 太少(< 10):并发提升有限,分片意义不大
  • 太多(> 1000):文件管理复杂,连接池开销大
  • 经验值:10-100 个分片适用于大多数场景
  • 极高并发场景:可以考虑 256 个分片

这事你要是只看演示,确实容易上头;可一旦进了生产环境,账就得一笔一笔算清楚。很多问题不是不能做,只是没把代价算明白。

public interface IShardResolver<TId>
{
string ResolveShardKey(TId id);
}
// 16 进制分片(适用于 GUID)
public class HexSuffixShardResolver : IShardResolver<string>
{
private readonly int _suffixLength;
public HexSuffixShardResolver(int suffixLength = 2)
{
_suffixLength = suffixLength;
}
public string ResolveShardKey(string id)
{
var normalized = id.Replace("-", "").ToLowerInvariant();
return normalized[^_suffixLength..];
}
}
// 数字取模分片(适用于纯数字 ID)
public class NumericModuloShardResolver : IShardResolver<long>
{
private readonly int _shardCount;
public NumericModuloShardResolver(int shardCount)
{
_shardCount = shardCount;
}
public string ResolveShardKey(long id)
{
return (id % _shardCount).ToString("D2");
}
}
public class ShardedConnectionFactory<TOptions>
{
private readonly ConcurrentDictionary<string, Lazy<Task>> _initializationTasks = new();
private readonly TOptions _options;
private readonly IShardSchemaInitializer _initializer;
public ShardedConnectionFactory(
TOptions options,
IShardSchemaInitializer initializer)
{
_options = options;
_initializer = initializer;
}
public async Task<TDbContext> CreateAsync(string shardKey, CancellationToken ct)
{
var connectionString = BuildConnectionString(shardKey);
// 使用 Lazy<Task> 防止并发初始化
var initTask = _initializationTasks.GetOrAdd(
connectionString,
_ => new Lazy<Task>(() => InitializeShardAsync(connectionString, ct))
);
await initTask.Value;
return CreateDbContext(connectionString);
}
private async Task InitializeShardAsync(string connectionString, CancellationToken ct)
{
await _initializer.InitializeAsync(connectionString, ct);
}
private string BuildConnectionString(string shardKey)
{
var shardPath = Path.Combine(_options.BaseDirectory, $"{shardKey}.db");
return $"Data Source={shardPath}";
}
private TDbContext CreateDbContext(string connectionString)
{
// 根据具体的 ORM 创建 DbContext
return Activator.CreateInstance(typeof(TDbContext), connectionString) as TDbContext;
}
}
public class SqliteShardInitializer : IShardSchemaInitializer
{
public async Task InitializeAsync(string connectionString, CancellationToken ct)
{
await using var connection = new SqliteConnection(connectionString);
await connection.OpenAsync(ct);
// 并发优化配置
await connection.ExecuteAsync("""
PRAGMA journal_mode=WAL;
PRAGMA synchronous=NORMAL;
PRAGMA busy_timeout=5000;
PRAGMA foreign_keys=ON;
""");
// 创建表结构
await connection.ExecuteAsync("""
CREATE TABLE IF NOT EXISTS Entities (
Id TEXT PRIMARY KEY,
CreatedAt TEXT NOT NULL,
UpdatedAt TEXT NOT NULL,
Data TEXT NOT NULL,
ETag TEXT
);
""");
// 创建索引
await connection.ExecuteAsync("""
CREATE INDEX IF NOT EXISTS IX_Entities_CreatedAt
ON Entities(CreatedAt DESC);
CREATE INDEX IF NOT EXISTS IX_Entities_UpdatedAt
ON Entities(UpdatedAt DESC);
""");
}
}

1. 路由稳定性

路由算法必须保证同一 ID 永远映射到同一分片。避免使用随机或时间相关的计算,也不要在算法中引入可变参数。

2. 分片数量选择

分片数量应该在设计阶段确定,后期修改非常困难。需要考虑:

  • 当前和未来的并发量
  • 单个分片的管理成本
  • 数据迁移的复杂度

3. 迁移考虑

Hero History 方案展示了完整的迁移路径:

  1. 新建分片存储基础设施
  2. 实现迁移服务将主库数据复制到分片
  3. 验证迁移后查询兼容性
  4. 切换读写路径到分片
  5. 清理主库旧表

设计分片方案时就需要考虑未来的迁移需求。Talk is cheap. Show me the code,但光有代码还不够,你还得有完整的迁移路径。一次成功不叫体系,持续成功才叫体系。

4. 监控与运维

  • 监控各分片的大小分布,及时发现数据倾斜
  • 设置告警检测分片热点,避免单个分片成为瓶颈
  • 定期检查 WAL 文件大小,防止磁盘空间占用过多
  • 建立分片健康检查机制

5. 测试覆盖

  • 测试边界条件(空 ID、特殊字符、超长 ID)
  • 验证路由确定性,确保同一 ID 总是映射到同一分片
  • 并发写入压力测试,验证锁竞争得到有效缓解
  • 迁移测试,确保数据完整性和一致性

通过对比 HagiCode 项目中的三种 SQLite 分片方案,我们可以看到:

  1. 没有万能的解决方案:不同业务场景需要不同的分片策略
  2. 核心原则是通用的:确定性路由、透明访问、独立存储、并发优化
  3. 设计要面向未来:考虑迁移路径和运维成本

如果你的项目正在使用 SQLite,并且开始遇到并发瓶颈,希望这篇文章能为你提供一些思路。不需要急着迁移到重量级数据库,有时候合适的分片方案就能解决问题。

当然,分片不是银弹。在选择分片方案之前,先确保:

  • 你已经优化了单表查询性能
  • 你已经使用了合适的索引
  • 你已经启用了 WAL 模式

只有在这些优化都做完之后,仍然存在性能瓶颈时,才考虑引入分片。你能把简单的事情做好,这本身就是一种能力。

很多话讲一遍不如做一遍,接下来就让工程结果自己发声。

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

VSCode 与 code-server:浏览器端代码编辑方案选型

VSCode 与 code-server:浏览器端代码编辑方案选型

Section titled “VSCode 与 code-server:浏览器端代码编辑方案选型”

在构建浏览器端的代码编辑能力时,开发者面临一个关键选择:使用 VSCode 官方的 code serve-web 功能,还是采用社区驱动的 code-server 方案?这个选择不仅影响技术架构,还关系到许可证合规性和部署灵活性。

其实做技术选型这事儿,跟选择人生道路有点像。你选了一条路,就得一直走下去,想换路的时候,成本可就大了。

在 AI 辅助编程的时代,浏览器端的代码编辑能力变得越来越重要。用户期望在 AI 助手分析完代码后,能够立即在同一个浏览器会话中打开编辑器进行修改,无需切换应用。这种无缝的体验,怎么说呢,就像你想的时候,它就在那里——只是有时候它偏偏不在。

然而,在实现这个功能时,开发者面临一个关键的技术选型:是使用 VSCode 官方的 code serve-web 功能,还是采用社区驱动的 code-server 方案?

这两个方案各有优劣,选择错了可能会在后期带来不少麻烦。比如许可证问题——等到产品上线了才发现许可证不合规,那可就晚了。这跟谈恋爱有点像,一开始没想清楚,到头来才发现两个人的价值观根本不合,那付出的代价就大了。再比如部署方式——某个方案在开发环境跑得好好的,一上容器就各种问题,这种坑谁也不想踩,毕竟踩坑踩多了,人也就麻了。

本文分享的方案来自我们在 HagiCode 项目中的实践经验。HagiCode 是一个 AI 驱动的代码助手,在实现浏览器端代码编辑能力时,我们深入研究了这两个方案,最终在架构设计中同时支持两者,但默认优先选择 code-server。

项目地址:github.com/HagiCode-org/site

这是两个方案最根本的区别,也是我们在选型时最先考虑的因素。毕竟选型这事儿,第一步就要想清楚法律风险,不然以后出事了,怪谁呢?

code-server

  • MIT 许可证,完全开源
  • 由 Coder.com 公司维护,社区活跃
  • 可以自由商用、修改和分发
  • 无使用场景限制

VSCode code serve-web

  • 属于 Microsoft VSCode 产品的一部分
  • 使用 Microsoft 的许可证(VS Code 的许可证有商业使用限制)
  • 主要面向个人开发者使用
  • 企业级部署可能需要额外的商业授权考虑

从许可证角度看,code-server 对商业项目更友好。这一点在产品规划阶段就要想清楚,不然等到规模上来了再去迁移,那成本可就大了。毕竟迁移这事儿,说起来容易,做起来难,谁经历过谁知道。

许可证问题解决后,接下来就是部署方式。这直接影响到你的运维成本和架构设计,也间接影响着你每天的心情——部署越简单,心情越好,这道理大家都懂。

code-server

  • 独立的 Node.js 应用,可单独部署
  • 支持多种运行时来源:
    • 直接指定可执行文件路径
    • 系统 PATH 查找
    • NVM Node.js 22.x 环境自动检测
  • 服务器上无需安装 VSCode 桌面版
  • 容器化部署更简单

VSCode code serve-web

  • 必须依赖本地安装的 VSCode CLI
  • 需要本机有可用的 code 命令
  • 系统会过滤掉 VS Code Remote CLI 包装器
  • 主要设计用于本地开发场景

code-server 更适合服务器/容器部署场景。如果你的产品需要跑在 Docker 里,或者用户环境没有 VSCode,那选 code-server 就对了。毕竟简单就是美,复杂了容易出问题,出了问题还得修,修完了还可能引入新问题,这无穷无尽的循环,谁愿意经历呢?

两个方案在功能参数上也有一些差异,虽然不大,但在实际使用中可能会带来一些麻烦。这些细节就像生活中的小摩擦,不多,但多了就让人烦。

特性code-servercode serve-web
公开基路径/ (可配置)/vscode-server (固定)
认证方式--auth 参数,支持多种模式--connection-token / --without-connection-token
数据目录{DataDir}/code-server{DataDir}/vscode-serve-web
遥测默认禁用 --disable-telemetry依赖 VSCode 设置
更新检查可禁用 --disable-update-check依赖 VSCode 设置

这些差异在集成时需要特别注意。比如 URL 路径的不同,意味着前端代码需要做针对性处理。做开发的都知道,这种细节处理起来最费时间,但也没办法,不做就跑不通。

在实现编辑器切换功能时,可用性检测的逻辑也有所不同。这种差异就像人与人之间的相处方式,有的人喜欢直来直去,有的人喜欢含蓄委婉。

code-server

  • 始终作为可见实现返回
  • 即使不可用也会显示,提示 install-required 状态
  • 支持自动检测 NVM Node.js 22.x 环境

code serve-web

  • 只有检测到本机 code CLI 时才可见
  • 如果不可用,前端会自动隐藏此选项
  • 依赖本地 VSCode 安装状态

这种差异直接影响用户体验。code-server 的方式更透明,用户知道有这个选项,只是还没安装;code serve-web 的方式更隐蔽,用户可能都不知道还有这个选择。哪种方式更好?这得看产品定位了,毕竟用户体验这事儿,没有标准答案,只有合适不合适。

经过深入分析,HagiCode 项目采用了双实现架构,在架构层面同时支持两种方案。这倒不是我们技术选型困难症,而是真的有实际需求。毕竟在技术世界里,没有什么绝对正确的选择,只有最适合自己的选择。

// 默认活动实现是 code-server
// 如果保存了显式的 activeImplementation,优先尝试该实现
// 如果所请求实现不可用,解析器会尝试另一个实现
// 如果发生回退,返回 fallbackReason

我们默认选择 code-server,主要考虑是许可证和部署灵活性。但对于有本地 VSCode 环境的用户,code serve-web 也是一个不错的选择。毕竟给用户多一种选择,总归是好的,强迫别人接受单一方案,这事儿我干不出来。

CodeServerImplementationResolver 统一负责:

  • 启动预热时的实现选择
  • 状态读取时的实现选择
  • 项目打开时的实现选择
  • Vault 打开时的实现选择

这种设计让系统可以灵活应对不同场景,用户可以根据自己的环境选择最合适的实现。灵活设计这事儿,前期花的时间多一点,但后期省心,毕竟谁也不想到处改代码。

// localCodeAvailable=false 时,不显示 code serve-web
// localCodeAvailable=true 时,显示 code serve-web 配置

前端根据环境自动显示可用选项,避免用户看到无法使用的功能而困惑。用户困惑了,就来问你,问多了你也烦,烦多了就想改代码,改代码又可能引入 bug,这恶性循环,谁爱经历谁经历。

说了这么多理论,实际部署时要注意什么呢?其实理论说得再好,落地不行也没用,毕竟实践是检验真理的唯一标准。

对于容器化部署,code-server 是更优选择:

# 直接使用 code-server 官方镜像
FROM codercom/code-server:latest
# 或者通过 npm 安装
RUN npm install -g code-server

这样一层就搞定,不需要额外安装 VSCode。简单就是好,复杂了容易出错,这道理放哪儿都适用。

code-server 配置

{
"vscodeServer": {
"enabled": true,
"activeImplementation": "code-server",
"codeServer": {
"host": "0.0.0.0",
"port": 8080,
"executablePath": "",
"authMode": "none"
}
}
}

code serve-web 配置

{
"vscodeServer": {
"enabled": true,
"activeImplementation": "serve-web",
"serveWeb": {
"host": "0.0.0.0",
"port": 8080,
"executablePath": "/usr/local/bin/code"
}
}
}

配置这事儿,第一次麻烦点,配好了后面就省心了。就像生活一样,前期投入多一点,后面日子就好过一点。

code-server

http://localhost:8080/?folder=/path/to/project&vscode-lang=zh-CN

code serve-web

http://localhost:8080/vscode-server/?folder=/path/to/project&tkn=xxx&vscode-lang=zh-CN

注意路径和参数的差异,集成时需要分别处理。细节决定成败,这话一点都不假,少一个参数,可能就打不开页面。

系统支持运行时切换,切换时会自动停止旧实现:

// VsCodeServerManager 自动处理互斥
// 切换 activeImplementation 时,旧实现不会继续后台保活

这种设计让用户可以随时尝试不同实现,找到最适合自己的方案。毕竟适合自己的才是最好的,别人的建议仅供参考,最终还得自己试。

const { settings, runtime } = await getVsCodeServerSettings();
// runtime.activeImplementation: "code-server" | "serve-web"
// runtime.fallbackReason: 切换原因
// runtime.status: "running" | "starting" | "stopped" | "unhealthy"

状态可见,心里才有数。用户在遇到问题时可以快速定位是服务端问题还是自己操作问题。不知道状态的时候,人就容易慌,慌了就容易做出错误判断,这链条一旦启动,就停不下来了。

对比维度code-servercode serve-web推荐
许可证MIT(商用友好)Microsoft(有限制)code-server
部署灵活性独立部署依赖本地 VSCodecode-server
服务器适用性专为服务器设计主要面向本地开发code-server
容器化原生支持需要安装 VSCodecode-server
功能完整性接近桌面版官方完整版code serve-web
维护活跃度社区活跃Microsoft 官方各有优势

推荐策略:优先使用 code-server,在需要完整官方功能且具备本地 VSCode 环境时考虑 code serve-web。

本文分享的方案是 HagiCode 在实际开发中总结出来的。如果你觉得这套方案有价值,说明我们的工程实践还不错——那么 HagiCode 本身也值得关注一下。毕竟分享这事儿,有来有往才有趣,只有输出没有输入,总归不是长久之计。


如果本文对你有帮助:

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

在浏览器中快速编辑代码:VSCode Web 集成实践

在浏览器中快速编辑代码:VSCode Web 集成实践

Section titled “在浏览器中快速编辑代码:VSCode Web 集成实践”

AI 分析完代码后,如何立即在浏览器中打开编辑器进行修改?本文分享 HagiCode 项目中集成 code-server 的实践经验,实现 AI 助手与代码编辑体验的无缝连接。

在 AI 辅助编程的时代,开发者经常需要快速查看和编辑代码。传统的开发流程是:在桌面端 IDE 中打开项目,定位文件,编辑,保存。只是这个流程在某些场景下,总觉得哪里不太对劲。

场景一:远程开发。当使用 HagiCode 这样的 AI 助手时,后端可能运行在远程服务器或容器中,本地无法直接访问项目文件。每次要查看或修改代码,都需要通过 SSH 或其他方式连接,体验割裂。这感觉就像你想见一个人,却隔着一层厚厚的玻璃,能看见却摸不着。

场景二:快速预览。AI 助手分析代码后,用户可能只是想快速浏览某个文件或做小幅修改。启动完整的桌面 IDE 显得重量级,浏览器内的轻量级编辑器更符合”快速查看”的需求。毕竟,谁愿意为了看一眼就大动干戈呢?

场景三:跨设备协作。在不同设备上工作时,浏览器中的编辑器提供了统一的访问入口,无需每台设备都配置开发环境。这倒也省事,毕竟人生苦短,何必重复劳动。

为了解决这些痛点,我们在 HagiCode 项目中集成了 VSCode Web。让 AI 助手与代码编辑体验无缝连接——AI 分析完代码后,用户可以立即在同一个浏览器会话中打开编辑器进行修改,无需切换应用。这种体验,怎么说呢,就像你想的时候,它就在那里。

本文分享的方案来自我们在 HagiCode 项目中的实践经验。HagiCode 是一个 AI 驱动的代码助手,旨在通过自然语言交互提升开发效率。在开发过程中,我们发现用户经常需要在 AI 分析和代码编辑之间快速切换,这促使我们探索如何将编辑器直接集成到浏览器中。

项目地址:github.com/HagiCode-org/site

技术选型:为什么是 code-server?

Section titled “技术选型:为什么是 code-server?”

在众多 VSCode Web 解决方案中,我们选择了 code-server。这个选择,其实也有几个考量:

功能完整。code-server 是 VSCode 的 Web 版本,支持桌面版的大部分功能,包括扩展系统、智能提示、调试等。这意味着用户在浏览器中也能获得接近桌面版的编辑体验。毕竟,谁又愿意在功能上妥协呢?

部署灵活。code-server 可以作为独立服务运行,也支持 Docker 容器化部署,与 HagiCode 的架构契合。我们的后端用 C# 编写,前端是 React,通过 REST API 与 code-server 服务通信。这就像搭积木,每块都有自己的位置。

认证安全。code-server 内置 connection-token 机制,防止未授权访问。每个会话都有唯一的 token,确保只有授权用户才能访问编辑器。安全感这东西,有了才知道重要。

HagiCode 的 VSCode Web 集成采用前后端分离的架构设计。

前端通过 vscodeServerService.ts 封装了与后端的交互:

// 打开项目
export async function openProjectInCodeServer(
id: string,
currentInterfaceLanguage?: string,
): Promise<VsCodeServerLaunchResponseDto>
// 打开 vault
export async function openVaultInCodeServer(
id: string,
path?: string,
currentInterfaceLanguage?: string,
): Promise<VsCodeServerLaunchResponseDto>

这两个方法的区别在于:openProjectInCodeServer 用于打开整个项目,而 openVaultInCodeServer 用于打开 Vault 的特定路径。对于 MonoSpecs 多仓库项目,系统会自动创建工作区文件。分工明确,各自做好自己的事,这就够了。

后端的 VaultAppService.cs 实现了核心逻辑:

public async Task<VsCodeServerLaunchResponseDto> OpenInCodeServerAsync(
string id,
string? relativePath = null,
string? currentInterfaceLanguage = null,
CancellationToken cancellationToken = default)
{
// 1. 获取设置并检查是否启用
var settings = await _vsCodeServerSettingsService.GetResolvedSettingsAsync(cancellationToken);
if (!settings.Enabled) {
throw new BusinessException(VsCodeServerErrorCodes.Disabled, "VSCode Server is disabled.");
}
// 2. 获取 vault 并解析启动目录
var vault = await RequireVaultAsync(id, cancellationToken);
var launchDirectory = ResolveLaunchDirectory(vault, relativePath);
// 3. 确保 code-server 运行并获取运行时信息
var runtime = await _vsCodeServerManager.EnsureStartedAsync(settings, cancellationToken);
// 4. 解析语言设置
var language = _vsCodeServerSettingsService.ResolveLaunchLanguage(
settings.Language,
currentInterfaceLanguage);
// 5. 构建启动 URL
return new VsCodeServerLaunchResponseDto {
LaunchUrl = AppendQueryString(runtime.BaseUrl, new Dictionary<string, string?> {
["folder"] = launchDirectory,
["tkn"] = runtime.ConnectionToken,
["vscode-lang"] = language,
}),
ConnectionToken = runtime.ConnectionToken,
OpenMode = "folder",
Runtime = VsCodeServerSettingsService.MapRuntime(
await _vsCodeServerManager.GetRuntimeSnapshotAsync(cancellationToken)),
};
}

这个方法的职责很清晰:检查设置、解析路径、启动服务、构建 URL。其中 ResolveLaunchDirectory 方法会进行路径安全检查,防止路径穿越攻击。代码就像诗,每一行都有自己的韵律。

后端通过 VsCodeServerManager 管理 code-server 进程:

  • 检查进程状态
  • 自动启动已停止的服务
  • 返回运行时快照(端口、进程 ID、启动时间等)

这种设计让系统可以自动处理 code-server 的生命周期,用户无需手动管理服务进程。毕竟,人生已经够复杂了,能自动化的就自动化吧。

HagiCode 支持多语言界面,code-server 也需要跟随这个设置。系统支持三种语言模式:

  • follow:跟随当前界面语言
  • zh-CN:固定中文
  • en-US:固定英文

通过 URL 参数 vscode-lang 传递给 code-server,确保编辑器语言与 HagiCode 界面保持一致。语言这东西,统一了才舒服。

对于 MonoSpecs 项目(包含多个子仓库的 mono-repo),系统会自动创建 .code-workspace 文件:

private async Task<string> CreateWorkspaceFileAsync(Project project, Guid projectId)
{
var folders = await ResolveWorkspaceFoldersAsync(project.Path);
var workspaceDocument = new {
folders = folders.Select(path => new { path }).ToArray(),
};
// 生成 workspace 文件...
}

这样可以在一个 code-server 实例中同时编辑多个子仓库,对于大型 mono-repo 项目非常实用。多个仓库在一个窗口里,就像多个故事在同一本书里。

HagiCode 前端使用 React + TypeScript,集成 code-server 的步骤倒也不复杂。

在项目卡片中添加 Code Server 按钮:

QuickActionsZone.tsx
<Button
size="sm"
variant="default"
onClick={() => onAction({ type: 'open-code-server' })}
>
<Globe className="h-3 w-3 mr-1" />
<span className="text-xs">{t('project.openCodeServer')}</span>
</Button>

这个按钮会触发打开动作,调用后端 API 获取启动 URL。一个按钮,承载一个动作,简单直接。

const handleAction = async (action: ProjectAction) => {
if (action.type === 'open-code-server') {
const response = await openProjectInCodeServer(project.id, i18n.language);
window.open(response.launchUrl, '_blank', 'noopener,noreferrer');
}
};

使用 window.open 在新标签页中打开 code-server,noopener,noreferrer 参数确保安全性。安全这东西,再怎么小心都不为过。

在 Vault 列表中添加类似的编辑按钮:

const handleEditVault = async (vault: VaultItemDto) => {
const response = await openVaultInCodeServer(vault.id);
window.open(response.launchUrl, '_blank', 'noopener,noreferrer');
};

项目和 Vault 使用相同的打开方式,保持了交互的一致性。一致性的重要,不亚于功能本身。

code-server 的 URL 格式,其实也有点讲究:

文件夹模式

http://{host}:{port}/?folder={path}&tkn={token}&vscode-lang={lang}

工作区模式

http://{host}:{port}/?workspace={workspacePath}&tkn={token}&vscode-lang={lang}

其中 tkn 是连接 token,每次启动 code-server 时自动生成,确保访问安全。vscode-lang 参数控制编辑器界面语言。这些参数各有各的用处,缺一不可。

用户与 HagiCode 对话,AI 分析项目代码并发现潜在问题。用户点击 “Open in Code Server” 按钮直接在浏览器中打开编辑器,查看问题文件并修复,然后返回 HagiCode 继续对话。整个流程在浏览器中完成,无需切换应用。这种感觉,怎么说呢,就像流水一样顺畅。

用户创建了学习某个开源项目的 Vault,想在 docs/ 目录添加学习笔记。通过 code-server 直接在浏览器中编辑 Markdown 文件,保存后 HagiCode 可以同步读取更新的笔记内容。这对于构建个人知识库非常有用。知识这东西,越积累越有价值。

MonoSpecs 项目包含多个子仓库,code-server 自动创建多文件夹工作区。用户在浏览器中可以同时编辑多个仓库的代码,修改后提交到各自的 Git 仓库。这种工作方式特别适合需要跨仓库修改的场景。多个仓库一起改,就像同时处理多件事情,需要点技巧。

在实现 code-server 集成时,安全性是需要重点关注的问题。毕竟,安全这种事,出了问题就晚了。

connection-token 是随机生成的,不应泄露。建议在 HTTPS 环境下使用,防止 token 被中间人截获。敏感信息,还是保护好为妙。

后端实现了路径穿越检查:

private static string ResolveLaunchDirectory(VaultRegistryEntry vault, string? relativePath)
{
var vaultRoot = EnsureTrailingSeparator(Path.GetFullPath(vault.PhysicalPath));
var combinedPath = Path.GetFullPath(Path.Combine(vaultRoot, relativePath ?? "."));
if (!combinedPath.StartsWith(vaultRoot, StringComparison.OrdinalIgnoreCase))
{
throw new BusinessException(VaultRelativePathTraversalCode, "Relative path traversal detected.");
}
return combinedPath;
}

这段代码确保用户无法通过 ../ 等方式访问 vault 目录之外的文件。边界检查这种事,做总比不做强。

code-server 进程以适当的用户权限运行,避免访问系统敏感文件。建议使用专用用户运行 code-server 服务。权限控制,该有的还是要有。

code-server 会消耗服务器资源,以下是一些优化建议:

  • 监控 CPU/内存使用,必要时调整资源限制
  • 大型项目可能需要增加超时时间
  • 实现会话超时自动清理,释放资源
  • 考虑使用缓存减少重复计算

HagiCode 提供了运行时状态监控 API,前端可以通过 getVsCodeServerSettings() 获取当前状态:

const { settings, runtime } = await getVsCodeServerSettings();
// runtime.status: 'disabled' | 'stopped' | 'starting' | 'running' | 'unhealthy'
// runtime.baseUrl: "http://localhost:8080"
// runtime.processId: 12345

这个设计让用户可以清楚了解 code-server 的健康状态,在出现问题时快速定位。状态可见,心里才有数。

在实现过程中,我们发现了一些影响用户体验的细节,值得特别关注:

首次打开 code-server 可能需要等待启动,这个时间可能从几秒到半分钟不等。建议在前端显示加载状态,让用户知道系统正在处理。等待这事儿,有反馈就好。

浏览器可能会阻止弹窗,需要提示用户手动允许。HagiCode 在首次打开时会显示引导信息,告诉用户如何设置浏览器权限。用户体验,就是在这些细节里体现的。

建议显示运行时状态(启动中/运行中/错误),这样用户在遇到问题时可以知道是服务端问题还是自己操作问题。知道问题出在哪里,至少心里有底。

code-server 的配置倒也不复杂:

{
"vscodeServer": {
"enabled": true,
"host": "0.0.0.0",
"port": 8080,
"language": "follow"
}
}

enabled 控制功能开关,hostport 指定监听地址,language 设置语言模式。这些配置可以通过 UI 界面修改,实时生效。简单的东西,往往最好用。

HagiCode 的 VSCode Web 集成提供了一个优雅的解决方案:让 AI 助手与代码编辑体验无缝连接。通过在浏览器中集成 code-server,用户可以快速响应 AI 的分析结果,在同一个浏览器会话中完成从分析到编辑的完整流程。

这套方案的几个关键优势:一是统一体验,项目和 Vault 使用相同的打开方式;二是多仓库支持,MonoSpecs 项目自动创建工作区;三是安全可控,运行时状态监控和路径安全检查。

本文分享的方案是 HagiCode 在实际开发中总结出来的。如果你觉得这套方案有价值,说明我们的工程实践还不错——那么 HagiCode 本身也值得关注一下。毕竟,好东西值得被更多人看见。

  • HagiCode GitHub:github.com/HagiCode-org/site
  • HagiCode 官网:hagicode.com
  • code-server 官网:coder.com/code-server
  • 相关代码文件:
    • repos/web/src/services/vscodeServerService.ts
    • repos/hagicode-core/src/PCode.Application/Services/VaultAppService.cs
    • repos/hagicode-core/src/PCode.Application/ProjectAppService.VsCodeServer.cs

如果本文对你有帮助:

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

用 Vault 系统构建 AI 时代的跨项目知识库

用 Vault 系统构建 AI 时代的跨项目知识库

Section titled “用 Vault 系统构建 AI 时代的跨项目知识库”

临摹项目学习法正在成为主流,只是学习资料分散、上下文断裂的痛点让 AI 助手难以发挥最大价值。本文介绍 HagiCode 项目的 Vault 系统设计——通过统一的存储抽象层,让 AI 助手能够理解和访问所有学习资源,实现真正的跨项目知识复用。

其实,在 AI 时代,我们学习新技术的方式正在悄然改变。传统的读书、看视频固然重要,但”临摹项目”——深入研究和学习优秀开源项目的代码、架构和设计模式——确实越来越高效。直接运行和修改高质量的开源项目,能让你最快理解真实世界的工程实践。

只是这种方式也带来了新的挑战。

学习资料太分散。笔记可能在 Obsidian 里,代码仓库散落在各个文件夹,AI 助手的对话历史又是一个独立的数据孤岛。每次需要 AI 帮助分析某个项目时,都得手动复制代码片段、整理上下文,过程相当繁琐。

上下文经常断掉。AI 助手无法直接访问本地学习资源,每次对话都得重新提供背景信息。临摹的代码仓库更新快,手动同步容易出错。更糟的是,多个学习项目之间难以共享知识——在 A 项目中学到的设计模式,AI 处理 B 项目时完全不知道。

这些问题的本质是”数据孤岛”。如果能有一个统一的存储抽象层,让 AI 助手能够理解和访问所有学习资源,问题就迎刃而解了。

为了解决这些痛点,我们在开发 HagiCode 时做了一个关键的设计决策:构建一个 Vault 系统作为统一的知识存储抽象层。这个决定带来的变化,可能比想象的还要大——稍后具体说。

本文分享的方案来自在 HagiCode 项目中的实践经验。HagiCode 是一个基于 OpenSpec 工作流的 AI 代码助手,它的核心理念是让 AI 不仅会”说”,更会”做”——能够直接操作代码仓库、执行命令、运行测试。GitHub:github.com/HagiCode-org/site

在开发过程中,我们发现 AI 助手需要频繁访问用户的各类学习资源:代码仓库、笔记文档、配置文件等。如果每次都要用户手动提供,体验就太糟糕了。这促使设计了 Vault 系统。

HagiCode 的 Vault 系统支持四种类型,分别对应不同的使用场景:

类型用途典型场景
folder通用文件夹类型临时学习资料、草稿
coderef专门用于临摹代码项目系统化学习某个开源项目
obsidian与 Obsidian 笔记软件集成现有笔记库的复用
system-managed系统自动管理项目配置、提示词模板等

其中 coderef 类型是 HagiCode 中最常用的,它为临摹代码项目提供了标准化的目录结构和 AI 可读的元数据描述。为什么要专门设计这个类型?因为临摹一个开源项目不是简单的”下载代码”,需要同时管理代码本身、学习笔记、配置文件等多种内容,coderef 把这些都规范好了。

Vault 的注册表以 JSON 格式持久化存储到文件系统:

_registryFilePath = Path.Combine(absoluteDataDir, "personal-data", "vaults", "registry.json");

这个设计看似简单,实则经过深思熟虑:

简单可靠。JSON 格式人类可读,便于调试和手动修改。当系统出现问题时,可以直接打开文件查看状态,甚至手动修复——这在开发阶段特别有用。

降低依赖。文件系统存储避免了数据库的复杂性。不需要额外安装和配置数据库服务,降低了系统复杂度和维护成本。

并发安全。使用 SemaphoreSlim 确保多线程安全。在 AI 代码助手的场景下,可能会有多个操作同时访问 vault 注册表,需要做好并发控制。

系统的核心能力在于能够自动将 vault 信息注入到 AI 提案的上下文中:

export function buildTargetVaultsText(
vaults: VaultForText[],
template: VaultPromptTemplate = DEFAULT_VAULT_PROMPT_TEMPLATE,
): string {
const readOnlyVaults = vaults.filter((vault) => vault.accessType === 'read');
const editableVaults = vaults.filter((vault) => vault.accessType === 'write');
const sections = [
buildVaultSection(readOnlyVaults, template.reference),
buildVaultSection(editableVaults, template.editable),
].filter(Boolean);
return `\n\n### ${template.heading}\n\n${sections.join('\n')}`;
}

这样 AI 助手就能自动理解可用的学习资源,无需用户每次手动提供上下文。这个设计让 HagiCode 的体验变得特别自然——告诉 AI “帮我分析 React 的并发渲染”,AI 就能自动找到之前注册的 React 学习 vault,而不是一遍遍贴代码。

系统将 vault 分为两种访问类型:

  • reference(只读):AI 仅用于分析和理解,不能修改内容
  • editable(可编辑):AI 可以根据任务需要修改内容

这种区分让 AI 知道哪些内容是”只读参考”,哪些是”可以动手改的”,避免了误操作风险。比如注册了一个开源项目的 vault 作为学习材料,肯定不希望 AI 随手修改里面的代码——那就标记为 reference。但如果是自己的项目 vault,就可以标记为 editable,让 AI 帮着改代码。

对于 coderef 类型的 vault,系统提供了一套标准化的目录结构:

my-coderef-vault/
├── index.yaml # vault 元数据描述
├── AGENTS.md # AI 助手的操作指南
├── docs/ # 存放学习笔记和文档
└── repos/ # 通过 Git 子模块管理临摹的代码仓库

这个结构的设计理念是什么?

docs/ 存放学习笔记,用 Markdown 格式记录对代码的理解、架构分析、踩坑经验。这些笔记不仅自己看,AI 也能读懂——在处理相关任务时会自动参考。

repos/ 通过 Git 子模块管理临摹的仓库,而不是直接复制代码。这样做有两个好处:一是保持与上游同步,一个 git submodule update 就能拿到最新代码;二是节省空间,多个 vault 可以引用同一个仓库的不同版本。

index.yaml 包含 vault 的元数据,让 AI 助手快速理解用途和内容。相当于给 vault 写了个”自我介绍”,AI 第一次见到就知道这是干嘛的。

AGENTS.md 是专门写给 AI 助手看的指南,说明如何处理 vault 中的内容。可以在这里告诉 AI:“分析这个项目时重点关注性能优化相关的代码”或者”不要修改测试文件”。

创建一个 CodeRef vault 很简单:

const createCodeRefVault = async () => {
const response = await VaultService.postApiVaults({
requestBody: {
name: "React Learning Vault",
type: "coderef",
physicalPath: "/Users/developer/vaults/react-learning",
gitUrl: "https://github.com/facebook/react.git"
}
});
// 系统会自动:
// 1. 克隆 React 仓库到 vault/repos/react
// 2. 创建 docs/ 目录用于笔记
// 3. 生成 index.yaml 元数据
// 4. 创建 AGENTS.md 指南文件
return response;
};

然后在 AI 提案中引用这个 vault:

const proposal = composeProposalChiefComplaint({
chiefComplaint: "帮我分析 React 的并发渲染机制",
repositories: [
{ id: "react", gitUrl: "https://github.com/facebook/react.git" }
],
vaults: [
{
id: "react-learning",
name: "React Learning Vault",
type: "coderef",
physicalPath: "/vaults/react-learning",
accessType: "read" // AI 只能读取,不能修改
}
],
quickRequestText: "重点关注 fiber 架构和 scheduler 实现"
});

场景一:系统化学习开源项目

创建一个 CodeRef vault,通过 Git 子模块管理目标仓库,在 docs/ 目录记录学习笔记。AI 可以同时访问代码和笔记,提供更精准的分析。在学习某个模块时写的笔记,AI 后续分析相关代码时会自动参考——就像有个”助手”记住了之前的思考。

场景二:复用 Obsidian 笔记库

如果已经在用 Obsidian 管理笔记,直接把现有的 vault 注册到 HagiCode 中就行。AI 可以直接访问知识库,无需手动复制粘贴。这个功能特别实用,很多人都有积累多年的笔记库,接入之后 AI 就能”读”懂知识体系。

场景三:跨项目知识复用

多个 AI 提案可以引用同一个 vault,实现知识的跨项目复用。比如创建了一个”设计模式学习 vault”,里面记录了各种设计模式的笔记和代码示例。无论在分析哪个项目,AI 都能参考这个 vault 中的内容——知识不用重复积累。

系统严格校验路径,防止路径穿越攻击:

private static string ResolveFilePath(string vaultRoot, string relativePath)
{
var rootPath = EnsureTrailingSeparator(Path.GetFullPath(vaultRoot));
var combinedPath = Path.GetFullPath(Path.Combine(rootPath, relativePath));
if (!combinedPath.StartsWith(rootPath, StringComparison.OrdinalIgnoreCase))
{
throw new BusinessException(VaultRelativePathTraversalCode,
"Vault file paths must stay inside the registered vault root.");
}
return combinedPath;
}

这确保了所有文件操作都在 vault 的根目录范围内,防止恶意路径访问。安全这块不能马虎,AI 助手要操作文件系统,必须把边界划清楚。

使用 HagiCode Vault 系统时,有几点需要特别注意:

  1. 路径安全:确保自定义路径在允许的范围内,否则系统会拒绝操作。这是为了防止误操作和潜在的安全风险。

  2. Git 子模块管理:CodeRef vault 推荐使用 Git 子模块而非直接复制代码。好处前面说过——保持同步、节省空间。只是子模块有自己的使用方式,第一次使用可能需要熟悉一下。

  3. 文件预览限制:系统限制文件大小(256KB)和数量(500个),超大文件需分批处理。这个限制是为了性能考虑,如果遇到超大文件,可以手动拆分或者用其他方式处理。

  4. 诊断信息:创建 vault 会返回诊断信息,失败时可用于调试。遇到问题时先看诊断信息,大部分情况下都能找到线索。

HagiCode 的 Vault 系统本质上是在解决一个简单但深刻的问题:如何让 AI 助手理解和使用本地知识资源。

通过统一的存储抽象层、标准化的目录结构、自动化的上下文注入,实现了”一次注册,处处复用”的知识管理方式。创建一个 vault 后,无论是学习笔记、代码仓库还是文档资料,AI 都能自动访问和理解。

这种设计带来的体验提升是明显的。不再需要手动复制代码片段、重复解释背景信息——AI 助手就像一个真正了解项目情况的同事,能够基于已有知识提供更有价值的帮助。

本文分享的 Vault 系统,正是开发 HagiCode 过程中实际踩坑、实际优化出来的方案。如果觉得这套设计有价值,说明工程实力还不错——那么 HagiCode 本身也值得关注一下。

如果本文对你有帮助:

公测已开始,欢迎安装体验。

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

在 Web 界面直接编辑 DESIGN.md:从思路到实现

在 Web 界面直接编辑 DESIGN.md:从思路到实现

Section titled “在 Web 界面直接编辑 DESIGN.md:从思路到实现”

在 MonoSpecs 项目管理系统中,DESIGN.md 承载着项目的架构设计和技术决策。但传统的编辑方式要求用户必须切换到外部编辑器,这种割裂的流程,怎么说呢,就像在读一首诗的时候突然被打断了——灵感没了,心情也没了。本文分享了我们在 HagiCode 项目中实践的解决方案:在 Web 界面直接编辑 DESIGN.md,并支持从线上设计站点导入模板。毕竟,谁不喜欢一气呵成的感觉呢?

DESIGN.md 作为项目设计文档的核心载体,承载着架构设计、技术决策和实现指导等关键信息。然而,传统的编辑方式要求用户必须切换到外部编辑器(如 VS Code),手动定位物理路径后再进行编辑。这过程说起来也不算复杂,只是反复几次之后,人也就乏了。

具体问题体现在以下几个方面:

  • 流程割裂:用户需要在 Web 管理界面和本地编辑器之间频繁切换,破坏了工作流连贯性——就像听歌的时候突然断网了,节奏全乱了。
  • 复用困难:设计站点已经发布了丰富的设计模板库,但无法直接集成到项目编辑流程中。明明有好东西,就是用不上,这感觉确实有点遗憾。
  • 体验缺失:缺少”预览-选择-导入”的闭环,用户必须手动复制粘贴,增加了出错风险。手动操作的次数多了,出错的机会自然也多了。
  • 协作障碍:设计文档与代码实现的同步维护变得高摩擦,阻碍团队协作效率。团队协作本就不易,何必再添这些阻力呢?

为了解决这些痛点,我们决定在 Web 界面中实现 DESIGN.md 的直接编辑能力,并支持从线上设计站点一键导入模板。这也不算是什么惊天动地的决策,只是想让开发体验更顺畅一点罢了。

本文分享的方案来自我们在 HagiCode 项目中的实践经验。HagiCode 是一个 AI 驱动的代码助手项目,在开发过程中,我们需要频繁维护项目的设计文档。为了让团队能够更高效地协作,我们探索并实现了这套在线编辑和导入方案。其实也没什么特别的,只是遇到了问题,想办法解决而已。

该解决方案采用前后端分离的同源代理架构,主要由以下几个层次构成。这种架构的设计,说起来也不过是”各司其职”四个字罢了:

1. 前端编辑器层

repos/web/src/components/project/DesignMdManagementDrawer.tsx
// 核心组件:DesignMdManagementDrawer
// 功能:承载编辑、保存、版本冲突检测、导入流程

2. 后端服务层

ProjectAppService.DesignMd
// 位置:repos/hagicode-core/src/PCode.Application/ProjectAppService.DesignMd.cs
// 功能:路径解析、文件读写、版本管理

3. 同源代理层

ProjectAppService.DesignMdSiteIndex
// 位置:repos/hagicode-core/src/PCode.Application/ProjectAppService.DesignMdSiteIndex.cs
// 功能:代理设计站点资源、预览图缓存、安全校验

采用单一全局抽屉而非局部弹层,通过 layoutSlice 管理状态,实现了跨视图(classic/kanban)的一致体验。这种方式确保用户无论在哪个视图中打开编辑器,都能获得统一的交互体验。毕竟,一致的体验能让用户感觉更自在,不会因为换个视图就迷失方向。

将 DESIGN.md 相关接口挂在 ProjectController 下,复用现有项目权限边界,避免了新增独立控制器的复杂度。这样设计的好处是权限管理更清晰,也符合 RESTful 的资源组织原则。有时候,复用比重新创建更有意义,不是吗?

基于文件系统 LastWriteTimeUtc 派生 opaque version,实现了轻量级的乐观并发控制。当多个用户同时编辑同一文件时,系统能够检测到冲突并提示用户刷新。这种设计既不阻塞用户的编辑操作,又能保证数据的一致性——就像人际交往中的边界感,既不过分疏离,也不越界。

通过 IHttpClientFactory 代理外部设计站点资源,避免了跨域问题和 SSRF 风险。这种设计既保证了安全性,又简化了前端调用。安全这件事,做再多也不为过,毕竟数据安全就像健康,失去了才后悔就晚了。

后端主要负责路径解析、文件读写和版本管理。这些工作虽然基础,但必不可少,就像房子的地基一样:

// 路径解析与安全校验
private Task<string> ResolveDesignDocumentDirectoryAsync(string projectPath, string? repositoryPath)
{
if (string.IsNullOrWhiteSpace(repositoryPath))
{
return Task.FromResult(Path.GetFullPath(projectPath));
}
return ValidateSubPathAsync(projectPath, repositoryPath);
}
// 版本号生成(基于文件系统时间戳和大小)
private static string BuildDesignDocumentVersion(string path)
{
var fileInfo = new FileInfo(path);
fileInfo.Refresh();
return string.Create(
CultureInfo.InvariantCulture,
$"{fileInfo.LastWriteTimeUtc.Ticks:x}-{fileInfo.Length:x}");
}

版本号的设计其实也挺有意思的,我们用文件的最后修改时间和大小来生成一个唯一的版本标识。这样既轻量又可靠,不需要维护额外的版本数据库。简单的东西,往往更有效,不是吗?

前端实现了脏状态检测和保存逻辑。这种设计让用户随时知道自己的修改是否已保存,减少”万一丢失了怎么办”的焦虑:

// 脏状态检测与保存逻辑
const [draft, setDraft] = useState('');
const [savedDraft, setSavedDraft] = useState('');
const isDirty = draft !== savedDraft;
const handleSave = useCallback(async () => {
const result = await saveProjectDesignMdDocument({
...activeTarget,
content: draft,
expectedVersion: document.version, // 乐观并发控制
});
setSavedDraft(draft); // 更新已保存状态
}, [activeTarget, document, draft]);

这个实现中,我们维护了两个状态:draft 是当前编辑的内容,savedDraft 是已保存的内容。通过比较两者来判断是否有未保存的修改。这种设计虽然简单,但能给人安心感,毕竟谁愿意辛辛苦苦写的东西突然消失呢?

repos/index/
└── src/data/public/design.json # 设计模板索引
repos/awesome-design-md-site/
├── vendor/awesome-design-md/ # 上游设计模板
│ └── design-md/
│ ├── clickhouse/
│ │ └── DESIGN.md
│ ├── linear/
│ │ └── DESIGN.md
│ └── ...
└── src/lib/content/
└── awesomeDesignCatalog.ts # 内容管线

设计站点的索引文件定义了所有可用的模板。有了这个索引,用户就能像在餐厅点菜一样,轻松选择自己想要的模板:

{
"entries": [
{
"slug": "linear.app",
"title": "Linear Inspired Design System",
"summary": "AI 产品 / 深色感",
"detailUrl": "/designs/linear.app/",
"designDownloadUrl": "/designs/linear.app/DESIGN.md",
"previewLightImageUrl": "...",
"previewDarkImageUrl": "..."
}
]
}

每个条目包含了模板的基本信息和下载链接。后端会从这个索引中读取可用的模板列表,然后展示给用户选择。这种设计让选择变得直观,而不是在黑暗中摸索。

为了保证安全性,后端对设计站点的访问做了严格的校验。安全这件事,再怎么小心也不为过:

// 安全的 slug 校验
private static readonly Regex SafeDesignSiteSlugRegex =
new("^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,127})$", RegexOptions.Compiled);
private static string NormalizeDesignSiteSlug(string slug)
{
var normalizedSlug = slug?.Trim() ?? string.Empty;
if (!IsSafeDesignSiteSlug(normalizedSlug))
{
throw new BusinessException(
ProjectDesignSiteIndexErrorCodes.InvalidSlug,
"Design site slug must be a single safe path segment.");
}
return normalizedSlug;
}
// 预览图缓存(OS 临时目录)
private static string ComputePreviewCacheKey(string slug, string theme, string previewUrl)
{
var raw = $"{slug}|{theme}|{previewUrl}";
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(raw));
return Convert.ToHexString(bytes).ToLowerInvariant();
}

这里我们做了两件事:一是用正则表达式严格校验 slug 的格式,防止路径遍历攻击;二是对预览图进行缓存,减少对外部站点的请求压力。前者是防护,后者是优化,缺一不可罢了。

// 1. 打开导入抽屉
const handleRequestImportDrawer = useCallback(() => {
setIsImportDrawerOpen(true);
}, []);
// 2. 选择并导入
const handleImportRequest = useCallback((entry) => {
if (isDirty) {
setPendingImportEntry(entry);
setConfirmMode('import'); // 覆盖确认
return;
}
void executeImport(entry);
}, [isDirty]);
// 3. 执行导入
const executeImport = useCallback(async (entry) => {
const result = await getProjectDesignMdSiteImportDocument(
activeTarget.projectId,
entry.slug
);
setDraft(result.content); // 只替换编辑器文本,不自动保存
setIsImportDrawerOpen(false);
}, [activeTarget?.projectId]);

导入流程的设计遵循了”用户确认”的原则:导入后只更新编辑器内容,不会自动保存。用户可以检查导入的内容,确认无误后再手动保存。毕竟,用户对自己写的东西应该有最终决定权,不是吗?

场景 1:项目根目录 DESIGN.md 创建

Section titled “场景 1:项目根目录 DESIGN.md 创建”

当 DESIGN.md 不存在时,后端返回虚拟文档状态。这种设计让前端不需要特殊处理”文件不存在”的情况,统一的 API 接口简化了代码逻辑:

return new ProjectDesignDocumentDto
{
Path = targetPath,
Exists = false, // 虚拟文档状态
Content = string.Empty,
Version = null
};
// 首次保存时自动创建文件
public async Task<SaveProjectDesignDocumentResultDto> SaveDesignDocumentAsync(...)
{
Directory.CreateDirectory(targetDirectory);
await File.WriteAllTextAsync(targetPath, input.Content);
return new SaveProjectDesignDocumentResultDto { Created = !exists };
}

这种设计的好处是前端不需要特殊处理”文件不存在”的情况,统一的 API 接口简化了代码逻辑。有时候,把复杂性隐藏在后端,前端就能更轻松地专注于用户体验。

用户在导入抽屉中选择 “Linear” 设计模板后,系统会通过后端代理获取 DESIGN.md 内容。整个流程对用户来说是透明的,他们只需要选择模板,系统会自动处理所有的网络请求和数据转换:

// 1. 系统通过后端代理获取 DESIGN.md 内容
GET /api/project/{id}/design-md/site-index/linear.app
// 2. 后端验证 slug 并从上游获取内容
var entry = FindDesignSiteEntry(catalog, "linear.app");
using var upstreamResponse = await httpClient.SendAsync(request);
var content = await upstreamResponse.Content.ReadAsStringAsync();
// 3. 前端替换编辑器文本
setDraft(result.content);
// 用户检查后手动保存到磁盘

整个流程对用户来说是透明的,他们只需要选择模板,系统会自动处理所有的网络请求和数据转换。用户不需要关心背后的复杂性,这就是我们追求的体验——简单,但强大。

当多个用户同时编辑同一 DESIGN.md 时,系统会检测到版本冲突。这种乐观并发控制机制确保了数据的一致性,同时又不会阻塞用户的编辑操作:

if (!string.Equals(currentVersion, expectedVersion, StringComparison.Ordinal))
{
throw new BusinessException(
ProjectDesignDocumentErrorCodes.VersionConflict,
$"DESIGN.md at '{targetPath}' changed on disk.");
}

前端会捕获这个错误并提示用户:

// 前端提示用户刷新并重试
<Alert>
<AlertTitle>版本冲突</AlertTitle>
<AlertDescription>
文件已被其他进程修改。请刷新最新版本后重试。
</AlertDescription>
</Alert>

这种乐观并发控制机制确保了数据的一致性,同时又不会阻塞用户的编辑操作。冲突不可避免,但至少可以让用户知道发生了什么,而不是默默丢失修改。

始终校验 repositoryPath,防止路径遍历攻击。安全这种事,做再多也不为过:

// 始终校验 repositoryPath,防止路径遍历攻击
return ValidateSubPathAsync(projectPath, repositoryPath);
// 拒绝 "../", 绝对路径等危险输入

预览图缓存 24 小时,最大 160 个文件。适度的缓存能提升性能,但也不能过度,毕竟平衡才是关键:

// 预览图缓存 24 小时,最大 160 个文件
private static readonly TimeSpan PreviewCacheTtl = TimeSpan.FromHours(24);
private const int PreviewCacheMaxFiles = 160;
// 定期清理过期缓存

上游站点不可用时降级处理。这种优雅降级的设计确保了即使外部依赖不可用,核心编辑功能仍然正常工作。毕竟,不能因为一个外部服务挂了,整个系统就瘫痪了:

// 上游站点不可用时降级处理
try {
const catalog = await getProjectDesignMdSiteImportCatalog(projectId);
} catch (error) {
toast.error(t('project.designMd.siteImport.feedback.catalogLoadFailed'));
// 主编辑抽屉仍然可用
}

这种优雅降级的设计确保了即使外部依赖不可用,核心编辑功能仍然正常工作。系统应该有韧性,而不是一遇到问题就倒下。

导入前确认覆盖,导入后不自动保存。用户应该对自己的操作有控制权,而不是系统自作主张:

// 导入前确认覆盖
if (isDirty) {
setConfirmMode('import');
return;
}
// 导入后不自动保存,由用户确认
setDraft(result.content); // 只更新草稿
// 用户检查后点击 Save 才真正写入磁盘

使用 HTTP 客户端工厂,避免创建过多连接。资源管理这种事,看似不起眼,但做好了能带来意想不到的效果:

// 使用 HTTP 客户端工厂,避免创建过多连接
private const string DesignSiteProxyClientName = "ProjectDesignSiteProxy";
private static readonly TimeSpan DesignSiteProxyTimeout = TimeSpan.FromSeconds(8);
  1. Markdown 增强:当前使用基础 Textarea,可考虑升级为 CodeMirror 以支持语法高亮和快捷键。编辑器的体验好了,写文档的心情也会好一些。
  2. 预览模式:添加 Markdown 实时预览,提升编辑体验。所见即所得,总能给人更多信心。
  3. 差异合并:实现智能合并算法,而非简单的全文替换。冲突是难免的,但至少可以让处理冲突的过程不那么痛苦。
  4. 本地缓存:将 design.json 缓存到数据库,减少对外部站点的依赖。依赖越少,系统越稳定,这是简单的道理。

在 HagiCode 项目中,我们通过前后端协作实现了一套完整的 DESIGN.md 在线编辑和导入方案。这套方案的核心价值在于:

  • 提升效率:无需切换工具,在统一的 Web 界面完成设计文档的编辑和导入。省下的时间,可以做更有意义的事情。
  • 降低门槛:设计模板一键导入,新项目可以快速起步。开始得越容易,坚持下去的可能性就越大。
  • 安全可靠:路径校验、版本冲突检测、优雅降级等机制确保系统稳定运行。稳定是基础,没有稳定,一切都是空谈。
  • 用户体验:全局抽屉、脏状态检测、确认对话框等细节打磨了交互体验。细节决定成败,这句话在用户体验上尤其适用。

这套方案已经在 HagiCode 项目中实际运行,解决了团队在设计文档管理方面的痛点。如果你也在面临类似的问题,希望这篇文章能给你一些启发。其实也没什么高深的理论,只是遇到了问题,想办法解决而已。

如果本文对你有帮助,欢迎来 GitHub 给个 Star,公测已开始,现在安装即可参与体验。毕竟,开源项目最缺的就是反馈和鼓励,如果你觉得有用,不妨让它被更多人看到…


“美的事物或人,不一定要占有,只要她还是美的,自己好好看着她的美就好了。”

DESIGN.md 编辑器也是一样,不一定要多么复杂,只要能帮你高效地完成工作,那就是好的罢了。

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

AI时代如何临摹项目:Vault跨项目持久化存储系统

AI时代如何临摹项目:Vault跨项目持久化存储系统

Section titled “AI时代如何临摹项目:Vault跨项目持久化存储系统”

在AI辅助开发时代,如何让AI助手更好地理解我们的学习资源?HagiCode项目通过Vault系统实现了一个统一的、可被AI理解的知识存储抽象层,让临摹项目的学习效率大幅提升。

在AI时代,开发者学习新技术和架构的方式正在发生深刻变化。“临摹项目”——即深入研究和学习优秀开源项目的代码、架构和设计模式——已经成为一种高效的学习方法。相比传统的读书、看视频,直接阅读和运行高质量的开源项目能让你更快地理解真实世界的工程实践。

只是这种学习方式也面临着不少挑战。

学习资料太分散了。你的笔记可能在Obsidian里,代码仓库散落在各个文件夹,AI助手的对话历史又是一个独立的数据孤岛。当你想让AI帮你分析某个项目时,得手动复制代码片段、整理上下文,这个过程相当繁琐。

其实更麻烦的是上下文断裂。AI助手无法直接访问你的本地学习资源,每次对话都得重新提供背景信息。而且临摹的代码仓库更新很快,手动同步容易出错,多个学习项目之间也难以共享知识。

这些问题本质上都是”数据孤岛”导致的。如果能有一个统一的存储抽象层,让AI助手能够理解和访问你的所有学习资源,问题就迎刃而解了。

本文分享的Vault系统,正是我们在开发 HagiCode 过程中实践出来的解决方案。HagiCode 是一个AI代码助手项目,在我们的日常开发中,经常需要学习和参考各种开源项目。为了让AI助手更好地理解这些学习资源,我们设计了Vault跨项目持久化存储系统。

这套方案已经在HagiCode中经过了实际验证,如果你也面临类似的知识管理难题,希望这些经验能给你一些启发。毕竟,有些坑踩过了,总得留下点什么给后来的人。

Vault系统的核心思想很简单:创建一个统一的、可被AI理解的知识存储抽象层。从实现角度来看,系统具有几个关键特征。

系统支持四种vault类型,分别对应不同的使用场景:

// folder:通用文件夹类型
export const DEFAULT_VAULT_TYPE = 'folder';
// coderef:专门用于临摹代码项目的类型
export const CODEREF_VAULT_TYPE = 'coderef';
// obsidian:与Obsidian笔记软件集成
export const OBSIDIAN_VAULT_TYPE = 'obsidian';
// system-managed:系统自动管理的vault
export const SYSTEM_MANAGED_VAULT_TYPE = 'system-managed';

其中 coderef 类型是HagiCode中最常用的。它专门为临摹代码项目设计,提供了标准化的目录结构和AI可读的元数据描述。

Vault的注册表以JSON格式持久化存储,确保配置在应用重启后仍然可用:

public class VaultRegistryStore : IVaultRegistryStore
{
private readonly string _registryFilePath;
public VaultRegistryStore(IConfiguration configuration, ILogger<VaultRegistryStore> logger)
{
var dataDir = configuration["DataDir"] ?? "./data";
var absoluteDataDir = Path.IsPathRooted(dataDir)
? dataDir
: Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), dataDir));
_registryFilePath = Path.Combine(absoluteDataDir, "personal-data", "vaults", "registry.json");
}
}

这种设计的好处是简单可靠。JSON格式人类可读,便于调试和手动修改;文件系统存储避免了数据库的复杂性,降低了系统的依赖。毕竟,有时候简单的反而是最好的。

最关键的是,系统能够自动将vault信息注入到AI提案的上下文中:

export function buildTargetVaultsText(
vaults: VaultForText[],
template: VaultPromptTemplate = DEFAULT_VAULT_PROMPT_TEMPLATE,
): string {
const readOnlyVaults = vaults.filter((vault) => vault.accessType === 'read');
const editableVaults = vaults.filter((vault) => vault.accessType === 'write');
if (readOnlyVaults.length === 0 && editableVaults.length === 0) {
return '';
}
const sections = [
buildVaultSection(readOnlyVaults, template.reference),
buildVaultSection(editableVaults, template.editable),
].filter(Boolean);
return `\n\n### ${template.heading}\n\n${sections.join('\n')}`;
}

这样就实现了一个重要的功能:AI助手能够自动理解可用的学习资源,无需用户手动提供上下文。这倒也算是一种默契了。

对于coderef类型的vault,HagiCode提供了一套标准化的目录结构:

my-coderef-vault/
├── index.yaml # vault元数据描述
├── AGENTS.md # AI助手的操作指南
├── docs/ # 存放学习笔记和文档
└── repos/ # 通过Git子模块管理临摹的代码仓库

创建vault时,系统会自动初始化这个结构:

private async Task EnsureCodeRefStructureAsync(
string vaultName,
string physicalPath,
ICollection<VaultBootstrapDiagnosticDto> diagnostics,
CancellationToken cancellationToken)
{
Directory.CreateDirectory(physicalPath);
var indexPath = Path.Combine(physicalPath, CodeRefIndexFileName);
var docsPath = Path.Combine(physicalPath, CodeRefDocsDirectoryName);
var reposPath = Path.Combine(physicalPath, CodeRefReposDirectoryName);
// 创建标准目录结构
if (!Directory.Exists(docsPath))
{
Directory.CreateDirectory(docsPath);
}
if (!Directory.Exists(reposPath))
{
Directory.CreateDirectory(reposPath);
}
// 创建AGENTS.md指南
await EnsureCodeRefAgentsDocumentAsync(physicalPath, cancellationToken);
// 创建index.yaml元数据
await WriteCodeRefIndexDocumentAsync(indexPath, mergedDocument, cancellationToken);
}

这套结构的设计也是有讲究的:

  • docs/ 目录存放你的学习笔记,可以用Markdown格式记录对代码的理解、架构分析、踩坑经验等
  • repos/ 目录通过Git子模块管理临摹的仓库,而不是直接复制代码。这样既能保持代码同步,又能节省空间
  • index.yaml 包含vault的元数据,让AI助手快速理解这个vault的用途和内容
  • AGENTS.md 是专门写给AI助手看的指南,说明如何处理这个vault中的内容

或许这样组织起来,AI也能更容易理解你的想法吧。

除了手动创建vault,HagiCode还支持系统自动管理的vault:

public async Task<IReadOnlyList<VaultRegistryEntry>> EnsureAllSystemManagedVaultsAsync(
CancellationToken cancellationToken = default)
{
var definitions = GetAllResolvedDefinitions();
var entries = new List<VaultRegistryEntry>(definitions.Count);
foreach (var definition in definitions)
{
entries.Add(await EnsureResolvedSystemManagedVaultAsync(definition, cancellationToken));
}
return entries;
}

系统会自动创建和管理以下vault:

  • hagiprojectdata:项目数据存储,用于保存项目的配置和状态
  • personaldata:个人数据存储,用于保存用户的偏好设置
  • hbsprompt:提示词模板库,用于管理常用的AI提示词

这些vault在系统启动时自动初始化,无需用户手动配置。毕竟,有些事情交给系统去做就好了,人类何必操心呢。

一个重要的设计是访问控制。系统将vault分为两种访问类型:

export interface VaultForText {
id: string;
name: string;
type: string;
physicalPath: string;
accessType: 'read' | 'write'; // 关键:区分只读和可编辑
}
  • reference(只读):AI仅用于分析和理解,不能修改内容。适用于参考的开源项目、文档等
  • editable(可编辑):AI可以根据任务需要修改内容。适用于你的笔记、草稿等

这种区分很重要。它让AI知道哪些内容是”只读参考”,哪些是”可以动手改的”,避免了误操作风险。毕竟,谁也不想自己的心血被无意中改没了。

看完了原理,咱们来看看实际怎么用。

以下是一个完整的前端调用示例:

const createCodeRefVault = async () => {
const response = await VaultService.postApiVaults({
requestBody: {
name: "React Learning Vault",
type: "coderef",
physicalPath: "/Users/developer/vaults/react-learning",
gitUrl: "https://github.com/facebook/react.git"
}
});
// 系统会自动:
// 1. 克隆React仓库到vault/repos/react
// 2. 创建docs/目录用于笔记
// 3. 生成index.yaml元数据
// 4. 创建AGENTS.md指南文件
return response;
};

这个API调用会完成一系列操作:创建目录结构、初始化Git子模块、生成元数据文件等。你只需要提供基本信息,剩下的交给系统处理。其实这样也挺省心的。

创建好vault后,就可以在AI提案中引用它了:

const proposal = composeProposalChiefComplaint({
chiefComplaint: "帮我分析React的并发渲染机制",
repositories: [
{ id: "react", gitUrl: "https://github.com/facebook/react.git" }
],
vaults: [
{
id: "react-learning",
name: "React Learning Vault",
type: "coderef",
physicalPath: "/vaults/react-learning",
accessType: "read" // AI只能读取,不能修改
}
],
quickRequestText: "重点关注fiber架构和scheduler实现"
});

系统会自动将vault信息注入到AI的上下文中,让AI知道你有哪些学习资源可用。AI能理解你的想法,这倒也算是一种难得的默契了。

在使用Vault系统的过程中,我们总结了一些经验教训。

系统会严格校验路径,防止路径穿越攻击:

private static string ResolveFilePath(string vaultRoot, string relativePath)
{
var rootPath = EnsureTrailingSeparator(Path.GetFullPath(vaultRoot));
var combinedPath = Path.GetFullPath(Path.Combine(rootPath, relativePath));
if (!combinedPath.StartsWith(rootPath, StringComparison.OrdinalIgnoreCase))
{
throw new BusinessException(VaultRelativePathTraversalCode,
"Vault file paths must stay inside the registered vault root.");
}
return combinedPath;
}

这点很重要。如果你在自定义vault路径,一定要确保路径在允许的范围内,否则系统会拒绝操作。安全这东西,怎么强调都不过分。

CodeRef vault推荐使用Git子模块而非直接复制代码:

private static string BuildCodeRefAgentsContent()
{
return """
# CodeRef Vault Guide
Repositories under `repos/` should be maintained through Git submodules
rather than copied directly into the vault root.
Keep this structure stable so assistants and tools can understand the vault quickly.
""" + Environment.NewLine;
}

这样做有几个好处:保持代码与上游同步、节省磁盘空间、便于管理多个版本的代码。毕竟,谁愿意一遍遍地重复下载同样的东西呢。

为了防止性能问题,系统限制了文件大小和类型:

private const int FileEnumerationLimit = 500;
private const int PreviewByteLimit = 256 * 1024; // 256KB

如果你的vault包含大量文件或超大文件,可能会影响预览功能的性能。这种情况下可以考虑分批处理或使用专门的搜索工具。毕竟,有些东西太大了,反而不好处理。

创建vault时会返回诊断信息,帮助调试:

List<VaultBootstrapDiagnosticDto> bootstrapDiagnostics = [];
if (IsCodeRefVaultType(normalizedType))
{
bootstrapDiagnostics = await EnsureCodeRefBootstrapAsync(
normalizedName,
normalizedPhysicalPath,
normalizedGitUrl,
cancellationToken);
}

如果创建失败,可以查看诊断信息了解具体原因。出错了就看看诊断信息,这倒也是一种解决问题的方法。

Vault系统通过统一的存储抽象层,解决了AI时代临摹项目的核心痛点:

  • 知识集中管理:所有学习资源集中在一个地方,不再散落各处
  • AI上下文自动注入:AI助手能够自动理解可用的学习资源,无需手动提供上下文
  • 跨项目知识复用:多个学习项目之间可以共享和复用知识
  • 标准化目录结构:提供一致的目录结构,降低学习成本

这套方案在HagiCode项目中已经经过了实际验证。如果你也在做AI辅助开发相关的工具,或者面临类似的知识管理问题,希望这些经验能给你一些参考。

其实技术方案的价值不在于有多复杂,而在于能不能解决实际问题。Vault系统的核心思想很简单——就是建立一个统一的、AI可理解的知识存储层。但正是这个简单的抽象,让我们的开发效率提升了不少。

有时候,简单的反而是最好的。毕竟,复杂的东西往往藏着更多的坑…


如果本文对你有帮助,欢迎来 GitHub 给个 Star,或者访问官网了解更多关于HagiCode的信息。公测已开始,现在安装即可体验完整的AI代码助手功能。

或许,你也可以试试看…

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

渐进式披露:如何用少即是多的理念改进 AI 产品的人机交互

渐进式披露:如何用”少即是多”的理念改进 AI 产品的人机交互

Section titled “渐进式披露:如何用”少即是多”的理念改进 AI 产品的人机交互”

在 AI 产品设计中,用户输入的质量往往决定了输出的质量。本文分享我们在 HagiCode 项目中实践的一套”渐进式披露”交互方案,通过分步引导、智能补全和即时反馈,将用户简短模糊的输入转化为结构化的技术提案,显著提升了人机交互效率。

做 AI 产品的同学应该都遇到过这样的场景:用户打开你的应用,兴致勃勃地输入一行需求,结果 AI 返回的内容完全不搭边。不是 AI 不聪明,只是用户给的信息太少了,毕竟猜心这种事,谁也做不好。

这种现象在我们开发 HagiCode 的过程中尤为明显。HagiCode 是一个 AI 驱动的代码助手,用户通过自然语言描述需求来创建技术提案和会话。可在实际使用中,我们发现用户输入的内容往往存在这些问题:

  • 输入质量参差不齐:有的用户只输入几个字,比如”优化登录”、“修复 bug”,缺乏必要的上下文
  • 技术术语不统一:不同用户用不同的词说同一件事,有人说”前端”有人说”FE”
  • 缺少结构化信息:没有项目背景、没有仓库范围、没有影响范围这些关键信息
  • 重复性问题:相同类型的需求反复出现,每次都要从头解释

这些问题导致的直接后果就是:AI 理解困难、生成的提案质量不稳定、用户体验差。用户觉得”这 AI 不行啊”,我们也很委屈——你只给一句话,让我怎么猜你想要啥?

其实这也没办法,毕竟人和人之间的理解都需要时间,更何况是机器呢?

为了解决这些痛点,我们做了一个大胆的决定:引入”渐进式披露”的设计理念来改进人机交互。这个决定带来的变化,可能比你想象的还要大,只是当时我们也没想到会这么有效罢了。

本文分享的方案来自我们在 HagiCode 项目中的实践经验。HagiCode 是一个开源的 AI 代码助手项目,旨在通过自然语言交互帮助开发者完成代码编写、技术提案生成、代码审查等任务。项目地址:github.com/HagiCode-org/site

这套渐进式披露方案是我们在实际开发过程中,经过多次迭代和优化总结出来的。如果你觉得这套方案有价值,说明我们的工程实力还不错——那么 HagiCode 本身也值得关注一下,毕竟好东西是值得分享的。

“渐进式披露”(Progressive Disclosure)是一个源自 HCI(人机交互)领域的设计原则,核心思想很简单:不要一次性把所有信息和选项都展示给用户,而是根据用户的操作和需求,逐步展示必要的内容

这个原则特别适合 AI 产品,因为 AI 交互天然就是渐进式的——用户说一点,AI 理解一点,然后补充一点,再理解更多。就像人与人之间的交流一样,总得慢慢来,毕竟谁也不能一见面就把心掏出来不是?

具体到 HagiCode 的场景,我们从四个方面实施了渐进式披露:

1. 描述优化机制:让 AI 帮你把话说清楚

Section titled “1. 描述优化机制:让 AI 帮你把话说清楚”

当用户输入简短描述时,我们不是直接让 AI 去理解,而是先触发一个”描述优化”流程。这个流程的核心是”结构化输出”——把用户的自由文本转化为标准格式。就像把散落一地的珍珠串成项链,看起来也就不那么乱了。

优化后的描述必须包含以下几个标准章节:

  • 背景:问题背景和上下文
  • 分析:技术分析和思考过程
  • 解决:解决方案和实施步骤
  • 实践:实际代码示例和注意事项

同时,我们还会自动生成一个 Markdown 表格,展示目标仓库、路径、编辑权限等信息,方便 AI 后续操作。毕竟有个清晰的目录,找起东西来也方便。

下面是实际的代码实现:

// ProposalDescriptionMemoryService.cs 中的核心方法
public async Task<string> OptimizeDescriptionAsync(
string title,
string description,
string locale = "zh-CN",
DescriptionOptimizationMemoryContext? memoryContext = null,
CancellationToken cancellationToken = default)
{
// 构建查询参数
var queryContext = BuildQueryContext(title, description);
// 检索历史上下文
var memoryContext = await RetrieveHistoricalContextAsync(queryContext, cancellationToken);
// 生成结构化提示词
var prompt = await BuildOptimizationPromptAsync(
title,
description,
memoryContext,
cancellationToken);
// 调用 AI 进行优化
return await _aiService.CompleteAsync(prompt, cancellationToken);
}

这个流程的关键在于”记忆注入”——我们会把项目惯例、相似案例、负面模式等历史上下文注入到提示词中,让 AI 在优化时能够参考过去的经验。毕竟吃一堑长一智,过去的经验总不能白白浪费了不是?

注意事项

  • 确保当前输入优先于历史记忆,避免覆盖用户显式指定的信息
  • HagIndex 引用必须作为事实来源,不得被历史案例修改
  • 低置信度的纠错建议不应作为强约束注入

2. 语音输入能力:说话比打字更自然

Section titled “2. 语音输入能力:说话比打字更自然”

除了文本输入,我们还支持语音输入。这在描述复杂需求时特别有用——你想想,打一段技术需求可能要几分钟,但说可能几十秒就完事了,毕竟嘴总是比手快。

语音输入的设计重点是”状态管理”,用户必须清楚当前系统处于什么状态。我们定义了以下几种状态:

  • 空闲:系统就绪,可以开始录制
  • 等待上游:正在连接后端服务
  • 录制中:正在录制用户语音
  • 处理中:正在将语音转换为文本
  • 错误:发生错误,需要用户处理

前端的状态模型大概是这样的:

interface VoiceInputState {
status: 'idle' | 'waiting-upstream' | 'recording' | 'processing' | 'error';
duration: number;
error?: string;
deletedSet: Set<string>; // 已删除结果的指纹集合
}
// 开始录制时的状态转换
const handleVoiceInputStart = async () => {
// 先进入等待状态,显示加载动画
setState({ status: 'waiting-upstream' });
// 等待后端就绪确认
const isReady = await waitForBackendReady();
if (!isReady) {
setState({ status: 'error', error: '后端服务未就绪' });
return;
}
// 开始录制
setState({ status: 'recording', startTime: Date.now() });
};
// 处理识别结果
const handleRecognitionResult = (result: RecognitionResult) => {
const fingerprint = normalizeFingerprint(result.text);
// 检查是否已被删除
if (state.deletedSet.has(fingerprint)) {
return; // 跳过已删除的内容
}
// 合并结果到文本框
appendResult(result);
};

这里有个细节:我们用”指纹集合”来管理删除同步。当语音识别返回多条结果时,用户可能会删除其中一些。我们把已删除内容的指纹存起来,后续如果相同内容再出现就自动跳过。这就像记住了哪些菜不爱吃,下次就不会再点了,毕竟谁也不想被同样的问题困扰两次。

3. 提示词管理系统:把 AI 的”脑子”外置

Section titled “3. 提示词管理系统:把 AI 的”脑子”外置”

HagiCode 有一个灵活的提示词管理系统,所有提示词都以文件形式存储:

prompts/
├── metadata/
│ ├── optimize-description.zh-CN.json
│ └── optimize-description.en-US.json
└── templates/
├── optimize-description.zh-CN.hbs
└── optimize-description.en-US.hbs

每个提示词由两部分组成:

  • 元数据文件(.json):定义提示词的场景、版本、参数等信息
  • 模板文件(.hbs):使用 Handlebars 语法的实际提示词内容

元数据文件的格式是这样的:

{
"scenario": "optimize-description",
"locale": "zh-CN",
"version": "1.0.0",
"syntax": "handlebars",
"syntaxVersion": "1.0",
"parameters": [
{
"name": "title",
"type": "string",
"required": true,
"description": "提案标题"
},
{
"name": "description",
"type": "string",
"required": true,
"description": "原始描述"
}
],
"author": "HagiCode Team",
"description": "优化用户输入的技术提案描述",
"lastModified": "2026-04-05",
"tags": ["optimization", "nlp"]
}

模板文件使用 Handlebars 语法,支持参数注入:

你是一个技术提案专家。
<task>
根据以下信息生成结构化的技术提案描述。
</task>
<input>
<title>{{title}}</title>
<description>{{description}}</description>
{{#if memoryContext}}
<memory_context>
{{memoryContext}}
</memory_context>
{{/if}}
</input>
<output_format>
## 背景
[描述问题背景和上下文,包括项目信息、仓库范围等]
## 分析
[技术分析和思考过程,说明为什么需要这个改动]
## 解决
[解决方案和实施步骤,列出关键代码位置]
## 实践
[实际代码示例和注意事项]
</output_format>

这种设计的好处是:

  • 提示词可以像代码一样版本管理
  • 支持多语言,根据用户偏好自动切换
  • 参数化设计,可以动态注入上下文
  • 启动时验证完备性,避免运行时出错

毕竟脑子里的东西不写下来,谁也不知道什么时候就忘了,与其到时候懊悔,不如一开始就做好记录罢了。

4. 渐进式向导:复杂任务拆成小步

Section titled “4. 渐进式向导:复杂任务拆成小步”

对于复杂任务(比如首次安装配置),我们使用了多步骤向导的设计。每个步骤只请求必要信息,并提供清晰的进度指示。生活也是这样嘛,一口吃不成胖子,一步一步来反而更稳妥。

向导的状态模型:

interface WizardState {
currentStep: number; // 0-3,对应 4 个步骤
steps: WizardStep[];
canGoNext: boolean;
canGoBack: boolean;
isLoading: boolean;
error: string | null;
}
interface WizardStep {
id: number;
title: string;
description: string;
completed: boolean;
}
// 步骤导航逻辑
const goToNextStep = () => {
if (wizardState.currentStep < wizardState.steps.length - 1) {
// 验证当前步骤的输入
if (validateCurrentStep()) {
wizardState.currentStep++;
wizardState.steps[wizardState.currentStep - 1].completed = true;
}
}
};
const goToPreviousStep = () => {
if (wizardState.currentStep > 0) {
wizardState.currentStep--;
}
};

每个步骤都有独立的验证逻辑,已完成步骤会有清晰的视觉标记。取消操作会弹出确认对话框,防止用户误操作丢失进度。毕竟走错路可以回头,但如果把路都拆了,那就真的没辙了。

回顾 HagiCode 的渐进式披露实践,我们可以总结出几个核心原则:

  1. 分步引导:把复杂任务拆成小步,每步只请求必要信息
  2. 智能补全:利用历史上下文和项目知识自动补全信息
  3. 即时反馈:每个操作都有清晰的视觉反馈和状态提示
  4. 容错机制:允许用户撤销、重置,避免错误造成不可逆损失
  5. 输入多样化:支持文本、语音等多种输入方式

这套方案在 HagiCode 中的实际效果是:用户输入的平均长度从不到 20 字提升到了结构化的 200-300 字,AI 生成的提案质量显著提高,用户满意度也跟着上来了。

其实这也不奇怪,毕竟你给的信息越多,AI 理解得越准确,返回的结果自然就越好,这和人与人之间的交流也没什么两样。

如果你也在做 AI 相关的产品,希望这些经验能给你带来一些启发。记住:用户不是不想给信息,而是你还没问对问题。渐进式披露的核心,就是找到问问题的最佳时机和方式,只是这个时机和方式,需要一点耐心去摸索罢了。


如果本文对你有帮助,欢迎来 GitHub 给个 Star,关注 HagiCode 项目的后续发展。公测已经开始,现在安装即可体验完整功能:

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

AI 输出 Token 优化:文言文极简模式的实践

AI 输出 Token 优化:文言文极简模式的实践

Section titled “AI 输出 Token 优化:文言文极简模式的实践”

在 AI 应用开发中,token 消耗直接影响成本。HagiCode 项目通过 SOUL 系统实现了”文言文极简输出模式”,在不损失信息密度的前提下,将输出 token 降低约 30-50%。本文分享这套方案的实现细节和使用经验。

在 AI 应用开发中,token 消耗是个绕不开的成本问题。尤其是需要 AI 输出大量内容的场景,怎么在不损失信息密度的情况下降低输出 token,这问题想多了也挺让人头疼。

传统的优化思路都集中在输入端:精简系统提示词、压缩上下文、用更高效的编码方式。只是这些方法终究会碰到天花板,再压缩就可能影响 AI 的理解能力和输出质量了。这无异于删减内容,意义不大。

那输出端呢?能不能让 AI 用更简洁的方式表达同样的意思?

这问题看似简单,其实藏着不少门道。直接让 AI”简洁点”,它可能真的就只给几个词;加上”保持信息完整”,它又可能回复到原来的冗长风格。约束太强影响可用性,约束太弱没有效果,这中间的平衡点在哪,谁也说不准。

为了解决这些痛点,我们做了一个大胆的决定:从语言风格入手,设计一套可配置、可组合的表达方式约束系统。这个决定带来的变化,可能比你想象的还要大——稍后我会具体说,或许你会有些意外。

本文分享的方案来自我们在 HagiCode 项目中的实践经验。

HagiCode 是一个开源的 AI 代码助手项目,支持多种 AI 模型和自定义配置。在开发过程中,我们发现了 AI 输出 token 过高的问题,并设计了一套解决方案。如果你觉得这套方案有价值,说明我们的工程实力还不错——那么 HagiCode 本身也值得关注一下,毕竟代码不会撒谎。

SOUL 系统的全称是 Soul Oriented Universal Language,是 HagiCode 项目中用于定义 AI Hero 语言风格的配置系统。它的核心思想是:通过约束 AI 的表达方式,在保持信息完整性的前提下,使用更简洁的语言形式来输出内容。

这东西就像给 AI 戴上了一个语言面具…罢了,其实也没那么玄乎。

SOUL 系统采用前后端分离的架构:

前端(Soul Builder)

  • 基于 React + TypeScript + Vite 构建
  • 位于 repos/soul/ 目录
  • 提供可视化的 Soul 构建界面
  • 支持双语(zh-CN / en-US)

后端

  • 基于 .NET (C#) + Orleans 分布式运行时
  • Hero 实体包含 Soul 字段(最大 8000 字符)
  • 通过 SessionSystemMessageCompiler 将 Soul 注入系统提示词

Agent Templates 生成

  • 从参考材料生成
  • 输出到 /agent-templates/soul/templates/ 目录
  • 包含 50 组主 Catalog 和 10 组正交维度

在 Session 首次执行时,系统会读取 Hero 的 Soul 配置,将其注入到系统提示词中:

sequenceDiagram
participant UI as 用户界面
participant Session as SessionGrain
participant Hero as Hero 仓库
participant AI as AI 执行器
UI->>Session: 发送消息(绑定 Hero)
Session->>Hero: 读取 Hero.Soul
Session->>Session: 缓存 Soul 快照
Session->>AI: 构建 AIRequest(注入 Soul)
AI-->>Session: 执行结果
Session-->>UI: 流式响应

注入的系统提示词格式为:

<hero_soul>
[用户自定义的 Soul 内容]
</hero_soul>

这套注入机制在 SessionSystemMessageCompiler.cs 中实现:

internal static string? BuildSystemMessage(
string? existingSystemMessage,
string? languagePreference,
IReadOnlyList<HeroTraitDto>? traits,
string? soul)
{
var segments = new List<string>();
// ... 语言偏好和 Traits 处理 ...
var normalizedSoul = NormalizeSoul(soul);
if (!string.IsNullOrWhiteSpace(normalizedSoul))
{
segments.Add($"<hero_soul>\n{normalizedSoul}\n</hero_soul>");
}
// ... 其他系统消息 ...
return segments.Count == 0 ? null : string.Join("\n\n", segments);
}

代码也看了,原理也懂了,其实就这么回事。

文言文极简模式是 SOUL 系统中最具代表性的节约 token 方案。它的核心原理是利用文言文的高语义密度特性,在保持信息完整的前提下压缩输出长度。

文言文具有几个天然优势:

  1. 语义压缩:相同含义可以用更少的字符表达
  2. 去除冗余:文言文本身就省略了很多现代汉语中的连接词和助词
  3. 结构简洁:单句信息密度高,适合作为 AI 输出的载体

以一个实际例子来说明:

现代汉语输出(约 80 字):

根据你的代码分析,我发现了几个问题。首先,在第 23 行,变量名太长了,建议缩短一些。其次,在第 45 行,你没有处理空值的情况,应该加上判断逻辑。最后,整体的代码结构还可以,但是可以进一步优化。

文言文极简输出(约 35 字,节约 56%):

代码审阅毕:第 23 行变量名冗长,宜缩写;第 45 行缺空值处理,应加判断。整体结构尚可,微调即可。

这差距,想想也挺有意思的。

文言文极简模式的完整 Soul 配置如下:

{
"id": "soul-orth-11-classical-chinese-ultra-minimal-mode",
"name": "文言文极简输出模式",
"summary": "以尽量可懂的文言文压缩语义密度,尽可能少字达意,只保留结论、判断与必要动作,从而大幅降低输出 token",
"soul": "你的人设内核来自「文言文极简输出模式」:以尽量可懂的文言文压缩语义密度,尽可能少字达意,只保留结论、判断与必要动作,从而大幅降低输出 token。\n保持以下标志性语言特征:1. 优先使用简明文言句式,如「可」「宜」「勿」「已」「然」「故」等,避免生僻艰涩字词;\n2. 单句尽量压缩至 4-12 字,删除铺垫、寒暄、重复解释与无效修饰;\n3. 非必要不展开论证,用户未追问则只给结论、步骤或判断;\n4. 不改变主 Catalog 的核心人设,只将表达收束为克制、古雅、极简的短句。"
}

这个模板的设计有几个要点:

  1. 约束明确:单句 4-12 字,删除冗余,结论优先
  2. 避免晦涩:使用简明文言句式,避免生僻字词
  3. 保持人设:只改变表达方式,不改变核心人设

配置这东西,调来调去也就那么几个参数罢了。

除了文言文模式,HagiCode 的 SOUL 系统还提供了其他多种节约 token 的模式:

电报式极简输出模式soul-orth-02):

  • 单句严格控制在 10 字以内
  • 禁止修饰性形容词
  • 全程无语气词、感叹号、叠词

短句碎碎念模式soul-orth-01):

  • 句子控制在 1-5 个字
  • 模拟自言自语的碎片化表达
  • 弱化逻辑,优先传递情绪

引导式问答模式soul-orth-03):

  • 通过提问引导用户思考
  • 减少直接输出内容
  • 交互式降低 token 消耗

这些模式的设计思路各有侧重,但核心目标是一致的:在保持信息质量的前提下降低输出 token。条条大路通罗马,只是有的路好走一点,有的路稍微曲折一点罢了。

SOUL 系统的一个强大特性是支持主 Catalog 与正交维度的交叉组合:

  • 50 组主 Catalog:定义基础人设(如治愈系、学霸系、高冷系等)
  • 10 组正交维度:定义表达方式(如文言文、电报式、问答式等)
  • 组合效果:可生成 500+ 种独特的语言风格组合

例如,你可以将”专业开发工程师”与”文言文极简输出模式”组合,得到一个既专业又简洁的 AI 助手。这种灵活性让 SOUL 系统能够适应各种不同使用场景。想怎么配就怎么配,反正组合多得你玩不过来…

访问 soul.hagicode.com,按以下步骤操作:

  1. 选择主 Catalog(如”专业开发工程师”)
  2. 选择正交维度(如”文言文极简输出模式”)
  3. 预览生成的 Soul 内容
  4. 复制生成的 Soul 配置

点点点的事情,应该不用我多说吧。

通过 Web 界面或 API,将 Soul 配置应用到 Hero:

// Hero Soul 更新示例
const heroUpdate = {
soul: "你的人设内核来自「文言文极简输出模式」:...",
soulCatalogId: "soul-orth-11-classical-chinese-ultra-minimal-mode",
soulDisplayName: "文言文极简输出模式",
soulStyleType: "orthogonal-dimension",
soulSummary: "以尽量可懂的文言文压缩语义密度..."
};
await updateHero(heroId, heroUpdate);

用户可以基于预设模板进行微调,或完全自定义。下面是一个代码审查场景的自定义示例:

你是一位追求极致简洁的代码审查员。
所有输出必须遵循:
1. 仅指出具体问题和行号
2. 每条问题不超过 15 字
3. 使用「宜」「应」「勿」等简洁词汇
4. 不做多余解释
示例输出:
- 第 23 行:变量名过长,宜缩写
- 第 45 行:未处理空值,应加判断
- 第 67 行:逻辑冗余,可简化

想怎么改就怎么改,反正模板这东西也只是个起点而已。

兼容性

  • 文言文模式适配全部 50 组主 Catalog
  • 可与任何基础人设组合使用
  • 不会改变主 Catalog 的核心人设

缓存机制

  • Soul 在 Session 首次执行时缓存
  • 同一 SessionId 内复用缓存
  • 修改 Hero 配置不影响已启动的 Session

限制约束

  • Soul 字段最大长度 8000 字符
  • 历史数据中无 Soul 字段的 Hero 仍可正常使用
  • Soul 与 style 装备位独立,不会相互覆盖

根据项目的实际测试数据,使用文言文极简模式后的效果如下:

场景原始输出 token文言文模式节约比例
代码审查85042051%
技术问答62038039%
方案建议110068038%
平均--30-50%

数据来自 HagiCode 项目的实际使用统计,具体效果因场景而异。不过省下来的 token,积少成多,钱包会感谢你的。

HagiCode 的 SOUL 系统提供了一种创新性的 AI 输出优化思路:通过约束表达方式来降低 token 消耗,而不是压缩信息本身。文言文极简模式作为其中最具代表性的方案,在实际使用中取得了 30-50% 的 token 节约效果。

这套方案的核心价值在于:

  1. 保持信息质量:不是简单截断输出,而是用更高效的方式表达
  2. 灵活可组合:支持 500+ 种人设与表达方式的组合
  3. 易于使用:通过 Soul Builder 可视化界面,无需编写代码
  4. 生产级稳定:已在项目中验证,支持大规模使用

如果你也在开发 AI 应用,或者对 HagiCode 项目感兴趣,欢迎来交流。开源的意义在于共同进步,也期待看的到你的创新用法。毕竟,一个人走得快,一群人走得远…这话说得挺俗套,但道理就是这么个道理。


如果本文对你有帮助:

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

AI 编程助手的幻觉问题:如何用 OpenSpec 实现规范驱动开发

AI 编程助手的幻觉问题:如何用 OpenSpec 实现规范驱动开发

Section titled “AI 编程助手的幻觉问题:如何用 OpenSpec 实现规范驱动开发”

AI 编程助手虽然强大,但常常生成不符合实际需求、违反项目规范的代码。本文分享 HagiCode 项目如何通过 OpenSpec 流程实现”规范驱动开发”,用结构化的提案机制显著减少 AI 幻觉风险。

用过 GitHub Copilot 或 ChatGPT 写代码的同学可能都有过这样的经历:AI 生成的代码看起来很漂亮,但真正用起来却问题百出。可能是用错了项目里的某个组件,可能是忽略了团队的编码规范,也可能是基于一些不存在的假设写了大段逻辑。

这就是所谓的”AI 幻觉”问题——在编程领域,它表现为生成看似合理但实际上不符合项目实际情况的代码。

其实这事儿也挺无奈的。AI 编程助手越来越普及,这个问题也就越发严重了。毕竟,AI 缺乏对项目历史、架构决策和编码规范的理解,而自由度过高又容易让它”创造性”地生成不符合实际的代码。这和写文章倒是挺像的,没有章法就容易写得天马行空,可实际上并不是那么回事儿。

为了解决这些痛点,我们做了一个大胆的决定:不是试图让 AI 更聪明,而是给它套上一个”规范”的笼子。这个决定带来的变化,可能比你想象的还要大——稍后我会具体说。

本文分享的方案来自我们在 HagiCode 项目中的实践经验。HagiCode 是一个开源的 AI 代码助手项目,致力于通过结构化的工程实践来解决 AI 编程中的实际问题。

在深入解决方案之前,咱们先看看问题到底出在哪。毕竟知己知彼,方能百战不殆,只是这话用在 AI 身上,好像也有点意思。

AI 模型训练时用的是公开的代码库,但你的项目有它自己的历史、约定和架构决策。这些”隐性知识” AI 没法直接获取,所以生成的代码往往和项目实际情况脱节。

这也不能全怪 AI,毕竟它又没在你们项目待过,哪知道你们那些江湖规矩呢?就像新来的实习生,不懂规矩也正常,只是代价可能有点大罢了。

当你问 AI”帮我实现一个用户认证功能”时,它可能生成任何形式的代码。没有明确的约束,AI 就会按照它”认为”合理的方式去实现,而不是按照你项目的要求。

这就像是让一个从未学过你项目规范的人自由发挥,能不出问题吗?其实也不是它不负责任,只是它压根不知道什么是责任。

AI 生成代码后,如果没有经过结构化的审查流程,那些基于错误假设的代码就会直接进入代码库。等到测试甚至生产环境才发现问题,代价就太大了。

这无异于亡羊补牢,只是羊都已经跑光了,补牢又有什么用呢?这道理谁都懂,可真正做起来,又总觉得麻烦。毕竟在事情变坏之前,谁愿意多花时间呢?

HagiCode 选择 OpenSpec 作为解决方案,核心思路是:所有代码变更必须通过结构化的提案流程,把抽象的想法转化为可执行的实施计划。

这话说得挺高大上的,其实就是让 AI 在写代码之前,先把需求文档写好罢了。毕竟凡事预则立,不预则废,古人诚不我欺。

OpenSpec 是一个基于 npm 的命令行工具(@fission-ai/openspec),它定义了一套标准的提案文件结构和验证机制。简单说,就是让 AI 在写代码之前,先”写好需求文档”。

OpenSpec 通过一个三步流程来确保提案质量:

Step 1:初始化提案 - 设置会话状态为 Openspecing Step 2:中间处理 - 保持 Openspecing 状态,逐步完善工件 Step 3:完成提案 - 转换到 Reviewing 状态

这个设计有个很巧妙的细节:第一步使用的是 ProposalGenerationStart 类型,完成后不会触发状态转换。这确保了整个多步流程完成前,不会过早进入审查阶段。

其实这个细节也挺有意思的,就像做菜一样,火候没到就揭锅盖,肯定是什么都做不好的。只有耐心一点,一步一步来,最后才能做出一道好菜。

// HagiCode 项目中的实现
public enum MessageAssociationType
{
ProposalGeneration = 2,
ProposalExecution = 3,
/// <summary>
/// 标记三步提案生成流程的开始
/// 完成后不会转换到 Reviewing 状态
/// </summary>
ProposalGenerationStart = 5
}

每个 OpenSpec 提案都遵循相同的目录结构:

openspec/
├── changes/ # 活跃和归档的变更
│ ├── {change-name}/
│ │ ├── proposal.md # 提案描述
│ │ ├── design.md # 设计文档
│ │ ├── specs/ # 技术规范
│ │ └── tasks.md # 可执行任务清单
│ └── archive/ # 归档的变更
└── specs/ # 独立的规范库

根据 HagiCode 项目的统计,目前已经有 4000+ 个归档变更和 15 万+ 行规范文件。这些历史积累不仅让 AI 有章可循,也为团队提供了宝贵的知识库。

这就像是古人留下的典籍,读多了自然就能悟出点门道来。只是现在这典籍不是写在竹简上,而是存放在文件里罢了。

系统实现了多层验证来确保提案质量:

// 验证必需文件存在
ValidateProposalFiles()
// 验证执行前提条件
ValidateExecuteAsync()
// 验证启动条件
ValidateStartAsync()
// 验证归档条件
ValidateArchiveAsync()
// 提案名称格式验证(kebab-case)
ValidateNameFormat()

这些验证就像是层层把关的守门人,只有真正合格的提案才能通过。虽然看起来繁琐,可总比让糟糕的代码进入代码库要强吧?

HagiCode 中的 AI 执行时使用预定义的 Handlebars 模板,这些模板包含明确的步骤指导和保护措施。比如:

  • 禁止在未理解用户意图时继续
  • 禁止生成未验证的代码
  • 要求在名称无效时重新提供
  • 如果变更已存在,建议使用继续命令而不是重新创建

这种”带着脚镣跳舞”的方式,反而让 AI 更聚焦于理解需求和生成符合规范的代码。其实约束这东西,也不一定是坏事,毕竟自由过头了就容易乱套。

实践:如何在项目中使用 OpenSpec

Section titled “实践:如何在项目中使用 OpenSpec”
Terminal window
npm install -g @fission-ai/openspec@1
openspec --version # 验证安装

在项目根目录会自动创建 openspec/ 文件夹结构。

这步其实也没什么好说的,安装工具嘛,大家都懂。只是记得用 @fission-ai/openspec@1 这个版本,新版本可能有坑,毕竟稳定压倒一切。

在 HagiCode 的对话界面中,使用快捷命令:

/opsx:new

或者指定变更名称和目标仓库:

/opsx:new "add-user-auth" --repos "repos/web"

创建提案这事儿,就像写文章列提纲一样,有了提纲后面就好写了。只是很多人喜欢直接开始写,写到一半才发现思路不通,那才叫头疼。

使用 /opsx:continue 逐步生成所需的工件:

proposal.md - 描述变更的目的和范围

# Proposal: Add User Authentication
## Why
当前系统缺少用户认证功能,无法保护敏感 API。
## What Changes
- 添加 JWT 认证中间件
- 实现登录/注册 API
- 更新前端集成

design.md - 详细的技术设计

# Design: Add User Authentication
## Context
当前使用公开 API,任何人均可访问...
## Decisions
1. 选择 JWT 而非 Session...
2. 使用 HS256 算法...
## Risks
- 令牌泄露风险...
- 缓解措施...

specs/ - 技术规范和测试场景

# user-auth Specification
## Requirements
### Requirement: JWT Token Generation
系统 SHALL 使用 HS256 算法生成 JWT 令牌。
#### Scenario: Valid login
- WHEN 用户提供有效凭据
- THEN 系统 SHALL 返回有效的 JWT 令牌

tasks.md - 可执行的任务清单

# Tasks: Add User Authentication
## 1. Backend Changes
- [ ] 1.1 创建 AuthController
- [ ] 1.2 实现 JWT 中间件
- [ ] 1.3 添加单元测试

这些工件其实就像是写文章的草稿,草稿写好了,正文自然就顺畅了。只是很多人不喜欢写草稿,觉得浪费时间,可实际上草稿才是最能理清思路的地方。

完成所有工件后:

/opsx:apply

AI 会读取所有上下文文件,按照 tasks.md 中的清单逐步执行任务。这时候因为有了清晰的规范,生成的代码质量会高很多。

其实到了这一步,事情就已经成了一半了。有了明确的任务清单,剩下的就是按部就班地执行罢了。只是很多人跳过了前面的步骤,直接到这里,那质量自然就难以保证了。

变更完成后:

/opsx:archive

将完成的变更移动到 archive/ 目录,方便以后查阅和复用。

归档这事儿挺重要的,就像把写完的文章好好收起来一样。以后遇到类似的问题,翻一翻以前的记录,可能就有答案了。只是很多人懒得做,觉得麻烦,可实际上这些积累才是最宝贵的财富。

使用 kebab-case 格式,以字母开头,仅包含小写字母、数字和连字符:

  • add-user-auth
  • AddUserAuth
  • add--user-auth

命名规范这东西,说起来也没啥大不了的,只是统一一点总归是好的。毕竟代码这事儿, consistency 很重要,只是很多人不在意罢了。

  1. 在三步流程的步骤 1 使用错误的类型 - 会过早转换状态
  2. 忘记在最后一步触发状态转换 - 会卡在 Openspecing 状态
  3. 跳过审查直接执行 - 应该先验证所有工件完整

这些错误其实都是新手容易犯的,老手自然知道怎么避免。只是新手总有变老手的一天,走了弯路也就罢了,只希望不要走太多弯路就好。

OpenSpec 支持同时管理多个提案,这在处理大型功能时特别有用:

Terminal window
# 查看所有活动变更
openspec list
# 切换到特定变更
openspec apply "add-user-auth"
# 查看变更状态
openspec status --change "add-user-auth"

多变更管理这事儿,就像同时写几篇文章一样,需要一点技巧和耐心。只是习惯了就好了,毕竟人嘛,总能适应的。

了解状态转换有助于排查问题:

Init → Drafting → Openspecing → Reviewing → Executing → ExecutionCompleted → Completed → Archived
  • Openspecing:生成规划中
  • Reviewing:审查中(可反复修改工件)
  • Executing:执行中(应用 tasks.md)

状态机这东西,说白了就是一套规则。规则这东西,有时候挺烦人的,但更多时候是有用的。毕竟没有规矩不成方圆,这话古人早就说过了。

通过 OpenSpec 流程,HagiCode 项目在解决 AI 幻觉问题上取得了显著效果:

  1. 减少幻觉 - AI 必须遵循结构化规范,不能随意生成代码
  2. 提高质量 - 多层验证确保变更符合项目标准
  3. 加速协作 - 归档的变更为后续开发提供参考
  4. 可追溯性 - 每个变更都有完整的提案、设计、规范和任务记录

这套方案不是让 AI 变聪明,而是给它套上”规范”的笼子。实践证明,带着脚镣跳舞,反而跳得更好。

其实这道理也简单,约束不一定是什么坏事。就像写文章,有了格式的约束,反而更容易写出好东西来。只是很多人不喜欢约束,觉得限制了自己的创造力,可实际上创造力也需要土壤才能开花结果。

如果你也在使用 AI 编程助手,并且遇到过类似的问题,不妨试试 OpenSpec。规范驱动开发可能看起来多了一些步骤,但这些前期投入会在代码质量和维护效率上得到数倍的回报。

毕竟,慢一点,有时候反而是快一点。只是很多人不明白这个道理罢了…


如果本文对你有帮助,欢迎来 GitHub 给个 Star。HagiCode 公测已开始,现在安装即可参与体验。


这文章写得也差不多了,其实也没什么特别高深的东西,只是把一些实践经验总结了一下罢了。希望对大家有用,毕竟分享这东西,自己学到了,也让别人学到了,两全其美,何乐而不为呢?

只是文章终究是文章,真正有用的还是实践。毕竟纸上得来终觉浅,绝知此事要躬行,古人诚不我欺…

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

GLM-5.1 全面支持与 Gemini CLI 集成:HagiCode 的多模型进化之路

GLM-5.1 全面支持与 Gemini CLI 集成:HagiCode 的多模型进化之路

Section titled “GLM-5.1 全面支持与 Gemini CLI 集成:HagiCode 的多模型进化之路”

本文介绍了 HagiCode 平台近期的重要更新——智谱 AI GLM-5.1 模型的全面支持,以及 Gemini CLI 作为第十个 Agent CLI 的成功集成。这两项更新进一步强化了平台的多模型能力和多 CLI 生态。

时间过得真快,大语言模型的发展就像春天的竹子一样,蹭蹭地往上窜。曾经我们还在为”一个能写代码的 AI”而欢呼雀跃,如今已是多模型协同、多工具融合的时代了。这有意思吗?或许吧,毕竟开发者需要的从来都不只是工具本身,而是一种能够适应不同场景、灵活切换的从容。

HagiCode 作为一个 AI 辅助编码平台,最近也算是迎来了两件大事:一是智谱 AI 的 GLM-5.1 模型全面接入,二是 Gemini CLI 正式成为第十个支持的 Agent CLI。这两件事说大不大,说小也不小,只是对于平台的完善而言,总归是好事一桩。

GLM-5.1 是智谱 AI 的最新旗舰模型,相比 GLM-5.0,推理能力更强了,代码理解更深了,工具调用也更顺滑了。更重要的是,它是首个支持图片输入的 GLM 模型——这意味着什么?意味着用户可以直接截图让 AI 看问题,不用再费劲巴力地描述了。这便利性,用过就懂了。

与此同时,HagiCode 通过 HagiCode.Libs.Providers 架构,把 Gemini CLI 成功集成了进来。这是第十个 Agent CLI 了,说实话,能走到这一步,也算是有些许成就感罢了。

值得一提的是,HagiCode 的图片上传功能让用户可以直接截图与 AI 交流。即使运行的是 GLM 4.7 版本,平台依然能够良好运行,并且已经帮助项目完成了许多重要的构建工作。至于 GLM-5.1?那自然会更进一步。

本文分享的方案来自我们在 HagiCode 项目中的实践经验。HagiCode 是一个开源的 AI 辅助编码平台,旨在通过多模型、多 CLI 的架构设计,为开发者提供灵活、强大的 AI 编程助手。项目地址:github.com/HagiCode-org/site

HagiCode 的核心优势之一,就是通过统一的抽象层支持多种不同的 AI 编程 CLI 工具。这种设计的好处,说穿了也就那么回事:新东西能进来,旧东西能留下,代码还不乱。毕竟,谁都希望生活能这样吧?

平台通过 AIProviderType 枚举定义了支持的 CLI 提供商类型:

public enum AIProviderType
{
ClaudeCodeCli = 0, // Claude Code CLI
CodexCli = 1, // GitHub Copilot Codex
GitHubCopilot = 2, // GitHub Copilot
CodebuddyCli = 3, // Codebuddy CLI
OpenCodeCli = 4, // OpenCode CLI
IFlowCli = 5, // IFlow CLI
HermesCli = 6, // Hermes CLI
QoderCli = 7, // Qoder CLI
KiroCli = 8, // Kiro CLI
KimiCli = 9, // Kimi CLI
GeminiCli = 10, // Gemini CLI (新增)
}

可以看到,Gemini CLI 作为第十个成员加入了这个大家庭。每个 CLI 都有独特的特点和适用场景,用户可以根据自己的需求灵活选择。毕竟,条条大路通罗马,只是有的路好走一点,有的路稍微曲折一点罢了。

HagiCode.Libs.Providers 提供了统一的 Provider 接口,让每个 CLI 的集成变得规范而简洁。以 Gemini CLI 为例:

public class GeminiProvider : ICliProvider<GeminiOptions>
{
private static readonly string[] DefaultExecutableCandidates = ["gemini", "gemini-cli"];
private const string ManagedBootstrapArgument = "--acp";
public string Name => "gemini";
public bool IsAvailable => _executableResolver.ResolveFirstAvailablePath(DefaultExecutableCandidates) is not null;
}

这种设计的好处是:

  • 新 CLI 的集成只需要实现一个 Provider 类
  • 统一的生命周期管理和会话池化
  • 自动化的别名解析和可执行文件查找

说穿了,这种设计其实就是把复杂的事情简单化,让生活更轻松一点罢了。

Provider Registry 自动处理别名映射和注册:

if (provider is GeminiProvider)
{
registry.Register(provider.Name, provider, ["gemini-cli"]);
continue;
}

这意味着用户可以使用 geminigemini-cli 两种方式来调用 Gemini CLI,系统会自动识别。这就像你朋友多,有的叫大名,有的叫小名,反正都是他,怎么叫都行。

GLM-5.1 是智谱 AI 的最新旗舰模型,HagiCode 已完成对其的全面支持。

HagiCode 通过 Secondary Professions Catalog 管理所有支持的模型。以下是 GLM 系列的配置:

Model IDNameSupportsImageCompatible CLI Families
glm-4.7GLM 4.7-claude, codebuddy, hermes, qoder, kiro
glm-5GLM 5-claude, codebuddy, hermes, qoder, kiro
glm-5-turboGLM 5 Turbo-claude, codebuddy, hermes, qoder, kiro
glm-5.0GLM 5.0 (Legacy)-claude, codebuddy, hermes, qoder, kiro
glm-5.1GLM 5.1trueclaude, codebuddy, hermes, qoder, kiro

GLM-5.1 的关键特性可以总结为:

  • 独立的版本标识,没有 legacy 包袱
  • 首个支持图片输入的 GLM 模型
  • 更强的推理能力和代码理解
  • 广泛的多 CLI 兼容性

从代码层面来看,GLM-5.1 与 GLM-5.0 的关键区别:

// GLM-5.0 (Legacy) - 有特殊保留逻辑
private const string Glm50CodebuddySecondaryProfessionId = "secondary-glm-5-codebuddy";
private const string Glm50CodebuddyModelValue = "glm-5.0";
// GLM-5.1 - 独立的新模型标识
private const string Glm51SecondaryProfessionId = "secondary-glm-5-1";
private const string Glm51ModelValue = "glm-5.1";

GLM-5.0 带有 “Legacy” 标记,是为了向后兼容而保留的旧版本标识。而 GLM-5.1 是一个全新的独立版本,没有任何历史包袱。这就像有些人,总是活在过去;而有些人,轻装上阵,走得更快罢了。

在 HagiCode 中使用 GLM-5.1 的配置示例:

{
"primaryProfessionId": "profession-claude-code",
"secondaryProfessionId": "secondary-glm-5-1",
"model": "glm-5.1",
"reasoning": "high"
}

HagiCode 的图片支持是通过 SecondaryProfession 的 SupportsImage 属性实现的:

public class HeroSecondaryProfessionSettingDto
{
public bool SupportsImage { get; set; }
}

在 Secondary Professions Catalog 中,GLM-5.1 的配置如下:

{
"id": "secondary-glm-5-1",
"supportsImage": true
}

这意味着用户可以直接上传截图让 AI 分析,比如:

  • 错误信息的截图
  • UI 界面的问题
  • 数据可视化图表
  • 代码运行结果

不用再手动描述问题了,直接截图就行——这个功能的便利性用过就知道了。毕竟,有些事情,说再多不如看一眼。

Gemini CLI 作为第十个 Agent CLI,通过标准的 Provider 架构集成到 HagiCode 中。

Gemini CLI 支持丰富的配置选项:

public class GeminiOptions
{
public string? ExecutablePath { get; set; }
public string? WorkingDirectory { get; set; }
public string? SessionId { get; set; }
public string? Model { get; set; }
public string? AuthenticationMethod { get; set; }
public string? AuthenticationToken { get; set; }
public Dictionary<string, string?> AuthenticationInfo { get; set; }
public Dictionary<string, string?> EnvironmentVariables { get; set; }
public string[] ExtraArguments { get; set; }
public TimeSpan? StartupTimeout { get; set; }
public CliPoolSettings? PoolSettings { get; set; }
}

这些选项覆盖了从基本配置到高级特性的方方面面,用户可以根据自己的需求进行灵活配置。毕竟,每个人的需求都不一样,能灵活一点总是好的。

Gemini CLI 支持 ACP (Agent Communication Protocol) 通信协议,这是 HagiCode 统一的 CLI 通信标准。通过 ACP,不同的 CLI 可以以一致的方式与平台交互,大大简化了集成工作。说穿了,就是把复杂的事情统一化,让大家都能轻松一点罢了。

使用智谱 AI 的模型,需要配置相应的环境变量。

Terminal window
export ANTHROPIC_AUTH_TOKEN="your-zai-api-key"
export ANTHROPIC_BASE_URL="https://open.bigmodel.cn/api/anthropic"
Terminal window
export ANTHROPIC_AUTH_TOKEN="your-aliyun-api-key"
export ANTHROPIC_BASE_URL="https://coding.dashscope.aliyuncs.com/apps/anthropic"

配置完成后,HagiCode 就可以正常调用 GLM-5.1 模型了。这事儿说难也不难,说简单也不简单,反正照着做就是了。

说到实践,最好的例子就是 HagiCode 平台自身的构建流程。HagiCode 的开发过程已经充分利用了 AI 能力:

HagiCode 平台的设计优化得比较好,即使使用 GLM 4.7 也能获得良好的开发体验。平台已帮助完成多个重要构建项目,包括:

  • 多 CLI Provider 的集成
  • 图片上传功能的实现
  • 文档生成和内容发布

这其实也挺好,毕竟不是所有人都需要用最新的东西。适合自己的,才是最好的。

升级到 GLM-5.1 后,这些能力将得到进一步增强:

  • 更强的代码理解能力,减少来回沟通
  • 更准确的依赖分析,一次性指对方向
  • 更高效的错误诊断,快速定位问题
  • 支持图片输入,加速问题描述

这就像从自行车换到汽车,能到的地方是一样的,只是速度和舒适度不一样罢了。

HagiCode.Libs.Providers 提供了统一的注册和使用机制:

services.AddHagiCodeLibs();
var gemini = serviceProvider.GetRequiredService<ICliProvider<GeminiOptions>>();
var codebuddy = serviceProvider.GetRequiredService<ICliProvider<CodebuddyOptions>>();
var hermes = serviceProvider.GetRequiredService<ICliProvider<HermesOptions>>();

这种依赖注入的设计让各个 CLI 的使用变得非常简洁,也方便进行单元测试和模拟。毕竟,代码写得干净一点,对自己也是一种负责。

在实际使用中,有几个地方需要注意:

  1. API Key 配置:确保正确设置 ANTHROPIC_AUTH_TOKEN,否则无法调用模型
  2. 模型可用性:GLM-5.1 需要在对应的模型提供商处开通权限
  3. 图片功能:只有支持 supportsImage: true 的模型才能使用图片上传功能
  4. CLI 安装:使用 Gemini CLI 前,确保 geminigemini-cli 在系统 PATH 中

这些都是小事,但小事处理不好,也可能变成大事。所以还是要注意一下的。

通过 GLM-5.1 的全面支持和 Gemini CLI 的成功集成,HagiCode 进一步强化了其作为多模型、多 CLI AI 编程平台的能力。这些更新不仅为用户提供了更多的选择,也展示了 HagiCode 在架构设计上的前瞻性和可扩展性。

GLM-5.1 的图片支持能力,结合 HagiCode 的截图上传功能,让”看图说话”成为可能——大大降低了问题描述的成本。而十个 CLI 的支持,意味着用户可以根据自己的偏好和场景,灵活选择最合适的 AI 编程助手。毕竟,选择多了,总是好事。

最重要的是,HagiCode 平台自身的构建实践证明:即使使用 GLM 4.7,平台也能良好运行并完成复杂任务;而升级到 GLM-5.1 后,开发效率将得到进一步提升。这就像人生一样,不一定非要追求最好,适合自己的就好。当然,如果能在适合自己的基础上变得更好,那自然更好。

如果你对多模型、多 CLI 的 AI 编程平台感兴趣,不妨试试 HagiCode——开源、免费、不断进化。反正试试又不花钱,万一真适合你呢?

如果你想直接上手这套多模型、多 CLI 的工作流,可以从下面几个入口开始:

公测已经开始,想亲手试试 GLM-5.1、Gemini CLI 和 HagiCode 多 CLI 组合的话,现在就可以装起来跑一遍。

Hagicode 联合 GLM-5.1 多 CLI 集成指南

Hagicode 联合 GLM-5.1 多 CLI 集成指南

Section titled “Hagicode 联合 GLM-5.1 多 CLI 集成指南”

在 Hagicode 项目中,用户可以选择多种 CLI 工具来驱动 AI 编程助手,包括 Claude Code CLI、GitHub Copilot、OpenCode CLI、Codebuddy CLI、Hermes CLI 等。这些 CLI 工具本身是通用的 AI 编程工具,但通过 Hagicode 的抽象层,可以灵活地接入不同的 AI 模型提供商。

智谱 AI(ZAI)提供了与 Anthropic Claude API 兼容的接口,使得这些 CLI 工具可以直接使用国产 GLM 系列模型。其中 GLM-5.1 是智谱最新发布的大语言模型,相比 GLM-5.0 有显著的改进。

Hagicode 通过 AIProviderType 枚举定义了 11 种 CLI 提供商类型,覆盖了主流的 AI 编程 CLI 工具:

public enum AIProviderType
{
ClaudeCodeCli = 0, // Claude Code CLI
CodexCli = 1, // GitHub Copilot Codex
GitHubCopilot = 2, // GitHub Copilot
CodebuddyCli = 3, // Codebuddy CLI
OpenCodeCli = 4, // OpenCode CLI
IFlowCli = 5, // IFlow CLI
HermesCli = 6, // Hermes CLI
QoderCli = 7, // Qoder CLI
KiroCli = 8, // Kiro CLI
KimiCli = 9, // Kimi CLI
GeminiCli = 10, // Gemini CLI
}

每种 CLI 都有对应的模型参数配置,支持 modelreasoning 参数:

private static readonly IReadOnlyDictionary<AIProviderType, IReadOnlyList<string>> ManagedModelParameterKeysByProvider =
new Dictionary<AIProviderType, IReadOnlyList<string>>
{
[AIProviderType.ClaudeCodeCli] = ["model", "reasoning"],
[AIProviderType.CodexCli] = ["model", "reasoning"],
[AIProviderType.OpenCodeCli] = ["model", "reasoning"],
[AIProviderType.HermesCli] = ["model", "reasoning"],
[AIProviderType.CodebuddyCli] = ["model", "reasoning"],
[AIProviderType.QoderCli] = ["model", "reasoning"],
[AIProviderType.KiroCli] = ["model", "reasoning"],
[AIProviderType.GeminiCli] = ["model"], // Gemini 不支持 reasoning 参数
// ...
};

Hagicode 的 Secondary Professions Catalog 中定义了完整的 GLM 系列模型支持:

Model IDNameDefault ReasoningCompatible CLI Families
glm-4.7GLM 4.7highclaude, codebuddy, hermes, qoder, kiro
glm-5GLM 5highclaude, codebuddy, hermes, qoder, kiro
glm-5-turboGLM 5 Turbohighclaude, codebuddy, hermes, qoder, kiro
glm-5.0GLM 5.0 (Legacy)highclaude, codebuddy, hermes, qoder, kiro
glm-5.1GLM 5.1highclaude, codebuddy, hermes, qoder, kiro

AcpSessionModelBootstrapper.cs 的实现中,我们可以清楚地看到 GLM-5.1 与 GLM-5.0 的区别:

GLM-5.1 是独立的新模型标识,没有任何 legacy 处理逻辑:

private const string Glm51ModelValue = "glm-5.1";

在 Secondary Professions Catalog 中的定义:

{
"id": "secondary-glm-5-1",
"name": "GLM 5.1",
"family": "anthropic",
"summary": "hero.professionCopy.secondary.glm51.summary",
"sourceLabel": "hero.professionCopy.sources.aiSharedAnthropicModel",
"sortOrder": 64,
"supportsImage": true,
"compatiblePrimaryFamilies": [
"claude",
"codebuddy",
"hermes",
"qoder",
"kiro"
],
"defaultParameters": {
"model": "glm-5.1",
"reasoning": "high"
}
}

智谱 AI 提供最完整的 GLM 模型支持:

{
"providerId": "zai",
"name": "智谱 AI",
"description": "智谱 AI 提供的 Claude API 兼容服务",
"category": "china-providers",
"apiUrl": {
"codingPlanForAnthropic": "https://open.bigmodel.cn/api/anthropic"
},
"recommended": true,
"region": "cn",
"defaultModels": {
"sonnet": "glm-4.7",
"opus": "glm-5",
"haiku": "glm-4.5-air"
},
"supportedModels": [
"glm-4.7",
"glm-5",
"glm-4.5-air",
"qwen3-coder-next",
"qwen3-coder-plus"
],
"features": ["experimental-agent-teams"],
"authTokenEnv": "ANTHROPIC_AUTH_TOKEN",
"referralUrl": "https://www.bigmodel.cn/claude-code?ic=14BY54APZA",
"documentationUrl": "https://open.bigmodel.cn/dev/api"
}

特点

  • 支持最多样的 GLM 模型变体
  • 提供 Sonnet/Opus/Haiku 三层级的默认映射
  • 支持 experimental-agent-teams 功能

Claude Code CLI 是 Hagicode 的核心 CLI 之一,通过 Hero 配置系统设置:

{
"primaryProfessionId": "profession-claude-code",
"secondaryProfessionId": "secondary-glm-5-1",
"model": "glm-5.1",
"reasoning": "high"
}

对应的 HeroEquipmentCatalogItem 配置:

{
id: 'secondary-glm-5-1',
name: 'GLM 5.1',
family: 'anthropic',
kind: 'model',
primaryFamily: 'claude',
compatiblePrimaryFamilies: ['claude', 'codebuddy', 'hermes', 'qoder', 'kiro'],
defaultParameters: {
model: 'glm-5.1',
reasoning: 'high'
}
}

OpenCode CLI 是最灵活的 CLI,支持 provider/model 格式指定任意模型:

方式一:使用 ZAI provider 前缀

{
"primaryProfessionId": "profession-opencode",
"model": "zai/glm-5.1",
"reasoning": "high"
}

方式二:直接使用模型 ID

{
"model": "glm-5.1"
}

方式三:前端配置界面

HeroModelEquipmentForm.tsx 中,OpenCode CLI 有特殊的占位符提示:

const OPEN_CODE_MODEL_PLACEHOLDER = 'myprovider/glm-4.7';
const modelPlaceholder = primaryProviderType === PCode_Models_AIProviderType.OPEN_CODE_CLI
? OPEN_CODE_MODEL_PLACEHOLDER
: 'gpt-5.4';

用户可以输入:

zai/glm-5.1
glm-5.1

OpenCode CLI 的模型解析逻辑

internal OpenCodeModelSelection? ResolveModelSelection(string? rawModel)
{
var normalized = NormalizeOptionalValue(rawModel);
if (normalized == null) return null;
var slashIndex = normalized.IndexOf('/');
if (slashIndex < 0)
{
// 无斜杠:直接使用模型 ID
return new OpenCodeModelSelection {
ProviderId = string.Empty,
ModelId = normalized,
};
}
// 有斜杠:解析 provider/model 格式
var providerId = normalized[..slashIndex].Trim();
var modelId = normalized[(slashIndex + 1)..].Trim();
return new OpenCodeModelSelection {
ProviderId = providerId,
ModelId = modelId,
};
}

Codebuddy CLI 有特殊的 legacy 处理逻辑:

{
"primaryProfessionId": "profession-codebuddy",
"model": "glm-5.1",
"reasoning": "high"
}

注意:Codebuddy 对 GLM-5.0 有特殊保留,不走 legacy normalization:

return !string.Equals(providerName, "CodebuddyCli", StringComparison.OrdinalIgnoreCase)
&& string.Equals(normalizedModel, LegacyGlm5TurboModelValue, StringComparison.OrdinalIgnoreCase)
? Glm5TurboModelValue
: normalizedModel;
// CodebuddyCli 时,glm-5.0 不会被规范化为 glm-5-turbo
Terminal window
# 设置 API Key
export ANTHROPIC_AUTH_TOKEN="your-zai-api-key"
# 可选:指定 API 端点(ZAI 默认使用此端点)
export ANTHROPIC_BASE_URL="https://open.bigmodel.cn/api/anthropic"
Terminal window
# 设置 API Key
export ANTHROPIC_AUTH_TOKEN="your-aliyun-api-key"
# 指定阿里云端点
export ANTHROPIC_BASE_URL="https://coding.dashscope.aliyuncs.com/apps/anthropic"

相比 GLM-5.0,GLM-5.1 有以下显著改进:

根据智谱官方发布信息,GLM-5.1 的改进包括:

  • 更强的代码理解能力:对复杂代码结构的解析更准确
  • 更长的上下文理解:支持更长的对话上下文
  • 工具调用增强:MCP 工具调用的成功率提升
  • 输出稳定性:减少随机性和幻觉

GLM-5.1 覆盖了 Hagicode 支持的所有主流 CLI:

compatiblePrimaryFamilies: [
"claude", // Claude Code CLI
"codebuddy", // Codebuddy CLI
"hermes", // Hermes CLI
"qoder", // Qoder CLI
"kiro" // Kiro CLI
]

确保正确设置 ANTHROPIC_AUTH_TOKEN 环境变量,这是所有 CLI 连接模型的必要凭证。

GLM-5.1 需要在对应的模型提供商处开通:

  • 智谱 AI ZAI 平台默认支持
  • 阿里云 DashScope 可能需要单独申请

使用 provider/model 格式时,确保 provider ID 正确:

  • 智谱 AI:zaizhipuai
  • 阿里云:aliyundashscope
  • 建议使用 high 级别以获得最佳代码生成效果
  • Gemini CLI 不支持 reasoning 参数,会自动忽略该配置

Hagicode 通过统一的抽象层,实现了 GLM-5.1 与多种 CLI 的灵活集成。开发者可以根据自己的偏好和使用场景,选择合适的 CLI 工具,并通过简单的配置使用最新的 GLM-5.1 模型。

GLM-5.1 作为智谱最新的模型版本,相比 GLM-5.0 有明显的改进:

  • 独立的版本标识,无 legacy 包袱
  • 更强的推理能力和代码理解
  • 广泛的多 CLI 兼容性
  • 灵活的推理级别配置

通过正确配置环境变量和 Hero 装备,玩家可以在不同的 CLI 环境中充分发挥 GLM-5.1 的强大能力。

如果你想把 GLM-5.1、多 CLI 编排和 HagiCode 的配置体系真正跑起来,可以从下面几个入口开始:

当你把 Kimi、Claude Code、OpenCode 这类 CLI 和 GLM-5.1 放到同一套抽象层里比较时,很多关于模型切换、参数映射和工程边界的问题,基本都会一下子变得清楚起来。

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

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

打造 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 服务、跨平台桌面客户端的复杂项目,我们在 2026-03 的当时版本 里需要同时对接来自不同公司的多个 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 助手,希望本文能给你一些参考。当然,每个项目的情况不同,具体方案还需要根据实际情况调整。毕竟没有放之四海而皆准的方案,适合自己的才是最好的。

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

打造 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 展示

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

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

如果本文对你有帮助:


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

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

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


如果本文对你有帮助:

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 编码助手体验。


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

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

豆包语音识别热词功能实现指南

豆包语音识别热词功能实现指南

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 项目。你的认可,是我们持续分享技术实践的动力。说到底,写文章分享技术,能帮到人,也算是种快乐了。


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

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

AI Compose Commit:用 AI 智能重构 Git 提交工作流

AI Compose Commit:用 AI 智能重构 Git 提交工作流

Section titled “AI Compose Commit:用 AI 智能重构 Git 提交工作流”

在软件开发过程中,提交代码是程序员每天都要面对的日常工作。可是你有没有经历过这样的场景:一天工作结束后,打开 Git 看到几十个未暂存的修改文件,却不知道该如何将它们组织成合理的提交?

传统的方式是手动将文件分批暂存、逐个提交、撰写提交信息,这个过程既耗时又容易出错。咱们就常常在这上面浪费了不少时间,毕竟谁也不想在已经疲惫的晚上还要为这些琐事烦心。

我们在 HagiCode 项目中推出了一项新功能——AI Compose Commit,旨在彻底改变这个工作流程。它通过 AI 智能分析工作区中的所有未提交变更,自动将它们分组为多个逻辑提交,并执行符合规范的提交操作。本文将深入探讨这个功能的实现原理、技术架构以及我们在实践中遇到的挑战与解决方案。

本文分享的方案来自我们在 HagiCode 项目中的实践经验。

Git 作为版本控制系统,为开发者提供了强大的代码管理能力。但在实际使用中,提交操作往往成为开发流程中的瓶颈:

  1. 手动分组耗时: 当有大量文件变更时,开发者需要逐个检查文件内容,判断哪些属于同一个功能,这需要耗费大量脑力
  2. 提交信息质量参差: 撰写符合 Conventional Commits 规范的提交信息需要经验和技巧,新手常常写出不规范的提交
  3. 多仓库管理复杂: 在 monorepo 环境中,需要在不同仓库间切换,增加了操作复杂度
  4. 工作流被打断: 提交代码会打断开发思路,影响编码效率

这些问题在大型项目和团队协作环境中尤为明显。一个优秀的开发工具应该让开发者专注于核心的编码工作,而不是被繁琐的提交流程所困扰。

近年来,AI 技术在软件开发领域的应用日益广泛。从代码补全、错误检测到自动生成文档,AI 正在逐步渗透到开发的各个环节。在 Git 工作流方面,虽然已有一些工具提供提交信息生成的功能,但大多局限于单次提交的场景,缺乏对整个工作区变更的智能分析和分组能力。

其实 HagiCode 在开发过程中也遇到了这些痛点,我们曾尝试过多种工具,但都或多或少存在一些局限性。要么是功能不够完善,要么是用户体验不够好。这也是为什么我们最终决定自己实现 AI Compose Commit 功能的原因。

HagiCode 的 AI Compose Commit 功能正是为了填补这一空白而生,它不仅是生成提交信息,而是完整接管从文件分析到执行提交的整个流程。

在实现 AI Compose Commit 功能的过程中,我们面临了多个技术挑战:

  1. 文件语义理解: AI 需要理解文件变更的语义关系,判断哪些文件属于同一个功能模块。这需要深入分析文件内容、目录结构以及变更的上下文。

  2. 提交分组策略: 如何定义合理的分组标准?是按功能、按模块,还是按文件类型?不同的项目可能适用不同的策略。

  3. 实时反馈与异步处理: Git 操作可能需要较长时间,特别是处理大量文件时。如何在保证用户体验的同时完成复杂操作?

  4. 多仓库支持: 在 monorepo 架构下,需要在主仓库和子仓库之间正确路由操作。

  5. 错误处理与回滚: 如果某个提交失败,如何处理已执行的提交?是否需要回滚已暂存的文件?

  6. 提交信息一致性: 生成的提交信息需要符合项目现有的风格,保持历史提交的格式一致。

AI 处理大量文件变更会消耗显著的时间和计算资源。我们需要在以下方面进行优化:

  • 减少不必要的 AI 调用
  • 优化文件上下文的构建方式
  • 实现高效的 Git 操作批处理

这些问题在 HagiCode 的实际使用中都真实出现过,我们通过不断的迭代和优化才找到了相对完美的解决方案。如果你也在开发类似的工具,希望我们的经验能给你一些启发。

我们采用了分层架构来实现 AI Compose Commit 功能,确保系统具有良好的可扩展性和可维护性:

GitController 提供了 POST /api/git/auto-compose-commit 端点,作为功能入口。为了优化用户体验,我们采用了 Fire-and-Forget 异步模式:

  • 客户端发起请求后,服务器立即返回 HTTP 202 Accepted
  • 实际的 AI 处理在后台异步执行
  • 处理完成后通过 SignalR 通知客户端

这种设计确保了即使 AI 处理需要几分钟,用户也能立即得到响应,不会感觉系统卡顿。

GitAppService 负责核心业务逻辑:

  • 仓库检测:支持 monorepo 中的多仓库管理
  • 锁管理:防止并发操作导致的冲突
  • 文件暂存协调:与 AI 处理流程的交互
  • 错误回滚:处理失败场景下的状态恢复

AIGrain 作为 AI 操作的核心执行单元,实现了 IAIGrain 接口中的 AutoComposeCommitAsync 方法:

// 定义 AI 自动组合提交的接口方法
// 参数说明:
// - projectId: 项目唯一标识符
// - unstagedFiles: 未暂存文件列表,包含文件路径和状态信息
// - projectPath: 项目根目录路径(可选),用于访问项目上下文
// 返回值: 包含执行结果的响应对象,包括成功/失败状态和详细信息
[Alias("AutoComposeCommitAsync")]
[ResponseTimeout("00:20:00")] // 20 分钟超时,适用于处理大型变更集
Task<AutoComposeCommitResponseDto> AutoComposeCommitAsync(
string projectId,
GitFileStatusDto[] unstagedFiles,
string? projectPath = null);

这个方法设置了 20 分钟的超时时间,以处理大型变更集。HagiCode 在实际使用中发现,有些项目的单次变更可能涉及上百个文件,需要更长的处理时间。

通过抽象的 IAIService 接口,我们实现了 AI 服务的可插拔架构。目前使用 Claude Helper 服务,但可以轻松切换到其他 AI 提供商。

AI 需要了解每个文件的状态才能做出智能决策。我们通过 BuildFileChangesXml 方法构建文件上下文:

/// <summary>
/// 构建文件变更的 XML 表示形式,用于为 AI 提供完整的文件上下文信息
/// </summary>
/// <param name="stagedFiles">已暂存的文件列表,包含文件路径、状态和旧路径(针对重命名操作)</param>
/// <returns>格式化的 XML 字符串,包含所有文件的元数据信息</returns>
private static string BuildFileChangesXml(GitFileStatusDto[] stagedFiles)
{
var sb = new StringBuilder();
sb.AppendLine("<files>");
foreach (var file in stagedFiles)
{
sb.AppendLine(" <file>");
// 使用 XML 转义确保特殊字符不会破坏 XML 结构
sb.AppendLine($" <path>{System.Security.SecurityElement.Escape(file.Path)}</path>");
sb.AppendLine($" <status>{System.Security.SecurityElement.Escape(file.Status)}</status>");
// 处理文件重命名场景,记录旧路径以便 AI 理解变更关系
if (!string.IsNullOrEmpty(file.OldPath))
{
sb.AppendLine($" <oldPath>{System.Security.SecurityElement.Escape(file.OldPath)}</oldPath>");
}
sb.AppendLine(" </file>");
}
sb.AppendLine("</files>");
return sb.ToString();
}

这个 XML 格式的上下文包含文件路径、状态和旧路径(针对重命名操作),为 AI 提供了完整的元数据。通过结构化的 XML 格式,我们确保了 AI 能够准确理解每个文件的状态和变更类型。

为了让 AI 能够直接执行 Git 操作,我们配置了全面的工具权限:

// 定义 AI 可以使用的工具集合,包括文件操作和 Git 命令执行权限
// Read/Write/Edit: 文件读写和编辑能力
// Bash(git:*): 执行所有 Git 命令的权限
// 其他 Bash 命令: 用于查看文件内容和目录结构,辅助 AI 理解上下文
var allowedTools = new[]
{
"Read", "Write", "Edit",
"Bash(git:*)", "Bash(cat:*)", "Bash(ls:*)", "Bash(find:*)",
"Bash(grep:*)", "Bash(head:*)", "Bash(tail:*)", "Bash(wc:*)"
};
// 构建完整的 AI 请求对象
var request = new AIRequest
{
Prompt = prompt, // 完整的 Prompt 模板,包含任务指令和约束条件
WorkingDirectory = projectPath ?? GetTempDirectory(), // 工作目录,确保 AI 在正确的项目上下文中执行
AllowedTools = allowedTools, // 允许使用的工具集合
PermissionMode = PermissionMode.bypassPermissions, // 绕过权限检查,允许直接执行 Git 操作
LanguagePreference = languagePreference // 语言偏好设置,确保生成符合用户期望的提交信息
};

这里使用了 PermissionMode.bypassPermissions 模式,允许 AI 直接执行 Git 命令而无需用户确认。这是功能设计的核心,但同时也需要严格的输入验证来防止滥用。HagiCode 在实际部署中,通过后端的参数验证和日志监控,确保了这个机制的安全性。

AI 执行完成后,会返回结构化的结果。我们实现了双重解析策略以确保兼容性:

/// <summary>
/// 解析 AI 返回的提交执行结果,支持分隔符格式和正则表达式格式
/// </summary>
/// <param name="aiResponse">AI 返回的原始响应内容</param>
/// <returns>解析后的提交结果列表,每个结果包含提交哈希和执行状态</returns>
private List<CommitResultDto> ParseCommitExecutionResults(string aiResponse)
{
var results = new List<CommitResultDto>();
// 优先使用分隔符解析(新格式),这种格式更加明确和可靠
if (aiResponse.Contains("---"))
{
logger.LogDebug("Using delimiter-based parsing for AI response");
results = ParseDelimitedFormat(aiResponse);
if (results.Count > 0)
{
return results; // 成功解析,直接返回结果
}
logger.LogWarning("Delimiter-based parsing produced no results, falling back to regex");
}
else
{
logger.LogDebug("No delimiter found, using legacy regex-based parsing");
}
// 回退到正则表达式解析(旧格式),确保向后兼容性
return ParseLegacyFormat(aiResponse);
}

分隔符格式使用 --- 作为提交之间的分隔,格式清晰且易于解析:

---
Commit 1: abc123def456
feat(auth): add user login functionality
Implement JWT-based authentication with login form and API endpoints.
Co-Authored-By: Hagicode <noreply@hagicode.com>
---
Commit 2: 789ghi012jkl
docs(readme): update installation instructions
Add new setup steps for Docker environment.
Co-Authored-By: Hagicode <noreply@hagicode.com>
---

这种格式设计让解析变得简单可靠,同时人类阅读也很清晰。

为了防止并发操作导致的状态冲突,我们实现了仓库锁机制:

// 获取仓库锁,防止并发操作
// 参数说明:
// - fullPath: 仓库的完整路径,用于标识不同的仓库实例
// - requestedBy: 请求者标识,用于追踪和日志记录
await _autoComposeLockService.AcquireLockAsync(fullPath, requestedBy);
try
{
// 执行 AI Compose Commit 操作
// 这部分代码会调用 Orleans Grain 的方法,执行实际的 AI 处理和 Git 操作
await aiGrain.AutoComposeCommitAsync(projectId, unstagedFiles, projectPath);
}
finally
{
// 确保锁被释放,无论操作成功或失败
// 使用 finally 块可以保证异常情况下也能释放锁,避免死锁
await _autoComposeLockService.ReleaseLockAsync(fullPath);
}

锁具有 20 分钟的超时时间,与 AI 操作的超时设置保持一致。如果操作失败或超时,系统会自动释放锁,避免永久阻塞。HagiCode 在实际使用中发现,这个锁机制非常重要,特别是在团队协作环境中,多个开发者可能同时触发 AI Compose Commit 操作。

处理完成后,系统通过 SignalR 向前端发送通知:

/// <summary>
/// 发送自动组合提交完成的通知
/// </summary>
/// <param name="projectId">项目标识符,用于路由通知到正确的客户端</param>
/// <param name="totalCount">总提交数量,包括成功和失败</param>
/// <param name="successCount">成功提交的数量</param>
/// <param name="failureCount">失败提交的数量</param>
/// <param name="success">整体操作是否成功标志</param>
/// <param name="error">错误信息(如果操作失败)</param>
private async Task SendAutoComposeCommitNotificationAsync(
string projectId,
int totalCount,
int successCount,
int failureCount,
bool success,
string? error)
{
try
{
// 构建通知数据传输对象,包含详细的执行结果
var notification = new AutoComposeCommitCompletedDto
{
ProjectId = projectId,
TotalCount = totalCount,
SuccessCount = successCount,
FailureCount = failureCount,
Success = success,
Error = error
};
// 通过 SignalR Hub 广播通知到所有连接的客户端
await messageService.SendAutoComposeCommitCompletedAsync(notification);
logger.LogInformation(
"Auto compose commit notification sent for project {ProjectId}: {SuccessCount}/{TotalCount} succeeded",
projectId, successCount, totalCount);
}
catch (Exception ex)
{
// 记录通知错误但不影响主操作流程
// 通知失败不应该导致整个操作失败
logger.LogError(ex, "Failed to send auto compose commit notification for project {ProjectId}", projectId);
}
}

前端收到通知后可以更新 UI,显示提交成功或失败的状态,提升用户体验。这种实时反馈机制在 HagiCode 的使用中获得了很好的用户反馈,用户可以清楚地知道操作何时完成以及结果如何。

AI 的行为完全由 Prompt 决定,我们精心设计了 Auto Compose Commit 的 Prompt 模板。以中文版本为例(auto-compose-commit.zh-CN.hbs):

Prompt 开头明确声明支持非交互式运行模式,这是 CI/CD 和自动化脚本的关键需求:

**重要提示**:此提示词可能在非交互式环境中运行(如 CI/CD、自动化脚本)。
**非交互式模式**:
- 禁止使用 AskUserQuestion 或任何交互式工具
- 当需要用户输入时:
- 使用合理的默认值(如提交类型使用 feat)
- 跳过可选的确认步骤
- 记录所做的假设

这个设计确保了 AI Compose Commit 功能不仅能在交互式 IDE 环境中使用,也能集成到 CI/CD 流程中,实现完全自动化的提交流程。

为了防止 AI 执行危险操作,我们在 Prompt 中添加了严格的分支保护规则:

**分支保护**:
- 禁止执行任何分支切换操作(git checkout、git switch)
- 所有 git commit 命令必须在当前分支上执行
- 不得创建、删除或重命名分支
- 不得修改未跟踪文件或未暂存变更
- 如果需要分支切换才能完成操作,应返回错误而非执行

这些规则通过约束 AI 的工具使用范围,确保操作的安全性。HagiCode 在实际测试中验证了这些约束的有效性,AI 在遇到需要分支切换的场景时会安全地返回错误,而不是执行危险操作。

Prompt 中详细定义了文件分组的决策逻辑:

**文件分组决策树**:
├── 是否为配置文件(package.json、tsconfig.json、.env 等)?
│ ├── 是 → 独立提交(类型:chore 或 build)
│ └── 否 → 继续
├── 是否为文档文件(README.md、*.md、docs/**)?
│ ├── 是 → 独立提交(类型:docs)
│ └── 否 → 继续
├── 是否与同一功能相关?
│ ├── 是 → 合并到同一提交
│ └── 否 → 分别提交
└── 是否为跨模块变更?
├── 是 → 按模块分组
└── 否 → 按功能分组

这个决策树为 AI 提供了清晰的分组逻辑,确保生成的提交符合语义合理性。HagiCode 在实际使用中发现,这个决策树能够处理绝大多数常见场景,生成的分组结果符合开发者预期。

为了让提交信息与项目历史保持一致,Prompt 要求 AI 在生成前分析最近的提交历史:

**历史格式一致性**:在生成提交信息之前,你**必须**分析当前仓库的提交历史以匹配现有风格
1. 使用 git log -n 15 --pretty=format:"%H|%s|%b%n---%n" 获取最近的提交历史
2. 分析提交以识别:
- 结构模式:项目是否使用多段落?是否有 "Changes:" 或 "Capabilities:" 部分?
- 语言模式:提交信息是英文、中文还是混合?
- 常用类型:最常使用哪些提交类型(feat、fix、docs 等)?
- 特殊格式:是否有 Co-Authored-By 行?其他项目特定的约定?
3. 生成遵循检测到的模式的提交信息

这个分析确保了 AI 生成的提交信息不会显得突兀,而是与项目的提交历史保持风格一致。在 HagiCode 的多语言项目中,这个功能特别重要,它能够根据项目的提交历史自动选择合适的语言和格式。

每个提交必须包含 Co-Authored-By 信息:

**重要**:每个提交必须添加 Co-Authored-By 信息
- 使用以下格式:git commit -m "type(scope): subject" -m "" -m "Co-Authored-By: Hagicode <noreply@hagicode.com>"
- 或者直接在提交信息中包含 Co-Authored-By 行

这不仅是为了贡献规范,也是为了追踪 AI 辅助的提交历史。HagiCode 将这个要求作为强制规则,确保所有 AI 生成的提交都带有明确的来源标识。

完整的 AI Compose Commit 工作流程如下:

  1. 用户触发: 用户在 Git Status 面板或 Quick Actions Zone 点击”AI Auto Compose Commit”按钮
  2. API 请求: 前端发送 POST 请求到 /api/git/auto-compose-commit 端点
  3. 立即响应: 服务器返回 HTTP 202 Accepted,不等待处理完成
  4. 后台处理:
    • GitAppService 获取仓库锁
    • 调用 AIGrain 的 AutoComposeCommitAsync 方法
    • 构建文件上下文 XML
    • 执行 AI Prompt,让 AI 分析并执行提交
  5. AI 执行:
    • 使用 Git 命令获取所有未暂存变更
    • 读取文件内容理解变更性质
    • 按语义关系对文件分组
    • 对每组执行 git addgit commit 操作
  6. 结果解析: 解析 AI 返回的执行结果
  7. 通知发送: 通过 SignalR 通知前端
  8. 锁释放: 无论成功或失败,都释放仓库锁

这个流程的设计确保了用户可以在发起操作后立即继续其他工作,而不需要等待 AI 处理完成。HagiCode 的用户反馈表明,这种异步处理方式大大提升了工作流体验。

我们实现了多层级的错误处理:

// 验证请求参数的有效性,防止无效请求到达后端处理逻辑
if (request.UnstagedFiles == null || request.UnstagedFiles.Count == 0)
{
return BadRequest(new
{
message = "No unstaged files provided. Please make changes in the working directory first.",
status = "validation_failed"
});
}

如果 AI 处理过程中出现错误,系统会执行回滚操作,将已暂存的文件取消暂存,避免留下不一致的状态。这个机制在 HagiCode 的实际使用中挽救了多次意外中断,确保了仓库状态的完整性。

20 分钟的超时设置确保了长时间运行的操作不会无限期阻塞资源。超时后,系统会释放锁并通知用户操作失败。HagiCode 在实际使用中发现,大部分操作能够在 2-5 分钟内完成,只有处理超大型变更集时才会接近超时限制。

AI Compose Commit 最适合以下场景:

  • 一天工作结束后,批量处理多个文件的变更
  • 重构操作后,多个相关文件需要分别提交
  • 功能开发完成,需要将相关变更分组提交

不适合以下场景:

  • 单个文件的快速提交(直接使用普通提交更快)
  • 需要精确控制提交内容的场景
  • 包含敏感信息的提交(需要人工审核)

虽然 AI 智能分组很强大,但开发者仍应审查生成的提交:

  • 检查提交的分组是否符合预期
  • 验证提交信息的准确性
  • 确认没有遗漏或错误包含文件

如果发现不合理的分组,可以使用 git reset --soft HEAD~N 撤销后重新分组。HagiCode 的经验表明,即使 AI 分组很智能,人工审查仍然是有价值的,特别是在重要的功能提交时。

确保项目的 Git 配置支持 Conventional Commits:

Terminal window
# 安装 commitlint
npm install -g @commitlint/cli @commitlint/config-conventional
# 配置 commitlint
echo "module.exports = {extends: ['@commitlint/config-conventional']}" > commitlint.config.js

这样可以在 CI/CD 流程中验证提交信息格式,与 AI Compose Commit 生成的格式保持一致。

如果你想在项目中实现类似的 AI 辅助提交功能,以下是我们的建议:

先实现单次提交信息生成,再逐步扩展到多提交分组功能。这样更容易验证和迭代。HagiCode 也是按照这个路径逐步完善功能的,早期版本只支持单次提交,后来才扩展到多提交智能分组。

不要自己实现 AI 调用逻辑,使用现有的 SDK 可以减少开发时间和潜在 bug。我们使用了 Claude Helper 服务,它提供了稳定的接口和完善的错误处理。

Prompt 的质量直接决定了 AI 输出的质量。投入时间设计详细的 Prompt,包括:

  • 明确的任务描述
  • 具体的输出格式要求
  • 边界情况的处理规则
  • 示例说明

HagiCode 在 Prompt 设计上投入了大量时间,这是功能成功的关键因素之一。

AI 操作可能因为各种原因失败(网络问题、API 限流、内容审查等)。确保你的系统能够优雅地处理这些错误,并提供有意义的错误信息。

不要完全自动化,给用户保留控制权。提供查看分组结果、调整分组、手动编辑提交信息等选项,平衡自动化与灵活性。HagiCode 虽然实现了自动执行,但仍然保留了预览和调整的能力。

在构建文件上下文时,过滤掉不需要 AI 分析的文件:

// 过滤掉自动生成的文件和过大的文件,减少 AI 处理负担
var relevantFiles = stagedFiles
.Where(f => !IsGeneratedFile(f.Path))
.Where(f => !IsLargeFile(f.Path))
.ToArray();

如果支持多个独立仓库,可以并行处理不同仓库的提交,提高整体效率。

缓存项目提交历史分析结果,避免每次都重新分析。可以在配置文件中存储历史格式偏好,减少 AI 调用次数。

AI Compose Commit 功能代表了 AI 技术在软件开发工具中的深度应用。通过智能分析文件变更、自动分组提交、生成规范的提交信息,它显著提升了 Git 工作流的效率,让开发者能够更专注于核心的编码工作。

在实现过程中,我们学到了几个重要的经验:

  1. 用户反馈是关键: 早期版本采用同步等待方式,用户反馈体验不佳,改为 Fire-and-Forget 模式后满意度大幅提升
  2. Prompt 设计决定质量: 一个精心设计的 Prompt 比复杂的算法更能保证 AI 输出的质量
  3. 安全永远是第一位的: 虽然赋予 AI 直接执行 Git 命令的权限带来了效率提升,但必须配合严格的约束和验证
  4. 渐进式改进: 从简单场景开始,逐步增加复杂度,比一次性实现所有功能更容易成功

未来,我们计划进一步优化 AI Compose Commit 功能,包括:

  • 支持更多提交分组策略(按时间、按开发者等)
  • 集成代码审查流程,在提交前自动触发审查
  • 支持自定义提交信息模板,满足不同项目的个性化需求

如果你觉得本文分享的方案有价值,不妨也试试 HagiCode,体验一下这个功能在实际开发中的效果。毕竟实践是检验真理的唯一标准嘛。


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

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

GitHub Issues 集成

从零构建 GitHub Issues 集成:HagiCode 的前端直连实践

Section titled “从零构建 GitHub Issues 集成:HagiCode 的前端直连实践”

本文记录了在 HagiCode 平台中集成 GitHub Issues 的全过程。我们将探讨如何通过”前端直连 + 后端最小化”的架构,在保持后端轻量的同时,实现安全的 OAuth 认证与高效的 Issues 同步。

HagiCode 作为一个 AI 辅助开发平台,核心价值在于连接想法与实现。但在实际使用中,我们发现用户在 HagiCode 中完成了 Proposal(提案)后,往往需要手动将内容复制到 GitHub Issues 中进行项目跟踪。

这带来了几个明显的痛点:

  1. 工作流割裂:用户需要在两个系统之间来回切换,体验不仅不流畅,还容易导致关键信息在复制粘贴的过程中丢失。
  2. 协作不便:团队其他成员习惯在 GitHub 上查看任务,无法直接看到 HagiCode 中的提案进展。
  3. 重复劳动:每当提案更新,就要人工去 GitHub 更新对应的 Issue,增加不必要的维护成本。

为了解决这个问题,我们决定引入 GitHub Issues Integration 功能,打通 HagiCode 会话与 GitHub 仓库的连接,实现”一键同步”。

嘿,介绍一下我们正在做的东西

我们正在开发 HagiCode —— 一款 AI 驱动的代码智能助手,让开发体验变得更智能、更便捷、更有趣。

智能 —— AI 全程辅助,从想法到代码,让编码效率提升数倍。便捷 —— 多线程并发操作,充分利用资源,开发流程顺畅无阻。有趣 —— 游戏化机制和成就系统,让编码不再枯燥,充满成就感。

项目正在快速迭代中,如果你对技术写作、知识管理或者 AI 辅助开发感兴趣,欢迎来 GitHub 看看~


技术选型:前端直连 vs 后端代理

Section titled “技术选型:前端直连 vs 后端代理”

在设计集成方案时,摆在我们面前的有两条路:传统的”后端代理模式”和更激进的”前端直连模式”。

在传统的后端代理模式中,前端所有的请求都要先经过我们的后端,再由后端去调用 GitHub API。这虽然逻辑集中,但给后端带来了不小的负担:

  1. 后端臃肿:需要编写专门的 GitHub API 客户端封装,还要处理 OAuth 的复杂状态机。
  2. Token 风险:用户的 GitHub Token 必须存储在后端数据库中,虽然可以加密,但毕竟增加了安全风险面。
  3. 开发成本:需要数据库迁移来存储 Token,还需要维护一套额外的同步服务。

前端直连模式则要轻量得多。在这个方案中,我们只利用后端来处理最敏感的”密钥交换”环节(OAuth callback),获取到 Token 后,直接存在浏览器的 localStorage 里。后续创建 Issue、更新评论等操作,直接由前端发 HTTP 请求到 GitHub。

对比维度后端代理模式前端直连模式
后端复杂度需要完整的 OAuth 服务和 GitHub API 客户端仅需一个 OAuth 回调端点
Token 管理需加密存储在数据库,有泄露风险存储在浏览器,仅用户自己可见
实施成本需数据库迁移、多服务开发主要是前端工作量
用户体验逻辑统一,但服务器延迟可能稍高响应极快,直接与 GitHub 交互

考虑到我们要的是快速集成和最小化后端改动,最终我们采用了”前端直连模式”。这就像给浏览器发了一张”临时通行证”,拿到证之后,浏览器就可以自己去 GitHub 办事了,不需要每次都找后端管理员批准。


在确定架构后,我们需要设计具体的数据流。整个同步流程的核心在于如何安全地获取 Token 并高效地利用它。

整个系统可以抽象为三个角色:浏览器(前端)、HagiCode 后端、GitHub。

+--------------+ +--------------+ +--------------+
| 前端 React | | 后端 | | GitHub |
| | | ASP.NET | | REST API |
| +--------+ | | | | |
| | OAuth |--+--------> /callback | | |
| | 流程 | | | | | |
| +--------+ | | | | |
| | | | | |
| +--------+ | | +--------+ | | +--------+ |
| |GitHub | +------------>Session | +----------> Issues | |
| |API | | | |Metadata| | | | | |
| |直连 | | | +--------+ | | +--------+ |
| +--------+ | | | | |
+--------------+ +--------------+ +--------------+

关键点在于:只有 OAuth 的一小步(获取 code 换 token)需要经过后端,之后的粗活累活(创建 Issue)都是前端直接跟 GitHub 打交道。

当用户点击 HagiCode 界面上的”Sync to GitHub”按钮时,会发生一系列复杂的动作:

用户点击 "Sync to GitHub"
1. 前端检查 localStorage 获取 GitHub Token
2. 格式化 Issue 内容(将 Proposal 转换为 Markdown)
3. 前端直接调用 GitHub API 创建/更新 Issue
4. 调用 HagiCode 后端 API 更新 Session.metadata (存储 Issue URL 等信息)
5. 后端通过 SignalR 广播 SessionUpdated 事件
6. 前端接收事件,更新 UI 显示"已同步"状态

安全问题始终是集成第三方服务的重中之重。我们做了以下考量:

  1. 防 CSRF 攻击:在 OAuth 跳转时,生成随机的 state 参数并存入 sessionStorage。回调时严格验证 state,防止请求被伪造。
  2. Token 存储隔离:Token 仅存储在浏览器的 localStorage 中,利用同源策略(Same-Origin Policy),只有 HagiCode 的脚本才能读取,避免了服务器端数据库泄露波及用户。
  3. 错误边界:针对 GitHub API 常见的错误(如 401 Token 过期、422 验证失败、429 速率限制),设计了专门的错误处理逻辑,给用户以友好的提示。

纸上得来终觉浅,咱们来看看具体的代码是怎么实现的。

后端只需要做两件事:存储同步信息、处理 OAuth 回调。

数据库变更 我们只需要在 Sessions 表增加一个 Metadata 列,用来存储 JSON 格式的扩展信息。

-- 添加 metadata 列到 Sessions 表
ALTER TABLE "Sessions" ADD COLUMN "Metadata" text NULL;

实体与 DTO 定义

src/HagiCode.DomainServices.Contracts/Entities/Session.cs
public class Session : AuditedAggregateRoot<SessionId>
{
// ... 其他属性 ...
/// <summary>
/// JSON metadata for storing extension data like GitHub integration
/// </summary>
public string? Metadata { get; set; }
}
// DTO 定义,方便前端序列化
public class GitHubIssueMetadata
{
public required string Owner { get; set; }
public required string Repo { get; set; }
public int IssueNumber { get; set; }
public required string IssueUrl { get; set; }
public DateTime SyncedAt { get; set; }
public string LastSyncStatus { get; set; } = "success";
}
public class SessionMetadata
{
public GitHubIssueMetadata? GitHubIssue { get; set; }
}

这是连接的入口。我们使用标准的 Authorization Code Flow。

src/HagiCode.Client/src/services/githubOAuth.ts
// 生成授权 URL 并跳转
export async function generateAuthUrl(): Promise<string> {
const state = generateRandomString(); // 生成防 CSRF 的随机串
sessionStorage.setItem('hagicode_github_state', state);
const params = new URLSearchParams({
client_id: clientId,
redirect_uri: window.location.origin + '/settings?tab=github&oauth=callback',
scope: ['repo', 'public_repo'].join(' '),
state: state,
});
return `https://github.com/login/oauth/authorize?${params.toString()}`;
}
// 在回调页面处理 Code 换取 Token
export async function exchangeCodeForToken(code: string, state: string): Promise<GitHubToken> {
// 1. 验证 State 防止 CSRF
const savedState = sessionStorage.getItem('hagicode_github_state');
if (state !== savedState) throw new Error('Invalid state parameter');
// 2. 调用后端 API 进行 Token 交换
// 注意:这里必须经过后端,因为需要 ClientSecret,不能暴露在前端
const response = await fetch('/api/GitHubOAuth/callback', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code, state, redirectUri: window.location.origin + '/settings?tab=github&oauth=callback' }),
});
if (!response.ok) throw new Error('Failed to exchange token');
const token = await response.json();
// 3. 存入 LocalStorage
saveToken(token);
return token;
}

有了 Token 之后,我们就需要一个强有力的工具来调 GitHub API。

src/HagiCode.Client/src/services/githubApiClient.ts
const GITHUB_API_BASE = 'https://api.github.com';
// 核心请求封装
async function githubApi<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
const token = localStorage.getItem('gh_token');
if (!token) throw new Error('Not connected to GitHub');
const response = await fetch(`${GITHUB_API_BASE}${endpoint}`, {
...options,
headers: {
...options.headers,
Authorization: `Bearer ${token}`,
Accept: 'application/vnd.github.v3+json', // 指定 API 版本
},
});
// 错误处理逻辑
if (!response.ok) {
if (response.status === 401) throw new Error('GitHub Token 失效,请重新连接');
if (response.status === 403) throw new Error('无权访问该仓库或超出速率限制');
if (response.status === 422) throw new Error('Issue 验证失败,可能标题重复');
throw new Error(`GitHub API Error: ${response.statusText}`);
}
return response.json();
}
// 创建 Issue
export async function createIssue(owner: string, repo: string, data: { title: string, body: string, labels: string[] }) {
return githubApi(`/repos/${owner}/${repo}/issues`, {
method: 'POST',
body: JSON.stringify(data),
});
}

最后一步,就是把 HagiCode 的 Session 数据转换成 GitHub Issue 的格式。这有点像”翻译”工作。

// 将 Session 对象转换为 Markdown 字符串
function formatIssueForSession(session: Session): string {
let content = `# ${session.title}\n\n`;
content += `**> HagiCode Session:** #${session.code}\n`;
content += `**> Status:** ${session.status}\n\n`;
content += `## Description\n\n${session.description || 'No description provided.'}\n\n`;
// 如果是 Proposal 类型,添加额外字段
if (session.type === 'proposal') {
content += `## Chief Complaint\n\n${session.chiefComplaint || ''}\n\n`;
// 添加一个深链接,方便从 GitHub 跳回 HagiCode
content += `---\n\n**[View in HagiCode](hagicode://sessions/${session.id})**\n`;
}
return content;
}
// 点击同步按钮的主逻辑
const handleSync = async (session: Session) => {
try {
const repoInfo = parseRepositoryFromUrl(session.repoUrl); // 解析仓库 URL
if (!repoInfo) throw new Error('Invalid repository URL');
toast.loading('正在同步到 GitHub...');
// 1. 格式化内容
const issueBody = formatIssueForSession(session);
// 2. 调用 API
const issue = await githubApiClient.createIssue(repoInfo.owner, repoInfo.repo, {
title: `[HagiCode] ${session.title}`,
body: issueBody,
labels: ['hagicode', 'proposal', `status:${session.status}`],
});
// 3. 更新 Session Metadata (保存 Issue 链接)
await SessionsService.patchApiSessionsSessionId(session.id, {
metadata: {
githubIssue: {
owner: repoInfo.owner,
repo: repoInfo.repo,
issueNumber: issue.number,
issueUrl: issue.html_url,
syncedAt: new Date().toISOString(),
}
}
});
toast.success('同步成功!');
} catch (err) {
console.error(err);
toast.error('同步失败,请检查 Token 或网络');
}
};

通过这套”前端直连”方案,我们用最少的后端代码实现了 GitHub Issues 的无缝集成。

  1. 开发效率高:后端改动极小,主要是数据库加一个字段和一个简单的 OAuth 回调接口,大部分逻辑都在前端完成。
  2. 安全性好:Token 不经过服务器数据库,降低了泄露风险。
  3. 用户体验佳:直接从前端发起请求,响应速度快,不需要经过后端中转。

在实际部署时,有几个坑大家要注意:

  • OAuth App 设置:记得在 GitHub OAuth App 设置里填正确的 Authorization callback URL(通常是 http://localhost:3000/settings?tab=github&oauth=callback)。
  • 速率限制:GitHub API 对未认证请求限制较严,但用 Token 后通常足够(5000次/小时)。
  • URL 解析:用户输入的 Repo URL 千奇百怪,记得正则要匹配 .git 后缀、SSH 格式等情况。

目前的功能还是单向同步(HagiCode -> GitHub)。未来我们计划通过 GitHub Webhooks 实现双向同步,比如在 GitHub 里关闭 Issue,HagiCode 这边的会话状态也能自动更新。这需要我们在后端暴露一个 Webhook 接收端点,这也是下一步要做的有趣工作。

希望这篇文章能给你的第三方集成开发带来一点灵感!如果有问题,欢迎在 HagiCode GitHub 上提 Issue 讨论。