教程整体目标
通过逐章可运行的增量,理解 Agent 的模型、上下文、任务状态、规划、工具、Skills 与 Agent Loop 等关键组件,并从零构建一个由 Harness 约束、不依赖 Agent 框架的可用 Agent。
语音 Agent
处理实时输入输出、打断、转录、延迟和会话状态。
扩展篇 · 预计 55 分钟 · 前置:主线 11 用户澄清与中途转向、14 Agent Loop 已经通过 Checkpoint。本页目录
主线 11 用户澄清与中途转向、14 Agent Loop 已经通过 Checkpoint。
src/voice.tssrc/transcribe.tssrc/barge-in.tssrc/latency.tstests/voice.test.ts
语音不是主线 Capstone 的前置条件。它是扩展篇里对已经稳定的边界的复用:本章不引入任何真实音频 SDK、不做音频编解码、也不假设某种麦克风或 TTS 厂商,全部验证都跑在 Fake Stream 上离线完成。语音只是在 AgentState 之上再叠一层会话生命周期。
Step 1定义会话与事件契约
语音输入不是一次函数调用,而是一路持续到达的分片。先把它变成少量稳定事件,并给会话一个明确的阶段机,避免各处零散判断“现在到底在听、在想还是在说”。
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 直接消费音频。
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,恢复入口与主线完全相同。
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,也不重置预算。
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 的语音扩展和阈值:
export interface VoiceUsage { firstByteMs: number; totalTurnMs: number; bargeInMs: number; speechChunks: number; degraded: boolean;}export interface RunUsage { voice?: VoiceUsage | undefined;}firstByteMs 从 final transcript 到第一个 agent_speech_chunk 目标 ≤ 700ms 硬上限 1200mstotalTurnMs 从 final transcript 到最后一个 agent_speech_chunk 目标 ≤ 2500ms 硬上限 4000msbargeInMs 从检测到插话到停止播放并取消模型请求 目标 ≤ 200ms 硬上限 500msexport 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 中都能读到三个延迟指标。三条负向用例:
1 打断后仍收到旧回答:插话后旧的 agent_speech_chunk 不得继续进入输出; 停止原因必须是 interrupted,工具被取消,不得记入 failedAttempts,会话仍能开始下一个 turn。2 partial 覆盖 final:final 之后的 partial 不得修改已提交的用户消息, 也不得让 messages 出现第二条用户消息。3 超预算未降级:人造超过 firstByteMs 与 totalTurnMs 的延迟后, 必须触发 DegradeAction 且 degraded 为 true;没有降级即为失败。断言状态、事件序列、停止原因和延迟数字,不断言模型或识别器的具体措辞。修复前三条用例必须报错,修复后必须全部通过。语音这一章独立于 Capstone 交付,主线的 Checkpoint 与最终 Capstone 都不引用它。