🤖
AI审核中

READING IN THE WILD · 读一篇手记

Java上下文传播正在改变

文章摘要

随着 Java 应用从传统 CRUD 走向 AI Agent,userId、tenantId、traceId、conversationId 等上下文需要在更复杂的调用链与并发任务中稳定传播。JDK 25 正式定稿的 ScopedValue,为“上游绑定、下游只读、任务结束自动失效”的场景提供了比 ThreadLocal 更清晰的语义。本文结合虚拟线程、结构化并发与 Spring AI ToolContext,分析 ScopedValue 的设计原理、适用边界、异步传播限制及安全注意事项,并给出 Spring Boot 项目的上下文分层与迁移思路,帮助开发者重新理解 Agent 时代的 Java Context 设计。

在传统 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 条评论

如果你觉得文章对你有帮助,那就请作者喝杯咖啡吧

评论区 1

头像
 1 条评论
伴我
学不动了chenqiangluolei