斗破Google ADK Java苍穹-第10境斗圣-开界控场-Live与Eval

斗圣开界:Live实时语音与数字人可回归试炼场

文字Demo能跑,语音却不可控,改一行Prompt都不知道退没退化。

[读者] 把ADK数字人从文本推进到实时语音的Java工程师
[痛点] 一打断就乱,换模型或改Prompt后无法判断是否退化
[现在读] Live入口与观测能力已就位,必须把语音和评测一起收进工程
[读完] 能用runLive跑通实时语音,并用JUnit Golden Case和指标守住质量底线
[旧方案] 文本单轮Demo + 人工试听确认
    |
    v
[新需求] 实时语音交互,且每次升级都要有可回归质量证据
    |
    v
[冲突] 实时链路是流式、有状态、随时被打断的,人工试听给不出证据
    |
    v
[后果] 每次换模型、改Prompt都在赌,退化只能等用户投诉才发现

我是老李,在广州带一个六人小团队,做企业内部的数字人助手。前面九境,我们把 ADK Java 的数字人从一张需求图,养到了能跑 Workflow、能借 MCP 和 A2A 跨进程协同的程度。到斗圣这一境,业务方说了一句很轻、但很重的话:能不能让它在会客厅里听人说话。

这句话把我们从"文本助手"逼成了"实时交互体",也把过去九境里所有的偷懒一次性暴露出来。

现在的方案还是老样子:一个 Spring Boot 接口,收一段文本,调一次模型,返回一段文本;语音只在演示视频里出现过,靠的是前端播放预先录好的 TTS 音频。

任务是把这条链路改造成真实可用的实时语音:用户对着浏览器说话,数字人边听边说,说完能记住上下文,被打断能停下,断线了能接回来。但真正难的地方不在"能不能出声"。

实时语音这条链路,状态到底归谁管——是媒体层,还是 ADK Runtime?

评测是继续靠人工试听,还是挂到 JUnit 与 Golden Case 上?

如果评测和指标都不落地,下一次换模型时,你拿什么证明它没有退化?

01、故事

周五下午两点,我们第一次把数字人接进 LiveKit 房间。浏览器页面打开,麦克风亮起,我说了句"你好",数字人回了一句问候,声音自然,停顿也像人。会议室里有人鼓掌。

三分钟后,掌声停了。

我先说了半句话停住,又接着说完。数字人在我说到一半的时候仍在输出,两个声音叠在一起。它没有停。

再往后,我故意把笔记本的 Wi-Fi 关掉五秒。重连回来,数字人重新问了一遍"你好,有什么可以帮您"。它把刚才那两分钟全忘了。

散会后,团队三个人给了三种解释:小周说是模型不行,换个更强的就行;阿珊说是 Prompt 没写清楚,加一句"用户说话时要停下";运维的同学说这是网络抖动,跟代码没关系。

三个人可能都不算错,但他们都没回答我真正关心的问题。这次改动,比昨天那版好,还是差?

我们手里唯一的证据,是刚才那三分钟的体感。体感不是证据。

任务是把它从"能跑的文字 Demo"升级成"能交谈、能被打断、能被证明没有退化的实时交互体"。这句话里,前半句是 Live,后半句是 Eval 与 Observability。它们看起来是两件事,其实是同一件事的两面:没有观测的实时系统,你连它现在是什么状态都说不清。

说得响不算数
听得清才算准
有证据才敢改
无度量莫升级

02、问题

旧方案是标准的请求-响应:一个 POST 接口,收到一段文本,调一次模型,返回一段文本。它有三个隐含前提——同步、无状态、有唯一确定的输出。评测也简单:给一组输入,比对输出文本,差不多就算过。

实时语音把这三个前提全部推翻了。

业务影响先到。数字人用在会客厅答疑,用户是会打断的。打断失败一次,用户会认为系统卡死;重连忘掉上下文一次,用户会认为"换了个客服"。这两件事各发生一次,试点就会被叫停,而不是等我们把指标打磨漂亮。

技术表现是三个具体现象。第一,打断之后旧音频还在播。第二,断线重连后 Session 是新的,Memory 也回到初始。第三,改完 Prompt 之后,我们只能靠人听,而人听五次能给出三种结论。

