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/