Files
meida_backend/packages/llm
xsl c97f8ae3e1
CI / lint / typecheck / import-linter / tests / secrets (push) Canceled after 0s
feat(cost): persist reasoning tokens and the peak/off-peak tier
ob_ai_call is the only input to V2 pricing, and it was dropping the two
numbers needed to interpret its own cost column.

tokens_reasoning was captured from the provider response but never written to
the table, so the cost of thinking mode was invisible once it left the
process. Folding it into tokens_out was not an option either -- it is billed
at the output rate, and mixing the two hides exactly the difference we now
have to watch.

rate_tier records which price list applied. Without it a row's cost_cents
cannot be rechecked: DeepSeek's peak rate is double off-peak from 2026-08-17,
so recomputing an old row at "the current rate" silently produces a different
answer than what was actually billed.

Migration 0002 adds both columns. tokens_reasoning defaults to 0 rather than
NULL so cost aggregates do not collapse to NULL on historical rows;
rate_tier stays nullable because rows written before this change genuinely
have an unknown tier, and NULL says that more honestly than a guessed 'flat'.

The smoke script now asserts both: reasoning tokens must be 0 (a non-zero
value means a provider default re-enabled thinking mode behind our backs) and
rate_tier must be present. Verified against the real API -- tokens_reasoning=0,
rate_tier=off_peak, 3.9s round trip.
2026-08-14 01:02:13 +08:00
..

packages/llm/ — LLM 网关

架构 §4.3、§8.1。V1 是进程内包,V2 拆成独立服务。 因此接口必须按远程调用来设计:无共享内存假设、明确超时、可序列化的入参出参。

唯一入口

所有 LLM 调用都走 LLMGateway.complete()。业务代码不许自己 httpx.post 到任何 模型供应商 —— 那样会绕过成本归因、预算闸门、故障转移和 Prompt 版本记录,而这四件事 一旦有缺口就补不回来(历史数据是补不出来的)。

已实现

能力 位置
场景化路由(scene → provider/model/温度/超时/预算,全配置化) routing.py
故障转移 + 熔断(超时/5xx/限流切备用;连续失败熔断,冷却后半开探测) gateway.py circuit.py
结构化输出(Pydantic Schema 约束 + 校验失败重试) gateway.py
Prompt 版本(文件化,按 id+version 加载,版本随每次调用落库) prompts.py /prompts
缓存前缀纪律(固定前缀含时间戳/随机 ID/昵称 → 拒绝或告警) prompts.py
成本归因(每次 provider 调用写一行 ob_ai_call,含缓存命中 token) cost.py recording.py
全局日成本硬熔断(超预算即拒绝非关键场景,不是只告警) budget.py

未实现(按 §8.1 还差)

  • 流式输出 SSE(FR-AGT-01 要求首字 p95 < 2s,非流式做不到)
  • 审核联动:输入输出审核应在网关内统一完成,业务方无需关心(CR-06)
  • 用户级频控:目前只有平台级日预算与业务侧配额
  • A/B 分流:按用户哈希选择 prompt 版本
  • 智谱 GLM provider:仅结构占位,见 providers/zhipu.py

加一个新场景

  1. 在 types.Scene 加枚举值。
  2. 在 routing._DEFAULTS 加默认配置,想清楚 critical 该不该为真 —— critical=True 意味着日预算耗尽时它仍然会花钱。
  3. 在 prompts/<scene>/v1.txt 写模板。[system] 段不许有任何变量。
  4. 跑 pytest tests/unit/test_prompt_cache_prefix.py,它会扫描仓库里全部 prompt 的固定前缀。

加一个新 provider

  1. 实现 providers/base.LLMProvider 协议(name / default_model / complete / aclose)。
  2. HTTP 错误必须映射到 errors.py 的异常族,retryable 决定要不要切备用。 映射错了会导致「明明该切备用却没切」或者「明明是我们请求写错了却把备用也打一遍」。
  3. usage 字段必须映射出 缓存命中 token 数,否则 NFR-COST-03 的缓存命中率 看板是空的。
  4. 在 apps/api/deps.build_llm_gateway() 注册。

费率

RateTable 来自配置(LLM_COST_RATES_JSON),不是常量。未知模型走 default 费率 而不是 0 —— 成本上的盲区比不精确更危险。上线前必须用供应商实际价目表校准。