斗破Google ADK Java苍穹-第11境斗帝-血脉传承-Production生产化

斗帝境:从 Demo 到可复制的数字人生产交付模板

Demo 能演示,是因为只有一台机器、一个进程、一次对话。

[读者] 已经把 ADK Java 数字人跑到能演示,准备把它交付给第二个项目的工程师
[痛点] 只能在一台机器上起 AdkWebServer,多实例、重启、滚动升级全都丢状态
[现在读] 因为要交出去的是模板而不是 Demo,必须先把边界、状态和部署路径一次定死
[读完] 能拿到一套可配置、可执行、可恢复、可治理、可观测、可复制的 Runtime 交付包
[旧方案] 一个进程里塞着 AdkWebServer + LlmAgent + 内存 Session
    |
    v
[新需求] 交付给第二个数字人项目复用,要能多实例、能滚动升级、能外部配置
    |
    v
[冲突] 开发脚手架把 UI、Runtime、状态、配置焊在一个进程里,换项目就得复制粘贴
    |
    v
[后果] 每接一个新项目就重写一遍壳,线上重启一次丢一次上下文

我是老李,带一个不大的 AI 工程小组。前面十境我们把数字人一路练到了能实时对话、能跑评测、能借 MCP 和 A2A 出门借力的程度,演示效果确实好。

但演示结束后的第二周,隔壁项目组的负责人找过来,说他们的客服数字人也要一套,问我们能不能“照着做一个”。这句话把我们从修炼状态直接拽回了工程现场。

因为我很清楚:现在这套东西,只有我知道怎么启动它。它跑在我的笔记本上,跑在一条命令里,跑的时候状态全在内存。

如果要交出去,第一个问题不是模型选谁,而是运行它的那个壳由谁负责?

第二个问题是,当它重启、扩容、滚动升级的时候,用户上一轮说的话还算不算数?

第三个问题是,如果这两个问题现在不解决,第二个项目多久会变成第二份复制粘贴的代码?

01、故事

现场是这样的:一个 Spring Boot 进程,里面嵌着 ADK 的 dev Web UI,同一个进程里装着 LlmAgent、内存态 Session、几个 Tool,还有一个只写了日志的评测入口。

任务是把这个数字人交付成一个别人能直接跑起来的产品模板,而不是交付一份“你先 clone 我这个仓库,然后我远程教你配环境”的说明。

第一个演示项目里,这个方案没有任何问题。单机、单实例、一个用户、一次对话,所有状态都在堆内存里,快得让人误以为生产化只是加个 Dockerfile。

变化出现在交付对象变了:从“一个我们自己维护的项目”变成“一个要被复制到 N 个项目里的模板”。复制意味着两件事,一是配置必须能从外部注入,二是状态不能跟某个具体进程绑定。

而 AdkWebServer 的定位是开发期脚手架,它把 UI、Agent 装配、会话管理、调试入口全放在一个进程里,这在单机演示时是优点,在多项目复制时就成了硬伤。

我们试过最朴素的办法:把仓库打包成 zip 发过去,改改包名。结果是第二个项目上线第三天,因为一次普通的滚动重启,用户的历史上下文全部消失,客服对话从“你刚才说的订单号”变成了“请问您要咨询什么问题”。

那一刻我确认了一件事:这不是配置问题,是架构问题。

演示靠一台机
交付靠一条界
状态若不离身
复制必成重写

02、问题

旧方案失效的地方很具体:把开发脚手架当成运行宿主。

业务上的影响有三层。第一层是交付成本,每接一个新项目就要重走一遍环境配置,人力被重复消耗。第二层是可用性,进程重启、实例扩缩容、版本发布都会打断正在进行的对话。第三层是治理,谁改了哪版 Prompt、哪版工具、哪版模型,没有任何地方能查。

技术上的表现同样具体:

一是会话状态在 JVM 堆里,多实例部署时请求落到不同实例就会读不到历史;

二是 Agent 与配置在代码里硬编码,换一个项目就要改源码重新编译;

