跳到主要内容

语言模型:流式分句、工具调用、有界历史与后台压缩

30 秒导读: 这一章讲流水线的"大脑到嘴巴"那一段——从最终转写(用户说完的那句话)出发,怎么调语言模型、怎么把模型一个 token 一个 token 吐出来的流,变成能被 TTS 一句句朗读的干净文本,同时把里面夹带的工具调用摘出来走旁路,还要把越攒越长的对话历史控制在可控大小(必要时在后台偷偷压缩)。

这是核心机制二。机制一(流水线运行时、线程模型)见 01-pipeline-runtime.md;"边说边改、可打断"的 speculative turns / barge-in 见 03-speculative-turns.md——本章会引用它的 stale 判定,但不重复原理。


1. 这是什么(零基础也能懂)

1.1 它在整条流水线里的位置

一次语音对话大致是这样一条流水线:

你说话 → VAD 判断"说完了" → STT 转写成文字 → 【语言模型】→ TTS 合成语音 → 你听到回答
↑ 本章

本章聚焦中间那个方块:拿到"用户说完的那句话"(最终转写),产出"可朗读的助手文本 + 可能的工具调用"。

1.2 为什么这一段不只是"调一下 API"

如果只是文字聊天,调 LLM 很简单:发一段消息,等它返回一整段文字。但这里是语音,多出三个真实约束:

  • 要快开口。 用户说完话,不能干等模型把整段答案生成完再开口——那样延迟太长。得边生成边朗读
  • 但又不能太碎。 TTS 一次只喂半句话("我觉得……"),合出来的语音会断断续续、语调怪异。所以要攒够一小批完整句子再喂
  • 模型可能想"动手"。 模型输出里可能夹着工具调用(查天气、算数)。这部分不能读出来,得摘出去走另一条路。

再加上对话越来越长会撑爆上下文/显存,得有个机制把老历史裁掉或压缩。

1.3 一句话直觉

把语言模型这段想成一个同声传译:一边听模型"说"(token 流),一边攒够一整句才开口翻给 TTS;听到对方说"我要查一下资料"(工具调用)时,先把手头的话说完,然后暂停朗读、去后台查;同时脑子里只记最近几轮对话,太老的谈话内容边听边总结成一两句塞回记忆。

1.4 支持哪些后端

模型可以来自四种后端,本章主要讲前两种"OpenAI 兼容"的:

后端 (llm_backend)走什么协议典型场景
responses-apiOpenAI /v1/responses官方 OpenAI、支持 Responses API 的服务
chat-completionsOpenAI /v1/chat/completionsvLLM + Qwen 等,工具调用最成熟的路
transformers本地 HuggingFace 模型离线、自托管
mlx-lmApple MLXMac 本地推理

前两者由本章的主角 BaseOpenAICompatibleHandler 统一编排;后两者是另一套本地推理 handler,本章只在分发处提一句。


2. 顶层全景(它大概怎么转)

2.1 三层结构

这一段代码分三层,各司其职:

干什么关键文件
归一编排层把各后端的流映射成统一事件,做分句批处理、取消、历史写回LLM/base_openai_compatible_language_model.py
后端适配层每种后端只负责"怎么发请求 + 怎么把它的流翻成统一事件"LLM/responses_api_language_model.pyLLM/chat_completions_language_model.py
历史与旁路有界对话历史 + 后台压缩;把工具事件摘出来送客户端LLM/chat.pyLLM/lm_output_processor.py

2.2 一次请求的主线(高层)

LLMIn (最终转写 + response/turn 元数据)


process() ── stale? ──► 直接 EndOfResponse(丢弃) [引用 03 的话轮判定]
│ 不 stale

build_active_chat / copy 组装本轮要发的 Chat
│ + 系统提示(voice vs text 两种)

_serialize → _request → _iter_events 后端适配层:发请求、把流翻成 ProviderEvent


ProviderEvent 流: TextDelta / AssistantMessage / ToolCall / Usage


_consume_streaming ← 本章核心:攒句子、过滤、text/audio 分支、工具前 flush
│ 产出 LLMResponseChunk(一批句子 or 一个工具调用)

