跳转至

end-to-end-speech

第9章 · 多模态与实时交互 · 配套项目 chapter9/end-to-end-speech

项目说明

实验 9-4 配套:端到端语音思考 vs 级联流水线

对应《深入理解 AI Agent》第 9 章 实验 9-4 ★★★:使用 Step-Audio R1 实现端到端语音思考

目的

书中实验 9-4 的核心是端到端语音思考模型 Step-Audio R1:单一模型直接「听 → 想 → 说」,把 ASR、LLM、TTS 三段合而为一,在隐空间中直接传递副语言信息(情绪、语气、语速、环境声),延迟更低、韵律更自然。

Step-Audio R1 无公开 endpoint、需多卡 GPU 部署,读者难以直接跑通。本 demo 因此用 OpenAI 的 speech-to-speech 模型 gpt-audio 作为真实可跑的端到端代表:它同样是「音频进 → 单模型 → 音频出」,一次调用就返回语音答案与其转写,中间没有独立的 ASR/LLM/TTS 三段。demo 让你同题对照端到端与级联两条真实管道,直观看到二者在延迟信息损失(语气、副语言)上的差异——两条路的延迟都是本次运行真实测得,两段输出音频都用 ffprobe 校验。

demo 提供两类任务(--task),对应书中两条对照轴:

  • math(默认):口述数学题(Spoken-MQA 风格)。答案只取决于「说了什么」,级联与端到端准确率相当(对应书 9.3「自级联」一节),因此这一任务主要对照延迟维度。
  • paralinguistic:一句「答案取决于怎么说」的话。级联在 ASR 处把语音压成纯文本,LLM 拿到的只有字面,任何情绪判断都是文本代理思考(Textual Surrogate Reasoning,书中 9.3「文本代理思考问题」)——凭词汇猜情绪;端到端则直接听声学特征(语速、音调),据此回应。这一任务对照信息损失维度,正是书中 MGRD(模态锚定思考蒸馏) 要解决的核心问题。

原理:三种语音范式

书中把语音架构分为三种范式(chapter9.md「语音架构的三种范式」):

范式 结构 特点
级联(Cascaded) ASR → LLM → TTS 三个独立模型串联 模块清晰、可独立调优、可解释性好;但延迟串行累加,模型间以纯文本接口相连,情绪/语速/语调/环境声在交接时几乎全部丢失
端到端全模态(Omni / End-to-End) 单一模型「听→想→说」(Step-Audio R1、gpt-audio 等) 延迟更低、保留副语言信息、韵律自然,可「边想边说」;代价是训练数据需求大、可解释性差。仍假设「轮流说话」,靠 VAD 划分轮次
全双工(Full-Duplex) 单模型边听边说,取消「轮流」假设(Moshi、GPT-Live) 持续同时处理输入与输出,每秒多次决策该说/该听/该停/该打断;本 demo 不涉及

本 demo 聚焦前两种范式的对照:端到端(范式二)vs 级联(范式一)。

Step-Audio R1 是范式二里把「思考」也内化进单模型的代表:通过 MGRD(模态锚定思考蒸馏)真正基于声学特征思考,通过 MPS 双脑架构实现「边想边说」。gpt-audio 是同一范式(音频进单模型出音频)里读者可直接调用的代表。

运行

# 1. 安装依赖
pip install -r requirements.txt

# 2. 配置 Key
cp env.example .env
# 编辑 .env,填入有效的 OPENAI_API_KEY(需有权访问 gpt-audio / whisper-1 / tts-1)。
# 音频端点(端到端 gpt-audio、级联 ASR/TTS)只有 OpenAI 直连才有——OpenRouter 无音频端点,
# 故本实验必须用 OpenAI 直连 Key。可选:额外配 OPENROUTER_API_KEY,则级联中间的纯文本
# LLM 思考自动改走 OpenRouter 的 gpt-5.6-luna(绕开 gpt-5.6* 直连的组织实名认证)。

# 3. 运行(默认:math 任务,同题跑通端到端 + 级联,并打印真实延迟对照)
python demo.py

# 副语言任务:凸显端到端相对级联的优势(端到端听得到语气,级联只看到字面)
python demo.py --task paralinguistic

# 用真实带情绪的录音作输入(比 TTS 合成更能体现语气差异;与 --question 互斥)
python demo.py --task paralinguistic --audio-input my_voice.wav

# 只跑端到端 / 自定义提问 / 换音色 / 换输出目录
python demo.py --skip-cascade
python demo.py --question "从北京到上海高铁 4 小时,我 9 点出发,几点到?"
python demo.py --voice nova --output-dir out
python demo.py --help

# 切到自部署的真实 Step-Audio R1(覆盖环境变量)
python demo.py --step-audio-endpoint http://localhost:8000/v1/audio/chat

# 4.(可选)试听生成的语音答案
ffplay audio/answer_end_to_end.wav   # 端到端
ffplay audio/answer_cascade.mp3      # 级联

CLI 全部参数见 python demo.py --help--task--question--audio-input--e2e-model--step-audio-endpoint--voice--output-dir--skip-cascade。默认行为(python demo.py 跑 math 任务、端到端 + 级联对照)与改造前保持一致。

依赖 ffprobe/ffplay(用于校验、试听音频):brew install ffmpeg(macOS)。

预期输出示例(真实节选)

