斗破Google ADK Java苍穹-第5境斗灵-凝物通脉-Context与State

斗灵境:用 InvocationContext 终结 projectId 到处手工传递

一次执行不是一段会话,参数不该穿透整条调用栈。

text 复制代码
[读者] Java 后端 / Agent 平台开发者
[痛点] projectId、userId、sessionId、requestId 层层手传,状态语义混乱
[现在读] 数字人要支持多项目、多用户、多轮次,参数链已经拖不动
[读完] 能用 InvocationContext + State 把一次执行凝成可追踪实体
text 复制代码
[旧方案] Controller 收参数,逐层透传 projectId/userId/sessionId/requestId
    |
    v
[新需求] 动态人格、按项目取资料、多轮续聊、可审计、可恢复
    |
    v
[冲突] 参数是"值",执行是"上下文";值传得动,上下文传不动
    |
    v
[后果] 状态漂移、串号、审计断链、Resume 无从下手

我是老李,做企业数字人平台。三年前我们交付的还是"一个输入框接一次大模型",今天要交付的是嵌进业务系统、带项目人格、能续聊、能审批、能追溯的数字人。技术栈基线是 Google ADK Java 1.11.0、JDK 21、Maven 3.9+;服务端只有一个 JVM,可这个 JVM 里同时住着几十个项目、上千个用户。

上一境我们给数字人穿上了 Runtime 铠甲:请求不再由 Controller 临时调模型,而是交给 Runner 驱动,Event 持续外放。铠甲穿上了,里面却是空的。

空在身份是散落的。Controller 拿到 projectId、userId、sessionId、requestId,像发牌一样往下递。递到第三层,参数已经六个;递到 Tool 那一层,有个新人为了少写两个参数,把 projectId 塞进了 ThreadLocal。

到底该把 projectId 这类东西当成一个参数,还是当成一次执行的上下文?

是继续在方法签名上做加法,还是把上下文收敛成运行时的一个对象?

如果继续手工传,等到要做 Resume、要审计、要跨进程协同转发时,会付出什么代价?

01、故事

现场是一个典型的多租户数字人:一个 Java 服务,接入企业微信、网页、内部 IM 三个通道;每个 projectId 对应一套知识库、一套人格、一套工具白名单;每个 userId 有自己的偏好和历史;每个 sessionId 是一段连续对话;每个 requestId 是这一句话。这个月的任务是:让数字人知道"我现在在为谁、在哪个项目、在哪一轮对话里"说话。

V1 的方案很朴素,也很常见:

java 复制代码
public String chat(String projectId, String userId, String sessionId,
                   String requestId, String text) {
    PromptContext ctx = new PromptContext(projectId, userId, sessionId, requestId);
    return agentService.invoke(ctx, text);
}

一开始它能跑。项目只有两个,用户只有几十个,一轮对话两三句话。然后三件事同时发生了:项目从 2 个变成 30 个;人格从静态模板变成"按项目 + 按用户角色"动态拼装;会话从"一次性问答"变成"能续聊、会记人"。

于是 PromptContext 开始长胖。它长出了 channel、tenant、locale、role、knowledgeBaseId、approvalToken。它被 service 层复制一次,被 tool 层复制一次,被回调层又复制一次。最要命的是,Tool 层拿到的 PromptContext 是入口那一份快照,而会话进行到第三轮时,用户角色已经变了------Tool 还在用旧的角色去查权限。

我们真正遇到的第一个事故,是串号。高峰期两个项目的用户同时提问,A 项目的数字人用 B 项目的知识库回答了问题。日志里看不出任何异常:每个方法都在正确的位置,每个参数都非空,就是内容错了。找到根因花了两个小时------有人把 projectId 放进了 ThreadLocal,而线程池是复用的。

那一刻我确认:这不是"参数太多"的问题,是"一次执行没有身份"的问题。

无形横四野

徒手难自持

一线穿诸境

方知我是谁

02、问题

