LinOnward / Agent TutorialAgent Tutorial
扩展篇5 / 6
教程整体目标

通过逐章可运行的增量,理解 Agent 的模型、上下文、任务状态、规划、工具、Skills 与 Agent Loop 等关键组件,并从零构建一个由 Harness 约束、不依赖 Agent 框架的可用 Agent。

E05

语音 Agent

处理实时输入输出、打断、转录、延迟和会话状态。

本页目录
本章任务接入一个离线 Fake 音频流,完成一次“部分转录 → 最终转录 → 语音回答”的完整回合,再用插话打断它,并验证三条负向用例。
章节目标在既有 Harness 边界内增加语音会话:把连续音频分片收敛成稳定事件,用显式状态区分部分转录与最终转录,让用户插话立刻取消当前模型流与副作用工具,并把延迟预算写进 Trace 与 RunUsage。
开始之前

主线 11 用户澄清与中途转向、14 Agent Loop 已经通过 Checkpoint。

本章涉及文件
  • src/voice.ts
  • src/transcribe.ts
  • src/barge-in.ts
  • src/latency.ts
  • tests/voice.test.ts

语音不是主线 Capstone 的前置条件。它是扩展篇里对已经稳定的边界的复用:本章不引入任何真实音频 SDK、不做音频编解码、也不假设某种麦克风或 TTS 厂商,全部验证都跑在 Fake Stream 上离线完成。语音只是在 AgentState 之上再叠一层会话生命周期。

Step 1定义会话与事件契约

语音输入不是一次函数调用,而是一路持续到达的分片。先把它变成少量稳定事件,并给会话一个明确的阶段机,避免各处零散判断“现在到底在听、在想还是在说”。

typescript
export interface VoiceBudget {  firstByteMs: number;  totalTurnMs: number;  bargeInMs: number;}export interface VoiceSessionState {  turnId: string;  phase: "idle" | "listening" | "thinking" | "speaking" | "interrupted";  partialTranscript: string | undefined;  budget: VoiceBudget;}export interface VoiceSession {  sessionId: string;  state: VoiceSessionState;  speech: SpeechOutcome | undefined;}export type SpeechOutcome =  | { kind: "spoken"; turnId: string; text: string }  | { kind: "interrupted"; turnId: string; spokenPrefix: string; reason: string };export type VoiceEvent =  | { type: "partial_transcript"; turnId: string; text: string }  | { type: "final_transcript"; turnId: string; text: string }  | { type: "agent_speech_started"; turnId: string }  | { type: "agent_speech_chunk"; turnId: string; text: string }  | { type: "barge_in"; turnId: string; reason: string }  | { type: "session_closed"; sessionId: string; reason: string };// 事件 type 继续使用主线状态章那种稳定字符串注册表,而不是另造一套联合类型;// appendEvent、nextEventSequence 和 recordedAt 都原样复用。export type VoiceEventType = VoiceEvent["type"];export function appendVoiceEvent(  session: VoiceSession,  event: VoiceEvent,): VoiceSession {  return {    ...session,    state: {      ...session.state,      partialTranscript:        event.type === "partial_transcript" ? event.text : session.state.partialTranscript,    },  };}

会话与运行是两层:VoiceSessionState.phase 只描述这一轮语音走到哪里,AgentState.status 仍然由 Harness 掌握。最终转录写入 messages 后运行进入 running;插话记成事件并写入停止原因;延迟写进 Trace 与 RunUsage;session_closed 才把运行转成 cancelled。

Step 2流式输入与转录

AudioChunk 是分片,SpeechRecognizer 是它的状态机。分片边界与语义边界无关,所以必须由识别器把噪声收敛成事件,而不是让 Harness 直接消费音频。

typescript
export interface AudioChunk {  sequence: number;  pcm: Uint8Array;  startedAt: number;  durationMs: number;}export interface SpeechRecognizer {  accept(chunk: AudioChunk): VoiceEvent[];  flush(): VoiceEvent[];}

识别器只负责发事件:最后一个分片之后必须 flush(),因为只有它才能确定末句已经结束;sequence 必须对同一个 turnId 单调递增,乱序或重复分片直接丢弃。部分转录(partial)和最终转录(final)不是“快慢”之分,而是可信度与权限之分。final 到达时,若模型仍需用户补充,就在 waiting 中持久化一个 UserInputRequest,恢复入口与主线完全相同。

typescript
function reduceTranscript(  state: AgentState,  session: VoiceSession,  event: VoiceEvent,  now: Date,): { session: VoiceSession; state: AgentState } {  if (event.type === "partial_transcript") {    return {      session: appendVoiceEvent(session, event),      state: appendEvent(state, "voice_partial_transcript", event.text, now),    };  }  if (event.type !== "final_transcript" || event.turnId === state.lastCommittedTurnId) {    return { session, state };  }  return {    session: { ...session, state: { ...session.state, partialTranscript: undefined } },    state: appendEvent(      { ...state, lastCommittedTurnId: event.turnId },      "voice_final_transcript",      event.text,      now,    ),  };}
  • partial 可能被随时改写甚至撤回,只能作为临时上下文来源,priority 低于所有持久来源,不写 messages,不触发规划,也不能用来授权工具。
  • final 才是权威:只有它写入 messages、推进 goalVersion 或约束、并允许运行从 waiting 回到 running;同一个 turnId 只提交一次,重复的 final 必须丢弃,否则一次说话会变成两条用户消息。

Step 3处理打断与取消

