跳转到内容

Codex

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

如何实现 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 的朋友可以先加个愿望单,自己点进去看一眼,比我在这儿多说十句都来得直接。

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

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

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 多 AI 提供者切换与互操作实现方案

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

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

在现代开发工具生态中,开发者经常需要使用不同的 AI 编码助手来辅助开发工作。Anthropic 的 Claude Code CLI 和 OpenAI 的 Codex CLI 各有其优势:Claude 以出色的代码理解和长上下文处理能力著称,而 Codex 在代码生成和工具使用方面表现优异。

本文将深入分析 hagicode 项目如何实现多个 AI 提供者的无缝切换与互操作,包括核心架构设计、关键实现细节以及实践中的注意事项。

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

  1. 根据需求灵活切换不同的 AI 提供者
  2. 在切换过程中保持会话状态的连续性
  3. 统一抽象不同 CLI 的 API 差异
  4. 为未来添加新的 AI 提供者预留扩展空间
  1. 接口差异统一:Claude Code CLI 通过命令行调用,Codex CLI 使用 JSON 事件流
  2. 流式响应处理:两种提供者都支持流式响应,但数据格式不同
  3. 工具调用语义:Claude 和 Codex 对工具调用的表示和生命周期管理不同
  4. 会话生命周期:需要正确管理每个提供者的会话创建、恢复和终止

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

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

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

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

接口设计的关键特性:

  • 统一的请求/响应模型:所有提供者使用相同的 AIRequestAIResponse 类型
  • 流式支持:通过 IAsyncEnumerable<AIStreamingChunk> 统一流式输出
  • 能力描述ProviderCapabilities 描述提供者支持的功能(流式、工具、最大 token 等)
  • 嵌入式命令SendMessageAsync 支持将 OpenSpec 命令嵌入到提示中
public enum AIProviderType
{
ClaudeCodeCli, // Anthropic Claude Code
OpenCodeCli, // 其他 CLI(可扩展)
GitHubCopilot, // GitHub Copilot
CodebuddyCli, // Codebuddy
CodexCli // OpenAI Codex
}

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

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

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

工厂模式的优势:

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

AIProviderSelector 实现提供者选择策略:

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

选择器策略:

  • 场景映射优先:首先检查业务场景是否有特定的提供者映射
  • 默认提供者回退:场景映射失败时使用默认提供者
  • 回退链兜底:逐个尝试回退链中的提供者
  • 可用性缓存:缓存提供者可用性检查结果,减少 Ping 调用
public class ClaudeCodeCliProvider : IAIProvider
{
private readonly ILogger<ClaudeCodeCliProvider> _logger;
private readonly IClaudeStreamManager _streamManager;
private readonly ProviderConfiguration _config;
public string Name => "ClaudeCodeCli";
public bool SupportsStreaming => true;
public ProviderCapabilities Capabilities { get; }
public async Task<AIResponse> ExecuteAsync(AIRequest request, CancellationToken cancellationToken = default)
{
_logger.LogInformation("Executing AI request with provider: {Provider}", Name);
var sessionOptions = ClaudeRequestMapper.MapToSessionOptions(request, _config);
var messages = _streamManager.SendMessageAsync(request.Prompt, sessionOptions, cancellationToken);
var responseBuilder = new StringBuilder();
ResultMessage? finalResult = null;
await foreach (var streamMessage in messages)
{
switch (streamMessage.Message)
{
case ResultMessage result:
finalResult = result;
responseBuilder.Append(result.Result);
break;
}
}
if (finalResult != null)
{
return ClaudeResponseMapper.MapToAIResponse(finalResult, Name);
}
return new AIResponse
{
Content = responseBuilder.ToString(),
FinishReason = FinishReason.Unknown,
Provider = Name
};
}
}

Claude Code CLI 提供者的特点:

  • 流式管理器集成:使用 IClaudeStreamManager 与 Claude CLI 通信
  • CessionId 会话隔离:使用 CessionId 作为会话唯一标识,与系统 sessionId 区分
  • 工作目录配置:支持配置工作目录、权限模式等
  • 工具支持:支持 AllowedTools、DisallowedTools 等工具权限配置
