降SpringAI阿里第3掌-见龙在田-数字人底座

降SpringAI阿里第3掌-见龙在田-数字人底座

模型能跑通,不等于产品能跑起来。

[读者] 正在把 AI 数字人/语音助手往生产推的 Java 工程师
[痛点] 示例散、链路断,模型调用和实时音频各写一套,越写越重
[现在读] 后面 15 章都要复用同一套底座,必须先把契约冻住
[读完] 能跑通注册登录、项目 CRUD、运行页与最小语音会话闭环
[旧方案] 单文件 Demo,前端直连大模型,语音再单独写一套脚本
    |
    v
[新需求] 管理链路与实时链路分离,后续持续叠加 Tool/RAG/Agent/Graph
    |
    v
[冲突] 边界、表结构、会话上下文都没冻结,每章都要重造一次底座
    |
    v
[后果] 示例越写越肥,主线失焦,走不到多 Agent、A2A 与 K8s

我是老李,在深圳写了十几年 Java,最近两年基本都在做 AI 应用落地。手上这套教程仓库 springaialibabapractice 已经走到第 3 掌,前两掌把选型和环境底子铺完了,这一掌要动真格:把后面 15 章都要共用的那个“最小交互数字人 Demo”冻下来。

本篇的事实依据来自三处一手入口:Spring AI Alibaba 的 v1.1.2.2 Release、spring-ai-alibaba/examples 仓库,以及 LiveKit 官方的 Agents 文档。本系列固定使用 alibaba/spring-ai-alibaba v1.1.2.2、JDK 21 LTS。需要提前说明的是,文中涉及的 LiveKit Agents 插件名、参数名与 token SDK 形态,随版本演进较快,请以当前官方文档核验;未实测的部分我会显式标注。

底座到底该做到多细,才算“够用”?

实时链路里,理解推理该塞进 LiveKit 里,还是放回 Spring Boot?

如果这一掌的边界画错了,后面 15 章会付出什么代价?

01、故事

老李带的是一个三人小队,接了个很典型的需求:客户要在网页上点开一个“数字人主播”,既能打字提问,也能直接开口说话,主播要把答案念出来。需求方给的时间是两周,而老李心里清楚,真正的目标不是这两周,而是这套东西后面还要长出 Tool、RAG、ReactAgent、Graph、多 Agent、A2A,最后上 K8s。

任务是:在两周内交付一个能被后续 15 章复用的最小交互数字人 Demo,并且把它的契约、表结构和链路边界一次性冻住。

当时手里的方案有两份代码。一份是 Spring Boot 的单体示例,页面直接拿密钥去调大模型,问答能通,但项目、话术、外观这些配置全写死在 HTML 里;另一份是本地跑的 Python 语音脚本,STT 和 TTS 效果都不错,可它跟业务库完全没关系,连“这个回答属于哪个项目”都说不清。

变化发生在教程策划会上。后面要从第 4 掌一路走到第 18 掌,每一掌都要在这套 Demo 上叠加能力。如果底座不先冻住,第 5 掌加记忆时要改表,第 6 掌加 Tool 时要改上下文,第 11 掌接 Graph 时可能要推翻会话模型,那不是教学,那是返工展览。

冲突就摆在眼前:业务范围必须砍到最小,但链路边界必须一次画对。砍少了,后面每一章都在补底座;砍多了,这一掌就写成了产品经理的需求文档。

老李最后定下的方案是六件事,一件不多:注册/登录、数字人项目 CRUD、标题/主题色/背景图、开场白/结束语、最小 Agent 配置、Web 运行页 /run/{projectId}。语音先做“能用”,口型、复杂视频、多租户、计费全部不进本系列。分支开在 chapter/03-digital-human-demo,提交信息写作 feat(ch03): bootstrap minimal interactive digital human demo。

底座不先冻
后章皆重来
一横定南北
一竖定始终

02、问题

旧方案失效得很彻底。前端直连模型的那版,密钥挂在浏览器里,任何会按 F12 的人都能拿走;配置写死在页面上,想给第二个客户换一套开场白,只能复制一份 HTML。Python 语音脚本那版更尴尬,它回答得挺流畅,但它根本不知道自己在给哪个项目打工。

