在传统 Spring Boot 项目里,ThreadLocal 几乎是请求上下文传播的标准答案。
用户登录以后,把 userId 放进去;多租户系统把 tenantId 放进去;链路追踪再塞一个 traceId。后面的 Controller、Service、Repository 不需要一层一层传参数,随时从当前线程读取即可。
public final class UserContext {
private static final ThreadLocal<String> USER_ID = new ThreadLocal<>();
public static void set(String userId) {
USER_ID.set(userId);
}
public static String get() {
return USER_ID.get();
}
public static void clear() {
USER_ID.remove();
}
}
这套方式用了很多年,而且直到今天依然能用。
甚至进入虚拟线程时代后,ThreadLocal 也没有失效。JEP 444 明确规定,虚拟线程和平台线程一样支持 ThreadLocal 与 InheritableThreadLocal。OpenJDK 只是特别提醒:虚拟线程数量可能非常大,因此在线程中保存大量 ThreadLocal 状态需要更加谨慎。
真正发生变化的,并不是 ThreadLocal “不能用了”。
而是 Java 开始提供一种更符合请求上下文语义的新选择:
ScopedValue。
JDK 25 已通过 JEP 506 将 Scoped Values 正式定稿,不再是 Preview API。OpenJDK 对它的定位非常明确:当数据需要从调用方单向传递给深层调用方法,而又不希望通过方法参数层层传递时,ScopedValue 比 ThreadLocal 更容易推理,并且尤其适合与虚拟线程和结构化并发配合。
对于普通 CRUD 项目,这可能只是一次 JDK API 更新。
但对于 AI Agent,它值得认真研究。
因为 Agent 正在让 Java 后端里的“上下文”迅速膨胀。
一、Agent 系统里的 Context 已经越来越复杂
假设我们正在做一个企业级 AI Agent。
用户输入:
帮我查询昨天失败的退款订单,可以自动重试的重新执行,剩下的创建异常工单。
表面看只有一句自然语言。
但 Java 后端真正执行这个任务时,可能还需要知道大量模型没有直接提供的信息:
| 上下文 | 作用 |
|---|---|
userId |
当前操作用户 |
tenantId |
当前企业或租户 |
requestId |
当前请求唯一标识 |
traceId |
分布式链路追踪 |
conversationId |
AI 会话 |
taskId |
Agent 任务 |
permissionScope |
当前用户可调用的业务能力 |
modelRoute |
当前模型路由策略 |
然后一次 Agent 请求内部,又可能继续产生多个调用。
flowchart LR
A["用户请求"] --> B["Agent"]
B --> C["LLM"]
C --> D["Tool Calling"]
D --> E["退款订单查询"]
D --> F["退款重试"]
D --> G["创建工单"]
D --> H["发送通知"]
此时,一个很现实的问题就出现了:
这些 Context 到底应该怎么传?
最简单的方法当然是参数。
refundService.retry(
tenantId,
userId,
requestId,
traceId,
orderId
);
继续深入一层:
paymentService.retry(
tenantId,
userId,
requestId,
traceId,
paymentId
);
再继续:
auditService.record(
tenantId,
userId,
requestId,
traceId,
operation
);
业务参数开始被大量基础设施参数污染。
于是很多项目最终都会走向 ThreadLocal。
问题也从这里开始。
二、ThreadLocal 的问题不只是“记得 remove”
一个典型的 ThreadLocal 请求上下文通常是这样处理的:
try {
RequestContextHolder.set(context);
agentService.execute(request);
} finally {
RequestContextHolder.clear();
}
这段代码没有错。
但 ThreadLocal 实际表达的语义是:
当前线程拥有一个可以被读取、修改,并持续存在直到主动删除或线程终止的变量。
这和很多请求上下文真正想表达的语义并不完全一致。
例如 tenantId。
请求进入系统时,它已经确定:
当前请求属于 tenant-a。
正常情况下,Service、Agent、Tool、Repository 只应该读取这个值,而不是在业务执行到一半时突然:
TENANT_ID.set("tenant-b");
ThreadLocal 并不会阻止这种事情。
Oracle 在 Java 25 ScopedValue API 文档中专门总结了 ThreadLocal 在这种场景下的三个问题:深层调用代码可以重新设置值;生命周期没有天然边界,需要显式清理;继承 ThreadLocal 时还需要复制线程本地变量映射。
因此,ThreadLocal 最大的问题并不是 API 老。
而是它经常被用来表达一种它并不完全匹配的语义:
上游确定,下游读取,任务结束自动失效。
这正是 ScopedValue 的设计目标。
三、ScopedValue 到底改变了什么
最简单的 ScopedValue 是这样的:
private static final ScopedValue<String> USER_ID =
ScopedValue.newInstance();
绑定一个值:
ScopedValue.where(USER_ID, "user-10001")
.run(() -> orderService.queryOrders());
后续调用链可以读取:
String userId = USER_ID.get();
当 run() 执行结束,无论正常返回还是抛出异常,这次绑定都会自动结束。Oracle 将这个范围称为 dynamic scope,也就是动态作用域。
它的生命周期可以表示为:
flowchart LR
A["请求进入"] --> B["绑定 ScopedValue"]
B --> C["Controller"]
C --> D["Service"]
D --> E["Agent"]
E --> F["Tool"]
F --> G["Repository"]
G --> H["请求结束"]
H --> I["绑定自动失效"]
这里最大的变化不是少写了一个 remove()。
而是 Context 的生命周期第一次和任务执行范围天然绑定在了一起。
ThreadLocal 更像:
这个线程现在保存着什么?
ScopedValue 更像:
执行这段业务代码期间,当前上下文是什么?
对于请求级数据,后者通常更加准确。
四、不要给每个字段创建一个 ScopedValue
看到这里,很容易写出这样的代码:
private static final ScopedValue<String> USER_ID =
ScopedValue.newInstance();
private static final ScopedValue<String> TENANT_ID =
ScopedValue.newInstance();
private static final ScopedValue<String> TRACE_ID =
ScopedValue.newInstance();
private static final ScopedValue<String> REQUEST_ID =
ScopedValue.newInstance();
private static final ScopedValue<String> CONVERSATION_ID =
ScopedValue.newInstance();
技术上可以。
但生产项目并不建议这么设计。
Java 25 官方 API 的实现说明甚至明确建议:ScopedValue 应该以较少的数量使用,如果需要传播多个值,更适合用一个 record 将它们组合起来,然后只绑定一个 ScopedValue。
所以更推荐:
public record RequestContext(
String requestId,
String traceId,
String tenantId,
String userId,
String conversationId
) {
}
然后建立统一入口:
public final class RequestContexts {
private static final ScopedValue<RequestContext> CURRENT =
ScopedValue.newInstance();
private RequestContexts() {
}
public static RequestContext current() {
return CURRENT.get();
}
public static boolean isBound() {
return CURRENT.isBound();
}
public static <T, X extends Throwable> T call(
RequestContext context,
ScopedValue.CallableOp<T, X> task
) throws X {
return ScopedValue.where(CURRENT, context)
.call(task);
}
}
这样整个应用只维护一个 Request Context。
调用链深处直接:
RequestContext context = RequestContexts.current();
String tenantId = context.tenantId();
String userId = context.userId();
不用在几十层方法之间不断增加参数。
五、为什么 Context 最好设计成不可变对象
这里还有一个很容易被忽略的问题。
很多人会说:
ScopedValue 是不可变的,所以放进去的数据也是安全的。
这句话并不准确。
ScopedValue 限制的是绑定关系,而不是你绑定进去的对象本身。
如果这样写:
public class RequestContext {
private String tenantId;
public void setTenantId(String tenantId) {
this.tenantId = tenantId;
}
}
那么即使它被放进 ScopedValue,下游仍然可以:
RequestContexts.current()
.setTenantId("another-tenant");
所以请求上下文更适合使用:
public record RequestContext(
String requestId,
String traceId,
String tenantId,
String userId
) {
}
如果包含集合,也应该尽量保存不可变集合。
Oracle 同样要求:当 ScopedValue 在多个结构化子线程之间共享时,绑定值应该是不可变对象,否则就需要自行保证同步。
另外,ScopedValue 也允许在嵌套动态作用域中进行 rebind。也就是说,拥有 ScopedValue key 的代码可以在更小的范围内临时绑定另一个值,但内层代码执行结束后,外层绑定会自动恢复。
所以它不是一种“永远无法变化”的全局常量。
它真正保证的是:
Context 的修改具有明确的作用域边界。
六、在 Spring Boot 中应该在哪里绑定
既然 Context 代表一次请求,那么最合理的位置自然是系统边界。
例如:
flowchart LR
A["HTTP Request"] --> B["Filter / Interceptor"]
B --> C["创建 RequestContext"]
C --> D["ScopedValue 绑定"]
D --> E["Controller"]
E --> F["Service"]
F --> G["Agent"]
G --> H["请求结束"]
例如可以在 Filter 层完成初始化:
@Component
public class RequestContextFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain
) throws ServletException, IOException {
RequestContext context = new RequestContext(
UUID.randomUUID().toString(),
request.getHeader("X-Trace-Id"),
request.getHeader("X-Tenant-Id"),
request.getHeader("X-User-Id"),
request.getHeader("X-Conversation-Id")
);
try {
RequestContexts.call(context, () -> {
filterChain.doFilter(request, response);
return null;
});
} catch (ServletException | IOException e) {
throw e;
} catch (Exception e) {
throw new ServletException(e);
}
}
}
实际项目里,当然不能直接相信客户端传入的 X-Tenant-Id 和 X-User-Id。
它们应该来自已经完成身份认证和权限校验的可信结果。
这里的重点只是:
ScopedValue 的 binding 应该尽可能靠近请求入口建立,而不是由业务 Service 随意创建。
这样调用链才有清晰的上下文边界。
七、到了 Agent 系统,必须区分两种 Context
AI Agent 项目特别容易出现一个设计错误:
把所有东西都塞给模型。
例如:
String systemPrompt = """
当前用户ID:%s
当前租户ID:%s
当前用户权限:%s
当前内部请求ID:%s
""".formatted(
userId,
tenantId,
permissions,
requestId
);
表面看很方便。
但这样实际上把两个完全不同的东西混在了一起。
| 类型 | 示例 | 模型是否需要知道 |
|---|---|---|
| AI Context | 用户问题、对话历史、知识库内容、任务描述 | 通常需要 |
| Execution Context | tenantId、userId、内部权限、requestId | 通常不需要 |
模型需要知道:
“用户希望查询退款订单”。
但模型并不一定需要知道:
“这个用户数据库里的 tenantId 是多少”。
更不应该让模型自己决定应该使用哪个 tenantId。
因此,一个更合理的 Agent 架构应该是:
flowchart LR
A["HTTP 请求"] --> B["RequestContext"]
B --> C["ScopedValue"]
C --> D["Agent Service"]
D --> E["LLM"]
D --> F["Tool Execution"]
C --> F
F --> G["权限校验"]
G --> H["业务系统"]
这里有一个非常关键的边界:
LLM 负责决策“做什么”,Java 执行环境负责决定“以谁的身份做”。
这对企业级 Agent 尤其重要。
八、Spring AI 的 ToolContext 正好可以接这一层
如果使用 Spring AI,这个设计还能进一步落地。
Spring AI 官方提供了 ToolContext,专门用于向 Tool 执行过程传递额外上下文。更关键的是,官方文档明确说明:
ToolContext 中的数据不会发送给 AI 模型。
于是 ScopedValue 和 ToolContext 可以形成非常自然的配合。
ScopedValue 保存 Java 请求生命周期中的可信上下文。
当真正进入 Tool Calling 时,再从 ScopedValue 中提取必要数据,交给 ToolContext。
flowchart LR
A["HTTP Request"] --> B["ScopedValue"]
B --> C["Agent Service"]
C --> D["ChatClient"]
D --> E["LLM"]
C --> F["ToolContext"]
F --> G["Tool"]
G --> H["业务服务"]
例如:
public String chat(String question) {
RequestContext context = RequestContexts.current();
return chatClient.prompt()
.user(question)
.toolContext(Map.of(
"tenantId", context.tenantId(),
"userId", context.userId(),
"requestId", context.requestId()
))
.call()
.content();
}
然后 Tool:
@Component
public class OrderTools {
private final OrderService orderService;
private final AuthorizationService authorizationService;
public OrderTools(
OrderService orderService,
AuthorizationService authorizationService
) {
this.orderService = orderService;
this.authorizationService = authorizationService;
}
@Tool(description = "根据订单编号查询订单信息")
public Order queryOrder(
Long orderId,
ToolContext toolContext
) {
String tenantId = (String) toolContext
.getContext()
.get("tenantId");
String userId = (String) toolContext
.getContext()
.get("userId");
authorizationService.requirePermission(
tenantId,
userId,
"order:query"
);
return orderService.query(
tenantId,
orderId
);
}
}
Spring AI 当前官方文档给出的 ToolContext 用法就是通过 .toolContext(Map.of(...)) 提供上下文,并在 @Tool 方法中通过 ToolContext 获取;这些数据不会作为 Tool 参数暴露给模型。
这比让模型生成:
queryOrder(
orderId,
tenantId,
userId
)
安全边界清晰得多。
因为 orderId 可以由模型根据用户意图决定。
但 tenantId 应该由服务端确定。
九、ScopedValue 真正强大的地方,是结构化并发
如果 ScopedValue 只能代替 ThreadLocal,它的价值其实还没有那么大。
真正值得关注的是它和 Structured Concurrency 的组合。
假设 Agent 开始执行任务之前,需要并行加载三份数据:
用户资料、用户权限、长期记忆。
flowchart LR
A["Agent 请求"] --> B["StructuredTaskScope"]
B --> C["加载用户资料"]
B --> D["加载权限"]
B --> E["加载会话记忆"]
C --> F["汇总"]
D --> F
E --> F
F --> G["开始 Agent 推理"]
在 JDK 25 的 Structured Concurrency Preview API 中,可以这样写:
public AgentBootstrapData loadBootstrapData()
throws InterruptedException {
try (var scope = StructuredTaskScope.open()) {
var profileTask = scope.fork(() -> {
RequestContext context = RequestContexts.current();
return userService.getProfile(
context.userId()
);
});
var permissionTask = scope.fork(() -> {
RequestContext context = RequestContexts.current();
return permissionService.getPermissions(
context.tenantId(),
context.userId()
);
});
var memoryTask = scope.fork(() -> {
RequestContext context = RequestContexts.current();
return memoryService.load(
context.conversationId()
);
});
scope.join();
return new AgentBootstrapData(
profileTask.get(),
permissionTask.get(),
memoryTask.get()
);
}
}
外层仍然只是:
RequestContexts.call(
context,
() -> agentService.execute(request)
);
当 StructuredTaskScope 被创建时,会捕获当前 ScopedValue bindings;通过 fork() 创建的结构化子任务能够继承这些绑定。Oracle Java 25 API 对这一行为有明确规定。
因此三个子任务都可以读取同一个 RequestContext。
不需要手动把 Context 复制三遍。
这就是 ScopedValue 和普通 ThreadLocal 最大的架构差异之一。
十、为什么这和虚拟线程天然是一套东西
Java 近年来的并发变化并不是三个互相独立的新功能。
实际上它们正在逐渐形成一套新的任务模型:
| 能力 | 解决的问题 |
|---|---|
| Virtual Thread | 如何低成本执行大量并发任务 |
| Structured Concurrency | 如何管理父任务与子任务 |
| ScopedValue | 如何在任务调用树中传播上下文 |
虚拟线程已经在 JDK 21 成为正式特性,并且明确支持 ThreadLocal;但由于虚拟线程可能大量存在,OpenJDK 同时提醒开发者谨慎使用大量线程本地状态。
ScopedValue 则在 JDK 25 正式定稿,OpenJDK 给出的理由之一正是降低这类上下文传播的空间与时间成本,并使其更适合虚拟线程和结构化并发。
截至 2026 年 8 月,Structured Concurrency 仍然没有正式定稿。OpenJDK 的 JDK 27 项目已经将 JEP 533 列入版本内容,它仍处于 Seventh Preview。
所以当前状态很有意思:
Virtual Thread 已成熟,ScopedValue 已正式,Structured Concurrency 仍在继续打磨。
这也说明 Java 正在逐步完善“一个业务任务及其子任务应该如何组织”的完整模型。
十一、但 ScopedValue 不会自动穿越所有异步边界
这是使用 ScopedValue 时最容易踩的坑之一。
看到 StructuredTaskScope 可以继承 Context 后,很容易误以为:
只要创建新线程,ScopedValue 就会自动传过去。
并不是。
Oracle 对 ScopedValue 的跨线程共享有明确限制:它针对的是父线程与子线程生命周期受控的结构化场景,StructuredTaskScope 会负责捕获和继承 binding。
因此传统的:
CompletableFuture.supplyAsync(() -> {
return RequestContexts.current();
});
不能因为父线程存在 ScopedValue,就默认异步任务一定能够读取它。
普通 Executor 同样如此:
executor.submit(() -> {
RequestContext context = RequestContexts.current();
});
这和 StructuredTaskScope 是完全不同的并发模型。
所以实际项目必须把上下文边界分清。
| 边界 | ScopedValue 是否天然解决 |
|---|---|
| 普通同步方法调用 | 是 |
| StructuredTaskScope 子任务 | 是 |
| 普通 Executor | 否 |
| CompletableFuture 异步线程 | 否 |
| MQ 消息 | 否 |
| HTTP 微服务调用 | 否 |
| RPC 调用 | 否 |
ScopedValue 解决的是 JVM 内部动态作用域中的上下文传播。
它不是分布式上下文协议。
跨 HTTP 服务仍然需要 Header。
跨 RPC 仍然需要 Metadata。
跨 MQ 仍然需要 Message Header。
TraceId 仍然需要遵守对应的 Trace Context 传播机制。
这个边界必须明确。
十二、ScopedValue 也不能代替权限校验
另外一个常见误区是:
既然 tenantId 已经存在 ScopedValue 中,那么数据库查询是不是直接使用就安全了?
还不够。
ScopedValue 只是负责:
把 tenantId 传到这里。
它并不证明这个用户拥有访问当前订单的权限。
Tool 层依然应该进行授权:
authorizationService.requirePermission(
context.tenantId(),
context.userId(),
"order:query"
);
数据库层也必须继续执行租户隔离:
SELECT
id,
order_no,
amount,
status
FROM orders
WHERE tenant_id = ?
AND id = ?;
而不是:
SELECT
id,
order_no,
amount,
status
FROM orders
WHERE id = ?;
Context Propagation 解决的是“上下文怎么到达”。
Authorization 解决的是“这个人能不能做”。
Data Isolation 解决的是“数据是否真正隔离”。
三者不能混为一谈。
对于可以执行真实业务操作的 AI Agent,这个区别尤其重要。
十三、ThreadLocal 到底还要不要用
答案当然不是:
以后所有 ThreadLocal 全删掉。
ScopedValue 的官方定位本身就非常明确,它适合的是“单向传递”的场景。
可以简单这样判断:
| 使用场景 | 更推荐 |
|---|---|
| userId | ScopedValue |
| tenantId | ScopedValue |
| traceId | ScopedValue |
| requestId | ScopedValue |
| conversationId | ScopedValue |
| 请求权限上下文 | ScopedValue |
| 调用链共享的不可变配置 | ScopedValue |
| 必须在线程内不断修改的状态 | ThreadLocal 或重新设计 |
| 线程级缓存 | 根据具体场景判断 |
| 跨进程 Context | Header / RPC / MQ |
| Spring AI Tool 执行上下文 | ToolContext |
关键不是“哪个 API 更新”。
而是先问一句:
这个数据到底是什么?
如果它表达的是:
“这一次请求是谁发起的”。
“这一次任务属于哪个租户”。
“这一次 Agent 运行属于哪个会话”。
“整个执行链只读取,不应该随意修改”。
那么 ScopedValue 会比 ThreadLocal 更符合语义。
十四、AI Agent 的 Context 最好再拆一层
随着系统复杂度继续增加,只定义一个 RequestContext 可能仍然不够。
我更推荐把上下文至少分成三个层次。
| Context | 典型内容 | 生命周期 |
|---|---|---|
| Request Context | requestId、traceId、tenantId、userId | 当前请求 |
| Agent Context | conversationId、taskId、modelRoute | 当前 Agent 任务 |
| Tool Context | tenantId、userId、permissionScope、审计数据 | 当前 Tool 调用 |
例如 RequestContext:
public record RequestContext(
String requestId,
String traceId,
String tenantId,
String userId
) {
}
AgentContext:
public record AgentContext(
String conversationId,
String taskId,
String modelRoute
) {
}
然后分别维护不同生命周期。
flowchart LR
A["HTTP Request"] --> B["Request Context"]
B --> C["Agent Task"]
C --> D["Agent Context"]
D --> E["LLM"]
D --> F["Tool Calling"]
B --> G["Tool Context"]
G --> F
这样做的好处是:
RequestContext 不会因为 Agent 功能不断增加而变成一个几十个字段的万能对象。
更重要的是,每一种 Context 都拥有明确生命周期。
这其实和 ScopedValue 的设计思想是一致的:
数据应该被绑定到真正需要它的执行范围,而不是挂在一个无限扩张的全局上下文里。
十五、从 ThreadLocal 迁移时,不要做全局替换
现有 Spring Boot 项目如果已经大量使用 ThreadLocal,不建议直接全局搜索替换。
第一步应该是给现有 ThreadLocal 分类。
例如项目里存在:
| ThreadLocal | 实际语义 | 是否适合迁移 |
|---|---|---|
USER_ID |
请求身份 | 适合 |
TENANT_ID |
请求租户 | 适合 |
TRACE_ID |
请求链路 | 适合 |
REQUEST_CONTEXT |
请求上下文 | 适合 |
DATE_FORMATTER |
历史线程缓存 | 应重新评估 |
DB_CONNECTION |
可变资源 | 不适合直接替换 |
CURRENT_TRANSACTION |
框架内部状态 | 不应自行修改 |
然后再把真正属于请求级、只读、单向传播的数据迁移到一个不可变 Context。
最终目标不是:
ThreadLocal 全部消失。
而是:
flowchart LR
A["现有 ThreadLocal"] --> B{"它保存的是什么?"}
B -->|"请求级只读上下文"| C["ScopedValue"]
B -->|"线程级可变状态"| D["保留 ThreadLocal 或重新设计"]
B -->|"跨服务上下文"| E["HTTP / RPC / MQ"]
B -->|"AI Tool 执行信息"| F["ToolContext"]
这比机械替换 API 更有意义。
十六、Agent 时代,真正应该重新设计的是“上下文边界”
如果只看 API,ScopedValue 的代码甚至没有比 ThreadLocal 神奇多少。
ThreadLocal:
REQUEST_CONTEXT.set(context);
try {
agentService.execute();
} finally {
REQUEST_CONTEXT.remove();
}
ScopedValue:
ScopedValue.where(
REQUEST_CONTEXT,
context
).run(agentService::execute);
只是少了几行代码。
但从架构角度看,两者表达的是完全不同的思想。
ThreadLocal 更强调:
线程拥有状态。
ScopedValue 更强调:
任务在一个明确的上下文中执行。
而 AI Agent 正在让后者越来越重要。
因为 Agent 请求已经不再是传统的:
flowchart LR
A["HTTP"] --> B["Service"]
B --> C["Database"]
C --> D["Response"]
而越来越像:
flowchart LR
A["HTTP"] --> B["Agent"]
B --> C["LLM"]
C --> D["任务规划"]
D --> E["并行查询"]
D --> F["Tool Calling"]
F --> G["业务系统"]
G --> H["再次推理"]
H --> I["继续调用 Tool"]
I --> J["最终结果"]
一次请求内部可能出现多个子任务、多次模型调用、多次 Tool Calling,甚至继续访问其他微服务。
此时如果上下文边界不清晰,很快就会出现:
租户信息到底来自哪里?
哪个 Tool 是以谁的身份调用?
子任务有没有继承当前请求?
conversationId 为什么突然丢了?
异步线程里的 traceId 为什么没有了?
某个 Tool 为什么能够读取另一个租户的数据?
这些表面看起来是各种不同的问题,本质上都指向同一件事情:
Context 已经成为系统架构的一部分。
十七、结语
JDK 25 将 ScopedValue 正式定稿,并不意味着 ThreadLocal 即将被淘汰。
ThreadLocal 依然存在,虚拟线程也依然完整支持它。
真正值得 Java 开发者关注的是:
Java 对“上下文”这件事的建模正在发生变化。
过去我们习惯把 Context 和 Thread 绑定。
现在随着 Virtual Thread、Structured Concurrency 和 ScopedValue 逐渐形成一套完整模型,更自然的设计开始变成:
一个任务拥有明确的生命周期,一个任务下面拥有结构化的子任务,而 Context 随着这棵任务树传播。
对于传统 CRUD,这可能只是一次代码优化。
对于 AI Agent,却很可能成为基础设施设计。
因为未来的 Java Agent 后端真正需要解决的,已经不只是:
“如何调用大模型?”
而是:
“谁正在调用?”
“代表哪个租户?”
“拥有怎样的权限?”
“哪些数据可以交给模型?”
“哪些数据只能留在可信执行环境?”
“Context 应该传播到哪里,又应该在哪里终止?”
ScopedValue 并不能解决这些问题的全部。
但它提供了一种更准确的基础语义:
上下文属于这一次任务,而不是永远属于某个线程。
这也是为什么,在 AI Agent 越来越复杂的今天,重新审视 ThreadLocal 比单纯学习一个新的 JDK API 更有价值。
参考资料
| 资料 | 主要内容 |
|---|---|
| OpenJDK JEP 506 | Scoped Values 在 JDK 25 正式定稿,以及设计目标 |
| Oracle Java 25 ScopedValue API | 动态作用域、rebind、ThreadLocal 对比、StructuredTaskScope 继承规则 |
| OpenJDK JEP 444 | Virtual Threads 与 ThreadLocal 的关系 |
| OpenJDK JEP 533 | Structured Concurrency 在 JDK 27 继续 Seventh Preview |
| Spring AI Tool Calling | ToolContext、Tool Calling 以及上下文不会发送给模型的规则 |

评论区 1