可验证的完成标准,我们后来定了五条。

  1. 用户的语音能通过 LiveRequestQueue 进入 Runtime,Agent 的 Event 流能实时回到渲染层;
  2. 用户中途说话,数字人能在一次交互内停止当前输出,不再出现两路声音叠加;
  3. 断线重连后使用同一个 sessionId 续接,此前轮次仍在上下文中;
  4. 至少 10 个 Golden Case 能在 JUnit 里一键跑完,并断言 Event 序列与 Tool 调用轨迹;
  5. invocation、model、tool 三类延迟,以及 step count、tool error、resume count、session size、打断次数,都有指标与结构化日志可查。

第五条是最容易被省略、也最不该省略的一条:没有第五条,前四条随时可能悄悄退回原点。

需求先落地
现象再归因
标准写得清
退步藏不住

03、原理

先讲清 Live 这条链路的形态,因为后面所有故障都长在它上面。

Live 不是一次请求,是一条队列。在 ADK Java 里,业务侧不直接调用模型,而是往 LiveRequestQueue 里投递 LiveRequest;Runtime 从队列取,产出 Event 流。官方 API Reference 把 LiveRequestQueue 和 LiveRequest 放在 com.google.adk.agents 包下;一个 LiveRequest 表达的是"投递给运行时的一个元素"——一段内容、一段实时音频、或者一个关闭信号,具体的工厂方法与字段以官方 API Reference 为准。

Runner 的 live 入口把三样东西接起来:Session、Queue、以及返回的 Event 流。文本模式下我们用的是 runAsync;live 模式下走 runLive,它的输出不再是单个结果,而是一条可持续推送到渲染层的事件流。方法签名以官方文档为准,但形状是确定的:runLive(session, queue) -> Event 流。

第一个反直觉判断:实时语音的主要故障不在模型,而在生命周期。

我们一开始的直觉是"打断失败 = 模型不听指令"。但模型根本不知道什么叫打断。打断是上层决定不再需要这段输出,它需要做的是:停止向渲染层推流、把当前这一轮的输出作废、把队列清理干净。这是生命周期问题,不是 Prompt 问题。把它当 Prompt 问题修,会得到一个"看起来好一点"的版本,和一堆没法解释的随机失败。

第二个反直觉判断:实时语音的评测对象不是最终文本,而是 Event 与 Tool 轨迹。

文本模式下,"最终回答"是唯一确定的产物,比对字符串是有意义的。live 模式下,输出是流式的、可被打断的、顺序敏感的:同一段回复被切成几十个事件,中间还可能插入工具调用。你没法把"最终文本"当成唯一真相。能稳定断言的是结构——某次输入之后,是否产生了 function_call、是否产生了对应的 function_response、事件顺序是否符合预期、有没有产生不该产生的工具调用。

第三个反直觉判断:可观测性不是上线之后补的运维手段,它是评测的数据源。

我们后面写的 Golden Case,断言的很多事实(第几步调了工具、这一轮有没有被恢复)本来就要从 trace 和结构化日志里拿。先有埋点,测试才是"可写的";反过来先写测试再补埋点,通常会在测试里复制一套统计逻辑,最终两套数据长期不一致。官方 observability 文档给的方向是:把 ADK 的运行过程暴露成标准的观测数据——trace、日志、指标——而不是让每个业务自己造一套。前提是你得让每一次执行都有可识别的上下文,这一点在第五境(InvocationContext 与 State)和第六境(Session 与 Memory)已经打好了地基,这一境只是把它接上观测后端。

队列不是请求
事件才是真相
埋点先于测
度量先于改

04、架构

[输入] 浏览器麦克风 / LiveKit 房间音频轨
    |
    v
[模块] RealtimeBridge(Java 侧桥接进程)
    |
    v
[数据/状态] LiveRequestQueue / Session(sessionId) / InvocationContext
    |
    v
[处理] Runner.runLive → Agent(LlmAgent + Tools)→ Event 流
    |
    v
[输出] Event → 文本/音频帧 → LiveKit → Avatar 渲染(TTS/唇形)

分层边界是这样划的。

LiveKit 负责房间与媒体:谁在房间里、音频轨怎么走、抖动和丢包怎么处理。它不负责 Agent 的思考过程,也不负责记住用户是谁。ADK 负责 Session、Agent、Tool、Runtime:谁在对话、这一轮调用了什么工具、事件以什么顺序产生。RealtimeBridge 是唯一同时理解两边的人:它把媒体层的音频转成 LiveRequest 投进队列,把 Event 流转回渲染层。它是薄的一层,越薄越好。

