AgentScope Tool 热更新技术方案:不重启服务,实时管控 Agent 工具

一、为什么 AI Agent 需要 Tool 热更新?

在生产环境中运行 AI Agent 时,运维团队经常面临一个尴尬的困境:

  • 某个 Tool 出现 Bug 需要紧急禁用,但重启服务会中断所有正在进行的会话;
  • 新增了一个 Tool 想先灰度给部分 Agent 使用,却发现只能全局生效;
  • 高危操作工具(如删除数据、发送通知)需要人工确认后才能执行,但改配置必须重启。

这些问题的根源在于:Agent 的工具配置是启动时静态绑定的,运行时无法动态调整。

本文介绍一种基于 AgentScope 框架的 Tool 热更新方案,实现以下能力:

  • 不重启服务即可启用/禁用/配置工具
  • 三层防护确保禁用的工具不会被执行
  • 多实例同步保证分布式环境下状态一致
  • HITL(Human-in-the-Loop) 高危工具执行前需人工确认

二、整体架构

graph TB subgraph Controller TC[ToolController] CC[ChatController] end subgraph Service TRS[ToolRegistryServiceImpl] CS[ChatServiceImpl] end subgraph HotReload HR[ToolkitHotReloader] TFM[ToolFilterMiddleware] HPM[HitlPermissionMiddleware] end subgraph AgentScope HA[HarnessAgent] TK[Toolkit copy] TG[ToolGroup] AT[AgentTool] MW[Middleware Chain] end subgraph Storage DB[MySQL] RS[Redis] end TC --> TRS CC --> CS CS --> HA TRS --> HR TRS --> TFM TRS --> HPM HR --> TK TK --> TG TG --> AT HA --> MW MW --> TFM MW --> HPM TRS --> DB TRS --> RS TFM --> RS HPM --> RS

各层职责:

层级 组件 职责
Controller 层 ToolController、ChatController 接收管理端和用户请求,委托 Service 处理
Service 编排层 ToolRegistryServiceImpl 核心编排器,协调 DB、热更新引擎、Middleware
热更新引擎 ToolkitHotReloader 纯粹负责 Toolkit 的运行时工具加载/卸载
Middleware ToolFilterMiddleware / HitlPermissionMiddleware 运行时拦截,兜底防护
AgentScope 框架层 HarnessAgent / Toolkit / Middleware Chain Agent 执行引擎和中间件管道
存储层 MySQL + Redis 持久化配置 + 分布式状态共享

三、核心设计:三层防护

单纯从 Toolkit 中移除工具并不足够安全------LLM 可能缓存了旧的 tool list,仍会尝试调用已移除的工具。因此本方案设计了三层纵深防护:

graph LR LLM[LLM ToolCall] --> L1 subgraph ToolkitLayer L1{in Toolkit?} L1 -->|No| R1[Agent Error] end L1 -->|Yes| L2 subgraph MiddlewareLayer L2{in disabled?} L2 -->|Yes| R2[Return DENIED] end L2 -->|No| L3 subgraph HITLLayer L3{confirmRequired?} L3 -->|Yes| R3[Agent Pause] end L3 -->|No| R4[Execute Tool]

三层防护的设计理念:

  1. Toolkit 层(removeTool):从 Agent 的工具注册表中移除工具,LLM 在下次获取 tool list 时看不到这个工具。这是"正常路径"的防护。

  2. Middleware 层(ToolFilterMiddleware) :在 onActing 阶段拦截,即使 LLM 缓存了旧的 tool list 仍然发起调用,Middleware 也能拦截并返回明确的 DENIED 结果------告诉 LLM"这个工具已被管理员禁用,请勿重试",避免 LLM 因收不到结果而陷入重试循环。

  3. HITL 层(HitlPermissionMiddleware):对高危工具不禁用,但在每次调用前动态注入 ASK 规则,Agent 执行到该工具时自动暂停,等待人工确认后才能继续。

三层防护互为补充,确保任何场景下被禁用的工具都不会被执行。

四、热更新引擎实现(ToolkitHotReloader)

4.1 AgentScope 的 Toolkit Copy 机制

