• v1.3.1 2bff962e48

    iomgaa released this 2026-08-26 16:39:14 +08:00 | 47 commits to main since this release

    「这次调用到底推理没推理」从此是库的一等返回值(issue #16 + #17): LLMResponse.thinking_observation 三态如实作答,判不出来时说 unknown 而不是伪装成「没推理」,并与推理能力表持续对账。

    版号是 patch,但本版含三处会影响下游的变更——深路径 import 断裂、端口签名扩参、一条新告警。patch 版号从设计上就不承担预警职责,预警只能由这份 CHANGELOG 扛,故三条置于最前。

    请先读这一条(一): polygateway.providers 的深路径 import 断了

    推理相关的六个符号providers.py 移进新模块 polygateway.thinkingfrom polygateway.providers import ... 引用其中任何一个,升级后当场 ImportError:

    providers 断掉的符号 改成(推荐)
    ThinkingCapabilityThinkingUnsupportedError from polygateway import ... from polygateway.thinking import ...
    get_capabilityregister_capabilityresolve_thinking from polygateway import ... from polygateway.thinking import ...
    DEFAULT_CAPABILITIES from polygateway.thinking import DEFAULT_CAPABILITIES

    前五个请改用包根 import: 它们此前只能深路径引用,而深路径引用正是模块重组会打断下游的原因——本版一并把它们提升到包根导出(连同本版新增的 ThinkingObservation,共六个新导出),给的就是一个此后不会因内部重组而变的引用点。DEFAULT_CAPABILITIES 有意不进包根: 它是可变注册表的当前快照,不是稳定 API 面。

    providers.py 保留的 ProviderProfile / DEFAULT_PROFILES / get_provider / register_provider 逐字未动。

    拆分本身不是顺手重构: 推理这件事从「请求侧注入什么参数」长成了「注入 + 响应侧裁定 + 两者对账」三件事,再留在 provider 注册表里,那个文件的职责就得用「和」来描述。

    请先读这一条(二): TelemetryRecorder.record_llm_call 从 24 参变 25 参

    新增 keyword-only 参数 thinking_observation: str,且按该 Protocol 的既有纪律不设默认值(库外没有第三方实现者,带默认值只会让 emitter 漏传时静默落一个默认值)。自定义 recorder 实现必须同步补这个参数,否则调用时 TypeError。库自带的 SQLiteRecorder / PostgresRecorder 已同步,不受影响。

    TelemetryRecorder 之外的端口逐字未变;TelemetryStatusProvider 不受影响。

    请先读这一条(三): MiniMax-M3 非流式开推理 = 付费买看不见的推理,库现在会说出来

    2026-08-25 实测: M3 非流式开启推理时 completion_tokens 从 3 涨到 53(推理段确实产生并计费),而响应里既没有 reasoning_content 正文、也没有 usage.completion_tokens_details——钱花了,东西一个字都拿不到。这是上游行为,库修不了,但从本版起不再默不作声: 该档观测判为 unknown,并按 (模型, 方向)一次 warning,说明「已注入开启参数,但本路径观测不到,推理内容可能已计费却不回传」。

    要拿到推理正文,该模型请走流式路径(实测 185 字符正文完整)。

    诊断纠正: 不是模型不推理,是 MiniMax 停报 completion_tokens_details

    issue 判定「M3 开启推理静默失效,模型不推理」。实测推翻了这个诊断——绕开库用裸 httpx 抓真实响应,M3 流式开启档拿到 124 字符完整推理过程,prompt_tokens 194→216、completion_tokens 3→60,三个独立信号一致。

    真正变的是 MiniMax 这一路上游不再返回 usage.completion_tokens_details(qwen 与 deepseek 在同一网关、同一 key 上照常返回),reasoning_tokens 因此恒为 None。而库把「推理是否发生」全押在这一个字段上,于是手里握着 185 字符推理正文,却对外报告「没推理」

    缺口的形态是本版真正要修的东西: 库拿到的信息足以回答问题,却把答案丢掉,转而返回一个语义歧义的 None

    三态,以及它为什么不能折叠成布尔

    LLMResponse.thinking_observation(类型 ThinkingObservation,StrEnum,缺省 unknown)由多信号裁定,判据按证据硬度排序:

    判据
    observed 推理正文 thinking 非空(事实本身),或 reasoning_tokens > 0(上游对事实的转述)
    absent reasoning_tokens == 0——上游明确上报本次未推理,是正面证据
    unknown 两个信号双缺,判不出来

    unknownabsent 不是一回事,把前者折叠进后者正是本次故障的病根。unknown 没有证伪力: 它不能用来声称推理关掉了,也不能用来报警「没推理」。缺省取 unknown 使任何填不了这个字段的路径(非 OpenAI 兼容 transport、失败尝试、终态失败行)天然诚实——默认值本身不撒谎。

    对下游的口径变化: 统计「未推理」不要再写 reasoning_tokens IS NULL OR = 0,那个条件在供应商停报 usage 明细后会把推理了的调用一并算进去。改按 thinking_observation 分组,unknown 独立成一档。

    声明 × 观测对账: 能力表过期从静默错觉变成日志里的告警

    推理能力表(can_disable)是静态声明,而静态声明必然过期——M3 的 evidence 曾停在 8-02 整整 23 天。过期的表现是静默错觉: 库照常注入关闭参数,模型照常推理,下游拿到推理内容却以为关了,全程无人吭声。

    本版在 transport 拿到结果处做一次比较,矛盾即 warning(不抛错——一次观测不足以否决一次成功的调用,矛盾结果已随响应与遥测落地,处置权归下游):

    请求方向 观测 告警内容
    关闭 observed 关闭请求未被满足。能力表已登记则点出 evidence 日期并指路复测更新;未登记则说明本次是按 provider 形态尽力注入
    开启 absent 已注入开启参数,上游却明确上报未推理
    开启 unknown 已注入开启参数,但本路径观测不到;若为非流式,推理内容可能已计费却不回传

    关闭 × unknown 与「调用方没提要求」两类有意不表态: 前者没有证伪力,拿它报警等于每次关闭调用都喊一遍(M3 关闭档恒落此档),噪声即等于没有告警。同一 (源, 模型, 方向) 只喊一次,文案点名出问题的源——多源多账号下同一模型跨 N 个源是常态,键漏掉源名会让第一个出问题的源喊完之后其余源永久静音,而告警也定位不到该查哪个网关。

    保障的覆盖面必须说清楚: 对账只在可观测路径上成立(推理若真的发生,流式路径会带出正文,翻成 observed 触发告警);M3 非流式那种两个信号双缺的路径,没有任何保障——本版让它可见,但不能让它可判。

    遥测新增一列 thinking_observation

    llm_calls 加一列 thinking_observation TEXT(可空,取值 observed / absent / unknown),排在最末,SQLite 与 Postgres 两端 DDL 与补列语句同步。旧表按既有 backfill 路径补列: sqlite→auto 档自动补,postgres→manual 档点名缺列并给出可执行 SQL、同时按现有列裁剪 INSERT 继续写(不补列不会让遥测整体失效,只是少这一列)。补列失败仍只逐行降级、绝不判死。

    照 README「生产部署 DDL 模板」部署的下游不需要改模板: 那份模板用 LIKE llm_calls_seed 从库自己建出的表派生列,与 telemetry/schema.py 同源,不存在手抄漂移(本版加了一条测试断言把这个同源性钉死)。

    其他

    • 缓存回放的 thinking_observationThinkingObservation 枚举实例而非裸字符串: JSON 复活出来的是 str,与字段注解分叉,CacheMW._rehydrate 现在显式转换。取值不在本版三态值域内时(多个项目共用同一 Redis、先升级的那个写入了新态)降级为 unknown 并单独告警,响应内容照常复活——一个纯可观测性字段不该有能力作废内容完好的缓存,否则未升级的项目会在这些 key 上每次真打网关、随后覆写回旧值,两个版本互相打对方的缓存;「整条作废」只留给真正破坏内容完整性的失败。
    • M3 的推理能力 evidence 刷新到 2026-08-25 复测。can_disable 仍为 True(reasoning_effort=none → prompt 194 = 基线、completion 3、无正文,声明依然成立),同时补记两条限制: 推理信号在非流式路径不可观测;enable_thinkingthinking={"type":"enabled"} 对该模型无效,只有 reasoning_effort 是真开关。
    • TransportResult 同步新增该字段并由 RetryMW 透传;裁定在 openai_compat 的流式与非流式两条组装路径各做一次。
    • 遥测的新列只经 TelemetryEmitter._record 这一个出口下沉给 recorder(单一 helper 铁律),且在那里由枚举归一化为裸 str——StrEnum 虽是 str 子类,asyncpg 的参数编码对 str 子类不保证接受,而遥测写失败只是一条 warning,这类问题不会当场炸,只会让 Postgres 那一路悄悄少一列数据。归一化按外部输入防御: LLMResponse 无运行时校验,下游填裸 str 完全自然,而直接取 .value 会抛异常并被降级路径吞成丢掉整行遥测;域外取值同样只降级记 unknown 并单独告警,不拿整行当代价。
    Downloads