Files
smartmate/docs/backend/统一出口Credit计费最终计划.md
Losita 61db646805 Version: 0.9.80.dev.260506
后端:
1. LLM 独立服务与统一计费出口落地:新增 `cmd/llm`、`client/llm` 与 `services/llm/rpc`,补齐 BillingContext、CreditBalanceGuard、价格规则解析、stream usage 归集与 `credit.charge.requested` outbox 发布,active-scheduler / agent / course / memory / gateway fallback 全部改走 llm zrpc,不再各自本地初始化模型。
2. TokenStore 收口为 Credit 权威账本:新增 credit account / ledger / product / order / price-rule / reward-rule 能力与 Redis 快照缓存,扩展 tokenstore rpc/client 支撑余额快照、消耗看板、商品、订单、流水、价格规则和奖励规则,并接入 LLM charge 事件消费完成 Credit 扣费落账。
3. 计费旧链路下线与网关切口切换:`/token-store` 语义整体切到 `/credit-store`,agent chat 移除旧 TokenQuotaGuard,userauth 的 CheckTokenQuota / AdjustTokenUsage 改为废弃,聊天历史落库不再同步旧 token 额度账本,course 图片解析请求补 user_id 进入新计费口径。

前端:
4. 计划广场从 mock 数据切到真实接口:新增 forum api/types,首页支持真实列表、标签、搜索、防抖、点赞、导入和发布计划,详情页补齐帖子详情、评论树、回复和删除评论链路,同时补上“至少一个标签”的前后端约束与默认标签兜底。
5. 商店页切到 Credit 体系并重做展示:顶部改为余额 + Credit/Token 消耗看板,支持 24h/7d/30d/all 周期切换;套餐区展示原价与当前价;历史区改为当前用户 Credit 流水并支持查看更多,整体视觉和交互同步收口。

仓库:
6. 配置与本地启动体系补齐 llm / outbox 编排:`config.example.yaml` 增加 llm rpc 和统一 outbox service 配置,`dev-common.ps1` 把 llm 纳入多服务依赖并自动建 Kafka topic,`docker-compose.yml` 同步初始化 agent/task/memory/active-scheduler/notification/taskclass-forum/llm/token-store 全量 outbox topic。
2026-05-06 20:16:53 +08:00

