斗破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 之后,我们只能靠人听,而人听五次能给出三种结论。
可验证的完成标准,我们后来定了五条。
- 用户的语音能通过 LiveRequestQueue 进入 Runtime,Agent 的 Event 流能实时回到渲染层;
- 用户中途说话,数字人能在一次交互内停止当前输出,不再出现两路声音叠加;
- 断线重连后使用同一个 sessionId 续接,此前轮次仍在上下文中;
- 至少 10 个 Golden Case 能在 JUnit 里一键跑完,并断言 Event 序列与 Tool 调用轨迹;
- 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 也不知道是否退化。这一境的答案不是"换个更强的模型",而是三件事的合体——把实时链路的控制面收回自己手里,把评测对象从文本换成结构,把观测数据变成测试的地基。
由此得到一条可复用的工程判断:任何"看起来能跑但说不清状态"的系统,问题都不在它跑得对不对,而在你没有给它定义边界与度量。定义边界,是让每个组件只对自己的职责负责;定义度量,是让每一次改动都能被证明。
实时先定界
评测先断形
观测先于测
可证才可改

浙公网安备 33010602011771号