旧方案失效:参数链能表达"值",表达不了"上下文树"。chat(projectId, userId, sessionId, requestId, text) 是扁平的,但真实的执行是嵌套的------一次请求里有多个 Agent 调用、多个 Tool 调用、多个 Callback,它们共享同一份上下文,却不该共享同一份可变状态。

业务影响分三层:正确性上,串号、越权回答、人格错配;可审计上,requestId 只出现在日志第一行和最后一行,中间层没有它,出问题只能整段重放;可演进上,想加 Resume、想加人工审批、想把执行转发给远程 Agent,全都卡在"执行没有显式边界"。

技术表现也很具体。方法签名爆炸,核心方法七个参数,新增一个上下文维度就要改十几处;ThreadLocal 泄漏,线程池复用导致上一个请求的属性残留;状态语义混乱,sessionId 和 requestId 混用,有人把请求级数据写进会话级 Map,有人把用户偏好写到全局缓存;孤儿写入,Tool 改了状态,主流程不知道,Session 也没落盘。

本境的验收口径是可验证的完成标准:第一,业务层方法签名里不再出现 projectId、userId、sessionId、requestId;第二,任意一次执行都能通过一个执行标识反查到这四个值;第三,动态指令能基于项目、用户、会话状态构建,且不需要业务层再传参;第四,状态写入有明确作用域,同一会话的并发请求互不污染;第五,一次执行的事件序列可以完整导出,供审计与后续 Resume 使用。

旧法难承重

新求已压身

若不明边界

越修越失真

03、原理

先把两个词分清,这是本章的核心。

Session 是长期容器:它跨轮次存在,装的是这个用户这段对话的历史和长期记忆。一次执行不是一段会话。

Invocation 是一次执行:从收到这一句用户消息开始,到这一轮最终产出为止,中间可能经过若干 Agent、若干 Tool、若干 Callback。它是一次性的、可追踪的、有边界的。

ADK Java 用 InvocationContext 表达后者。它把这一轮执行需要的东西串在一起:当前 Agent、当前 Session、Artifact 服务、Memory 服务、RunConfig,以及本次执行的 invocationId。具体访问器与方法签名以 adk-java 1.11.0 源码为准,需按当前官方文档核验。

围绕它有三个重要视图。ReadonlyContext 是只读视图,动态指令与只读型 Tool 拿到的就是它------你能读到项目、用户、状态,但改不了;这个限制是刻意的,读的地方不该有能力写。CallbackContext 是回调期视图,Agent 生命周期回调与 Tool 回调拿到它,既能读上下文,也能写状态变更、影响控制流。RunConfig 是这一轮执行的配置:流式与否、轮数上限、模型参数等,字段集合需按官方文档核验。

然后是最容易被忽略的一件事:状态怎么写。

Java 后端的习惯是 map.put("projectId", x),然后全公司共享一个巨型会话状态。这在单机、单线程、短生命周期时没问题,在 Agent Runtime 里会立刻出事,因为同一个 Session 上可能有并发的 Invocation。ADK 的状态实践是给 key 加作用域前缀:应用级、用户级、会话级、临时级;前缀决定了这份状态活多久、被谁看见、会不会落盘。

反直觉判断:状态治理里最关键的从来不是"字段叫什么",而是"这份状态的作用域有多大"。一个命名规范救不了作用域错配,而作用域对了,命名差一点也能运转。

第二个反直觉判断:把状态变更做成 State Delta、由 Runtime 统一合并,比"直接改 Map"更啰嗦,但它换来的是可持久化、可回放、可在中断后续跑。手工改 Map 是不可回放的副作用。

会话非执行

一境只一身

作用域先定

状态自归真

04、架构

text 复制代码
[输入] HTTP / IM 请求 + projectId/userId/sessionId/requestId
    |
    v
[模块] Gateway:只做 RuntimeRequestContext 组装与校验
    |
    v
[数据/状态] Session State(app:/user:/session:/temp: 分作用域)
    |
    v
[处理] Runner → InvocationContext(agent + session + memory + artifact + runConfig)
    |            |
    |            +--> 动态 Instruction 从 ReadonlyContext 读上下文
    |            +--> Tool / Callback 通过 CallbackContext 写 State Delta
    v
[输出] Event 流(含 invocationId、stateDelta)→ 前端 / 审计 / 下一轮 Session

边界说明:Gateway 的职责只有两个,把外部协议翻译成 RuntimeRequestContext,把 RuntimeRequestContext 翻译成会话状态与一次执行输入,它不做业务判断;Runner 与 InvocationContext 属于 ADK Runtime,业务代码不 new、不持有、不缓存;业务层只通过两个入口与 Runtime 交互,读用 ReadonlyContext,写用 CallbackContext;Memory、Artifact、Session 的持久化同样挂在 InvocationContext 上,业务不需要自己维护连接。

收益有三点:业务签名收敛,新增一个上下文维度不需要改调用链;每次执行天然带 invocationId,日志、审计、Resume 有锚点;状态作用域显式,并发污染面被限制在临时级。

代价同样有三点:学习曲线,要理解 Invocation 与 Session 的区别、作用域前缀、Delta 合并时机;调试方式变了,不能再靠断点看参数,要靠事件序列与状态快照;一定程度的框架耦合,业务代码开始 import ADK 类型,这是要付的税。

适用条件:每次执行跨多个组件、需要共享上下文时收益最大;如果只是单轮、单 Agent、无状态的问答,直接传参反而更省事。

铠甲非空壳

内有经纬线

一线穿诸器

各守其方圆

05、实战一次

目标:让数字人第一次在"知道自己是谁"的前提下说话。项目级人格从会话状态里读,用户级偏好从用户作用域读,本轮请求标识只活在这一次执行里。

未在当前环境实测,以下为预期结果。环境为 JDK 21、Maven 3.9+、Google ADK Java 1.11.0。依赖坐标与版本以 adk-java 仓库 README 与 Release 为准。

xml 复制代码
<dependency>
  <groupId>com.google.adk</groupId>
  <artifactId>google-adk</artifactId>
  <version>1.11.0</version>
</dependency>

第一步,定义运行期请求上下文。它不是 ADK 的类型,是我们自己的类型,唯一使命是把外部协议规范化,然后一次性交给 Runtime。

java 复制代码
package com.example.dh.runtime;

import java.util.Map;
import java.util.Objects;

public record RuntimeRequestContext(
        String projectId,
        String userId,
        String sessionId,
        String requestId,
        String channel,
        Map<String, Object> attributes) {

    public RuntimeRequestContext {
        Objects.requireNonNull(projectId, "projectId");
        Objects.requireNonNull(userId, "userId");
        Objects.requireNonNull(sessionId, "sessionId");
        Objects.requireNonNull(requestId, "requestId");
        attributes = attributes == null ? Map.of() : Map.copyOf(attributes);
    }
}

第二步,定义状态键。前缀是作用域契约,不是装饰。

java 复制代码
package com.example.dh.runtime;

public final class StateKeys {
    private StateKeys() {}

    public static final String APP_PROJECT_ID = "app:projectId";
    public static final String USER_ROLE = "user:role";
    public static final String SESSION_LAST_INTENT = "session:lastIntent";
    public static final String TEMP_REQUEST_ID = "temp:requestId";
    public static final String TEMP_CHANNEL = "temp:channel";
}

第三步,把 RuntimeRequestContext 落成状态。注意 projectId 是应用级、requestId 是临时级,这两者绝不能混在同一个作用域。

java 复制代码
package com.example.dh.runtime;

import java.util.HashMap;
import java.util.Map;

public final class StateMapper {

