一、为什么 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]
三层防护的设计理念:
-
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 中:
// 缓存所有 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]
对应的核心代码:
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 三种触发路径
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
为什么需要三种路径?
- API 直接触发:管理员操作后立即在本地生效,同时发布 Redis 消息通知其他实例。
- Redis Pub/Sub 广播:其他实例收到消息后从 DB 重新加载状态并刷新本地 Toolkit。这是主要的跨实例同步手段。
- 定时轮询兜底:Redis Pub/Sub 是"fire-and-forget"模式,如果某个实例在消息发布时恰好断连,就会丢失这条消息。每 30 秒的轮询通过比较 DB 状态哈希(
computeConfigStateHash)来检测遗漏的变更,确保最终一致性。
5.2 状态哈希检测
轮询机制并非每次都全量刷新,而是计算当前 DB 配置的状态哈希,与上次记录的哈希值比较:
@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 结果:
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.content 为 null,导致下游处理异常。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捕获这个状态,提取待确认的工具列表
关键代码:
// 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事件后渲染确认对话框
// 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,再处理新消息:
// ChatServiceImpl.chat()
hitlStateService.removePendingState(ctx.getSessionId())
.ifPresent(state -> cancelPendingState(state, ctx));
cancelPendingState 向 Agent 发送一条 denied 的 ConfirmResult,让 Agent 释放暂停状态,然后再处理用户的新消息。这确保 Agent 不会永远卡在等待确认的状态。
八、数据模型与存储设计
8.1 MySQL 表结构
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) 延迟注入:
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