三是没有区分健康探针,进程活着不代表它准备好接流量;

四是容器默认以 root 运行,也没有内存上限,一次大模型长响应就可能触发 OOM 把整个实例打死;

五是流式接口一旦断线,客户端不知道断在哪,重连之后又没有幂等去重,容易出现重复调用与重复计费。

所以本境的完成标准不是“能跑”,而是一组可验证的条件:同一个数字人项目里,运行时可以外部配置,可以执行一次完整调用,重启后能恢复会话,能对外暴露管理与治理入口,能观测每一次执行,能支撑实时流式交互,并且整套结构可以被第二个项目原样复制。

注意,最后一条“可复制”必须能通过测试结果证明,而不是靠文档描述。

状态若在堆里
副本就是孤岛
配置若在码里
复制就是重写

03、原理

生产化最容易搞错的一点是:我们以为瓶颈在 Agent,其实瓶颈在 Agent 外面的那层壳。

反直觉判断:Agent 本身在 ADK Java 里几乎是“无状态”的,真正让系统无法恢复、无法复制的,是我们写在外围的那几千行胶水代码。

LlmAgent 描述的是“怎么思考”,Runner 描述的是“怎么执行一次”,Session 描述的是“执行之间留下了什么”。这三件事的职责是分开的,可一旦我们把它们全塞进一个 Controller,或者干脆交给开发脚手架托管,它们就又粘回一起了。

于是本境的核心动作就是划线,划成三条职责清晰的边界:

第一条是 Management API,管的是“版本与配置”。它回答:当前生效的是哪一版 Agent 配置、哪个模型、哪些工具、谁在什么时间发布的。它不做推理,只做版本管理。

第二条是 Agent Runtime,管的是“执行与状态”。它回答:这次调用由哪个 Runner 执行、属于哪个用户和会话、产生了哪些 Event、结束之后状态写到了哪里。

第三条是 Realtime Bridge,管的是“长连接”。它回答:流式事件怎么推给前端、连接断了怎么恢复、心跳怎么保活。它对 Agent 无感,只对 Event 流负责。

另一条原理是状态归属。一个系统能不能“可恢复”,不取决于它重试了几次,而取决于它的状态存在哪个生命周期里。状态活一次请求,系统就只能活一次请求;状态活一次进程,系统就活一次进程;只有状态活过进程,系统才谈得上恢复。

第三条原理是配置版本化。RunnerRegistry 的意义不是“缓存一个 Runner 对象”,而是让“当前生效的 Agent 配置”变成一个带版本号、可原子替换、可回滚的对象。没有版本号的热更新,本质上是在线上做无痕改动。

壳厚才敢上线
状态须在进程外
配置要能版本化
边界先划后合

04、架构

[输入] HTTP /api/v1/runtime/invoke 或 /api/v1/management/config
    |
    v
[模块] Management API ── Agent Runtime(RunnerRegistry + Runner) ── Realtime Bridge
    |
    v
[数据/状态] ConfigVersion / SessionStore / ArtifactStore
    |
    v
[处理] 校验身份 → 取当前版本 Runner → 执行一次 Invocation → 产出 Event
    |
    v
[输出] SSE 事件流给前端,Session 状态写入持久化存储,指标写入 Observability

边界说明。Management API 只接受配置类请求,它不能直接调用模型,也不能持有会话;Agent Runtime 只接受执行类请求,它不知道 HTTP 长连接的存在,只输出 Event 流;Realtime Bridge 只做协议转换与连接保活,它不解析业务语义。

收益很直接。第一,第一版可以把三条边界放在同一个 Spring Boot 进程里,部署成本低,但代码依赖方向是单向的,Runtime 不依赖 Web 层,明天要拆分随时能拆。第二,状态外置之后,多实例部署从“危险操作”变成“正常操作”。第三,配置版本化之后,发布与回滚变成了一个接口调用。

