Skip to content

重构计费系统:将计费逻辑下沉到模型服务层 #109

@sdcb

Description

@sdcb

背景

当前系统的计费逻辑位于应用层(Controller层),每次调用AI模型时,应用层需要手动处理计费的各个环节。这导致:

  1. 应用层职责过重:Controller不仅要处理业务逻辑,还要关心计费的初始化、余额检查、费用计算、事务记录等
  2. 重复代码:每个Controller(ChatControllerOpenAICompatibleController)都要实现相同的计费流程
  3. 扩展困难:添加新的API端点或调用方式时,必须重复实现完整的计费逻辑
  4. 关注点不分离:计费本质上是模型调用的一个内在属性,不应该暴露给应用层

现状分析

当前的计费流程(应用层主导)

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使用量
    • ✅ 自动创建 UserModelUsageBalanceTransactionUsageTransaction
    • ✅ 自动更新用户余额和模型余额
  • 应用层只需要处理业务异常(如余额不足、模型调用失败等)

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. 🟡 缓存机制的计费处理

场景OpenAICompatibleControllerv1-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 方法创建了 UserModelUsageBalanceTransactionUsageTransaction,这些创建逻辑应该放在哪里?
  • 多处代码依赖 InChatContext,重构可能影响面较大

可能方案

  • 创建新的 BillingTracker 类,负责计费相关逻辑
  • InChatContext 保留性能追踪和内容收集职责
  • 在模型服务内部组合使用 InChatContextBillingTracker

5. 🟡 预估费用的准确性

场景:基于 max_tokens 预估费用,但实际使用量可能远小于预估。

挑战

  • 用户可能不提供 max_tokens(OpenAI API默认是 inf
  • 如何设置合理的预估值,既不能太大(导致余额充足但被拒绝),也不能太小(可能中途余额不足)
  • 不同模型的定价策略不同(按token vs 按次数)

可能方案

  • 为每个模型配置"默认最大tokens"(如4096或8192)
  • 支持管理员配置"预估策略"(保守/正常/激进)
  • 对于按次数计费的模型,预估为固定1次费用
  • 如果用户提供了 max_tokens,使用用户值;否则使用默认值

6. 🟢 数据库事务的处理

场景:计费涉及多张表的写入(UserModelUsageBalanceTransactionUsageTransaction),需要保证原子性。

当前实现

// 在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层代码不再包含计费相关逻辑(UserModelBalanceCalculatorBalanceTransaction 等)
  • ✅ 应用层调用模型服务时,只需传入业务参数,无需关心计费
  • ✅ 所有计费逻辑封装在模型服务内部,边界清晰
  • ✅ 新增API端点时,无需编写任何计费代码
  • ✅ 生产环境运行稳定,计费准确无误
  • ✅ 单元测试和集成测试覆盖率达到80%以上

风险评估

高风险:计费逻辑错误可能导致:

  • 用户被错误扣费(损失用户信任)
  • 用户被少扣费或不扣费(公司经济损失)
  • 余额验证不准确(误杀或漏过)

缓解措施

  • ✅ 充分的单元测试和集成测试
  • ✅ 双写验证:新旧逻辑并行运行,对比结果
  • ✅ Feature Flag:支持快速回滚
  • ✅ 详细的审计日志:记录所有计费决策和操作
  • ✅ 灰度发布:先在内部测试账号验证,再小范围用户,最后全量
  • ✅ 监控告警:计费异常、差异告警实时通知

预期收益

  1. 代码质量提升:职责分离,每个层次只做自己该做的事
  2. 可维护性增强:计费逻辑集中管理,修改只涉及一处
  3. 扩展性提升:新增API端点无需重复编写计费逻辑
  4. 性能优化:减少不必要的余额检查,降低数据库查询
  5. 开发效率提升:开发者只需关注业务逻辑,无需理解复杂的计费细节

参考资料

当前计费相关代码

  • ChatController.cs - 应用层计费流程示例
  • OpenAICompatibleController.cs - API层计费流程示例
  • InChatContext.cs - 计费逻辑集中地(第67-71行余额检查,第88-143行创建计费记录)
  • UserModelBalanceCalculator.cs - 余额计算器
  • BalanceService.cs - 余额更新服务

相关数据表

  • UserModelUsage - 模型使用记录
  • BalanceTransaction - 余额交易记录
  • UsageTransaction - 用量交易记录
  • UserBalance - 用户余额
  • UserModel - 用户模型权限和余额

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions