源码地址:后端地址 前端地址

第 01 集那张旅程图里,灰度路由排在第二段——鉴权之后、装配之前。第 02 集你跑通环境时,给 data_analyst 发了一条消息,但那条消息命中的是草稿(DRAFT)版本,因为 V38 种子的 current_publish_version 是 0。

真实生产环境不会这么简单。一个 Agent 改了 sys_prompt 或换了模型,不会直接全量上线——先给 5% 的用户试,没问题再放量。这就要决定:每个进来的请求,到底该命中哪个版本的 Agent

这一篇走完整条灰度链路:请求环境怎么解析、用户命中哪个版本、三种 Agent 变体的 key 怎么拼、注册表的 SmartLifecycle 三阶段启动、发布与回滚的事务与广播。

两个输入:用户是谁、从哪来

灰度路由的决策函数签名很简洁——GrayRoutingService.resolveVersion(agentCode, userId, env),三个输入决定一切。

第一个输入 agentCode,来自请求参数 ?agentCode=xxx,标识要调哪个 Agent。第二个输入 userId,来自 X-Emp-Id 请求头(AuthFilter 从 token 派生,客户端不能伪造)。第三个输入 env,来自 X-Env 请求头,由 RequestEnvResolver 解析。

RequestEnvResolver 全部代码 37 行,核心逻辑在第 28-36 行:

public String resolve(HttpServletRequest request) {
    String header = request == null ? null : request.getHeader(HEADER_NAME);
    String value = (header == null || header.isBlank()) ? defaultEnv : header.trim().toUpperCase();
    if (!ALLOWED.contains(value)) {
        throw new IllegalArgumentException(
                HEADER_NAME + " must be one of " + ALLOWED + ", got: " + value);
    }
    return value;
}

三个允许值:DAILY(日常环境)、PRE(预发)、PRODUCTION(生产)。请求头没带就用配置项 loser-agent.runtime-env 的默认值(PRODUCTION)。这不是随便分的——灰度的 ENVIRONMENT 策略就是按这个值决定是否命中灰度版本:配了 envScope=DAILY,PRE 的灰度规则,只有 DAILY 和 PRE 环境的请求命中灰度,生产流量纹丝不动。

四种灰度策略

拿到三个输入后,GrayRoutingService.doResolve() 做决策。第 46-60 行:

private int doResolve(String agentCode, String userId, String env) {
    AcAgentGrayConfig gray = findActive(agentCode);
    if (gray == null) {
        return stableVersion(agentCode);
    }
    if (!publishRecordExists(agentCode, gray.getGrayPublishVersion())) {
        log.warn("[GrayRouting] gray version missing in publish_record: agent={}, v={}",
                agentCode, gray.getGrayPublishVersion());
        return stableVersion(agentCode);
    }
    boolean hitsGray = evaluate(gray, userId, env);
    int chosen = hitsGray ? gray.getGrayPublishVersion() : gray.getStablePublishVersion();
    grayMetrics.grayDecision(agentCode, gray.getStrategy(), hitsGray ? "GRAY" : "STABLE");
    return chosen;
}

三步走:查灰度配置(没配就走 stable)、校验灰度版本是否存在于发布记录(不存在也走 stable,防配置指向不存在的版本)、按策略判定是否命中。

四种策略在 evaluate() 方法里用 Java 17 的 switch 表达式分发,第 87-96 行:

private boolean evaluate(AcAgentGrayConfig g, String userId, String env) {
    return switch (GrayStrategy.valueOf(g.getStrategy())) {
        case PERCENTAGE  -> percentageHits(userId, g.getPercentage());
        case WHITELIST   -> whitelistHits(userId, g.getWhitelist());
        case ENVIRONMENT -> envScopeHits(env, g.getEnvScope());
        case COMBINED    -> percentageHits(userId, g.getPercentage())
                         && whitelistHits(userId, g.getWhitelist())
                         && envScopeHits(env, g.getEnvScope());
    };
}
策略判定逻辑适用场景
PERCENTAGEuserId.hashCode() % 100 < percentage按比例放量:5% → 10% → 50% → 100%
WHITELISTuserId 在白名单列表里内测:指定几个工号先试
ENVIRONMENTenv 在 envScope 逗号分隔列表里按环境放量:先 DAILY 再 PRE 再 PRODUCTION
COMBINED三者 AND精确控制:DAILY 环境的白名单用户取 5% 流量

PERCENTAGE 的实现值得细看,第 98-102 行:

private boolean percentageHits(String userId, Integer percentage) {
    if (userId == null || userId.isBlank() || percentage == null) return false;
    int bucket = Math.abs(userId.hashCode()) % 100;
    return bucket < percentage;
}

userId.hashCode() % 100 做分桶——同一个用户每次算出来的桶位一样,不会忽灰忽稳。Math.abs 防止 hashCode 负数导致负模。这种 hash 分桶是灰度发布的经典做法,比随机数稳定:用户 A 永远命中灰度,用户 B 永远不命中,不会因为换个请求就变卦。

WHITELIST 有个小坑值得注意——第 104-121 行,MyBatis-Plus 的 JSON handler 可能把白名单列表序列化回 String 而不是 List。代码里做了两种类型的兼容处理,把 "["emp001","emp002"]" 这样的字符串手动解析回 List。这种「DB handler 行为不稳定,代码做防御」的情况在 MyBatis-Plus + JSON 列组合里很常见。

三种 Agent 变体与 key 拼装

灰度策略决定的是「命中哪个版本号」,但版本号本身不直接用作注册 key。key 的拼装规则收在 AgentVariant 里——一个枚举,60 行代码,却定义了整个注册表的 key 空间。

三种变体对应三种 key:

变体key 格式含义数据来源
DRAFTagentCode草稿态,实时读 DB 配置ac_agent_config 主行
PUBLISHED (stable)agentCode_published稳定发布,读快照ac_agent_publish_record 最新版本
PUBLISHED (gray)agentCode_published_v<n>灰度版本,读指定版本快照ac_agent_publish_record v=n

第 13-14 行的 registryKey() 产出前两种:

public String registryKey(String agentCode) {
    return this == PUBLISHED ? agentCode + PUBLISHED_SUFFIX : agentCode;
}

第 22-27 行的 registryKeyWithVersion() 产出第三种:

public static String registryKeyWithVersion(String agentCode, int version) {
    if (version < 0) {
        throw new IllegalArgumentException("version must be >= 0, got " + version);
    }
    return agentCode + PUBLISHED_SUFFIX + "_v" + version;
}

灰度的核心设计就在这里:agentCode_published(stable,无版本后缀)和 agentCode_published_v3(gray,带版本后缀)是两个独立的注册项,可以同时存在于注册表里。灰度命中 v3 就找 _published_v3 的工厂,没命中就找 _published 的工厂。这样 stable 和 gray 的工厂各管各的快照,互不干扰。

反过来,AgentVariant.parse() 方法(第 39-56 行)从 key 反解出 agentCode 和变体类型。它用 lastIndexOf 而非 endsWith 定位 _published 后缀——容忍 agentCode 本身包含 _published 子串的边界场景(虽然第 66-68 行在注册时禁止了这种 agentCode)。版本号必须是纯数字,空串或非数字都返回 null(当 DRAFT 处理)。

Controller 里的路由逻辑

回到第 01 集的旅程图第二段,AguiChatController 第 123-138 行:

String envName = envResolver.resolve(request);
int version = grayRoutingService.resolveVersion(agentCode, empId, envName);
BaseFinanceAgentFactory factory;
if (version > 0) {
    String versionedKey = AgentVariant.registryKeyWithVersion(agentCode, version);
    factory = registry.find(versionedKey);
    if (factory == null) {
        log.info("[AguiChatController] versioned factory not loaded, refresh: agent={}, v={}",
                agentCode, version);
        registry.refresh(agentCode, version);
        factory = registry.find(versionedKey);
    }
} else {
    factory = registry.find(agentCode);   // 未发布 / fallback → DRAFT 路径
}

version=0 表示「没配灰度或没命中灰度」,走 DRAFT 路径——直接用 agentCode 作 key 找工厂。version>0 表示命中了某个灰度版本,拼出 agentCode_published_v<n> 的 key 去找。

这里有个值得注意的懒加载逻辑:如果灰度路由说命中 v3,但注册表里没有 _published_v3 的工厂(可能灰度配置是刚加的,注册表还没加载这个版本),先调 registry.refresh(agentCode, version) 触发按需加载,再找一次。如果还没有,往下走 DRAFT fallback。

这个设计的微妙之处在于:灰度决策(GrayRoutingService)和工厂加载(AgentFactoryRegistry)是解耦的。灰度服务只管算版本号,不关心工厂在不在;注册表只管找/加载工厂,不关心为什么找。这让灰度配置可以随时改,不用重启——下次请求来了,灰度服务算出新版本,注册表按需加载。

注册表的 SmartLifecycle 三阶段

AgentFactoryRegistry 实现了 Spring 的 SmartLifecycle 接口,在所有 Bean 初始化完成后执行 start() 方法。为什么不直接用 @PostConstruct?第 18 行的注释说得很直白:避免循环依赖时序问题——@PostConstruct 在依赖注入后立即执行,但此时其他 Bean 可能还没准备好;SmartLifecycle 在所有 Bean 就绪后才跑。

start() 方法的三个阶段,第 40-112 行:

阶段 1:建静态索引。 第 44-48 行,遍历所有 BaseFinanceAgentFactory 的 Spring Bean(通过构造器注入的 staticFactories 列表),以 f.simpleName() 为 key 建索引。这些是代码里硬编码的静态 Agent 工厂(如果有的话),启动时就知道。

阶段 2:读 DB 分流。 第 52-92 行,查 ac_agent_config 表所有 status >= 1 的行,分成静态和动态两类:

  • 2a. 静态 Agent(第 73-80 行):如果静态索引里有这个 agentCode,但 DB 里没有对应行——抛异常「static factory exists but no ac_agent_config row」。这是启动校验,防止代码和配置不一致。如果有行,调 refreshFromDb() 加载配置。
  • 2b. 动态 Agent DRAFT(第 84-92 行):DB 里有但静态索引里没有的 agentCode,走 dynamicRegistry.register(code) 动态注册。V38 种子的 data_analyst 就走这条路——它没有硬编码的工厂类,由 DynamicAgentRegistry 用通用 BaseFinanceAgentFactory 实例化。
  • 2c. PUBLISHED variant(第 95-107 行):对每个 current_publish_version > 0 的配置行,注册一个 _published 的 stable 工厂。注意这里不注册灰度版本——灰度版本的工厂是请求驱动的懒加载。

阶段 3:标记 running。 第 109 行 running = true,注册表开始对外服务。

getPhase() 返回 Integer.MIN_VALUE + 1000(第 125-127 行),意味着这个 SmartLifecycle 在几乎所有其他组件之前启动——因为后面的一切(Controller、灰度、装配)都依赖注册表就绪。

DRAFT vs PUBLISHED:实时读 vs 读快照

动态注册时,DynamicAgentRegistry 给每个工厂注入一个 Supplier<AgentConfigSnapshot>——一个延迟加载的快照供应器。DRAFT 和 PUBLISHED 用不同的 loader,这是两种变体的根本区别。

DRAFT loader(第 243-273 行):每次 refreshFromDb() 时实时查 ac_agent_config 主行 + 所有子配置表(tools、skills、mcp、hitl rules…),组装成 AgentConfigSnapshot草稿态改了配置,刷一下就生效——这就是你在管理页改 Agent 配置后能立即看到效果的原因。

PUBLISHED loader(第 274-294 行):查 ac_agent_publish_record 表指定版本号的 snapshot_json 列,反序列化成 PublishSnapshot,取其中的配置。发布版本读的是快照,不是实时配置——发布之后改草稿不影响已发布的版本。

这个设计的意义在于:你可以改草稿配置做实验,线上跑的发布版本不受影响;实验满意了再发一次新版本覆盖。

发布:快照捕获 + 事务 + 事务后广播

发布操作在 PublishService.publish(),第 42-83 行,核心流程:

  1. 捕获快照(第 53 行):snapshotService.capture(agentCode) 把 Agent 主行 + prompts + models + mcpServers + skills + hitlRules + knowledge + subagents 等 8 张子配置表读出来,组装成不可变的 PublishSnapshot
  2. 写发布记录(第 57-63 行):current_publish_version + 1 作为新版本号,快照 JSON 存进 ac_agent_publish_record
  3. 更新主行(第 65-68 行):ac_agent_config.current_publish_version 改成新版本号,status 改成 2(已发布)。
  4. 刷新 LLM 路由(第 77 行):llmConfigRegistry.rebuildForAgent(agentCode) 重建这个 Agent 的模型路由缓存。
  5. 事务后广播(第 79 行):fireBroadcastAfterCommit() 注册一个 TransactionSynchronization,在事务提交后触发广播。

第 4 步和第 5 步的时序很关键——第 4 步在事务内(能读到本事务的写),第 5 步在事务后(afterCommit)。为什么广播要放在事务后?第 78 行的注释解释了:如果事务回滚,不应该触发其他节点刷新缓存——否则别的节点会去加载一个根本没提交的版本。TransactionSynchronizationManager.registerSynchronization() 保证只有 commit 成功才回调。

广播本身目前是个 in-process 桩——AgentConfigBroadcaster 用 Spring 的 ApplicationEventPublisher 发本地事件。注释里写了预留:V2 版本替换成 MetaQ/RocketMQ,远端节点通过消息监听器收到后刷新本地注册表。这是集群部署的前置设计——单机跑不生效,但多机部署时每个节点都能收到配置变更通知。

回滚:快照反序列化 + 乐观锁 + 审计

回滚比发布多了三道防线,PublishService.rollback() 第 86-136 行:

  • 防御 1(第 88-90 行):targetVersion <= 0 直接拒绝——版本号必须是正整数,防误传 0 或负数。
  • 防御 2(第 95-97 行):Agent 不存在就报 AGENT_NOT_FOUND
  • 防御 3(第 99-101 行):已经在目标版本就报 ALREADY_AT_TARGET——幂等保护,重复回滚请求不报错也不执行。

然后读目标版本的发布记录,反序列化快照。第 113 行有个类型转换细节:snapshotJson 在实体里声明为 Object 类型(MyBatis-Plus JSON handler 存取),但 Jackson 2.x 的 readValue 需要 String,所以强转 (String) rec.getSnapshotJson()

回滚的审计逻辑值得注意——第 118-128 行,审计日志在 apply() 之前写,记录的是 beforeSnapshot(当前状态)和 afterSnapshot(目标快照)。如果 apply() 抛异常,审计插入也会被 @Transactional(rollbackFor = Exception.class) 回滚——保证审计记录和实际变更要么一起成功要么一起失败,不会出现「审计说回滚了但实际没滚」的情况。

apply() 本身用 MyBatis-Plus 的 @Version 乐观锁——AcAgentConfigversion 字段有 @Version 注解,更新时 SQL 自动带 WHERE version = x,并发回滚时只有一个能成功。

灰度配置表

V17 建的 ac_agent_gray_config 表,结构清晰:

