[prefactor] LLMClient/EmbeddingClient 契约携带 token 用量 #84

Merged
Yushu merged 2 commits from feature/78-usage-in-client-contracts into main 2026-07-27 07:47:30 +00:00
Member

Closes #78

What

纯 prefactor,不交付任何用户可见行为 —— 让 LLMClient/EmbeddingClient 契约携带每次调用的 token 用量,为 ADR-0010 六池成本治理铺地基。此前 AnyLLMClient.complete() 拿到 any-llm 响应后直接把 usage 丢弃了。PRD #77 第一片。

实现要点

  • 新增 usage.TokenUsage(单独成模块:两条调用边界都要用,后续记账层也要用;放任一侧都会让另一侧反向 import)。
  • None 表示「provider 没回传」,不等于 TokenUsage(0, 0)。这条区分是后续记账的地基:把「不知道」当 0 累加,「今天花了多少钱」就会悄悄失真。提取器任一层读不到即整体 None,不半补;也绝不抛错 —— 拿不到用量是记账精度问题,不该让一次正常的对话请求失败。
  • EmbeddingClient.embed() 返回类型改为 EmbeddingResult(vector, usage),而不是新开 embed_with_usage():留着旧方法就等于留下一条绕过记账的路,六池治理会出现「这个数为什么对不上」的黑洞。破坏面只有 5 个 src 调用点 + 2 个测试构造点。
  • 测试替身默认不带用量(「这个替身不知道用了多少」,与真实 provider 未回传同义),需要断言记账的测试可显式脚本化。既有测试零断言改动,只补齐新字段(AC4)。

Review