TTS 与唇形保持在渲染层,不要把 TTS 塞进 Agent 的 Tool 里。Agent 产出的应该是语义事件——说了什么、说完没有、调用了什么工具,合成语音是渲染细节。

收益:实时链路的每一侧都能被单独替换,换模型不动媒体层,换媒体层不动 Agent;Session 续接有唯一事实源;评测有稳定的断言面。

代价:多了一个桥接进程和一个状态归属的约定;多一个进程就多一次网络跳数,也多一类新故障——桥接重启时队列怎么办。

适用条件:只有当"实时"是真的业务需求时才值得。如果只是"回答慢一点没关系",第六境的文本链路加一点流式输出更省事。

各自守边界
桥只做转译
状态归运行
渲染归前端

05、实战一次

环境与版本:JDK 21;Maven 3.9+;Google ADK Java 1.11.0(Git 分支 chapter/10-live-eval)。依赖坐标与可用模型标识请以官方 Release 与文档核验。

依赖:

<properties>
  <maven.compiler.release>21</maven.compiler.release>
  <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.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>5.10.2</version>
    <scope>test</scope>
  </dependency>
</dependencies>

配置:模型凭据通过环境变量注入,实时模型标识单独配置,不写进代码常量。

核心实现(V1):

package com.demo.ch10;

import com.google.adk.agents.LiveRequestQueue;
import com.google.adk.agents.LlmAgent;
import com.google.adk.events.Event;
import com.google.adk.runner.InMemoryRunner;
import com.google.adk.runner.Runner;
import com.google.adk.sessions.Session;
import com.google.genai.types.Content;
import com.google.genai.types.Part;
import io.reactivex.rxjava3.core.Flowable;

public final class LiveSmoke {

  public static void main(String[] args) throws Exception {
    LlmAgent agent =
        LlmAgent.builder()
            .name("live_digital_human")
            .model(System.getProperty("live.model"))
            .instruction("你是项目数字人,回答不超过两句,先说结论。")
            .build();

    Runner runner = new InMemoryRunner(agent, "digital-human");

    Session session =
        runner.sessionService().createSession("digital-human", "u-1001").blockingGet();

    LiveRequestQueue queue = new LiveRequestQueue();

    Flowable<Event> events = runner.runLive(session, queue);

    events.subscribe(
        event -> System.out.println("事件 " + event.id() + " 作者=" + event.author()),
        error -> System.err.println("实时链路异常 " + error),
        () -> System.out.println("实时链路关闭"));

    queue.sendContent(Content.fromParts(Part.fromText("你好,介绍一下你能做什么")));

    Thread.sleep(8000L);
    queue.close();
  }
}

启动:

export GOOGLE_API_KEY=your-key
mvn -q compile exec:java -Dexec.mainClass=com.demo.ch10.LiveSmoke -Dlive.model=your-live-model

请求:向队列投递一段文本内容。真实场景下是 Bridge 把音频以 LiveRequest 投进来,文本是最小可验证路径。

验证(未在当前环境实测,以下为预期结果,示例输出):

事件 e-1 作者=live_digital_human
事件 e-2 作者=live_digital_human
实时链路关闭

V1 跑通之后,立刻留下两个问题:一是打断——我们还没有把"停止"变成一次明确的动作;二是评测——上面这段"看起来对"的输出,没有任何一条断言。

先跑最短路
再补断与续
最后挂观测
一步不跳级

06、排查

现象一:用户插话后,两路声音叠加。

怀疑方向:模型不听话,Prompt 没写清楚。

检查:在 Bridge 里打点,记录我们向渲染层推流的起点与终点,以及"用户开始说话"这个事件到达的时间戳。

证据:日志显示用户说话的时间点,我们只做了一件事——把用户音频投进了队列;没有任何一处代码去停止向下游推流。

根因:我们把"打断"当成模型语义问题,而不是运行时控制问题。队列还在被消费,渲染层还在播。

修复:把打断定义为一次明确的控制动作——停止下游推流、丢弃当前轮的剩余事件、按需重建队列;同时把 close() 的调用做成幂等的。

错误尝试: 我们最早的做法是"给 LiveRequestQueue 配一个 volatile boolean 开关,用户一说话就置 true,Agent 产出事件时检查一下,发现为 true 就丢弃事件"。这个改法在低负载时看起来有效,但它错在把状态判断压进了事件回调:事件在另一个线程上被消费,回调丢弃只能"事后丢",音频可能已经推了一帧出去;更糟的是,我们同时在 Prompt 里加了一句"用户说话时要停下",于是同一件事有了两个真相源,两者偶尔结论不一致,故障变得更难复现。错误的本质是把控制面的事丢给了数据面。

现象二:断线重连后,数字人把上下文全忘了。

怀疑方向:上下文窗口问题,或者 Memory 服务坏了。

检查:打印每次创建 Session 的 appName、userId、sessionId,比对重连前后是否一致。

证据:重连那次日志里的 sessionId 是新的,和断线前那一条不同。

根因:Bridge 在连接断开时重建了整条链路,包括 Session。我们从来没把 sessionId 当成一个需要被保存的东西——它只活在那个 Java 对象的生命周期里。

修复:sessionId 由业务侧生成并随连接标识一起保存,重连时先按 sessionId 取回 Session,取不到再新建。

现象三(附带):改了 Prompt 之后,没人说得清是变好还是变差。

怀疑方向:没有怀疑方向,这正是问题所在。

检查:翻记录。上一次评测是什么时候?结论是什么?——没有结论。

根因:整条链路上没有一处可复现的断言。

修复:把 Golden Case 与指标补上,也就是下一节的 V2。

先问谁控制
再问谁知道
日志给证据
直觉不算数

07、优化

三条根因,对应三组修改。

根因一:打通"打断"的控制面。修改是把"停止"抽成一轮输出的显式动作,而不是散落在回调里的布尔判断。一个 LiveTurn 持有该轮输出的订阅句柄与一个 AtomicBoolean finished,stop() 先置位、再释放订阅、再通知渲染层清空缓冲。Prompt 里那句"用户说话时停下"删掉——它是无用的第二真相源。原因是控制面的事必须由控制面解决,数据面天然滞后一拍。新行为是用户插话后下游很快停止推流(这是目标,不是实测值,需按你的渲染链路验证),验证方式是写一个 Case:投一段长回答,200ms 后调用 stop(),断言此后不再有事件到达下游 sink。

根因二:Session 续接。修改是 Bridge 用 sessionId = roomId + ":" + userId 作为会话标识,重连时先取回 Session,为空再创建。原因是 Session 是跨连接存在的事实源,但它没有义务跨进程重启自动存在,需要业务侧显式指定。新行为是重连后历史轮次仍在上下文中。验证方式是 Case 里显式模拟两次连接共用同一个 sessionId,断言第二次能读到第一次留下的会话痕迹(计数来自我们自己的埋点,见下)。

根因三:把评测与观测接进来。修改分两半。

一半是 Eval。用 JUnit + Golden Case,断言结构而不是文本:

package com.demo.ch10;

import static org.junit.jupiter.api.Assertions.assertTrue;

import com.google.adk.events.Event;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.Test;

class LiveGoldenCaseTest {

  @Test
  void should_stop_output_when_user_interrupts() throws Exception {
    LiveHarness harness = new LiveHarness("digital-human", "u-1001", "room-7:u-1001");

    List<Event> downstream = new CopyOnWriteArrayList<>();
    harness.events().subscribe(downstream::add);

    harness.send("请用五句话介绍这个项目");
    Thread.sleep(200L);
    long interruptAt = System.currentTimeMillis();
    harness.interrupt();
    Thread.sleep(1500L);

    long after =
        downstream.stream().filter(e -> e.timestamp() > interruptAt).count();
    assertTrue(after <= 1, "打断后仍向下游推送了 " + after + " 个事件");
  }

  @Test
  void should_keep_context_across_reconnect() throws Exception {
    LiveHarness first = new LiveHarness("digital-human", "u-1001", "room-7:u-1001");
    first.send("我叫老李");
    first.awaitEvents(2, 5, TimeUnit.SECONDS);
    first.close();

    LiveHarness second = new LiveHarness("digital-human", "u-1001", "room-7:u-1001");
    List<Event> events = new CopyOnWriteArrayList<>();
    second.events().subscribe(events::add);
    second.send("我叫什么?");
    second.awaitEvents(2, 5, TimeUnit.SECONDS);

    assertTrue(second.historicalTurnCount() >= 1, "重连后未恢复历史轮次");
  }
}

LiveHarness 是我们自己的测试夹具,把队列、Session、事件收集、打断动作封装起来;事件对象上的时间戳与 id 访问方法以官方 API 为准。

另一半是 Observability。结构化日志加指标,先落这几类:

