斗灵境:用 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、跨回调、跨线程,甚至未来要跨进程,它就必然需要一个显式身份。这个身份不该由业务层的每一次调用去重新组装,也不该藏在任何一个线程的私有存储里。
这背后可复用的工程判断是:凡是会被多方共享、需要被审计、可能被中断和恢复的东西,都不该是散落的参数,而应该是一个有生命周期、有作用域、有所有者的运行时对象。先把边界画出来,再把状态放进去;先确定它活多久,再决定它叫什么名字。
反过来看,如果一次执行确实只有一步、只有一个消费者、不需要恢复,那传参就是最诚实的方案。判断标准不是"先进",而是"这个对象需不需要被别的东西看见,需不需要活过这一次调用"。
斗灵境的收益,是把无形的执行凝成了可操控的实体。代价,是我们要开始为自己的状态作用域负责。这笔账值不值,取决于你的数字人要不要长成"持续存在"的东西------而这个答案,会在下一次执行被中断时逼着我们给出来。
一境一身份
一域一分明
不争术之新
只求界之清