    public static Map<String, Object> toInitialState(RuntimeRequestContext ctx) {
        Map<String, Object> state = new HashMap<>();
        state.put(StateKeys.APP_PROJECT_ID, ctx.projectId());
        state.put(StateKeys.USER_ROLE, ctx.attributes().getOrDefault("role", "member"));
        state.put(StateKeys.TEMP_REQUEST_ID, ctx.requestId());
        state.put(StateKeys.TEMP_CHANNEL, ctx.channel() == null ? "unknown" : ctx.channel());
        return state;
    }
}

第四步,动态指令。它只依赖只读上下文,不接收任何来自业务层的手工参数。

java 复制代码
package com.example.dh.agent;

import com.example.dh.runtime.StateKeys;
import com.google.adk.agents.ReadonlyContext;

public final class PersonaAssembler {

    private PersonaAssembler() {}

    public static String assemble(ReadonlyContext context) {
        String projectId = String.valueOf(context.state().get(StateKeys.APP_PROJECT_ID));
        String role = String.valueOf(context.state().get(StateKeys.USER_ROLE));
        String channel = String.valueOf(context.state().get(StateKeys.TEMP_CHANNEL));

        return """
            你是项目 %s 的数字人助理。
            当前用户角色:%s,本轮接入渠道:%s。
            回答必须基于该项目知识库;不确定时明确说明不确定,不要编造。
            """.formatted(projectId, role, channel);
    }
}

只读上下文的状态读取访问器名称需按 adk-java 1.11.0 源码核验;本文关注的是"从上下文读,而不是从参数读"这个结构。

第五步,装配 Agent。指令是每轮执行时求值的,不是启动时求值的,这正是动态人格能成立的原因。

java 复制代码
package com.example.dh.agent;

import com.google.adk.agents.LlmAgent;

public final class AgentFactory {

    private AgentFactory() {}

    public static LlmAgent build() {
        return LlmAgent.builder()
                .name("digital-human")
                .model("gemini-2.5-flash")
                .instruction(PersonaAssembler::assemble)
                .build();
    }
}

第六步,入口只组装上下文、只调 Runner。

java 复制代码
package com.example.dh;

import com.example.dh.agent.AgentFactory;
import com.example.dh.runtime.RuntimeRequestContext;
import com.example.dh.runtime.StateMapper;
import com.google.adk.agents.LlmAgent;
import com.google.adk.runner.InMemoryRunner;
import com.google.adk.sessions.InMemorySessionService;
import com.google.genai.types.Content;
import com.google.genai.types.Part;

import java.util.Map;

public class DigitalHumanApp {

    public static void main(String[] args) {
        RuntimeRequestContext req = new RuntimeRequestContext(
                "proj-risk", "u-1001", "s-77", "req-20250101-001", "web",
                Map.of("role", "auditor"));

        LlmAgent agent = AgentFactory.build();
        InMemorySessionService sessionService = new InMemorySessionService();
        InMemoryRunner runner = new InMemoryRunner(agent);

        sessionService.createSession("digital-human", req.userId(), req.sessionId(),
                StateMapper.toInitialState(req));

        Content message = Content.fromParts(Part.fromText("这个项目的风险等级怎么判断?"));

        runner.runAsync(req.userId(), req.sessionId(), message)
              .blockingForEach(event -> System.out.println(event.stringifyContent()));
    }
}

启动与验证:

bash 复制代码
mvn -q compile exec:java -Dexec.mainClass=com.example.dh.DigitalHumanApp

示例输出(预期形态,非实测数据):

text 复制代码
你是项目 proj-risk 的数字人助理。
当前用户角色:auditor,本轮接入渠道:web。
事件: functionCall -> ...
事件: functionResponse -> ...
事件: text -> "该项目风险等级按..."
invocationId: <一次执行唯一标识>

验证点:PersonaAssembler 里没有任何 projectId、userId 参数,人格却正确;日志中只有一次执行标识,不用再看四个参数;换一个 sessionId 重新创建 Session,人格随之变化。

V1 到此跑通。但它留了一个明显的问题:requestId 写在初始状态里,而初始状态是会话级的,第二个请求进来就会被覆盖。这正是下一节要排查的事。

初凝成一物

