fix(llm): price by call time and stop paying for thinking tokens

Two defects that only surface against the real DeepSeek API, both of which
break the cost model rather than the code:

deepseek-v4-flash enables thinking mode by default. The same single-turn
question took 13.5s with it on and 1.8s with it off, and the thinking tokens
are billed at the output rate. That misses NFR-PERF-02b (15s per turn) and
inflates every conversational call. SceneConfig now carries an explicit
`thinking` flag, all four V1 scenes set it to false, and the provider always
sends the parameter rather than relying on the upstream default. Reasoning
tokens are recorded separately in Usage.tokens_reasoning so the cost of ever
turning it back on is visible.

DeepSeek moves to peak/off-peak pricing on 2026-08-17, with peak (Beijing
09:00-12:00 and 14:00-18:00) at twice the off-peak rate. Reports are generated
mostly during peak hours, so a single blended rate would leave ob_ai_call --
the only input to V2 pricing -- off by nearly a factor of two.
PeakAwareRateTable resolves the rate at call time; cost is computed and stored
then, never recomputed later at whatever the current rate happens to be.

Also here:
- Replace the live API key in the redaction test with a synthetic one. The
  test that proves we redact secrets was itself a secret leak waiting for its
  first commit.
- Correct LLM_COST_RATES_JSON, which held placeholder rates.
- Make ids.SnowflakeGenerator take an injectable clock so rollover and
  clock-rollback are testable without patching a slots class.
- Rename exceptions to the -Error suffix (N818) and clear the remaining ruff,
  mypy and import-linter findings, including two dead lines: a duplicate
  pytestmark assignment and a no-op assert on a None return.

