← 文章

用 inspect.signature 过滤关键字参数:接受 **kwargs 的函数被当成什么都不收,重试丢了 model_path

约 5 分钟#Python#LLM#调试

inspect.signature(f).parameters 只列声明过的形参,**kwargs 在里面是一个叫 kwargs 的条目。按名字检查「对方收不收这个参数」,最宽松的函数反而什么都过不去。Autarkia 的空回复重试因此丢了 model_path,换成默认模型去请求,返回的 400 混在上游过载的 503 里,同一天的错误分诊把它判成了网关误报。

目录

想把一组可选参数传给一个函数、又不确定它收不收某个参数时,常见的写法是先看签名:if "x" in inspect.signature(fn).parameters,在里面才传。这个检查对显式写出形参的函数是对的。对 def f(messages, **kwargs) 这种函数,parameters 里只有 messages 和 kwargs 两项,kwargs 那一项的 kind 是 VAR_KEYWORD。它什么关键字都收,但按名字查,"model_path"、"max_tokens" 都不在里面,于是一个都不传。最宽松的函数,在这个检查下看起来最严格。

python
import inspect

def explicit(messages, model_path=None, max_tokens=None): ...
def wrapper(messages, **kwargs): ...

for fn in (explicit, wrapper):
    params = inspect.signature(fn).parameters
    kept = {k: v for k, v in {"model_path": "cloud:x:y", "max_tokens": 512}.items()
            if k in params}
    print(fn.__name__, list(params), kept)

# explicit ['messages', 'model_path', 'max_tokens'] {'model_path': 'cloud:x:y', 'max_tokens': 512}
# wrapper ['messages', 'kwargs'] {}

unittest.mock.MagicMock() 的签名也是 (*args, **kwargs),同样什么都过不去。被一个没用 functools.wraps 的装饰器包过的函数也会变成这样。

01空回复重试

Autarkia 的对话流程里有一个兜底:模型这一轮没有产出可见的回复时,用非流式接口再请求一次。重试要和第一次用同一个模型、同样的长度上限,所以要把 model_path、max_tokens 传下去。当时的代码是这样的:

python
chat_fn = agent.llm_module.chat
chat_sig = inspect.signature(chat_fn)
retry_kwargs = {"messages": retry_messages, "temperature": temperature or 1.1}
if "max_tokens" in chat_sig.parameters:
    retry_kwargs["max_tokens"] = max_tokens
if "model_path" in chat_sig.parameters and model_path:
    retry_kwargs["model_path"] = model_path
if "conversation_id" in chat_sig.parameters:
    retry_kwargs["conversation_id"] = user_id
retry_payload = await chat_fn(**retry_kwargs)

agent.llm_module 实际是 HybridLLMModule,它负责按 model_path 在本地模型和各家云端之间路由。它的 chat 是这样声明的:

python
async def chat(self, messages: list, **kwargs):
    ...

所以三个 if 都不成立,重试只带着 messages 和 temperature 出去。HybridLLMModule 没拿到 model_path,就用配置里的默认 provider 和默认模型名。8 月 4 日那次,主对话选的是 SiliconFlow 上的 Kimi-K2.6,默认 provider 也是 SiliconFlow,默认模型名却是 deepseek-v4-flash。这是 DeepSeek 自家 API 的模型名,SiliconFlow 上没有,于是重试拿到 400:code 20012, Model does not exist。

这个签名在 git 历史的第一个提交(6 月 18 日)里就是 **kwargs。过滤代码是什么时候写成这样的,我查不到:那部分代码 8 月 20 日才进 git。

02为什么没早发现

过滤本身不出声。被丢掉的参数没有任何日志,调用照常发出去。

重试只在第一次请求没有结果时才触发,而第一次请求失败,多数是因为上游出了问题。所以它报的错,天然混在上游故障里。8 月 3 日晚上到 4 日凌晨,SiliconFlow 有两段过载:503、流式连接被断开,中间夹着 4 次 Model does not exist,出在非流式的 generate / chat 那一层,也就是重试走的那一层。

4 日白天,复现时看到重试的路由日志写着 provider=siliconflow model=deepseek-v4-flash,定位到这段代码并修好了。同一天晚些时候,自动的错误日志分诊处理前一晚的这 38 条错误,归成 8 类,全部判为上游瞬时故障,不改代码。对那 4 次 400 的结论是「过载网关返回了不准确的错误响应」,理由是 deepseek-v4-flash 是项目配置里在用的有效模型名。它确实在配置里,在 DeepSeek 那一组。分诊核对的是模型名存不存在,没核对它和 provider 配不配;而且就在同一天,这个问题已经修过了。

那 4 次 400 是不是全部来自这个重试,我没法逐条核实:原始错误文件在分诊之后就删了。时间、调用层和模型名都对得上。

还有一种情况更难看出来:如果默认模型在默认 provider 上是有效的,重试会正常返回一段回复,只是换了一个模型、用了默认的长度上限。这种情况不会留下错误。我推测它发生过,但没有日志能证明。

这个函数也没有测试。

03修法和还剩下的

当天的修法是先看签名里有没有 VAR_KEYWORD,有就不再逐个检查,直接传:

python
has_var_keyword = any(
    p.kind == inspect.Parameter.VAR_KEYWORD
    for p in chat_sig.parameters.values()
)
if has_var_keyword:
    if max_tokens is not None:
        retry_kwargs["max_tokens"] = max_tokens
    if model_path:
        retry_kwargs["model_path"] = model_path
    retry_kwargs["conversation_id"] = user_id
else:
    ...  # 原来的按名字检查

这样能用,但签名检查本身还在。现在仓库里 9 个 chat 实现全都带 **kwargs,else 分支走不到;唯一写了显式形参的那个,第一个参数叫 prompt 而不是 messages,真走进来会直接 TypeError。另一处用同样方式决定要不要传 system_prompt,目标函数现在是显式形参所以没事,但只要它被包一层装饰器、或者在测试里被 MagicMock 替掉,system_prompt 就会静默不传。

我打算把签名探测删掉。llm_module.chat 是自己仓库里的接口,给它定一个统一的签名(model_path、max_tokens、conversation_id 都是具名的可选参数),调用方直接按关键字传。哪个实现不接受,就让它在第一次调用时报 TypeError,这比悄悄少传一个参数好查得多。重试再补一个测试:用真的 HybridLLMModule、把云端客户端打桩,断言重试路由到的模型和第一次相同。

如果确实要对付不受自己控制的函数,判断「收不收 x」至少要写成「x 在 parameters 里,或者 parameters 里有 VAR_KEYWORD」。