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”,说不了“用户真的完成了一次交互”。

底座的完成标准必须可验证,本章定义为四条:

  1. 能注册、能登录,未登录访问项目接口返回 401;
  2. 能创建、编辑、删除项目,能配置标题、主题色、背景图 URL、开场白、结束语;
  3. 能为项目配置最小 Agent,只填 agentName、model、systemPrompt;
  4. 能从 /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 做推理。

sequenceDiagram participant W as Web 运行页 participant S as Spring Boot participant L as LiveKit participant B as Realtime Bridge participant A as AgentScope Java participant M as LLM W->>S: POST /api/projects/{id}/runtime/token S-->>W: url + roomName + roomToken W->>L: 加入 Room (WebRTC) L-->>W: 房间就绪 W->>B: 文本/音频消息 B->>A: 归一化后的输入 A->>M: 推理请求 M-->>A: 模型输出 A-->>B: 回复 B-->>W: 数据消息 / 音频

边界:

  • 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、思考

回到开篇的冲突:底座到底要做多大。答案不是“越大越好”或“越小越好”,而是“边界清楚的前提下

posted @ 2026-10-11 11:18  李福春  阅读(3)  评论(0)    收藏  举报