两轴都抓到实质问题:

  • 漏网的契约适配(两轴都点了)tests/test_presence_events_e2e.py 里手写了一个与 DummyEmbeddingClient 一模一样的内联替身,我漏了 —— 而且那条路径压根不 embed,测试照样全绿。讽刺的是这正是本 ticket 单独拆出来要防的东西。根因是我 grep 时只搜了 .embed( 调用点、没搜 async def embed 实现点。已改为直接用现成替身。Spec 轴另穷举确认除此之外无遗漏(6 处 embed 实现、11 处 complete 实现)。
  • 缺失判定收敛:两个 _usage_of 里那段判定是同一条策略写了两遍,改规则容易只改一边。抽出共用的 read_token_count,各自保留字段/语义差异。
  • 删掉 TokenUsage.__add__total_tokens:累加就是记账,本片明确不含(AC6)。__add__ 更糟 —— 它顺手把「累加时怎么对待 None」这条真正的记账策略提前拍死了。
  • EmbeddingResult.vector 改必填:空向量永远不是合法 embedding。它原是照抄 AssistantTurn 的默认值,但那边有明确理由(几十个既有构造点),这边只有 3 个。

Spec 轴 introspect 了实装的 any_llm_sdk 1.19.0,确认 prompt_tokens/completion_tokens 字段名正确 —— 排除了「字段名写错导致永远静默返回 None」这个对 prefactor 而言最坏的结果。

自查时先发现过一个测试缺口:最初只单测了 _usage_of 提取器,变异验证时把 usage=_usage_of(response) 改回 usage=None,19 条测试全绿 —— 提取器写对了但适配器忘了接上,照样一个 token 都记不到。已补四条真的走 AnyLLMClient.complete()/AnyLLMEmbeddingClient.embed() 的测试。

已知局限(如实记录,未修)

gemini/lmstudio 的 any-llm embedding 转换器会伪造零用量(硬写 Usage(prompt_tokens=0)),那时这里会返回 TokenUsage(0, 0) —— 按本仓定义那是「确实没花」,与「不知道」混为一谈。core 无从分辨「真算出 0」和「懒得填」,已写进 _usage_of docstring 而不假装它不存在。默认配置走 openai,真回传用量,不受影响。

Tests

全仓 1097 个测试通过(新增 26)。变异复核三处:两侧适配器(各挂 1 条)、共用类型判定(两侧测试同时挂)。

Closes #78 ## What 纯 prefactor,不交付任何用户可见行为 —— 让 `LLMClient`/`EmbeddingClient` 契约携带每次调用的 token 用量,为 ADR-0010 六池成本治理铺地基。此前 `AnyLLMClient.complete()` 拿到 any-llm 响应后直接把 usage 丢弃了。PRD #77 第一片。 ## 实现要点 - **新增 `usage.TokenUsage`**(单独成模块:两条调用边界都要用,后续记账层也要用;放任一侧都会让另一侧反向 import)。 - **`None` 表示「provider 没回传」,不等于 `TokenUsage(0, 0)`**。这条区分是后续记账的地基:把「不知道」当 0 累加,「今天花了多少钱」就会悄悄失真。提取器任一层读不到即整体 `None`,不半补;也绝不抛错 —— 拿不到用量是记账精度问题,不该让一次正常的对话请求失败。 - **`EmbeddingClient.embed()` 返回类型改为 `EmbeddingResult(vector, usage)`**,而不是新开 `embed_with_usage()`:留着旧方法就等于留下一条绕过记账的路,六池治理会出现「这个数为什么对不上」的黑洞。破坏面只有 5 个 src 调用点 + 2 个测试构造点。 - **测试替身默认不带用量**(「这个替身不知道用了多少」,与真实 provider 未回传同义),需要断言记账的测试可显式脚本化。既有测试零断言改动,只补齐新字段(AC4)。 ## Review 两轴都抓到实质问题: - **漏网的契约适配(两轴都点了)**:`tests/test_presence_events_e2e.py` 里手写了一个与 `DummyEmbeddingClient` 一模一样的内联替身,我漏了 —— 而且那条路径压根不 embed,测试照样全绿。**讽刺的是这正是本 ticket 单独拆出来要防的东西**。根因是我 grep 时只搜了 `.embed(` 调用点、没搜 `async def embed` 实现点。已改为直接用现成替身。Spec 轴另穷举确认除此之外无遗漏(6 处 embed 实现、11 处 complete 实现)。 - **缺失判定收敛**:两个 `_usage_of` 里那段判定是同一条策略写了两遍,改规则容易只改一边。抽出共用的 `read_token_count`,各自保留字段/语义差异。 - **删掉 `TokenUsage.__add__` 与 `total_tokens`**:累加就是记账,本片明确不含(AC6)。`__add__` 更糟 —— 它顺手把「累加时怎么对待 `None`」这条真正的记账策略提前拍死了。 - **`EmbeddingResult.vector` 改必填**:空向量永远不是合法 embedding。它原是照抄 `AssistantTurn` 的默认值,但那边有明确理由(几十个既有构造点),这边只有 3 个。 **Spec 轴 introspect 了实装的 `any_llm_sdk 1.19.0`**,确认 `prompt_tokens`/`completion_tokens` 字段名正确 —— 排除了「字段名写错导致永远静默返回 None」这个对 prefactor 而言最坏的结果。 **自查时先发现过一个测试缺口**:最初只单测了 `_usage_of` 提取器,变异验证时把 `usage=_usage_of(response)` 改回 `usage=None`,19 条测试全绿 —— 提取器写对了但适配器忘了接上,照样一个 token 都记不到。已补四条真的走 `AnyLLMClient.complete()`/`AnyLLMEmbeddingClient.embed()` 的测试。 ## 已知局限(如实记录,未修) `gemini`/`lmstudio` 的 any-llm embedding 转换器会**伪造零用量**(硬写 `Usage(prompt_tokens=0)`),那时这里会返回 `TokenUsage(0, 0)` —— 按本仓定义那是「确实没花」,与「不知道」混为一谈。core 无从分辨「真算出 0」和「懒得填」,已写进 `_usage_of` docstring 而不假装它不存在。默认配置走 openai,真回传用量,不受影响。 ## Tests 全仓 1097 个测试通过(新增 26)。变异复核三处:两侧适配器(各挂 1 条)、共用类型判定(两侧测试同时挂)。
纯 prefactor,不交付任何用户可见行为——为 ADR-0010 六池成本治理铺地基。此前
`AnyLLMClient.complete()` 拿到 any-llm 响应后直接把 usage 丢弃了。

新增 `usage.TokenUsage`(单独成模块:两条调用边界都要用它,后续记账层还要累加
它;放任一侧都会让另一侧反向 import)。带 `total_tokens` 与 `__add__` 供后续
记账累加,刻意不支持与 `None` 相加——见下。

**`None` 表示"provider 没回传",不等于 `TokenUsage(0, 0)`**(AC"不假装知道用了
多少")。这条区分是后续记账的地基:把"不知道"当 0 累加,"今天花了多少钱"这个
数就会悄悄失真。提取器任一层缺失即整体返回 `None`,不半补;也绝不抛错——拿不到
用量是记账精度问题,不该让一次正常的对话请求失败。

`EmbeddingClient.embed()` 返回类型改为 `EmbeddingResult(vector, usage)`,而不是
新开一个 `embed_with_usage()`:留着旧方法就等于留下一条绕过记账的路,六池治理
会出现"这个数为什么对不上"的黑洞。破坏面只有 5 个 src 调用点 + 2 个测试构造点。

测试替身默认不带用量("这个替身不知道用了多少",与真实 provider 未回传同义),
需要断言记账的测试可显式脚本化——后续记账 ticket 靠这个能力写测试。既有测试
零断言改动,只是补齐新字段(AC 要求)。

**自查时发现的测试缺口**:先只单测了 `_usage_of` 提取器,变异验证时把
`usage=_usage_of(response)` 改回 `usage=None`,19 条测试全绿——提取器写对了但
适配器忘了接上,照样一个 token 都记不到。补了四条真的走 `AnyLLMClient.complete()`/
`AnyLLMEmbeddingClient.embed()` 的测试,两侧适配器变异后都确实挂。

全仓 1094 测试通过(新增 23)。
**漏网的契约适配(两轴都点了,最实在的一条)**:
`tests/test_presence_events_e2e.py` 里手写了一个与 `DummyEmbeddingClient`
一模一样的内联 embedding 替身,契约扩展时漏掉了它——而且那条路径压根不 embed,
测试照样全绿,漏了也不会被发现。讽刺的是这正是本 ticket 单独拆出来要防的东西。
根因是我 grep 时只搜了 `.embed(` 调用点、没搜 `async def embed` 实现点。改为
直接用现成的替身,少一份重复实现就少一处会漏适配的地方。

**缺失判定收敛成 `usage.read_token_count`**:两个 `_usage_of` 里那段
"getattr → 判 None → 判 int → 读不到就整体 None" 是同一条策略写了两遍,改判定
规则(比如以后容忍 float、或允许 total_tokens 兜底)容易只改一边。抽出共用的
一半,各自保留"读哪些字段/输出维度语义"的差异。

**删掉 `TokenUsage.__add__` 与 `total_tokens`**:累加就是记账,而本片明确不含
记账(AC6)。`__add__` 更糟——它顺手把"累加时怎么对待 None"这条真正的记账策略
提前拍死了,该由发现真实需求的那个 ticket 定。

**`EmbeddingResult.vector` 改必填**:空向量永远不是合法 embedding,默认值只会
让 `EmbeddingResult()` 看起来像个能用的东西。它原是照抄 `AssistantTurn` 的默认
值,但那边有明确理由(几十个既有构造点),这边是全新类型只有 3 个构造点。

**如实记录一条已知局限**(Spec 轴查证 any-llm 源码发现):`gemini`/`lmstudio`
的 embedding 转换器会**伪造零用量**(硬写 `Usage(prompt_tokens=0)`),那时这里
会返回 `TokenUsage(0, 0)`——按本仓定义那是"确实没花",与"不知道"混为一谈。core
无从分辨"真算出 0"和"懒得填",写进 docstring 而不假装它不存在;默认 openai 路径
不受影响。

Spec 轴另 introspect 了实装的 any_llm_sdk 1.19.0,确认 `prompt_tokens`/
`completion_tokens` 字段名正确——排除了"字段名写错导致永远静默返回 None"这个
对 prefactor 而言最坏的结果。

全仓 1097 测试通过。共用判定已变异复核:去掉类型判定,两侧的测试同时挂。
Yushu merged commit 281f6435e6 into main 2026-07-27 07:47:30 +00:00
Yushu deleted branch feature/78-usage-in-client-contracts 2026-07-27 07:47:31 +00:00
Sign in to join this conversation.
No description provided.