← 文章

子类重写请求体时的一行 pop:max_tokens 在发出前被删掉,调用方、日志和测试都看不到

约 4 分钟#Python#LLM#可观测性

父类把参数组进请求体,子类在返回前 pop 掉,调用方传什么都没用,也不会报错。Autarkia 的 MiniMax 客户端从第一个提交起就删掉了所有调用方的 max_tokens,推理模型的输出没有上限,而这件事在记录里被提到三次都没改。

目录

一个常见的客户端结构:父类负责把参数组装成请求体,各家供应商的子类覆盖 _build_payload,先调 super(),再按自己的接口增删字段。这种写法的问题是,子类删掉的字段对调用方完全不可见。调用方传了 max_tokens=400,单元测试断言传的是 400,日志里记的也是调用参数,没有任何一层看的是最后真正发出去的那个字典。

01那一行

Autarkia 的 MiniMax 客户端是这样的:

python
class MiniMaxClient(OpenAIClient):
    def _build_payload(self, messages: list, stream: bool, **kwargs):
        thinking = kwargs.pop("thinking_enabled", None)
        payload = super()._build_payload(messages, stream, **kwargs)
        if thinking is False:
            payload["thinking"] = {"type": "disabled"}
        payload["reasoning_split"] = True
        payload.pop("max_tokens", None)
        ...
        return payload

父类那边写得很规矩:

python
def _build_payload(self, messages: list, stream: bool, **kwargs):
    model = kwargs.pop("model", self.default_model)
    max_tokens = kwargs.pop("max_tokens", self.default_max_tokens)
    ...
    payload = build_payload(messages=..., model=model, max_tokens=max_tokens, ...)

单看父类,max_tokens 进了请求体;单看调用方,配置里的值被读出来传了进去。中间夹着子类的一行 pop,这个值在离开进程之前被扔掉了。这行在 git 历史的第一个提交里就已经在,什么时候、为什么加的,现在查不到。

后来所有业务模型都切到了 MiniMax,这一行于是对所有调用生效:主动消息生成配的 active_care_generation_max_tokens=400、记忆提取配的 4096、夜间任务配的各种预算,全部没有发出去。用的又是推理模型,不设上限也能正常回答,只是想多久就多久。

02它出现在记录里三次

这件事我不是没看到。9 月 11 日的记录写着「minimax_client 仍会 pop max_tokens(该接口不接受该字段),所以 nightly 传的 max_tokens 实际不生效,本次未动」。10 月 14 日查主动消息的成本,记的根因之一是「输出按 8.4 元/百万计费,且因 MiniMax 客户端 pop 掉 max_tokens 而没有上限」,当时账单里输出 token 占了七成成本。10 月 22 日接入新模型,又记了一句「仍会 pop max_tokens,本次未改客户端行为」。

三次都把括号里那半句当成了前提:接口不接受这个字段,所以只能删。10 月 26 日一个评测实验推翻了它。那个实验没走这个客户端,自己拼请求直接发给同一个 MiniMax 接口,带着 max_tokens。结果是预算给紧了:推理模型的思考 token 也计入 max_tokens,一个实验组 237 次调用里有 169 次正文为空。接口不但接受这个字段,而且严格执行。

03为什么一直没有暴露

每一层能看到的东西都是对的。

  • 调用方:配置读出来了,参数传进去了。
  • 日志:调用统计确实是在最终请求体上取的值,但默认那一行只打印 provider、模型、条数、字数和是否流式,max_tokens 只有打开完整 prompt 日志时才出现。
  • 测试:记忆提取的单元测试 mock 的是调度器,断言 call["max_tokens"] >= 4096,旁边的注释写着「推理模型思考 token 计入 max_tokens:预算至少 4096」。写测试的时候我知道这个预算很重要,但断言落在客户端之前,客户端删不删它都过。
  • 行为:没有上限的推理模型照样给出正常回答,没有报错,没有截断,只有账单里输出的占比偏高。

子类覆盖是这个问题能藏住的结构原因。父类的作者、调用方的作者、写测试的人,都有理由不去看某个子类的 _build_payload 里有没有删东西,而这几个人都是我。

04修法

这一行在主项目里还没改,我列的修法是三条。第一,删掉这行;如果某个模型确实只认 max_completion_tokens,就改名而不是丢弃:

python
if "max_tokens" in payload:
    payload["max_completion_tokens"] = payload.pop("max_tokens")

第二,测试断言落在最终请求体上:对每个客户端子类调一次 _build_payload(..., max_tokens=123),断言返回的字典里有这个值。这条测试和具体供应商无关,以后新加子类自动被覆盖。

python
@pytest.mark.parametrize("cls", all_client_subclasses())
def test_payload_keeps_output_budget(cls):
    payload = cls(api_key="x")._build_payload(
        [{"role": "user", "content": "hi"}], stream=False, max_tokens=123
    )
    assert 123 in (payload.get("max_tokens"), payload.get("max_completion_tokens"))

第三,默认日志那一行带上最终请求体里的 max_tokens。如果它一直打印 None,这件事在第一天就会被看见。

相关阅读