log.info(
    "invocation_started appName={} userId={} sessionId={} invocationId={} model={}",
    appName, userId, sessionId, invocationId, model);

meterRegistry.counter("digitalhuman.tool.error", "tool", toolName).increment();
meterRegistry.timer("digitalhuman.model.latency", "model", modelName).record(duration);
meterRegistry.counter("digitalhuman.live.interruption").increment();

核心指标清单就是本境的验收清单。

指标 类型 用途
invocation latency Timer 一次调用的端到端耗时
model latency Timer 模型段耗时,换模型时最先变化
tool latency Timer 工具段耗时,区分"模型慢"和"工具慢"
step count Distribution 一次 invocation 走了几步,判断是否绕路
tool error Counter 工具失败率
resume count Counter 恢复与续跑次数,流程与人工介入的健康度
session size Gauge 会话膨胀,长期运行的风险
live interruption Counter 打断次数,实时体验的直接信号

为什么是这几个:它们两两成对。三类延迟把"慢"切成三段;step count 与 tool error 共同解释"为什么慢";resume count 与 session size 解释"是不是在悄悄变重";live interruption 是实时链路特有的、也是唯一直接反映交互体验的指标。

控制归控制
状态要带走
断言断结构
指标说人话

08、演进

[同一输入] 用户在会客厅打断数字人,随后断线重连
    |
    +--[V1] 事件照推,音频叠加;重连新建 Session;
    |       评测靠人听,改一次 Prompt 结论取决于谁听的
    |       代价:演示可过,试点即崩,退化无法发现
    |
    +--[V2] 打断是显式控制动作,一次交互内停止下游推流;
    |       同一 sessionId 续接历史轮次;
    |       Golden Case 断言 Event/Tool 轨迹,指标与结构化日志可查
    |       代价:多了桥接层状态管理与测试夹具的长期维护
    |
[Trade-off]
得到:实时链路可解释、可回归,模型与媒体层都能单独替换
失去:一次"随手改 Prompt 就发"的灵活
适用边界:真的需要实时交互的场景;纯文本问答用第六境的链路更划算

正确性上,V1 只在理想输入下正确;V2 把打断与重连纳入了正确性的定义。

稳定性上,V1 的失败是随机的、不可复现的;V2 把随机失败变成了有名字的 Case。

复杂度上,V2 多了一个 Bridge、一个测试夹具、一套指标。这是有明确观测收益的复杂度。

成本上,实时链路的资源占用高于文本链路,评测要跑真实事件流,CI 时间会变长。

适用范围上,会客厅、语音客服、陪伴类交互适用;异步工单、批处理不适用。

遗留问题上,还没做的是评测集本身的漂移——Golden Case 也需要随业务更新,否则会变成一层自我安慰。这属于长期治理,不在这一境收口。

从能跑到可控
从可控到可证
复杂度换证据
账要算得清

09、洞见

9.1 实时系统里,控制面永远先于数据面

反直觉判断:打断不是"让模型听话",而是"让下游别再听"。把打断写进 Prompt,是给数据面加规则,而数据面天然滞后。真正的修复点在我们自己的订阅与推流句柄上。这条判断可以推到所有流式场景:停止、取消、超时,都必须在控制面闭合。

9.2 流式输出没有"最终文本",所以评测对象必须是结构

反直觉判断:越是"自然"的输出,越不该用字符串比对。Golden Case 断言的是事件序列、工具调用次数与顺序、是否产生了不该有的调用。字符串可以是人的观察对象,但不能当回归的判定依据。

9.3 可观测性不是运维补丁,它是测试的地基

反直觉判断:先写测试再补埋点,通常得到两套互不信任的统计。我们在这一境把结构化日志与指标放在 V2 的前半段,后半段的 Case 直接读这些数据。指标不是给人看的报表,它是断言的数据源。

9.4 边界清晰才有资格谈实时

LiveKit 与 ADK 的职责划分看起来是"架构洁癖",实际是故障隔离:媒体层抖动不该污染 Agent 的会话状态,换模型不该动房间。只有当两侧都能被单独替换,实时链路才谈得上持续演进。

先定控制面
再断结构面
埋点在上游
边界定生死

10、系统落地

原来有什么:一个文本请求-响应接口;一次可跑的 LlmAgent;Runner 与 Event 机制(第四境);Session 与 Memory(第六境);Workflow 与人工介入(第八境);MCP 与 A2A(第九境)。它能在文本世界里持续对话,但一进实时场景就失控。

