-
Notifications
You must be signed in to change notification settings - Fork 82
Open
Description
背景
当前系统的计费逻辑位于应用层(Controller层),每次调用AI模型时,应用层需要手动处理计费的各个环节。这导致:
- 应用层职责过重:Controller不仅要处理业务逻辑,还要关心计费的初始化、余额检查、费用计算、事务记录等
- 重复代码:每个Controller(
ChatController、OpenAICompatibleController)都要实现相同的计费流程 - 扩展困难:添加新的API端点或调用方式时,必须重复实现完整的计费逻辑
- 关注点不分离:计费本质上是模型调用的一个内在属性,不应该暴露给应用层
现状分析
当前的计费流程(应用层主导)
Controller层:
1. 创建 UserModelBalanceCalculator
2. 查询用户余额 (UserBalance)
3. 创建 InChatContext
4. 调用 ChatService.ChatStreamedXXX()
5. 在 InChatContext.Run() 中每个segment后检查余额
6. 调用 icc.ToUserModelUsage() 创建计费记录
7. 保存 UserModelUsage、BalanceTransaction、UsageTransaction
8. 调用 balanceService.AsyncUpdateBalance()
9. 调用 balanceService.AsyncUpdateUsage()
关键问题:
- Controller需要了解计费的所有细节
InChatContext既要处理聊天流程,又要处理计费逻辑- 计费逻辑和模型调用逻辑耦合但又分散在不同地方
目标架构(模型层主导)
Controller层:
1. 调用 ModelService.ChatAsync(user, model, messages, options)
2. 等待结果或流式处理响应
3. 完成 ✅(不需要关心计费)
ModelService层(内部自动处理):
1. 预估费用,验证余额
2. 调用上游AI模型
3. 实时跟踪使用量
4. 自动创建计费记录
5. 自动更新余额
核心理念:模型调用和计费是不可分割的,应该封装在同一个服务边界内
目标
1. 创建独立的模型服务层
将当前的 ChatService 改造为完整的模型服务,内部自动处理计费:
src/BE/Services/Models/
├── ModelInvokeService.cs // 新增:模型调用服务(封装计费)
├── BillingIntegration/ // 新增:计费集成模块
│ ├── BillingContext.cs // 计费上下文
│ ├── BillingTracker.cs // 计费追踪器
│ └── BillingRecorder.cs // 计费记录器
├── ChatServices/ // 现有:各种ChatService实现
│ ├── ChatService.cs
│ ├── OpenAIChatService.cs
│ └── ...
└── InChatContext.cs // 改造:移除计费逻辑
2. 实现模型调用与计费的一体化
- 应用层只需调用模型服务,传入用户、模型、消息等业务参数
- 模型服务内部自动处理:
- ✅ 预估最大费用(基于
max_tokens) - ✅ 验证用户余额是否充足
- ✅ 调用上游AI模型
- ✅ 实时跟踪token使用量
- ✅ 自动创建
UserModelUsage、BalanceTransaction、UsageTransaction - ✅ 自动更新用户余额和模型余额
- ✅ 预估最大费用(基于
- 应用层只需要处理业务异常(如余额不足、模型调用失败等)
3. 优化余额检查策略
当前问题:InChatContext.Run() 在每个segment后都检查余额,带来不必要的性能开销。
新方案:
- 请求开始前,基于
max_tokens预估最大可能费用 - 如果
当前余额 < 预估费用,直接抛出InsufficientBalanceException,拒绝调用 - 移除 segment级别的实时余额检查
- 调用完成后,基于实际使用量(
InputTokens + OutputTokens)精确扣费
好处:
- 大幅减少数据库查询(从 N次segment 减少到 1次预估 + 1次实际扣费)
- 简化
InChatContext的职责 - 用户体验更好(要么能聊,要么直接告知余额不足,不会聊到一半被中断)
4. 简化应用层代码
当前代码(ChatController.cs):
// 需要手动处理计费的各个环节
UserBalance userBalance = await db.UserBalances.Where(x => x.UserId == currentUser.Id).SingleAsync(cancellationToken);
UserModelBalanceCalculator cost = new(BalanceInitialInfo.FromDB(userModels.Values, userBalance.Balance), []);
// ... 200行业务逻辑 ...
UserModelUsage usage = new()
{
Usage = icc.ToUserModelUsage(currentUser.Id, calc, userModel, await clientInfoIdTask, isApi: false),
};
db.UserModelUsages.Add(usage);
await db.SaveChangesAsync(cancellationToken);
if (calc.BalanceCost > 0)
{
_ = balanceService.AsyncUpdateBalance(currentUser.Id, CancellationToken.None);
}目标代码:
// 只需关心业务逻辑,计费自动处理
await foreach (var segment in modelInvokeService.ChatStreamAsync(
userId: currentUser.Id,
userModel: userModel,
messages: messageToSend,
options: cco,
cancellationToken: cancellationToken))
{
await YieldResponse(segment);
}
// 计费已自动完成 ✅预估的困难与挑战
1. 🔴 多Span并发调用的计费协调
场景:ChatController 支持一次请求并发调用多个 ChatSpan,每个Span可能使用不同的模型。
当前实现:
// ChatController.cs 第264-270行
UserModelBalanceCalculator cost = new(BalanceInitialInfo.FromDB(userModels.Values, userBalance.Balance), []);
Channel<SseResponseLine>[] channels = [.. toGenerateSpans.Select(x => Channel.CreateUnbounded<SseResponseLine>())];
Task[] streamTasks = [.. toGenerateSpans.Select((span, index) => ProcessChatSpan(
...,
cost.WithScoped(span.SpanId.ToString()), // 每个Span有独立的计费scope
...
))];挑战:
- 多个Span可能使用不同的模型(ModelA、ModelB),每个模型有独立的token余额
- 需要在所有Span开始前预估总费用,确保余额充足
- 某个Span失败时,其他成功的Span如何计费?是全部回滚还是部分计费?
- 如何将
ScopedBalanceCalculator的职责迁移到模型服务内部?
可能方案:
- 模型服务支持"批量调用"模式,一次性预估所有Span的费用
- 采用"各自独立计费"策略:每个Span成功就计费,失败就不计费
- 提供事务性API:全部成功才计费,任一失败就全部回滚(复杂度高)
2. 🔴 流式响应的计费时机
场景:用户通过SSE接收流式响应,可能中途取消请求。
当前实现:
- Controller通过
stopService管理取消令牌 - 用户取消时,
InChatContext抛出TaskCanceledException - finally块中仍然会记录已产生的费用
挑战:
- 模型服务内部如何感知用户取消?
- 取消时,已经消耗的token如何计费?
- 如何避免"取消时机"的竞态条件(用户刚取消,服务器刚扣费)?
可能方案:
- 传入
CancellationToken,模型服务内部监听取消信号 - 采用"按实际使用计费"原则:无论是否取消,已消耗的token都要计费
- 在服务内部的 finally 块中确保计费记录被创建
3. 🟡 缓存机制的计费处理
场景:OpenAICompatibleController 的 v1-cached 端点支持响应缓存,命中缓存时直接返回,不调用上游模型。
当前实现:
// OpenAICompatibleController.cs 第142-162行
if (cache != null)
{
// 返回缓存内容
isSuccess = true;
return Ok(fullResponse);
// 记录缓存使用但不扣费
_ = asyncCacheUsageService.SaveCacheUsage(...);
}挑战:
- 缓存命中时,是否调用模型服务?如果调用,模型服务如何知道是缓存命中?
- 缓存命中的计费策略是什么?(免费?按特殊价格?)
- 模型服务是否需要感知缓存层的存在?
可能方案:
- 方案A:缓存逻辑保留在Controller,命中时直接返回,不调用模型服务(推荐)
- 方案B:模型服务提供
InvokeWithCache()方法,内部处理缓存逻辑 - 方案C:通过参数传递
isCacheHit,模型服务根据此参数决定计费策略
4. 🟡 InChatContext的职责重构
当前问题:InChatContext 承担了过多职责:
- 追踪响应时间(preprocess、reasoning、firstResponse等)
- 收集响应内容(_items列表)
- 检查余额(第67-71行)
- 创建计费记录(第88-143行
ToUserModelUsage方法)
挑战:
- 如何将计费职责剥离出去,但保留其他职责?
ToUserModelUsage方法创建了UserModelUsage、BalanceTransaction、UsageTransaction,这些创建逻辑应该放在哪里?- 多处代码依赖
InChatContext,重构可能影响面较大
可能方案:
- 创建新的
BillingTracker类,负责计费相关逻辑 InChatContext保留性能追踪和内容收集职责- 在模型服务内部组合使用
InChatContext和BillingTracker
5. 🟡 预估费用的准确性
场景:基于 max_tokens 预估费用,但实际使用量可能远小于预估。
挑战:
- 用户可能不提供
max_tokens(OpenAI API默认是inf) - 如何设置合理的预估值,既不能太大(导致余额充足但被拒绝),也不能太小(可能中途余额不足)
- 不同模型的定价策略不同(按token vs 按次数)
可能方案:
- 为每个模型配置"默认最大tokens"(如4096或8192)
- 支持管理员配置"预估策略"(保守/正常/激进)
- 对于按次数计费的模型,预估为固定1次费用
- 如果用户提供了
max_tokens,使用用户值;否则使用默认值
6. 🟢 数据库事务的处理
场景:计费涉及多张表的写入(UserModelUsage、BalanceTransaction、UsageTransaction),需要保证原子性。
当前实现:
// 在Controller的finally块中
UserModelUsage usage = new() { ... };
db.UserModelUsages.Add(usage);
await db.SaveChangesAsync(cancellationToken);挑战:
- 模型服务如何访问
ChatsDB?是通过依赖注入还是传参? - 如果模型调用成功但计费记录保存失败,如何处理?
- 多Span并发时,如何确保所有计费记录的一致性?
可能方案:
- 模型服务通过依赖注入获取
IServiceScopeFactory,内部创建独立的ChatsDB实例 - 使用数据库事务确保计费记录的原子性
- 计费失败时记录日志,支持后续人工补偿
7. 🟢 异常处理和错误传播
场景:模型调用可能抛出多种异常(余额不足、上游错误、配置错误等)。
当前实现:
// ChatController.cs 第708-739行
catch (ChatServiceException cse)
{
icc.FinishReason = cse.ErrorCode;
errorText = cse.Message;
}
catch (ClientResultException e)
{
icc.FinishReason = DBFinishReason.UpstreamError;
errorText = e.Message;
}
// ... 更多catch块 ...挑战:
- 模型服务内部处理计费,但异常需要传播到应用层供用户感知
- 如何区分"计费异常"和"模型调用异常"?
- 计费异常是否应该对用户可见?
可能方案:
- 定义明确的异常层次结构:
ModelInvokeException>BillingException/UpstreamException - 计费异常(如余额不足)直接传播给应用层
- 模型调用异常仍然按现有方式处理
8. 🟢 向后兼容性和灰度迁移
挑战:
- 系统已在生产环境运行,不能一次性完全重构
- 需要支持新旧代码共存,逐步迁移
- 如何验证新的计费逻辑和旧的完全一致?
可能方案:
- 先实现新的模型服务,但不删除旧的计费代码
- 通过Feature Flag控制是否启用新的计费逻辑
- 双写模式:同时使用新旧逻辑,记录差异,验证正确性
- 灰度发布:先在小部分用户中测试,逐步扩大范围
实施步骤
Phase 1: 设计和原型验证
- 设计模型服务的API接口和内部架构
- 创建
BillingIntegration模块的基础结构 - 实现预估费用和余额验证逻辑
- 编写单元测试验证核心计费逻辑
Phase 2: 重构InChatContext
- 将计费逻辑从
InChatContext剥离到BillingTracker - 移除
InChatContext.Run()中的实时余额检查 - 保留
InChatContext的性能追踪和内容收集职责 - 确保现有功能不受影响
Phase 3: 实现模型调用服务
- 创建
ModelInvokeService,封装模型调用和计费 - 实现流式调用的计费处理
- 处理异常情况(取消、失败、超时等)
- 集成测试验证正确性
Phase 4: 迁移OpenAICompatibleController
- 使用新的
ModelInvokeService替换现有逻辑 - 处理缓存场景的特殊情况
- 保持API行为完全一致
- 并行运行新旧逻辑,对比验证结果
Phase 5: 迁移ChatController
- 处理多Span并发调用场景
- 处理MCP工具调用的计费
- 保持SSE响应格式不变
- 并行运行新旧逻辑,对比验证结果
Phase 6: 清理和优化
- 移除旧的计费代码
- 优化性能(缓存、批量处理等)
- 完善文档和代码注释
- 生产环境全量上线
成功标准
- ✅ Controller层代码不再包含计费相关逻辑(
UserModelBalanceCalculator、BalanceTransaction等) - ✅ 应用层调用模型服务时,只需传入业务参数,无需关心计费
- ✅ 所有计费逻辑封装在模型服务内部,边界清晰
- ✅ 新增API端点时,无需编写任何计费代码
- ✅ 生产环境运行稳定,计费准确无误
- ✅ 单元测试和集成测试覆盖率达到80%以上
风险评估
高风险:计费逻辑错误可能导致:
- 用户被错误扣费(损失用户信任)
- 用户被少扣费或不扣费(公司经济损失)
- 余额验证不准确(误杀或漏过)
缓解措施:
- ✅ 充分的单元测试和集成测试
- ✅ 双写验证:新旧逻辑并行运行,对比结果
- ✅ Feature Flag:支持快速回滚
- ✅ 详细的审计日志:记录所有计费决策和操作
- ✅ 灰度发布:先在内部测试账号验证,再小范围用户,最后全量
- ✅ 监控告警:计费异常、差异告警实时通知
预期收益
- 代码质量提升:职责分离,每个层次只做自己该做的事
- 可维护性增强:计费逻辑集中管理,修改只涉及一处
- 扩展性提升:新增API端点无需重复编写计费逻辑
- 性能优化:减少不必要的余额检查,降低数据库查询
- 开发效率提升:开发者只需关注业务逻辑,无需理解复杂的计费细节
参考资料
当前计费相关代码:
- ChatController.cs - 应用层计费流程示例
- OpenAICompatibleController.cs - API层计费流程示例
- InChatContext.cs - 计费逻辑集中地(第67-71行余额检查,第88-143行创建计费记录)
- UserModelBalanceCalculator.cs - 余额计算器
- BalanceService.cs - 余额更新服务
相关数据表:
UserModelUsage- 模型使用记录BalanceTransaction- 余额交易记录UsageTransaction- 用量交易记录UserBalance- 用户余额UserModel- 用户模型权限和余额
Metadata
Metadata
Assignees
Labels
No labels