采集 completion_tokens_details.reasoning_tokens:推理开销目前无法与生成开销分开归因 #6

Closed
opened 2026-08-02 11:03:15 +08:00 by iomgaa · 2 comments
Owner

需求

推理模型会在 usage 里单独上报推理消耗的 token,例如 Kimi K3 经中转网关返回的:

"usage": {
  "prompt_tokens": 87,
  "completion_tokens": 10,
  "total_tokens": 97,
  "cached_tokens": 87,
  "completion_tokens_details": {"reasoning_tokens": 7},
  "prompt_tokens_details": {"cached_tokens": 87}
}

completion_tokens_details.reasoning_tokens 目前没有被采集。这是 OpenAI 兼容格式里
prompt_tokens_details.cached_tokens(issue #3 已支持)对称的一个字段,采集方式
可以完全照搬 _coerce_cached_tokens

为什么需要它

推理 token 已经计入 completion_tokens,所以成本总额是对的——这不是计费缺口,
是归因缺口。缺了它,"这次调用花的钱里有多少是花在推理上"就无法区分。

对下游项目(dissect)而言这个区分是必需的。该项目要测各个设计因子的边际成本,而
其中一个因子恰好是"反思模型用多强";推理开销是这个因子最主要的成本通道,如果它和
生成答案的开销混在一列里,这个因子的成本效应就没法单独报告。上面那条真实响应里
10 个输出 token 有 7 个是推理,比例并不小。

顺带一个具体的用途:判断"推理到底关掉了没有"。issue #5 提到 minimax 的
enable_thinking 是空声明,而 reasoning_tokens == 0 是比输出长度更直接的判据。

建议

LLMResponse 加一个字段,与 cached_prompt_tokens 同款语义(None = 该源不报,
0 = 上报了且确实没推理,两者对下游处置不同):

reasoning_tokens: int | None = None
"""推理消耗的输出 token 数(含在 completion_tokens 内);None = 该源未上报。"""

在 transport 侧照 _coerce_cached_tokens 的形态从 usage.completion_tokens_details
里取,非负整数才收,bool 同样要排除。

需要留意流式:completion_tokens_details 只在最后的 usage 帧里,走打捞路径
missing_done)时可能拿不到,这时记 None 而不是 0 就是正确的。

## 需求 推理模型会在 usage 里单独上报推理消耗的 token,例如 Kimi K3 经中转网关返回的: ```json "usage": { "prompt_tokens": 87, "completion_tokens": 10, "total_tokens": 97, "cached_tokens": 87, "completion_tokens_details": {"reasoning_tokens": 7}, "prompt_tokens_details": {"cached_tokens": 87} } ``` `completion_tokens_details.reasoning_tokens` 目前没有被采集。这是 OpenAI 兼容格式里 和 `prompt_tokens_details.cached_tokens`(issue #3 已支持)对称的一个字段,采集方式 可以完全照搬 `_coerce_cached_tokens`。 ## 为什么需要它 推理 token 已经计入 `completion_tokens`,所以**成本总额是对的**——这不是计费缺口, 是归因缺口。缺了它,"这次调用花的钱里有多少是花在推理上"就无法区分。 对下游项目(dissect)而言这个区分是必需的。该项目要测各个设计因子的边际成本,而 其中一个因子恰好是"反思模型用多强";推理开销是这个因子最主要的成本通道,如果它和 生成答案的开销混在一列里,这个因子的成本效应就没法单独报告。上面那条真实响应里 10 个输出 token 有 7 个是推理,比例并不小。 顺带一个具体的用途:判断"推理到底关掉了没有"。issue #5 提到 minimax 的 `enable_thinking` 是空声明,而 `reasoning_tokens == 0` 是比输出长度更直接的判据。 ## 建议 `LLMResponse` 加一个字段,与 `cached_prompt_tokens` 同款语义(None = 该源不报, 0 = 上报了且确实没推理,两者对下游处置不同): ```python reasoning_tokens: int | None = None """推理消耗的输出 token 数(含在 completion_tokens 内);None = 该源未上报。""" ``` 在 transport 侧照 `_coerce_cached_tokens` 的形态从 `usage.completion_tokens_details` 里取,非负整数才收,`bool` 同样要排除。 需要留意流式:`completion_tokens_details` 只在最后的 usage 帧里,走打捞路径 (`missing_done`)时可能拿不到,这时记 None 而不是 0 就是正确的。
Author
Owner

更正上文的一处论证错误:我把 K3 说成了"反思模型",实际它在下游项目里是被试底座之一,
不是反思模型。这条 issue 的诉求不变,但理由应当改成下面两条,它们比原来的更贴切:

