一、为什么 AI Agent 需要 Tool 热更新?
在生产环境中运行 AI Agent 时,运维团队经常面临一个尴尬的困境:
- 某个 Tool 出现 Bug 需要紧急禁用,但重启服务会中断所有正在进行的会话;
- 新增了一个 Tool 想先灰度给部分 Agent 使用,却发现只能全局生效;
- 高危操作工具(如删除数据、发送通知)需要人工确认后才能执行,但改配置必须重启。
这些问题的根源在于:Agent 的工具配置是启动时静态绑定的,运行时无法动态调整。
本文介绍一种基于 AgentScope 框架的 Tool 热更新方案,实现以下能力:
- 不重启服务即可启用/禁用/配置工具
- 三层防护确保禁用的工具不会被执行
- 多实例同步保证分布式环境下状态一致
- HITL(Human-in-the-Loop) 高危工具执行前需人工确认
二、整体架构
各层职责:
| 层级 | 组件 | 职责 |
|---|---|---|
| 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,仍会尝试调用已移除的工具。因此本方案设计了三层纵深防护:
三层防护的设计理念:
-
Toolkit 层(removeTool):从 Agent 的工具注册表中移除工具,LLM 在下次获取 tool list 时看不到这个工具。这是"正常路径"的防护。
-
Middleware 层(ToolFilterMiddleware) :在
onActing阶段拦截,即使 LLM 缓存了旧的 tool list 仍然发起调用,Middleware 也能拦截并返回明确的 DENIED 结果------告诉 LLM"这个工具已被管理员禁用,请勿重试",避免 LLM 因收不到结果而陷入重试循环。 -
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 方法中,分三步执行:
对应的核心代码:
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 中,使用 ReentrantLock(stateRefreshLock)保护所有可能并发修改 Toolkit 的操作,包括:
- API 直接触发的状态变更
- Redis Pub/Sub 广播消息触发的刷新
- 定时轮询触发的刷新
bindAgent初始化操作
五、多实例状态同步
在生产环境中,服务通常以多实例方式部署。当管理员在实例 A 上禁用了某个工具,实例 B 和 C 也需要尽快感知到这一变化。
5.1 三种触发路径
为什么需要三种路径?
- API 直接触发:管理员操作后立即在本地生效,同时发布 Redis 消息通知其他实例。
- Redis Pub/Sub 广播:其他实例收到消息后从 DB 重新加载状态并刷新本地 Toolkit。这是主要的跨实例同步手段。
- 定时轮询兜底 :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.";
此外,ToolFilterMiddleware 还修复了一个 AgentScope 框架的 bug:HITL resume 后 ToolUseBlock.content 为 null,导致下游处理异常。Middleware 在拦截前先检测并填充 "{}" 作为默认值。
6.2 HitlPermissionMiddleware:每次调用前强制刷新
HitlPermissionMiddleware 在 onAgent 阶段执行(order=10,高优先级),其核心逻辑是:每次 Agent 执行前,从 Redis 读取最新的 confirmRequiredTools 集合,动态构建 PermissionContext 并覆盖 Agent 的旧规则。
为什么需要每次强制刷新? AgentScope 的 stateStore(MySQL 持久化)可能缓存了旧的 PermissionContext。如果不覆盖,管理员取消了某个工具的 HITL 要求后,Agent 仍会按旧规则暂停。通过 replacePermissionContext 每次覆盖,确保 Agent 始终遵循最新策略。
Resume 调用为何跳过? 当用户确认了某个工具的执行后,系统会发送 resume 消息让 Agent 继续。如果此时再次注入 ASK 规则,Agent 会再次暂停,形成死循环。
七、HITL 人工确认:暂停→确认→恢复 核心循环
HITL(Human-in-the-Loop)的核心是一个暂停→确认→恢复的循环:Agent 遇到高危工具时暂停,等待人工确认后恢复执行。整个流程围绕这个循环展开。
7.0 核心循环总览
这个循环可能在一轮对话中多次发生------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 暂停后,需要将"哪些工具等待确认"这个状态保存下来,并通知前端展示确认对话框:
- 保存 pending 状态到 Redis :Key 为
app:agent:hitl:pending:{sessionId},TTL 10 分钟 - 通过 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 接口,流程进入恢复阶段:
恢复的核心是构造一条携带 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 |
轮询哈希 | 保证可见性 |
十、总结与扩展思路
方案优势
- 零停机更新:管理员通过 API 修改工具配置后,所有实例在秒级内生效,无需重启服务。
- 纵深防护:三层防护确保禁用工具不会被执行,即使 LLM 缓存了旧的 tool list。
- 最终一致性:Pub/Sub + 轮询兜底的双重机制,保证多实例间状态一致。
- 安全可控:高危工具通过 HITL 机制实现人工确认,防止 Agent 自主执行危险操作。
- 架构清晰:编排层、引擎层、拦截层职责分明,易于维护和扩展。
已知限制
- 不支持工具参数热更新 :当前方案仅支持工具的启用/禁用/确认配置变更,工具本身的参数定义(
@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