🤖
AI审核中

从ThreadLocal到ScopedValue:Java上下文传播正在改变

Java 54分钟 120浏览 1评论

在传统 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 明确规定,虚拟线程和平台线程一样支持 ThreadLocalInheritableThreadLocal。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-IdX-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 条评论
伴我   湖南省衡阳市

学不动了chenqiangluolei