AgentScope第3式·数字人底座
AgentScope第3式·数字人底座
别让每篇教程换一个 Demo,先把宿主冻结下来。
[读者] Java 后端 / 架构师,准备把 Agent 接进真实业务
|
v
[痛点] 教程逐篇换场景,代码无法累积,越学越散
|
v
[期望] 一个能长期演进的宿主 + 清晰的 LiveKit / Agent 边界
[旧方案] 每章一个独立 Demo,手工建库,实时层兼做推理
|
v
[新需求] 冻结一个最小数字人底座,供后续 26 章增量开发
|
v
[冲突] 底座没有边界,实时通信与 Agent Runtime 职责混住
|
v
[后果] 每加一个能力就重写宿主,教程讲不深,工程不可复用
我是老李,做过几个“跟着教程敲一遍就废”的项目。最典型的一次是数字人 Demo:第一版能跑,第二版接了工具调用,第三版想加记忆,结果发现三版都在动同一批 Controller,改到最后没人敢删代码。
后来我把这类项目叫做“没有宿主的技术验证”。它们不是跑不起来,而是跑完就结束了,没法作为下一次演进的起点。
AgentScope Java 的能力会一篇一篇往里加:Tool、MCP、Memory、Skill、Plan、Sandbox、Subagent。如果每一篇都换一个场景,读者学到第二十式时会发现自己在维护二十个互不相干的仓库。那不是学习,那是收集。
问题是,这个底座到底要做多大?
如果只做“输入→输出”,后面 LiveKit、AgentScope 全都没地方挂;如果一上来把记忆、工具、多 Agent 全铺好,这一篇就变成大杂烩,后面每一式都得先解释一遍前面写了什么。
那到底是先把能力铺满,还是先把边界画清?
01、故事
评审会上,一位同事把三个仓库投到屏幕上:demo-tool、demo-memory、demo-multi-agent。同样一个“数字人”页面,三套后端、三套表结构、三种实时方案,共同点是没人知道下周该改哪一份。
任务是:在 AgentScope Java 正式进入之前,先把最小交互数字人底座冻结下来,后续 26 章全部在它上面增量开发。不换场景、不换仓库、不换表。
我们把范围压到四块:注册登录、数字人项目 CRUD、项目与 Agent 配置、网页版运行。项目配置只留展示与话术字段,Agent 配置只留 agentName、model、systemPrompt。
有人问:Tool、MCP、Memory 都先不做,那这一篇是不是太薄了?
薄,正是它的设计目标。底座的价值不在于它现在能做什么,而在于它能不能接住后面所有能力而不被推翻。
评审结束时定了两条硬约束:第一,实时链路最小化,LiveKit 只做房间和音频/数据通道;第二,Realtime Bridge 必须保持最薄,只做媒体与消息适配,不碰业务逻辑,也不自己实现推理。
后续所有教程代码都放同一个仓库:https://github.com/lifuchun522/agentscopepractice ,本章分支 chapter/03-digital-human-demo,从第 2 式分支继续创建。
底座先冻结
场景不乱换
边界先画清
后面才好接
02、问题
旧做法是这样的:每一式开一个新工程,数据库手工建,前端页面顺手写,实时能力直接把 LiveKit 的示例代码改一改,推理就塞在页面里调 HTTP 接口。
它跑得起来,但顶不住三件事。
第一件是累积。第二式要讲工具调用,得先把第一式的用户体系抄一遍;第三式要讲记忆,又得把前两式的表结构再拼一次。抄到最后,代码里同时存在三套命名风格。
第二件是边界。示例代码里,LiveKit 既管房间又管业务,Token 里塞了 systemPrompt,房间名等于业务 ID,接入方拿到的不是授权凭证而是一份隐式配置。等到 AgentScope Java 接进来时,没人说得清哪一层该改。
第三件是验收。没有一个统一入口,每篇讲完只能说“接口返回了 JSON”,说不了“用户真的完成了一次交互”。
底座的完成标准必须可验证,本章定义为四条:
- 能注册、能登录,未登录访问项目接口返回 401;
- 能创建、编辑、删除项目,能配置标题、主题色、背景图 URL、开场白、结束语;
- 能为项目配置最小 Agent,只填
agentName、model、systemPrompt; - 能从
/run/{projectId}启动一次会话,完成一轮文本交互闭环。
注意第四条只要求“文本闭环”。语音链路本章给出接通方式,但不作为验收门槛。
旧法各立山头
代码无法累积
证据指向宿主
完成标准先立
03、原理
先给一个反直觉判断:数字人项目的成本大头不在推理,而在宿主。
模型换一个,接口协议基本一样;宿主没设计好,每换一个能力就得重写一遍接入层。这一点在 AgentScope Java 里尤其明显,因为它的能力是分层递进的——从 Agent 到 Tool、Memory、Plan、Subagent,每一层都要求宿主能提供稳定的配置、状态和调用位置。
第二个原理是边界。本系列里,LiveKit 只负责 WebRTC Room 与实时音频/数据传输;AgentScope Java 负责推理、Tool、状态、Harness、Memory、Skill、Subagent。这句话听起来像套话,落到代码里就是:LiveKit 的 Token 里只放身份与房间授权,不放业务配置;Realtime Bridge 不实现推理,只做消息形状转换。
第三个原理是为什么 Bridge 必须薄。Bridge 越厚,它就越是第二个 Agent Runtime。一旦有人在 Bridge 里写了 if-else 处理业务分支,后面接 AgentScope 的 Memory 和 Plan 时就会出现两套状态,谁也不知道哪套是真的。线上排障最怕的不是没有日志,而是有两套互相矛盾的状态。
AgentScope Java 是这套结构里的推理与服务编排层,官方主仓库在 https://github.com/agentscope-ai/agentscope-java ,Java 版文档入口是 https://java.agentscope.io/ 。本教程基线固定为 v2.0.3,本章不引入其运行时代码,只预留接入位置。
LiveKit 侧的定位同样固定:它是实时通信基础设施,不是 Agent 框架。官方文档在 https://docs.livekit.io/agents/ ,服务端源码在 https://github.com/livekit/livekit 。把它当成“能跑 Agent 的东西”是本章要提前纠掉的误判。
推理不是瓶颈
宿主才是成本
桥要薄到透明
通信不碰业务
04、架构
[输入] 用户操作 / 浏览器会话
|
v
[模块] Spring Boot (Auth / Project / AgentConfig / Runtime)
|
v
[数据/状态] MySQL: users / digital_human_project / agent_config
|
v
[处理] 运行页取 Token → 加入 Room → Bridge 归一化 → 推理
|
v
[输出] 文本或语音回复呈现到页面
两条链路分开看。
管理链路:Web 管理端 → Spring Boot → MySQL。只做用户、项目、Agent 配置的增删改查。
实时链路:Web 运行端 ⇄ LiveKit ⇄ Realtime Bridge ⇄ AgentScope Java ⇄ LLM。LiveKit 承担房间与音频/数据传输,Bridge 做媒体与消息适配,AgentScope Java 做推理。
边界:
- LiveKit 只做房间与传输,不做配置、不做推理;
- Bridge 只做适配,不写业务分支、不持有业务状态;
- AgentScope Java 负责推理与后续全部 Agent 能力;
- MySQL 只存配置,不存媒体文件,背景图只存 URL。
收益:每一层职责单一,替换其中一层不影响另外三层;代价:链路变长,本地要同时跑四个进程,调试时需要清楚“这一步是谁的日志”。
适用条件:需要一个能长期演进、要接入多种 Agent 能力的宿主工程。
不适用条件:只做单机演示、不需要实时音视频、或者已经有成熟 IM/音视频体系只需要挂一个对话接口的场景——这时候引入 Bridge 和 LiveKit 是净负担。
关于 AgentScope Java 内部机制,本章不展开;从下一式开始,涉及框架行为时都会落到固定版本 v2.0.3 的源码或测试位置。
四服务分好层
两条链路归位
数据只存配置
运行页作入口
05、实战一次
环境与版本:
- JDK 17
- Spring Boot 3.3.x
- MySQL 8.0
- LiveKit Server 1.x(自建或使用官方托管)
- LiveKit Java Server SDK(用于签发 Room Token)
- 教程基线:AgentScope Java v2.0.3(本章不含其运行时代码)
以下代码未在当前环境实测,按教程仓库分支 chapter/03-digital-human-demo 的组织方式给出,运行结果为预期结果,需按本机环境核验。
建表:
CREATE TABLE users (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(64) NOT NULL,
password_hash VARCHAR(100) NOT NULL,
display_name VARCHAR(64) NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_users_username (username)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE digital_human_project (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
owner_id BIGINT UNSIGNED NOT NULL,
name VARCHAR(64) NOT NULL,
title VARCHAR(128) NOT NULL,
theme_color VARCHAR(16) NOT NULL DEFAULT '#1677ff',
background_image VARCHAR(512) NOT NULL DEFAULT '',
opening_text VARCHAR(512) NOT NULL DEFAULT '',
closing_text VARCHAR(512) NOT NULL DEFAULT '',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
KEY idx_project_owner (owner_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE agent_config (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
project_id BIGINT UNSIGNED NOT NULL,
agent_name VARCHAR(64) NOT NULL,
model VARCHAR(64) NOT NULL,
system_prompt VARCHAR(2000) NOT NULL DEFAULT '',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_agent_project (project_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-crypto</artifactId>
</dependency>
<dependency>
<groupId>io.livekit</groupId>
<artifactId>livekit-server</artifactId>
<version>${livekit.version}</version>
</dependency>
livekit.version 与具体坐标需按 LiveKit 当前官方文档核验,不同版本的服务端 SDK 包名与 Token 构造方式可能不同。
配置:
server:
port: 8080
spring:
datasource:
url: jdbc:mysql://127.0.0.1:3306/digital_human?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8mb4
username: dh
password: dh_pwd
jpa:
hibernate:
ddl-auto: validate
open-in-view: false
app:
jwt:
secret: change-me-in-real-env
ttl-seconds: 86400
livekit:
url: ws://127.0.0.1:7880
api-key: devkey
api-secret: secret
四个应用服务。Auth:
@Service
public class AuthService {
private final UserRepository users;
private final PasswordEncoder encoder;
private final JwtIssuer jwtIssuer;
public AuthService(UserRepository users, PasswordEncoder encoder, JwtIssuer jwtIssuer) {
this.users = users;
this.encoder = encoder;
this.jwtIssuer = jwtIssuer;
}
@Transactional
public Long register(String username, String rawPassword, String displayName) {
if (users.existsByUsername(username)) {
throw new BizException("USERNAME_TAKEN");
}
User u = new User();
u.setUsername(username);
u.setPasswordHash(encoder.encode(rawPassword));
u.setDisplayName(displayName);
return users.save(u).getId();
}
public String login(String username, String rawPassword) {
User u = users.findByUsername(username)
.orElseThrow(() -> new BizException("BAD_CREDENTIALS"));
if (!encoder.matches(rawPassword, u.getPasswordHash())) {
throw new BizException("BAD_CREDENTIALS");
}
return jwtIssuer.issue(u.getId(), u.getUsername());
}
}
Runtime:
@Service
public class RuntimeTokenService {
private final LiveKitProperties props;
private final DigitalHumanProjectRepository projects;
public RuntimeTokenService(LiveKitProperties props,
DigitalHumanProjectRepository projects) {
this.props = props;
this.projects = projects;
}
public RuntimeToken issue(Long projectId, Long userId) {
projects.findByIdAndOwnerId(projectId, userId)
.orElseThrow(() -> new BizException("PROJECT_NOT_FOUND"));
String roomName = "dh-" + projectId;
// 具体类名与构造方式需按 LiveKit Java Server SDK 当前文档核验
AccessToken token = new AccessToken(props.getApiKey(), props.getApiSecret());
token.setIdentity("u-" + userId);
token.setName("user-" + userId);
token.addGrants(new RoomJoin(true), new RoomName(roomName));
return new RuntimeToken(props.getUrl(), roomName, token.toJwt(), 600);
}
}
这里刻意只放身份与房间授权,不放 systemPrompt、不放 model。运行页需要的话,通过 GET /api/projects/{id}/agent 单独取。
Controller:
@RestController
@RequestMapping("/api")
public class ProjectController {
private final ProjectService service;
public ProjectController(ProjectService service) {
this.service = service;
}
@GetMapping("/projects")
public List<ProjectView> list(@AuthUserId Long userId) {
return service.list(userId);
}
@PostMapping("/projects")
public ProjectView create(@AuthUserId Long userId, @Valid @RequestBody ProjectForm form) {
return service.create(userId, form);
}
@PutMapping("/projects/{id}")
public ProjectView update(@AuthUserId Long userId,
@PathVariable Long id,
@Valid @RequestBody ProjectForm form) {
return service.update(userId, id, form);
}
@DeleteMapping("/projects/{id}")
public void delete(@AuthUserId Long userId, @PathVariable Long id) {
service.delete(userId, id);
}
}
运行页核心逻辑(原生 JS + LiveKit Client SDK):
const projectId = location.pathname.split('/').pop();
const token = await fetch(`/api/projects/${projectId}/runtime/token`, {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + localStorage.getItem('token') }
}).then(r => r.json());
const room = new LivekitClient.Room();
await room.connect(token.url, token.roomToken);
const ws = new WebSocket(`ws://localhost:8080/bridge/ws?projectId=${projectId}`);
ws.onmessage = (e) => appendBubble('agent', e.data);
ws.onopen = () => ws.send(JSON.stringify({ projectId, text: '你好' }));
说明一下这里的设计取舍:本章的文本消息走 Bridge 的 WebSocket,音频走 LiveKit 房间。这是最小可跑通的方式;后续再收敛到 LiveKit 数据通道,让 Bridge 只面对一种输入。
Bridge 侧:
@Service
public class MinimalChatService {
private final RestClient client;
private final String baseUrl;
private final String apiKey;
public MinimalChatService(RestClient.Builder builder,
@Value("${app.bridge.llm-base-url}") String baseUrl,
@Value("${app.bridge.llm-api-key:}") String apiKey) {
this.client = builder.build();
this.baseUrl = baseUrl;
this.apiKey = apiKey;
}
public String chat(String model, String systemPrompt, String userText) {
Map<String, Object> payload = Map.of(
"model", model,
"stream", false,
"messages", List.of(
Map.of("role", "system", "content", systemPrompt),
Map.of("role", "user", "content", userText)));
JsonNode resp = client.post()
.uri(baseUrl + "/chat/completions")
.header("Authorization", "Bearer " + apiKey)
.contentType(MediaType.APPLICATION_JSON)
.body(payload)
.retrieve()
.body(JsonNode.class);
return resp.path("choices").path(0).path("message").path("content").asText();
}
}
这就是本章的全部推理能力:一个直连 LLM 的最小实现。下一式会把它换成 AgentScope Java 的推理入口,接口形状保持 chat(systemPrompt, userText) 不变,这样 Bridge 不用改。
启动与验证(示例输出):
curl -s -X POST http://localhost:8080/api/auth/register \
-H 'Content-Type: application/json' \
-d '{"username":"laoli","password":"pass1234","displayName":"老李"}'
# {"id":1}
curl -s -X POST http://localhost:8080/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"username":"laoli","password":"pass1234"}'
# {"token":"eyJhbGciOi..."}
创建项目、配置 Agent、申请 Token,然后打开 /run/{projectId},看到开场白渲染、背景图加载、发一条文本能收到回复,就算底座冻结完成。
建表先落库
接口按最小
页面五张够
会话能跑通
06、排查
第 1 条证据链:背景图到底存哪里。
现象:项目编辑页上传背景图后,项目列表接口从几十毫秒变成明显卡顿,digital_human_project 表体积增长很快。
怀疑:接口写得慢。
检查:看表结构,发现 background_image 被定义成 MEDIUMBLOB,图片以二进制存进了配置表。GET /api/projects 每次都要把整张图读出来传给前端。
错误尝试: 第一反应是给列表接口加分页,以为“查询量大”是根因。加完分页后单页仍然慢——因为慢的不是行的数量,而是每行的体积。分页治的是行数问题,治不了单行过大的问题。
证据:EXPLAIN 显示查询本身走了主键索引,执行计划正常;慢在结果集序列化与网络传输。
根因:把“配置”和“媒体”混在一张表里。配置是低频小数据,媒体是高频大数据,两者生命周期和访问模式都不同。
修复:background_image 改成 VARCHAR(512),只存 URL;开发阶段放本地静态目录,后续换对象存储时接口不变。
第 2 条证据链:Token 里该不该放配置。
现象:接入方拿运行 Token 后,行为跟管理端配置不一致,改了 systemPrompt 不生效,必须重启 Bridge。
怀疑:Bridge 缓存没刷新。
检查:读 Token 生成代码,发现 systemPrompt 被塞进了 Token 的 metadata;Bridge 从 Token 里解析配置。
错误尝试: 第二反应是给 Bridge 加一个定时刷新配置的任务。这会让问题更隐蔽——配置来源从一个变成两个,Token 里的旧值和数据库里的新值长期共存,排障时谁也不知道生效的是哪一份。
证据:Token 是授权凭证,签发后有效期内不可变;配置是可变状态,必须可随时生效。把可变状态放进不可变凭证,本质上是拿授权机制当配置中心用。
根因:职责错位。POST /api/projects/{id}/runtime/token 的语义是“颁发一次入房许可”,不是“下发项目配置”。
修复:Token 只保留 identity 与房间授权;运行页通过 GET /api/projects/{id} 和 GET /api/projects/{id}/agent 取配置;Bridge 每次处理消息时按 projectId 查一次配置,配合短 TTL 缓存。
上游贡献判断:
- 是否可在最小 Demo 复现:本章问题均属教程自身设计,不涉及 AgentScope Java 运行时代码;
- v2.0.3 是否存在:不适用,本章未引入 AgentScope Java 依赖;
- main 是否仍存在:不适用;
- 是否已有 Issue/PR:未检索到相关上游问题;
- 结论:本章未发现明确上游问题,不制造 Issue/PR。
先怀疑存储
再怀疑边界
证据对源码
根因在别处
07、优化
基于上面两条证据链,V2 的修改集中在三处。
第一处,字段约束进表结构。background_image 限定为 VARCHAR(512),并在应用层做校验:只接受 http(s):// 或 /static/ 开头的值,其他一律拒绝。之所以不只在应用层校验,是因为表结构是最后一道防线——只要列类型是 BLOB,迟早有人往里写二进制。
private static final Pattern IMAGE_URL =
Pattern.compile("^(https?://|/static/).+");
public void validateBackgroundImage(String url) {
if (url != null && !url.isEmpty() && !IMAGE_URL.matcher(url).matches()) {
throw new BizException("INVALID_BACKGROUND_IMAGE");
}
}
新行为:任何绕过前端的上传都会被后端拒绝,配置表不会因为一张图而膨胀。
第二处,Bridge 收敛成接口。把推理部分抽成一个接口,最小实现是直连 LLM,下一式替换为 AgentScope Java 实现,Bridge 本身不改。
public interface AgentRuntime {
String reply(AgentSpec spec, String userText);
}
@Service
public class DirectLlmRuntime implements AgentRuntime {
private final MinimalChatService chat;
public DirectLlmRuntime(MinimalChatService chat) {
this.chat = chat;
}
@Override
public String reply(AgentSpec spec, String userText) {
return chat.chat(spec.model(), spec.systemPrompt(), userText);
}
}
新行为:换推理实现只需要换一个 Bean,Bridge 的 WebSocket 处理逻辑保持稳定。
第三处,Token 限时限房。房间名固定为 dh-{projectId},TTL 设为 600 秒,身份前缀 u- 与 Agent 身份区分开。
验证方式:
# 1. 非法背景图被拒
curl -s -X PUT http://localhost:8080/api/projects/1 \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"name":"p","title":"t","backgroundImage":"data:image/png;base64,AAAA"}'
# {"code":"INVALID_BACKGROUND_IMAGE"}
# 2. Token 不含业务配置
# 解码 JWT payload,应只出现 video.roomJoin / video.room / sub / exp
约束进表结构
桥接收成接口
Token 限时限房
规范代替记忆
08、演进
[同一输入] 一次 /run/{projectId} 会话
|
+--[V1] LiveKit 兼做业务、背景图进 BLOB、配置塞进 Token
| 代价:接口变慢、配置来源分裂、宿主无法复用
|
+--[V2] 四服务分层、配置只存 URL、Bridge 只做适配
| 代价:链路变长、本地进程变多、调试路径更长
|
[Trade-off]
得到:可增量演进的宿主 + 稳定的接入边界
失去:一次成型、单进程跑通的“炫酷 Demo”
边界:不做口型、表情、视频合成、直播、多租户、计费
逐项比较:
正确性:V1 的配置来源有两个(Token 与数据库),行为不确定;V2 只有一个来源,行为可解释。
稳定性:V1 的表结构会随上传内容膨胀,长期不可控;V2 的字段长度固定,容量可预估。
复杂度:V1 单进程就能跑,V2 需要同时起 MySQL、Spring Boot、LiveKit、Bridge;这是明确付出的成本。
成本:V2 多了一台 LiveKit 和一次网络往返,但在本地开发环境几乎无感。具体延迟数据需要在真实环境实测,本章不给出估算值。
适用范围:V2 适合需要长期演进、要逐步接入 Agent 能力的项目;一次性演示用 V1 更省事。
遗留问题:Bridge 目前同时面对 WebSocket 与 LiveKit 两条输入,尚未收敛到单通道;Agent 配置只有三个字段,缺少版本与灰度能力。
V1 各自为战
V2 统一宿主
得到可演进
失去炫技感
09、洞见
9.1 边界比能力重要
反直觉判断:在数字人项目里,先把“谁不负责什么”写清楚,比先实现一个能说话的功能更有价值。
理由是能力可以逐个加,边界一旦定错就要推倒重来。本章花了大量篇幅在分工上:LiveKit 不做配置,Bridge 不做推理,MySQL 不存媒体。这三句话看起来平淡,但它们决定了后面 26 章能不能只改一层。
9.2 薄桥是最高性价比的设计
反直觉判断:Bridge 写得越少,接住的能力越多。
一个只有消息适配的 Bridge,在接入 Memory 时不需要改;一个已经在 Bridge 里做了对话历史的实现,接入 Memory 时就必须先决定“两份历史谁是主”。薄不是偷懒,是把复杂度推回它该待的地方。
9.3 配置不要过早泛化
Agent 配置只留三个字段,看起来很“不够用”。但如果本章就把 tools、memory、skills 都做成 JSON 字段塞进 agent_config,后面每一式都要面对一个没人填过的巨型 schema。
正确的顺序是:能力出现时再扩字段,而不是先猜能力长什么样。代价是每加一个能力都可能改表,这比维护一个空壳 schema 划算。
9.4 什么时候不应该用本章这套底座
这套底座有明确的不适用场景。
如果你只是要做一个本地演示,不需要实时音视频,直接一个 HTTP 接口加一个页面就够了,引入 LiveKit 与 Bridge 是净负担。
如果你的产品已经有成熟的 IM 或音视频体系,正确的做法是把 Agent 当作一个消息处理器接进去,而不是再起一套房间模型。
如果需求里只有“文本问答”,没有多轮实时语音,本章的实时链路可以直接砍掉,管理链路单独就是一套完整的配置管理系统。
边界比能力重要
薄桥性价比最高
配置别过早泛化
不适用就别硬上
10、系统落地
原来有什么:一堆各自独立、无法累积的教程 Demo。
本篇新增了什么:一个可以长期演进的宿主工程。管理链路有了用户、项目、Agent 配置三张表和四个应用服务;实时链路有了 LiveKit 房间与最薄的 Realtime Bridge;运行页 /run/{projectId} 成为后续所有能力的统一验收入口。
现在能做什么:注册登录、增删改查项目、配置标题/配色/背景图/开场白/结束语、配置最小 Agent、从运行页启动一次会话并完成一轮文本或语音交互。
还缺什么:推理层还是直连 LLM 的最小实现,没有 Agent 抽象、没有 Tool、没有 MCP、没有 Memory、没有 Skill、没有 Plan、没有 Subagent,也没有多 Agent 与沙箱。
下一步如何演进:下一式把 DirectLlmRuntime 换成基于 AgentScope Java v2.0.3 的推理入口,AgentRuntime 接口与 AgentSpec 保持不变。这样 Bridge、运行页、数据模型都不需要动,能力是在一个点上长出来的,不是散着长的。
开源参与点:本章未发现明确上游问题。原因是本章没有触达 AgentScope Java 的运行时代码,问题都出在教程自己的设计上;LiveKit 侧只用到房间与 Token 签发,属于常规用法。后续章节一旦在最小 Demo 中复现出框架行为异常,再按“先教程复现、再对比 main、再检索 Issue”的顺序推进。
底座已就绪
能力尚未进
下一步接 Agent
缺口在推理层
11、小结
Q1 → 底座为什么必须先冻结
Q2 → LiveKit 与 AgentScope Java 的边界怎么切
Q3 → 运行页凭什么成为统一验收入口
状态 → chapter/03-digital-human-demo 可运行的最小数字人底座
Q1 → 因为后续 26 章的能力是叠加关系,不是并列关系。宿主不稳定,每一式都得先重写上一式的宿主,教程讲不深,工程也没法复用。
Q2 → LiveKit 只负责 WebRTC Room 与实时音频/数据传输,Token 只承载身份与房间授权;AgentScope Java 负责推理与全部 Agent 能力;Bridge 只做媒体与消息适配,不持有业务状态。
Q3 → 因为它是唯一能同时验证配置、实时链路和推理的入口。管理端的增删改查只能证明“数据存对了”,只有运行页能证明“用户真的完成了一次交互”。
状态 → 本章交付一个可注册登录、可管理项目与 Agent 配置、可从运行页发起会话完成一轮交互的最小数字人底座,分支 chapter/03-digital-human-demo,建议提交 feat(ch03): bootstrap minimal interactive digital human demo。
三问已有答
状态可交付
分支能续写
下一步不停
12、作业
12.1 理解题:为什么 Agent 配置只保留三个字段?
参考答案:因为本章的目标是冻结宿主边界,不是穷举能力。Tool、MCP、Memory、Skill、Plan、Subagent 都还没进入教程,此时设计与它们对应的字段只能靠猜。猜出来的 schema 会长期存在但没人真正使用,反而增加维护成本。正确做法是能力出现时再扩字段。
12.2 实战题:为运行页补一个“项目未配置 Agent”的降级路径,并说明验收方式。
参考答案:在 RuntimeController 申请 Token 时校验 agent_config 是否存在;若不存在,返回 AGENT_NOT_CONFIGURED,运行页展示提示并引导跳转到 Agent 配置页。验收方式是删掉某项目的 Agent 配置后访问 /run/{projectId},页面不进入房间而是展示引导;恢复配置后再次访问可以正常进入。
12.3 排障题:运行页能加入房间但收不到回复,请给出至少三条排查路径。
参考答案:第一,检查 Bridge 的 WebSocket 是否真的连上,看服务端是否记录到连接建立;第二,检查 projectId 在消息里是否传对,Bridge 查配置用的是不是同一个 ID;第三,检查 Bridge 到 LLM 的调用是否返回非 200,很多“没回复”其实是上游返回了错误被静默吞掉;第四,检查运行页的回调是不是挂在正确的事件上。
12.4 架构判断题:如果把 LiveKit 换成自研 WebSocket 音频通道,本章哪些代码需要改,哪些不需要改?
参考答案:需要改的是 RuntimeTokenService 与运行页的连接部分,因为它们直接依赖 LiveKit 的房间与 Token 模型。不需要改的是 AuthService、ProjectService、AgentConfigService、agent_config 表与 AgentRuntime 接口——这些都不关心传输层是什么。这道题也可以在读完下一式后回头再看:打开 https://github.com/agentscope-ai/agentscope-java 与 https://docs.livekit.io/agents/ ,核验 Agent 运行时与实时通信层的职责划分是否真的可以互换。
四题见深浅
源码要核验
边界要能画
判断要能说
13、思考
回到开篇的冲突:底座到底要做多大。答案不是“越大越好”或“越小越好”,而是“边界清楚的前提下

浙公网安备 33010602011771号