业务影响是具体的。运营同学要改一句开场白,得找开发改代码重新发版;客户追问“这周谁用了多少次”,数据库里查不到任何记录;教研想把同一套知识换个角色形象,只能新建一个仓库。

技术上,问题集中在三处。第一,配置、会话、模型调用三样东西耦合在一个进程里,谁改都牵动谁。第二,实时音频链路和业务链路混在一起,音频一断,业务逻辑也跟着挂。第三,没有稳定的项目主键,导致后续想做记忆隔离、知识库隔离、Agent 隔离,全都无从下手。

所以这一掌的完成标准必须是可验证的,不是“感觉做完了”。老李列了五条验收线:能注册、能登录、登录态能带住;能建项目、能改项目、能删项目;标题、主题色、背景图、开场白、结束语能存能取;/run/{projectId} 能加载出这个项目的完整运行配置;文本能问答、语音能一问一答,形成最小闭环。

五条线里,前四条是管理面,最后一条是实时面。真正难的是最后一条,因为它第一次把“Spring Boot 的业务世界”和“LiveKit 的实时世界”接在了一起。

边界说不清
验证无从谈
标准先立住
交付才可期

03、原理

这一掌真正要讲清楚的原理只有一个:控制面与数据面分离,并且在实时链路里做一次“三明治”切分。

官方 Voice Agent 的典型形态是 STT 负责把声音转文字、LLM 负责理解与生成、TTS 负责把文字转回声音,三段串起来就是三明治。Spring AI Alibaba v1.1.2.2 的语音能力正是借鉴这条链路,把中间的“理解与生成”交给 SAA,两侧的听和说交给语音服务。开发期用 DashScope 的 ASR 与 CosyVoice TTS 就够了,先不追口型与视频合成。

反直觉判断:把 LLM 放在 LiveKit Agent 进程里直连模型,看起来延迟更短、代码更少,但对于本系列来说是错的。因为从第 6 掌开始,Tool 要注册、RAG 要检索、第 9 掌要 ReactAgent、第 11 掌要 Graph Runtime,这些东西长在 Spring Boot 侧。如果 Bridge 自己直连模型,就会长出第二套上下文与第二套工具注册表,后面每一掌都要改两遍。

把理解推理收回 Spring Boot,Bridge 只做音频编排和协议适配,代价是多了一跳 HTTP,收益是后续所有 Agent 能力只在一个地方生长。这个取舍在小 Demo 里看不出好,在第 14 掌读源码、第 17 掌看观测时就能看出好——你只需要观测一个点。

第二个反直觉判断跟表有关:先冻结三张最小表,比先冻结接口更有效。因为接口可以重命名、可以加字段,但表结构一旦被后续章节依赖,改动成本会指数上升。users、digital_human_project、agent_config 三张表定下来,projectId 就成了后面记忆隔离、知识库隔离、Agent 隔离、会话隔离的公共锚点。没有这个锚点,第 5 掌的会话记忆就只能写内存,第 8 掌的 RAG 就只能全局共享,第 12 掌的多 Agent 就只能靠命名约定。

第三个原理是 LiveKit 的定位要守住:它负责 WebRTC 连接、房间、音轨与数据通道,负责把浏览器和后台连起来,但它不负责理解、不负责推理、不负责工具调用。这条守住了,实时链路就能在 Agent 框架升级时保持稳定。

三明治切片
中间归业务
两侧管听说
边界即架构

04、架构

