降SpringAI阿里第16掌-履霜冰至-立派服务
降SpringAI阿里第16掌-履霜冰至-立派服务
跑得通的 Demo 不是服务,边界才是交付物
[读者] 已把 Agent/Graph/RAG 跑通,代码却还堆在 Demo 类里的 Java 工程师
[痛点] 别人要部署你的东西,你没健康检查、没契约、Key 还在 yml 里
[现在读] 第 16 掌,该把学习产物收成一个能独立启动的 Spring Boot 服务
[读完] 能划出七层包结构,做 REST + SSE 契约,把配置全部外置
[旧方案] 单模块 Demo:Controller 里 new Agent、Prompt 硬编码、Key 写在 yml
|
v
[新需求] 独立启动、健康检查、接口契约、环境可切换、LiveKit 语音接入
|
v
[冲突] Demo 里每个"省事"的决定,在交付时都要重做一遍
|
v
[后果] 换环境要改代码、上线靠口头交接、Studio 被当成用户前端直接暴露
我在第 3 掌给数字人项目搭了底座,第 4 到第 13 掌一路把对话、记忆、工具、MCP、RAG、ReactAgent、Flow、Graph、多 Agent、A2A 全部接了上来。到这一刻,仓库里跑的是"一堆能演示的东西",不是"一个能交付的东西"。我是老李,这篇只干一件事:把这些跑得通的代码,收成一个别人敢部署的 Spring Boot 服务。
先把"跑得通"和"能交付"的差距说清楚。跑得通的标准是:我本机 mvn spring-boot:run,浏览器点一下,有输出。能交付的标准是:运维拿到镜像能起、能看健康检查;前端拿到契约能联调;测试切到预发环境不改一行代码;安全扫描扫一遍仓库,扫不到任何 Key。
这一掌的主线是"收束",不是"新增能力"。前面十几掌都在做加法,这一掌在做减法:把散落各处的 new、字符串常量和硬编码地址,收进有名字的层里。
但只把文件挪到不同的包里,问题不会消失。分层如果只在目录上成立、在依赖方向上不成立,你只会得到一个更精致的 Demo。
那到底怎么收?
先收敛服务边界,还是先补健康检查?
如果这两件事的顺序选错了,会付出什么代价?
01、故事
周一早上,前端同事在群里问:聊天接口的返回字段到底叫 answer 还是 content?运维同事接着问:镜像什么时候给我,我这边要先配健康检查探针。测试同事问:预发环境我怎么切?
我打开仓库。src/main/java/com/example/demo 下面躺着十几个类:DemoController、AgentDemoRunner、GraphDemoRunner、RagDemoRunner。每个 Runner 里都有一份自己的 ChatClient.builder(...),Prompt 是 static final String,模型名是字符串字面量,spring.ai.dashscope.api-key 明明白白写在 application.yml 里,跟着代码一起提交了。
任务是:把这些跑得通的东西,收成一个可交付的 Spring Boot 服务。
现场是什么样,冲突就在哪里。原来的方案假设"只有我一个人用、只在我这台机器上跑";现在的新需求是"前端联调 + 测试验收 + 运维部署 + 安全扫描"四个角色同时进场。Demo 里每一个省事的决定,在交付环节都要还账:
- Controller 里直接
newAgent → 前端一压测,就发现会话状态是共享的 - Prompt 写成常量 → 运营想改一句开场白,要重新打包发版
- Key 进仓库 → 安全扫描直接报红,而且必须做凭据轮换
- 只有一个
/demo/chat→ 前端要做打字机效果,没有 SSE,只能轮询 - 我把 Studio 地址顺手发给了前端 → 前端真的把它当产品页面用了
这五条里,前四条是"服务能力缺失",第五条是"边界认知错误"。后者更贵。
现场先看清
边界再划明
省事皆有账
交付方见真
02、问题
旧方案失效:以"类"为单位组织代码,是学习期的最优解——每个类独立可跑,改一个不影响另一个。但这种组织方式在多角色协作下的成本是全部:没有任何地方定义"这个服务对外承诺什么"。
业务影响:
- 无法独立交付:换一个环境要改代码、重新构建
- 无法验收:没有健康检查,运维不知道服务是"活着"还是"看起来活着";没有契约,前端靠猜字段名
- 无法切换:测试、预发、生产共用同一份配置
- 触安全红线:凭据进了版本库
技术表现都是可以直接看到的:
application.yml里出现明文api-key- 包名按技术分(
demo、runner、config),而不是按职责分 - Controller 同时做入参校验、Agent 创建、Prompt 拼装、流式格式转换
- 流式接口直接返回
String,前端无法逐字渲染
可验证的完成标准,正好对应验收四条:
- 服务可独立启动:
java -jar app.jar能起,不依赖 IDE、不依赖本地目录 - 健康检查:
/actuator/health返回 UP,并且能区分"进程活着"和"关键配置齐了" - 接口契约清晰:路径、方法、
Content-Type、字段名在 Controller 上就能读出来 - 环境配置可切换:同一个镜像,靠环境变量/profile 切换环境,不改代码不重新构建
这四条不是锦上添花,是"能不能交出去"的判定线。
旧法已到顶
新需四面来
先立判定线
再谈优与劣
03、原理
本篇只需要三条原理。
原理一:分层的本质是依赖方向,不是目录结构。
反直觉判断:分层的第一价值不是"代码更整洁",而是"变更被隔离在单一方向上"。只建目录、不管依赖方向,controller 里照样 import 配置类、import ChatClient,你得到的是一个更精致的 Demo——文件多了,耦合一点没少。
七层的职责边界:
- Controller(api):只认 HTTP 语义——路径、方法、媒体类型、状态码
- Application:只认用例——"一次会话要经过哪几步"
- Agent:只认能力装配——Prompt、模型、工具、Graph
- Tool / RAG / Graph:各自的领域能力
- Infrastructure:只认外部世界——配置、健康检查、LiveKit、Nacos
依赖方向必须单向:api → application → agent → {tool, rag, graph} → infrastructure。反向 import 一律禁止。
原理二:可交付的最小单位是"可独立启动 + 可自检"。
反直觉判断:一个服务能不能交付,取决于它在依赖缺失时的表现,而不是依赖正常时的表现。Demo 在 Key 缺失时会抛一个 401,你查半小时;服务应该在启动那一刻就失败,或者健康检查直接 DOWN。健康检查不是"上线前补的附件",它是分层设计的一部分——因为它决定了"配置校验放在哪一层、什么时候生效"。
原理三:Studio 是开发期的观测器,不是生产前端。
Studio 的能力是开发期可视调试:看会话、看 Graph 执行链路。它的定位是"给你自己看",所以它天然缺少面向终端用户的东西——面向公网的鉴权、限流、多租户隔离、内容审核。把它当用户前端,等于把调试后门直接开到用户面前。
这条原理在架构上落成一个硬约束:Studio 的启用只能由 profile 控制,且生产 profile 里不加载。
这三条原理分别解释了后面的架构(04)、故障(06)和优化(07)。
分层看方向
交付看自检
调试归开发
三者不可混
04、架构
[输入] HTTP 请求 / SSE 订阅 / LiveKit 房间事件
|
v
[模块] api(Controller) → application(用例) → agent(Prompt/模型/工具装配)
|
v
[数据/状态] 会话上下文 / 外置配置 DigitalHumanProperties / Graph State
|
v
[处理] Agent 调用 → Tool / RAG / Graph 编排 → Infrastructure(配置/健康检查/LiveKit/Nacos)
|
v
[输出] REST JSON / text-event-stream / LiveKit 音频流
边界——这套架构里谁不做什么:
- Controller 不认识模型,不认识
ChatClient,只认识 DTO - Application 不出现任何 Spring AI 类型,只出现本项目的领域方法名
- Agent 不认识 HTTP,不 import
HttpServletRequest - Infrastructure 只提供能力,不编排业务
- Studio 只在开发 profile 存在,不进入生产依赖图
收益:变更被隔离。改 Prompt 只动配置或 Agent;加一个接口只动 Controller + Application;换模型只动 Agent 装配。至于 Tool、RAG、Graph 各自的位置,沿用第 6、8、11 掌已经跑通的实现,只是换个包放,调用方向不变,所以不需要重写。
代价:类的数量上升,"从 HTTP 请求到模型"的阅读路径从 1 跳变成 4 跳。对单人 Demo 而言这是纯负担。
适用条件:当"使用这份代码的人"多于一个角色时,这笔开销才开始回本。只有一个角色时,你不需要它。
输入先划界
模块各守门
状态有归属
输出方可期
05、实战一次
环境与版本:
- JDK 21 LTS
- alibaba/spring-ai-alibaba v1.1.2.2
- Spring Boot 3.x(具体小版本以 v1.1.2.2 的依赖管理为准,需按当前官方文档核验)
- 分支:
chapter/16-spring-service
依赖。下方 artifactId 与版本以主仓库 spring-boot-starters 目录当前发布为准,需按当前官方文档核验:
<dependency>
<groupId>com.alibaba.cloud.ai</groupId>
<artifactId>spring-ai-alibaba-starter-dashscope</artifactId>
<version>1.1.2.2</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
目录结构:
src/main/java/com/example/digitalhuman
├── DigitalHumanApplication.java
├── api
│ ├── controller/ChatController.java
│ └── dto/ChatRequest.java
├── application/DigitalHumanService.java
├── agent/DigitalHumanAgent.java
├── tool/ # 第 6 掌的 Tool 落在这里
├── rag/ # 第 8 掌的检索落在这里
├── graph/ # 第 11 掌的 Graph 落在这里
└── infrastructure
├── config/DigitalHumanProperties.java
├── health/ConfigReadinessHealthIndicator.java
└── livekit/LiveKitTokenService.java
配置全部外置,Key 不入代码:
server:
port: 8080
spring:
application:
name: digitalhuman-service
ai:
dashscope:
api-key: ${DASHSCOPE_API_KEY}
chat:
options:
model: ${DH_CHAT_MODEL:qwen-plus}
management:
endpoints:
web:
exposure:
include: health,info
endpoint:
health:
show-details: always
digitalhuman:
agent:
default-system-prompt: ${DH_SYSTEM_PROMPT:你是数字人助手,回答简短,不编造事实。}
livekit:
url: ${LIVEKIT_URL:}
api-key: ${LIVEKIT_API_KEY:}
api-secret: ${LIVEKIT_API_SECRET:}
启动类:
package com.example.digitalhuman;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
@SpringBootApplication
@ConfigurationPropertiesScan
public class DigitalHumanApplication {
public static void main(String[] args) {
SpringApplication.run(DigitalHumanApplication.class, args);
}
}
配置属性,带校验:
package com.example.digitalhuman.infrastructure.config;
import jakarta.validation.constraints.NotBlank;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.validation.annotation.Validated;
@Validated
@ConfigurationProperties(prefix = "digitalhuman")
public class DigitalHumanProperties {
private final Agent agent = new Agent();
private final Livekit livekit = new Livekit();
public Agent getAgent() { return agent; }
public Livekit getLivekit() { return livekit; }
public static class Agent {
@NotBlank
private String defaultSystemPrompt;
public String getDefaultSystemPrompt() { return defaultSystemPrompt; }
public void setDefaultSystemPrompt(String v) { this.defaultSystemPrompt = v; }
}
public static class Livekit {
private String url = "";
private String apiKey = "";
private String apiSecret = "";
public String getUrl() { return url; }
public void setUrl(String v) { this.url = v; }
public String getApiKey() { return apiKey; }
public void setApiKey(String v) { this.apiKey = v; }
public String getApiSecret() { return apiSecret; }
public void setApiSecret(String v) { this.apiSecret = v; }
}
}
Agent 层,Prompt 从配置来,会话标识只作为方法参数向下传递:
package com.example.digitalhuman.agent;
import com.example.digitalhuman.infrastructure.config.DigitalHumanProperties;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Flux;
@Component
public class DigitalHumanAgent {
private final ChatClient chatClient;
public DigitalHumanAgent(ChatClient.Builder builder, DigitalHumanProperties properties) {
this.chatClient = builder
.defaultSystem(properties.getAgent().getDefaultSystemPrompt())
.build();
}
public String call(String sessionId, String message) {
return chatClient.prompt()
.user(message)
.call()
.content();
}
public Flux<String> stream(String sessionId, String message) {
return chatClient.prompt()
.user(message)
.stream()
.content();
}
}
第 5 掌的会话记忆就挂在这一层——把 memory advisor 加在 chatClient.prompt() 后面即可,参数键名以当前版本 ChatMemory.CONVERSATION_ID 为准,需按当前官方文档核验;关键是它不会向上污染 Controller。
Application 层:
package com.example.digitalhuman.application;
import com.example.digitalhuman.agent.DigitalHumanAgent;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
@Service
public class DigitalHumanService {
private final DigitalHumanAgent agent;
public DigitalHumanService(DigitalHumanAgent agent) {
this.agent = agent;
}
public String chat(String sessionId, String message) {
return agent.call(sessionId, message);
}
public Flux<String> stream(String sessionId, String message) {
return agent.stream(sessionId, message);
}
}
DTO 与 Controller,REST + SSE 两条契约:
package com.example.digitalhuman.api.dto;
public record ChatRequest(String sessionId, String message) {
}
package com.example.digitalhuman.api.controller;
import com.example.digitalhuman.api.dto.ChatRequest;
import com.example.digitalhuman.application.DigitalHumanService;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Flux;
@RestController
@RequestMapping("/api/v1/digital-human")
public class ChatController {
private final DigitalHumanService service;
public ChatController(DigitalHumanService service) {
this.service = service;
}
@PostMapping(value = "/chat", produces = MediaType.APPLICATION_JSON_VALUE)
public ChatResponse chat(@RequestBody ChatRequest request) {
String content = service.chat(request.sessionId(), request.message());
return new ChatResponse(request.sessionId(), content);
}
@PostMapping(value = "/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> stream(@RequestBody ChatRequest request) {
return service.stream(request.sessionId(), request.message());
}
public record ChatResponse(String sessionId, String content) {
}
}
健康检查:探的是"关键配置齐没齐",不做远程调用,避免被动探测产生成本:
package com.example.digitalhuman.infrastructure.health;
import com.example.digitalhuman.infrastructure.config.DigitalHumanProperties;
import org.springframework.boot.actuate.health.Health;
import org.springframework.boot.actuate.health.HealthIndicator;
import org.springframework.stereotype.Component;
@Component
public class ConfigReadinessHealthIndicator implements HealthIndicator {
private final DigitalHumanProperties properties;
public ConfigReadinessHealthIndicator(DigitalHumanProperties properties) {
this.properties = properties;
}
@Override
public Health health() {
String prompt = properties.getAgent().getDefaultSystemPrompt();
if (prompt == null || prompt.isBlank()) {
return Health.down()
.withDetail("reason", "digitalhuman.agent.default-system-prompt is blank")
.build();
}
return Health.up()
.withDetail("livekitConfigured", !properties.getLivekit().getUrl().isBlank())
.build();
}
}
构建与启动:
export DASHSCOPE_API_KEY=sk-xxxx
./mvnw clean package -DskipTests
java -jar target/digitalhuman-service-0.0.1-SNAPSHOT.jar
请求与验证。以下为预期结果,未在当前环境实测:
curl -s http://localhost:8080/actuator/health
示例输出:
{"status":"UP","components":{"configReadiness":{"status":"UP","details":{"livekitConfigured":false}},"diskSpace":{"status":"UP"}}}
curl -s -X POST http://localhost:8080/api/v1/digital-human/chat -H 'Content-Type: application/json' -d '{"sessionId":"s-1","message":"你好"}'
示例输出:
{"sessionId":"s-1","content":"你好,我是数字人助手。"}
curl -N -X POST http://localhost:8080/api/v1/digital-human/chat/stream -H 'Content-Type: application/json' -d '{"sessionId":"s-1","message":"一句话介绍你自己"}'
示例输出(逐段下行,不是一次性返回):
data:我是
data:数字人
data:助手
data:[DONE]
到这里 V1 跑通了:服务能独立启动、健康检查能区分"进程活着"和"配置齐了"、两条接口契约清晰。但留下了四个问题:api-key 缺失仍然只在第一次调用时才 401;SSE 没有超时和心跳,静默期可能被中间层掐断;Studio 依赖还在主依赖里,没做 profile 隔离;Tool / RAG / Graph 只是搬进了包,资源加载时机还没收口。
先求能立起
再求能自证
契约写明白
后患留待查
06、排查
链 A:容器内首次调用 401。
现象:本地 ./mvnw spring-boot:run 正常;同一份代码打成镜像后,容器日志显示 Started DigitalHumanApplication,但第一次请求返回 401 Unauthorized。
怀疑:网络不通;模型名写错了。
检查:带上环境变量重跑一次:
docker run --rm -e DASHSCOPE_API_KEY=$DASHSCOPE_API_KEY -p 8080:8080 digitalhuman:0.1
结果正常。
证据:不带 -e 时,容器内 env | grep DASHSCOPE 输出为空;而 application.yml 里写的是 ${DASHSCOPE_API_KEY},没有默认值。
根因:这不是代码 bug,是"部署契约"缺失。外置配置只完成了"代码不再写死",没完成"部署时必需项被声明"。K8s 的 ConfigMap/Secret 清单没有跟着交付,环境变量就只能靠人记。
修复:把必需环境变量写进部署清单(Compose / K8s),并在启动阶段做一次前置校验。
错误尝试: 我第一反应是在 yml 里补默认值——api-key: ${DASHSCOPE_API_KEY:sk-xxxx}。这是错的。它同时犯了两个错:把凭据又写回了版本库;并且把"启动期配置缺失"这个显式错误,退化成一个更晚、更难定位的运行时错误。默认值只能给那种"缺失也能正确运行"的项,比如端口、模型名。
链 B:SSE 经网关后不吐字。
现象:直连 curl -N 能看到逐段 data:;前端经网关后,要么长时间空白,要么一次收到整段,要么 60 秒后连接断开。
怀疑:模型出字慢。
检查:在后端记录首字节时间戳,在网关侧看响应头的传输时间。后端出首字节在数百毫秒级;网关到浏览器侧长时间无数据下行。
证据:响应头 Content-Type: text/event-stream 正常,但中间层对响应做了缓冲。
根因:SSE 是长连接加小包,任何一层的响应缓冲、压缩或代理空闲超时,都能把它变成"假流式"。这不是模型问题,是传输链路问题。
修复:在网关层对该路径关闭响应缓冲、放大空闲超时;服务端补周期性心跳事件,避免静默期被判定为空闲连接。具体配置项名称以你实际使用的网关产品为准,需按当前官方文档核验。
链 C:Studio 被匿名访问。
现象:安全扫描报告 Studio 相关路径可匿名访问。
怀疑:代码里开启了 Studio。
检查:确认 spring-ai-alibaba-studio 依赖被无条件引入,且没有任何 profile 约束。该模块的定位是开发期可视调试界面,v1.1.2.2 仓库中存在对应目录,其依赖坐标、默认启用条件与访问路径需按当前官方文档核验。
证据:生产镜像的依赖树里出现了 Studio 相关包。
根因:把"给我看的调试器"和"给用户用的前端"混在了同一个可运行产物里。开发期工具的默认假设是"访问者是开发者",它不会替你做面向公网的安全设计。
修复:让 Studio 只在开发 profile 存在——依赖加 provided 作用域由启动脚本补齐,或放进独立的 dev 启动模块;生产镜像的依赖图里不出现它。
现象莫先判
证据再开口
根因分几类
修法各不同
07、优化
V2 严格来自 06 章的证据。
优化一:配置校验前置。
根因:链 A 证明"配置缺失"被推迟到了第一次调用。
修改:把 spring.ai.dashscope.api-key 也纳入启动校验——在 Infrastructure 层加一个 ApplicationRunner 或 @PostConstruct 检查,或在部署阶段用 K8s 的 required env、启动脚本的 set -u 拦截。
原因:让错误在最便宜的时刻暴露。
新行为:Key 缺失时进程在启动阶段失败,错误信息直接指出缺少哪个环境变量,而不是等第一次请求返回 401。
验证:清空环境变量启动一次,观察失败发生在启动期还是请求期。
优化二:Studio profile 隔离。
根因:链 C 证明开发期工具进入了生产依赖图。
修改:Studio 相关依赖移入 dev 专用模块或加 provided,并绑定到开发 profile。
原因:边界不该靠"记得关掉"来维持。
新行为:生产 profile 下 Studio 路由不可达。
验证:用生产 profile 启动,请求 Studio 路径应 404;用 dev profile 启动,该路由存在。
优化三:SSE 契约化。
根因:链 B 证明"流式"是跨层契约,后端一个人保证不了。
修改:Controller 上显式声明 text/event-stream;服务端增加周期性心跳事件;把网关的缓冲与超时配置写进交付清单。
原因:任何一个环节不配合,流式就是假的。
新行为:静默期仍有事件下行,连接不会被误判为空闲。
验证:让模型输出一段较长内容,观察静默期是否仍有事件;再经网关重复同样的 curl -N。
优化四:契约文件化。
根因:01 章前端的第一句话就是"字段叫什么"。
修改:请求/响应定义收敛到 api/dto 下的 record,并按需引入 OpenAPI 描述生成。
原因:契约写在代码里,但要让别人能读到。
新行为:对外描述与 Controller 同源,字段名不会两处不一致。
验证:启动后拉取接口描述,逐字段与 ChatRequest、ChatResponse 比对。
V2 之后,服务具备四件事:可独立启动、可自检、契约可读、环境可切(同一镜像 + 环境变量 + profile)。
根因定方向
修改有出处
新行为可验
旧债逐条清
08、演进
[同一份代码 + 同一份模型]
|
+--[V1] 单模块 Demo:能跑,改动要重打包,环境靠改 yml,Key 在仓库
| 代价:不可交付、不可验收、不可切换
|
+--[V2] 分层服务:可独立启动 + 健康检查 + 契约化 + 配置外置 + Studio 隔离
| 代价:类变多、阅读路径变长、需要维护部署清单
|
[Trade-off]
得到:变更隔离、可验收、可切换、可交接
失去:单文件改动的"爽感",以及"一套代码只有一个入口"的简单心智
适用边界:使用这份代码的角色多于 1 个;只有你自己时,V1 更划算
逐维度比较:
- 正确性:V1 在单环境单角色下正确;V2 在"配置缺失、环境切换、多角色协作"这三个条件下才正确。
- 稳定性:V1 的失败点分散在运行时各处;V2 把配置类失败前移到启动期和健康检查。
- 复杂度:类数量上升,但每个类的职责变窄。复杂度从"人脑记忆"转移到了"结构"。
- 成本:新增的是维护成本(部署清单、profile、契约描述),减少的是排障成本和交接成本。
- 适用范围:单体服务、多环境、有前端与运维参与的交付场景。
- 遗留问题:健康检查不覆盖"模型是否真的可达"(那是有成本的探测,不宜放进被动检查);LiveKit 目前只到凭证签发与接入,尚未与会话生命周期绑定;Tool / RAG / Graph 只是搬进了包,资源加载与隔离还没收口。
同一份输入
两种活法差
取舍看人数
边界定成本
09、洞见
9.1 分层的收益随协作角色数增长,不随代码行数增长
同一个七层结构,放在只有作者的 Demo 里是纯负担;放在"前端 + 测试 + 运维 + 安全"同时进场的场景里才开始回本。所以不要问"这个项目要不要分层",要问"有几类人在改这份代码"。反直觉判断:分层不是为了让人看懂代码,是为了让不同的人可以互不干扰地改同一份代码。
9.2 反直觉判断:健康检查不是监控,是分层的一部分
多数人把健康检查当成运维的活儿,上线前补一个 /actuator/health 就算完事。但它真正决定的是"配置校验放在哪一层、什么时候生效"——放在 Infrastructure 层,缺失就在启动期暴露;随手放在 Controller,缺失就在第一次请求暴露。06 章链 A 那个 401,本质是健康检查缺位的结果,不是运维的问题。
9.3 反直觉判断:Studio 能力越强,越不能进生产
直觉是"Studio 功能这么全,直接给用户用能省掉整个前端"。事实相反:Studio 是开发期观测器,它的强项是"把内部状态摊开给你看",而面向终端用户的需求正相反——把内部状态全部藏起来。能力越全,暴露面越大。判断标准不是"它好不好用",而是"访问它的人是不是开发者"。
9.4 配置外置的终点不是"拆 yml",是"配置有唯一来源"
把 Key 从代码挪进 yml、再挪进环境变量,只是换了存放位置。真正的终点是:每个配置项只有一个权威来源,且缺失时的行为被明确规定——要么启动失败,要么存在安全的默认值。06 章那个 sk-xxxx 的错误尝试说明,这件事最容易被做成一知半解:看起来外置了,实际上凭据又回到了版本库。
9.5 交付物的边界,比交付物的功能更值得先定
本篇从头到尾没有新增任何模型能力——Agent、Graph、RAG 在前面的章节里就跑通了。这一掌做的全在边界上:谁能调、怎么调、什么时候失败、失败给谁看。反直觉判断:服务化的最大工作量不在"加功能",而在"承诺不做什么"。
结构生收益
自检生信任
调试归内网
边界生交付
10、系统落地
原来有什么:一个能跑的 Demo 集合——ChatClient、Memory、Tool、MCP、RAG、ReactAgent、Flow、Graph、多 Agent,全部以"类"的形式存在,各自可跑,互不约束。
本篇新增什么:一层可交付的外壳,具体是四样东西——
- 七层包结构与单向依赖:api / application / agent / tool / rag / graph / infrastructure
- REST + SSE 契约:
/api/v1/digital-human/chat返回 JSON,/chat/stream返回text/event-stream;实时语音由 LiveKit Bridge 接入,服务端负责凭证签发与事件桥接 - 健康检查与配置前置校验:
/actuator/health能区分"进程活着"和"关键配置齐了" - 全部配置外置:模型、Prompt、Key、LiveKit、Nacos 等项一律从环境变量/profile 来,代码里不留凭据
现在能做什么:同一个镜像,注入不同环境变量就能在测试、预发、生产之间切换;运维靠健康检查判断启动是否成功;前端按契约联调,并按 SSE 事件渲染打字机效果;安全扫描扫不到凭据;Studio 只在开发环境存在,用于开发期可视调试,不承担用户前端职责。
还缺什么:
- LiveKit 目前只到"接入"这一步,凭证签发与房间生命周期还没和 Agent 会话绑定
- 第 6、8、11 掌的 Tool / RAG / Graph 只是搬进了层里,检索索引的加载时机与资源隔离还没收口
- 没有评测:改了 Prompt,无法回答"是不是变差了"
- 没有运行时治理:谁在调、调了多少、慢在哪,都还不清楚
- 部署停在"能起",没有滚动升级与回滚
下一步如何演进:先把"改了 Prompt 能不能量化"这件事接上,再把"线上运行时看得见吗"接上,最后才是部署形态升级。顺序不能反——先能度量,再谈治理;先能观测,再谈升级。
原来各自跑
现在一体收
缺口仍可见
次第再补齐
11、小结
Q1 → 先收敛服务边界,还是先补健康检查?
先定边界。健康检查是边界的产物——它由"哪些配置是必需的"决定,不是独立补丁。
Q2 → 收束的核心动作是什么?
把"类"改成"层",把依赖方向固定成单向,把可变项从代码移到配置。
Q3 → 顺序选错会付出什么代价?
先补健康检查会得到一堆探不到重点的探针;边界没定,探针也不知道该探什么。
状态 → 数字人项目从一个 Demo 集合,变成一个可独立启动、可自检、契约清晰、配置可切换的 Spring Boot 服务。
一问定先后
二问定动作
三问定代价
状态即交付
12、作业
12.1 理解题:为什么说"分层的第一价值是变更隔离,而不是代码整洁"?
参考答案:整洁是结果,隔离才是目的。判断分层的标准不是目录好不好看,而是"改一句 Prompt 需要动几个包"。如果改一句话要同时碰 Controller、Agent、配置三处,说明依赖方向没定死,分层没成立。判据见 03 章原理一与 04 章的边界清单。
12.2 实战题:给数字人服务补一条完整证据链,证明"环境可切换"且不重新构建镜像。
参考答案:把所有可变项改成 ${ENV_VAR:default} 或 profile 驱动——模型名、Prompt、LiveKit 地址、Nacos 地址。打一次镜像,分别在两组环境变量下启动,观察 /actuator/health 的 details 与 /chat 的响应是否随之变化。关键点是:两组的差异只能来自注入的变量,镜像 digest 必须完全一致。
12.3 排障题:服务本地正常,进了 K8s 后 Pod 反复重启,日志里只有一次启动记录然后退出。怎么定位?
参考答案:先区分"启动失败"和"启动后被探针杀死",再去翻日志。检查三处:一是 liveness/readiness 探针的路径与端口是否和 server.port、management.server.port 一致;二是探针的 initialDelaySeconds 是否小于应用启动耗时(引入 RAG 索引加载后启动会明显变长);三是必需环境变量是否由 Secret 注入成功。顺序上先看事件里的 Killing/Unhealthy 记录,再去读应用日志,避免直接在业务代码里绕圈。
12.4 架构判断题:为了省一个前端,直接把 Studio 暴露给终端用户,可行吗?
参考答案:不可行。Studio 是开发期观测器,默认假设访问者是开发者,缺少面向公网的鉴权、限流、多租户隔离与内容审核。可行的替代是:Studio 只留在开发与内网环境用于调试,生产侧对外只暴露你自己定义的 REST + SSE 契约。判断标准是"访问它的人是不是开发者",不是"它功能多不多"。见 03 章原理三与 06 章链 C。
一题验认知
二题验证据
三题验排查
四题验边界
13、思考
回到本篇的核心冲突:Demo 里每个"省事"的决定,在交付时都要还账。
这一掌给出的判断可以复用成一句话:服务化的本质是一次边界谈判,而不是一次功能开发。你要谈的对象是四类人——调用方(契约)、运维方(启动与自检)、安全方(凭据与暴露面),以及未来的自己(配置的来源与缺失时的行为)。谈不成,功能再多也交不出去。
还有一个更隐蔽的判断:收束的成本必须现在就付,越晚越贵。第 4 到第 13 掌一路加能力的时候,"以后再收"看起来是零成本的;等到 Studio 被前端当成产品页面、Key 已经进了 git 历史的那一刻,收束的成本就不只是重构,还要加上凭据轮换和协作关系的修复。
最后一条:边界不是靠自觉维持的,是靠结构维持的。profile、单向依赖、启动期校验,

浙公网安备 33010602011771号