理解热更新引擎,首先要理解 AgentScope 的一个关键设计:HarnessAgent.build() 会调用 toolkit.copy() 创建副本

这意味着:

  • 你在 Spring Bean 中持有的 Toolkit 引用是原始实例
  • Agent 内部持有的是副本实例
  • 对原始实例的操作不会影响 Agent,反之亦然

因此,热更新必须操作 Agent 内部的副本,而非原始 Toolkit。

4.2 AgentTool 缓存的必要性

AgentScope 的 removeTool(name) 会将工具从注册表中移除,移除后 getTool(name) 返回 null。如果后续需要重新启用该工具,就没有办法从 Toolkit 中取回它了。

解决方案是在 removeTool 之前,先将所有 AgentTool 实例缓存到 ConcurrentHashMap 中:

java 复制代码
// 缓存所有 AgentTool 实例,用于 removeTool 后精确重新注册
private final Map<String, AgentTool> agentToolCache = new ConcurrentHashMap<>();

public void cacheAgentTools(Toolkit sourceToolkit) {
    for (String toolName : sourceToolkit.getToolNames()) {
        AgentTool agentTool = sourceToolkit.getTool(toolName);
        if (agentTool != null) {
            agentToolCache.put(toolName, agentTool);
        }
    }
}

4.3 syncSingle 三步操作

热更新的核心逻辑在 syncSingle 方法中,分三步执行:

graph TB START[Start] --> STEP1 STEP1[Step1 Unload disabled] -->|scan toolkit| CHECK1{in enabledNames?} CHECK1 -->|No| REMOVE[removeTool] CHECK1 -->|Yes| NEXT1[keep] REMOVE --> NEXT1 NEXT1 --> STEP2[Step2 Load enabled] STEP2 -->|scan enabledNames| CHECK2{already in toolkit?} CHECK2 -->|No| LOAD[register from cache] CHECK2 -->|Yes| NEXT2[skip] LOAD --> NEXT2 NEXT2 --> STEP3[Step3 setActiveGroups] STEP3 --> DONE[Done]

对应的核心代码:

java 复制代码
private void syncSingle(Toolkit tk,
                        Set<String> enabledToolNames,
                        Map<String, String> toolGroupMapping,
                        List<String> activeGroupNames) {
    Set<String> currentTools = CollUtil.newHashSet(tk.getToolNames());

    // 1. 卸载不在 enabledToolNames 中的工具
    for (String toolName : currentTools) {
        if (!enabledToolNames.contains(toolName)) {
            tk.removeTool(toolName);
        }
    }

    // 2. 加载在 enabledToolNames 中但当前不在 Toolkit 中的工具
    for (String toolName : enabledToolNames) {
        if (!tk.getToolNames().contains(toolName)) {
            AgentTool cached = agentToolCache.get(toolName);
            String groupName = toolGroupMapping.get(toolName);
            if (cached != null && groupName != null) {
                tk.registration().agentTool(cached).group(groupName).apply();
            }
        }
    }

    // 3. 同步组激活状态
    tk.setActiveGroups(activeGroupNames);
}

4.4 并发安全设计

热更新引擎使用 CopyOnWriteArrayList 管理绑定的 Toolkit 引用列表,使用 ConcurrentHashMap 缓存 AgentTool 实例。在上层编排器 ToolRegistryServiceImpl 中,使用 ReentrantLockstateRefreshLock)保护所有可能并发修改 Toolkit 的操作,包括:

  • API 直接触发的状态变更
  • Redis Pub/Sub 广播消息触发的刷新
  • 定时轮询触发的刷新
  • bindAgent 初始化操作

五、多实例状态同步

在生产环境中,服务通常以多实例方式部署。当管理员在实例 A 上禁用了某个工具,实例 B 和 C 也需要尽快感知到这一变化。

5.1 三种触发路径