可触亦可循

虽已通经脉

尚欠一层分

06、排查

排查一:人格里出现了上一个请求的渠道。

现象:同一会话连续两次请求,第二次从 IM 渠道进来,但人格里打印的渠道还是 web。怀疑:初始状态没有被更新,或者更新写到了错误的会话。检查:在指令里打印本轮临时渠道值,同时在调用前后打印会话状态快照。证据:临时渠道值等于第一个请求写入的值,第二次调用没有重新写入。根因:我们把 requestId、channel 这类属于一次执行的数据,写进了会话初始化状态里。会话是长期的、只创建一次;执行是短期的、每次都要新值。修复:把请求级数据从"创建会话时写入"改为"每次执行前以临时作用域注入",或通过 Callback 在执行期写入 Delta。

排查二:并发请求状态互相污染。

现象:A 用户和 B 用户在同一个项目的不同会话里提问,偶发出现 A 的人格里带了 B 的会话意图。怀疑:状态 Map 是共享引用。检查:对比两次执行的 invocationId 与状态快照,检查 Map 是否被同一个实例持有。证据:两次执行读到的最新意图都是后写入的那一份。根因:状态写入用的是会话级作用域,但写入内容属于本轮请求,作用域错配,并发下必然互相覆盖。修复:区分会话级与临时级写入,临时级状态只在本次执行可见。

排查三:Tool 改了状态,主流程不知道。

现象:工具查到用户已提交审批,但下一轮对话里数字人仍然催用户提交。怀疑:Tool 写状态时直接改了本地 Map。检查:检查 Tool 回调里是写 Delta 还是写本地引用,检查事件流里有没有对应的状态变更事件。证据:事件流里没有该次状态变更,会话落盘的状态也没有变化。根因:回调期绕过了 Runtime 的状态合并通道。修复:在回调中通过 CallbackContext 写状态变更,让它进入 Delta 合并流程。

错误尝试: 一开始我们的修法是"把 projectId 放进 ThreadLocal,在 Filter 里设置,在 Tool 里读取"。这看起来一步到位:不用改签名,不用加参数,读的地方随取随用。它之所以错,是因为一次执行的运行线程不保证与请求线程相同------异步调度、流式回放、回调线程都可能换线程,ThreadLocal 要么读到 null,要么读到上一个请求残留的值。我们那次串号事故就是这套方案的直接产物。状态必须挂在执行对象上,而不是挂在线程上。

现象可为凭

求证不为猜

根因一挖出

修改自有方

07、优化

基于第 06 章的证据,V2 只做三件事,每一件都对应一条根因。

修改一:作用域分层落位。 requestId、channel、traceId 这类值一律走临时作用域;projectId、项目人格走应用与会话作用域;用户角色、偏好走用户作用域。

java 复制代码
package com.example.dh.runtime;

import java.util.HashMap;
import java.util.Map;

public final class StateMapper {

    /** 只有长期状态在会话创建时写入。 */
    public static Map<String, Object> toSessionState(RuntimeRequestContext ctx) {
        Map<String, Object> state = new HashMap<>();
        state.put(StateKeys.APP_PROJECT_ID, ctx.projectId());
        state.put(StateKeys.USER_ROLE, ctx.attributes().getOrDefault("role", "member"));
        return state;
    }

    /** 每次执行的状态变更,走 Delta。 */
    public static Map<String, Object> toInvocationDelta(RuntimeRequestContext ctx) {
        Map<String, Object> delta = new HashMap<>();
        delta.put(StateKeys.TEMP_REQUEST_ID, ctx.requestId());
        delta.put(StateKeys.TEMP_CHANNEL, ctx.channel() == null ? "unknown" : ctx.channel());
        return delta;
    }
}

原因:会话创建只发生一次,请求级数据每次都要新值。把这两者放进同一个写入点,等于让长期容器承担短期状态。新行为:同一会话连续请求,临时状态每次刷新,长期状态保持稳定。验证:连续两次请求打印临时渠道值,值应不同;打印项目标识,值应一致。

