# hybrid-ASR sidecar (:8012) 实现规格 — 可交付给任意 agent 的自含复刻文档

> 真源仓: `/data/workspace/hybrid-asr-server/`（hybrid_server.py 432行 / transcribe_worker.py 286行 / kap-hybrid-asr.sh 185行）
> 定谳: v2c 选择规则，29句宏 CER **0.1155**（双超 fw 纯转写 0.140 与纯草稿 0.152，oracle 上限 0.087）；人耳盲选 3:1 胜 fw
> 硬件: 单张 24GB 卡即可，稳态 VRAM ~11.6G（R2T2 8.6G + fw 3G），懒加载不驻留

---

## 0. 一页架构

```
调用方 (KMC p10c / 任意 HTTP 客户端)
   │
   ▼
[KAP :10588] /api/production/hybridAsr/transcribe        ← 薄路由 (可选层)
   │  ① 本地 400 镜像校验 (不发上游先短路)
   │  ② GpuScheduler ensureAllocated("hybrid-asr") → 未 granted → 503 allocate-not-granted
   │  ③ axios 转发 (proxy:false, timeout 600s)
   ▼
[hybrid_server.py :8012] FastAPI 主进程 — 零 GPU 依赖，秒起
   │  懒加载: 首个请求 spawn worker 子进程 (pilot venv python)
   │  TTL reaper: 空闲 900s 杀 worker → VRAM 硬释放
   │  通信: 127.0.0.1 私有端口 JSON 行协议
   ▼
[transcribe_worker.py] GPU 子进程 — 全仓唯一 GPU 依赖点
   R2T2 (vLLM, 流式草稿, max_new_tokens=4)  +  faster-whisper large-v3 (fp16, 终稿重转写)
```

**三层生命周期（互补不冲突）**:
- L1 进程内: TTL reaper 900s 杀 worker 子进程 → VRAM 随进程死亡硬释放，主进程保持驻留（health 仍 200/idle）
- L2 调度器: GpuScheduler idleTimeoutMs 10min → 整服务 stop（主进程也退）
- 异常自愈: worker 死亡/链路断 → 状态回退未加载 → 下次请求懒加载复活（设计行为，被 gpu-kill-external 杀掉后同理）

---

## 1. 依赖与模型

```bash
# venv — 现成只读复用（自建需含: vllm, r2t2, faster-whisper, av, fastapi, uvicorn, numpy）
# ⚠ Python ≤3.12 (worker 用 audioop, 3.13 已移除)
VENV_PY=/data/workspace/r2t2-t3po-pilot/Confucius4-R2T2/.venv/bin/python

# R2T2 权重 — 网易有道 Confucius4-R2T2 (Qwen3-ASR-1.7B 微调, 4.08GB bf16, 同 qwen3_asr 架构)
#   HF: netease-youdao/Confucius4-R2T2  ⚠ 自定义许可: §3.4c 禁衍生物改进其他 AI 模型
R2T2_WEIGHTS=$R2T2_HOME/weights/Confucius4-R2T2

# faster-whisper large-v3 — Systran/faster-whisper-large-v3 转 CT2 格式, float16, ~3G
FW_MODEL=/data/models/asr/faster-whisper-large-v3
```

**模型装载参数（verbatim，错一个都炸）**:
```python
from r2t2 import R2T2ASRModel
import faster_whisper

_ASR = R2T2ASRModel.LLM(
    model=R2T2_WEIGHTS,
    gpu_memory_utilization=0.35,
    max_model_len=4096,    # ⚠ 8192 会 KV cache 爆 (实测)
    max_new_tokens=4,      # 流式增量出字, 官方如此
)
_FW = faster_whisper.WhisperModel(FW_MODEL, device="cuda", device_index=0, compute_type="float16")
```

---

## 2. GPU 子进程 (transcribe_worker.py) — 核心算法层

### 2.1 启动协议（端口发现）

stdout 会被 vLLM 冗长日志污染，所以协议走 TCP、端口发现走 stdout **READY 行**，且**必须在任何重库 import 之前**打印:

```python
srv = socket.socket(...); srv.bind(("127.0.0.1", 0)); srv.listen(1)
print(f"HYBRID_WORKER_READY port={srv.getsockname()[1]}", flush=True)  # 先于 import vllm
load_s = _load_models()          # 40-90s 冷启 (vLLM 编译)
# 之后每条连接 accept 后先回: {"event":"ready","load_s":<秒>}
```