sequenceDiagram participant Admin as 管理员 participant A as 实例A participant Redis as Redis participant B as 实例B participant C as 实例C Note over A,C: 路径1 API直接触发 Admin->>A: POST /api/tools/updateEnabled A->>A: 更新DB A->>A: applyCurrentState 本地生效 A->>Redis: publish refresh msg Note over A,C: 路径2 Redis PubSub广播 Redis->>B: onMessage B->>B: applyCurrentState 本地生效 Redis->>C: onMessage C->>C: applyCurrentState 本地生效 Note over A,C: 路径3 定时轮询兜底 每30秒 loop 每30秒 B->>B: pollToolConfigChanges B->>B: 比较DB状态哈希 C->>C: pollToolConfigChanges C->>C: 比较DB状态哈希 end

为什么需要三种路径?

  1. API 直接触发:管理员操作后立即在本地生效,同时发布 Redis 消息通知其他实例。
  2. Redis Pub/Sub 广播:其他实例收到消息后从 DB 重新加载状态并刷新本地 Toolkit。这是主要的跨实例同步手段。
  3. 定时轮询兜底 :Redis Pub/Sub 是"fire-and-forget"模式,如果某个实例在消息发布时恰好断连,就会丢失这条消息。每 30 秒的轮询通过比较 DB 状态哈希(computeConfigStateHash)来检测遗漏的变更,确保最终一致性。

5.2 状态哈希检测

轮询机制并非每次都全量刷新,而是计算当前 DB 配置的状态哈希,与上次记录的哈希值比较:

java 复制代码
@Scheduled(fixedDelay = 30_000L)
public void pollToolConfigChanges() {
    List<ToolConfigDO> configs = listAllActive();
    long currentHash = computeConfigStateHash(configs);
    if (currentHash != lastDbStateHash) {
        applyCurrentState();
        lastDbStateHash = currentHash;
    }
}

这种方式将轮询的开销降到最低------只有在配置确实发生变化时才执行全量刷新。

六、Middleware 机制详解

6.1 ToolFilterMiddleware:不是简单丢弃

一个常见的误区是:拦截被禁用的工具调用时,直接丢弃该请求即可。但这样做会导致 LLM 收不到任何 tool result,它会认为调用超时或失败,然后反复重试。

正确的做法是返回一个明确的 DENIED 结果

java 复制代码
private static final String DISABLED_HINT_TEMPLATE =
    "[SYSTEM] Tool '%s' has been disabled by the administrator " +
    "and is currently unavailable. Do NOT retry this tool. " +
    "Please answer the user's question using other available tools " +
    "or inform the user that this feature is temporarily unavailable.";
graph TB START[onActing] --> FIX[fix null content] FIX --> GET[get disabledTools from cache] GET --> CHECK{empty?} CHECK -->|Yes| PASS[passthrough] CHECK -->|No| SPLIT[split allowed vs denied] SPLIT --> DENIED[generate DENIED result] SPLIT --> ALLOWED[execute allowed tools] DENIED --> MERGE[merge results to LLM] ALLOWED --> MERGE

此外,ToolFilterMiddleware 还修复了一个 AgentScope 框架的 bug:HITL resume 后 ToolUseBlock.contentnull,导致下游处理异常。Middleware 在拦截前先检测并填充 "{}" 作为默认值。

6.2 HitlPermissionMiddleware:每次调用前强制刷新

HitlPermissionMiddleware 在 onAgent 阶段执行(order=10,高优先级),其核心逻辑是:每次 Agent 执行前,从 Redis 读取最新的 confirmRequiredTools 集合,动态构建 PermissionContext 并覆盖 Agent 的旧规则。

graph TB START[onAgent] --> A{is ReActAgent?} A -->|No| PASS[passthrough] A -->|Yes| B{is resume call?} B -->|Yes| SKIP[skip ASK injection] B -->|No| LOAD[load confirmRequiredTools from Redis] LOAD --> C{empty?} C -->|Yes| CLEAR[inject empty BYPASS context] C -->|No| BUILD[build ASK rules per tool] CLEAR --> NEXT[next.apply] BUILD --> REPLACE[replacePermissionContext] REPLACE --> NEXT

为什么需要每次强制刷新? AgentScope 的 stateStore(MySQL 持久化)可能缓存了旧的 PermissionContext。如果不覆盖,管理员取消了某个工具的 HITL 要求后,Agent 仍会按旧规则暂停。通过 replacePermissionContext 每次覆盖,确保 Agent 始终遵循最新策略。

Resume 调用为何跳过? 当用户确认了某个工具的执行后,系统会发送 resume 消息让 Agent 继续。如果此时再次注入 ASK 规则,Agent 会再次暂停,形成死循环。

七、HITL 人工确认:暂停→确认→恢复 核心循环

HITL(Human-in-the-Loop)的核心是一个暂停→确认→恢复的循环:Agent 遇到高危工具时暂停,等待人工确认后恢复执行。整个流程围绕这个循环展开。

7.0 核心循环总览

graph LR A[Agent run] -->|high risk tool| B[Pause] B -->|save state| C[Wait confirm] C -->|approve| D[Resume] C -->|deny| E[Abort] C -->|new message| F[Cancel pending] D -->|high risk again| B E --> A F --> A

这个循环可能在一轮对话中多次发生------Agent 可能连续调用多个高危工具,每次都会重新进入暂停→确认→恢复的流程。

7.1 触发暂停

当 Agent 决定调用一个被标记为 requireConfirm 的工具时,AgentScope 框架的 Permission 系统会拦截这次调用:

  • HitlPermissionMiddleware 在每次 Agent 执行前,从 Redis 读取最新的 confirmRequiredTools 集合,构建 ASK 规则并注入 PermissionContext
  • Agent 执行到该工具时,检测到 ASK 规则,不执行工具 ,而是返回 PERMISSION_ASKING 状态
  • ChatServiceImpl 捕获这个状态,提取待确认的工具列表

关键代码:

java 复制代码
// ChatServiceImpl --- 检测 Agent 暂停
Msg result = agent.call(msg, ctx).block();
if (result != null && result.getGenerateReason() == GenerateReason.PERMISSION_ASKING) {
    List<HitlPendingTool> pendingTools = HitlToolExtractor.extractAskingTools(result);
    // 进入状态持久化流程...
}

7.2 状态持久化与前端通知

Agent 暂停后,需要将"哪些工具等待确认"这个状态保存下来,并通知前端展示确认对话框:

  1. 保存 pending 状态到 Redis :Key 为 app:agent:hitl:pending:{sessionId},TTL 10 分钟
  2. 通过 SSE 推送确认事件 :前端收到 require_user_confirm 事件后渲染确认对话框
java 复制代码
// HitlStateService --- 基于 Redis 的分布式状态管理
private static final String KEY_PREFIX = "app:agent:hitl:pending:";
private static final Duration DEFAULT_TIMEOUT = Duration.ofMinutes(10);

public void savePendingState(HitlPendingState state) {
    RBucket<HitlPendingState> bucket = redissonClient.getBucket(buildKey(state.getSessionId()));
    bucket.set(state, DEFAULT_TIMEOUT);
}

使用 Redis 而非本地内存的原因:多实例部署下,用户的 resume 请求可能被路由到不同实例,必须通过共享存储确保状态可访问。

7.3 用户确认与 Agent 恢复

用户在确认对话框中做出选择后,前端调用 POST /api/chat/resume 接口,流程进入恢复阶段:

sequenceDiagram participant FE as 前端 participant CS as ChatService participant Agent as HarnessAgent participant Tool as 目标Tool Note over FE,Tool: 确认后恢复执行 FE->>CS: POST /api/chat/resume approved CS->>CS: removePendingState + 构建ConfirmResult CS->>Agent: agent.call resumeMsg + ConfirmResult Agent->>Tool: 执行Tool Tool-->>Agent: 返回结果 Agent-->>CS: 最终回复 CS-->>FE: SSE 回复内容 Note over FE,Tool: 拒绝后放弃执行 FE->>CS: POST /api/chat/resume denied CS->>CS: removePendingState + ConfirmResult false CS->>Agent: agent.call denyMsg + ConfirmResult Agent-->>CS: 告知用户操作已取消 CS-->>FE: SSE 取消回复