一,底座开着推理时,推理 token 是"生成账"的大头,而不是"反思账"的。 下游项目把 LLM
调用分成生成 / 评估 / 反思三本账分开记,用来回答各设计因子的边际成本。被试 agent 每做一道
题都要多轮调用,如果它的底座在推理,那部分 token 全部落在生成账上。现在只能看到
completion_tokens 这一个总数,无法回答"这道题的成本里有多少花在推理上"。

二,reasoning_tokens == 0 是"推理确实关掉了"的直接判据。 这一条其实更要紧。配合
issue #5:那边说的是 enable_thinking=False 对 minimax 静默失效,而发现它失效靠的是
比较输出长度——一个间接、需要反复试的信号。如果 usage 里的推理 token 数被采集上来,
"关掉了没有"就是一次调用即可确认的事实,而不需要设计对照实验去猜。

对下游项目而言这不是锦上添花:它的公共设定要求关闭 thinking 以隔离思维链效应,而"是否
真的关上了"目前无法在运行时验证——只能靠事后看输出长度异常。

更正上文的一处论证错误:我把 K3 说成了"反思模型",实际它在下游项目里是**被试底座**之一, 不是反思模型。这条 issue 的诉求不变,但理由应当改成下面两条,它们比原来的更贴切: **一,底座开着推理时,推理 token 是"生成账"的大头,而不是"反思账"的。** 下游项目把 LLM 调用分成生成 / 评估 / 反思三本账分开记,用来回答各设计因子的边际成本。被试 agent 每做一道 题都要多轮调用,如果它的底座在推理,那部分 token 全部落在生成账上。现在只能看到 `completion_tokens` 这一个总数,无法回答"这道题的成本里有多少花在推理上"。 **二,`reasoning_tokens == 0` 是"推理确实关掉了"的直接判据。** 这一条其实更要紧。配合 issue #5:那边说的是 `enable_thinking=False` 对 minimax 静默失效,而发现它失效靠的是 比较输出长度——一个间接、需要反复试的信号。如果 usage 里的推理 token 数被采集上来, "关掉了没有"就是一次调用即可确认的事实,而不需要设计对照实验去猜。 对下游项目而言这不是锦上添花:它的公共设定要求关闭 thinking 以隔离思维链效应,而"是否 真的关上了"目前无法在运行时验证——只能靠事后看输出长度异常。
Author
Owner

已在 1.0.6 落地,关闭。

  • 89ff916 按建议照搬 _coerce_cached_tokens 的形态,新增 transports/openai_compat.py_coerce_reasoning_tokens,从 usage.completion_tokens_details 取值,非负整数才收、排除 bool;非流式与流式两条路径都接了。
  • LLMResponse / TransportResult 新增 reasoning_tokens: int | None;遥测表 llm_callsreasoning_tokens 列,TelemetryRecorder 端口 21 → 22 字段,补列纪律与 issue #3/#4 逐字相同。

一条与本 issue 原文预期不同、下游需要知道的点:None 的语义是「本次调用未上报」,不是「该源不上报」,与 cached_prompt_tokens 的 NULL 语义不同——中转网关在上游不返回 usage 时会用本地 tokenizer 补算并整体替换 usage 对象,把 completion_tokens_details 一并吃掉(同一请求 10 轮实测呈 6:4 双峰)。所以判「是否发生推理」要写 in (None, 0);实测三家供应商未推理时都是整个 details 缺失,无人上报字面 0,写 == 0 的条件永远不成立。

已在 1.0.6 落地,关闭。 - `89ff916` 按建议照搬 `_coerce_cached_tokens` 的形态,新增 `transports/openai_compat.py` 的 `_coerce_reasoning_tokens`,从 `usage.completion_tokens_details` 取值,非负整数才收、排除 bool;非流式与流式两条路径都接了。 - `LLMResponse` / `TransportResult` 新增 `reasoning_tokens: int | None`;遥测表 `llm_calls` 补 `reasoning_tokens` 列,`TelemetryRecorder` 端口 21 → 22 字段,补列纪律与 issue #3/#4 逐字相同。 一条与本 issue 原文预期不同、下游需要知道的点:`None` 的语义是「本次调用未上报」,不是「该源不上报」,与 `cached_prompt_tokens` 的 NULL 语义不同——中转网关在上游不返回 usage 时会用本地 tokenizer 补算并整体替换 usage 对象,把 `completion_tokens_details` 一并吃掉(同一请求 10 轮实测呈 6:4 双峰)。所以判「是否发生推理」要写 `in (None, 0)`;实测三家供应商未推理时都是整个 details 缺失,无人上报字面 `0`,写 `== 0` 的条件永远不成立。
Sign in to join this conversation.
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: iomgaa/PolyGateway#6