[输入] 浏览器 /run/{projectId} 页面请求、/api/** 管理请求、房间内音频帧
    |
    v
[模块] Spring Boot(Auth / Project / Runtime / AgentBridge)+ LiveKit Room + Bridge Worker
    |
    v
[数据/状态] MySQL(users、digital_human_project、agent_config)+ Room 元数据(projectId:sessionId)
    |
    v
[处理] 管理链路:CRUD 落库;实时链路:WebRTC 音频 → STT → SAA ChatClient → TTS
    |
    v
[输出] 运行页配置、文本回复、合成语音回放

这张图有两条独立链路,这是本掌最重要的结构决策。

管理链路的边界很窄:Web 页面 → Spring Boot → MySQL,只干 CRUD 和鉴权,不出网、不调模型、不碰音频。它的收益是稳定、可测、可回滚,代价是每次读配置都要一次数据库往返,运行页首屏会多几毫秒,对本场景完全可接受。

实时链路的边界是:Web ⇄ LiveKit ⇄ Realtime Bridge ⇄ Spring Boot/SAA。LiveKit 只做 WebRTC 与实时音频/数据传输,Bridge 只做音频编排与协议转换,理解、推理、Tool、RAG、Agent 全部压在 Spring Boot 侧。收益是能力集中、上下文唯一、后续演进不用动实时链路;代价是多了一跳内网请求,以及 Bridge 需要额外部署一个进程。

适用条件要写清楚:这套结构适合“业务逻辑会持续长、实时交互是其中一环”的项目。如果只是做一个纯语音对讲 Demo,模型直连更省事;如果业务规则极少、也不打算接工具与知识库,那这套分层就是过度设计。

还有一条隐性边界:agent_config 与 digital_human_project 是一对一。这不是技术限制,而是这一掌刻意做的收敛——把 Agent 配置做成项目的一个属性,后面第 9 掌要把 ReactAgent 引入时,只需要把 agent_config 扩展成配置来源,不需要重画主键关系。

两链各安位
一锚定全盘
少即是边界
稳即是速度

05、实战一次

环境与版本

JDK 21 LTS、Spring Boot 3.x、MySQL 8.x、alibaba/spring-ai-alibaba v1.1.2.2、LiveKit Server(本地 Docker 起)、Python 3.11+ 跑 Bridge。Spring Boot 与 LiveKit 的具体小版本请以官方文档与 Release 页面为准。以下代码未在当前环境实测,输出为预期结果,标注为“示例输出”。

依赖

<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>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>
</dependency>

SAA 侧的 DashScope starter 坐标以官方文档为准,本掌只用到 ChatClient 的最小能力。

配置

spring:
  datasource:
    url: jdbc:mysql://127.0.0.1:3306/digital_human?useUnicode=true&characterEncoding=utf8mb4
    username: root
    password: root
  jpa:
    hibernate:
      ddl-auto: validate

livekit:
  url: ws://127.0.0.1:7880
  api-key: devkey
  api-secret: secret

server:
  port: 8080

核心实现

先落三张最小表。

CREATE TABLE users (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  username VARCHAR(64) NOT NULL UNIQUE,
  password_hash VARCHAR(100) NOT NULL,
  display_name VARCHAR(64),
  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE digital_human_project (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  owner_id BIGINT NOT NULL,
  name VARCHAR(128) NOT NULL,
  title VARCHAR(128) NOT NULL,
  theme_color VARCHAR(16) NOT NULL DEFAULT '#2F6BFF',
  background_url VARCHAR(512),
  opening_line VARCHAR(512),
  closing_line VARCHAR(512),
  status VARCHAR(16) NOT NULL DEFAULT 'DRAFT',
  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  KEY idx_owner (owner_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE agent_config (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  project_id BIGINT NOT NULL UNIQUE,
  model VARCHAR(64) NOT NULL DEFAULT 'qwen-plus',
  system_prompt TEXT NOT NULL,
  temperature DECIMAL(3,2) NOT NULL DEFAULT 0.70,
  max_tokens INT NOT NULL DEFAULT 1024
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

注册与登录用 BCrypt 加内存态会话,仅用于 Demo,生产必须换成正式认证方案。

@RestController
@RequestMapping("/api/auth")
public class AuthController {
    private final UserRepository users;
    private final BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
    private final Map<String, Long> sessions = new ConcurrentHashMap<>();

    public AuthController(UserRepository users) {
        this.users = users;
    }

    public record AuthRequest(String username, String password) {}

    @PostMapping("/register")
    public ResponseEntity<?> register(@RequestBody AuthRequest req) {
        if (req.username() == null || req.password() == null || req.password().length() < 8) {
            return ResponseEntity.badRequest().body(Map.of("error", "username/password invalid"));
        }
        if (users.findByUsername(req.username()).isPresent()) {
            return ResponseEntity.status(409).body(Map.of("error", "username exists"));
        }
        User u = new User();
        u.setUsername(req.username());
        u.setPasswordHash(encoder.encode(req.password()));
        u.setDisplayName(req.username());
        users.save(u);
        return ResponseEntity.ok(Map.of("id", u.getId(), "username", u.getUsername()));
    }

    @PostMapping("/login")
    public ResponseEntity<?> login(@RequestBody AuthRequest req) {
        return users.findByUsername(req.username())
            .filter(u -> encoder.matches(req.password(), u.getPasswordHash()))
            .map(u -> {
                String token = UUID.randomUUID().toString().replace("-", "");
                sessions.put(token, u.getId());
                return ResponseEntity.ok(Map.of("token", token, "userId", u.getId()));
            })
            .orElseGet(() -> ResponseEntity.status(401).body(Map.of("error", "bad credentials")));
    }

    public Long resolve(String token) {
        return token == null ? null : sessions.get(token);
    }
}

项目 CRUD 与运行页配置读取。

@RestController
@RequestMapping("/api/projects")
public class ProjectController {
    private final DigitalHumanProjectRepository projects;
    private final AgentConfigRepository configs;
    private final AuthController auth;

    public ProjectController(DigitalHumanProjectRepository projects,
                             AgentConfigRepository configs,
                             AuthController auth) {
        this.projects = projects;
        this.configs = configs;
        this.auth = auth;
    }

    @PostMapping
    public ResponseEntity<?> create(@RequestHeader("X-Token") String token,
                                    @RequestBody DigitalHumanProject input) {
        Long uid = auth.resolve(token);
        if (uid == null) {
            return ResponseEntity.status(401).body(Map.of("error", "unauthorized"));
        }
        input.setId(null);
        input.setOwnerId(uid);
        DigitalHumanProject saved = projects.save(input);
        AgentConfig cfg = new AgentConfig();
        cfg.setProjectId(saved.getId());
        cfg.setSystemPrompt("你是一个专业的数字人主播,回答简洁友好。");
        configs.save(cfg);
        return ResponseEntity.ok(Map.of("id", saved.getId()));
    }

    @GetMapping("/{id}/runtime")
    public ResponseEntity<?> runtime(@PathVariable Long id) {
        return projects.findById(id).map(p -> {
            AgentConfig cfg = configs.findByProjectId(id).orElseThrow();
            return ResponseEntity.ok(Map.of(
                "project", p,
                "agent", cfg
            ));
        }).orElseGet(() -> ResponseEntity.status(404).body(Map.of("error", "not found")));
    }

    @PutMapping("/{id}")
    public ResponseEntity<?> update(@PathVariable Long id,
                                    @RequestHeader("X-Token") String token,
                                    @RequestBody DigitalHumanProject input) {
        Long uid = auth.resolve(token);
        if (uid == null) {
            return ResponseEntity.status(401).body(Map.of("error", "unauthorized"));
        }
        return projects.findById(id)
            .filter(p -> p.getOwnerId().equals(uid))
            .map(p -> {
                p.setTitle(input.getTitle());
                p.setThemeColor(input.getThemeColor());
                p.setBackgroundUrl(input.getBackgroundUrl());
                p.setOpeningLine(input.getOpeningLine());
                p.setClosingLine(input.getClosingLine());
                return ResponseEntity.ok(projects.save(p));
            })
            .orElseGet(() -> ResponseEntity.status(404).body(Map.of("error", "not found")));
    }
}

运行页只需要一份可读配置加一个 LiveKit token。

@RestController
@RequestMapping("/api/livekit")
public class LiveKitTokenController {
    private final LiveKitProperties props;
    private final AuthController auth;

    public LiveKitTokenController(LiveKitProperties props, AuthController auth) {
        this.props = props;
        this.auth = auth;
    }

    @PostMapping("/token")
    public ResponseEntity<?> token(@RequestHeader("X-Token") String token,
                                   @RequestParam Long projectId) {
        Long uid = auth.resolve(token);
        if (uid == null) {
            return ResponseEntity.status(401).body(Map.of("error", "unauthorized"));
        }
        String room = "dh-" + projectId;
        AccessToken at = new AccessToken(props.getApiKey(), props.getApiSecret());
        at.setIdentity("user-" + uid);
        at.setName("dh-user-" + uid);
        at.addGrants(new RoomJoin(true), new RoomName(room));
        return ResponseEntity.ok(Map.of(
            "url", props.getUrl(),
            "room", room,
            "token", at.toJwt()
        ));
    }
}

Spring Boot 侧再暴露一个 OpenAI 兼容端点,让 Bridge 用标准协议接入 SAA,上下文由 Spring 侧统一组装。

@RestController
@RequestMapping("/internal/llm/v1")
public class InternalLlmController {
    private final AgentConfigRepository configs;
    private final ChatClient chatClient;

    public InternalLlmController(AgentConfigRepository configs, ChatClient.Builder builder) {
        this.configs = configs;
        this.chatClient = builder.build();
    }

    @SuppressWarnings("unchecked")
    private String lastUserMessage(Map<String, Object> body) {
        List<Map<String, Object>> messages = (List<Map<String, Object>>) body.get("messages");
        String text = "";
        if (messages != null) {
            for (Map<String, Object> m : messages) {
                if ("user".equals(m.get("role"))) {
                    Object c = m.get("content");
                    text = (c instanceof String s) ? s : String.valueOf(c);
                }
            }
        }
        return text;
    }

    @PostMapping("/chat/completions")
    public Map<String, Object> completions(@RequestBody Map<String, Object> body) {
        String user = String.valueOf(body.getOrDefault("user", "1:-"));
        Long projectId = Long.valueOf(user.split(":")[0]);
        AgentConfig cfg = configs.findByProjectId(projectId).orElseThrow();
        String answer = chatClient.prompt()
            .system(cfg.getSystemPrompt())
            .user(lastUserMessage(body))
            .call()
            .content();
        return Map.of(
            "id", "chatcmpl-local",
            "object", "chat.completion",
            "choices", List.of(Map.of(
                "index", 0,
                "message", Map.of("role", "assistant", "content", answer),
                "finish_reason", "stop"
            ))
        );
    }
}

Bridge 侧的 Python 代码把三明治串起来。插件名与参数随版本变化,需按当前官方文档核验。

import os
from livekit.agents import Agent, AgentSession, JobContext, WorkerOptions, cli
from livekit.plugins import openai

SPRING_BASE = os.environ["SPRING_BASE"]

class DigitalHuman(Agent):
    def __init__(self, opening_line: str):
        super().__init__(instructions="你是一个数字人主播,回答简洁。")
        self.opening_line = opening_line

    async def on_enter(self):
        await self.session.generate_reply(
            instructions=f"请用这句话开场:{self.opening_line}"
        )

async def entrypoint(ctx: JobContext):
    await ctx.connect()
    meta = ctx.room.metadata or "1:-"
    project_id, session_id = (meta.split(":") + ["-"])[:2]

    session = AgentSession(
        stt=dashscope.STT(),
        llm=openai.LLM(
            base_url=SPRING_BASE + "/internal/llm/v1",
            api_key="internal",
            model="saa-agent",
            user=f"{project_id}:{session_id}",
        ),
        tts=dashscope.TTS(voice="cosyvoice-v1"),
    )

    await session.start(
        room=ctx.room,
        agent=DigitalHuman("你好,我是这个项目的数字人主播。"),
    )

if __name__ == "__main__":
    cli.run_app(WorkerOptions(entrypoint_fnc=entrypoint))

启动

三步:mvn spring-boot:run 起后端;docker run 起 LiveKit Server;python agent.py dev 起 Bridge。

请求

curl -X POST localhost:8080/api/auth/register \
  -H 'Content-Type: application/json' \
  -d '{"username":"lifuchun","password":"password123"}'

curl -X POST localhost:8080/api/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"username":"lifuchun","password":"password123"}'

curl -X POST localhost:8080/api/projects \
  -H 'Content-Type: application/json' -H 'X-Token: <token>' \
  -d '{"name":"demo-1","title":"知识主播","themeColor":"#2F6BFF","openingLine":"很高兴见到你","closingLine":"今天就聊到这里"}'

curl 'localhost:8080/api/livekit/token?projectId=1' \
  -H 'X-Token: <token>'

验证

示例输出:注册返回 {"id":1,"username":"lifuchun"};登录返回 {"token":"...","userId":1};建项目返回 {"id":1};token 接口返回带 url、room、token 的 JSON。浏览器打开 /run/1,页面按 themeColor 着色、显示 title 与 backgroundUrl,文本提问能拿到回答,语音问答能形成一问一答。这一条通过,第 3 掌的底座就算立住了。

三表定乾坤
两链各归一
跑通即证据
闭环即验收

06、排查

第一条诊断链。现象是运行页拿到了 token,点“开始对话”后一直连不上房间,控制台报连接超时。怀疑是 LiveKit Server 没起来,检查容器状态发现进程正常;再怀疑端口,检查 7880 端口监听正常。继续查返回的 token 内容,解码后发现 grant 里没有房间加入权限。证据是 token 的 payload 缺少 roomJoin。根因是构造 token 时只设置了 identity,忘了加 RoomJoin 与 RoomName。修复后返回 room 为 dh-1,浏览器成功入房。这里要注意,livekit-server 的 AccessToken API 形态随版本变化,需按当前官方文档核验。

第二条诊断链。现象是语音能听见,但回答驴唇不对马嘴,而且每次换项目问同样的话,回答口吻完全一样。怀疑是 STT 转写错了,检查 Bridge 日志发现转写文本正确;再怀疑模型本身,直接用 curl 调 /internal/llm/v1/chat/completions,传 user=1:- 与 user=2:-,两次返回完全一致。证据是 agent_config 表里项目 1 与项目 2 的 system_prompt 确实不同,但请求里 projectId 没被正确解析,split 后取到的默认值是 1。根因是 Bridge 侧 room metadata 没有按 projectId:sessionId 写入,user 字段恒为默认值。修复是创建房间时显式写入 metadata,并在 Bridge 启动时校验解析结果。

错误尝试: 一开始的修法是让 Bridge 直接读 agent_config 的数据库,绕开 Spring Boot。这个做法是错的,原因有三层:一是 Bridge 是 Python 进程,直连 MySQL 会把数据访问凭据扩散到第二个服务;二是从第 6 掌开始 Tool 与 RAG 都在 Spring 侧,第二套上下文一定会漂移;三是第 17 掌做观测时,两条链路各记一套日志,排障成本翻倍。

先疑后查证
证据定根因
图省事者败
走弯路者明

07、优化

根因清晰之后,V2 的改动只做三件事,全部来自第 06 章的证据。

第一件,上下文组装权收归 Spring Boot。Bridge 只传 projectId 与 sessionId,System Prompt、模型名、温度、最大 token 全部由 Spring 侧从 agent_config 读取后注入。这样做的原因是让“角色设定”只有一个真实来源,新行为是同一句话在不同项目下会得到不同口吻的回答,验证方式是同一问题在两个项目下分别调用,回答风格应随 system_prompt 变化。

第二件,token 签发收紧。签发时限定房间名为 dh-{projectId},并设置有效期与身份前缀,避免一个 token 进所有房间。原因是 Demo 阶段最容易留下的隐患就是权限过宽,新行为是拿项目 1 的 token 无法进入项目 2 的房间,验证方式是跨房间连接应被拒绝。

第三件,异常路径显式化。/internal/llm/v1/chat/completions 在项目不存在、配置缺失、模型调用失败三种情况下,分别返回不同错误标识,Bridge 侧据此播报不同的兜底话术。原因是语音场景下把异常静默掉,用户只会听到长久的沉默,体验比报错更糟。验证方式是构造缺失配置的项目,运行页应播报兜底提示而不是卡死。

三件改动都没有引入新组件,也没有引入新的表。这正是第 03 章那个判断的兑现:表结构冻对了,优化就只需要改流程,不需要改数据。

权归一处使
证由两端出
不添新组件
只把边界清

08、演进

[同一输入] 运行页发起一次语音提问
    |
    +--[V1] Bridge 直连模型 / 上下文来自默认配置 / 代价:多套上下文,项目隔离失效
    |
    +--[V2] Bridge 只传 projectId / 上下文由 Spring Boot 组装 / 代价:多一跳内网调用
    |
[Trade-off]
得到:上下文唯一、能力集中、后续 Agent 演进不必动实时链路
失去:端到端少一跳的极致延迟、Bridge 的“自包含”部署便利
适用边界:业务逻辑会持续生长的交互型应用;纯对讲 Demo 可退回 V1

正确性上,V1 在单项目下看不出问题,多项目就会串味,V2 从一开始就把 projectId 作为公共锚点,正确性边界更清楚。稳定性上,V1 把模型调用压力放在 Bridge,Bridge 重启会丢上下文,V2 把状态收在 Spring 侧,Bridge 变成无状态编排器,可随时重启。

复杂度上,V2 多了一个内部端点与一次协议转换,代码量更大,但复杂度集中在一个 Controller 里,而不是散落在两个语言两个进程。成本上,V2 多一跳内网请求,在局域网内通常可以接受;如果实测发现延迟不可接受,可以在后续章节用流式响应缓解,而不是把 LLM 挪回 Bridge。

适用范围上,这套结构适合“业务会成长”的场景;如果只是做一次性演示、答完即弃,V1 更省事。遗留问题有三个:内存态会话在重启后失效,属于 Demo 有意留的口子;Bridge 目前只处理线性对话,没有接记忆与工具,这是第 5、6 掌的入口;agent_config 与项目一对一,多 Agent 场景要扩表,这是第 12 掌的入口。

旧法省一跳
新法省全局
取舍有边界
演进有来路

09、洞见

9.1 底座的正确形态是“契约”,不是“代码量”

这一掌交付的代码并不多,真正值钱的是三个契约:projectId 作为公共锚点、管理链路与实时链路分离、理解推理只长在一处。契约定对了,后面 15 章是在同一个坐标系里加东西;契约定错了,每一章都要重新找原点。反直觉判断:底座做得越薄,后面加得越快,因为薄的底座约束少、接口面小,反而更容易扩展。

9.2 实时链路里,“少一跳”常常是负优化

把 LLM 挪进 Bridge 省掉一跳网络,理论上更快,工程上更贵。反直觉判断:在会持续演进的项目里,多一跳内网调用的代价,远小于多一套上下文的代价。因为延迟可以优化,上下文漂移只能重构。这个判断在第 06 章排名第二的故障里被验证过——项目隔离失效不是性能问题,是结构问题。

9.3 三明治的关键不在三段,而在中间那段归谁

STT → LLM → TTS 是三明治,但真正决定架构的不是分段,而是中间那段归哪个进程。中间段归 Bridge,Bridge 就成了第二业务中心;中间段归 Spring,Bridge 就退化为适配器。后面接 Tool、RAG、ReactAgent、Graph 时,这个归属决定了你要改一处还是改两处。

9.4 先冻表,再冻接口

接口是软的,表是硬的。接口可以加字段、可以重命名、可以加版本;表一旦被依赖,改动要连着迁移脚本与数据。所以最小底座应该先把 users、digital_human_project、agent_config 定下来,让 projectId 提前成为所有后续能力的挂载点。这一点在第 5 章的记忆隔离和第 8 章的 RAG 隔离上会立刻兑现。

9.5 砍功能的判断力,比加功能的执行力更稀缺

口型、复杂视频、多租户、计费都被明确排除。这不是偷懒,而是把“这一掌能不能被验证”放在“这一掌看起来有多完整”之前。一个能在两周内跑通闭环的最小系统,比一个三个月也验收不完的完整系统更适合做教学底座。

契约重于码
归属定架构
表先于接口
减法即能力

10、系统落地

原来有什么?有两个彼此不相干的半成品:一个只能跑通模型问答的 Spring Boot 页面,一个只能本地出声的语音脚本。它们各自能演示,合起来不能交付。

本篇新增了什么?三张最小表、五个管理接口族、一个 OpenAI 兼容的内部推理端点、一个运行页配置读取路径、一条通往 LiveKit 房间的 token 签发链路,以及一个只做音频编排的 Bridge。同时冻结了实时链路的走向:Web ⇄ LiveKit ⇄ Realtime Bridge ⇄ Spring Boot/SAA。

现在能做什么?能注册登录、能建改

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