603 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 统一出口 Credit 计费最终计划
## 1. 文档目的
本计划用于把当前系统从“各业务服务进程内直接持有 LLM 组件、调用结束后再走旧 token 累加”的模式,重构为:
1. `LLM` 成为真正的独立服务统一掌管模型调用入口、出口、usage 采集与计费事件发布。
2. `TokenStore` 服务切换为 Credit 语义权威服务,掌管余额、商品、订单、流水与最终扣费落账。
3. 前端商店直接消费 Credit 语义接口,并能展示“当前登录用户自己的 Credit 流水”。
这份计划改成“三步走”版本,便于直接按阶段批准与执行。
---
## 2. 总体结论
### 2.1 核心结论
1. `backend/services/llm` 不能再以“进程内共享组件”存在,必须升级为独立服务。
2. 所有用户态模型调用统一经过 `LLM RPC`,由 `LLM` 服务统一采集 usage。
3. `LLM` 服务自己做同步准入 `CreditBalanceGuard`,并使用 Redis 余额快照提速。
4. `LLM` 服务不做同步扣费,也不允许裸 goroutine 异步扣费。
5. `LLM` 服务在拿到最终 usage 后,把 `credit.charge.requested` 写入 **自己的 outbox 表**
6. `TokenStore` 服务异步消费扣费事件,更新 Credit 余额与流水。
7. 旧 token 累加链路、旧 token 商店链路和旧 `/token-store` 接口本轮直接删除,不保留兼容层。
### 2.2 关键原则
1. **统一出口**:所有 LLM 计费只在 `LLM` 服务出口发生,不允许业务层各自重复扣费。
2. **强制入参**:不能只靠 `Metadata` 约定传 `user_id`,必须改 RPC/函数签名,让漏传在编译期报错。
3. **准入同步、结算异步**:调用模型前同步做 `CreditBalanceGuard`,拿到最终 usage 后通过 outbox 异步结算。
4. **旧商店直删**:旧 token 商店前后端都未真正接线,本轮不做 token/credit 双轨并存。
5. **调用面兼容优先**`LLM RPC` 可以新增内部协议,但 `backend/client/llm` 对业务侧暴露的调用方式应尽量贴近当前 `services/llm`,把迁移成本压到最低。
---
## 3. 为什么要这样改
### 3.1 当前最大问题
1. `services/llm` 名字像服务,实际上不是服务,而是多个进程直接 new 的本地组件。
2. 计费边界分散,谁发起调用谁就得自己记账,很容易遗漏。
3. 当前只有旧 `token_usage` 累加,没有人民币成本与 Credit 扣费真相。
4. `userauth` 当前承担的是 `token quota` 门禁,不是 Credit 余额门禁。
5. 旧 token 商店前后端都没有真正接上,因此没有保留兼容层的价值。
### 3.2 这次改完后的好处
1. LLM 调用入口统一。
2. usage 采集统一。
3. 余额准入统一。
4. 扣费事件发布统一。
5. TokenStore 只做 Credit 权威账本,不再和模型调用散乱耦合。
---
## 4. 三步走总览
### 第一步:把 LLM 变成真正独立服务
目标:
1. 所有模型调用统一改走 `LLM RPC`
2. `LLM` 服务拥有自己的 `CreditBalanceGuard`
3. `LLM` 服务拥有自己的 outbox 表
### 第二步:把 TokenStore 改造成 Credit 权威服务
目标:
1. 所有余额、流水、商品、订单都切成 Credit 语义
2. 异步消费 `credit.charge.requested`
3. 提供“查看自己流水”的正式接口
### 第三步:切 Gateway 与前端,并删旧链路
目标:
1. 上线 `/credit-store/*`
2. 商店页接 Credit 真接口与流水列表
3. 删掉旧 token 累加和旧 token 商店链路
---
## 5. 第一步:把 LLM 变成真正独立服务
## 5.1 这一步的目标
把当前“各进程内直接持有 LLM 组件”的模式,改成“统一调 LLM 服务”。
## 5.2 新增/改造内容
### 5.2.1 新增独立 LLM 进程与 RPC
新增:
1. `backend/cmd/llm/main.go`
2. `backend/client/llm/client.go`
3. `backend/services/llm/rpc/pb/llm.proto`
4. `backend/services/llm/rpc/handler.go`
5. `backend/services/llm/rpc/server.go`
6. `backend/services/llm/dao/connect.go`
说明:
1. 这样 `services/llm` 放在 `services` 目录下才真正名副其实。
2. 业务服务以后全部只依赖 `client/llm`,不再直接 import `services/llm` 的本地组件。
### 5.2.2 统一 RPC 能力,但业务侧调用面尽量保持原状
服务间 `LLM RPC` 至少提供三类底层能力:
1. `GenerateText`
2. `StreamText`
3. `GenerateResponsesText`
但是对业务代码,不建议直接散落调用底层 RPC 方法名,而是由 `backend/client/llm` 继续暴露与当前本地 `llm client` 基本一致的门面:
1. `(*Client).GenerateText(ctx, messages, options)`
2. `GenerateJSON[T](ctx, client, messages, options)`
3. `(*Client).Stream(ctx, messages, options)`
4. `(*ArkResponsesClient).GenerateText(ctx, messages, options)`
5. `GenerateArkResponsesJSON[T](ctx, client, messages, options)`
迁移目标:
1. 业务层尽量只替换 client 来源,不重写 prompt 组织方式。
2. `GenerateOptions``ArkResponsesOptions` 现有字段语义尽量不变。
3. `generateJson / stream / responses json` 这些现有能力名和使用习惯尽量延续,避免把改造面扩大成“全链路重写调用协议”。
使用分工:
1. `GenerateText`memory、active-scheduler、agent 非流式调用
2. `StreamText`agent chat / plan / execute / deliver
3. `GenerateResponsesText`course 图片解析
### 5.2.3 统一 BillingContext但不要污染现有 GenerateOptions
所有请求都必须携带:
```go
type BillingContext struct {
UserID int
EventID string
Scene string
RequestID string
ConversationID string
ModelAlias string
SkipCharge bool
}
```
要求:
1. `UserID` 必须明确,普通用户态调用禁止省略。
2. `EventID` 必须稳定,作为幂等扣费键。
3. `Scene` 必须明确,方便价格表与审计。
4. `SkipCharge` 只允许系统内部场景显式声明。
5. `BillingContext` 不再塞进 `GenerateOptions.Metadata` 里,避免继续靠弱约定传关键字段。
6. `GenerateOptions``ArkResponsesOptions` 继续只承载模型行为参数,例如 `Temperature / MaxTokens / Thinking`
落地建议:
1. `client/llm` 对调用方继续保留现有 `GenerateText / GenerateJSON / Stream / Responses` 风格方法。
2. 计费必填信息通过独立的调用包装层传入,例如“绑定过 BillingContext 的 client”或“显式 request 参数对象”,但要由 `client/llm` 吸收复杂度。
3. 调用方改造目标应控制在“补一处 BillingContext 组装 + 替换 client 初始化来源”,而不是每个业务点重写整段调用代码。
### 5.2.4 LLM 服务自己的 CreditBalanceGuard
这一步必须同时完成,否则独立 LLM 服务没有准入价值。
Guard 设计要求:
1. 模型真正发起前同步执行。
2. 不直接查 MySQL 真相表,必须走 Redis 快照优先。
3. 缓存 miss 或过期时,再回源 `TokenStore RPC`
建议缓存 key
1. `smartflow:credit_balance_snapshot:{user_id}`
2. `smartflow:credit_balance_blocked:{user_id}`
执行顺序:
1. 先查 `blocked`
2. 再查 `snapshot`
3. miss 时调用 TokenStore 获取余额快照
4. 若余额不足,写短 TTL 的 `blocked`
### 5.2.5 LLM 服务自己的 outbox
这一步也必须同时完成。
新增:
1. `llm_outbox_messages`
并在 outbox 目录注册:
1. `ServiceLLM`
2. `ServiceNameLLM`
3. `smartflow.llm.outbox`
4. `smartflow-llm-outbox-consumer`
涉及文件:
1. `backend/shared/infra/outbox/service_catalog.go`
2. `backend/shared/infra/outbox/service_route.go`
3. `backend/services/llm/dao/connect.go`
### 5.2.6 LLM 服务统一 usage 采集
`LLM` 服务内部统一输出:
```go
type BillingUsage struct {
InputTokens int64
OutputTokens int64
CachedTokens int64
ReasoningTokens int64
TotalTokens int64
ModelName string
ProviderName string
}
```
### 5.2.7 LLM 服务只发 charge 事件,不同步扣费
新增事件:
1. `credit.charge.requested`
事件载荷至少包含:
1. `event_id`
2. `user_id`
3. `scene`
4. `request_id`
5. `conversation_id`
6. `provider_name`
7. `model_name`
8. `input_tokens`
9. `output_tokens`
10. `cached_tokens`
11. `reasoning_tokens`
12. `rmb_cost_micros`
13. `credit_cost`
14. `triggered_at`
时机:
1. 拿到最终 usage
2. 算出本次 `credit_cost`
3. 写入 `llm_outbox_messages`
4. 结果即可返回调用方
## 5.3 这一步涉及哪些调用方改造
以下服务都要从“本地 import `services/llm`”改成“调用 `client/llm`”:
1. `agent`
2. `memory`
3. `active-scheduler`
4. `course`
---
## 6. 第二步:把 TokenStore 改造成 Credit 权威服务
## 6.1 这一步的目标
`TokenStore` 不再只是“充值/奖励入账中心”,而是:
1. Credit 余额真相
2. Credit 商品与订单中心
3. Credit 流水中心
4. LLM 扣费事件的最终结算方
## 6.2 新的 Credit 数据表
本轮不复用旧 `token_*` 表,直接建立纯 Credit 语义的新表:
1. `credit_products`
2. `credit_orders`
3. `credit_accounts`
4. `credit_ledger`
5. `credit_price_rules`
6. `credit_reward_rules`
### 6.2.1 `credit_products`
用途:商店展示的 Credit 商品
核心字段:
1. `sku`
2. `name`
3. `description`
4. `credit_amount`
5. `price_cent`
6. `currency`
7. `badge`
8. `status`
9. `sort_order`
### 6.2.2 `credit_orders`
用途:购买订单与 mock paid 状态机
核心字段:
1. `order_no`
2. `user_id`
3. `product_id`
4. `product_snapshot_json`
5. `quantity`
6. `credit_amount`
7. `amount_cent`
8. `status`
9. `payment_mode`
10. `idempotency_key`
11. `paid_at`
12. `credited_at`
### 6.2.3 `credit_accounts`
用途:余额真相与门禁回源表
核心字段:
1. `user_id`
2. `balance`
3. `total_recharged`
4. `total_rewarded`
5. `total_consumed`
6. `version`
### 6.2.4 `credit_ledger`
用途:统一正负流水,也是“查看自己流水”的唯一真相源
核心字段:
1. `event_id`
2. `user_id`
3. `direction`
4. `source`
5. `scene`
6. `description`
7. `credit_delta`
8. `balance_after`
9. `input_tokens`
10. `output_tokens`
11. `cached_tokens`
12. `reasoning_tokens`
13. `rmb_cost_micros`
14. `created_at`
### 6.2.5 `credit_price_rules`
用途:模型价格表
核心字段:
1. `provider_name`
2. `model_name`
3. `scene`
4. `input_price_micros_per_1k`
5. `output_price_micros_per_1k`
6. `cached_price_micros_per_1k`
7. `reasoning_price_micros_per_1k`
8. `credits_per_yuan`
9. `status`
### 6.2.6 `credit_reward_rules`
用途:社区奖励规则
核心字段:
1. `source`
2. `name`
3. `credit_amount`
4. `status`
5. `config_json`
## 6.3 TokenStore 如何消费 charge 事件
新增消费逻辑:
1. 订阅 `credit.charge.requested`
2. 幂等校验 `event_id`
3. 在事务中写 `credit_ledger`
4. 扣减 `credit_accounts.balance`
5. 刷新或删除 Redis 余额快照
## 6.4 用户查看自己流水接口
这是本轮必须落的能力。
对外新增:
1. `ListCreditTransactions`
要求:
1. 明确用于“当前登录用户查看自己的 Credit 流水”
2. 必须覆盖购买入账、社区奖励入账、LLM 消费扣费三类记录
HTTP 接口:
1. `GET /api/v1/credit-store/transactions`
必须满足:
1. 只返回当前登录用户自己的 Credit 流水
2. 支持分页:`page``page_size`
3. 支持可选筛选:`direction``source``scene`
4. 返回字段至少包含:
- `transaction_id`
- `event_id`
- `direction`
- `source`
- `scene`
- `description`
- `credit_delta`
- `balance_after`
- `created_at`
## 6.5 这一步要删除什么旧东西
直接删除:
1. `token_products`
2. `token_orders`
3. `token_grants`
4. `token_reward_rules`
---
## 7. 第三步:切 Gateway/前端并删旧链路
## 7.1 这一步的目标
1. 上线 `/credit-store/*`
2. 商店页接 Credit 真接口
3. 删掉旧 token 累加和旧 token 商店链路
## 7.2 Gateway 切换
只保留 Credit 语义路由:
1. `GET /api/v1/credit-store/summary`
2. `GET /api/v1/credit-store/products`
3. `POST /api/v1/credit-store/orders`
4. `POST /api/v1/credit-store/orders/:order_id/mock-paid`
5. `GET /api/v1/credit-store/transactions`
直接删除:
1. `/token-store/*`
2. `backend/gateway/api/tokenstoreapi/*`
## 7.3 前端商店页切换
商店页必须改成:
1.`/credit-store/*`
2. 新增“我的 Credit 流水”展示区
3. 流水数据源只允许来自 `GET /api/v1/credit-store/transactions`
4. 至少展示:
- 流水类型
- 流水说明
- Credit 增减值
- 流水发生时间
- 可选余额快照
## 7.4 旧 token 累加机制下线
直接删除:
1. `backend/services/runtime/eventsvc/chat_token_usage_adjust.go`
2. `backend/services/runtime/model/agent.go``ChatTokenUsageAdjustPayload`
3. `backend/services/agent/sv/agent_graph.go` 中旧 token 调整调用
4. `backend/services/agent/sv/agent_meta.go` 中旧 token 调整调用
5. `backend/services/runtime/dao/agent.go``tokens_total` 累加写路径
6. `backend/gateway/middleware/token_quota_guard.go` 在聊天主链的使用
7. `userauth``CheckTokenQuota / AdjustTokenUsage` 的计费用途
8. `agent_chats.tokens_total` 字段
---
## 8. 这三步分别改哪些地方
### 第一步改动面
1. `backend/services/llm/*`
2. `backend/client/llm/*`
3. `backend/cmd/llm/*`
4. `backend/shared/infra/outbox/service_catalog.go`
5. `backend/shared/infra/outbox/service_route.go`
6. `agent / memory / active-scheduler / course` 的所有本地 LLM 调用点
### 第二步改动面
1. `backend/services/tokenstore/*`
2. `backend/client/tokenstore/*`
3. TokenStore 的 outbox consumer
4. Redis 余额快照与失效逻辑
### 第三步改动面
1. `backend/gateway/*`
2. `frontend/src/views/StoreView.vue`
3. 可能新增商店流水列表组件
4. `runtime/userauth` 旧 token 累加链删除
---
## 9. 验证清单
### 9.1 第一步验证
1. 业务服务代码里不再直接 import `services/llm`
2. 所有模型调用都改经 `LLM RPC`
3. 所有请求都必须带 `BillingContext`
4. `CreditBalanceGuard` 命中缓存、miss 回源、余额不足封禁都通过测试
5. `llm_outbox_messages` 能正常写入 `credit.charge.requested`
### 9.2 第二步验证
1. `TokenStore` 能幂等消费 `credit.charge.requested`
2. `credit_accounts` 余额更新正确
3. `credit_ledger` 流水写入正确
4. `ListCreditTransactions` 返回当前用户自己的流水
### 9.3 第三步验证
1. 商店页能展示 Credit 概览
2. 商店页能展示商品
3. 商店页能展示“我的 Credit 流水”
4. 全文搜索确认以下旧链路不再出现在主路径:
- `PublishChatTokenUsageAdjustRequested`
- `AdjustTokenUsage(`
- `CheckTokenQuota(`
- `TokenQuotaGuard`
- `tokens_total +`
- `tokenstoreapi`
- `/token-store`
- `token_products`
- `token_orders`
- `token_grants`
- `token_reward_rules`
---
## 10. 风险与取舍
### 10.1 这次改造面确实更大
因为这次不是只改计费,而是顺带把 `LLM` 从“共享组件”升级成真正独立服务,所以会涉及:
1. 新 RPC
2. 新 client
3. 新 outbox
4. 业务服务调用方式切换
### 10.2 但长期结构更对
收益是:
1. LLM 调用入口统一
2. usage 采集统一
3. 准入 guard 统一
4. 计费事件发布统一
5. TokenStore 只做账本,不再和模型调用散乱耦合
---
## 11. 服务影响总表
### 必改服务
1. `backend/services/llm`
2. `backend/services/tokenstore`
3. `backend/gateway`
4. `backend/services/agent`
5. `backend/services/course`
6. `backend/services/memory`
7. `backend/services/active_scheduler`
### 新增
1. `backend/cmd/llm`
2. `backend/client/llm`
3. `backend/services/llm/rpc/*`
4. `llm_outbox_messages`
### 后续前端接入
1. `frontend/src/views/StoreView.vue`
2. 若商店页拆组件,则补充独立的 Credit 流水列表组件