数据截至 (上游 commit b21e54d6a845)
语言模型层与会话历史
这一章讲什么: LLM 是整条链上最慢的一环,所以这一层的全部设计都围绕两件事:尽早把第一句话吐给 TTS,以及随时能把已经产生的东西干净地撤回。
1. 四种归一化事件:让 provider 差异只影响一层
它要解决的小问题
Responses API 和 Chat Completions API 的流式事件结构完全不同,本地 transformers/mlx-lm 更是连 HTTP 都没有。但「按句子批量吐给 TTS」「中途检查取消」「写历史」这些逻辑对三者是一样的。
思路
定义四个极小的事件类型,每个后端只负责把自己的响应翻译成它们:
| 事件 | 含义 | 定义位置 |
|---|---|---|
TextDelta | 一段原始助手文本(未做任何过滤) | base_openai_compatible_language_model.py:77-81 |
AssistantMessage | 一整个助手回合,用于写回历史 | 同上 :79-82 |
ToolCall | 一次完整的函数调用 | 同上 :85-88 |
Usage | token 计数 | 同上 :91-95 |
TextDelta 的 docstring 特意强调「Always RAW」——过滤是基类的事,后端不要自作主张。
子类只填七个钩子
BaseOpenAICompatibleHandler(base_openai_compatible_language_model.py:141-151)是抽象基类,七个 @abstractmethod 集中在 :259-301:warmup、_build_compaction_generate_fn、_serialize、_request、_iter_stream_events、_iter_response_events、_build_optional_kwargs。
(类 docstring 里写的 "four hooks" 和 _iter_events 是过期表述,以 @abstractmethod 列表为准。)
两个实现:
| 后端 | 类 | 打哪个端点 |
|---|---|---|
responses-api(默认) | ResponsesApiModelHandler | /v1/responses |
chat-completions | ChatCompletionsApiModelHandler | /v1/chat/completions |
两者共用同一套 --responses_api_* 连接参数(注册表里 config_prefix 都是 "responses_api",backend_registry.py:405 与 404),所以切换后端不用改连接配置。
本地模型走另一条继承链 BaseLanguageModelHandler(src/speech_to_speech/LLM/language_model.py:163),不复用这套 provider 事件。
2. 按句成批:延迟和自然度的平衡
它要解决的小问题
LLM 一个 token 一个 token 地出。TTS 不能一个词一个词地合成(听起来会断);但也不能等整段生成完(延迟爆炸)。
做法
用 NLTK 的 sent_tokenize 切句,攒够 stream_batch_sentences(默认 3)句才发一批。核心在 _consume_streaming(base_openai_compatible_language_model.py:581-677):
# 真实源码节选,base_openai_compatible_language_model.py:637-654
new_text = remove_unspeechable(event.text)
state.clean_text += new_text
printable_text += new_text
trailing_whitespace = printable_text[len(printable_text.rstrip()):]
sentences = sent_tokenize(printable_text)
if len(sentences) > 1:
for s in sentences[:-1]:
sentence_batch.append(s)
if len(sentence_batch) >= self.stream_batch_sentences:
...
yield from _flush(sentence_batch)
sentence_batch = []
...
printable_text = sentences[-1] + trailing_whitespace
为什么只取 sentences[:-1]? 最后一句可能还没说完(sent_tokenize 会把没写完的半句也当成一句),所以留在缓冲里等更多 token。trailing_whitespace 被特意保留并重新拼回去——因为 sent_tokenize 会吞掉尾部空白,而 TTS 拼接时需要它。
文本模式与语音模式的分叉
同一段代码里有一个显眼的 if not turn.wants_audio 分支(:624-636),行为完全相反:
| 语音模式 | 纯文本模式 | |
|---|---|---|
| 字符过滤 | remove_unspeechable 去掉念不出来的符号 | 逐字保留 |
| 切分 | 按句子批量 | 不切,来多少发多少 |
注释给了理由:sent_tokenize 会把换行和 markdown 结构压平,而纯文本客户端要的是原样输出。
remove_unspeechable(src/speech_to_speech/LLM/utils.py:127-136)是一个白名单正则:保留字母数字、常见标点、CJK 标点,其余全删;顺带把弯引号统一成直引号。
工具调用要打断句子批
遇到 ToolCall 事件时,必须先把攒着的句子冲出去再发工具调用(:610-622)。否则客户端会先看到工具调用、再听到本该在它之前的那句话。
3. 取消检查:检查点密度
流式消费循环里,取消检查出现在每一个可能产生副作用的位置之前:
for event in events:
├─ Usage? → 记账,不检查(用量是账单,取消也得算)
├─ 取消检查 ① → _turn_is_cancelled 或 不是最新 revision → break
├─ AssistantMessage → 只入 pending,不发
├─ ToolCall
│ ├─ 取消检查 ②(冲句子批之前)
│ └─ _record_tool_call 内还有 取消检查 ③(写历史之前)
└─ TextDelta
└─ 取消检查 ④(每次 flush 之前)
_turn_is_cancelled(:355-360)同时看两条线:预取事务是否被丢弃、以及 cancel_scope.is_stale(gen)。
一个诚实的局限,源码注释直接写明了(:1092-1094):
CancelScope.is_stale(gen)只在流迭代器前进时才检查;卡在 httpx 里的阻塞读无法被cancel_scope.cancel()打断。缓解手段是request_timeout_s/ReadTimeout。
也就是说:如果 provider 完全不吐字,取消要等到超时(默认 20 秒,:163)。
4. 生成的完整生命周期:_generate
_generate(base_openai_compatible_language_model.py:726-911)是这一层最重的函数。它的骨架:
┌─ try ────────────────────────────────────────────────────┐
│ 序列化请求 → 记录本轮实际看到的图片 id │
│ 空请求? → 记 error_message,不发请求 │
│ 发请求(有预取事务则走独立 worker 线程) │
│ 消费事件流(streaming / non-streaming) │
├─ except ─────────────────────────────────────────────────┤
│ httpx.ReadTimeout → 记 error_message │
│ 其他任何异常 → 记 error_message(必须吞掉!) │
├─ 收尾 ───────────────────────────────────────────────────┤
│ 失败且一个字都没吐 → 发一句兜底话术 │
│ 可以提交? │
│ ├─ 写历史(add_provisional_generation_items) │
│ └─ cleanup_history:剥图片 + 压缩历史 │
│ 否则 → rollback_transaction() │
│ 发 TokenUsage(如果有) │
│ 发 EndOfResponse(无论成败,必须发) │
├─ finally ────────────────────────────────────────────────┤
│ 再兜一次回滚 + 关闭响应 │
└──────────────────────────────────────────────────────────┘
为什么必须吞掉所有异常
源码注释写得很清楚(:794-798):任何异常逃出 process() 都会导致 EndOfResponse 不被发出,于是协议层的 st.in_response 永远卡在 True,之后所有响应全部被锁死。这是一个「一次失败拖垮整个会话」的类型,值得记住。
兜底话术
PROVIDER_FAILURE_FALLBACK = "I'm having trouble responding right now. Please try again."
(:63)只在请求确实发出去了、且一个字都没吐出来时才用(:803-810)。这个条件很克制:如果已经说了半句,不会再补一句道歉。
5. Chat:一个带事务的会话历史
为什么不能「生成成功再写历史」
因为工具调用。客户端拿到 function_call_arguments.done 后可能立刻回一个 function_call_output。如果这时 function_call 还没进历史,就会报 “No function_call with call_id … found”,模型会重发同一个工具调用。
所以必须先写后撤:工具调用一暴露给客户端就立刻入历史(_record_tool_call,base_openai_compatible_language_model.py:532-577),失败了再回滚。
事务原语
Chat(src/speech_to_speech/LLM/chat.py:88-133)提供一组按 response_key 索引的操作:
| 方法 | 语义 |
|---|---|
add_provisional_generation_items | 原子写入并登记为「临时」;返回 None 表示取消抢先了 |
finalize_provisional_generation | 转正 |
rollback_provisional_generation | 按 key 撤回 |
copy_without_provisional_generation | 拷一份不含某 key 临时内容的历史 |
add_provisional_generation_items(chat.py:379-440)最值得看:它先快照 buffer、_pending_tool_calls、_ordered_pending_call_ids、_user_turn_count 四份状态,任何一项写入抛异常就整体还原再重抛。手写的 all-or-nothing。
还有一个先手检查:if response_key in self._cancelled_provisional_generations: return None(chat.py:395-396)。取消可能比写入先到,所以取消会留下一个「墓碑」,让后到的写入直接失败。墓碑集合限长 128(chat.py:459-460)。
历史裁剪的两种策略
trim_if_needed(compactor)(chat.py:305-325)在每次成功生成之后调用一次,而不是在 add_item 里:
| compactor | 行为 |
|---|---|
None | 同步淘汰最老的一个完整回合(_evict_oldest_turn) |
| 有 | 后台线程调 LLM 把老内容摘要成一对 user/assistant 消息;单飞(in-flight 时后续触发直接跳过) |
--compact_history 控制是否启用后者(:166)。
淘汰的一个细节:_evict_oldest_turn(chat.py:137-164)删掉一个回合后,还要回头扫描整个 buffer,把那些 call_id 已被删除的孤儿 function_call_output 一并删掉。否则历史里会留下没有配对调用的输出,provider 会报错。
另有一道硬上限:用户回合数超过 2 * size 时,add_item 内联强制淘汰(chat.py:266-273),防跑飞客户端。
音频历史压缩
直接音频输入模式下,历史里塞满 base64 WAV 会迅速撑爆上下文。compact_audio_history(max_audio_turns)(chat.py:490-518)保留最新 N 个音频回合,更老的把音频部分替换成一段文字占位符,保留 user 角色和配对的 assistant 回复结构。默认 audio_history_turns=1(base_openai_compatible_language_model.py:175)。
6. 系统提示词:两个通道两套规则
_apply_config(base_openai_compatible_language_model.py:497-506) 按 wants_audio 选不同的构建器:
builder = build_voice_system_prompt if wants_audio else build_text_system_prompt
语音提示词的结构
build_voice_system_prompt(src/speech_to_speech/LLM/voice_prompt.py:33-42)拼三段:
[ LEAD: 你在语音对话中;下面的会话提示定义人设,本规则只管说话方式 ]
↓
[ 用户的 session instructions + 可选的工具说明块 ]
↓
[ TAIL: Voice Rules —— 最强约束放最后 ]
函数 docstring 直接点明排序理由:「最强约束放最后」。Voice Rules 的内容也很具体(voice_prompt.py:8-20),值得抄的几条:
- 默认一句话,最多两句,除非被要求;
- 不许 markdown、不许
*笑*这种动作描写; - 把转写当作有噪声的,除非被问到或影响理解,否则不要纠正可能的听错;
- 绝不在口播里提工具名;
- 信息类工具:直接调用,不要「我可以帮你查」;调用之间不说话;
- 表情/动作类工具:先说话再调用。
最后两条明显是为机器人场景(Reachy Mini)定制的。
7. 几个连接细节
| 细节 | 位置 | 说明 |
|---|---|---|
| 本机地址免 key | :193-199 | base_url 是 localhost/回环 且无 API key 时,自动填 "none" |
| 官方 OpenAI 不加 extra_body | _is_official_openai, :208-218 | 官方端点会拒绝未知字段 |
| 关思考的两种方式 | _build_extra_body, :233-255 | reasoning_effort 优先;否则用 chat_template_kwargs.enable_thinking=false |
| 预热重试 | WARMUP_MAX_RETRIES = 6, :59 | 注释估算约 18–24 秒 SDK 退避 |
| 请求超时 | :186-190 | 总超时 20s,连接超时取 min(10, total) |
_build_extra_body 的注释解释了为什么要两种:vLLM/Qwen 认 enable_thinking,而 HF router 上的 GLM 忽略它、只认 reasoning_effort='none'。
8. LMOutputProcessor:保序的最后一环
它坐在 LLM 和 TTS 之间(src/speech_to_speech/LLM/lm_output_processor.py),做一件事:把每个输出部分拆成「事件 + TTS 输入」两条消息,按顺序放进同一条队列。
LLMResponseChunk(parts=[文本A, 工具B, 文本C])
│
▼ 对每个 part:
┌────────────────────────────────────────────┐
│ AssistantOutputEvent(part, seq=n) ──────┼──► 同一条队列
│ 若是文本且需要音频:TTSInput(text) ──────┼──►
└────────────────────────────────────────────┘
│
└─ 若是工具调用:额外往旁路队列放 AssistantToolCallReadyEvent
为什么工具调用要走两条路? 因为工具调用可以在前面的 TTS 还没播完时就交给客户端去执行(节省往返),但它在有序队列里的那份事件仍然负责音频的收尾顺序。AssistantToolCallReadyEvent 的 docstring 说明了这个分工(pipeline/events.py:137-144),output_sequence 保证它不会越过前面的助手消息。
处理器还负责一个边界:回合已经过期时,不能直接什么都不发——否则协议层的响应永远不会关闭。它会发一个 cleanup_only=True 的 EndOfResponse(lm_output_processor.py:129-137),这条消息绕过下游所有投机闸门,只做生命周期清理。
9. 代码地图
| 主题 | 文件路径 | 符号名 |
|---|---|---|
| provider 事件归一化 | src/speech_to_speech/LLM/base_openai_compatible_language_model.py | TextDelta, AssistantMessage, ToolCall, Usage, ProviderEvent |
| 流式消费与按句批量 | 同上 | _consume_streaming, _consume_nonstreaming |
| 生成生命周期 | 同上 | _generate, PROVIDER_FAILURE_FALLBACK, _turn_is_cancelled |
| 连接与 provider 差异 | 同上 | _is_local_base_url, _is_official_openai, _build_extra_body |
| Responses 后端 | src/speech_to_speech/LLM/responses_api_language_model.py | ResponsesApiModelHandler |
| Chat Completions 后端 | src/speech_to_speech/LLM/chat_completions_language_model.py | ChatCompletionsApiModelHandler, _iter_chat_stream_events |
| 本地模型后端 | src/speech_to_speech/LLM/language_model.py | BaseLanguageModelHandler, LanguageModelHandler, VisionLanguageModelHandler |
| 会话历史与事务 | src/speech_to_speech/LLM/chat.py | Chat, add_provisional_generation_items, rollback_provisional_generation, _evict_oldest_turn, compact_audio_history |
| 历史摘要 | src/speech_to_speech/LLM/compaction_prompt.py | build_compactor, _extract_json |
| 提示词 | src/speech_to_speech/LLM/voice_prompt.py, text_prompt.py | build_voice_system_prompt, build_text_system_prompt, VOICE_SYSTEM_PROMPT_TAIL |
| 文本清洗与语言名 | src/speech_to_speech/LLM/utils.py | remove_unspeechable, resolve_auto_language, WHISPER_LANGUAGE_TO_LLM_LANGUAGE |
| 保序输出处理器 | src/speech_to_speech/LLM/lm_output_processor.py | LMOutputProcessor.process |