恢复的核心是构造一条携带 ConfirmResult 的 resume 消息发送给 Agent。Agent 收到后根据确认结果决定是执行工具还是放弃。HitlPermissionMiddleware 检测到 resume 调用时会跳过 ASK 规则注入,否则 Agent 会再次暂停,形成死循环。

7.4 新消息覆盖策略

当 Agent 处于 HITL 等待状态时,用户可能直接发送新消息而非回应确认对话框。此时的策略是先拒绝旧 pending,再处理新消息

java 复制代码
// ChatServiceImpl.chat()
hitlStateService.removePendingState(ctx.getSessionId())
    .ifPresent(state -> cancelPendingState(state, ctx));

cancelPendingState 向 Agent 发送一条 denied 的 ConfirmResult,让 Agent 释放暂停状态,然后再处理用户的新消息。这确保 Agent 不会永远卡在等待确认的状态。

八、数据模型与存储设计

8.1 MySQL 表结构

sql 复制代码
CREATE TABLE agentscope_tool_config (
    id              BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
    group_name      VARCHAR(100)    NOT NULL COMMENT 'ToolGroup 名称',
    tool_name       VARCHAR(200)    NOT NULL COMMENT 'Tool 名称',
    enabled         TINYINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '是否启用:1=启用,0=禁用',
    require_confirm TINYINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '是否需要人工确认',
    is_deleted      TINYINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '逻辑删除',
    description     VARCHAR(500)    DEFAULT NULL COMMENT 'Tool 描述',
    created_at      DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at      DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    creator         VARCHAR(64)     NOT NULL DEFAULT 'system',
    modifier        VARCHAR(64)     NOT NULL DEFAULT 'system'
) COMMENT='Tool 启用/禁用配置表';

8.2 Redis Key 设计

Key 类型 用途 TTL
app:agent:tool:disabled RSet 被禁用的工具名集合 永不过期
app:agent:tool:confirmRequired RSet 需要人工确认的工具名集合 永不过期
app:agent:tool:refresh Pub/Sub Topic 配置变更广播 -
app:agent:hitl:pending:{sessionId} RBucket HITL 待确认状态 10 分钟

8.3 设计考量

为什么用 Redis Set 而非 Hash? 禁用和确认工具集合本质上是"成员是否存在"的判断,Set 的 contains 操作是 O(1) 复杂度,且 Redisson 的 RSet 提供了原子替换整个集合的能力,适合全量同步场景。

为什么用 Pub/Sub 而非 Redis Stream? 工具配置变更是"最新状态覆盖"语义,不需要消息持久化和回溯。即使丢失一条消息,兜底的轮询机制也能在 30 秒内检测到变更。Pub/Sub 的简单语义恰好匹配这个需求。

为什么 HITL 用 RBucket 而非 RSet? 每个 session 的 pending 状态是独立的,且需要设置 TTL 自动过期。RBucket 的"一个 Key 一个值"模型天然适合这种按 sessionId 隔离的场景。

九、关键设计决策总结

9.1 设计模式应用

设计模式 应用位置 解决的问题
编排者模式 ToolRegistryServiceImpl 协调 DB、HotReloader、Middleware 多个组件
中间件链 ToolFilterMiddleware + HitlPermissionMiddleware 可插拔的拦截逻辑,order 控制执行顺序
发布-订阅 + 轮询兜底 Redis Pub/Sub + @Scheduled 多实例同步,解决 Pub/Sub 消息丢失问题
延迟绑定 bindAgent() 解决 HarnessAgent ↔ Toolkit ↔ ToolRegistryService 循环依赖
Copy-on-Build HarnessAgent.build() → toolkit.copy() Agent 使用 Toolkit 副本,隔离原始配置
缓存前置 agentToolCache (ConcurrentHashMap) removeTool 后仍可重新注册

9.2 延迟绑定解决循环依赖

AgentScope 的依赖关系存在一个循环:

复制代码
HarnessAgent → Toolkit(Agent 持有 Toolkit 副本)
Toolkit → ToolRegistryService(启动时初始化需要 Service 同步 DB)
ToolRegistryService → HarnessAgent(热更新需要操作 Agent 内部的 Toolkit 副本)