主进程用 selectors 扫 stdout 直到 READY 行（30s 预算），解析 `port=` 建连，再等 `event:ready`（300s 预算）。

### 2.2 请求协议（JSON 行，127.0.0.1）

```
请求: {"id":n,"op":"transcribe","audio_path":"/abs/x.wav","language":"Chinese"}
      {"id":0,"op":"shutdown"}
响应: {"id":n,"ok":true,"result":{...}} / {"id":n,"ok":false,"error":"..."}
```
单条请求失败只回 error 不杀 worker，下一条继续服务。

### 2.3 音频解码 → 16k mono float32

```python
def decode_pcm16k(path):
    if path.endswith(".wav"):   # audioop 链: lin2lin → tomono → ratecv(→16000)
        ... # wave + audioop
    else:                        # .mp3 等走 av (PyAV) 重采样
        resampler = av.AudioResampler(format="s16", layout="mono", rate=16000)
```

### 2.4 两段式管线（v2c 定谳核心）

```python
SENT_END = "。！？；?!;"      # 句末标点集; "|" 是 R2T2 自产"不稳定后缀"分隔符
DRAFT_CHUNK_MS = 160
step = 160 * 16               # = 2560 samples/160ms @16k
chunks = [pcm[i:i+step] for i in range(0, len(pcm), step)]

# ── 段1: R2T2 流式草稿 + 句子边界记录 ──
state = asr.init_streaming_state(context="", language=language,
                                 unfixed_chunk_num=0, unfixed_token_num=1,
                                 chunk_size_sec=0.160)
committed = ""; sent_windows = []; win_start = 0; first_text_s = None
for i, ch in enumerate(chunks):
    _, text = asr.streaming_transcribe(ch.astype(np.float32), state, max_new_tokens=4)
    if text and len(state.text) > len(committed):
        if first_text_s is None: first_text_s = (i+1) * 0.160   # 首字延迟 (实测 median ~0.8s)
        new = state.text[len(committed):]; committed = state.text
        if any(m in new for m in SENT_END) or "|" in state.text:  # 句末信号 (| 同样视为终止)
            sent_windows.append((state.text[win_start:], win_start, i))
            win_start = len(state.text)
asr.finish_streaming_transcribe(state)
full_draft = state.text
if win_start < len(full_draft):
    sent_windows.append((full_draft[win_start:], win_start, len(chunks)-1))

# ── 段2: fw 逐句窗重转写 + v2c 选择 ──
for w_idx, (sent_text, c0, c1) in enumerate(sent_windows):
    a0 = max(0, int(c0*step - 0.32*16000))       # 窗外扩 320ms 防切字
    a1 = min(len(pcm), int((c1+1)*step + 0.32*16000))
    # window → 临时 wav (16k mono int16) → fw.transcribe(tmp, language="zh", beam_size=5, vad_filter=False)
    fw_text = "".join(s.text for s in segments).strip()

    # ★ v2c 选择规则 (截断症状门) — 整个 hybrid 的价值所在:
    truncated = ("|" in sent_text) or (not any(m in sent_text for m in SENT_END))
    if truncated:
        text = fw_text if fw_text else sent_text.split("|")[0]   # fw 空则剥 | 后缀兜底
        engine = "fw"
    else:
        text, engine = sent_text, "draft"
```

**返回结构**:
```json
{"draft": "...", "final": "...",
 "sent_windows": [{"text","start_chunk","end_chunk"}],
 "fw_calls": N, "first_text_s": 0.8,
 "per_sentence": [{"text":"...","engine":"draft"|"fw"}],
 "timings": {"draft_s":.., "final_s":..}}
```

### 2.5 为什么是 v2c（历史定谳，勿走回头路）

| 方案 | CER | 结论 |
|---|---|---|
| v0 纯 fw | 0.140 | 整词幻听失败族 |
| 纯 R2T2 draft | 0.152 | 句尾 `|` 截断静默丢字（16/29 句），官方代码自己 `split("|")[0]` |
| v1 逐窗无条件换 fw | **0.202 翻车** | **碎窗 = fw 幻听放大器；utterance 级选择 > 窗级重转写** |
| **v2c 症状门选择** | **0.1155** | 只在截断症状句（带 `|` 或无句末标点）才请 fw；fw 与 draft 仅在 keep 集分歧 → 结构上 hybrid 下限 = fw 上限 |
| oracle 三源上限 | 0.087 | |

