1. 项目概述:为什么 Java 场景下的 AI Agent 权限管控不是“锦上添花”,而是“生死线”
最近在某金融类 SaaS 平台做智能客服模块升级时,团队把一个原本跑在 Python 环境里的 RAG+Agent 流程,整体迁移到了主业务线的 Java 技术栈里。迁移完成后,测试阶段一切顺利——知识库检索准、工具调用快、多轮对话逻辑稳。但上线前安全审计一票否决:所有 Agent 调用外部系统(如用户画像服务、订单查询接口、风控决策引擎)的行为,完全暴露在统一网关之后,没有任何细粒度权限校验机制。不是没鉴权,而是“粗粒度”——整个 Agent 服务只配了一个全局 service-token,一旦泄露或被越权调用,后果是灾难性的。
这正是“AgentScope Java 实战:给 AI Agent 加上权限管控”这个标题背后的真实切口:它不是教你怎么写个带角色的聊天机器人,而是在 Java 工程化落地中,直面 AI Agent 架构带来的全新安全范式挑战。Agent 不再是单点脚本,而是会主动发起 HTTP 调用、读写数据库、触发工作流、甚至调用内部 RPC 接口的“数字员工”。它的行为不可预测、路径不可穷举、上下文高度动态——传统基于用户身份的 RBAC(基于角色的访问控制)模型,在这里直接失灵。你不能说“张三可以调用订单接口”,因为 Agent 是以服务身份运行的;你也不能说“所有 Agent 都能查用户信息”,因为 A 客服 Agent 和 B 风控 Agent 的数据边界必须物理隔离。
我试过三种主流解法:第一种是让每个 Agent 实例绑定一个最小权限的 service-account,但 Java Spring Cloud 微服务架构下,Agent 本身是无状态组件,频繁创建销毁账号成本高、审计难;第二种是改写所有工具调用层,加一层代理拦截器,结果发现光是 OpenAPI 规范里定义的 200+ 个工具接口,就要手动补 200+ 个 @PreAuthorize 注解,且无法覆盖动态生成的工具(比如根据用户提问实时构造的 SQL 查询);第三种才是我们最终落地的方案——在 Agent 执行生命周期的关键断点注入权限决策点,将权限判断从“静态配置”升级为“上下文感知的动态策略执行”。它不改变原有工具代码,不侵入业务逻辑,却能让每个 Agent 在调用前 50ms 内,完成对“当前意图、当前用户、当前会话上下文、当前工具参数”的四维校验。这不是加个开关,而是给 AI Agent 装上了一套可编程的“数字安检门”。
这个方案的核心关键词,就是标题里的三个锚点:“AgentScope”——指代 Agent 的完整执行范围与可观测边界;“Java”——强调必须适配 Spring Boot + Jakarta EE 生态,兼容 JDK 17+、Spring Security 6.x、Micrometer 指标体系;“权限管控”——不是简单的 allow/deny,而是支持策略链(Policy Chain)、条件表达式(SpEL)、实时缓存、审计留痕的工业级能力。适合正在将 LLM 应用深度集成进核心 Java 业务系统的架构师、安全工程师和资深后端开发——如果你还在用 curl 测试 Agent、用 Postman 模拟调用、靠人工 review 日志来确认权限是否生效,那这篇实战记录,就是你该按下暂停键认真读完的第一份材料。
2. 整体设计思路:为什么放弃“网关统管”和“工具层硬编码”,选择“执行时动态注入”
在正式写一行代码前,我们花了整整三天时间画了七版架构图,核心目标只有一个:让权限管控能力像“空气”一样存在——无感、无侵入、可灰度、可回滚。这意味着它不能成为 Agent 运行的瓶颈,不能增加单次推理的延迟超过 15ms(实测要求),更不能让业务开发同学为了加个新工具,就跑去改权限配置中心。我们最终放弃两个看似“省事”的方案,原因非常具体:
2.1 为什么不用 API 网关统一鉴权?
很多团队第一反应是“把所有 Agent 发起的请求都走网关,网关统一做权限检查”。听起来很美,但实测下来有三个致命缺陷:
语义丢失严重:网关看到的只是
POST /api/v1/tool/query-order,它根本不知道这个请求来自哪个 Agent、服务于哪个用户、当前对话历史中用户是否已授权查看订单。网关只能做 path-level 或 header-level 校验,而 Agent 的权限恰恰依赖于请求内容语义—— 比如“查张三的订单”合法,“查李四的订单”非法,但两者 URL 完全一致。性能雪崩风险:Agent 一次复杂任务可能串行/并行调用 5~8 个工具。如果每个工具调用都要经过网关鉴权,意味着单次 Agent 执行要产生 5~8 次网关往返。在 QPS 300+ 的生产环境,网关将成为最脆弱的单点。我们压测发现,当网关鉴权 RT 超过 8ms,Agent 整体 P95 延迟直接突破 2.3s,用户明显感知卡顿。
调试与审计脱节:网关日志里只有
403 Forbidden,但你完全不知道是哪个 Agent、在处理哪条用户消息、因为哪条策略失败而被拦下。排查问题时,你要同时翻网关日志、Agent 日志、策略中心日志,三者时间戳对不齐,ID 关联不上,效率极低。
提示:网关适合做“流量入口级”的粗粒度防护(如防爬、限流、IP 黑白名单),但绝不适合承担 AI Agent 的细粒度权限决策。把它当成“城门守卫”可以,但别指望它能分辨进城的人是来送快递还是来偷保险柜。
2.2 为什么不在每个工具方法上硬编码 @PreAuthorize?
Spring Security 的@PreAuthorize确实强大,支持 SpEL 表达式,能写@PreAuthorize("@permissionService.hasPermission(#userId, 'ORDER_QUERY')")。但问题在于——Agent 的工具调用是动态的、参数化的、非强类型的。
举个真实例子:我们有一个通用数据库查询工具,Agent 可以传入任意 SQL 片段:
public class SqlQueryTool implements Tool { @Override public String invoke(String input) { // input 是 JSON 字符串,包含 { "sql": "SELECT * FROM orders WHERE user_id = ?", "params": ["u_123"] } return executeSql(input); } }你无法在invoke()方法上写@PreAuthorize,因为#input是字符串,SpEL 解析不了其中的user_id。你也不可能为每条可能的 SQL 都预定义一个权限规则。更麻烦的是,Agent 还支持“工具组合”——比如先调getUserProfile,拿到department_id后,再动态构造queryDeptReport的参数。这种跨工具的上下文关联,硬编码方式完全无法建模。
2.3 最终选定:执行时动态注入(Runtime Injection)的三层架构
我们采用“Agent 执行引擎 → 权限决策中心 → 策略执行器”三层解耦设计,所有权限检查发生在 Agent 工具调用前的毫秒级窗口内:
第一层:Agent 执行引擎增强
基于 AgentScope 的ToolExecutor接口,我们编写了一个SecuredToolExecutor装饰器。它不修改任何原有工具代码,只在execute()方法被调用前,自动提取当前执行上下文(Context):包括agentId、sessionId、currentUserId、currentMessageId、toolName、toolInput(已解析为 Map)、callStack(调用链路)。这个 Context 是权限决策的唯一数据源。第二层:权限决策中心(Policy Decision Point, PDP)
独立的 Spring Boot 服务,提供/v1/decideREST 接口。输入是上述 Context,输出是DecisionResult { allowed: true/false, reason: "策略X拒绝", auditId: "a-7f3e" }。PDP 内部使用 Drools 规则引擎,规则文件.drl支持热加载,语法接近自然语言:rule "风控Agent禁止查询非本人订单" when $c: Context(agentId == "risk-agent", toolName == "query-order", $uid: currentUserId, $input: toolInput["user_id"] != $uid) then insert(new DenyDecision("非本人订单查询被拒", "POLICY_RISK_001")); end第三层:策略执行器(Policy Enforcement Point, PEP)
就是SecuredToolExecutor本身。它同步调用 PDP,拿到DecisionResult后:若allowed==false,则抛出AccessDeniedException,并自动记录审计日志(含auditId、sessionId、toolInput脱敏后的内容);若allowed==true,则放行并记录ACCESS_GRANTED日志。整个过程平均耗时 3.2ms(PDP 本地缓存命中率 92%)。
这个设计的最大优势是“策略与代码彻底分离”。安全团队在 PDP 后台改一条规则,5 秒内全量生效,无需重启任何 Agent 服务。业务开发写新工具时,只要实现Tool接口,SecuredToolExecutor就自动接管其权限校验——他们甚至不知道权限系统存在。
3. 核心细节解析:如何构建可审计、可回溯、低延迟的权限上下文
权限管控失效,90% 的原因是上下文信息不完整、不准确、不可信。在 Agent 场景下,“当前用户是谁”这个问题本身就充满陷阱:是登录系统的用户?是对话中提到的第三方用户?还是 Agent 自己模拟的用户身份?我们必须定义一套严格、可验证、带来源标记的上下文模型。以下是我们在生产环境稳定运行 6 个月的ExecutionContext结构设计与关键实现细节。
3.1 ExecutionContext 的四大可信字段及其来源约束
我们定义的ExecutionContext不是一个泛泛的 Map,而是强类型的 POJO,强制要求以下四个字段必须由可信链路注入,禁止任何工具方法自行设置:
| 字段名 | 类型 | 必填 | 来源约束 | 为什么必须这样设计 |
|---|---|---|---|---|
principalId | String | 是 | 仅来自 OAuth2.0 Access Token 的sub声明。Agent 服务启动时,由 Spring Security 的JwtAuthenticationToken解析并注入,不可伪造。 | 这是整个权限体系的“根信任”。如果连用户身份都不可信,后续所有校验都是空中楼阁。我们禁用所有setPrincipalId()方法,只允许构造时传入。 |
sessionId | String | 是 | 仅来自 WebSocket 连接 ID 或 HTTP HeaderX-Session-ID。由网关在首次请求时生成并透传,Agent 服务将其与principalId绑定存入 ThreadLocal。 | 会话是行为审计的最小单位。同一个用户开 5 个 Tab,就有 5 个独立sessionId,确保权限决策和日志能精确到每一次对话。 |
agentId | String | 是 | 仅来自 Agent 配置元数据。每个 Agent 实例在 Spring 容器初始化时,通过@Value("${agent.id}")注入,如"customer-service-agent"。 | 区分 Agent 身份是策略编写的前提。风控 Agent 和客服 Agent 的权限规则完全不同,必须在上下文中明确标识。 |
callStack | List | 否(但强烈建议) | 由 SecuredToolExecutor 自动维护。每次工具调用前,将toolNamepush 进栈;调用返回后 pop。最大深度限制为 10。 | 解决“工具链路污染”问题。例如A→B→C,C 的权限可能依赖于 A 的原始意图。callStack让策略能写callStack.contains("get-user-profile")。 |
注意:
toolInput字段虽然也放入上下文,但它不参与信任链构建,而是作为策略校验的“待检对象”。我们对toolInput做了两件事:一是自动 JSON 解析为Map<String, Object>,方便 SpEL 访问input["user_id"];二是对敏感字段(如password、token)做自动脱敏,写入审计日志时显示为***。
3.2 如何保证上下文在异步调用链中不丢失?
Agent 的典型执行模式是:主线程接收用户消息 → 启动CompletableFuture异步执行推理 → 推理结果触发多个Mono工具调用。Java 的 ThreadLocal 在这种场景下会失效。我们的解决方案是ContextualExecutorService+MDC双保险:
第一步:封装线程池
创建ContextualThreadPoolTaskExecutor,重写execute(Runnable task)方法:@Override public void execute(Runnable task) { ExecutionContext currentCtx = ExecutionContextHolder.get(); // 将当前上下文快照,绑定到新线程 super.execute(() -> { ExecutionContextHolder.set(currentCtx); // 设置到新线程 try { task.run(); } finally { ExecutionContextHolder.remove(); // 清理 } }); }所有 Agent 的异步任务都必须通过此线程池提交。
第二步:集成 MDC(Mapped Diagnostic Context)
在ExecutionContextHolder.set()时,同步将关键字段写入 SLF4J 的 MDC:MDC.put("principalId", ctx.getPrincipalId()); MDC.put("sessionId", ctx.getSessionId()); MDC.put("agentId", ctx.getAgentId());这样,无论日志打印在哪条线程,只要配置了
%X{principalId} %X{sessionId},就能自动带上上下文,极大提升日志可追溯性。第三步:WebFlux 场景专用适配
对于基于 Project Reactor 的响应式 Agent,我们编写了ContextualMonoOperator,在Mono.defer()和Mono.flatMap()的每个订阅点,自动从父ContextView中提取并重建ExecutionContext。这确保了从 Controller → Service → Tool 的整个 Reactor 链路,上下文零丢失。
这套机制经受住了峰值 QPS 1200 的压力考验。我们用 Arthas 监控ExecutionContextHolder.get()调用,确认 99.99% 的请求都能正确获取上下文,丢失率低于 0.01%,完全满足金融级审计要求。
3.3 审计日志的黄金标准:什么必须记,什么绝对不能记
权限系统最大的价值之一是“事后可证伪”。当发生安全事件时,审计日志就是你的法律证据。我们制定了严格的日志规范,所有日志都写入独立的audit.log文件,并同步推送至 ELK:
必须记录的 7 个字段(JSON 格式):
{ "timestamp": "2024-06-15T14:22:33.128Z", "auditId": "a-8b2f4c1e", "principalId": "u_556677", "sessionId": "s-9a3d8f2c", "agentId": "customer-service-agent", "toolName": "query-order", "decision": "DENIED", "reason": "策略'客服Agent禁止查询超30天订单'触发", "policyId": "POLICY_CS_007", "inputSummary": {"user_id": "u_556677", "days": 45}, "stackTrace": ["query-order", "get-user-profile"] }其中
inputSummary是toolInput的脱敏摘要,只保留策略校验必需的字段,且值已哈希或截断。绝对禁止记录的 3 类信息:
- 原始
toolInput全量内容:避免日志中出现用户身份证号、银行卡号等 PII(个人身份信息)。我们用正则预扫描,匹配到id_card、bank_card等 key 就整字段替换为***。 - AccessToken 或 RefreshToken:即使它们出现在
AuthorizationHeader 中,也必须在日志中过滤。我们自定义了 Logback 的MaskingPatternLayout,对Bearer [token]模式做全局掩码。 - 堆栈跟踪(StackTrace)中的敏感变量:
AccessDeniedException的printStackTrace()默认会打印局部变量,可能泄露userId。我们重写了toString()方法,只输出策略 ID 和简短原因。
- 原始
实操心得:上线前,我们请安全团队用
grep -r "id_card\|bank_card\|token" /var/log/app/audit.log扫描了 7 天的日志,结果为 0。这才是合格的审计日志——它不追求“信息越多越好”,而追求“关键信息精准、无关信息零泄露”。
4. 实操过程详解:从零搭建 SecuredToolExecutor 到策略热更新
现在进入最硬核的部分:手把手带你写出生产可用的权限管控模块。我们假设你已有一个基于 Spring Boot 3.x + JDK 17 的 Agent 项目,核心依赖如下:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-webflux</artifactId> </dependency> <dependency> <groupId>org.kie</groupId> <artifactId>kie-api</artifactId> <version>8.41.0.Final</version> </dependency> <dependency> <groupId>org.drools</groupId> <artifactId>drools-core</artifactId> <version>8.41.0.Final</version> </dependency>4.1 第一步:定义 ExecutionContext 与 Holder(15 行代码)
创建ExecutionContext.java,注意final和private修饰符,杜绝外部篡改:
public final class ExecutionContext { private final String principalId; private final String sessionId; private final String agentId; private final List<String> callStack; private final Map<String, Object> toolInput; // 已解析的输入 public ExecutionContext(String principalId, String sessionId, String agentId, List<String> callStack, Map<String, Object> toolInput) { this.principalId = Objects.requireNonNull(principalId); this.sessionId = Objects.requireNonNull(sessionId); this.agentId = Objects.requireNonNull(agentId); this.callStack = new ArrayList<>(Objects.requireNonNullElse(callStack, Collections.emptyList())); this.toolInput = new HashMap<>(Objects.requireNonNullElse(toolInput, Collections.emptyMap())); } // getters only, no setters }配套的ExecutionContextHolder使用InheritableThreadLocal,确保子线程继承:
public class ExecutionContextHolder { private static final InheritableThreadLocal<ExecutionContext> CONTEXT = new InheritableThreadLocal<>(); public static void set(ExecutionContext context) { CONTEXT.set(context); } public static ExecutionContext get() { return CONTEXT.get(); } public static void remove() { CONTEXT.remove(); } }4.2 第二步:编写 SecuredToolExecutor(核心 80 行,含注释)
这是整个方案的“心脏”。它实现了ToolExecutor接口,包装原始Tool,并在execute()前插入权限检查:
@Component public class SecuredToolExecutor implements ToolExecutor { private final ToolExecutor delegate; // 原始执行器 private final PolicyDecisionClient policyClient; // PDP 客户端 private final AuditLogger auditLogger; // 审计日志器 public SecuredToolExecutor(ToolExecutor delegate, PolicyDecisionClient policyClient, AuditLogger auditLogger) { this.delegate = delegate; this.policyClient = policyClient; this.auditLogger = auditLogger; } @Override public ToolResponse execute(ToolRequest request) { // 1. 构建 ExecutionContext ExecutionContext context = buildExecutionContext(request); // 2. 同步调用 PDP 决策 DecisionResult decision = policyClient.decide(context); // 3. 记录审计日志(无论允许与否) auditLogger.log(context, decision); // 4. 拒绝则抛异常,允许则放行 if (!decision.isAllowed()) { throw new AccessDeniedException( String.format("Permission denied by policy %s: %s", decision.getPolicyId(), decision.getReason())); } // 5. 执行原始工具 return delegate.execute(request); } private ExecutionContext buildExecutionContext(ToolRequest request) { ExecutionContext baseCtx = ExecutionContextHolder.get(); if (baseCtx == null) { throw new IllegalStateException("No ExecutionContext found in ThreadLocal!"); } // 解析 toolInput 为 Map,自动脱敏 Map<String, Object> inputMap = parseAndSanitizeInput(request.getInput()); // 构建新上下文,push callStack List<String> newStack = new ArrayList<>(baseCtx.getCallStack()); newStack.add(request.getToolName()); return new ExecutionContext( baseCtx.getPrincipalId(), baseCtx.getSessionId(), baseCtx.getAgentId(), newStack, inputMap ); } private Map<String, Object> parseAndSanitizeInput(String inputJson) { // 使用 Jackson 解析,对敏感 key 做脱敏 JsonNode node = objectMapper.readTree(inputJson); ObjectNode sanitized = objectMapper.valueToTree(node); maskSensitiveFields(sanitized); return objectMapper.convertValue(sanitized, Map.class); } }4.3 第三步:实现 PolicyDecisionClient(对接 PDP 服务)
PDP 是一个独立服务,我们用WebClient调用其/v1/decide接口。关键点在于超时控制与降级:
@Service public class PolicyDecisionClient { private final WebClient webClient; private final Cache<String, DecisionResult> decisionCache; // Caffeine 缓存 public PolicyDecisionClient(WebClient.Builder builder) { this.webClient = builder .codecs(configurer -> configurer.defaultCodecs().maxInMemorySize(2 * 1024 * 1024)) // 2MB .build(); this.decisionCache = Caffeine.newBuilder() .maximumSize(10000) .expireAfterWrite(10, TimeUnit.MINUTES) .build(); } public DecisionResult decide(ExecutionContext context) { String cacheKey = generateCacheKey(context); DecisionResult cached = decisionCache.getIfPresent(cacheKey); if (cached != null) { return cached; } try { // 同步调用,超时 50ms,失败则降级为 ALLOW(安全保守策略) DecisionResult result = webClient.post() .uri("http://pdp-service:8080/v1/decide") .bodyValue(context) // 直接传 ExecutionContext .retrieve() .bodyToMono(DecisionResult.class) .block(Duration.ofMillis(50)); if (result != null) { decisionCache.put(cacheKey, result); return result; } } catch (Exception e) { log.warn("PDP call failed, fallback to ALLOW for security", e); } // 降级策略:PDP 不可用时,默认允许(业务连续性优先) return DecisionResult.allow("PDP_UNAVAILABLE_FALLBACK"); } private String generateCacheKey(ExecutionContext ctx) { // 基于 principalId + toolName + inputSummary 的 MD5 String summary = ctx.getToolInput().entrySet().stream() .filter(e -> !SENSITIVE_KEYS.contains(e.getKey())) .map(e -> e.getKey() + "=" + e.getValue()) .collect(Collectors.joining("&")); return DigestUtils.md5Hex(ctx.getPrincipalId() + ":" + ctx.getToolName() + ":" + summary); } }4.4 第四步:PDP 服务的 Drools 规则热加载(关键技巧)
PDP 服务的核心是 Drools 规则引擎。我们不把.drl文件打包进 jar,而是放在外部目录/etc/pdp/rules/,并监听文件变化:
@Configuration public class DroolsConfig { @Bean public KieContainer kieContainer() { KieServices kieServices = KieServices.Factory.get(); KieFileSystem kieFileSystem = kieServices.newKieFileSystem(); // 从外部目录加载所有 .drl 文件 File rulesDir = new File("/etc/pdp/rules/"); if (rulesDir.exists()) { Arrays.stream(rulesDir.listFiles((dir, name) -> name.endsWith(".drl"))) .forEach(file -> { try { kieFileSystem.write(ResourceFactory.newFileResource(file)); } catch (Exception e) { log.error("Failed to load rule file: {}", file.getName(), e); } }); } KieBuilder kieBuilder = kieServices.newKieBuilder(kieFileSystem); kieBuilder.buildAll(); return kieServices.newKieContainer(kieServices.getRepository().getDefaultReleaseId()); } } // 在 Controller 中提供热重载端点 @RestController public class RuleController { @Autowired private KieContainer kieContainer; @PostMapping("/v1/rules/reload") public ResponseEntity<String> reloadRules() { // 重新构建 KieContainer(Drools 8.41+ 支持) ((KieContainerImpl) kieContainer).dispose(); // ... 重建逻辑 return ResponseEntity.ok("Rules reloaded"); } }实操心得:规则热加载有个坑——Drools 的
KieContainer重建后,旧的KieSession会失效。我们用@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)注解KieSessionBean,确保每次decide()调用都获取新实例。另外,.drl文件里不要写import java.util.*,而要写全限定名,避免类加载冲突。
5. 常见问题与排查技巧实录:那些文档里不会写的血泪教训
在 6 个月的线上运行中,我们遇到了 17 个典型问题。下面精选 5 个最高频、最隐蔽、最易踩坑的问题,附上真实排查过程和终极解法。这些不是理论推演,而是凌晨三点在服务器前敲命令、看日志、改代码换来的经验。
5.1 问题:PDP 返回 ALLOW,但 Agent 工具调用仍报 403,日志里找不到auditId
现象:用户反馈“查订单功能突然不能用了”,PDP 日志显示{"decision":"ALLOW","auditId":"a-123"},但 Agent 服务日志里搜索a-123为空,且抛出AccessDeniedException。
排查过程:
- 第一步:在
SecuredToolExecutor.execute()开头加log.info("Before PDP call, ctx={}", context),确认上下文已正确构建。 - 第二步:在
policyClient.decide()返回后加log.info("After PDP call, result={}", result),发现日志里result是null! - 第三步:检查
webClient调用,发现block(Duration.ofMillis(50))超时了,但降级逻辑里log.warn("PDP call failed...")被日志级别ERROR挡住了(当时 warn 被设为 ignore)。
根本原因:PDP 服务因 GC 停顿导致响应超时,webClient返回null,降级逻辑执行,但日志没打出来,导致误以为 PDP 正常返回了ALLOW。
终极解法:
- 强制日志可见:将降级日志级别改为
ERROR,并添加Thread.dumpStack():} catch (Exception e) { log.error("PDP call FAILED and fallback to ALLOW. Stack:", e); Thread.dumpStack(); // 打印当前线程堆栈,定位超时点 return DecisionResult.allow("PDP_UNAVAILABLE_FALLBACK"); } - 增加 PDP 健康检查:Agent 启动时,用
@PostConstruct调用 PDP/health接口,失败则直接System.exit(1),避免带病上线。 - 监控告警:Prometheus 指标
pdp_call_fallback_total,当 5 分钟内 > 10 次,企业微信告警。
5.2 问题:同一个principalId,不同sessionId的请求,PDP 缓存命中率奇高,导致权限误判
现象:用户 A 在 Tab1 查订单正常,在 Tab2 却被拒绝。两个请求的principalId相同,但sessionId不同。PDP 缓存 key 只用了principalId + toolName,没包含sessionId,导致 Tab2 拿到了 Tab1 的缓存结果。
排查过程:
- 用
jcmd <pid> VM.native_memory summary查内存,发现decisionCache占用 800MB,远超预期。 - 抽样打印缓存 key:
log.info("Cache key: {}", cacheKey),发现大量 key 形如u_123:query-order,完全一样。
根本原因:缓存 key 生成函数generateCacheKey()里漏掉了sessionId。我们最初认为principalId足够唯一,忽略了会话隔离是权限的基本要求。
终极解法:
- 修复缓存 key:
return DigestUtils.md5Hex(ctx.getPrincipalId() + ":" + ctx.getSessionId() + ":" + ctx.getToolName() + ":" + summary); - 增加缓存监控:暴露指标
pdp_cache_hit_ratio,当 < 85% 时告警(说明 key 设计有问题)。 - 强制缓存驱逐:在
SecuredToolExecutor的execute()结尾,添加if (ctx.getSessionId().startsWith("temp-")) { decisionCache.invalidate(cacheKey); },用于测试场景的临时会话。
5.3 问题:callStack在 Reactor 链路中深度错乱,策略callStack.contains("get-user-profile")总是 false
现象:一个 Agent 流程是A → B → C,策略要求 C 只能在 B 调用后执行。但日志显示callStack是["C"]或["A", "C"],从不出现["A", "B", "C"]。
排查过程:
- 在
buildExecutionContext()里打印baseCtx.getCallStack(),发现 Reactor 的flatMap操作后,baseCtx是空的。 - 查阅 Project Reactor 文档,确认
ContextView默认不传递ThreadLocal。
根本原因:ExecutionContextHolder依赖ThreadLocal,而 Reactor 的flatMap会切换线程,ThreadLocal断裂。我们之前写的ContextualMonoOperator没有覆盖flatMap的所有变体。
终极解法:
- 重写
ContextualMonoOperator,支持flatMap:public class ContextualMonoOperator<T> implements CoreSubscriber<T> { private final CoreSubscriber<? super T> actual; private final ExecutionContext context; public ContextualMonoOperator(CoreSubscriber<? super T> actual, ExecutionContext context) { this.actual = actual; this.context = context; } @Override public void onSubscribe(Subscription s) { actual.onSubscribe(s); } @Override public void onNext(T t) { ExecutionContextHolder.set(context); // 关键:每次 onNext 都重置 try { actual.onNext(t); } finally { ExecutionContextHolder.remove(); } } // ... onError, onComplete } - 全局注册:在
WebFluxConfigurer中,用Mono.usingWhen()包装所有工具调用,确保onNext时上下文始终有效。
5.4 问题:Drools 规则中input["user_id"]报NullPointerException,但toolInput明明有这个 key
现象:规则when $c: Context(toolInput["user_id"] == "u_123")总是不匹配,调试发现toolInput.get("user_id")返回null,但日志里inputSummary显示{"user_id": "u_123"}。
排查过程:
- 在
parseAndSanitizeInput()里加断点,发现inputJson是{"user_id": "u_123", "type": "order"},但objectMapper.readValue()后,toolInput.get("user_id")是null。 - 检查
ObjectMapper配置,发现启用了DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES,而toolInput是Map<String, Object>,Jackson 默认用LinkedHashMap,但LinkedHashMap.get("user_id")返回null如果 key 是"USER_ID"(大小写不匹配)。
根本原因:前端传来的 JSON key 是userId(驼峰),但规则里写的是user_id(下划线)。我们没做 key 标准化。
终极解法:
- 统一 key 标准化:在
parseAndSanitizeInput()中,添加 Snake-Case 转 Camel-Case:ObjectMapper mapper = new ObjectMapper(); mapper.setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE); // 这样 {"user_id": "u_123"} 会被解析为 map.put("userId", "u_123") - **规则