public class CodexCliProvider : IAIProvider
{
private readonly ILogger<CodexCliProvider> _logger;
private readonly CodexSettings _settings;
private readonly ConcurrentDictionary<string, string> _sessionThreadBindings;
public string Name => "CodexCli";
public bool SupportsStreaming => true;
public ProviderCapabilities Capabilities { get; }
public async IAsyncEnumerable<AIStreamingChunk> StreamAsync(
AIRequest request,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
_logger.LogInformation("Executing streaming AI request with provider: {Provider}", Name);
var codex = CreateCodexClient();
var thread = ResolveThread(codex, request);
var currentTurn = 0;
var activeToolCalls = new Dictionary<string, AIToolCallDelta>();
await foreach (var threadEvent in thread.RunStreamedAsync(BuildPrompt(request), cancellationToken))
{
if (threadEvent is TurnStartedEvent)
{
currentTurn++;
}
switch (threadEvent)
{
case ItemCompletedEvent { Item: AgentMessageItem message }:
var messageText = message.Text ?? string.Empty;
yield return new AIStreamingChunk
{
Content = messageText,
Type = StreamingChunkType.ContentDelta,
IsComplete = false
};
break;
case ItemStartedEvent or ItemUpdatedEvent or ItemCompletedEvent:
var toolChunk = BuildToolChunk(threadEvent, currentTurn);
if (toolChunk?.ToolCallDelta != null)
{
yield return toolChunk;
}
break;
case TurnCompletedEvent turnCompleted:
activeToolCalls.Clear();
yield return new AIStreamingChunk
{
Content = string.Empty,
Type = StreamingChunkType.Metadata,
IsComplete = true,
Usage = MapUsage(turnCompleted.Usage)
};
break;
}
}
BindSessionThread(request.SessionId, thread.Id);
}
private CodexThread ResolveThread(Codex codex, AIRequest request)
{
var sessionId = request.SessionId;
// 检查是否已有绑定的线程
if (!string.IsNullOrWhiteSpace(sessionId) &&
_sessionThreadBindings.TryGetValue(sessionId, out var threadId) &&
!string.IsNullOrWhiteSpace(threadId))
{
_logger.LogInformation("Resuming Codex thread {ThreadId} for session {SessionId}", threadId, sessionId);
return codex.ResumeThread(threadId, threadOptions);
}
_logger.LogInformation("Starting new Codex thread for session {SessionId}", sessionId ?? "(none)");
return codex.StartThread(threadOptions);
}
}

Codex CLI 提供者的特点:

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

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

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

会话线程绑定的优势:

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

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

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

桌面端 IPC 处理器示例:

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

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

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

Codex 的模型提供者支持:

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

appsettings.json 配置多个提供者:

{
"AI": {
"Providers": {
"DefaultProvider": "ClaudeCodeCli",
"Providers": {
"ClaudeCodeCli": {
"Type": "ClaudeCodeCli",
"Model": "claude-sonnet-4-20250514",
"WorkingDirectory": "/path/to/workspace",
"PermissionMode": "acceptEdits",
"AllowedTools": ["file-edit", "command-run", "bash"]
},
"CodexCli": {
"Type": "CodexCli",
"Model": "gpt-4.1",
"ExecutablePath": "codex",
"SandboxMode": "enabled",
"WebSearchMode": "auto",
"NetworkAccessEnabled": false
}
},
"ScenarioProviderMapping": {
"CodeAnalysis": "ClaudeCodeCli",
"CodeGeneration": "CodexCli",
"Refactoring": "ClaudeCodeCli",
"Debugging": "CodexCli"
},
"FallbackChain": ["CodexCli", "ClaudeCodeCli"]
},
"Selector": {
"EnableCache": true,
"CacheExpirationSeconds": 300
}
}
}
public class AIOrchestrator
{
private readonly IAIProviderFactory _providerFactory;
private readonly IAIProviderSelector _providerSelector;
private readonly ILogger<AIOrchestrator> _logger;
public AIOrchestrator(
IAIProviderFactory providerFactory,
IAIProviderSelector providerSelector,
ILogger<AIOrchestrator> logger)
{
_providerFactory = providerFactory;
_providerSelector = providerSelector;
_logger = logger;
}
public async Task<AIResponse> ProcessRequestAsync(
AIRequest request,
BusinessScenario scenario)
{
_logger.LogInformation("Processing request for scenario: {Scenario}", scenario);
try
{
// 智能选择提供者
var providerType = await _providerSelector.SelectProviderAsync(scenario, request.CancellationToken);
// 获取提供者实例
var provider = await _providerFactory.GetProviderAsync(providerType);
if (provider == null)
{
throw new InvalidOperationException($"Provider {providerType} not available");
}
_logger.LogInformation("Using provider: {Provider} for request", provider.Name);
// 执行请求
var response = await provider.ExecuteAsync(request, request.CancellationToken);
_logger.LogInformation("Request completed with provider: {Provider}, tokens used: {Tokens}",
provider.Name,
response.Usage?.TotalTokens ?? 0);
return response;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to process request for scenario: {Scenario}", scenario);
throw;
}
}
}
public async IAsyncEnumerable<AIStreamingChunk> StreamResponseAsync(
AIRequest request,
BusinessScenario scenario)
{
var providerType = await _providerSelector.SelectProviderAsync(scenario);
var provider = await _providerFactory.GetProviderAsync(providerType);
if (provider == null)
{
throw new InvalidOperationException($"Provider {providerType} not available");
}
await foreach (var chunk in provider.StreamAsync(request))
{
// 处理流式块
switch (chunk.Type)
{
case StreamingChunkType.ContentDelta:
// 实时显示文本内容
await SendToClientAsync(chunk.Content);
break;
case StreamingChunkType.ToolCallDelta:
// 处理工具调用
await HandleToolCallAsync(chunk.ToolCallDelta);
break;
case StreamingChunkType.Metadata:
// 处理完成事件和统计
if (chunk.IsComplete)
{
_logger.LogInformation("Stream completed, usage: {@Usage}", chunk.Usage);
}
break;
case StreamingChunkType.Error:
// 处理错误
_logger.LogError("Stream error: {Error}", chunk.ErrorMessage);
throw new InvalidOperationException(chunk.ErrorMessage);
}
}
}
public async Task<string> ExecuteOpenSpecCommandAsync(
string command,
string arguments,
BusinessScenario scenario)
{
var providerType = await _providerSelector.SelectProviderAsync(scenario);
var provider = await _providerFactory.GetProviderAsync(providerType);
// 构建嵌入式命令提示
var commandPrompt = $"""
Execute the following OpenSpec command:
Command: {command}
Arguments: {arguments}
Please execute this command and return the results.
""";
var request = new AIRequest
{
Prompt = "Process this command request",
EmbeddedCommandPrompt = commandPrompt,
WorkingDirectory = Directory.GetCurrentDirectory()
};
var response = await provider.SendMessageAsync(request, commandPrompt);
return response.Content;
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  1. 可扩展性:添加新的 AI 提供者只需实现 IAIProvider 接口
  2. 可测试性:提供者可以独立测试和模拟
  3. 可维护性:每个提供者的实现独立,职责单一
  4. 用户友好:支持场景自动选择和手动切换

通过这种设计,hagicode 成功实现了 Claude Code CLI 和 Codex CLI 的无缝切换与互操作,为开发者提供了灵活、强大的 AI 编码助手体验。


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

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

从 TypeScript 到 C#:Codex SDK 的跨语言移植实践

从 TypeScript 到 C#:Codex SDK 的跨语言移植实践

Section titled “从 TypeScript 到 C#:Codex SDK 的跨语言移植实践”

怎么说呢,这篇文章也算是个孩子,记录了我们把官方 TypeScript Codex SDK 完整移植到 C# 的全过程。说是”移植”,其实更像是一场漫长的冒险,毕竟两种语言的脾性不太一样,总得想办法让它们好好相处。

Codex 这东西,是 OpenAI 推出的 AI Agent CLI 工具,确实挺强大的。官方给了 TypeScript SDK,放在 @openai/codex 这个包里。它呢,通过调用 codex exec --experimental-json 命令跟 Codex CLI 交互,解析 JSONL 格式的事件流。

可是吧,我们在 HagiCode 项目里,需要在一个纯 .NET 环境中使用它。具体来说,就是 C# 后端服务和桌面端应用。你说这事闹的,总不能为了调用一个 CLI 工具而在 .NET 项目中引入 Node.js 运行时吧?那也太折腾了。

于是摆在我们面前的就两条路:一是维护一套复杂的 Node.js 桥接层,二是自己动手丰衣足食,实现一个原生 C# SDK。

我们选择了后者。

其实这篇文也是来自我们在 HagiCode 项目里的实践经验。HagiCode 是个开源的 AI 代码助手项目,听起来挺高大上的,但说白了也就是同时维护着前端 VSCode 扩展、后端 AI 服务、跨平台桌面客户端等多种组件。这种多语言、多平台的复杂度,正是我们需要原生 C# SDK 的直接原因——总不能真的在 .NET 项目里跑个 Node.js 吧?那也太魔幻了。

如果你觉得这篇文章有点帮助,欢迎来 GitHub 给个 Star:github.com/HagiCode-org/site,也欢迎访问官网了解更多:hagicode.com。毕竟一个人品无限的项目能得到支持,也是件开心的事。

在开始代码层面的转化之前,我们得先理解两套 SDK 的架构设计。毕竟知己知彼,百战不殆嘛。

TypeScript SDK 的核心架构是这样的:

Codex (入口类)
└── CodexExec (执行器,管理子进程)
└── Thread (对话线程)
├── run() / runStreamed() (同步/异步执行)
└── 事件流解析

C# SDK 呢,保持了相同的架构层次,但在实现细节上做了适配。整体思路是:保持 API 的一致性,但在具体实现上充分利用 C# 语言特性。毕竟语言不同,总得有点区别才行。

这是最基础也是最重要的工作。毕竟万丈高楼平地起,基础打不好,后面全是麻烦。

TypeScript 的类型系统比 C# 更灵活,这是事实。我们需要找到合适的映射方式:

TypeScriptC#说明
interface / typerecordC# 使用 record 实现不可变数据结构
string | nullstring?可空引用类型
boolean | undefinedbool?可空布尔值
AsyncGeneratorIAsyncEnumerable异步迭代器

事件类型系统是一个典型的例子。TypeScript 使用联合类型来定义事件:

export type ThreadEvent =
| ThreadStartedEvent
| TurnStartedEvent
| TurnCompletedEvent
| ...

在 C# 中,我们使用继承层次和模式匹配来实现类似的效果:

public abstract record ThreadEvent(string Type);
public sealed record ThreadStartedEvent(string ThreadId) : ThreadEvent("thread.started");
public sealed record TurnStartedEvent() : ThreadEvent("turn.started");
public sealed record TurnCompletedEvent(Usage Usage) : ThreadEvent("turn.completed");
// ...

使用 record 而不是 class,是因为事件对象应该是不可变的,这和 TypeScript 中使用普通对象是一个道理。而 sealed 关键字则确保不会有额外的子类继承,编译器可以进行优化。其实也就那么回事,习惯就好了。

事件解析是整个 SDK 的核心,毕竟这决定了我们能否正确理解 Codex CLI 返回的每一条信息。解析错了,后面全是白忙活。

TypeScript 版本使用 JSON.parse() 来解析每一行 JSON:

export function parseEvent(line: string): ThreadEvent {
const data = JSON.parse(line);
// 处理各种事件类型...
}

C# 版本则使用 System.Text.Json.JsonDocument

public static ThreadEvent Parse(string line)
{
using var document = JsonDocument.Parse(line);
var root = document.RootElement;
var type = GetRequiredString(root, "type", "event.type");
return type switch
{
"thread.started" => new ThreadStartedEvent(GetRequiredString(root, "thread_id", ...)),
"turn.started" => new TurnStartedEvent(),
"turn.completed" => new TurnCompletedEvent(ParseUsage(...)),
// ...
_ => new UnknownThreadEvent(type, root.Clone()),
};
}

这里有一个小技巧:root.Clone() 是必要的,因为 JsonDocument 的元素在文档释放后就会失效,我们需要保留一份副本给未知的事件类型。这也是没办法的事,毕竟 C# 的 JSON 处理和 JavaScript 不太一样。

这是两个 SDK 差异最大的地方。毕竟 Node.js 和 .NET 的脾性不太一样,总得适应适应。

TypeScript 使用 Node.js 的 spawn() 函数:

const child = spawn(this.executablePath, commandArgs, { env, signal });

C# 使用 .NET 的 System.Diagnostics.Process

using var process = new Process { StartInfo = startInfo };
process.Start();
// 需要手动管理 stdin/stdout/stderr

具体来说,C# 版本需要这样配置进程:

var startInfo = new ProcessStartInfo
{
FileName = _executablePath,
RedirectStandardInput = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
};

最大的区别在于取消机制。TypeScript 使用 AbortSignal,这是 Web API 的一部分,用起来挺顺手的:

const child = spawn(cmd, args, { signal: cancellationSignal });

C# 则使用 CancellationToken

public async IAsyncEnumerable<string> RunAsync(
CodexExecArgs args,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
// 在循环中检查取消状态
while (!cancellationToken.IsCancellationRequested)
{
// 处理输出...
}
// 取消时终止进程
if (cancellationToken.IsCancellationRequested)
{
try { process.Kill(entireProcessTree: true); } catch { }
}
}

这其中的区别,大概就是Web API 和 .NET 生态的差异吧,说到底也就是那么回事。

两套 SDK 都实现了将 JSON 配置转换为 TOML 配置的逻辑,因为 Codex CLI 接受 TOML 格式的配置覆盖。这部分逻辑必须完全保持一致,否则同样的配置在两个 SDK 中会产生不同的行为。

这叫什么?这就叫工匠精神嘛。毕竟细节决定成败,有些事不能将就。

我们创建了这样的项目结构:

CodexSdk/
├── CodexSdk.csproj
├── Codex.cs # 入口类
├── CodexThread.cs # 对话线程
├── CodexExec.cs # 执行器
├── Events.cs # 事件类型定义
├── Items.cs # 项目类型定义
├── EventParser.cs # 事件解析器
├── OutputSchemaTempFile.cs # 临时文件管理
└── ...

看起来也挺整齐的,不是吗?

基本的使用方式和 TypeScript SDK 保持一致:

using CodexSdk;
// 创建 Codex 实例
var codex = new Codex();
var thread = codex.StartThread();
// 执行查询
var result = await thread.RunAsync("Summarize this repository.");
Console.WriteLine(result.FinalResponse);

流式事件处理利用了 C# 的模式匹配能力:

await foreach (var @event in thread.RunStreamedAsync("Analyze the code."))
{
switch (@event)
{
case ItemCompletedEvent itemCompleted
when itemCompleted.Item is AgentMessageItem msg:
Console.WriteLine($"Assistant: {msg.Text}");
break;
case TurnCompletedEvent completed:
Console.WriteLine($"Tokens: in={completed.Usage.InputTokens}");
break;
case CommandExecutionItem command:
Console.WriteLine($"Command: {command.Command}");
break;
}
}

在实现过程中,我们也不算是白忙活,总结点经验如下:

  1. 进程管理:C# 版本需要手动管理进程的生命周期,包括取消时的进程终止。使用 Kill(entireProcessTree: true) 确保子进程也被清理。这叫什么?这就叫有始有终。

  2. 错误处理:我们使用 InvalidOperationException 抛出解析错误,保持与 TypeScript SDK 相似的错误处理方式。毕竟错误处理这事儿,不能太随意。

  3. 资源清理OutputSchemaTempFile 实现 IAsyncDisposable,确保临时文件被正确清理。这也是没办法的事,资源不清理干净,总会有问题。

  4. 环境变量:C# 版本支持通过 CodexOptions.Env 完全覆盖进程环境变量。这功能虽然小,但挺实用的。

  5. 平台差异:C# 版本不包含 TypeScript 版本中自动查找 npm 包中二进制文件的逻辑。这是因为 .NET 项目通常不依赖 npm,所以需要通过 CODEX_EXECUTABLE 环境变量或 CodexPathOverride 指定 codex 可执行文件路径。这叫什么?这就叫因地制宜。

将一个成熟的 TypeScript SDK 移植到 C#,不仅仅是语法层面的转换,更是对两种语言设计哲学的理解。TypeScript 的灵活性和 JavaScript 生态特性(如 AbortSignal)在 C# 中需要找到对应的替代方案。这其中的酸甜苦辣,大概也只有真正做过的人才能体会。

关键体会是:保持 API 的一致性比保持实现细节的一致性更重要。用户关心的是接口是否易用,而不是内部实现是否相同。这话听起来简单,但做起来需要取舍。

如果你也在做类似的跨语言移植工作,我们的经验是:先完整理解原 SDK 的架构设计,然后逐个模块进行转化,最后通过完整的测试用例确保行为一致。毕竟急不得,一口吃不成胖子。

一切都会好的,都会有的…



如果本文对你有帮助


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

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