范式一:端到端语音思考(后端=gpt-audio,模型=gpt-audio)
形态:音频进 → 单模型「听→想→说」→ 音频出(一次调用,无独立 ASR/LLM/TTS 段)
[单阶段] 端到端(听→想→说,单模型一次调用)  |  延迟=7.21s
    语音答案转写(模型顺带产出,非中间文本阶段):咱们先算一下:小明有12块钱,买了3支铅笔……最后不剩钱了。
    ffprobe 校验:{'格式': 'wav', '时长(秒)': 20.75, ...}
端到端总延迟(单模型一次前向):7.21s

范式二(对照基线):级联流水线 ASR → LLM → TTS
[阶段 1] ASR 语音识别  |  模型=whisper-1  |  延迟=1.35s
[阶段 2] LLM 思考      |  模型=gpt-5.6-luna  |  延迟=1.92s
[阶段 3] TTS 语音合成  |  模型=tts-1  |  延迟=3.66s
级联总延迟(各阶段串行相加):6.94s

端到端 vs 级联:真实延迟对照
【1】实测总延迟  端到端 7.21s;级联 ASR(1.35)+LLM(1.92)+TTS(3.66)=6.94s
【3】书中表 9-1(引自 Step-Audio R1 论文,非本 demo 产出)
    MPS Speak-First 92.8% / 完整 TBS 93.0% ……

副语言任务的真实节选(--task paralinguistic,一次真实运行)

同一句「行吧,那就这样吧,我没什么意见。」(TTS 合成,情感偏平),两条路的回答:

[阶段 1] ASR 语音识别  |  whisper-1  → 「行吧,那就这样吧,我没什么意见」   ← 级联 LLM 只拿得到这行纯文本

两条路对同一段音频的回答(可直观对比是否「听到了怎么说」):
    级联(只凭 ASR 文本,文本代理思考)→ 「听起来你有些无奈,可能对这个决定不太满意。……」
    端到端(直接听声学特征)        → 「听起来你有点无奈,语速不快,音调挺平稳的。……」

差异一目了然:级联只能从字面「没什么意见」猜出无奈,说不出任何声学依据(书中「文本代理思考」);端到端则直接引用了「语速不快、音调平稳」这类声学特征——这正是书中 MGRD 想让模型学会的「用耳朵思考」。注意默认输入是 TTS 合成、情感偏平,差异已被削弱;用 --audio-input 喂一段真实带情绪的录音,对照会更明显。

关于表 9-1:demo 里打印的 Spoken-MQA / URO-Bench 分数是引自 Step-Audio R1 论文(书中表 9-1 转引),并非本 demo 跑出来的——本 demo 真实产出的只有实测延迟与两段真实音频。延迟数字随网络与 OpenAI 负载波动;gpt-audio 是「一次前向出整段音频」,真流式的端到端(如 Step-Audio R1 的 MPS)还能「边想边说」把首字延迟进一步压低,这一点非本 demo 的整段延迟所能体现。

如何适配真实 Step-Audio R1

端到端一路的后端可切换:

  • 默认:不配置 STEP_AUDIO_ENDPOINT → 用 gpt-audio(模型名可用 E2E_MODEL 覆盖)。
  • 真实 Step-Audio R1:自行多卡 GPU 部署后,把服务地址写入 STEP_AUDIO_ENDPOINT,demo 的端到端一路即改走它。speech_model.pyEndToEndSpeechModel._run_step_audio 给出了「上传音频、取回音频」的调用骨架;不同部署方案(vLLM / 自定义 HTTP 服务)的请求体各异,接入时按需改写。

局限

  • gpt-audio 是端到端范式的代表,不是 Step-Audio R1 本身:它没有暴露 MPS「边想边说」的流式首字延迟,也没有 Step-Audio R1 论文报告的副语言基准分数。表 9-1 因此是引用而非复现。
  • demo 用「文本 TTS → 输入语音」构造输入,仅为演示闭环;真实场景中输入来自用户麦克风,副语言信息更丰富,级联的损失也更明显。
  • 端到端与级联的延迟高低会随网络、模型负载、答案长度波动,单次运行不代表稳定结论;范式差异(副语言信息是否保留)才是更稳健的对照维度。

文件

  • demo.py:可运行主程序(python demo.py),同题跑通端到端 + 级联并打印真实对照。
  • speech_model.pyEndToEndSpeechModel(gpt-audio / 可切 Step-Audio R1)与 CascadedSpeechModel(whisper-1 → gpt-5.6-luna → tts-1)。
  • requirements.txt / env.example:依赖与环境变量样例。
  • audio/:运行时生成的输入/输出音频(已在 .gitignore 中忽略)。

源代码

demo.py

"""
实验 9-4 配套 demo:端到端语音思考 vs 级联流水线(真实跑通两条路并对照)。

书中实验 9-4 的核心是端到端语音思考模型 Step-Audio R1(直接「听→想→说」)。
Step-Audio R1 需多卡 GPU 部署、无公开 endpoint,本 demo 因此用 OpenAI 的
speech-to-speech 模型 `gpt-audio` 作为**真实可跑的端到端代表**(音频进、单模型、
音频出,无独立 ASR/LLM/TTS 三段),与**级联基线**(whisper-1 → gpt-5.6-luna → tts-1)
同题对照,两条路的延迟都是真实测得、两段输出音频都用 ffprobe 校验。

流程:
  1. 用 TTS 合成一段「用户提问」的语音(一道需要多步推理的数学题,Spoken-MQA 风格),
     作为两条管道共同的输入;
  2. 端到端:一次 gpt-audio 调用,音频进 → 语音答案 + 转写出(单模型融合);
  3. 级联:whisper-1 转写 → gpt-5.6-luna 思考 → tts-1 合成回答语音(三段串行);
  4. 打印两条路的真实延迟对照,并用 ffprobe 确认两段输出音频真实生成;
  5. 给出端到端 vs 级联的范式对照(延迟、副语言信息损失),并引用书中表 9-1。
"""