代价同样明确。多了一层 RunnerRegistry,就有了一层间接性,调试时你需要多看一个版本号;状态外置引入了网络往返,单次调用的延迟会略高于纯内存方案;引入 Management 接口意味着配置本身也要做鉴权和审计,否则它就是一个任意改 Prompt 的后门。

适用条件也要说清楚。如果这个数字人只在一个项目里、只有一个实例、允许随时重启,那么这套边界属于过度设计;一旦出现“第二个项目要复用”或者“线上不能丢上下文”这两个条件中的任意一个,这套边界就是必需的。

管理面收配置
运行时管执行
桥接面守长连
三面各司其职

05、实战一次

环境与版本基线:JDK 21,Maven 3.9+,Google ADK Java 1.11.0,Spring Boot 3.x(WebFlux 承载 SSE)。ADK 的 Maven 坐标请以官方 README 与 Maven Central 的当前发布为准(需按当前官方文档核验)。

依赖。

<properties>
  <java.version>21</java.version>
  <adk.version>1.11.0</adk.version>
</properties>

<dependencies>
  <dependency>
    <groupId>com.google.adk</groupId>
    <artifactId>google-adk</artifactId>
    <version>${adk.version}</version>
  </dependency>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-webflux</artifactId>
  </dependency>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-actuator</artifactId>
  </dependency>
</dependencies>

配置。生产版本里所有可变量都不写在代码里。

server:
  port: 8080
  shutdown: graceful
spring:
  lifecycle:
    timeout-per-shutdown-phase: 30s
management:
  endpoint:
    health:
      probes:
        enabled: true
  endpoints:
    web:
      exposure:
        include: health,info,metrics
digitalhuman:
  app-name: digital-human-template
  config-version: v1
  runtime:
    sse-heartbeat-seconds: 15

核心实现一:Agent 装配。这里把模型名、指令、工具集全部走配置,而不是硬编码。

@Configuration
public class DigitalHumanConfig {

  @Bean
  public BaseAgent digitalHumanAgent(DigitalHumanProperties props,
                                     DigitalHumanToolset toolset) {
    return LlmAgent.builder()
        .name(props.getAppName())
        .description("项目数字人")
        .model(props.getModel())
        .instruction(props.getInstruction())
        .tools(toolset.tools())
        .build();
  }
}

核心实现二:RunnerRegistry,用配置版本号 + 原子替换,把“当前生效的 Runner”变成一个可观测对象。Runner 的构造方式在不同版本间可能有差异,若当前版本未提供 builder,请按官方 API 等价构造(需按当前官方文档核验)。

@Component
public class RunnerRegistry {

  public record Holder(String configVersion, Runner runner, Instant loadedAt) {}

  private final AtomicReference<Holder> current = new AtomicReference<>();
  private final SessionService sessionService;
  private final ArtifactService artifactService;
  private final DigitalHumanProperties props;

  public RunnerRegistry(SessionService sessionService,
                        ArtifactService artifactService,
                        DigitalHumanProperties props) {
    this.sessionService = sessionService;
    this.artifactService = artifactService;
    this.props = props;
  }

  public void load(BaseAgent agent, String configVersion) {
    Runner runner = Runner.builder()
        .agent(agent)
        .appName(props.getAppName())
        .sessionService(sessionService)
        .artifactService(artifactService)
        .build();
    current.set(new Holder(configVersion, runner, Instant.now()));
  }

  public Holder current() {
    Holder holder = current.get();
    if (holder == null) {
      throw new IllegalStateException("runner not loaded");
    }
    return holder;
  }
}

核心实现三:Runtime 入口,一次调用只做三件事——取当前版本 Runner、执行一次 Invocation、把 Event 转成 SSE。

@RestController
@RequestMapping("/api/v1/runtime")
public class RuntimeController {

  private final RunnerRegistry registry;

  public RuntimeController(RunnerRegistry registry) {
    this.registry = registry;
  }