Gates: ruff, mypy (96 files incl. tests), 4/4 import contracts,
detect-secrets, 166 unit + 3 integration tests, migration up/down round-trip.
This commit is contained in:
xsl
2026-08-14 00:54:40 +08:00
parent 8a4720472b
commit f46ecc5eb0
34 changed files with 1793 additions and 988 deletions
+39 -145
View File
@@ -1,166 +1,60 @@
# 美搭 backend — 工程地基 (M1)
# meida_backend · 美搭服务端
「美搭」智能穿搭美学助手的服务端。本仓库当前是 **工程地基**,不是业务实现:
骨架、横切能力、以及一条打通全链路的最小垂直切片。
「美搭」智能穿搭美学助手的服务端。当前状态是**工程地基 + 一条打通的纵向切片**
(登录取 token → 配额预扣 → 真实调用 DeepSeek → 记成本 → 提交配额),
业务域大多只有目录与 README,见各 `packages/*/README.md`。
> 设计文档是唯一事实来源,在 Windows 侧的 `G:\meida\docs\`:
> 《技术架构文档》《需求规格说明》《开发任务拆解》。本 README 只讲怎么跑起来。
---
## 现在有什么 / 现在没有什么
| 已实现 | 状态 |
| --- | --- |
| 仓库结构与包边界(架构 §16.1) | ✅ |
| ruff / mypy strict / import-linter / pytest / 密钥扫描 全部进 CI(§16.2) | ✅ |
| 统一响应与错误码 + 异常处理(§9.2, FR-SYS-02) | ✅ |
| 结构化日志 + 请求/追踪 ID + 日志脱敏(NFR-SEC-03) | ✅ |
| 异步 SQLAlchemy + Alembic(含 pgvector)+ 首个迁移 | ✅ |
| OTel + Prometheus + `/healthz` `/readyz` `/metrics` | ✅ |
| **LLM 网关**:路由 / 故障转移 / 熔断 / 结构化输出 / Prompt 版本 / 缓存前缀纪律 / 成本归因 / 日预算硬熔断 | ✅ |
| **配额三段式** reserve → commit → refund(§7.4, FR-BILL-05) | ✅ |
| **统一任务中心** 状态机 + Celery 四队列 + 一个贯通的 `ping` 任务 | ✅ |
| 垂直切片 `POST /v1/agent/chat` | ✅ |
| 刻意留白(占位) | 归属任务 |
| --- | --- |
| 华为账号登录、手机号验证码、双令牌撤销、游客合并、注销 | T-P1-12 ~ T-P1-16 |
| 统一合规出口(审核 / 水印 / 元数据 / 内容编号) | T-P1-22、T-P1-23(CR-05/CR-06 不可裁剪) |
| 知识库、混合检索、规则引擎、记忆系统 | M2 |
| 报告生成、换装、发型、视觉服务、抠图 | M3 / M4 |
| 智谱 GLM provider(仅结构占位) | 需先核实备案模型清单 |
| SSE 流式对话、意图识别、生成后校验 | FR-AGT-01/02/05 |
**开始写业务功能前请先读 `packages/README.md` 与对应域的 README** —— 里面写了
什么能放进去、什么绝对不能。
---
技术选型与分层依据见仓库外的《技术架构文档》《技术选型与调研报告》,本文只讲怎么跑起来。
## 快速开始
前置:Python 3.12、Docker、[uv](https://docs.astral.sh/uv/)。
```bash
cp .env.example .env # 填入 DEEPSEEK_API_KEY
make install # uv sync + 装 pre-commit
make up # PostgreSQL(pgvector) + Redis,端口 55432 / 56379
make migrate # 建表并创建 vector 扩展
make run # http://localhost:8000/api/v1/docs
```
验证真实链路(**会产生真实 API 费用**,是唯一花钱的入口):
```bash
# 1. 依赖(本仓库用 uv 管理;uv.lock 锁定全部版本)
make install
# 2. 配置。复制模板后至少填 DEEPSEEK_API_KEY 与 JWT_SECRET
cp .env.example .env
$EDITOR .env
# 3. 起本地基础设施(PostgreSQL 16 + pgvector,Redis 7)
make up
make migrate
# 4. 跑起来
make dev # API → http://localhost:8000/api/v1/docs
make worker # Celery worker(另开一个终端)
# 5. 真实链路验证:会真的调用 DeepSeek,花真钱
make smoke
```
**包管理器:本仓库使用 `uv`**(`uv sync --extra dev`,锁文件 `uv.lock`)。
没有 uv 时 `make install` 会退回 `python -m venv` + `pip install -e ".[dev]"`,
但 CI 与 Docker 镜像都走 uv,因此以 `uv.lock` 为准。
它会起依赖、迁移、拉起 API、发一次真实对话,然后把 `ob_ai_call`
的成本行与 `bl_ledger` 的配额流水打出来。跑通即证明「计量 + 配额 + 大模型」三者串通。
### 常用命令
## 目录结构
| 命令 | 作用 |
| 目录 | 职责 |
| --- | --- |
| `make lint` | ruff check + format check |
| `make typecheck` | mypy(`packages/domain`、`packages/llm` 为 strict) |
| `make importlint` | 校验架构 §4.2 的域间依赖规则 |
| `make test` | 上面三项 + pytest(本地质量门,与 CI 一致) |
| `make migrate` / `make makemigration m="..."` | Alembic |
| `make up` / `make down` | 本地 PG + Redis |
| `make smoke` | 真实 DeepSeek 端到端验证 |
| `apps/api` | HTTP 入口。只做鉴权、校验、幂等、配额、统一响应,不放业务规则 |
| `apps/worker` | Celery 长耗时任务入口 |
| `packages/domain` | 纯领域逻辑,不许 import 任何框架与 I/O |
| `packages/llm` | 大模型网关:场景路由、Prompt 版本、结构化输出、成本计量、预算熔断 |
| `packages/shared` | 配置、日志脱敏、DB、Redis、错误码、中间件 |
| `packages/<业务域>` | identity / billing / task / agent / kb / retrieval / report … |
| `services/` | 需要独立伸缩的重型服务(换装、抠图、视觉),V1 为占位 |
| `prompts/` | 按 `<prompt_id>/<version>.txt` 归档,与 `ob_ai_call.prompt_version` 对应 |
| `migrations/` | Alembic;CI 强制 upgrade → downgrade → upgrade 往返可用 |
---
依赖方向由 `lint-imports` 强制(`pyproject.toml` 的 `[tool.importlinter]`)。
`apps → packages → domain` 是单向的,业务包也不得绕过网关直连 provider。
## 垂直切片:`POST /v1/agent/chat`
一次请求依次经过:
```
JWT 鉴权
→ billing.reserve() 配额预扣,写 bl_ledger(status=reserved)
→ LLM 网关 场景路由 → 缓存前缀校验 → 日预算闸门 → DeepSeek
→ ob_ai_call 每次 provider 调用(含失败与重试)写一行
→ billing.commit() / refund() 成功确认扣除,失败自动退还
→ 统一响应 {code, message, data, request_id}
```
接口基础路径是架构 §9.1 规定的 `/api/v1`;`/v1/*` 作为等价别名同样可用
(内部脚本与部分文档用的是后者,见下方「与设计文档的偏差」)。
## 提交前
```bash
TOKEN=$(curl -s -XPOST localhost:8000/v1/auth/dev-token \
-H 'Content-Type: application/json' -d '{"device_key":"me"}' \
| python3 -c 'import json,sys;print(json.load(sys.stdin)["data"]["access_token"])')
curl -s -XPOST localhost:8000/v1/agent/chat \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"message":"面试穿什么?"}' | python3 -m json.tool
make check # lint + typecheck + imports + test + secrets,与 CI 同一套
```
### 地基验收结果(M1 出口)
单测不连库、不发网络请求。需要真实中间件的用例在 `tests/integration`,
默认跳过,用 `make test-integration` 显式开启。
以下均在本机实跑通过,不是「应该可以」:
## 两件容易踩的事
| 项 | 结果 |
| --- | --- |
| `make lint` / `make typecheck` / `make test` | 全绿;147 unit + 3 integration,`packages/llm` + `packages/domain` 覆盖率 97% |
| import-linter | 6 条契约全部 KEPT(含 compliance / identity 两条禁止依赖) |
| `make up` + `make migrate` | 8 张表 + `alembic_version` 建成,`vector` 扩展存在,配额规则已播种 |
| `alembic downgrade base` → `upgrade head` | 可逆,往返成功 |
| `make smoke`(真实 DeepSeek) | 通过,模型 **`deepseek-v4-flash`**;第二次调用 `tokens_cached=256`,证明缓存前缀纪律真的生效 |
| Celery 全链路 | `pending → queued → running → succeeded`,`tk_task.result` 落库 |
`GET https://api.deepseek.com/v1/models` 当前只返回两个模型:
`deepseek-v4-flash` 与 `deepseek-v4-pro`。默认用前者,通过 `DEEPSEEK_MODEL` 切换,
调用点不写死。
---
## 密钥
- `.env` 在 `.gitignore` 第一条,**永远不要提交**。
- `.env.example` 记录每一个变量的含义,**不含任何真实值**。
- pre-commit 与 CI 都跑密钥扫描(detect-secrets + gitleaks)。
- 生产环境的密钥来自 SSM/KMS,不来自文件(架构 §11.2)。
---
## 本地 GPU
开发机带一块 **RTX 5090(32GB VRAM)**。这意味着 BiRefNet 抠图(TD-07)与
RTMPose ONNX 人体关键点(TD-05/TD-06)**可以完全在本地开发和调参,不需要租云
GPU**。相关占位见 `services/vision/` 与 `services/matting/`。
本仓库尚未实现它们。
---
## 与设计文档的偏差 / 文档待补
记录在此,以便回灌设计文档:
1. **业务域包的位置**:架构 §16.1 的目录树列出了 `packages/{domain,llm,retrieval,
rules,memory,compliance,shared}`,但 §4.1 定义了 11 个业务域。由于
`compliance` 同时出现在两处,本仓库按「业务域各自是 `packages/<域>/`,
`packages/domain/` 只放纯逻辑」来落地。建议在 §16.1 补全域目录。
2. **接口前缀**:§9.1 规定 `/api/v1`,但多处任务描述使用 `/v1`。本仓库以
`/api/v1` 为准,并在 ASGI 层把 `/v1/*` 重写为等价路径。建议文档统一。
3. **`bl_ledger` 缺 `status` 列**:§6.2 的列清单没有 `status`,但 §7.4 的三段式
明确要求 `reserved → committed / refunded`。本仓库补了该列。
4. **`ob_ai_call` 缺 Prompt 版本列**:§8.1「可复现」要求记录 prompt 版本,
§6.2 的列清单里没有。本仓库补了 `prompt_id` / `prompt_version`。
5. **日成本熔断是「拒绝」而非「告警」**:§13 写的是「超预算自动降级并告警」。
本仓库把它实现为硬闸门(非关键场景直接拒绝),因为重试风暴产生成本的速度
远快于人响应告警的速度。关键场景通过 `SceneConfig.critical` 豁免。
6. **单位成本费率是占位值**:`.env.example` 里的 `LLM_COST_RATES_JSON` 需要用
供应商实际价目表校准,否则 `ob_ai_call.cost_cents` 只有相对意义。
7. **`.env` 里的 JSON 值必须用单引号包住**。`scripts/smoke.sh` 会 `source .env`,
裸双引号会被 bash 剥掉,JSON 随即损坏且报错发生在 pydantic-settings 内部,
现场很难看出根因。模板里已经这么写了,改的时候别改回去。
- **Prompt 固定前缀不许含时间戳、随机值等易变内容。** 命中缓存的输入单价只有未命中的
约 1/30,前缀被污染会让输入成本翻数十倍。`LLM_CACHE_PREFIX_MODE=strict` 下启动即失败。
- **DeepSeek 自 2026-08-17 起峰谷定价**,高峰(北京时间 09:00-12:00、14:00-18:00)
单价是空闲时段的 2 倍。单价配两套,由 `packages/llm/cost.py` 按调用时刻选择;
成本在调用当时算好落库,事后不按当前价重算。