修改二:动态指令只读上下文,不再有任何业务参数入口。

java 复制代码
package com.example.dh.agent;

import com.example.dh.runtime.StateKeys;
import com.google.adk.agents.ReadonlyContext;

public final class PersonaAssembler {

    private PersonaAssembler() {}

    public static String assemble(ReadonlyContext context) {
        Object projectId = context.state().get(StateKeys.APP_PROJECT_ID);
        Object role = context.state().get(StateKeys.USER_ROLE);
        Object channel = context.state().get(StateKeys.TEMP_CHANNEL);
        return """
            你是项目 %s 的数字人助理。
            当前用户角色:%s;本轮接入渠道:%s。
            只依据该项目的知识作答,缺少依据时明确说明。
            """.formatted(projectId, role, channel);
    }
}

原因:只要指令还能接收业务参数,调用链就还会长回去。新行为:新增一个上下文维度时,只改状态映射与人格装配两处。

修改三:回调里的状态写入统一走 Runtime 通道,而不是本地 Map。

java 复制代码
package com.example.dh.agent;

import com.example.dh.runtime.StateKeys;
import com.google.adk.agents.CallbackContext;

public final class IntentRecorder {

    private IntentRecorder() {}

    public static void record(CallbackContext callbackContext, String intent) {
        callbackContext.state().put(StateKeys.SESSION_LAST_INTENT, intent);
    }
}

原因:本地 Map 的修改不会进入事件流,审计与恢复都看不到。新行为:状态变更随事件一起外放,可被持久化和回放。验证:在一轮对话里触发意图记录,检查事件流中是否出现对应状态变更。

一处作用域

一层分短长

三次小改动

换来可回放

08、演进

text 复制代码
[同一输入:项目 proj-risk / 用户 u-1001 / 第二轮对话 / 渠道 im]
    |
    +--[V1] 参数穿透 + 会话级初始化状态
    |      结果:人格能生成,但渠道值陈旧;并发下会互相覆盖
    |      代价:看起来简单,排查成本极高
    |
    +--[V2] RuntimeRequestContext → 分层状态 + 动态指令
    |      结果:一次执行自带上下文,临时状态每轮刷新
    |      代价:需要理解作用域与 Delta,调试方式改变
    |
[Trade-off]
得到:可追踪、可审计、可续跑的一次执行边界
失去:方法签名上的"显式"直观感,以及部分调试惯性
适用边界:跨组件、多轮、多租户的 Agent 执行;单轮无状态问答不必上这套

逐项比较。正确性上,V1 依赖调用链完整,任何一层漏传就出错;V2 依赖状态作用域,只有作用域错配才会出错,且错配有明确的可验证信号。稳定性上,V1 在并发下不可控,ThreadLocal 与共享 Map 都是隐患;V2 把并发污染压缩到临时作用域内。复杂度上,V1 的复杂度分布在每个方法签名上,V2 的复杂度集中在状态键定义与作用域约定上,集中比分散好治理。成本上,V2 需要引入 ADK Runtime 类型,业务代码与框架产生耦合,这是必要的税。适用范围上,多租户、多轮、需要审计或恢复的数字人是 V2 的主场。遗留问题上,临时状态如何跨进程传递、执行标识与业务请求标识如何固化成日志锚点,本境只做到"有锚点",还没做到"可恢复"。

旧路已走尽

新桥尚未铺

取舍非对错

只在边界处

09、洞见

9.1 上下文是类型,不是参数

反直觉判断:上下文不是"参数变多了所以需要打包",而是"执行本身是一个对象,参数只是它的投影"。当我们把 projectId 当成参数,就默认了它是可选的、可替换的、可缺省的;而一旦它是一次执行的身份,它就不该有"忘记传"这个状态。类型化之后,编译器替你守住了这条边界。

9.2 作用域比字段名重要一百倍