from __future__ import annotations

import argparse
import json
import os
import shutil
import subprocess
import sys
from pathlib import Path

from dotenv import load_dotenv
from openai import OpenAI

from speech_model import (
    CascadedSpeechModel,
    EndToEndSpeechModel,
    PipelineResult,
    synthesize_question_audio,
)

HERE = Path(__file__).parent
AUDIO_DIR = HERE / "audio"

# 一道需要多步推理的口述数学题(Spoken-MQA 风格:先听懂,再多步计算)
USER_QUESTION = (
    "小明有 12 块钱,他买了 3 支铅笔,每支 2 块钱,"
    "然后又用剩下的钱买了尽可能多的、每块 1 块 5 的橡皮,"
    "请问他最后还剩多少钱?"
)

# 一句「答案取决于怎么说」的话:字面是顺从/无所谓,真实情绪要靠语气、语速、语调
# 才能判断(是真的没意见,还是无奈、赌气)。这类副语言(Paralinguistic)任务正是
# 端到端相对级联的分水岭——见 chapter9.md「文本代理思考」与 MGRD。
PARALINGUISTIC_QUESTION = "行吧,那就这样吧,我没什么意见。"

# 两类任务,对应书中两条对照轴:
#   math          —— 语义任务:级联与端到端准确率相当(书 9.3「自级联」一节),主看延迟。
#   paralinguistic—— 副语言任务:答案取决于「怎么说」。级联在 ASR 处把语音压成纯文本,
#                    LLM 只能凭字面猜情绪(书中「文本代理思考」);端到端直接听声学特征。
TASKS: dict[str, dict] = {
    "math": {
        "question": USER_QUESTION,
        "e2e_prompt": (
            "你是一个中文语音助手。请先在内部完成必要的推理,再用简洁、口语化、"
            "适合朗读的中文说出结论,控制在三句话以内。"
        ),
        "cascade_prompt": (
            "你是一个语音助手。请先进行必要的推理,再给出简洁、口语化、"
            "适合朗读的中文回答。回答控制在三句话以内。"
        ),
        "axis": (
            "语义(Spoken-MQA 风格):答案只取决于「说了什么」。级联与端到端准确率相当,"
            "本任务主要对照【延迟】维度。"
        ),
    },
    "paralinguistic": {
        "question": PARALINGUISTIC_QUESTION,
        "e2e_prompt": (
            "你是一个中文语音助手。请先判断说话人的情绪、语速和语调(尽量依据声学特征"
            "本身,而非仅凭字面意思),说出你听到的语气,再据此给出体贴、口语化、"
            "适合朗读的中文回应,三句以内。"
        ),
        "cascade_prompt": (
            "你是一个中文语音助手。请先判断说话人的情绪、语速和语调,说出你听到的语气,"
            "再据此给出体贴、口语化、适合朗读的中文回应,三句以内。"
        ),
        "axis": (
            "副语言(Paralinguistic):答案取决于「怎么说」。级联的 LLM 只拿得到 ASR 纯"
            "文本,任何情绪判断都是「文本代理思考」(凭字面猜);端到端直接听声学特征。"
            "本任务对照【信息损失】维度。注意:默认输入由 TTS 合成、情感偏平,差异被削弱;"
            "用 --audio-input 喂一段真实带情绪的录音,对照会明显得多。"
        ),
    },
}


def hr(char: str = "-", n: int = 68) -> str:
    return char * n


def ffprobe_info(audio_path: str) -> dict:
    """用 ffprobe 读取音频真实信息(时长、格式、码率),确认文件已生成。"""
    if not shutil.which("ffprobe"):
        return {"error": "未安装 ffprobe,跳过音频校验(brew install ffmpeg)"}
    try:
        out = subprocess.run(
            [
                "ffprobe", "-v", "error",
                "-show_entries", "format=duration,format_name,bit_rate,size",
                "-of", "json", audio_path,
            ],
            capture_output=True, text=True, check=True,
        )
        fmt = json.loads(out.stdout).get("format", {})
        return {
            "格式": fmt.get("format_name"),
            "时长(秒)": round(float(fmt.get("duration", 0)), 2),
            "码率(bps)": fmt.get("bit_rate"),
            "文件大小(字节)": fmt.get("size"),
        }
    except Exception as e:  # noqa: BLE001
        return {"error": f"ffprobe 失败:{e}"}


def print_e2e_result(result: PipelineResult, backend: str) -> None:
    s = result.stages[0]
    print(hr("="))
    print(f"范式一:端到端语音思考(后端={backend},模型={s.model})")
    print(hr("="))
    print("形态:音频进 → 单模型「听→想→说」→ 音频出(一次调用,无独立 ASR/LLM/TTS 段)")
    print(f"\n[单阶段] {s.name}  |  延迟={s.latency_s:.2f}s")
    if s.text is not None:
        print(f"    语音答案转写(模型顺带产出,非中间文本阶段):{s.text}")
    print(f"    语音答案:{s.audio_path}")
    print(f"    ffprobe 校验:{ffprobe_info(s.audio_path)}")
    print(f"\n端到端总延迟(单模型一次前向):{result.total_latency_s:.2f}s")