fw 换稿发生在句末异步（0.21s/句），不阻塞流式首字路径 → 流式延迟价值 + fw 精度两全。

---

## 3. 主进程 (hybrid_server.py :8012, FastAPI) — 生命周期层

### 3.1 关键不变量

1. **主进程零 GPU import**（fastapi/uvicorn only）——GPU 全在 worker 子进程，TTL 卸载 = 杀进程 = VRAM 硬释放，无进程内卸载残余风险
2. **单并发**: transcribe 全程持 `_lock` 串行；卸载与转写互斥同锁
3. **懒加载锁序**: `_load_lock` 只串行化 load 尝试，可在持 `_lock` 时获取（反之不可）→ 无 AB-BA；并发首请求排队 + double-check，失败 load 后的等待者直接 503 带原因，由下一个新请求重试
4. **health 探针永不抛**（breeze 语义），三态:
   - `ok` = worker 驻留；`loading` = 冷启中；`idle` = 未驻留（**不算病**，懒加载可复活）
   - 字段: `{status, model_loaded, engine:"hybrid-asr-v2c", idle_seconds?, ttl_sec, loading}`
5. **400 校验**（与 KAP 路由同语义镜像）: audio_path 必填绝对路径字符串 / 后缀 ∈ {.wav,.mp3} / 文件存在

### 3.2 环境旋钮

| 变量 | 默认 | 含义 |
|---|---|---|
| `HYBRID_ASR_IDLE_TTL_SEC` | 900 | L1 空闲卸载 |
| `HYBRID_ASR_LOAD_TIMEOUT_SEC` | 300 | worker 双模型冷启预算（实测 40-90s，余量给 vLLM 编译） |
| `HYBRID_ASR_REQUEST_TIMEOUT_SEC` | 600 | 单条转写应答超时（对齐 KAP 转发） |
| `HYBRID_ASR_VENV_PY` | pilot venv | spawn 用的 python |
| `HYBRID_ASR_FAKE_ENGINE=1` | - | 零 GPU 假引擎（自验/单测） |

### 3.3 WorkerEngine 摘要（spawn + 私有端口对话）

- `load()`: `Popen([VENV_PY, worker.py, "--port","0"], stdout=PIPE)` → 等 READY 行拿端口 → connect → 等 `event:ready` → 返回 load_s
- `transcribe()`: 写 JSON 行 → `sock.settimeout(600)` → 读应答行；OSError/EOF → `WorkerGoneError` + teardown
- `unload()`: 先发 shutdown（2s 优雅窗）→ terminate→kill→wait → **排干并关闭 stdout PIPE**（缓冲满会卡死子进程）
- worker 死亡时 `ConnectionResetError`（非纯 EOF）也算 WorkerGoneError → 主进程状态回退 → 下次请求复活

---

## 4. 生命周期脚本 (kap-hybrid-asr.sh) — 运维层

子命令 `start|stop|status`，幂等。核心片段与坑:

```bash
NEED_MB=12000; DEADLINE_S=420; WAIT_S=180; PORT=8012
HEALTH_OK() { curl -s --noproxy '*' --max-time 3 http://127.0.0.1:8012/health \
  | grep -Eq '"status":"(ok|idle|loading)"'; }   # 三态任一即活, 勿等模型载完

# ⚠ GPU 归属: UUID 锚定防 PCIe 枚举漂移 (双卡机), 但 CVD 必须传数字索引:
#   vLLM 0.14 的 device_id_to_physical_device_id 对 CVD 做 int() 强转,
#   UUID 字符串直接 ValueError → 上层 try/except 静默吞 → "not supported" 假象 (0925 实锤)
IDX=$(nvidia-smi --query-gpu=index,uuid --format=csv,noheader | grep -F "$GPU_UUID" | cut -d, -f1)
# 等显存: FREE_MB >= NEED_MB 才发车; deadline 到点放弃=正常 (调度器按 priority 驱逐后再战)
CUDA_DEVICE_ORDER=PCI_BUS_ID CUDA_VISIBLE_DEVICES="$IDX" \
  HYBRID_ASR_IDLE_TTL_SEC=900 nohup "$VENV_PY" hybrid_server.py >> log 2>&1 &

# ⚠ 孤儿清扫: worker 是主进程子进程, 主进程 kill -9 暴死后 worker 成孤儿继续占 11.6G
sweep() { pkill -9 -f "hybrid_server.py"; pkill -9 -f "transcribe_worker.py"; }
# start 重试路径 + stop 都必须 sweep, VRAM 才真释放
```