反直觉判断:状态混乱的第一原因几乎从来不是命名不规范,而是作用域错配。请求标识放进会话级状态,名字再规范也会串;项目标识放进临时状态,名字再土也能跑。判断一个状态该放哪,只问一句:它该活多久、该被谁看见。

9.3 执行与会话必须显式分开

一次执行不是一段会话。把两者混在一起,会同时得到两个坏结果:会话被请求级噪声污染,请求又被迫继承会话的全部重量。分开之后,恢复、审计、限流、并发控制才各有落点。

9.4 状态变更要走 Runtime 通道,不走本地引用

手工改 Map 是最快的写法,也是唯一不可回放的写法。Delta 合并看起来绕,但它把副作用变成了数据------数据可以被记录、被重放、被审查。工程上,可回放的副作用值钱得多。

9.5 框架对象不自己造

反直觉判断:拿到 InvocationContext 这类对象,第一反应常常是"我自己 new 一个,方便测试"。问题是它身上挂着 Session、Memory、Artifact 服务,自己造出来的实例往往只有壳。正确的姿势是让 Runtime 创建,自己只读,或只写它允许写的部分。

一境一执行

一域一状态

边界若分明

万变不离宗

10、系统落地

原来有什么:一个能驱动 Runner、能外放 Event 的数字人 Runtime 铠甲;但每次执行的身份靠方法参数透传,状态散落在 ThreadLocal、局部 Map 和会话初始化里。

本篇新增什么:一个业务侧的 RuntimeRequestContext,把外部协议规范化;一个状态键与作用域约定,把长期状态与临时状态分离开;一套动态指令装配方式,人格从上下文里长出来,而不是从参数里塞进来;一条可追踪的执行链路,每次执行有标识,状态变更随事件外放。

现在能做什么:数字人知道自己正在为哪个项目、哪个用户、哪一轮对话服务;同一会话的并发请求不再互串;日志里终于能把一次执行从头串到尾。

还缺什么:状态只活在内存里,进程重启即丢失,持久化还没做;Artifact 与 Memory 还没有真正接入执行链,项目资料仍靠占位;危险动作无法暂停等人工确认,执行要么一次跑完,要么全失败;跨 JVM 的协同没有出口。

下一步如何演进:把会话持久化、记忆检索、资料读写接进同一条执行链,让数字人从"一轮对话"变成"持续存在";随后再补统一治理、确定性流程与跨边界协同。

甲已成其形

脉已通其络

待有记忆在

方称久存身

11、小结

text 复制代码
Q1 → 上下文该被当成参数还是执行身份?
答:是执行身份。参数可以被漏传,身份不能。

Q2 → 继续加参数还是收敛成运行时对象?
答:收敛成运行时对象。让 Runtime 创建和驱动,业务只读写它允许的部分。

Q3 → 继续手传会在恢复、审计、跨进程时付出什么代价?
答:执行没有边界,审计断链、无法恢复、无法转发,只能整段重放重跑。

状态 → 数字人的每次执行都拥有明确、可追踪的运行上下文:知道用户、项目、会话、请求与当前状态。

凝物已成体

通脉已达心

至此一轮毕

下一境再行

12、作业

12.1 理解题:为什么请求标识不应该写在会话创建时的初始状态里?

参考答案:会话是跨轮次的长期容器,只在创建时初始化一次;请求标识属于一次执行的临时信息,每个请求都不同。把临时状态写进长期容器,会导致第二个请求读到第一个请求的值,并发时还会互相覆盖。正确做法是让请求标识进入临时作用域,并随本次执行的 Delta 写入。

12.2 实战题:为一个多项目数字人实现"请求上下文到状态"的映射,并让动态指令不需要任何手工参数。

参考答案:定义 RuntimeRequestContext 记录项目、用户、会话、请求四个标识;定义状态键常量并区分应用级、用户级、会话级、临时级作用域;在创建会话时只写长期状态,在执行前写临时状态;在人格装配中只依赖只读上下文读取状态生成指令文本。验收方式:连续两次不同渠道的请求,人格中的渠道值不同、项目值相同。