解决方案是在 AgentConfiguration 中先构建 HarnessAgent,再通过 toolRegistryService.bindAgent(agent) 延迟注入:

java 复制代码
HarnessAgent agent = HarnessAgent.builder()
    // ... 配置省略 ...
    .build();

// 延迟绑定 Agent 引用及其内部 Toolkit,避免循环依赖
toolRegistryService.bindAgent(agent);

return agent;

9.3 线程安全策略

数据结构 用途 线程安全机制
CopyOnWriteArrayList<Toolkit> boundToolkits 读多写少,迭代时不加锁
ConcurrentHashMap<String, AgentTool> agentToolCache 高并发读写
ReentrantLock stateRefreshLock applyCurrentState 保护 Toolkit 修改操作的互斥访问
volatile boolean toolkitInitialized 初始化标志 保证可见性
volatile long lastDbStateHash 轮询哈希 保证可见性

十、总结与扩展思路

方案优势

  1. 零停机更新:管理员通过 API 修改工具配置后,所有实例在秒级内生效,无需重启服务。
  2. 纵深防护:三层防护确保禁用工具不会被执行,即使 LLM 缓存了旧的 tool list。
  3. 最终一致性:Pub/Sub + 轮询兜底的双重机制,保证多实例间状态一致。
  4. 安全可控:高危工具通过 HITL 机制实现人工确认,防止 Agent 自主执行危险操作。
  5. 架构清晰:编排层、引擎层、拦截层职责分明,易于维护和扩展。

已知限制

  • 不支持工具参数热更新 :当前方案仅支持工具的启用/禁用/确认配置变更,工具本身的参数定义(@ToolParam)变更仍需重新部署。
  • 不支持按用户灰度:工具配置为全局维度,所有用户看到相同的工具集。
  • Pub/Sub 无持久化:Redis Pub/Sub 是 fire-and-forget 模式,依赖轮询兜底覆盖网络分区场景,最大同步延迟为 30 秒。
  • 单 Agent 实例 :当前设计为全局单 HarnessAgent,如需多 Agent 场景需扩展 boundToolkits 的管理维度。

可扩展方向

  • 工具版本管理:为每个 Tool 增加版本号,支持灰度升级和回滚。
  • 按用户维度配置:当前是全局配置,可扩展为按用户/租户维度定制工具可见性。
  • 灰度发布:结合 feature flag,先将新工具开放给部分用户,验证无误后再全量。
  • 工具调用审计:记录每次工具调用的完整上下文(输入、输出、耗时、操作人),用于安全审计和故障排查。
  • 动态工具注册:支持运行时动态加载新的 Tool 类(如通过 SPI 或 OSGi),而非仅限于启动时扫描。

技术栈:Spring Boot 3.5 + AgentScope 2.0.0 + MyBatis-Plus + Redisson + Java 21

相关推荐
ttwuai1 小时前
Go 后台接入 SSO 后菜单正常但接口 403,怎么排查权限链路?
开发语言·后端·golang
天远Date Lab1 小时前
零信任架构实战:基于天远行驶证核查构建自动化车队准入网关
运维·人工智能·架构·自动化
65岁退休Coder2 小时前
LangChain v1.3.4 笔记 - 08 MCP & 相关概念
后端·python·langchain
郑州光合科技余经理2 小时前
海外版多语言团购系统架构:主数据互通与核销边界
java·开发语言·前端·后端·系统架构·php·ai编程
这个DBA有点耶2 小时前
一文讲透数据库分类:关系型、非关系型、OLTP、OLAP、分布式、多模……
数据库·mysql·架构
vipxieliang3 小时前
ValidX 的 Date 和 DateTime vs JPA 的 Temporal 对比
java·后端
触底反弹3 小时前
🔥 从「为什么」到「怎么用」:TypeScript 类型约束与泛型完全指南
后端·面试·typescript
用户8356290780513 小时前
使用 Python 查找和替换 Word 文档中的文本
后端·python
Java编程爱好者3 小时前
Spring Boot 实现数据脱敏:自定义注解 + Jackson 序列化器
后端