CREATE TABLE ac_agent_gray_config (
  agent_code           VARCHAR(64)  NOT NULL,
  strategy             VARCHAR(16)  NOT NULL,   -- PERCENTAGE/WHITELIST/ENVIRONMENT/COMBINED
  percentage           INT          NULL,       -- PERCENTAGE 策略用
  whitelist            JSON         NULL,       -- WHITELIST 策略用
  env_scope            VARCHAR(64)  NULL,       -- ENVIRONMENT 策略用,逗号分隔
  gray_publish_version INT          NOT NULL,   -- 灰度版本号
  stable_publish_version INT         NOT NULL,   -- 稳定版本号(快照时锁定)
  is_active            TINYINT      NOT NULL DEFAULT 0,
  ...
  UNIQUE KEY uk_gray_agent_active (agent_code, is_deleted)
)

注意那个唯一键 uk_gray_agent_active (agent_code, is_deleted)——同一个 Agent 只能有一条未删除的灰度配置。配灰度时先停旧的(逻辑删除 is_deleted=1)再建新的,不会出现两条灰度配置打架。

stable_publish_version 在灰度配置创建时锁定——之后即使发布了新版本改了 ac_agent_config.current_publish_version,灰度的 stable 版本不变,还是用创建时锁定的那个。这保证灰度期间的 stable 流量不会被新发布意外改动。

一次完整的灰度发布流程

把上面所有片段串起来,一次灰度发布的完整操作:

  1. 改草稿配置:在管理页改 sys_prompt / model_params / 绑定工具,改的是 ac_agent_config 主行 + 子表,DRAFT loader 下次刷新就能看到。
  2. 发布 v2PublishService.publish() 捕获快照存 ac_agent_publish_record v2,更新 current_publish_version=2,注册 _published 工厂。此时所有流量走 stable = v2。
  3. 配灰度:在灰度管理页建 ac_agent_gray_config 行,strategy=PERCENTAGEpercentage=5gray_publish_version=2(灰度版本)、stable_publish_version=1(上一个版本做 stable)。
  4. 请求路由:用户发消息,GrayRoutingService 算出命中灰度 → version=2 → 找 agentCode_published_v2 工厂 → 走 v2 快照配置。没命中 → version=1 → 找 agentCode_published_v1 → 走 v1 快照。
  5. 灰度放量:没问题就改 percentage 从 5 → 20 → 50 → 100。
  6. 全量上线:灰度 100% 后,重新发布(v3),把 ac_agent_config.current_publish_version 更新为 3,停用灰度配置(is_active=0)。所有流量走 stable = v3。
  7. 回滚:如果 v3 出问题,PublishService.rollback(agentCode, 2, operator) 读 v2 快照、审计、乐观锁写回主行。

小结

记住三件事:

  1. 四种灰度策略:PERCENTAGE 按 hash 分桶、WHITELIST 按名单、ENVIRONMENT 按环境、COMBINED 三者 AND。hash 分桶保证同一用户命中稳定,不会忽灰忽稳。
  2. 三种 Agent 变体:DRAFT(agentCode,实时读 DB)、PUBLISHED stable(agentCode_published,读最新快照)、PUBLISHED gray(agentCode_published_v<n>,读指定版本快照)。灰度的本质是 stable 和 gray 两个工厂共存于注册表,各读各的快照。
  3. 注册表三阶段:SmartLifecycle 在所有 Bean 就绪后启动——建静态索引 → 读 DB 分流(静态校验、动态注册 DRAFT、注册 PUBLISHED stable)→ 标记 running。灰度版本的工厂是请求驱动的懒加载,不在启动时注册。

下一篇进入装配——BaseFinanceAgentFactory 的十个 Builder 开关怎么把 DB 配置翻译成一个可运行的 HarnessAgentvolatile 快照怎么实现热更新,以及 .stateStore(stateStore) 这个 SPI 插件点到底接了什么。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