本篇新增什么:一是 Live 通路,业务侧通过 LiveRequestQueue 投递 LiveRequest,Runner 的 live 入口产出 Event 流;二是 RealtimeBridge,媒体层与 ADK 之间唯一的转译点,负责打断、续接、帧路由;三是 Eval 资产,JUnit + Golden Case 断言 Event 与 Tool 轨迹;四是 Observability 资产,结构化日志字段约定加八类核心指标。

现在能做什么:数字人能边听边说,被打断会停,断线重连能续;每次改模型或 Prompt 之后,跑一遍 Case、看一组指标,就能给出"有没有退化"的判断。

还缺什么:评测集本身会漂移,Golden Case 与真实流量的差距会随时间扩大,这是真实存在的,不是靠一次努力能消除的。另外音频质量、回声消除这类媒体层问题不在 ADK 范围内。

下一步如何演进:斗帝境要做的是把这一整套能力沉淀成可配置、可复制、可交付的产品模板——把本境的 Live 通路、测试夹具、指标口径变成模板里的默认件,而不是每个项目重写一遍。

链路已闭环
证据已入库
尚缺是漂移
交付看模板

11、小结

Q1 → 状态归 ADK Runtime:Session、Agent、Tool、Event 都归运行时,媒体层只管房间与音频轨
Q2 → 挂到 JUnit + Golden Case:断言 Event/Tool 轨迹,而不是比对最终文本
Q3 → 靠指标与结构化日志:三类延迟、step count、tool error、resume、session size、打断次数
状态 → 数字人已是可实时交互、可评测、可观测的实体

一问定归属
二选定证据
三选定量尺
成境在可证

12、作业

12.1 理解题:为什么"用户说话时请停下"这句话不应该写进 Agent 的 instruction?

参考答案:因为打断是控制面问题,不是模型语义问题。模型输出的滞后性决定了它无法保证在恰当的时间点停止;而且一旦既在 instruction 里写规则、又在代码里做控制,同一件事就有两个真相源,故障时会互相掩盖。正确做法是在业务侧持有输出订阅句柄,显式停止、作废当前轮、清理下游缓冲。

12.2 实战题:给本境的 Live 链路补一个 Golden Case,断言"工具调用发生了且只发生一次"。

参考答案:在测试夹具里收集 Event 流,筛选出与 function_call、function_response 相关的事件;用必然触发该工具的输入构造 Case;断言该工具的调用事件恰好一次且顺序正确。注意断言对象是结构而不是文本,并且要标注该 Case 依赖的模型标识,否则换模型后 Case 会以不可解释的方式失败。

12.3 排障题:指标显示 model latency 平稳,但 invocation latency 翻倍,先查什么?

参考答案:三类延迟的价值就在于能互相切割。先看 tool latency 与 step count:如果 tool latency 涨了,问题在工具侧,比如外部依赖变慢或超时重试;如果 step count 涨了,说明模型在绕路,通常是工具描述或指令变化导致;如果两者都平,就要看桥接层与渲染层——延迟可能被记在了 invocation 的边界之外,或者事件队列已经堆积。

12.4 架构判断题:把 TTS 放进 Agent 的 Tool 里,让模型决定何时合成语音,是否合理?

参考答案:不合理。理由有三:TTS 是渲染细节,交给模型决策会把"说什么"和"怎么说"耦合成一个不可测的整体;每次合成都会变成一次工具调用,step count 与 tool latency 被污染,指标失去解释力;换 TTS 供应商时必须改动 Agent。应该让 Agent 只产语义事件,由渲染层决定何时用哪段音频回应,TTS 与唇形都留在那一侧。

四问四答后
境界即落袋
敢写敢断言
敢改敢回收

13、思考

回到最初的冲突:文字 Demo 能跑,实时语音不可控,换个模型或改句 Prompt 也不知道是否退化。这一境的答案不是"换个更强的模型",而是三件事的合体——把实时链路的控制面收回自己手里,把评测对象从文本换成结构,把观测数据变成测试的地基。

由此得到一条可复用的工程判断:任何"看起来能跑但说不清状态"的系统,问题都不在它跑得对不对,而在你没有给它定义边界与度量。定义边界,是让每个组件只对自己的职责负责;定义度量,是让每一次改动都能被证明。

实时先定界
评测先断形
观测先于测
可证才可改

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