斗破Google ADK Java苍穹-第4境大斗师-铠甲运转-Runner与Event
斗气铠甲:用 Runner 与 Event 重构数字人运行时
当执行过程只剩一行最终答案,Runtime 其实已经不在场了。
[读者] 用 Google ADK Java 做数字人、却把 Runner 当黑盒的 Java 后端
[痛点] Controller 里 new Agent、开 Session、调模型,只能拿到最终文本
[现在读] 斗气铠甲境界要求执行过程可观测、可回放、可分流
[读完] 能用 Runner 与 Event 重建运行时,并交付 RunnerRegistry 与 EventBridge
[旧方案] Controller 直接拼 Agent、手工管 Session、只取最终字符串
|
v
[新需求] 要执行轨迹、要 SSE 增量、要按项目隔离 Agent 生命周期
|
v
[冲突] 最终文本是结果不是事实,把结果当事实,中间态全部丢失
|
v
[后果] 出问题只能靠猜,换前端要重写一遍,多项目共用实例互相污染
我是老李,带一个三人小组做企业数字人,底座是 Google ADK Java 1.11.0、JDK 21、Maven 3.9。上一境结束时,我们的数字人已经有了项目身份、人格设定和工具边界,能查工单、能读知识库。
这一境要给它穿上斗气铠甲。斗气铠甲是防护,斗气外放是输出。翻译成工程语言只有一句话:执行过程本身要成为一等公民,而不是最终答案的附属品。
现场在南山的一间会议室。投影上是深智助手二期迭代计划:一期只服务内部 IT 工单;二期同时接入企业 IM 和 Web,Web 端要 SSE 增量,运营后台要点开任意一条会话,看它当时到底干了什么。
当前方案的源码只有一个类:DigitalHumanController。它注入 LlmAgent,自己生成 sessionId,把历史消息拼成 Content,调模型,取一段完整字符串,return ResponseEntity.ok(text)。
任务是:让同一个数字人在同步 JSON 和 SSE 两种通道下输出同一份事实,并且按项目隔离 Agent 生命周期、按配置版本回放执行轨迹。
代码写得很顺手,直到运营提了一个需求:能不能看到它调用工具的过程?我们打开日志,日志里只有一行 response=...,中间什么都没有。
一次执行到底发生了什么?是模型没调工具,还是工具调了但没返回?同一个 Agent 实例被两个项目共用,项目 A 的人格会不会漏进项目 B?
如果我们继续靠最终文本反推过程,会怎样?如果换一个前端框架,是不是要把整条链路再写一遍?如果三个月后线上出问题,我们拿什么去查?
01、故事
第一个现场是两个项目复用同一个 Spring Boot 服务。项目 A 是 IT 工单助手,项目 B 是 HR 政策助手。上线第一周,HR 的同事收到一句「已为你创建工单」——那是 IT 助手的口吻。我们查了半天,配置里只有一个 @Bean LlmAgent。
第二个现场是前端同学要求打字机效果。我们在 Controller 里把最终字符串按 8 个字符切片,Thread.sleep(30) 再 SseEmitter.send。前端看着像流式,实际上后端早就把完整答案拿到了。模型一旦思考 30 秒,前端就白屏 30 秒,然后瞬间刷完。
第三个现场是一次工具调用超时。用户投诉,我们打开日志,只有最终那句「抱歉,暂时无法查询」。是工具超时?是模型选错了工具?还是 Session 里根本没有历史?没人知道。
三个现场指向同一件事:我们把结果当成了运行。Controller 做了本不该它做的事——它既是 HTTP 接入层,又是 Agent 组装层,还是 Session 生命周期管理层,最后还兼职事件分发层。四层压成一层,中间没有任何一个地方记录事实。
这就是本章的 BOSS。它不是一个 bug,是一段结构。改一个字符串切片的长度,救不了它。
一 Controller 撑全局
一字符串答万事
需求一变即破功
铠甲未铸先见骨
02、问题
旧方案失效的症状很具体。第一,接口只能返回最终文本,任何一个中间态都拿不到,同步接口和 SSE 接口各写一套逻辑,两边永远对不齐。第二,Agent 实例是进程级单例,Session 命名空间重叠,跨项目人格串味。第三,模型、工具、状态变更全部没有落点,出了故障只能靠复现。
业务影响是三个层面的。客服侧无法回答「它为什么这么答」;运营侧无法回放争议会话;研发侧每次改前端展示,都要回到 Controller 里改一遍字符串拼装。
技术表现是:Controller 有 6 个以上不相干的职责;没有一类对象描述「运行时发生了一件事」;不同 projectId 共用 appName;SSE 是假流式,首字节时间等于整段生成时间。
可验证的完成标准我们定成三条。标准一:同一个 projectId 加 configVersion 只对应一个 Runner 实例,Session 不跨项目复用,两个项目的 state 互不可见。标准二:一次 run 至少产出 TEXT_DELTA、TOOL_CALL、TOOL_RESULT、STATE_CHANGE、ERROR、DONE 六类事件中的对应子集,同步 JSON 与 SSE 都由同一份事件流派生,不允许各自拼装。标准三:任意一次执行都能按 invocationId 从轨迹存储中还原出完整事件序列,顺序与实时流一致。
这三条都能用测试断言,不靠体感。
黑盒只余答
白盒方见流
标准立三条
方知何处修
03、原理
要理解这一境,先要把两个类分清。Runner 是编排者,负责一次调用的会话生命周期与事件产出;Agent 是推理者,负责想什么、调什么工具。Runner 组合 Agent、SessionService、MemoryService、ArtifactService,把「一次请求属于哪个会话、从哪里开始、到哪里结束」这件事接了过去。
反直觉判断:Runner 不是「更高级的模型调用封装」。如果你把它当模型客户端用,就会继续在 Controller 里拼 Session、拼历史、拼返回值,铠甲穿在身上却还是裸奔。Runner 真正的价值在于,它是唯一知道「这次执行从哪个 session 开始、经过哪些事件、什么时候 turn 结束」的地方。
第二个关键概念是 Event。Event 描述的是「运行时发生了一件事」,而不是「给用户看的一段字」。同一份 Event 流,前端拿去渲染增量文本,审计系统拿去落盘,测试拿去断言工具是否被调用,三个消费者读同一份事实。
还有一个反直觉判断:一次 run 的事件条数与回答长度无关。模型只回一句「好的」,可能只产生一个带 txt 的事件加一个 turnComplete;而一次带工具调用的回答,可能产生 functionCall、functionResponse、stateDelta、若干文本片和一个收尾事件。如果你按「事件等于段落」去理解,桥接层的分支判断立刻会写错。
所以 Runner 与 Event 的关系是:Runner 生产事实,Event 承载事实,Controller 只做协议转换。谁越界,谁就把黑盒还回来。
Runner 不推理
Agent 不排程
事件即事实
终答非全程
04、架构
[输入] HTTP /api/dh/{projectId}/chat 或 /stream
|
v
[模块] DigitalHumanRunnerRegistry -> Runner(Agent + Session/Memory/Artifact Service)
|
v
[数据/状态] Session.state / EventActions.stateDelta / Artifact
|
v
[处理] Runner.runAsync -> Flowable<Event> -> AdkEventBridge
|
v
[输出] DigitalHumanEvent 流 -> 同步 DTO / SSE / 轨迹存储
这张图里最重要的边界是那条竖线:Runner 之上是业务,Runner 之下是运行时。Registry 属于业务侧的装配点,它决定「哪个项目、哪个配置版本、用哪个 Agent」;Runner 属于运行时,它决定「会话怎么开、事件怎么流」。
收益有三个。第一,同步与 SSE 从两条代码路径变成一条事件流上的两个消费者,前端换协议不改内核。第二,按 projectId 加 configVersion 隔离运行时,人格串味从结构上不可能发生。第三,执行轨迹天然可落盘,回放和诊断不需要额外埋点。
代价也要说清楚。第一,内存占用上升,Runner 内部持有 SessionService,多项目多版本意味着多份 Session 存储;InMemoryRunner 适合开发与单机验证,生产必须换成持久化的 SessionService 实现。第二,事件模型带来一层映射成本,AdkEventBridge 需要跟随 ADK 的 Event 字段演进,官方升级时要回归。第三,Flowable<Event> 是冷流,谁订阅谁触发执行,Controller 里一旦重复订阅,模型会被调用两次。
适用条件是:数字人需要多端接入、需要执行可观测、需要按项目隔离。如果只是一个单项目原型,InMemoryRunner 直接跑也够用,不必上 Registry。
需要提醒的是,本章涉及的 Runner、InMemoryRunner、Event、EventActions 的具体方法签名,落笔时未能逐一读取官方仓库全部页面,凡标为「需按当前官方文档核验」之处,以 1.11.0 发布包与官方主线源码为准。
依赖注入身
会话系甲胄
事件四处流
边界各自守
05、实战一次
环境:JDK 21、Maven 3.9+、Google ADK Java 1.11.0。分支 chapter/04-runner-event,提交信息 feat(ch04): introduce runner lifecycle and event stream。
依赖坐标以官方发布为准,下面这一条需按当前官方文档核验:
<dependency>
<groupId>com.google.adk</groupId>
<artifactId>google-adk</artifactId>
<version>1.11.0</version>
</dependency>
第一步,交付 DigitalHumanRunnerRegistry,按 projectId 加 configVersion 管理 Runner 生命周期与配置版本。
package com.shenzhen.dh.runtime;
import com.google.adk.agents.BaseAgent;
import com.google.adk.runner.InMemoryRunner;
import com.google.adk.runner.Runner;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
public final class DigitalHumanRunnerRegistry {
public record ProjectRuntime(String projectId, String configVersion, BaseAgent agent, Runner runner) {
}
private final Map<String, ProjectRuntime> runtimes = new ConcurrentHashMap<>();
public ProjectRuntime register(String projectId, String configVersion, BaseAgent agent) {
String key = key(projectId, configVersion);
return runtimes.computeIfAbsent(key, k ->
new ProjectRuntime(projectId, configVersion, agent, new InMemoryRunner(agent, k)));
}
public ProjectRuntime acquire(String projectId, String configVersion) {
ProjectRuntime runtime = runtimes.get(key(projectId, configVersion));
if (runtime == null) {
throw new IllegalStateException("runtime not found: " + projectId + "@" + configVersion);
}
return runtime;
}
private static String key(String projectId, String configVersion) {
return projectId + "@" + configVersion;
}
}
这里把 appName 直接设成 key,是为了让每个项目每个版本的 Session 落在独立命名空间里。这一步是第 06 章那条串味故障的结构性预防。
第二步,交付 AdkEventBridge,把 Flowable<Event> 归一化成 DigitalHumanEvent。
package com.shenzhen.dh.runtime;
import com.google.adk.events.Event;
import com.google.genai.types.Part;
import io.reactivex.rxjava3.core.Flowable;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicLong;
public final class AdkEventBridge {
public enum Type { TEXT_DELTA, TOOL_CALL, TOOL_RESULT, STATE_CHANGE, ERROR, DONE }
public record DigitalHumanEvent(String projectId, String sessionId, String invocationId,
Type type, long seq, String text, Map<String, Object> payload) {
}
private final AtomicLong seq = new AtomicLong();
public Flowable<DigitalHumanEvent> bridge(String projectId, String sessionId, Flowable<Event> source) {
return source.concatMapIterable(event -> translate(projectId, sessionId, event));
}
private List<DigitalHumanEvent> translate(String projectId, String sessionId, Event event) {
String invocationId = event.invocationId();
List<DigitalHumanEvent> out = new ArrayList<>();
if (event.errorCode() != null) {
out.add(emit(projectId, sessionId, invocationId, Type.ERROR,
event.errorMessage(), Map.of("code", event.errorCode())));
}
if (!event.actions().stateDelta().isEmpty()) {
out.add(emit(projectId, sessionId, invocationId, Type.STATE_CHANGE,
null, Map.copyOf(event.actions().stateDelta())));
}
event.content().ifPresent(content -> content.parts().orElse(List.of()).forEach((Part part) -> {
part.functionCall().ifPresent(call ->
out.add(emit(projectId, sessionId, invocationId, Type.TOOL_CALL,
call.name(), Map.of("args", String.valueOf(call.args())))));
part.functionResponse().ifPresent(resp ->
out.add(emit(projectId, sessionId, invocationId, Type.TOOL_RESULT,
resp.name(), Map.of("response", String.valueOf(resp.response())))));
part.text().ifPresent(text -> {
if (!text.isEmpty()) {
out.add(emit(projectId, sessionId, invocationId, Type.TEXT_DELTA,
text, Map.of("partial", String.valueOf(event.partial()))));
}
});
}));
if (event.turnComplete() != null && event.turnComplete()) {
out.add(emit(projectId, sessionId, invocationId, Type.DONE, null, Map.of()));
}
return out;
}
private DigitalHumanEvent emit(String projectId, String sessionId, String invocationId,
Type type, String text, Map<String, Object> payload) {
return new DigitalHumanEvent(projectId, sessionId, invocationId, type, seq.incrementAndGet(), text, payload);
}
}
Event.content()、Event.partial()、Event.turnComplete()、EventActions.stateDelta() 与 Part 上各方法的返回形态(是否为 Optional、是否可能为空)需按当前官方文档核验。上面的写法是防御式的,真实的 Optional 嵌套形态以 1.11.0 为准。
第三步,Controller 退化成协议转换层,只负责把事件流转成同步 DTO 或 SSE。
package com.shenzhen.dh.web;
import com.shenzhen.dh.runtime.AdkEventBridge;
import com.shenzhen.dh.runtime.DigitalHumanRunnerRegistry;
import java.util.List;
import java.util.stream.Collectors;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
@RestController
public class DigitalHumanController {
private final DigitalHumanRunnerRegistry registry;
private final AdkEventBridge bridge;
private final TraceStore traceStore;
public DigitalHumanController(DigitalHumanRunnerRegistry registry,
AdkEventBridge bridge,
TraceStore traceStore) {
this.registry = registry;
this.bridge = bridge;
this.traceStore = traceStore;
}
@PostMapping("/api/dh/{projectId}/chat")
public ChatResponse chat(@PathVariable String projectId,
@RequestParam String configVersion,
@RequestBody ChatRequest request) {
var runtime = registry.acquire(projectId, configVersion);
List<AdkEventBridge.DigitalHumanEvent> trace = bridge
.bridge(projectId, request.sessionId(),
runtime.runner().runAsync(request.userId(), request.sessionId(), request.content()))
.toList()
.blockingGet();
traceStore.save(trace);
String answer = trace.stream()
.filter(e -> e.type() == AdkEventBridge.Type.TEXT_DELTA)
.map(AdkEventBridge.DigitalHumanEvent::text)
.collect(Collectors.joining());
return new ChatResponse(answer, trace.get(0).invocationId());
}
@GetMapping(value = "/api/dh/{projectId}/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flowable<ServerSentEvent<String>> stream(@PathVariable String projectId,
@RequestParam String configVersion,
@RequestParam String userId,
@RequestParam String sessionId,
@RequestBody ChatRequest request) {
var runtime = registry.acquire(projectId, configVersion);
return bridge.bridge(projectId, sessionId,
runtime.runner().runAsync(userId, sessionId, request.content()))
.map(e -> ServerSentEvent.<String>builder()
.event(e.type().name().toLowerCase())
.data(toJson(e))
.build());
}
}
第四步,构造并启动。runAsync 返回的是冷流,注意只订阅一次。
mvn -q -DskipTests package
java -jar target/dh-runtime.jar
第五步,发一个同步请求。
curl -s -X POST 'http://localhost:8080/api/dh/it-ticket/chat?configVersion=v3' -H 'Content-Type: application/json' -d '{"userId":"u-1001","sessionId":"s-2001","content":{"parts":[{"text":"帮我查一下工单 88123 的状态"}]}}'
未在当前环境实测,以下为预期结果。同步接口返回的 answer 应与 SSE 拼出来的文本完全一致,并且 invocationId 相同。
{
"answer": "工单 88123 当前状态为处理中,负责人是张工。",
"invocationId": "inv-7f3a"
}
第六步,验证轨迹。同一次执行落盘后应能按 invocationId 还原出事件序列,形态如下,仅为结构示例。
[
{"seq": 1, "type": "TOOL_CALL", "text": "query_ticket", "payload": {"args": "{ticketId=88123}"}},
{"seq": 2, "type": "TOOL_RESULT", "text": "query_ticket", "payload": {"response": "{status=处理中}"}},
{"seq": 3, "type": "STATE_CHANGE", "text": null, "payload": {"last_ticket": "88123"}},
{"seq": 4, "type": "TEXT_DELTA", "text": "工单 88123 当前状态为处理中"},
{"seq": 5, "type": "TEXT_DELTA", "text": ",负责人是张工。"},
{"seq": 6, "type": "DONE", "text": null}
]
V1 到这里跑通了,但留下了三个问题:Registry 只增不减,配置版本更新后旧 Runner 不会释放;EventBridge 的顺序号是全局的,跨会话回放需要按 invocationId 重置;轨迹存储只有接口没有实现。这三个问题留给第 06、07 章。
一注册管项目
一桥接分是非
先跑通最小径
再议增量归
06、排查
诊断链一:两个项目人格串味。
现象:HR 项目的会话返回了 IT 项目的口吻。
怀疑:Agent 是单例,被两个项目共用。
检查:在 Registry 的 acquire 里打印 runtime 的 identityHashCode,并打印 Runner 内部的 SessionService 实例标识。
证据:projectId 为 it-ticket 与 hr-policy 的两次 acquire,拿到的 Runner 引用 hash 相同;两个项目的 appName 都取自 agent.name(),也就是同一个名字。
根因:Agent 是 @Bean 单例,InMemoryRunner 又按同一个 appName 建 Session 命名空间,两个项目的 session 落在同一片空间里,state 互相可见。
修复:Registry 的 key 用 projectId 加 configVersion,并且把它同时作为 Runner 的 appName 传入,让命名空间随配置版本隔离。
错误尝试: 有人提议把 appName 改成每次随机 UUID。这个尝试是错的,因为随机 appName 确实隔离了命名空间,但同时把同一个用户的历史会话也隔离掉了——每发一次请求,Session 都是新的,多轮对话直接失效。它解决的是「撞车」,代价是「失忆」,而真正的根因是 key 粒度不对,不是 appName 不够随机。
诊断链二:SSE 只吐一段,打字机效果靠切片。
现象:stream 接口每次只收到一个 text 事件,整段一次性到达。
怀疑:EventBridge 把 partial 事件过滤掉了。
检查:在 bridge 之前加一层 doOnNext,直接打印原始 Event 的 partial 与 turnComplete 字段,并统计每轮事件条数。
证据:一轮请求总共只产生 2 条事件,文本事件一条,收尾一条,且文本事件不带 partial 标记。
根因:底层模型走的是非流式路径,ADK 在非流式模式下只在最后产出完整 Event,partial 增量本身就不存在。桥接层没错,错在我们默认了「一定有增量」。
修复:桥接层不再假设 partial 存在,而是用 turnComplete 判定一轮收尾;当一轮只有一个文本事件时,SSE 端按原样输出完整文本,前端不再依赖切片模拟。是否启用流式,需按当前官方文档核验模型侧的流式开关与 Runner 的 runLive 路径。
这两条链有一个共同点:证据都来自运行时对象本身,不是来自日志里的最终字符串。这正是穿上铠甲之后才有的能力。
现象先入眼
证据后定根
错试须记下
免得后人昏
07、优化
V2 只做三件事,每一件都对应第 06 章的一条证据。
第一件,Registry 增加配置版本热替换与引用计数。根因是「key 粒度不对导致命名空间重叠」,那么修改就是把生命周期显式化:新版本注册时,旧版本标记为 draining,等引用计数归零再释放。原因是把隔离从「碰巧不冲突」变成「结构上不可能冲突」。新行为是同一个 projectId 可以并存 v3 与 v4 两个 Runner,灰度时按 configVersion 分流。验证方式是注册两个版本后分别 acquire,断言两个 Runner 引用不同、两个 Session 空间互不可见。
public ProjectRuntime acquire(String projectId, String configVersion) {
ProjectRuntime runtime = runtimes.get(key(projectId, configVersion));
if (runtime == null) {
throw new IllegalStateException("runtime not found: " + projectId + "@" + configVersion);
}
runtime.retain();
return runtime;
}
public void drain(String projectId, String configVersion) {
ProjectRuntime runtime = runtimes.get(key(projectId, configVersion));
if (runtime != null && runtime.refCount() == 0) {
runtimes.remove(key(projectId, configVersion));
}
}
ProjectRuntime 从 record 改成带原子计数器的普通类即可,这里不展开。
第二件,EventBridge 的事件序号按 invocationId 分段。根因是「全局序号在跨会话回放时无意义」,修改是把 seq 从全局 AtomicLong 改成按 invocationId 维护的计数器,原因是回放需要「这一次执行内的第几件事」,而不是「进程启动以来的第几件事」。新行为是同一次执行的事件序号从 1 开始连续递增,跨会话不互相污染。验证方式是对两次不同 invocationId 的桥接结果做断言,两条序列都从 1 开始。
第三件,轨迹存储落地。根因是「同步与 SSE 各自拼装导致无法回放」,修改是让两条路径都调用同一个 TraceStore.save,原因是单一事实来源。新行为是任意 invocationId 都能查到完整事件序列。验证方式是发一次同步请求再查一次轨迹,断言事件条数与类型集合与实时流一致。
需要说明的是,上面三条验证都是断言级验证,不是性能数据。我们没有测过 QPS、延迟或成本变化,本篇也不编造这些数字。
根因既已明
改法自不疑
版本随配置
轨迹落成籍
08、演进
[同一输入]
|
+--[V1] Controller 直调模型 -> 返回最终字符串 / 代价:无中间态、无隔离、无回放
|
+--[V2] Runner 驱动 + Event 桥接 -> 事件流 / 代价:多一层映射与生命周期管理
|
[Trade-off]
得到:同步与 SSE 同源、按项目与版本隔离、执行可回放
失去:单点简单性,进程内多份 Session 存储,桥接层跟随官方 Event 演进
边界:单项目原型可直接用 InMemoryRunner;多项目多端多版本才需要 Registry
正确性上,V1 只能保证「答案对」,V2 保证「过程对」,后者可以推出前者,反之不成立。稳定性上,V1 的故障是静默的(人格串味、假流式),V2 把故障变成可观测事件,ERROR 与 DONE 有明确落点。复杂度上,V2 多了一个 Registry、一个 Bridge、一个 TraceStore,类数量上升,但这三个类各自职责单一,比原来一个 Controller 承担六件事更容易测试。成本上,V2 增加了进程内 Session 驻留,多项目多版本时内存增长明显,这是要监控的指标。适用范围上,V2 适合需要多端接入、需要审计回放的企业场景。
遗留问题有三个:第一,InMemoryRunner 只适合开发与单机验证,生产必须切换到持久化的 SessionService、MemoryService 与 ArtifactService 实现;第二,runLive 的实时通道本篇没有展开,双向实时交互中事件语义会更复杂,需按当前官方文档核验;第三,事件结构的每一次官方演进都会传导到 Bridge,需要回归用例守住。
同源分两路
各价各自担
得者明亦失
边界即答案
09、洞见
9.1 Controller 的职责上限是协议转换
Controller 可以决定「这次请求用哪个项目的哪个配置版本」,但不应该决定「Agent 怎么建、Session 怎么开、历史怎么拼」。前一件事属于业务装配,后一件事属于运行时。把后一件事放进 Controller,等于把运行时的知识散落在每个入口里,多一个入口就多一份重复实现。判断标准很简单:如果 Controller 里出现了 sessionId 的生成逻辑或历史消息的拼接逻辑,就说明越界了。
9.2 反直觉判断:Event 不是给前端看的消息
很多人第一反应是把 Event 当成流式响应的一段文本,于是桥接层写成「取 content 里的 text 拼起来」。这个理解在只有文本的场景下能跑通,一旦接入工具调用就立刻崩塌:functionCall 事件没有可展示文本,stateDelta 事件也没有,但它们恰恰是排查问题时最有价值的证据。反直觉判断:Event 是 Runtime 的事实流,UI 只是它的一个消费者。把 Event 当消息,你会为了 UI 方便而裁剪事实;把 Event 当事实,UI 反而可以自由取舍。
9.3 反直觉判断:Runner 的实例粒度应该等于配置版本,而不是项目
直觉上,一个项目一个 Runner 就够了。但配置是要灰度的:人格提示词要改,工具集合要改,模型参数要调。如果 Runner 粒度是项目,那么配置更新就只能原地替换,正在跑的会话会被新配置污染。反直觉判断:Runner 的粒度应该下沉到 projectId 加 configVersion,用多一份内存换一次可回滚的灰度。这条判断直接来自第 06 章那条串味故障——故障的本质不是并发,是粒度。
9.4 生命周期与状态必须分开管
Registry 管的是 Runner 的创建、复用与释放,这属于生命周期;SessionService 管的是会话与 state,这属于状态。两者混在一起时,你会看到「配置更新要重启服务」这类现象。分开之后,热替换只动生命周期层,状态层不动,正在进行的会话可以平滑过渡。工程判断是:任何「既要管对象又要管数据」的组件,都要先问一句这两件事是否能拆开。
舍近方见远
反直觉成理
边界非束缚
是力之根基
10、系统落地
原来有什么:一个 Controller,一个单例 Agent,一句最终答案,日志里只有 response。
本篇新增了什么:一个 DigitalHumanRunnerRegistry,按 projectId 加 configVersion 管理 Runner 生命周期与配置版本;一个 AdkEventBridge,把 Flowable<Event> 归一化成 TEXT_DELTA、TOOL_CALL、TOOL_RESULT、STATE_CHANGE、ERROR、DONE 六类事件;一个 TraceStore 接口,把同步与 SSE 两条路径统一到同一份事件流上。
现在能做什么:同一个数字人在同步接口和 SSE 接口下输出同一份事实;两个项目的人格与状态互不可见;任意一次执行都能按 invocationId 回放事件序列,工具是否被调用、参数是什么、状态改了什么,全部有据可查。
还缺什么:第一,Runner 的实例治理只有内存实现,没有持久化的 Session 与 Memory,重启即失忆;第二,工具调用的权限与配额还没有统一收口,每个工具自己保护自己;第三,确定性流程与人工介入还没有,危险动作无法暂停后原地续跑;第四,实时双向通道 runLive 未展开。
下一步如何演进:先把 InMemoryRunner 背后的三个 Service 换成持久化实现,让数字人真正「展翼」;再往上加一层统一的 Runtime 治理,把工具保护从「各自为政」升级成「统一收口」;然后才是确定性流程与暂停恢复。铠甲穿好了,下一步是让它飞起来。
旧器仍可用
新甲已成层
缺者留待补
一步一登临
11、小结
Q1 → 一次执行到底发生了什么?
由 Runner 产出的 Event 流回答,TEXT_DELTA / TOOL_CALL / TOOL_RESULT / STATE_CHANGE / ERROR / DONE 六类事件按 invocationId 可还原
Q2 → 同一个 Agent 被多项目共用会不会串味?
会,根因是 appName 与 key 粒度不对;改为 projectId 加 configVersion 后结构上不可能串
Q3 → 靠最终文本反推过程行不行?
不行,最终文本是结果不是事实;同步与 SSE 必须同源派生于同一份事件流
状态 → 数字人穿上 Runtime 铠甲,Controller 退化为协议转换层,执行过程持续外放且可回放
这一境交付的是一段可以被测试断言的能力,不是一个可以被演示的效果。判断标准第 1、2、3 条都在第 07 章的断言里落了地。
三问皆有答
一事已成章
铠甲既披身
可战下一场
12、作业
12.1 理解题:为什么 Runner 不应该被当成模型调用封装?
参考答案:因为 Runner 的核心职责是会话与编排,它组合 Agent、SessionService、MemoryService、ArtifactService,决定一次调用从哪个会话开始、经过哪些事件、什么时候 turn 结束。模型调用只是这条链路中的一个环节。如果把它当模型客户端用,Controller 就会继续承担 Session 生命周期与事件分发,架构退回到 V1。
12.2 实战题:给 AdkEventBridge 增加按 invocationId 分段的事件序号。
参考答案:把全局 AtomicLong 换成 ConcurrentHashMap 加 computeIfAbsent,以 invocationId 为键维护计数器;每次 translate 进入时取当前计数器,DONE 事件产生后移除该键,避免长期驻留。验证方式是对两次不同 invocationId 的桥接结果分别断言序号从 1 开始连续递增,且互不影响。
12.3 排障题:SSE 接口返回 200 但前端一直白屏,最终整段刷出,如何定位?
参考答案:第一,先确认后端是否真的产生了多条文本事件,在 bridge 之前打印原始 Event 的 partial 与 turnComplete 字段,统计每轮事件条数;第二,若一轮只有一条文本事件,说明模型走的是非流式路径,问题不在桥接层;第三,确认 Controller 返回的 Flowable<Event> 是否被重复订阅,冷流重复订阅会导致执行两次甚至挂住;第四,检查响应头是否为 text/event-stream 且未被代理缓冲。根因通常落在「非流式模型」或「代理缓冲」两处,需按当前官方文档核验模型侧流式开关。
12.4 架构判断题:Runner 的实例粒度应该按项目、按会话还是按配置版本?
参考答案:按 projectId 加 configVersion。按项目会导致配置更新原地污染正在跑的会话;按会话会导致每个会话都新建一份运行时依赖,Session 存储与内存开销失控,且配置灰度无法收敛。按配置版本既保证了命名空间隔离,又让灰度与回滚成为可能,代价是每个并存版本多占一份 Session 驻留。
理解须明理
实战要亲为
排障练证据
判断定取舍
13、思考
本篇的冲突从一开始就不是「怎么让数字人说得更好」,而是「怎么让它的执行过程被看见」。同一个 Controller,可以返回一句漂亮的答案,也可以把整段运行时遮得严严实实。
可复用的工程判断有三条。第一,凡是「只能拿到最终结果」的封装,都要警惕它是不是把过程藏起来了;结果可以被推断,过程只能被记录。第二,隔离问题的根因往往不在并发,而在粒度,粒度对了,冲突就不存在。第三,耦合不是靠拆类解决的,是靠划边界解决的——Controller 只做协议转换,Runner 只做编排,Agent 只做推理,边界清楚之后,类多几个反而不乱。
铠甲的意义不在于好看,而在于它把力量收束到该去的地方,同时把力量的余波外放出来,让人看得见。
一念辨边界
一证定根因
一改知取舍
一役见真章

浙公网安备 33010602011771号