def print_cascade_result(result: PipelineResult) -> None:
    print("\n" + hr("="))
    print(f"范式二(对照基线):级联流水线 ASR → LLM → TTS")
    print(hr("="))
    for i, s in enumerate(result.stages, 1):
        print(f"\n[阶段 {i}] {s.name}  |  模型={s.model}  |  延迟={s.latency_s:.2f}s")
        if s.text is not None:
            print(f"    文本:{s.text}")
        if s.audio_path is not None:
            print(f"    音频:{s.audio_path}")
            print(f"    ffprobe 校验:{ffprobe_info(s.audio_path)}")
    print(f"\n级联总延迟(各阶段串行相加):{result.total_latency_s:.2f}s")


def print_comparison(e2e: PipelineResult, e2e_backend: str, cas: PipelineResult,
                     task: str) -> None:
    """打印端到端 vs 级联的真实对照(实测延迟)+ 范式概念差异 + 书中表 9-1(引用)。"""
    stages = {s.name: s for s in cas.stages}
    asr = stages["ASR 语音识别"]
    llm = stages["LLM 思考"]
    tts = stages["TTS 语音合成"]

    print("\n" + hr("="))
    print("端到端 vs 级联:真实对照")
    print(hr("="))
    print(f"\n【任务】{task}{TASKS[task]['axis']}")
    print("\n【1】实测总延迟(本次运行,随网络与负载波动)")
    print(f"    端到端({e2e_backend},单模型一次调用):{e2e.total_latency_s:.2f}s")
    print(f"    级联  ASR({asr.latency_s:.2f}s) + LLM({llm.latency_s:.2f}s) "
          f"+ TTS({tts.latency_s:.2f}s) = {cas.total_latency_s:.2f}s(三段串行累加)")
    delta = cas.total_latency_s - e2e.total_latency_s
    faster = "端到端更快" if delta > 0 else "级联更快"
    print(f"    差值:{abs(delta):.2f}s({faster})。注意端到端是「一次前向出整段音频」,"
          "真流式端到端还能「边想边说」把首字延迟进一步压低;级联的三段则天然串行累加。")

    print("\n【2】信息损失(副语言 / 语气)——范式差异,非本次延迟数字")
    print("    级联在 ASR 处把语音压成纯文本,说话人的情绪、语速、语调、重音、停顿,")
    print("    以及背景环境声/音乐在交接时几乎全部丢失——LLM 只看到「说了什么」,")
    print("    看不到「怎么说的」。本次输入语音在 ASR 处被抹平为纯文本:")
    print(f"        ASR 文本 → 「{asr.text}」")
    print("    端到端模型在隐空间(Latent Space)中直接传递这些副语言信息,能感知")
    print("    情绪/语速/语调,并据此生成有表现力、韵律匹配的回复。")
    print("    两条路对同一段音频的回答(可直观对比是否「听到了怎么说」):")
    print(f"        级联(只凭 ASR 文本,文本代理思考)→ 「{llm.text}」")
    print(f"        端到端(直接听声学特征)        → 「{e2e.stages[0].text}」")

    print("\n【3】书中表 9-1:Step-Audio R1 不同语音思考配置(引自 Step-Audio R1 论文,"
          "非本 demo 产出)")
    print("    配置                          Spoken-MQA   URO-Bench")
    print("    不思考直接回答(基线)           70.6%        77.4")
    print("    MPS Speak-First(零延迟)        92.8%        82.5")
    print("    MPS Think-First(~80 tok 延迟)  93.9%        84.8")
    print("    完整 TBS(无延迟约束)           93.0%         —")
    print("    出处:Step-Audio R1 论文(书中表 9-1 转引)。这些是论文报告的评测分数,")
    print("    不是本 demo 跑出来的——本 demo 只产出上面【1】的真实延迟与两段真实音频。")
    print("    要点:Speak-First 几乎不损推理精度(92.8% ≈ TBS 93.0%),因为 CoT")
    print("    开头往往只是复述问题;这正是端到端「边想边说」能低延迟又不失准的原因。")

    print("\n【4】取舍小结")
    print("    级联:模块清晰、每段可独立调优、可解释性好;但延迟串行累加、副语言损失大。")
    print("    端到端:延迟更低、保留非文字信息、韵律自然;代价是训练数据需求大、")
    print("            可解释性差。二者在 2026 年的生产系统中长期并存。")