  @PostMapping(value = "/invoke", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
  public Flux<ServerSentEvent<String>> invoke(@RequestBody InvokeRequest req) {
    RunnerRegistry.Holder holder = registry.current();
    Content message = Content.fromParts(Part.fromText(req.message()));
    return Flux.from(holder.runner()
            .runAsync(req.userId(), req.sessionId(), message))
        .map(event -> ServerSentEvent.builder(render(event))
            .event("agent")
            .build())
        .concatWith(Flux.just(ServerSentEvent.builder("[DONE]")
            .event("done")
            .build()));
  }

  private String render(Event event) {
    return event.content()
        .map(content -> content.parts().orElse(List.of()).stream()
            .map(part -> part.text().orElse(""))
            .collect(Collectors.joining()))
        .orElse("");
  }
}

核心实现四:Management 入口,发布一个配置版本。它只改版本号,不碰会话。

@RestController
@RequestMapping("/api/v1/management")
public class ManagementController {

  private final RunnerRegistry registry;
  private final AgentFactory agentFactory;

  public ManagementController(RunnerRegistry registry, AgentFactory agentFactory) {
    this.registry = registry;
    this.agentFactory = agentFactory;
  }

  @PostMapping("/config/{version}/publish")
  public ResponseEntity<Map<String, String>> publish(@PathVariable String version) {
    registry.load(agentFactory.build(version), version);
    return ResponseEntity.ok(Map.of("configVersion", version));
  }
}

启动。

mvn -q clean package -DskipTests
java -jar target/digital-human-runtime.jar

请求与验证。以下内容未在当前环境实测,以下为预期结果;返回内容依赖模型与工具,实际输出会不同。

curl -s localhost:8080/actuator/health/readiness
# 示例输出:{"status":"UP"}

curl -N -X POST localhost:8080/api/v1/runtime/invoke \
  -H 'Content-Type: application/json' \
  -d '{"userId":"u-1001","sessionId":"s-2001","message":"帮我查一下订单 A123 的物流"}'
# 示例输出:
# event: agent
# data: 正在为你查询订单 A123 的物流
#
# event: done
# data: [DONE]

再发一次同样的 userId/sessionId,问“刚才那个订单到哪了”,如果第二轮能接上上一轮的订单号,说明会话确实续上了,而不是靠模型自己猜。这一步是后面所有恢复能力的地基。

依赖先钉版本
注册必留版本号
请求带全身份
事件一路外放

06、排查

诊断链一:会话在多实例下“失忆”。

现象:本地单机跑得好好的数字人,部署到 Cloud Run 多实例之后,同一个用户第二轮提问时上下文丢失,日志里偶尔出现找不到会话的记录。

怀疑:前端没有正确回传 sessionId。

检查:在 Runtime 入口打印 userId、sessionId、实例 ID 与配置版本;同时对比两次请求命中的实例。

证据:两次请求的 sessionId 完全相同,但实例 ID 不同;第一轮创建的会话只存在于实例 A 的进程内存中。

根因:会话状态归属在 JVM 堆里,属于进程级生命周期。进程死了,状态就死了;换一个进程,状态就找不到了。

修复:把 SessionService 换成持久化实现,让 Session 的生命周期脱离进程。

错误尝试: 第一反应是去配会话亲和性,让同一个用户的请求固定落到同一个实例。这个做法错在两点:一是它把正确性交给了负载均衡的调度策略,而调度策略不是契约;二是就算亲和性生效,滚动升级时旧实例会被销毁,状态照样一起消失。用调度解决状态归属问题,方向本身就是错的。

诊断链二:长连接静默断开,用户重复提问。

现象:流式对话在几分钟后无响应,客户端没有收到结束事件,用户以为卡住了就再发一次,于是同一句话被问了两次。

怀疑:模型响应太慢。

检查:统计连接存活时长与模型首字延迟,发现连接断开的时刻与模型耗时无关,却集中在一个固定时长附近;同时查看网关与运行时的超时配置。

证据:连接存活时长与配置的空闲超时高度吻合,且断开前后没有任何业务错误日志。

根因:长连接路径上没有心跳,中间层把它当成空闲连接回收了;同时服务端没有把“本次调用的唯一标识”暴露给客户端,导致重连只能靠用户手动重发。

修复:在 SSE 流上按固定间隔发送心跳事件,客户端基于同一个 invocation 标识做重连与去重,同时把运行时的请求超时调整为与业务预期一致的值。

现象不问玄学
证据只看日志
根因落在归属
修在状态外置

07、优化

针对诊断链一的根因——状态归属在进程内——V2 做三件事。

第一件,注入持久化 SessionService。Runtime 代码不变,只改装配,会话写入外部存储,实例扩容、重启、滚动升级都不再影响上下文。

第二件,RunnerRegistry 增加版本语义与平滑替换。发布新版本时不销毁旧 Runner,而是让新请求走新版本,旧版本的 in-flight 调用继续跑完再释放。这样“热更新”才不是一次线上的无痕改动。

第三件,优雅停机。Spring Boot 开启 graceful shutdown 之后,进程在收到终止信号时会先停止接受新请求,把在途请求跑完。这一步是滚动升级不丢请求的前提。

server:
  shutdown: graceful
spring:
  lifecycle:
    timeout-per-shutdown-phase: 30s

针对诊断链二的根因——长连接无保活、无幂等——V2 在 SSE 上加心跳,并给每次调用分配一个可回传的标识。客户端断线后用它续接,服务端用它判断幂等。

Flux<ServerSentEvent<String>> heartbeat =
    Flux.interval(Duration.ofSeconds(props.getSseHeartbeatSeconds()))
        .map(tick -> ServerSentEvent.builder("ping").event("heartbeat").build());

容器侧同步收敛。非 root 运行、显式资源上限、退出时对 OOM 直接失败而不是硬撑。

FROM eclipse-temurin:21-jre
RUN useradd --system --uid 10001 --create-home appuser
WORKDIR /app
COPY --chown=appuser:appuser target/digital-human-runtime.jar app.jar
USER 10001
EXPOSE 8080
ENV JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=75 -XX:+ExitOnOutOfMemoryError"
ENTRYPOINT ["java","-jar","/app/app.jar"]

探针分离生死。liveness 回答“进程要不要重启”,readiness 回答“能不能接流量”。RunnerRegistry 尚未加载配置时,readiness 必须是 DOWN,否则流量会在数字人还没“醒来”的时候打进来。

验证方式:本地起两个实例并共享同一份会话存储,用同一个 sessionId 交替打两个端口,确认第二轮能接上第一轮;再对其中一个实例发送终止信号,确认在途请求正常返回、新请求被拒绝。

会话落库可续
注册原子换版
停机先排水
探针分离生死

08、演进

[同一输入] {"userId":"u-1001","sessionId":"s-2001","message":"刚才那个订单到哪了"}
    |
    +--[V1] 单进程 AdkWebServer + 内存 Session / 快,但重启即失忆、无法多实例
    |
    +--[V2] 自建 Runtime + 持久化 Session + 版本化 Runner / 可恢复可复制,但多了一跳 I/O 与一层间接
    |
[Trade-off]
得到:可恢复、可扩容、可热更、可交付;失去:单机调试的简单、纯内存的极致延迟;边界:单项目单实例且允许随时重启时,V1 反而更划算

正确性上,V1 只在单实例、无重启窗口内成立,V2 把正确性建立在契约上而不是调度上。

稳定性上,V1 的故障面是“进程挂了就全挂”,V2 的故障面转移到了外部存储与网络,需要额外考虑连接池、超时和重试,但故障域变小了。

复杂度上,V1 是一个进程一个类,V2 多了注册表、配置版本、探针和迁移逻辑。这份复杂度不是为了好看,而是为了换取“能被第二个项目复制”。

成本上,V2 多了状态存储的网络往返,单次调用会比纯内存慢一点;但多实例与滚动升级带来的可用性收益,通常远超这点延迟。具体数值需要在你的环境里实测,不要照搬别人的数字。

适用范围上,如果数字人只是内部演示工具,V1 足够;一旦进入对外服务、多项目复用、需要审计与回滚的场景,V2 是起点而不是终点。

遗留问题也要写下来:会话数据的外置存储需要备份与清洗策略;配置版本的审计目前只有一个版本号,还没有“谁改的、改了什么、为什么改”;多实例下的并发写同一会话,需要明确冲突策略。

快是单机的快
稳是分布的稳
得一份取舍
换一身可复制

09、洞见

9.1 生产化的工程量,大头不在模型而在壳

反直觉判断:一个数字人项目里,最难复用、最容易被写死的部分,恰恰是看起来最不“AI”的那部分——配置装配、状态归属、进程生命周期。

模型、Prompt、工具这些是显性的,大家都会认真设计;而 Runner 怎么创建、会话存在哪、停机怎么排水,通常是在演示之后才补的,补的时候已经在生产上跑了。本境里所有的坑,几乎没有一个是模型造成的。

9.2 边界可以先划,进程不必先拆

反直觉判断:同进程不等于没有边界,微服务化也不是生产化的必要条件。

第一版把 Management、Runtime、Realtime 放在同一个 Spring Boot 进程里,完全可行,前提是依赖方向是单向的:Runtime 不认识 HTTP,Realtime 不认识业务,Management 不碰会话。边界是先写在代码里的,不是先拆在网络里的。反过来,如果代码里没有边界,拆成三个服务只会得到三个互相直接调用的服务。

9.3 可恢复性由状态归属决定,不由重试次数决定

我们习惯用重试解决不稳定,但重试只能解决“这次请求失败了”,解决不了“上次请求的成果丢了”。状态如果只活一次进程,重试多少次都换不回上下文。把 Session 从堆里搬到进程外,是这一境里性价比最高的一次改动。

9.4 可复制性来自契约,而不是来自代码

第二个项目要复用的,不是你写的那个 Controller,而是你定义的契约:工具的输入输出规范、Workflow 的步骤模板、验收用的 golden-cases、以及部署清单。代码会分叉,契约不会。这也是最终交付包要按 digital-human-template、runtime-harness、tool 规范、workflow 模板、golden-cases、deployment 分开组织的原因。

大头不在模型
边界先于拆分
可恢复靠归属
可复制靠契约

10、系统落地

原来有什么:一个能在单机演示的数字人,一个跑得通但只在本地有效的流式接口,一堆散落在代码里的配置常量,以及一份只存在于我脑子里的启动步骤。

本篇新增了什么:三条清晰的边界;一个自建的 Spring Boot API 承载 Management 与 Runtime,不再依赖 AdkWebServer 作为运行宿主;一个带配置版本的 RunnerRegistry;一套优雅停机与健康探针;一个非 root 的容器镜像与部署清单。

现在能做什么:可以多实例部署,会话在实例之间是共享的;可以在不重启进程的前提下发布新版本配置,in-flight 请求不受影响;可以在滚动升级时排空在途请求;可以通过探针把未就绪的实例挡在流量之外;可以把这一整套结构按模板交付给第二个数字人项目,并且每一项能力都有对应的验证步骤。

还缺什么:会话存储的备份与保留策略还没定;配置变更的审计只到版本号,没到人;多实例并发写同一会话的冲突策略是空缺的;配额与多租户隔离还没有;评测目前只在本地跑,没有进入发布流程的自动门禁。

下一步如何演进:把 golden-cases 接进发布流水线,让配置版本的发布必须过评测才能生效;把配置变更接入审计;把上游问题闭环纳入日常——从官方仓库的 Issues 或 CHANGELOG 里挑一个与 Java Runtime 相关的问题,按 CONTRIBUTING.md 的流程先开 Issue 描述现象与最小复现,再提交 PR 并附上测试,具体流程以仓库当前 CONTRIBUTING.md 为准。这样做的收益不只是修一个 bug,而是让团队真正读懂 Runtime 的源码路径。

对照本境的验收要求:所有能力都必须在同一个数字人项目中跑通,并且留下测试结果——多实例共享会话的验证、滚动升级不丢请求的验证、探针状态的验证、配置热更新的验证,这些都应该以可重复执行的脚本或测试形式沉淀在交付包里。

原来只会演示
现在能扩能升级
缺的是治理闭环
下一步是流水线

11、小结

Q1 → 运行它的壳由谁负责:由自建的 Spring Boot Management + Runtime 负责,AdkWebServer 退回开发期
Q2 → 重启扩容后用户上轮的话还算不算数:算,因为 Session 已外置到进程之外
Q3 → 第二个项目会不会变成复制粘贴:不会,交付的是 template + harness + 契约 + golden-cases,而不是一份代码
状态 → 交付一个可配置、可执行、可恢复、可治理、可观测、可实时交互、可复制的数字人产品模板

本境结束时,这个数字人第一次不再属于某一台机器。换一台机器、换一个实例、换一个项目,它的身份、状态和配置都还在。

一问定边界
二问定归属
三问定契约
四句成一境

12、作业

12.1 理解题:为什么“能多实例部署”和“能恢复会话”是同一个问题的两面?

参考答案:多实例意味着请求会被调度到任意进程,会话如果归属在单个进程的内存里,那么它在另一个进程上就是不存在的。恢复能力本质上要求状态的生命周期长于进程,而多实例要求状态不依赖于某一个具体进程,两者指向同一个结论:状态必须外置。

12.2 实战题:把 Management、Runtime、Realtime 三条边界在同一进程里划开,并给出可执行的验证。

参考答案:Runtime 层不引入任何 Web 依赖,只暴露“给定身份与输入,返回 Event 流”的方法;Realtime 层只做 Event 到 SSE 的转换与心跳;Management 层只操作配置版本与 RunnerRegistry。验证方式:对 Runtime 层写单元测试,直接调用而不用起 HTTP;对 Realtime 层写一个连接中断重连的测试;对 Management 层写发布新版本后 Runtime 取到新版本、in-flight 请求仍走旧版本的测试。

12.3 排障题:滚动升级期间客户端出现连接中断且偶发重复回答,如何定位与修复?

参考答案:先确认中断时刻是否与实例回收时刻吻合,通过在停机流程里打日志验证在途请求是否被排空;再检查长连接是否有心跳,中间层是否把空闲连接回收;最后检查客户端重连是否带幂等标识。修复方向是开启优雅停机、设置合理的停机等待时长、增加心跳、为每次调用分配唯一标识并在客户端做重连去重。

12.4 架构判断题:为了“可复制”,是否应该把 Runtime 拆成独立微服务?

参考答案:不必然。可复制的核心是契约与状态归属,而不是部署单元的数量。第一版同进程部署、边界清晰、依赖单向,复制成本最低;只有当 Runtime 的扩缩容节奏与 Web 层明显不同,或者需要独立的技术栈与发布周期时,拆分才带来净收益。先拆边界,后拆进程;反过来做,通常得到的是三个耦合的服务和两倍的运维成本。

题题回到现场
答案不靠背诵
动手才知深浅
复现才算掌握

13、思考

本境的核心冲突只有一个:Demo 需要的是一切都在手边,交付需要的是一切都不在手边也能跑。

前者要求耦合,后者要求边界。这场冲突不可能靠“再写详细一点的文档”解决,只能在代码里划线、在状态归属上做决定、在部署路径上把它固定下来。

可复用的工程判断是:当一件事从“我们自己做”变成“别人也要做”的时候,第一件要做的事不是抽象代码,而是明确边界和状态归属。抽象做早了是过度设计,边界划晚了是技术债,而状态放错了生命周期,则无论如何重构都恢复不了。

一念辨边界
一证定根因
一改知取舍
一役见真章

posted @ 2026-10-10 22:21  李福春  阅读(2)  评论(0)    收藏  举报