用户插话必须立刻停住正在播放的回答,并用同一个 AbortSignal 取消当前模型流和正在执行的副作用工具:取消要发生在前,记账要发生在后。turnController 就是主线 Harness 认识的那种取消通道,插话只中止当前回合,不结束 run,也不重置预算。

typescript
export function handleBargeIn(  session: VoiceSession,  state: AgentState,  turnController: AbortController,  now = new Date(),): { session: VoiceSession; state: AgentState } {  turnController.abort(new Error("barge_in"));  const spokenPrefix =    state.messages.at(-1)?.role === "assistant" ? state.messages.at(-1)!.content : "";  return {    // 插话只结束当前回合:运行状态仍是 running,预算、计划与已完成的步骤都不动。    state: appendEvent(state, "barge_in", session.state.turnId, now),    session: {      ...session,      state: { ...session.state, phase: "interrupted", partialTranscript: undefined },      speech: { kind: "interrupted", turnId: session.state.turnId, spokenPrefix, reason: "interrupted" },    },  };}// “被打断”不是“失败”:interrupted 表示用户主动结束了当前回合,// 而不是模型错误或工具异常。// 但要注意区分两件事:回合被打断**不结束 run**(不上报运行级停止原因),// 只有用户随后明确要求终止整个运行时,才写运行级 interrupted。export interface StopReasonMap {  interrupted: true;}

模型适配器抛错时按 signal.aborted 与 signal.reason 分类:因插话中止的请求归类为回合级 interrupted,只有真正的运行故障才写 failed;对应 TraceEvent 的 outcome 用既有的 blocked,不新增枚举值。不要用 failed 掩盖取消,也不要让一次插话落进 failedAttempts——否则 blocked_plan 门禁会把用户插话误判成需要修复的失败。

已经播出的语音输出不能当成完整回答:

  • 被标记为 interrupted 的回合,其不完整文本不写入 messages,只留在审计事件里,后续 turn 不能拿它当已完成的答案;
  • 已经完成的副作用不回滚,但在新回合里要显式声明“上一轮已经做了什么”,交给模型而不是交给播放缓冲区;
  • abort() 返回只代表请求已发出,必须等模型流与可取消工具都确认停止后,才允许进入 listening;不能假设“发出了取消”就等于“没有发生副作用”。

Step 4约束延迟预算

语音的体验由三个延迟决定,必须分别度量、分别设阈值,超预算时先降级而不是先解释。VoiceBudget 在第 1 步已经定义,这里只补 RunUsage 的语音扩展和阈值:

typescript
export interface VoiceUsage {  firstByteMs: number;  totalTurnMs: number;  bargeInMs: number;  speechChunks: number;  degraded: boolean;}export interface RunUsage {  voice?: VoiceUsage | undefined;}
text
firstByteMs   从 final transcript 到第一个 agent_speech_chunk  目标 ≤ 700ms   硬上限 1200mstotalTurnMs   从 final transcript 到最后一个 agent_speech_chunk  目标 ≤ 2500ms  硬上限 4000msbargeInMs     从检测到插话到停止播放并取消模型请求              目标 ≤ 200ms   硬上限 500ms
typescript
export type DegradeAction =  | { kind: "acknowledge_first"; text: string }  | { kind: "shorten_answer"; maxSentences: number }  | { kind: "defer_deep_work"; note: string };export function planVoiceDegradation(  budget: VoiceBudget,  elapsed: { firstByteMs: number; totalTurnMs: number },): DegradeAction | undefined {  if (elapsed.firstByteMs > budget.firstByteMs) {    return { kind: "acknowledge_first", text: "我先确认一下,再继续处理。" };  }  if (elapsed.totalTurnMs > budget.totalTurnMs) {    return { kind: "shorten_answer", maxSentences: 2 };  }  return undefined;}

超预算的三种降级按顺序触发:先回应再计算(先播一句确认,把首字节压进预算)、缩短回答(限制句数而不是丢信息)、把重活推到回合之后并明确告知。三个指标都必须写进 Trace 事件(type: "voice_timing",配 errorCode: "voice_<metric>")与 RunUsage.voice,而不是只打一行日志:日志无法参与评测阈值比较,也无法在回归门禁里被发现。session_closed(例如 Ctrl+C 的 SIGINT)用另一条路径结束整个 run,它不写 interrupted,而是复用既有的 cancelled。

Step 5测试状态与取消

创建 tests/voice.test.ts,全部使用 Fake Stream 与 Fake Model:一个固定分片脚本按顺序吐出 partial、final、插话和关闭信号,不依赖麦克风、TTS 或任何网络。正向回合断言事件顺序为 partial_transcript → final_transcript → agent_speech_started → agent_speech_chunk → session_closed,messages 里只出现最终转录,且 RunUsage.voice 与 Trace 中都能读到三个延迟指标。三条负向用例:

text
1 打断后仍收到旧回答:插话后旧的 agent_speech_chunk 不得继续进入输出;  停止原因必须是 interrupted,工具被取消,不得记入 failedAttempts,会话仍能开始下一个 turn。2 partial 覆盖 final:final 之后的 partial 不得修改已提交的用户消息,  也不得让 messages 出现第二条用户消息。3 超预算未降级:人造超过 firstByteMs 与 totalTurnMs 的延迟后,  必须触发 DegradeAction 且 degraded 为 true;没有降级即为失败。

断言状态、事件序列、停止原因和延迟数字,不断言模型或识别器的具体措辞。修复前三条用例必须报错,修复后必须全部通过。语音这一章独立于 Capstone 交付,主线的 Checkpoint 与最终 Capstone 都不引用它。