(生成结束)写回 original_chat → trim_if_needed → TokenUsage → 必发 EndOfResponse


LMOutputProcessor 把工具/文本事件送客户端队列;干净文本 → TTSInput

2.3 归一化的核心:ProviderEvent

不同后端返回的流长得完全不一样(/v1/responsesResponseTextDeltaEvent,/v1/chat/completionschoices[].delta)。基类不想为每种后端各写一套"分句 + 取消 + 历史"逻辑,于是定义了一个只有四种事件的小词汇表,每个后端只需把自己的流翻译成它:

事件含义定义位置
TextDelta增量助手文本(原始未过滤)base_openai_compatible_language_model.py:53
AssistantMessage一整段助手回复,用于写回历史base_openai_compatible_language_model.py:60
ToolCall一个完整的函数调用(call_id/id 已重生成)base_openai_compatible_language_model.py:66
Usage本轮 token 计数base_openai_compatible_language_model.py:72

妙处: 分句、批处理、取消、历史写回、错误兜底这些"难而通用"的逻辑,全部只写一遍,活在基类里;后端子类只差在"怎么产出这四种事件"。见 base_openai_compatible_language_model.py:109 类 docstring 对"四个钩子"的说明。


3. 核心机制一:流式分句(攒够 N 句再发)

3.1 它要解决的小问题

模型是逐 token 吐字的。TTS 想要的却是成句的文本。如果每来一个 token 就喂 TTS,语音会碎得没法听;如果等整段生成完再喂,首字延迟又太长。

折中:边生成边攒,凑够一小批完整句子(默认 3 句)就发一次。 既不太碎,又不用等到最后。这个批大小就是 stream_batch_sentences(默认 3,见 base_openai_compatible_language_model.py:137)。

3.2 思路:用 sent_tokenize 找句子边界

怎么知道"一句话说完了"?用 NLTK 的 sent_tokenize(句子切分器,能识别 . ? ! 等句末,又不会被 U.S. 这种缩写骗到)。核心技巧是:只有 sent_tokenize 切出 >1 段时,前面的段才算"已完结的句子",最后一段留着继续接 token——因为最后一段可能还没说完。

3.3 原理演示