def parse_args() -> argparse.Namespace:
    p = argparse.ArgumentParser(
        prog="demo.py",
        description="实验 9-4:端到端语音思考 vs 级联流水线。合成(或读取)一段「用户"
                    "提问」语音,同题跑通端到端(gpt-audio,可切 Step-Audio R1)与级联"
                    "(whisper-1 → gpt-5.6-luna → tts-1),打印真实延迟与信息损失对照。",
        epilog="示例:\n"
               "  python demo.py                               # 默认:数学题,端到端+级联对照\n"
               "  python demo.py --task paralinguistic         # 副语言任务:凸显端到端优势\n"
               "  python demo.py --audio-input my_voice.wav    # 用真实录音作输入(更能体现语气差异)\n"
               "  python demo.py --e2e-model gpt-audio --voice nova --output-dir out\n"
               "  python demo.py --step-audio-endpoint http://localhost:8000/v1/audio/chat",
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    p.add_argument("--task", choices=sorted(TASKS), default="math",
                   help="任务类型:math=口述数学题(语义任务,主看延迟);"
                        "paralinguistic=副语言任务(答案取决于「怎么说」,凸显端到端"
                        "对级联的优势)。默认 math。")
    p.add_argument("--question", default=None,
                   help="自定义口述提问文本,覆盖 --task 的默认题目。与 --audio-input 互斥。")
    p.add_argument("--audio-input", metavar="FILE", default=None,
                   help="直接用一段已有的音频文件(.wav/.mp3)作为输入,跳过 TTS 合成"
                        "提问;适合喂真实带情绪的录音。与 --question 互斥。")
    p.add_argument("--e2e-model", default=None,
                   help="端到端 speech-to-speech 模型名(默认取环境变量 E2E_MODEL,"
                        "再默认 gpt-audio)。")
    p.add_argument("--step-audio-endpoint", default=None,
                   help="自部署的 Step-Audio R1 服务地址;给定后端到端一路改走它"
                        "(覆盖环境变量 STEP_AUDIO_ENDPOINT)。")
    p.add_argument("--voice", default="alloy",
                   help="回答语音音色(端到端与级联 TTS 共用,默认 alloy)。")
    p.add_argument("--output-dir", metavar="DIR", default=None,
                   help="音频输出目录(默认为脚本同级的 audio/)。")
    p.add_argument("--skip-cascade", action="store_true",
                   help="只跑端到端,不跑级联对照基线。")
    args = p.parse_args()
    if args.question and args.audio_input:
        p.error("--question 与 --audio-input 互斥,请二选一。")
    return args


def _map_openrouter_model(model: str) -> str:
    """把常见模型名映射为 OpenRouter 的 provider/model 形式。"""
    if "/" in model:
        return model
    if model.startswith("gpt-"):
        return "openai/" + model
    if model.startswith("claude-"):
        return "anthropic/claude-opus-4.8"
    return "openai/gpt-5.6-luna"


def _resolve_llm_client(openai_client: OpenAI):
    """为级联的纯文本 LLM 思考选路:优先 OpenRouter,否则回落到 OpenAI 直连。

    返回 (client, model, route_label)。ASR/TTS/端到端音频仍固定用 openai_client。
    """
    model = os.getenv("LLM_MODEL") or os.getenv("OPENAI_MODEL") or "gpt-5.6-luna"
    if os.getenv("OPENROUTER_API_KEY"):
        client = OpenAI(
            base_url="https://openrouter.ai/api/v1",
            api_key=os.getenv("OPENROUTER_API_KEY"),
            timeout=120.0,
            max_retries=3,
        )
        return client, _map_openrouter_model(model), "OpenRouter"
    return openai_client, model, "OpenAI 直连"


def main() -> int:
    args = parse_args()
    task = args.task
    task_cfg = TASKS[task]
    question = args.question or task_cfg["question"]

    load_dotenv(HERE / ".env")

    api_key = os.getenv("OPENAI_API_KEY")
    if not api_key:
        print("错误:未配置 OPENAI_API_KEY。端到端 gpt-audio 与级联的 ASR(whisper)/TTS(tts-1) "
              "都是音频端点,只有 OpenAI 直连才有——OpenRouter 无音频端点,故本实验必须提供"
              " OpenAI 直连 Key。请复制 env.example 为 .env 并填入。", file=sys.stderr)
        return 1

    # 音频客户端:端到端 gpt-audio、级联的 whisper/tts 都必须走 OpenAI 直连。
    # timeout + 自动重试:单次网络/SSL 抖动不至于让整条流水线崩溃
    client = OpenAI(api_key=api_key, timeout=120.0, max_retries=3)

    # 级联中间的纯文本 LLM 思考:优先走 OpenRouter(绕开 gpt-5.6* 直连的组织实名认证),
    # 没有 OPENROUTER_API_KEY 时回落到上面的 OpenAI 直连客户端。
    llm_client, llm_model, llm_route = _resolve_llm_client(client)
    out_dir = Path(args.output_dir) if args.output_dir else AUDIO_DIR
    out_dir.mkdir(parents=True, exist_ok=True)
    e2e_audio = str(out_dir / "answer_end_to_end.wav")
    cascade_audio = str(out_dir / "answer_cascade.mp3")

    # -- 步骤 1:准备「用户提问」语音(两条管道共同输入) ----------------------
    print(hr("="))
    print(f"步骤 1:准备一段「用户提问」语音(任务={task},作为两条管道共同的输入)")
    print(hr("="))
    if args.audio_input:
        question_audio = args.audio_input
        if not Path(question_audio).is_file():
            print(f"错误:--audio-input 指定的音频文件不存在:{question_audio}",
                  file=sys.stderr)
            return 1
        print(f"使用已有输入音频(跳过 TTS 合成):{question_audio}")
        print("提问文本:(未知,来自音频文件,将由级联的 ASR 转写还原)")
    else:
        question_audio = str(out_dir / "user_question.mp3")
        print(f"提问文本:{question}")
        synthesize_question_audio(client, question, question_audio)
        print(f"已用 TTS 合成输入音频:{question_audio}")
    print(f"ffprobe 校验:{ffprobe_info(question_audio)}")

    # -- 步骤 2:端到端语音思考(真实跑通) -----------------------------------
    print("\n" + hr("="))
    print("步骤 2:端到端语音思考(音频进 → 单模型 → 音频出)")
    print(hr("="))
    e2e_model = EndToEndSpeechModel(
        client,
        model=args.e2e_model,
        voice=args.voice,
        system_prompt=task_cfg["e2e_prompt"],
        endpoint=args.step_audio_endpoint,
    )
    backend = e2e_model.backend
    if backend == "step-audio-r1":
        print(f"检测到 STEP_AUDIO_ENDPOINT={e2e_model.endpoint},走真实 Step-Audio R1。")
    else:
        print(f"未配置 STEP_AUDIO_ENDPOINT,用 OpenAI speech-to-speech 模型 "
              f"{e2e_model.model} 作为端到端代表(真正的音频→单模型→音频)。")
        print("如需换成书中的 Step-Audio R1,自部署后把地址写入 STEP_AUDIO_ENDPOINT 即可。\n")
    try:
        e2e_result = e2e_model.run(question_audio, e2e_audio)
    except Exception as e:  # noqa: BLE001
        print(f"错误:端到端调用失败:{e}", file=sys.stderr)
        return 2
    print_e2e_result(e2e_result, backend)

    # -- 步骤 3:级联流水线(对照基线) ---------------------------------------
    cascade_result = None
    if not args.skip_cascade:
        print(f"\n级联 LLM 思考路由:{llm_route}(模型 {llm_model});"
              f"ASR/TTS 仍走 OpenAI 直连。")
        cascaded = CascadedSpeechModel(
            client,
            llm_model=llm_model,
            tts_voice=args.voice,
            system_prompt=task_cfg["cascade_prompt"],
            llm_client=llm_client,
        )
        cascade_result = cascaded.run(question_audio, cascade_audio)
        print_cascade_result(cascade_result)

    # -- 步骤 4:真实延迟对照 + 范式差异 + 表 9-1(引用) ---------------------
    if cascade_result is not None:
        print_comparison(e2e_result, backend, cascade_result, task)

    print("\n完成。可试听:")
    print(f"  ffplay {e2e_audio}      # 端到端语音答案")
    if cascade_result is not None:
        print(f"  ffplay {cascade_audio}   # 级联语音答案")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

speech_model.py

"""
可插拔的语音模型接口:端到端(end-to-end)与级联(cascaded)两种范式。

对应《深入理解 AI Agent》实验 9-4「使用 Step-Audio R1 实现端到端语音思考」。

- 端到端(EndToEndSpeechModel):单一模型直接「听 → 想 → 说」,音频进、音频出,
  中间没有暴露给外部的纯文本推理阶段。Step-Audio R1 是书中的代表模型(音频编码器
  + 音频适配器 + Qwen2.5 32B 解码器,需多卡 GPU,无公开 endpoint)。为了让读者不
  依赖自建 GPU 集群也能真实跑通「端到端语音思考」这一范式,本 demo 默认改用 OpenAI
  的 speech-to-speech 模型 `gpt-audio`:它同样是「音频 → 单模型 → 音频」,一次调用
  就同时返回语音答案与其文字转写,不经过独立的 ASR/LLM/TTS 三段。若你已经自行部署
  了真正的 Step-Audio R1,把服务地址写入 STEP_AUDIO_ENDPOINT 即可切换到它。

- 级联(CascadedSpeechModel):把 ASR → LLM → TTS 三个独立模型串成流水线,
  一棒接一棒。可用 OpenAI 的 whisper-1 / gpt-5.6-luna / tts-1 真实跑通完整闭环。
  代价:模型间以离散文本接口相连,说话人的情绪、语气、语调等副语言信息在交接时
  几乎损失殆尽(见 chapter9.md 范式二 · 端到端全模态模型)。这一路在 demo 中作为
  与端到端对照的**基线**。
"""

from __future__ import annotations

import base64
import os
import time
from dataclasses import dataclass, field
from typing import Optional

from openai import OpenAI


# ---------------------------------------------------------------------------
# 数据结构
# ---------------------------------------------------------------------------
@dataclass
class StageResult:
    """流水线中单个阶段的结果与延迟。"""

    name: str          # 阶段名(如 "ASR 语音识别")
    model: str         # 使用的模型
    latency_s: float   # 该阶段耗时(秒)
    text: Optional[str] = None       # 文本产物(ASR 转写 / LLM 回答 / 端到端转写)
    audio_path: Optional[str] = None # 音频产物(TTS 合成 / 端到端语音答案)


@dataclass
class PipelineResult:
    """一次完整「语音输入 → 思考 → 语音输出」的结果。"""

    paradigm: str                 # "cascaded" 或 "end_to_end"
    input_audio: str              # 输入音频路径
    output_audio: Optional[str]   # 输出音频路径
    stages: list[StageResult] = field(default_factory=list)

    @property
    def total_latency_s(self) -> float:
        return sum(s.latency_s for s in self.stages)


# ---------------------------------------------------------------------------
# 端到端范式(可运行:默认 gpt-audio;可切换到自部署的 Step-Audio R1)
# ---------------------------------------------------------------------------
class EndToEndSpeechModel:
    """端到端语音思考模型:音频进 → 单模型「听→想→说」→ 音频出。

    两种后端,二选一:

    1. **gpt-audio(默认,OpenAI)**:真正的 speech-to-speech 模型,通过 Chat
       Completions 调用(modalities=["text","audio"],audio={voice,format},
       user 消息里放一个 input_audio 内容块)。一次调用即返回语音答案 + 其转写,
       中间没有独立的 ASR/LLM/TTS 阶段——这正是端到端范式的形态。模型名可用
       环境变量 E2E_MODEL 覆盖(默认 gpt-audio)。

    2. **Step-Audio R1(可选,自部署)**:书中的端到端语音思考模型,由音频编码器 +
       音频适配器 + Qwen2.5 32B 解码器组成,需多卡 GPU,无公开 endpoint。它通过
       MGRD(模态锚定思考蒸馏)真正基于声学特征思考,并通过 MPS 双脑架构实现
       「边想边说」。若配置了 STEP_AUDIO_ENDPOINT,本类改为向该地址上传音频、取回
       音频(请求体因部署方案而异,见 run() 中的骨架,接入时按需改写)。
    """

    def __init__(
        self,
        client: OpenAI,
        model: Optional[str] = None,
        voice: str = "alloy",
        system_prompt: Optional[str] = None,
        endpoint: Optional[str] = None,
    ) -> None:
        self.client = client
        self.model = model or os.getenv("E2E_MODEL", "gpt-audio")
        self.voice = voice
        # 优先用显式传入的 endpoint(CLI --step-audio-endpoint),否则回落到环境变量
        self.endpoint = (endpoint if endpoint is not None
                         else os.getenv("STEP_AUDIO_ENDPOINT", "")).strip()
        self.system_prompt = system_prompt or (
            "你是一个中文语音助手。请先在内部完成必要的推理,再用简洁、口语化、"
            "适合朗读的中文说出结论,控制在三句话以内。"
        )

    @property
    def backend(self) -> str:
        """当前生效的端到端后端标识。"""
        return "step-audio-r1" if self.endpoint else "gpt-audio"

    # -- 后端一:自部署 Step-Audio R1(可选) --------------------------------
    def _run_step_audio(self, input_audio: str, output_audio: str) -> PipelineResult:
        # 不同部署方案(vLLM / 自定义 HTTP 服务)的请求体各异,此处给出最常见的
        # 「上传音频、取回音频」形态,供接入真实 Step-Audio R1 时改写。
        import requests  # 延迟导入:仅在真正配置 endpoint 时才需要该依赖

        t0 = time.perf_counter()
        with open(input_audio, "rb") as f:
            resp = requests.post(self.endpoint, files={"audio": f}, timeout=120)
        resp.raise_for_status()
        with open(output_audio, "wb") as out:
            out.write(resp.content)
        latency = time.perf_counter() - t0

        stage = StageResult(
            name="端到端(听→想→说,单模型融合)",
            model="Step-Audio R1",
            latency_s=latency,
            audio_path=output_audio,
        )
        return PipelineResult(
            paradigm="end_to_end",
            input_audio=input_audio,
            output_audio=output_audio,
            stages=[stage],
        )

    # -- 后端二:gpt-audio speech-to-speech(默认) --------------------------
    def _run_gpt_audio(self, input_audio: str, output_audio: str) -> PipelineResult:
        in_fmt = "mp3" if input_audio.lower().endswith(".mp3") else "wav"
        out_fmt = "wav" if output_audio.lower().endswith(".wav") else "mp3"
        with open(input_audio, "rb") as f:
            in_b64 = base64.b64encode(f.read()).decode()

        t0 = time.perf_counter()
        resp = self.client.chat.completions.create(
            model=self.model,
            modalities=["text", "audio"],
            audio={"voice": self.voice, "format": out_fmt},
            messages=[
                {"role": "system", "content": self.system_prompt},
                {
                    "role": "user",
                    "content": [
                        {
                            "type": "input_audio",
                            "input_audio": {"data": in_b64, "format": in_fmt},
                        }
                    ],
                },
            ],
        )
        latency = time.perf_counter() - t0

        audio = resp.choices[0].message.audio
        if audio is None or not audio.data:
            raise RuntimeError(
                f"端到端模型 {self.model} 未返回音频。请确认该 Key 有权访问 gpt-audio,"
                "或用 E2E_MODEL 指定可用的 speech-to-speech 模型。"
            )
        with open(output_audio, "wb") as out:
            out.write(base64.b64decode(audio.data))

        # 端到端只有「一段」:听→想→说融合在单次前向中。transcript 是模型顺带
        # 吐出的语音文字稿,并非一个独立的、可供 TTS 消费的中间文本阶段。
        stage = StageResult(
            name="端到端(听→想→说,单模型一次调用)",
            model=self.model,
            latency_s=latency,
            text=audio.transcript,
            audio_path=output_audio,
        )
        return PipelineResult(
            paradigm="end_to_end",
            input_audio=input_audio,
            output_audio=output_audio,
            stages=[stage],
        )

    def run(self, input_audio: str, output_audio: str) -> PipelineResult:
        if self.endpoint:
            return self._run_step_audio(input_audio, output_audio)
        return self._run_gpt_audio(input_audio, output_audio)


# ---------------------------------------------------------------------------
# 级联范式(对照基线)
# ---------------------------------------------------------------------------
class CascadedSpeechModel:
    """级联语音流水线:ASR → LLM → TTS,三个独立模型串联。

    默认使用 OpenAI:
      - ASR:whisper-1        (语音 → 文本)
      - LLM:gpt-5.6-luna     (文本思考 → 文本回答)
      - TTS:tts-1            (文本 → 语音)

    ASR/TTS 只有 OpenAI 直连才有对应端点(OpenRouter 无音频端点),因此固定用 `client`
    (必须是直连 OpenAI);中间的纯文本 LLM 思考可用单独的 `llm_client` 走 OpenRouter,
    从而绕开 gpt-5.6* 直连所需的组织实名认证。`llm_client` 缺省时回落到 `client`。
    """

    def __init__(
        self,
        client: OpenAI,
        asr_model: str = "whisper-1",
        llm_model: str = "gpt-5.6-luna",
        tts_model: str = "tts-1",
        tts_voice: str = "alloy",
        system_prompt: Optional[str] = None,
        llm_client: Optional[OpenAI] = None,
    ) -> None:
        self.client = client
        self.llm_client = llm_client or client
        self.asr_model = asr_model
        self.llm_model = llm_model
        self.tts_model = tts_model
        self.tts_voice = tts_voice
        self.system_prompt = system_prompt or (
            "你是一个语音助手。请先进行必要的推理,再给出简洁、口语化、"
            "适合朗读的中文回答。回答控制在三句话以内。"
        )

    # -- 阶段 1:ASR 语音识别 ------------------------------------------------
    def transcribe(self, audio_path: str) -> StageResult:
        t0 = time.perf_counter()
        with open(audio_path, "rb") as f:
            resp = self.client.audio.transcriptions.create(
                model=self.asr_model,
                file=f,
            )
        latency = time.perf_counter() - t0
        return StageResult(
            name="ASR 语音识别",
            model=self.asr_model,
            latency_s=latency,
            text=resp.text.strip(),
        )

    # -- 阶段 2:LLM 思考 ----------------------------------------------------
    def think(self, question_text: str) -> StageResult:
        t0 = time.perf_counter()
        resp = self.llm_client.chat.completions.create(
            model=self.llm_model,
            messages=[
                {"role": "system", "content": self.system_prompt},
                {"role": "user", "content": question_text},
            ],
            temperature=0.3,
        )
        latency = time.perf_counter() - t0
        return StageResult(
            name="LLM 思考",
            model=self.llm_model,
            latency_s=latency,
            # content 可能为 None(如被截断/拒答);用空串兜底,避免 .strip() 崩溃。
            text=(resp.choices[0].message.content or "").strip(),
        )

    # -- 阶段 3:TTS 语音合成 ------------------------------------------------
    def synthesize(self, text: str, output_audio: str) -> StageResult:
        t0 = time.perf_counter()
        resp = self.client.audio.speech.create(
            model=self.tts_model,
            voice=self.tts_voice,
            input=text,
        )
        resp.stream_to_file(output_audio)
        latency = time.perf_counter() - t0
        return StageResult(
            name="TTS 语音合成",
            model=self.tts_model,
            latency_s=latency,
            audio_path=output_audio,
        )

    # -- 完整流水线 ----------------------------------------------------------
    def run(self, input_audio: str, output_audio: str) -> PipelineResult:
        asr = self.transcribe(input_audio)
        llm = self.think(asr.text)
        tts = self.synthesize(llm.text, output_audio)
        return PipelineResult(
            paradigm="cascaded",
            input_audio=input_audio,
            output_audio=output_audio,
            stages=[asr, llm, tts],
        )


def synthesize_question_audio(
    client: OpenAI,
    question_text: str,
    output_audio: str,
    tts_model: str = "tts-1",
    voice: str = "shimmer",
) -> None:
    """用 TTS 先合成一段「用户提问」的语音,作为两条管道共同的输入。"""
    resp = client.audio.speech.create(model=tts_model, voice=voice, input=question_text)
    resp.stream_to_file(output_audio)

test_none_content.py

"""回归测试:LLM 思考阶段返回 content=None 时不应在 .strip() 处崩溃。

某些模型在截断/拒答时 message.content 为 None;此前
CascadedSpeechModel.think 会以 AttributeError 中断整条级联流水线,
现在与项目里其它调用点一样用空串兜底。不依赖真实 API:客户端为假对象。
"""

import sys
from pathlib import Path
from types import ModuleType, SimpleNamespace

sys.path.insert(0, str(Path(__file__).parent))

try:
    import openai  # noqa: F401
except ImportError:
    sys.modules["openai"] = ModuleType("openai")
    sys.modules["openai"].OpenAI = object

from speech_model import CascadedSpeechModel


def _client_returning(content):
    msg = SimpleNamespace(content=content)
    completions = SimpleNamespace(
        create=lambda **kw: SimpleNamespace(choices=[SimpleNamespace(message=msg)]))
    return SimpleNamespace(chat=SimpleNamespace(completions=completions))


def test_think_tolerates_none_content():
    model = CascadedSpeechModel(client=None, llm_client=_client_returning(None))
    stage = model.think("还剩多少钱?")
    assert stage.text == ""


def test_think_normal_content_unchanged():
    model = CascadedSpeechModel(client=None, llm_client=_client_returning(" 还剩 3 元。 "))
    stage = model.think("还剩多少钱?")
    assert stage.text == "还剩 3 元。"