开头我先说一个比较普遍的现状:做 SaaS 化 Agent 平台,真正难啃的不是模型调用那一层,而是怎么把“Agent 能力”像水龙头一样,接到不同租户的业务系统里,还要求每个租户之间的模型路由、工具权限、上下文记忆互不干扰。很多团队一开始都想着直接上开源 Agent 框架,真到了多租户这一步,才发现那些框架对租户隔离的支持基本等于零,只能自己再写一层很厚的胶水代码。我之前正好完整经历过这个阶段:从零自研了一套 Agent 中间件 SDK,用 Nacos 配置中心做驱动,让业务方接入时只需要引入依赖、配上自己租户的 Agent 定义,就能开箱即用。这篇文章把我当时的完整设计思路、Nacos 配置模型、SDK 核心实现、以及后来线上踩过的坑全部梳理一遍,给正在做同类平台的同学一个可参考的落地路径。
1. 为什么是自研 Agent 中间件 SDK,而不是直接拼开源组件
很多朋友会觉得,搞 Agent 平台直接选一个成熟的开源框架,再套一层租户体系不就完了。我一开始也这么想过,实际评估完发现,这条路在当前阶段很难走通,问题不在模型能力,而是出在“多租户隔离”和“配置驱动”这两个被开源框架普遍忽略的维度上。
1.1 开源 Agent 框架在多租户场景下的天然缺陷
现在主流的开源 Agent 框架,核心设计目标基本是“让单个开发者/单条业务线快速跑通 Agent 应用”。它们默认假设只有一套配置、一个上下文环境、一组工具函数,你可以在单租户场景下把它们用得飞起,但只要把视角切到 SaaS 平台侧,几个问题就会立刻暴露。
首先是租户上下文无法透传。Agent 框架里的 Prompt 模板、工具函数、模型调用,通常只作用于单个会话。但在 SaaS 场景里,同一个 Agent 实例必须同时服务多个租户,框架本身并不知道当前请求属于哪个租户,更别提按租户加载不同 Prompt、不同工具权限、不同模型参数了。如果你在框架外面硬包一层 ThreadLocal 塞租户 ID,再在每个工具函数里手动做权限校验,代码量瞬间失控,而且很容易漏掉一些异步线程里的透传,导致租户数据串线。
其次是配置体系完全错位。开源框架的配置来源一般就是本地 YAML 文件或者环境变量,改一次配置就要重新发一次版。SaaS 平台面对的租户可能几十上百个,租户之间的模型路由、温度参数、工具开关、人设 Prompt 都不同,线上产品运营随时要调整。基于文件和环境变量的配置方式,在运维层面根本撑不住这种动态变化。
还有一个非常实际的问题是工具函数的注册机制。开源框架的工具注册通常是代码里写死,编译时确定。而 SaaS 平台里,哪些租户能使用哪些工具,往往是动态变更的,甚至同一个工具在不同租户下的参数约束都不一样。拿开源框架那一套静态注册机制去适配这种需求,要么你 fork 框架改源码,要么就只能把工具做成万能工具,再在内部做一堆 if else,两种方式都不优雅。
1.2 自研 SDK 的边界到底划在哪里
我并不是要劝你什么都硬造轮子。在动手前,我把自研 SDK 的边界想得很清楚:底层的模型调用、工具执行、会话管理,该用开源就用开源;但负责承接 SaaS 多租户规则的这一层,必须自己写。
举个例子,模型调用层面我直接接各家大模型厂商的 Java SDK;工具执行层我也复用了内部已有的工具函数库;但在这个 SDK 里,我要自己实现的是:租户配置的拉取与缓存、租户上下文的链路透传、Agent 实例的按租户装配、工具权限的按租户过滤、以及配置动态变更后的热更新逻辑。
这个边界划法有个好处:SDK 的所有定制逻辑都集中在“配置驱动装配”和“多租户隔离语义”上,不碰具体业务,也不碰模型底层。对上,业务方只需要提供租户 ID,剩下的 Agent 装配全部由 SDK 完成;对下,底层组件可以随时替换,只要上层配置模型不变,SDK 内部具体跑了什么框架对业务方完全透明。
后来我复盘,这一步是最关键的决策。如果当时图省事去 fork 开源框架改多租户,后续每次上游更新都要重新维护一套 fork,线上的稳定性风险会一直背在身上。自研中间层虽然前期要多写一些代码,但换来的是对“多租户配置模型”的完全掌控,后续扩展租户维度的高级策略时非常顺手。
2. 设计起点:SaaS 多租户 Agent 场景下,隔离的到底是什么
很多刚开始做多租户的人,第一反应是搞数据隔离,比如每个租户一个数据库。但 Agent 平台里,体系化的隔离维度远不止数据,更关键的是配置隔离、上下文隔离和权限隔离。这块想不清楚,后面做 SDK 大概率会变成一堆补丁的集合。
2.1 五个必须做隔离的维度
我梳理下来,Agent 场景下至少要做五个维度的隔离,缺一个都会在某个时刻爆雷。
配置隔离是基础中的基础。每个租户的模型路由策略、Prompt 模板、工具列表、模型参数都不一样。举个例子,大客户 A 签的是 GPT-4 级别的服务,温度参数要求低、追求确定性;B 类中小客户用的是轻量模型,要求成本优先。这些配置不能写在同一个全局文件里,要能按租户灵活加载。
模型路由隔离和配置隔离强相关,但更强调落点。同一个 Agent 在租户 A 请求下走模型 X,在租户 B 请求下走模型 Y,这套逻辑必须发生在每次请求的入口处,要能承受高频调用,不能每次动态解析配置。
工具权限隔离在多租户 Agent 场景里比在普通业务系统里更敏感。Agent 可以调用工具,工具能读写内部系统,那么工具级权限必须精确到租户,而且要做到动态生效。比如某个租户周末开通了一个新的工具权限,不可能等发版再生效,配置中心推下去就得立刻可用。
上下文与记忆隔离是最容易被忽略的。Agent 的上下文窗口里如果有跨租户的历史数据,那可是重大事故。每个租户的会话记忆、向量数据库集合、长期记忆存储,必须物理隔离或至少做到 Key 级隔离,避免 A 租户的数据在 B 租户的会话上下文中出现。
配额与审计这个维度,表面上和“隔离”关系不大,其实它决定了你能不能给租户提供 SLA。每个租户的调用次数、Token 使用量、并发上限都要独立计数和限制,此外还要保留每个租户的完整调用审计日志,方便安全团队回溯。没有隔离,这些全都无从谈起。
2.2 为什么 Agent 中间件层是隔离的落地最佳位置
我最早考虑过把隔离逻辑放在网关层,比如在 API 网关里解析租户信息,再转发到下游 Agent 服务。但很快发现这种做法太粗糙。网关层能看到租户 ID,但它看不到 Agent 内部的编排逻辑,也没办法理解 Prompt 模板的变量替换,更不可能替 Agent 框架做工具权限过滤。
把隔离下沉到 Agent 中间件 SDK 内部,最大的优势是“离上下文最近”。租户上下文在 SDK 入口被写入,在工具调用点被读取,在模型请求发出前被用于路由,这样的全链路可见性是上层网关没法提供的。而且 SDK 是嵌入在 Agent 服务进程里的,同一份配置缓存可以被进程内所有请求共享,性能上也优于每次远程拉取配置。
另外一个原因是版本问题。如果隔离逻辑在网关上做,Agent 框架升级时,网关需要同步适配;隔离逻辑放在 SDK 里,SDK 跟着 Agent 服务一起发版,内部实现无论怎么调整,对网关和业务方都是黑盒,接口稳定就行。这一点在平台发展后期尤其重要。
3. Nacos 配置模型拆解:开箱即用的根,是配置设计得好
“开箱即用”这句话听起来像营销词,但技术上它有一个非常明确的落点:业务方接入时,不需要写任何 Agent 装配代码,只需要在 Nacos 里按规范写几段配置,SDK 就能根据配置自动把 Agent 建好、把工具挂好、把模型路由好。所以配置模型的设计直接决定了 SDK 的上限。
3.1 配置分两级:平台级运行时配置和租户级 Agent 配置
我在设计配置模型时,先把配置分成了两个层级,避免所有配置揉成一团,导致权限和职责混乱。
平台级运行时配置,以 Nacos 的 namespace 为边界,一个环境一套 namespace。这套配置里主要放基础设施相关的信息:模型供应商的 API Key(或密钥引用标识)、底层框架的行为开关、链路追踪开关、默认的模型参数兜底值。这部分配置由平台运维负责修改,租户不可见。
租户级 Agent 配置,是每个租户下多个 Agent 的业务定义。这套配置的核心是 agentName、模型路由规则、Prompt 模板和工具权限清单。这部分配置由租户管理员(或者平台运营代管)维护,SDK 在初始化时拉取,后续通过 Nacos 监听实现动态变更。
拆成两级后,一个很直接的好处就体现出来了:租户的配置错误不会污染平台配置,平台配置变更也不会影响租户已有的定制内容。配置中心里 group 和 dataId 的规划也变得清晰,靠命名规范就能规避很多权限事故。
3.2 配置的具体数据结构:从 JSON Schema 到实际落盘
配置格式我最终选了 JSON,原因有三个:一是 JSON 的可读性好,运营同学也能看懂;二是 JSON 天然嵌套结构适合表达 Prompt/工具/模型参数这种多层级的对象;三是 Nacos 控制台编辑 JSON 的体验还算顺,而且我们内部可以做 JSON Schema 校验,格式不对时配置中心直接拒绝发布。
下面是我们实际在用的一个租户级 Agent 配置样例,我做了脱敏,但结构保留完整:
{ "tenantId": "tenant-001", "agentName": "order-robot", "version": "20250218", "prompt": { "templateId": "tmpl_order_robot_v3", "variables": { "shopName": "示例旗舰店", "serviceLevel": "VIP" }, "systemMessage": "你是{shopName}的智能客服助手,负责解答订单相关问题……" }, "model": { "provider": "openai-compatible", "modelName": "gpt-4o-mini", "temperature": 0.3, "maxTokens": 2048, "routeStrategy": "by-tenant", "fallbackModel": "gpt-4o-mini-lite" }, "tools": [ { "name": "query_order", "enabled": true, "timeoutMs": 3000, "params": { "fields": ["orderId", "status", "amount"] } }, { "name": "cancel_order", "enabled": false, "reason": "该租户暂未开通取消订单权限" } ], "memory": { "enabled": true, "storeType": "redis", "ttlSeconds": 86400 }, "limits": { "qps": 50, "dailyTokenQuota": 500000 } }这个配置里每一项不是随便定的,比如 prompt.templateId 就是为了让平台统一管理 Prompt 模板,租户配置里只存变量值,避免每个租户复制一长串 Prompt 导致后续模板更新无法统一。tools 里 disabled 的工具也要显式列出来,并且带 reason,这样 SDK 能向租户返回清晰的权限拒绝原因。
3.3 配置覆盖规则:Global、Tenant、Agent 三层覆盖
光是两级配置还不够,运行时经常出现这样的情况:平台默认给所有 Agent 开启工具 A,但某个 Agent 想关闭;租户统一设了模型参数,但租户下某个 Agent 想单独调温度。为了解决这种需求,我在配置模型里设计了 Global、Tenant、Agent 三层覆盖规则。
Global 层存放平台默认值,比如默认模型名、默认工具开关、默认超时时间。Tenant 层存放租户级默认值,租户下所有 Agent 没写到的配置项都从这一层继承。Agent 层是最高优先级,某个 Agent 专属配置直接覆盖前两层的同名配置项。
三层覆盖的合并逻辑发生在 SDK 每次装配 Agent 时,也就是配置加载阶段。整体流程是:先拉取 Global 配置作为基准,再叠加 Tenant 配置里的差异项,最后用 Agent 自身配置覆盖同名项。这样设计的好处是配置不必全量冗余,平台调一个默认值,几十个没覆盖该配置的 Agent 全部生效,运营成本低很多。
需要注意的是,覆盖只发生在“装配时”,不是每次请求时都重新合并。SDK 会把合并后的完整配置缓存本地,只有 Nacos 推送变更时才会触发重新合并。这既保证了性能,又不损失动态性。
4. SDK 核心实现:配置拉取、Agent 装配、上下文透传的路数
配置模型设计清楚了,接下来的问题就是 SDK 内部怎么把配置变成真正能跑起来的 Agent 实例。我按模块拆开讲,每个模块解决一个核心问题,代码示例都是简化的,重点讲路数。
4.1 基于 Nacos 的配置拉取与本地缓存模块
这一层是 SDK 的入口,负责从 Nacos 拉取当前租户的配置,并且监听配置变更事件。我直接用 Nacos 官方客户端做 Long Polling 监听,具体做法是在 SDK 内部为每个租户的 dataId 注册一个 Listener。
public class NacosConfigLoader { private final ConfigService configService; private final Map<String, AgentConfigWrapper> localCache = new ConcurrentHashMap<>(); public void registerTenant(String tenantId, String agentName) { String dataId = "agent-config-" + tenantId + "-" + agentName; String group = "AGENT_PLATFORM"; String config = configService.getConfigAndSignListener(dataId, group, 5000, new Listener() { @Override public Executor getExecutor() { return Executors.newSingleThreadExecutor(); } @Override public void receiveConfigInfo(String configInfo) { refreshLocalConfig(tenantId, agentName, configInfo); } }); if (config != null && !config.isEmpty()) { localCache.put(buildKey(tenantId, agentName), parseConfig(config)); } else { log.warn("tenant {} agent {} config not found, fallback to global defaults", tenantId, agentName); localCache.put(buildKey(tenantId, agentName), buildFallbackConfig()); } } public AgentConfigWrapper getConfig(String tenantId, String agentName) { AgentConfigWrapper cached = localCache.get(buildKey(tenantId, agentName)); if (cached != null) { return cached; } // 兜底逻辑:本地缓存不存在时,先拉一次远端配置再返回 registerTenant(tenantId, agentName); return localCache.get(buildKey(tenantId, agentName)); } }强调两个细节。第一是 getAndSignListener 的语义,它会在首次拉取配置时就把监听器挂上,避免“先 get 再 addListener”之间出现配置变更漏消费的窗口期。第二是本地缓存必须有,否则每次请求都去 Nacos 拉配置,性能上就是灾难。SDK 启动时先拉一次,之后靠监听器更新缓存。
有一个坑要提醒:Listener 里默认用的 Executor 是单线程,如果某个租户配置刷新时逻辑太重,可能会阻塞其他租户的刷新事件。所以 Listener 里我只做“解析配置 + 更新缓存”这种轻量操作,至于 Agent 实例要不要重建,交给独立的装配模块去异步处理,避免在 Nacos 的回调线程里做重活。
4.2 租户上下文的创建与链路透传
配置加载进来了,每个请求进入 SDK 时,必须把当前请求属于哪个租户、哪个 Agent 给标记上,而且要保证这个标记在异步链路里不丢。
实现上我用了 TransmittableThreadLocal(TTL),它比普通 ThreadLocal 强的地方在于,在线程池的场景下,父线程的上下文可以自动传递到子线程。Agent 场景里大量用到异步工具调用,比如查完订单数据再异步调一个物流接口,TTL 基本是必需品。
先定义租户上下文,再在 SDK 入口创建一个带有上下文的执行环境,Agent 编排的过程都包在这个上下文里执行。上下文最终会传给模型路由器和工具调用器,这样它们才能知道当前请求该用哪个模型、哪些工具可用。
public class AgentContext { private static final TransmittableThreadLocal<AgentContext> CONTEXT = new TransmittableThreadLocal<>(); private String tenantId; private String agentName; private AgentConfigWrapper config; private long requestId; public static AgentContext get() { return CONTEXT.get(); } public static void set(AgentContext context) { CONTEXT.set(context); } public static void clear() { CONTEXT.remove(); } }我封装了一个执行入口,业务方调用时只需要传入 tenantId 和 agentName,SDK 自动创建上下文并执行 Agent 的主流程。因为创建上下文时需要拿到配置,getOrLoadConfig 内部会完成缓存命中或远端拉取。
public class AgentExecutor { private final ConfigManager configManager; private final AgentAssembler assembler; public AgentResult execute(String tenantId, String agentName, UserMessage userMessage) { AgentConfigWrapper config = configManager.getOrLoadConfig(tenantId, agentName); AgentContext context = new AgentContext(tenantId, agentName, config); try { AgentContext.set(context); RunnableAgent agent = assembler.assembleIfNecessary(tenantId, agentName, config); return agent.run(userMessage); } finally { AgentContext.clear(); } } }这里有一点必须强调:finally 里的 AgentContext.clear() 一定不能省。线程池里的线程是复用的,如果上下文不清理,下一个请求会拿到上一个租户的上下文,造成严重的租户数据串线。这个问题的隐蔽性很高,本地测试很难发现,必须从框架层面强制清理。
4.3 配置驱动的 Agent 装配器
装配模块是整个 SDK 最核心的部分,它的职责是把一份配置变成一个可执行的 Agent 实例。我定义了一个 RunnableAgent 接口,内部包括“运行提示词”“调用工具”等方法。根据配置动态创建一个 agent 对象。
大概的实现思路是:根据配置里的 tools 列表,过滤掉 enabled=false 的工具,再从平台工具注册中心找出对应工具实例。随后构造一个 Prompt 渲染器,把配置中的模板和变量渲染成最终系统消息,再为 ModelRouter 配置模型路由规则和执行参数。最后用这些基础组件组装成一个 Agent 实例。
public class AgentAssembler { private final ToolRegistry toolRegistry; private final ModelRouter modelRouter; public RunnableAgent assembleIfNecessary(String tenantId, String agentName, AgentConfigWrapper config) { String cacheKey = tenantId + "::" + agentName + "::" + config.getVersion(); RunnableAgent cached = agentCache.get(cacheKey); if (cached != null) { return cached; } List<ToolInvoker> toolInvokers = config.getTools().stream() .filter(ToolConf::isEnabled) .map(toolConf -> toolRegistry.getInvoker(toolConf.getName())) .filter(Objects::nonNull) .map(invoker -> new PermittedToolInvoker(invoker, tenantId)) .collect(Collectors.toList()); String systemPrompt = promptRenderer.render(config.getPrompt().getSystemMessage(), config.getPrompt().getVariables()); AgentRuntime runtime = new AgentRuntime( modelRouter, new FixedParamsProvider(config.getModel()), toolInvokers, config.getMemory(), config.getLimits() ); RunnableAgent agent = new RunnableAgent(runtime, systemPrompt); agentCache.put(cacheKey, agent); log.info("assembled agent for tenant={} agentName={} version={} tools={}", tenantId, agentName, config.getVersion(), toolInvokers.size()); return agent; } }装配这个动作必须在每次配置版本变化后重新触发,所以我用 config.getVersion() 作为缓存 Key 的一部分。当 Nacos 推送新配置后,ConfigManager 更新本地配置,再调用 assembler 的 invalidate 方法清理对应缓存,下一次请求时就会按新配置重新装配。
另外注意 PermittedToolInvoker 这个包装类,它的作用是在真正调用工具前,再次检查当前租户是否有权限。虽然配置装配时已经过滤过 enabled=false 的工具,但工具内部通常还有更细粒度的权限,比如同一个 query_order 工具,租户 A 能查全部字段,租户 B 只能查基础字段。这种场景必须在包装层做二次权限校验,避免工具内部逻辑被绕过。
5. 开箱即用的完整链路:从引入依赖到业务侧首次跑通
说了这么多设计,现在回过来看“开箱即用”在业务侧具体长什么样。整个接入链路只有三步:引入 SDK 依赖、在启动类上开启功能、在 Nacos 里写好租户配置。
5.1 业务方只需三步接入
如果你们项目是 Spring Boot 的话,SDK 直接提供一个 starter,业务方第一步在 pom.xml 里加依赖:
<dependency> <groupId>com.platform.agent</groupId> <artifactId>agent-middleware-sdk-starter</artifactId> <version>1.4.2</version> </dependency>第二步,在启动类或者配置类上加上 @EnableAgentPlatform 注解。这个注解背后是一个 ImportBeanDefinitionRegistrar,SDK 会自动扫描配置目录、初始化 Nacos 连接、注册全局的 AgentExecutor Bean。
@SpringBootApplication @EnableAgentPlatform public class OrderServiceApplication { public static void main(String[] args) { SpringApplication.run(OrderServiceApplication.class, args); } }第三步,在 Nacos 里写入当前业务方需要的 Agent 定义配置。SDK 启动时会根据 spring.application.name 的前缀、或环境变量里指定的租户列表,自动拉取对应 Agent 配置,然后完成装配。整个过程业务方不需要写任何 Agent 装配代码,只需注入 AgentExecutor 使用。
5.2 SDK 启动时自检与可用性对外暴露
“开箱即用”不光是能跑,还得在跑之前明确告诉接入方“我准备好了”。SDK 在启动时做了三件自检:检查 Nacos 连通性,检查每个 Agent 配置是否能正确解析,检查每个已配置工具是否在注册中心里存在。如果配置里引用了不存在的工具,SDK 不会让启动失败,但会打印告警日志并自动禁用该工具,同时在 Actuator 的 health 指标里标记 degraded 状态。
我额外做了一个 AgentPlatformIndicator,继承 HealthIndicator,对外暴露每个租户 Agent 的就绪状态。业务方的监控系统可以直接拉这个指标做告警。比如某个租户因为配置中心变更导致 Agent 装配失败,这个指标能立刻反映出来,比接到用户投诉再去翻日志高效太多。
5.3 配置中心变更即时生效的机制说明
配置变更传达到业务方的完整流程是:运营在 Nacos 控制台修改配置,Nacos 服务端检测到 dataId 发布,通过 2.x 的长轮询机制通知 SDK 的 NacosConfigLoader,SDK 解析新配置并更新本地缓存,随后通知 AgentAssembler 失效对应的 agentCache。下一次请求进入时,request 里携带的配置版本号已不是老版本,SDK 重新装配新 Agent 实例。
这里面有个细节是“下一次请求进来时才重建”。也就是说,配置变更后,正在执行中的老请求不受影响,新请求开始走新装配的 Agent。这样做避免了运行中 Agent 被中途替换导致的上下文错乱,也避免了流量高峰期间大量 Agent 同时重建引发的 CPU 抖动。实测下来,这种“延迟重建”策略稳定性和性能都很理想。
6. 线上踩坑实录:Nacos 动态刷新与多租户线程串线的排查链路
方案跑通只是起点,真正让我学到东西的是后面一系列线上问题。我挑两个最有代表性的讲,一个是 Nacos 动态刷新的隐蔽失效,另一个是异步线程池里的租户上下文串线。
6.1 现象:改了配置,部分节点半个小时内不生效
上线后,运营反馈说改了某个租户的模型温度参数,大部分节点几分钟内生效,但有个别节点等了快半小时还没变化。第一时间怀疑是 Nacos 客户端 Long Polling 断了,但查看 Nacos 客户端日志,Listener 还在,也没有异常报错。于是我在出问题的节点上手动调了一次 Nacos OpenAPI,发现配置已经能拉取到最新内容,说明问题不在服务端。
继续深入排查,在出问题的节点上抓线程栈,发现 Nacos 客户端的配置更新线程处于 WAITING 状态,看起来没有收到 Push 通知。再到 Nacos 服务端看该 dataId 的订阅关系,诡异的是订阅记录在,就是不发变更事件。
最后定位到根因:该节点的 Nacos 客户端版本比较老,长轮询请求因为网络闪断被服务端断开,但客户端没有正确感知连接失效,导致后续监听事件全部丢失。配置变更后,服务端推给客户端的通知这条链路断了,客户端本地缓存永远停留在旧版本。
修复方案分成两步。第一步是升级 Nacos 客户端到修复该问题的版本;第二步是在 SDK 里做了一层兜底——每隔五分钟做一次配置版本号比对,主动调用 getConfig 接口获取当前配置,如果发现本地版本号与服务端不一致,即使 Listener 没触发,也会强制做一次刷新。这个兜底机制后来还救了我们好多次,强烈建议做配置中心中间件的同学都加上类似的“定时对账”逻辑。
6.2 现象:偶发的租户 A 请求,打到了租户 B 的模型路由配置
这个问题更隐蔽,影响更恶劣。用户反馈租户 A 的请求日志里,出现了租户 B 的模型名称。看一眼订单号确实对齐了,但日志里的 tenantId 和模型供应商明显不匹配。当时第一反应是配置串了,查 Nacos 配置没问题,查数据库数据归属也没问题。
把日志时间线拉出来对比后,发现问题集中在高并发时段。我怀疑是线程池复用 + 上下文清理遗漏。查代码里所有使用 AgentContext.set 的地方,大部分都有 finally clear,但有一个异步工具调用的内部线程池没有走 TTL 的包装,导致子线程从父线程继承上下文后没有清理。
具体链路是这样的:主线程进入 Agent 执行,设置 AgentContext A,继续调一个用普通 ThreadLocal 的工具线程池,工具执行完线程回收到池里,但 ThreadLocal 没有remove。下一次线程被租户 B 的请求复用,读取到的还是租户 A 的上下文。因为工具结果里不涉及用户敏感数据,没有造成跨租户数据泄露,但这个隐患不修,早晚出事。
排查链路走完后,我把所有线程池创建处统一替换成 TTL 包装的 TtlExecutors.getTtlExecutorService,并且把 AgentContext 的 clear 逻辑改成在 TTL 的 afterExecute 回调里强制执行。改完后又在压测环境用模拟混合租户流量连续压了几天,没再复现串线。后来我把全链路每个异步入口都扫了一遍,确保没有任何一个普通 ThreadLocal 的漏网之鱼。
7. 多租户 Agent 中间件落地后的几点进阶建议
主流程稳定后,我开始往更实用的方向打磨,这里分享几个对平台长期演进最有帮助的经验。
7.1 一定要建立配置版本审计
Nacos 配置是动态的,线上出问题往往就是某次配置变更引起的。没有版本审计的情况下,定位“这个租户的温度参数什么时候被改的”要翻半天日志,非常痛苦。我在 ConfigManager 里加了一个配置变更审计模块,每次 Listener 收到变更,都会把变更前后 diff、操作人(从 Nacos 控制台记录里拿)、变更时间记录到审计表里。这个表本身不复杂,但后续排查线上问题能节省大量时间。
7.2 工具权限校验要做运行时兜底
配置里的 enabled 开关只管“能不能被 Agent 装配”,但工具内部还有很多细粒度的权限判断。我在 PermittedToolInvoker 里维护了一份租户维度的工具字段权限缓存,TTL 设置 60 秒。这样既能保证权限变更在分钟级生效,又不会在每次工具调用时去打权限中心,性能损耗非常小。
字段级权限的典型场景是:客服机器人可以查订单金额,但不能查客户手机号;同样一个 query_order 工具,对 VIP 租户开放全部字段,对普通租户只开放部分字段。这类规则如果完全靠 Agent Prompt 约束,基本等于没有约束,必须从工具调用层面拦截。
7.3 配置模型要预留扩展点,防止被业务方写死
刚开始设计配置模型时,工具参数用的是固定字段。后来业务方越来越多,固定字段很难覆盖所有工具的自定义参数。我在配置模型里加了一个 metadata 字段,类型是 Map, 业务方可以放任何自定义配置进去,SDK 在装配工具时原样透传给工具调用器。这个看似微小的改动,后来成了整个配置模型里使用频率最高的字段之一。
不过也要给这个字段加约束:metadata 里只放非敏感配置,密钥类信息千万不要通过配置中心下发,而是通过密钥管理服务按租户动态获取。这是两个团队踩过坑之后才定下来的规矩。
7.4 平台方提供 Agent 配置体检工具
运营配置 Agent 时,经常出现模型名写错、工具名不匹配、Prompt 模板变量缺失这类低级错误。我在管理后台加了一个配置体检功能:提交配置前,后台调用 SDK 暴露的校验接口,解析一遍 JSON Schema、检查工具是否存在、Prompt 模板变量是否能完整渲染、模型名是否在供应商可用列表里。体检不通过就禁止发布,并把具体错误信息返回给运营。上线之后,运营侧的配置类工单下降了非常明显,这个投入产出比非常高。
8. 写在最后的实操体会
这套 SDK 从设计到稳定运行,我复盘下来最大的一个体会是:多租户 Agent 中间件的问题,很多不是模型问题,而是工程架构问题。模型能力再强,租户上下文串了、工具权限漏了、配置刷新失效了,产品就是不合格的。而这些问题,恰恰需要的是把配置模型做好、把隔离边界的功夫做扎实。
整个过程中我印象最深的一点是“延迟重建”这个机制。刚开始我也想过配置一变就立刻重建所有 Agent 实例,但实测发现流量高峰期重建成本太高,CPU 会有一个明显的尖峰,而且正在执行的请求还可能出现中间态。改成请求边界重建后,不仅平滑,而且实现还更简单——只用在下一请求装配前检查版本号。这类取舍如果你也在做类似中间件,强烈建议提前想清楚,别等线上被压垮了才改。
最后再分享一个给自己的规矩:中间件 SDK 的设计里,永远给“容灾兜底”留好位置。配置中心客户端升级前靠监听,升级后靠监听 + 定时对账,两条腿走路,任何一条腿断了都还能撑住。多租户系统的故障影响面往往是单租户系统的十倍不止,所以宁可多写一点防御性代码,也不要把全部信任押在某一个单一机制上。