下面这段用简化 Python 演示"攒句子"的核心逻辑(非源码,# 示意):

# 示意,非源码:边收 token 边攒句子,凑够 N 句发一批
buffer = "" # 还没成句的尾巴
batch = [] # 攒好的完整句子
BATCH_N = 3
for delta in token_stream: # 模型逐块吐字
buffer += clean(delta) # 先过滤掉不可朗读字符
sentences = sent_tokenize(buffer)
if len(sentences) > 1: # 切出多段 → 除最后一段外都算完结
for s in sentences[:-1]:
batch.append(s)
if len(batch) >= BATCH_N:
emit(" ".join(batch)) # 凑够 3 句,发给 TTS
batch = []
buffer = sentences[-1] # 最后一段留着继续接
# 收尾:把尾巴和没发满的批一起发出去
if buffer.strip():
batch.append(buffer.strip())
if batch:
emit(" ".join(batch))

重点看: sentences[:-1] 是"确定说完的句子",sentences[-1] 是"可能还没说完的尾巴",留到下一轮。

3.4 真实实现

真正的循环在 _consume_streaming(base_openai_compatible_language_model.py:310)。音频分支的分句在 base_openai_compatible_language_model.py:362-378:先 remove_unspeechable(event.text) 过滤,累加到 printable_text,sent_tokenize 后把 sentences[:-1] 逐句压入 sentence_batch,满 stream_batch_sentences_flush,printable_text = sentences[-1] 留尾。收尾在 base_openai_compatible_language_model.py:380-389:把残余尾巴补进批、最后一次 _flush

_flush(base_openai_compatible_language_model.py:315)用 " ".join(batch) 把一批句子拼成一个 LLMResponseChunk 发出去——但发之前先查话轮是否还是最新(见 §7)。

3.5 过滤:remove_unspeechable

模型爱输出 emoji、markdown 记号、奇怪符号,这些读出来很怪。remove_unspeechable(LLM/utils.py:24)干两件事:

  • 把弯引号 ''"" 归一成直引号(SMART_PUNCT_TRANSLATION);
  • 用一个白名单正则 SPEECHABLE_PATTERN(LLM/utils.py:18)只保留字母/数字/常见标点/空白/货币符号——其余(如 emoji)一律删掉。它保留 Unicode 文字,所以中日韩阿拉伯文都不受影响。

4. 核心机制二:text-only vs audio 两条路,与工具前 flush

4.1 为什么要分两条路

同一个模型输出,面向两种消费者:

  • 要朗读(wants_audio):文本要过滤 + 分句,喂给 TTS。
  • 纯文本(text-only):比如客户端只想要文字,那就要逐字原样转发——不能过滤(会丢 markdown 符号),也不能分句(sent_tokenize 会把换行/markdown 结构压平)。

wants_audioresponse_wants_audio(utils/utils.py:12)算:output_modalities 为空或含 "audio" 就是音频;显式 ["text"] 就是纯文本。

4.2 两条路的差异

维度要朗读 (audio)纯文本 (text-only)
过滤remove_unspeechable 删不可读符号不过滤,保留每个字符
分句sent_tokenize 攒句成批不分句,逐 TextDelta 原样转发
目的TTS 连贯保留 markdown / 换行结构
代码位置base_openai_compatible_language_model.py:362base_openai_compatible_language_model.py:350

流式里 text-only 分支(base_openai_compatible_language_model.py:349-361)见到 TextDelta 就直接 yield self._chunk(turn, text=event.text)continue,完全绕开分句逻辑。非流式的同样区分在 _consume_nonstreaming(base_openai_compatible_language_model.py:405-416)。

4.3 工具调用前必须先 flush

当流里冒出一个 ToolCall 事件时,可能手头还攒着没发的句子(比如模型先说了"我帮你查一下",紧接着发起工具调用)。必须先把这些话说完,再发工具调用,否则那半句"我帮你查一下"会被工具调用打断、乱序。

_consume_streaming 里对 ToolCall 的处理(base_openai_compatible_language_model.py:336-348):

  • 先把 printable_text 的残余压进 sentence_batch;
  • sentence_batch 非空,yield from _flush(...) 把话说完;
  • 然后才 yield from self._record_tool_call(...)

_record_tool_call(base_openai_compatible_language_model.py:289)做两件事:把工具调用记进历史(state.pending 里加一个 RealtimeConversationItemFunctionCall),再把它作为 LLMResponseChunk(tools=[item]) 发出去(除非话轮已 stale——那就只记录、不朗读)。


5. 核心机制三:错误必发 EndOfResponse 与超时兜底

5.1 为什么"必须发 EndOfResponse"是铁律

流水线用一个 EndOfResponse 信号告诉下游"这轮结束了,可以重新开始监听/释放槽位"。如果生成中途抛异常、EndOfResponse 没发出去,st.in_response卡死在 True,之后所有响应都被锁住。所以无论成功、失败、超时,收尾一定要发 EndOfResponse

_generate(base_openai_compatible_language_model.py:422)用 try/except/finally 把这条铁律钉死:

  • 任何非超时异常 → 记进 error_message,不重新抛出,落到函数末尾统一发 EndOfResponse(..., error=error_message)(base_openai_compatible_language_model.py:464-471base_openai_compatible_language_model.py:498)。docstring 明确点出"没有这段异常会逃出 process()EndOfResponse 不发、后续响应全锁死"。
  • finally 里还负责 api_response.close(),防止流泄漏(base_openai_compatible_language_model.py:472-477)。

5.2 ReadTimeout:道歉而不是崩

网络慢、模型半天不吐字,httpx.ReadTimeout 会触发(超时由 request_timeout_s 定,默认 20 秒,见 base_openai_compatible_language_model.py:136)。这时不该静默失败,而是发一句预置道歉:

"Wow I'm a bit slow today, could you repeat that?"

base_openai_compatible_language_model.py:448-463。注意这句道歉不带 language_code(沿袭旧 handler),之后照常落到 EndOfResponse

一个诚实的边界(代码里写明): cancel_scope.is_stale(gen) 只在流迭代器前进时才被检查;一次卡死在 httpx 里的读是无法被 websocket 那侧的 cancel_scope.cancel() 打断的。缓解手段就是 request_timeout_s / ReadTimeout 兜底。见 base_openai_compatible_language_model.py:540-542

5.3 空输入的前置校验

若序列化出的 api_input 为空(既没 instructions 也没 input),provider 会拒绝。基类提前给出清晰错误而不是等对端报晦涩错误(base_openai_compatible_language_model.py:433-437)。


6. 关思考:extra_body 的 provider 差异

6.1 问题

很多模型带"思考/推理"模式,会先输出一大段思维链再给答案。语音对话要低延迟,得关掉它。但各家关法不一样——这是很容易踩的坑。

6.2 三种情形

_build_extra_body(base_openai_compatible_language_model.py:173)按优先级处理:

provider 情形关思考的方式extra_body
官方 OpenAI(或 base_url is None)不能塞未知字段(会被拒)None
传了 reasoning_effort(如 GLM via HF router)优先用它{"reasoning_effort": reasoning_effort}
否则 disable_thinking(vLLM/Qwen)用 chat 模板开关{"chat_template_kwargs": {"enable_thinking": False}}

_is_official_openai(base_openai_compatible_language_model.py:161)会归一化末尾斜杠,让 https://api.openai.com/v1/ 也被识别成官方服务——官方服务对我们发给 vLLM/HF router 的那些 provider 专属键一律拒收,所以必须回 None


7. 与 03 的接口:话轮 stale 判定(只引用)

本章多处在发东西之前先问一句"这轮还算最新吗?"——这套判定的原理在 03-speculative-turns.md,这里只说本章怎么用它:

三个门在基类里(base_openai_compatible_language_model.py:245-254):

  • _turn_is_latestspeculative_turns.is_latest(...):话轮是否被更新的修订取代;
  • _turn_output_allowedis_latest_after_reopen_grace(...):允不允许输出(带"重开宽限期");
  • _generation_is_stalecancel_scope.is_stale(gen):这代生成是否已被取消。

它们被撒在:进入 process 时(base_openai_compatible_language_model.py:509,stale 就直接发 EndOfResponse 返回)、每次 _flush 前、每个 TextDelta/ToolCall 发出前、收尾写回历史前。只要话轮过期,就"记录但不朗读"或直接丢弃——这是"可打断"的落点。LMOutputProcessor 里也有一份同样的 _turn_output_allowed(lm_output_processor.py:49),在送客户端前再挡一道。


8. 核心机制四:有界对话历史(Chat)

8.1 它要解决的小问题

对话一轮轮加,历史无限增长会撑爆上下文/显存。Chat(chat.py:77)是一个有界的历史缓冲:系统消息单独存(init_chat_message),其余(用户/助手消息、工具调用、工具输出)进 buffer大小以"用户话轮数"计,超了就逐出或压缩。

8.2 两级上限:软 size + 硬 2*size

触发点何时怎么处理
软限 size每次成功生成后显式trim_if_needed无压缩器:同步逐出最老一整轮;有压缩器:后台压缩
硬限 2*sizeadd_item内联检查用户话轮 > 2*size 直接同步逐出(有损,防跑飞客户端)

关键设计:add_item(chat.py:174)执行软限——软限交给显式的 trim_if_needed(chat.py:239),这样"什么时候裁"由调用方(每轮生成完)掌控;但 add_item 保留一个硬上限 2*size 作为跑飞客户端的安全网(chat.py:228-235)。

8.3 逐出以"整轮"为单位

_evict_oldest_turn(chat.py:116)从头 pop,直到碰到下一条 UserMessage 边界——即把"最老一个用户问 + 它引出的所有助手/工具内容"整块删掉,不会留下无主的助手消息。

8.4 FC/FCO 配对与重注入

工具调用有两半:function_call(FC,模型说"调这个函数")和 function_call_output(FCO,函数返回值)。两者靠 call_id 配对,不能拆散——只有 FC 没 FCO,或只有 FCO 没 FC,发给模型都会出错。

Chat 用一个 _pending_tool_calls 字典专门盯这件事:

  • FC 进来时先不进 buffer,而是暂存进 _pending_tool_calls(chat.py:214-218);
  • FCO 到来时(append_tool_output_append_tool_output_locked,chat.py:152):若对应 FC 还在 buffer 就直接追加 FCO 并标 FC 为 completed;若 FC 已被逐出,就从 _pending_tool_calls 里把它重新注入到 buffer,再追加 FCO(chat.py:160-166,日志 "Re-injecting evicted function_call")——保证这对儿始终成对出现;找不到任何匹配则抛 ChatItemError

8.5 speculative 用到的两个方法

03 的"边说边改"依赖 Chat 两个方法(改的是同一个话轮的用户消息):

  • replace_user_message_text(chat.py:257):STT 先给一版短转写,后来用更长音频重转写出更准的一版,就地替换文本;
  • remove_user_message(chat.py:274):撤掉一条推测性用户消息。

9. 核心机制五:后台压缩(单飞 + gen 计数防串扰)

9.1 思路

逐出是有损的(老对话直接没了)。开了 compact_history 后,超限时不逐出,而是把老的若干轮丢给 LLM 总结成一对"用户摘要 / 助手摘要",拼回 buffer 前面——记忆更省又不至于全丢。因为要再调一次 LLM(慢),这活儿放后台线程,不阻塞对话。

9.2 单飞:同一时刻只跑一个压缩

_maybe_trigger_compaction(chat.py:512)在已 shutdown 或 _compact_in_flight 为真时静默跳过——保证同一时刻只有一个压缩线程。快照(_snapshot_for_compaction,chat.py:474)永远保留最近一轮用户消息不动(它可能正在生成),且少于 2 个可压缩轮次就不干。

9.3 gen 计数防串扰

压缩在后台跑,期间用户可能 reset()close() 了对话。若压缩结果还傻乎乎地拼回去,就会把新对话污染——这就是"串扰"。

防串扰靠一个单调递增的 _gen_counter:

  • 触发压缩时记下当时的 gen(chat.py:522);
  • reset/close_gen_counter += 1(chat.py:443chat.py:458),让在飞的那次压缩的 gen 作废;
  • worker 在调用 LLM 前、拿到结果后、真正 splice 前三处都检查 self._gen_counter != gen,不符就直接放弃(chat.py:548chat.py:558chat.py:581);finally 里也只在 gen 未变时才清 _compact_in_flight(chat.py:562-565)。

9.4 splice 时仍守 FC/FCO 配对

_apply_compaction(chat.py:567)把摘要拼进 buffer 前,特意保住那些 FCO 在压缩范围之外的 FC(chat.py:585-597):否则 remaining 里的 FCO 会变成无主孤儿。压缩只丢弃、从不插入 FC;pending FC 留在 _pending_tool_calls 里等它的 FCO 到来再配对(见 docstring chat.py:573-579)。

压缩器本身由 build_compactor(compaction_prompt.py:139)构造,它只依赖一个后端无关的 generate_fn: (system, user) -> text——两个 OpenAI 兼容子类各自的 _build_compaction_generate_fn 提供(见 §10)。


10. 后端分发与两个 OpenAI 兼容子类(责任划分)

10.1 四后端分发

get_llm_handler(s2s_pipeline.py:850)按 llm_backend 选 handler:

backendhandler位置
responses-apiResponsesApiModelHandlers2s_pipeline.py:858-866
chat-completionsChatCompletionsApiModelHandlers2s_pipeline.py:868-878
transformers / mlx-lmLanguageModelHandler / VisionLanguageModelHandler(本地推理)s2s_pipeline.py:880-902

其中 chat-completions 复用 responses-api 的参数类(字段一致);mlx-lm 只是把 backend 设成 "mlx" 走同一套本地 handler;is_vlm 决定用视觉版还是纯文本版。都不匹配则 raise ValueError(s2s_pipeline.py:904)。

10.2 两个子类差在哪

基类(§2.3)把通用逻辑收干净,子类只实现六个钩子:warmup / _build_compaction_generate_fn / _serialize / _request / _build_optional_kwargs / _iter_{stream,response}_events(见 base_openai_compatible_language_model.py:199-241)。两个子类的差异都落在这些钩子里:

钩子ResponsesApi (responses_api_language_model.py)ChatCompletions (chat_completions_language_model.py)
协议/v1/responses/v1/chat/completions
_serializeto_responses_api_chat()(:85)to_transformers_chat() 再修形(_chat_messages,:154)
工具流ResponseOutputItemDoneEvent 里取 FC(:116)逐 chunk 累积 delta.tool_calls(:221)
_iter_stream_events按事件类型 isinstance 分发(:112)累积文本+工具+usage,流尽再一次性 emit(:205)
tools 形状直接透传(:88)Responses 扁平 → Chat 嵌套(_to_chat_tools,:42)

共同细节:两边都会重生成 call_id/id Responses 在拿到 FC 时立刻改(responses_api_language_model.py:119-120);ChatCompletions 在 _tool_calls_from_accum(chat_completions_language_model.py:268)里生成——目的是让下游 FC/FCO 配对一致。两边也都把 refusal(拒答)当作助手文本处理,好让它被朗读并存进历史(chat_completions_language_model.py:231-236responses_api_language_model.py:107-110)。

10.3 ChatCompletions 的两处"修形"

to_transformers_chat 本是给 HuggingFace apply_chat_template 用的,喂给 OpenAI Chat Completions HTTP API 前要修两处(chat_completions_language_model.py:154-176):工具调用的 arguments 必须是 JSON 字符串(不是解析后的对象);多模态 content 要从 Realtime 的 input_text/input_image 改成 Chat 的 text/image_url 形状(_to_chat_content_part,:130)。


11. 出口:LMOutputProcessor 拆旁路、转 TTS

基类产出的是一串 LLMOut(可能是 LLMResponseChunkTokenUsageEndOfResponse)。LMOutputProcessor(lm_output_processor.py:26)是出口分流器,把它们分派到两条路:

LLMOut ──► LMOutputProcessor.process()

┌────────┼─────────────────────────────┐
▼ ▼ ▼
TokenUsage EndOfResponse LLMResponseChunk
→ 文本队列 → 失败则发 ResponseFailedEvent │
(计费事件) → 再照发 EndOfResponse ├─► 文本队列: AssistantTextEvent(带 tools)
└─► 干净文本 → TTSInput(仅当 wants_audio)

要点(lm_output_processor.py:54-148):

  • 工具走旁路。 LLMResponseChunk.tools 非空时,挂到 AssistantTextEvent.tools 发进 text_output_queue(送客户端),不进 TTS——工具调用不该被读出来。
  • 干净文本转 TTS。 只有当 lm_output.text 非空 response_wants_audio(response) 为真时,才 yield TTSInput(...)(lm_output_processor.py:137)——把这批句子送去合成语音,携带 language_codeturn_idspeech_stopped_at_s 等元数据接力给下游。
  • 失败也要收尾。 EndOfResponseerror 时,先往文本队列发一个 ResponseFailedEvent,再照发 EndOfResponse,好让音频侧重新开监听/释放槽位(lm_output_processor.py:92-107)。
  • 再挡一道 stale。 每种输出发出前都过 _turn_output_allowed,过期的直接丢(lm_output_processor.py:49)。

12. 工具 prompt(略提)

不带原生工具调用的本地模型,项目提供一个可选的系统提示,教模型把工具调用写进定界块 <code>func(arg='val')</code>。模板在 tool_call/tool_prompt.py:29(语音版 TOOL_PROMPT_TEMPLATE)和 :52(纯文本版 TEXT_TOOL_PROMPT_TEMPLATE,去掉了语音特有的"先开口说一句"规矩);由 build_tool_system_prompt(:78)渲染,build_block_regex(:102)生成解析用的非贪婪正则。规则要点:只许一个工具调用、只用具名参数并加引号、别提到"工具/标签"、结果没回来前别谎称已拿到结果。(本章不展开;这套主要服务于本地 transformers/mlx 后端。)


13. 巧妙之处(可带走的精华)

  • ProviderEvent 四事件抽象:把"多后端"的复杂度压进翻译层,分句/取消/历史只写一遍。base_openai_compatible_language_model.py:79
  • "攒够 N 句再发":用 sent_tokenize 的"最后一段留尾"技巧,在首字延迟与 TTS 连贯之间取平衡。base_openai_compatible_language_model.py:365-378
  • text-only 与 audio 走两套规则:朗读要过滤+分句,纯文本要逐字保结构。base_openai_compatible_language_model.py:349
  • 工具调用前先 flush:保证"我帮你查一下"这类话不被工具调用切断、乱序。base_openai_compatible_language_model.py:337-347
  • 错误必发 EndOfResponse:异常不外逃,避免 in_response 卡死锁全流水线。base_openai_compatible_language_model.py:464-500
  • FC/FCO 逐出后重注入:历史裁剪不破坏工具调用配对。chat.py:160-166
  • 压缩单飞 + gen 计数防串扰:后台总结不阻塞对话,又不会污染已 reset 的新会话。chat.py:522chat.py:548

14. 边界与局限(诚实)

  • 卡死的 httpx 读打不断。 stale 只在流前进时检查;一次真正阻塞在 socket 上的读只能靠 request_timeout_s 超时兜底,不能被 cancel_scope.cancel() 立刻打断。base_openai_compatible_language_model.py:540-542
  • 逐出是有损的。 不开 compact_history 时,超限直接删整轮老对话;硬上限 2*size 触发时也是有损逐出。chat.py:228-235
  • 压缩会静默跳过。 单飞语义下,压缩在飞时新的触发被静默忽略;压缩失败(异常/返回非 CompactionResult)则保持历史不变并只记日志。chat.py:551-556
  • out-of-band 响应不写回。 conversation="none" 的响应产出输出与 usage,但从不写回默认对话(它跑在一个一次性 Chat 上)。base_openai_compatible_language_model.py:486utils/utils.py:26

15. 代码地图(导航索引)

主题文件关键符号
统一编排基类src/speech_to_speech/LLM/base_openai_compatible_language_model.pyBaseOpenAICompatibleHandler
归一事件词汇同上ProviderEvent, TextDelta, AssistantMessage, ToolCall, Usage
流式分句/批处理同上_consume_streaming, _flush, stream_batch_sentences
非流式消费同上_consume_nonstreaming
工具调用记录同上_record_tool_call
错误/超时收尾同上_generate(ReadTimeout / 必发 EndOfResponse)
关思考 extra_body同上_build_extra_body, _is_official_openai
话轮门(引用 03)同上_turn_is_latest, _turn_output_allowed, _generation_is_stale
不可朗读过滤src/speech_to_speech/LLM/utils.pyremove_unspeechable, SPEECHABLE_PATTERN
有界历史src/speech_to_speech/LLM/chat.pyChat, _evict_oldest_turn, trim_if_needed, add_item
FC/FCO 配对同上_append_tool_output_locked, _pending_tool_calls
speculative 编辑同上replace_user_message_text, remove_user_message
后台压缩同上_maybe_trigger_compaction, _compact_worker, _apply_compaction, _gen_counter
out-of-band 语义同上 / utils/utils.pybuild_active_chat, is_out_of_band
压缩器工厂src/speech_to_speech/LLM/compaction_prompt.pybuild_compactor, CompactGenerateFn
Responses 子类src/speech_to_speech/LLM/responses_api_language_model.pyResponsesApiModelHandler
ChatCompletions 子类src/speech_to_speech/LLM/chat_completions_language_model.pyChatCompletionsApiModelHandler, _to_chat_tools, _chat_messages
后端分发src/speech_to_speech/s2s_pipeline.pyget_llm_handler
输出分流/转 TTSsrc/speech_to_speech/LLM/lm_output_processor.pyLMOutputProcessor, process
工具 promptsrc/speech_to_speech/LLM/tool_call/tool_prompt.pybuild_tool_system_prompt, build_block_regex