12.3 排障题:日志显示同一会话的两轮对话中,Tool 拿到的用户角色是上一轮的值,如何定位?

参考答案:先确认角色写入发生在哪个作用域。若写入会话级或应用级状态,则角色变更不会随请求刷新;接着打印两轮执行的 invocationId 与状态快照,确认第二轮读取的是旧值;根因通常是"把请求级数据写进了长期作用域"。修复方式是把角色这类可能随请求变化的属性放到临时作用域,或在回调中通过状态 Delta 显式刷新。

12.4 架构判断题:为了少改签名,把项目标识放进 ThreadLocal 与放进 InvocationContext,本质区别是什么?

参考答案:区别在于生命周期与所有权。ThreadLocal 的生命周期绑定线程,而 Agent 执行可能跨线程调度,导致读到 null 或残留值,且无法随事件外放、无法持久化、无法在恢复时重建。InvocationContext 的生命周期绑定一次执行,所有权在 Runtime,状态变更可进入事件流,因而可追踪、可回放。前者的"少改签名"是用正确性换来的。

一题映一理

一练贯一行

若问何以固

唯在动手中

13、思考

本篇的核心冲突只有一个:当一次执行需要知道"我是谁"时,是继续在调用链上加参数,还是把执行本身变成一个可承载上下文的实体。

答案不是"某个类更高级",而是"参数能表达值,表达不了边界"。一旦一次执行要跨 Agent、跨 Tool、跨回调、跨线程,甚至未来要跨进程,它就必然需要一个显式身份。这个身份不该由业务层的每一次调用去重新组装,也不该藏在任何一个线程的私有存储里。

这背后可复用的工程判断是:凡是会被多方共享、需要被审计、可能被中断和恢复的东西,都不该是散落的参数,而应该是一个有生命周期、有作用域、有所有者的运行时对象。先把边界画出来,再把状态放进去;先确定它活多久,再决定它叫什么名字。

反过来看,如果一次执行确实只有一步、只有一个消费者、不需要恢复,那传参就是最诚实的方案。判断标准不是"先进",而是"这个对象需不需要被别的东西看见,需不需要活过这一次调用"。

斗灵境的收益,是把无形的执行凝成了可操控的实体。代价,是我们要开始为自己的状态作用域负责。这笔账值不值,取决于你的数字人要不要长成"持续存在"的东西------而这个答案,会在下一次执行被中断时逼着我们给出来。

一境一身份

一域一分明

不争术之新

只求界之清

相关推荐
十年Java程序媛1 小时前
Java 接口和抽象类对比|抛弃老旧八股,正确选型
java·开发语言·spring boot·后端
u1301301 小时前
GitHub 热榜项目:周榜(2026-10-11)
人工智能·驱动开发·github
见叶之秋1 小时前
【C++】从基类到派生类:关于C++继承的务实梳理
开发语言·c++
范中勤1 小时前
OSS 多文件打包回流上传:流式管道无落地 VS 临时磁盘落地,两种工业方案深度对比
java·高并发·zip·oss·架构选型
空 白II1 小时前
10.11 大语言模型研究简报:Anthropic 披露 Claude Agent 四类越权失序行为
人工智能·语言模型·自然语言处理
无毁的湖光-Al1 小时前
解Bug之路-隔离故障点后业务不恢复?-热点Key处理与亚稳态故障
java·数据库·mysql
具身AGI1 小时前
世界模型改当裁判,物理AI 物理推理 做判据
人工智能·深度学习·机器学习
狂奔蜗牛(bradley)1 小时前
EtherCAT 主站拓扑判定的三级防护:从去抖到振荡检测
人工智能·嵌入式硬件·fpga开发·架构
XMAIPC_Robot1 小时前
工商业储能EMS边缘计算:本地AI负荷预测,彻底摆脱云端依赖
人工智能·fpga开发·机器人·边缘计算·arm+fpga·rk3588+fpga