`status` 输出 `{"running":bool,"port":8012,"health":{...}}`；stopped 不是错误（scheduler getState 语义，exit 0）。

---

## 5. KAP 集成层（可选；无 KAP 时直连 :8012 也能用）

### 5.1 调度注册

GpuScheduler 注册 serviceId=`hybrid-asr`，allocate 类型 `{type:"script", command:"bash", args:[kap-hybrid-asr.sh, subcmd]}`，profile `idleTimeoutMs: 600000`（L2 整服务卸载）。**不进 withGpuQueue 队列**（unmanaged-resident 记账口径，sidecar 内部单并发锁自管）。

### 5.2 路由 `/api/production/hybridAsr/{transcribe,status}`

**transcribe**: 本地 400 镜像（缺字段/后缀/不存在，不发上游先短路）→ `ensureAllocated`（进程内 in-flight 备忘收敛并发首拉）→ 未 granted → **503 allocate-not-granted** → axios 转发 → 响应 envelope:
```json
{"code":0, "data":{"engine":"hybrid-asr-v2c","audio_path","language","draft","final",
 "sent_windows","fw_calls","first_text_s","per_sentence","timings":{...}}}
```
错误归类: 上游 400→400 透传；上游 5xx→500 包装；不可达→502；超时→504。`KAP_VRAM_SKIP=1` 测试旁路。

**status**: 纯探针不触调度器，失败返回 200 + `healthy:false` 降级态（idle 不算病）。

### 5.3 消费端纪律（0927 事故换来的硬规）

1. **axios 必 `proxy: false`** — 127.0.0.1 目标走 shell 代理（7890）会被截胡误归 upstream-error
2. **超时底线 600s** — 冷启 40-90s + 整段流式转写
3. **「sidecar 活 ≠ 模型载完」** — 三态 health 只证进程在；冷启型引擎的 ensure 放行判据必须落到 `model_loaded` 字段，等待预算 ≥600s，**loading 窗内的 503 = 重试信号非引擎死**
4. **常态恒经 KAP 路由，勿直连 sidecar** — 直连绕过调度器空闲钟刷新 → L2 10min 到点把正在使用的服务整体停掉（长批次 >10min 必中）；直连仅作 KAP 宕机兜底
5. **503 allocate-not-granted 如实上抛，绝不绕队**

---

## 6. 验收清单（新部署跑这些）

```bash
# 1) 健康三态
curl -s http://127.0.0.1:8012/health | jq        # → status:"idle", model_loaded:false = 正常待命

# 2) 首打冷启 (真 wav 绝对路径)
time curl -s -X POST http://127.0.0.1:8012/transcribe \
  -H 'Content-Type: application/json' \
  -d '{"audio_path":"/abs/real.wav"}' | jq '.final, .timings'
#    → 冷启 40-90s (timings 有 load_s), final 非空; 二打热路径秒级
#    → nvidia-smi 应见 ~11.6G; 10min 无请求后归零 (TTL)

# 3) CER 抽检: 3 条已知真值 clip 算字错率, 应显著优于纯 fw 基线

# 4) 单测: tests/run_tests.py — FakeEngine 桩覆盖懒加载/TTL/串行/死亡复活语义, 零 GPU

# 5) KAP 面 (若集成): status 路由 healthy:false + 拉起后 transcribe envelope 完整
```

## 7. 已知边界

- 只收 .wav/.mp3；TTS 干净音频上 CER 评测须做**同音字归一**（fw 失分多为同音字，人耳不可辨，逐字对齐会系统性误判——盲选终审 3:1 的教训）
- R2T2 权重 = 网易自定义许可（代码 Apache-2.0）: >1亿 MAU 或 >10亿 RMB 年收需另授权，§3.4c 禁衍生物改进其他 AI 模型
- 语言参数透传 R2T2（默认 "Chinese"）；fw 侧固定 `language="zh"` + `beam_size=5` + `vad_filter=False`
- 流式场景（实时字幕/同传）才需要 R2T2 路径的价值；纯离线批量、无流式需求时直接用 fw 更简单——本项目采用它是因为要同时喂流式消费面与高精度终稿
