Spring Boot实现语音Agent发布门禁
语音 Agent 的评测真正接进 CI 以后,下一个问题马上出现:
几十个Case
几十个指标
到底什么情况下允许发布?
如果只有一个:
average_score = 0.86
基本不够。
一个身份验证 Agent 可能 100 个测试里通过 99 个,唯一失败的是:
验证身份前泄露敏感信息
平均分仍然很好看,但这个版本绝对不能上线。
所以语音 Agent 发布门禁需要把:
Case风险
Task Success
Tool顺序
Latency
Cost
Audio指标
一起建模。
下面直接用 Spring Boot 做一个最小 Gate Service,接收 ADK Live Eval 或其他语音 Agent 评测结果,生成:
PASS
WARN
BLOCK
先定义评测输入
public record VoiceEvalRunRequest(
String runId,
String candidateVersion,
String modelVersion,
String evaluatorVersion,
List cases) {
}
单个 Case:
public record VoiceEvalCaseResult(
String caseId,
RiskLevel risk,
boolean taskSuccess,
boolean toolSequencePass,
boolean safetyGatePass,
double trajectoryScore,
long firstAudioMs,
long maxTurnLatencyMs,
int turns,
BigDecimal costUsd,
List failureCodes) {
}
这里没有只存一个 score。
因为不同失败必须独立判断。
Risk Level
public enum RiskLevel {
LOW,
MEDIUM,
HIGH,
CRITICAL
}
例如:
普通FAQ:
LOW
订单查询:
MEDIUM
身份验证:
HIGH
医疗/金融敏感信息披露:
CRITICAL
Gate Decision
public enum GateDecision {
PASS,
WARN,
BLOCK
}
结果:
public record ReleaseGateResult(
GateDecision decision,
List violations,
GateMetrics metrics) {
}
第一条规则:Critical Case必须100%通过
@Component
public class CriticalCaseRule
implements ReleaseGateRule {
@Override
public List evaluate(
VoiceEvalRun run) {
return run.cases().stream()
.filter(c ->
c.risk()
== RiskLevel.CRITICAL)
.filter(c ->
!c.taskSuccess()
|| !c.safetyGatePass())
.map(c ->
GateViolation.block(
c.caseId(),
"CRITICAL_CASE_FAILED"))
.toList();
}
}
不存在:
Critical通过率95%
这种概念。
只要一个失败:
BLOCK
第二条规则:Tool顺序单独检查
很多语音业务最终答案是对的,但流程错了。
例如:
先说出预约信息
↓
再验证生日
最终对话结束时:
身份也验证了
预约也说对了
如果只看最终结果:
PASS
实际上已经泄露。
所以:
toolSequencePass
必须是独立 Gate。
@Component
public class ToolSequenceRule
implements ReleaseGateRule {
@Override
public List evaluate(
VoiceEvalRun run) {
long failed =
run.cases().stream()
.filter(c ->
!c.toolSequencePass())
.count();
if (failed > 0) {
return List.of(
GateViolation.block(
null,
"TOOL_SEQUENCE_FAILURE"));
}
return List.of();
}
}
高风险流程甚至不允许一条失败。
第三条:普通Case看通过率
例如:
normal_pass_rate: 0.95
double passRate =
passed / (double) total;
如果:
>= 0.95
通过。
如果:
0.90—0.95
WARN。
低于:
0.90
BLOCK。
规则配置不要写死
voice-agent-gate:
task:
normal-pass-rate: 0.95
warn-pass-rate: 0.90
latency:
p95-first-audio-ms: 1200
p95-turn-ms: 1800
cost:
max-case-usd: 0.30
p95-case-usd: 0.20
conversation:
max-turns: 15
映射:
@ConfigurationProperties(
prefix = "voice-agent-gate")
public record GateProperties(
Task task,
Latency latency,
Cost cost,
Conversation conversation) {
}
P95不能用平均值替代
语音体验最怕长尾。
数据:
600
610
590
620
580
5000
平均:
1333ms
已经不够好。
如果有 100 条里 5 条特别慢,平均值可能仍然看起来勉强。
所以必须记录:
P50
P95
P99
一个简单Percentile实现
public long percentile(
List values,
double p) {
List sorted =
values.stream()
.sorted()
.toList();
int index =
(int) Math.ceil(
p * sorted.size()) - 1;
return sorted.get(
Math.max(0, index));
}
生产可以用 Micrometer Histogram 或专业统计库。
First Audio和Turn Latency要分开
First Audio:
用户说完
→第一次听到Agent声音
Turn Latency:
整轮处理的最大等待
如果 Tool 调用很慢:
Agent可以先说:
“我正在查询订单。”
First Audio 很低。
但真正结果仍然慢。
所以两个指标都要。
Tool Latency也应该拆出来
Case Result 可以扩展:
public record ToolTiming(
String capabilityId,
long durationMs,
ToolOutcome outcome) {
}
最后找:
到底是ASR慢
模型慢
还是CRM慢
Cost Gate不能只看总成本
如果 100 个 Case:
总成本 $12
看起来没问题。
但某一条异常循环:
$2.8
说明可能有 Agent Loop。
所以同时检查:
Total
Average
P95
Max
单Case成本阈值
@Component
public class CostOutlierRule
implements ReleaseGateRule {
private final GateProperties props;
@Override
public List evaluate(
VoiceEvalRun run) {
return run.cases().stream()
.filter(c ->
c.costUsd().compareTo(
props.cost()
.maxCaseUsd()) > 0)
.map(c ->
GateViolation.warn(
c.caseId(),
"COST_OUTLIER"))
.toList();
}
}
如果是 Critical Loop:
自动升级 BLOCK
Turn数是一个非常好的异常指标
正常业务:
6—8轮
新版本突然:
13—15轮
即使最终成功,也说明:
提问效率下降
用户体验变差
成本上升
可以做:
turn_regression_ratio
比较 Baseline。
发布门禁必须比较Baseline
只看绝对阈值不够。
例如:
Candidate P95 = 1100ms
低于:
1200ms
绝对阈值通过。
但 Baseline:
700ms
新版本慢了:
57%
应该至少 WARN。
Baseline实体
public record EvalBaseline(
String agentId,
String releaseVersion,
GateMetrics metrics,
Instant promotedAt) {
}
Candidate 计算:
Delta
Regression Rule
double latencyDelta =
(candidateP95 - baselineP95)
/ (double) baselineP95;
例如:
regression:
p95-latency-max: 0.20
cost-max: 0.25
task-success-max-drop: 0.02
Failure Code一定要结构化
不要只保存:
“对话失败”
至少:
public enum VoiceFailureCode {
ASR_ENTITY_MISS,
IDENTITY_ORDER_VIOLATION,
TOOL_NOT_CALLED,
TOOL_ARGUMENT_ERROR,
TOOL_TIMEOUT,
INTERRUPT_IGNORED,
CONTEXT_LOST,
MAX_TURNS_EXCEEDED,
AUDIO_TIMEOUT,
SAFETY_DISCLOSURE
}
这样才能聚类。
Failure Cluster比单条失败更重要
例如:
12条失败
其中9条都是
ASR_ENTITY_MISS
根因可能是语音输入。
如果:
9个不同错误
可能是整体不稳定。
聚类:
select
failure_code,
count(*)
from voice_eval_failure
where run_id = ?
group by failure_code
order by count(*) desc;
数据库存三张表就够
voice_eval_run
voice_eval_case
voice_eval_failure
Run:
create table voice_eval_run (
run_id varchar(128) primary key,
candidate_version varchar(128) not null,
model_version varchar(128) not null,
evaluator_version varchar(128) not null,
decision varchar(32),
created_at timestamptz not null
);
Case:
create table voice_eval_case (
run_id varchar(128) not null,
case_id varchar(128) not null,
risk varchar(32) not null,
task_success boolean not null,
tool_sequence_pass boolean not null,
safety_gate_pass boolean not null,
trajectory_score numeric(8,4),
first_audio_ms bigint,
max_turn_latency_ms bigint,
turns int,
cost_usd numeric(12,6),
primary key(run_id, case_id)
);
API
POST /api/eval-runs
Body:
{
"runId": "eval-20260825-18",
"candidateVersion": "voice-agent-v18",
"modelVersion": "gemini-live-2.5-flash-native-audio",
"evaluatorVersion": "eval-v9",
"cases": [...]
}
返回:
{
"decision": "BLOCK",
"violations": [
{
"caseId": "identity-017",
"code": "CRITICAL_CASE_FAILED"
}
]
}
Gate Engine
@Service
public class ReleaseGateService {
private final List rules;
public ReleaseGateResult evaluate(
VoiceEvalRun run) {
List violations =
rules.stream()
.flatMap(rule ->
rule.evaluate(run).stream())
.toList();
GateDecision decision =
violations.stream()
.anyMatch(
GateViolation::blocking)
? GateDecision.BLOCK
: violations.isEmpty()
? GateDecision.PASS
: GateDecision.WARN;
return new ReleaseGateResult(
decision,
violations,
metrics(run));
}
}
Rule 可以不断增加,而不修改主流程。
ADK结果怎么接进来
不要让 Spring Boot 去解析 Console Log。
在 Eval 完成后转换成统一 JSON:
ADK Eval
↓
Result Adapter
↓
VoiceEvalRunRequest
↓
Gate Service
这样以后换:
ADK
自研Agent
其他Voice Framework
Gate 不变。
Adapter里做字段映射
例如:
ADK Rubric:
identity_before_disclosure
映射:
safety_gate_pass
Tool Trace:
validate_date_of_birth
映射:
tool_sequence_pass
Framework-specific 细节留在 Adapter。
CI里怎么调用
评测完成:
curl -X POST \
-H "Content-Type: application/json" \
--data @eval-result.json \
https://gate.internal/api/eval-runs
读取:
decision
如果:
BLOCK
退出:
exit 1
GitHub Actions
- name: Run live eval
run: ./scripts/run-live-eval.sh
- name: Evaluate release gate
run: |
result=$(curl -s \
-X POST \
-H "Content-Type: application/json" \
--data @build/eval-result.json \
"$GATE_URL/api/eval-runs")
decision=$(echo "$result" \
| jq -r '.decision')
test "$decision" != "BLOCK"
Gate结果要回写PR
不要让开发者点 CI 日志找。
PR Summary:
Voice Agent Gate: BLOCK
Critical:
1 failed
Task Success:
96.2%
Tool Sequence:
98.7%
P95 First Audio:
1140ms
Cost P95:
$0.18
Top Failure:
ASR_ENTITY_MISS (7)
一眼就知道问题。
失败Artifact最好生成链接
例如:
identity-017
Transcript
Audio
Tool Trace
Judge Detail
这样 Review 不需要重新跑。
评测版本必须固定
如果 Judge Prompt 今天改了:
Candidate看起来突然提升
不一定是真的。
所以每个 Run 保存:
evaluator_version
rubric_version
simulator_version
audio_profile_version
Gate 只比较:
同版本Baseline
跨版本需要重新建 Baseline。
Baseline晋升
Candidate:
PASS
上线稳定 24h 后:
Promote to Baseline
不要刚 CI 通过就自动覆盖 Baseline。
线上可能还有:
真实口音
真实噪声
真实Tool延迟
需要观察。
线上反馈也要回到Case
生产 Trace 发现:
用户连续插话三次
Agent卡死
把这条匿名化后加入:
interrupt-029
Nightly Eval。
这才会形成:
Production Failure
→ Regression Case
→ Release Gate
一个非常实用的指标:Critical Coverage
有多少关键业务规则真正有 Case 覆盖?
例如:
身份验证
敏感披露
取消
人工转接
支付确认
如果只有 40%,通过率再高也没有意义。
Critical Coverage
=
已覆盖关键规则
/
全部关键规则
目标:
100%
Gate本身也要审计
谁改了:
critical pass=100%
变成:
95%
这是重大生产变化。
所以 Gate Config:
Git管理
PR Review
Version
不要允许后台随手改阈值。
门禁变更必须回放历史
新 Gate v10:
拿过去30天 Eval Run
重新计算
看:
会有多少旧版本从PASS变BLOCK
这能防止过严或过松。
自动化测试Gate本身
至少:
Critical失败 → BLOCK
Safety失败 → BLOCK
普通通过率94% → WARN/BLOCK按配置
P95超阈值 → BLOCK
Cost单Case异常 → WARN
Baseline延迟回退30% → WARN/BLOCK
无Critical Case → BLOCK配置错误
Evaluator版本不一致 → 禁止直接比较
最后不要忘了Manual Override
生产中确实可能需要:
紧急修复
即使 Gate WARN。
Override 不能只是管理员点:
Force Merge
至少保存:
Reason
Approver
Expiry
Incident/Ticket
Critical Safety Failure:
默认不允许Override
语音 Agent 的发布门禁本质上不是一个“算平均分”的服务。
它要回答的是:
有没有不能失败的Case?
有没有流程顺序错误?
延迟有没有恶化?
成本有没有异常?
失败集中在哪一类?
Spring Boot 这一层只负责把不同评测框架的结果统一成:
Risk
Metric
Violation
Decision
前面用 ADK Live Eval 生成证据,后面用 Gate Service 做确定性发布决策。
这样语音 Agent 才真正进入一个成熟的工程闭环:
改动
→ 自动对话
→ 真实音频
→ Tool轨迹
→ Gate
→ PR决策
→ 线上反馈
→ 新Regression Case
做到这一步以后,“这个版本听起来还不错”才会逐渐变成:
这个版本有数据证明可以发布。
更多企业级 AI 应用、Agent、RAG 与模型工程化内容,我会继续整理在 智元界:
https://www.zyentor.com/