生成器的异常在第一次 next() 时才抛:try 包住 create_chat_completion(stream=True) 接不到超窗口错误
调用生成器函数只会造出生成器,函数体要等第一次 next() 才运行,里面的 raise 也一样。llama-cpp-python 的超窗口检查就在这样的函数体里,所以流式调用外面那层 try 和它的自动收缩重试在真实库下走不到;测试替身在调用时就抛错,测试全绿、覆盖率 100%。
Python 里调用一个带 yield 的函数,得到的是生成器对象,函数体一行都还没执行。函数体里的 raise 也要等第一次 next()(或者 for 第一次取值)才发生。所以 try 只包住调用、不包住迭代时,函数体里抛出的异常根本不经过这个 try。
库函数是不是生成器,从调用处看不出来。llama-cpp-python 的 create_chat_completion 本身是普通函数,但 stream=True 时它返回的是内部 _create_completion 生成器,而 prompt 的分词和「超过上下文窗口」的检查都在那个生成器的函数体里。
01那段代码
Autarkia 用本地 GGUF 模型流式生成时是这样写的(删掉了无关分支):
try:
stream = llama_model.create_chat_completion(
messages=messages, max_tokens=max_tokens, stream=True,
)
except Exception as e:
if is_context_window_error(str(e)):
# 解析 "Requested tokens (N) exceed context window of M",
# 收缩 max_tokens、裁剪 messages 后重试
stream = self._retry_context_window_stream(messages, kwargs, max_tokens, str(e))
...
self._iterate_stream(stream, ...) # 里面是 for chunk in stream读起来很合理:发请求,超了窗口就收缩重试。可在真实库下,create_chat_completion(..., stream=True) 只是把生成器造出来,prompt 再长也立刻正常返回。错误在 _iterate_stream 里 for chunk in stream 第一次取值时才抛出,落进那边的 except Exception,被报成「GPU推理stream异常」,没有重试。
llama-cpp-python 0.3.35 里相关的结构是这样的(简化):
def _create_completion(self, prompt, max_tokens, stream, ...):
prompt_tokens = self.tokenize(...)
if len(prompt_tokens) >= self._n_ctx:
raise ValueError(
f"Requested tokens ({len(prompt_tokens)}) exceed context window of {self._n_ctx}"
)
...
yield ... # 有 yield,整个函数是生成器
def create_completion(self, ..., stream=False):
completion_or_chunks = self._create_completion(...)
if stream:
return completion_or_chunks # 原样返回,函数体还没运行
return next(completion_or_chunks) # 非流式在这里就会抛非流式调用在 create_completion 里就 next() 了一次,所以同样的 try 写在非流式路径上是有效的。Autarkia 的非流式生成器就是这么写的,没有问题;问题只在流式这条。
我没法在这台开发机上加载这个库(它是 CUDA 构建,缺运行库),上面的结论来自已安装版本的源码:用 ast 确认 _create_completion 的函数体里有 yield。另外照这三层结构写了一个不依赖库的最小复现,替身走进了重试分支,真实结构的错误出现在迭代时。
02为什么一直没发现
单元测试里的假模型是这样写的:
class _FakeLlamaModel:
def create_chat_completion(self, messages=None, **kwargs):
self.calls.append((messages, kwargs))
if self._chat_error_once is not None:
err, self._chat_error_once = self._chat_error_once, None
raise err # 调用时就抛
return iter(list(self._chunks))它是普通函数,调用时直接抛错,比真实库「急」。于是「超窗口后重试成功」的测试通过了,重试分支的每一行都被执行到。9 月给这个文件补测试时,覆盖率从 41% 补到 100%,同一次还在这个文件里找出并删掉了另一个永远不会执行的分支。这个分支没有被怀疑,因为覆盖率显示它跑过。
日志也在帮它说话。Autarkia 现存的几天日志里,「请求超过上下文窗口,自动收缩并重试」出现了 1028 次,看上去机制一直在工作。逐条看参数,只有 requested=140, window=128 和 requested=300, window=128 两种,都是测试里写死的数字,单元测试把日志写进了同一个目录。
更早之前,6 月删掉一个旧的推理适配器时,理由之一是它的重试已经由这个函数替代;11 月的分析文档里也写着「重试失败才返回错误」。大家都相信它在工作。再加上这条路径只在用本地 GGUF、且不走 C++ 调度器时才会进,10 月下旬起业务模型又都换成了云端,平时很少有请求经过它。真实请求有没有撞上过,我查不到:更早的日志已经不在了。
03走到了也救不回来
顺着重试函数往下看,就算它被调用,也大概率没用。报错里的 requested 只是 prompt 的 token 数,max_tokens 超了库会自己截到剩余空间,不会因为它报错;重试却把超出量从 max_tokens 里扣。真正起作用的只有同时做的消息裁剪,而它按 window * 2 个字符裁,按仓库自己的估算中文约 1.67 token 一个字,裁完仍是窗口的三倍多(这个比例没用真实分词器量过)。函数里还有一个「收缩不动就折半」的分支,条件只在 max_tokens <= 16 时成立,进去以后结果恒为 16,测试名叫「shrinks_and_halves」,断言的值和传入的一样。
04怎么改
最直接的是把第一个 chunk 的获取放进同一个 try,确认生成器真的启动了再交出去:
import itertools
try:
stream = llama_model.create_chat_completion(messages=messages, stream=True, ...)
first = next(stream) # 长度检查在这一步执行
except StopIteration:
stream = iter(())
except Exception as e:
... # 这里才真的接得到超窗口错误
else:
stream = itertools.chain([first], stream)更稳的是别靠报错重试:调用前用模型自己的 tokenize 算 prompt 长度,按 token 裁到窗口以内。Autarkia 的调度器路径已经有按 token 裁剪的函数,流式 GGUF 这条可以直接复用。测试替身也要改成生成器函数,在第一次 next() 时抛错,和真实库一样惰性;否则下一次重写还会被同样的测试放过去。
这几处还在 HEAD 上,修复建议已经记下。