最近在折腾 SaaS 项目的计费体系时,我越来越强烈地感觉到:传统“按月订阅”的模式,正在被 AI 时代一种更细颗粒度的玩法冲击——按 token 付费。
这个想法源于一个很现实的场景:市面上的 AI 写作、AI 客服、AI 绘画工具,动不动就是每月几十上百美元。很多产品看起来功能很全,但真正用到的核心接口其实就那么一两个。于是我想,能不能用 AI 辅助,把最常用、最核心的 SaaS 能力复刻成一个轻量级 MVP,然后把计费模式从“按月订阅”改成“按 token 消耗”?
整个项目做下来,踩了不少坑,也把 token 计量、配额扣减、账本记录这套链路完整跑通了。这篇文章把完整思路、代码和排错经验整理出来,适合正在做 AI 应用、SaaS 计费或 API 网关的同学参考,哪怕你是新手,也可以照着一步步搭建。
需要先声明的是:本文所说的“克隆”,是指参考产品公开的功能设计,用 AI 辅助从零实现一个学习型 MVP,用来验证技术和计费逻辑,绝不是指破解商业软件、搬运版权代码或绕过付费限制。做技术复刻,必须守住合规底线。
1. 从“按月付费”到“按 token 付费”,SaaS 计费到底在变什么
1.1 传统订阅制的痛点
传统 SaaS 通常采用订阅式计费,也就是用户每月支付固定费用,换取一定范围内的功能使用权。这种模式的优点是收入稳定、用户心理预期清晰;但缺点也很明显:
- 低频用户觉得亏。一个月可能只调用几十次功能,却要承担完整月费。
- 高频用户又觉得不够。某些功能用量超过套餐上限后,还需要额外购买更高档套餐。
- 功能定价不精细。一个套餐内包含 10 个功能,用户只用其中 1 个,也要为另外 9 个买单。
- 对开发者来说,订阅制缺少“按实际消耗计费”的弹性,特别是当核心成本来自大模型 API 调用时,用户用得越多,你的成本越高,但订阅收入却是固定的。
1.2 为什么 token 成了新的计价单位
在 AI 应用中,token 是模型处理文本的最小单位。简单理解,50 个英文字母大约对应 10 到 15 个 token,中文字符则通常一个字对应 1 到 2 个 token。用户每调用一次 AI 接口,模型都会读取和生成一定数量的 token,这直接决定了你的 API 调用成本。
按 token 付费的逻辑,就是让用户为“实际消耗的模型资源”买单。这种方式最早出现在大模型 API 调用中,例如常见的 OpenAI、Claude、百度文心、通义千问等,都是按 token 或按字符计费。现在,越来越多的上层 AI SaaS 也开始采用这种模式,因为:
- 计费公平,用多少付多少。
- 容易和上游成本对齐,避免亏本运营。
- 用户门槛低,不用一次性付出高额月费。
- 适合 AI Agent、自动写作、批量处理等用量差距极大的场景。
1.3 本文要做的“轻量级 AI SaaS 克隆”
我不想做一个口水文章,只讨论概念没有实操。所以这篇博客的目标很明确:用 Spring Boot + AI 辅助开发,复刻一个最小可用的 AI 文案生成 SaaS,核心包含用户注册、API Key 签发、AI 接口调用、token 计量、余额扣减和账本查询。
先思考清楚“克隆”的边界:
- 功能边界:只做核心生成接口和计费体系,不做前端排版、团队协作、模板市场等外围功能。
- 代码边界:不复制任何商业项目源码,所有代码由 AI 辅助从零生成,我再做逻辑校验。
- 数据边界:演示数据全部使用模拟内容,AI 接口使用环境变量隔离,方便你替换成自己的模型服务。
这样既保证内容安全,也让你真正掌握一套可以落地到生产环境的架构方法。
2. 需求设计与技术选型:我们做一个什么样的 SaaS
2.1 以“AI 文案生成器”作为克隆对象
为了把问题讲清楚,我挑选一个非常有代表性的 SaaS 类型:AI 文案生成工具。这类工具通常包含:
- 用户注册登录。
- 选择文案类型,比如小红书种草文案、抖音标题、公众号开头。
- 输入主题或关键词。
- 调用大模型生成内容。
- 展示结果并扣费。
这个场景非常适合讲 token 计费,因为每次生成内容的长度差异很大,如果按条数收费或按套餐收费,成本和收入很容易错配。
2.2 功能模块拆分
为了方便 AI 编程和后续维护,系统按模块拆分如下:
| 模块 | 职责 |
|---|---|
| 用户模块 | 注册、登录、查看余额 |
| 密钥模块 | 签发和管理 API Key |
| 生成模块 | 根据参数调用大模型接口 |
| 计量模块 | 计算请求的 token 用量并扣费 |
| 账本模块 | 记录每笔收支明细 |
| 计费模块 | 支持预扣、实扣、返还三种状态 |
2.3 计费流程设计
按 token 付费不能等到模型返回后再扣费,这样容易出现余额不足但请求已经发出的情况。更合理的流程是“预扣 - 实扣 - 返还”:
- 用户发起请求时,根据预估模型消耗先冻结一笔费用。
- 调用大模型接口。
- 拿到实际 token 用量后,计算真实费用。
- 把预扣金额调整为实际金额,多冻结的部分自动返还给用户。
- 记录一笔完整的账本流水,同时更新用户余额。
这套流程在真实支付系统中很常见,用在这里可以避免“先执行后扣费”带来的资损风险。
2.4 数据库表设计
本项目只需要三张核心表,业务语义足够清晰。
-- 用户表 CREATE TABLE `user` ( `id` bigint NOT NULL AUTO_INCREMENT, `username` varchar(64) NOT NULL COMMENT '用户名', `password` varchar(128) NOT NULL COMMENT '加密后的密码', `balance_token` bigint NOT NULL DEFAULT '10000' COMMENT '账户剩余token数', `status` tinyint NOT NULL DEFAULT '1' COMMENT '1正常 0禁用', `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表'; -- API Key 表 CREATE TABLE `api_key` ( `id` bigint NOT NULL AUTO_INCREMENT, `user_id` bigint NOT NULL, `api_key` varchar(64) NOT NULL COMMENT '密钥', `status` tinyint NOT NULL DEFAULT '1' COMMENT '1启用 0停用', `expire_time` datetime DEFAULT NULL, `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_api_key` (`api_key`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='API Key表'; -- 用量账本表 CREATE TABLE `token_ledger` ( `id` bigint NOT NULL AUTO_INCREMENT, `user_id` bigint NOT NULL, `api_key` varchar(64) DEFAULT NULL, `biz_type` varchar(32) NOT NULL COMMENT 'RECHARGE充值 / CONSUME消费 / REFUND返还', `pre_token` bigint DEFAULT '0' COMMENT '预扣量', `actual_token` bigint DEFAULT '0' COMMENT '实际消耗量', `refund_token` bigint DEFAULT '0' COMMENT '返还量', `balance_after` bigint NOT NULL COMMENT '操作后余额', `remark` varchar(255) DEFAULT NULL COMMENT '备注', `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_user_id` (`user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='token账本表';字段设计上有一个容易被忽略的点:balance_after一定要记录操作后的余额,而不是只记录变化量。后续对账时,只需要按照时间顺序回放账本,就能反推每个时点的余额,排查问题会容易很多。
3. 环境准备与项目结构
3.1 环境说明
本文示例以常见 Java 技术栈为例,版本信息如下:
- JDK 17
- Spring Boot 3.x
- MySQL 8.x
- Maven 3.8+
- IDE:IntelliJ IDEA 或 VS Code
如果你的本地环境版本不同,不需要焦虑,重点理解配置和代码思路。Spring Boot 3.x 要求 JDK 17 及以上,如果你的环境是 JDK 8,建议先升级,或者把项目降级为 Spring Boot 2.7.x。
3.2 项目结构
为了便于 AI 辅助生成代码,我提前把项目结构定义得非常清晰:
ai-saas-token-demo ├── pom.xml └── src/main/java/com/example/aisaas ├── AisaasApplication.java ├── controller │ ├── AuthController.java │ └── GenerateController.java ├── service │ ├── UserService.java │ ├── ApiKeyService.java │ ├── AiProxyService.java │ └── TokenBillingService.java ├── filter │ └── ApiKeyAuthFilter.java ├── entity │ ├── User.java │ ├── ApiKey.java │ └── TokenLedger.java └── mapper ├── UserMapper.java ├── ApiKeyMapper.java └── TokenLedgerMapper.java先定义好结构,再让 AI 按图索骥生成代码,生成的代码完成度和一致性会高很多。这也是使用 AI 辅助开发的一个重要技巧:不要让 AI 自由发挥整体架构,而是由人先搭好骨架,AI 负责填充肌肉和血管。
4. 用 AI 辅助开发:从生成代码到人工把关
4.1 如何向 AI 描述需求
很多同学用 AI 写代码时,描述过于笼统,比如“帮我写一个用户的 Controller”,这样生成的代码通常是华而不实的“玩具代码”。正确做法是给出明确的接口签名、入参出参、异常规则。
我使用的提示词示例:
请生成一个 Spring Boot 3 的 Controller 类,路径是 /api/generate,方法为 POST /api/generate/text。 入参:{"topic": "夏季护肤", "style": "小红书"} 出参:{"content": "生成结果", "promptTokens": 128, "completionTokens": 256, "totalTokens": 384} 要求: 1. 调用 TokenBillingService 先校验余额并预扣 token; 2. 调用 AiProxyService 获取生成结果; 3. 根据真实 token 用量更新账本; 4. 如果余额不足,返回 402 和错误码 TOKEN_NOT_ENOUGH。这样生成的代码,无论是健壮性还是业务匹配度,都远比一句“写一个生成接口”要好。
4.2 人工必须检查的三个点
AI 生成代码速度很快,但绝不能直接合入项目。基于这次经验,我建议至少检查三个地方:
- 密码是否加密存储。AI 生成的示例代码经常用明文密码,这在真实项目里是绝对不能接受的。
- 事务边界是否完整。预扣、调用、实扣这三步必须在一个事务中控制,或者通过补偿机制保证最终一致。
- 外部 API 地址和密钥是否从配置读取。AI 经常把
sk-xxx写死在代码里,这种问题在生产环境是致命的。
下面我们逐块看核心代码实现。
5. 核心代码实现:AI 接口代理与 token 计量扣费
5.1 AI 接口代理层
为了便于本地测试,我没有直接请求外部大模型,而是做了一层可替换的代理。如果设置了ai.provider.base-url和ai.provider.api-key,就走真实接口;否则走模拟返回,方便在没有网络模型的环境下跑通整个计费链路。
package com.example.aisaas.service; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.web.client.RestTemplate; @Service public class AiProxyService { @Value("${ai.provider.base-url:}") private String baseUrl; @Value("${ai.provider.api-key:}") private String apiKey; @Value("${ai.provider.model:demo-model}") private String model; private final RestTemplate restTemplate = new RestTemplate(); /** * 调用大模型生成文本。如果未配置 base-url,则返回模拟结果。 */ public AiResult generate(String prompt) { if (baseUrl == null || baseUrl.isBlank()) { return mockResult(prompt); } // 构造请求体,这里按 OpenAI 兼容协议举例 String url = baseUrl + "/chat/completions"; String body = """ { "model": "%s", "messages": [{"role": "user", "content": "%s"}] } """.formatted(model, prompt); // 实际项目中不要手工拼 JSON,建议使用 HashMap + ObjectMapper // 这里只演示流程 String response = restTemplate.postForObject(url, body, String.class); // 解析 response,提取 content 和 usage 字段 // 省略解析代码,按实际模型返回格式调整 return mockResult(prompt); } private AiResult mockResult(String prompt) { String content = "AI生成结果:" + prompt + ",这是一段模拟生成内容。"; int promptTokens = prompt.length(); int completionTokens = content.length(); return new AiResult(content, promptTokens, completionTokens, promptTokens + completionTokens); } public record AiResult(String content, int promptTokens, int completionTokens, int totalTokens) { } }这里有一个重要提醒:真实项目调用大模型时,不要手工拼 JSON 字符串。因为提示词内容里可能包含引号、换行、特殊字符,手工拼串轻则 JSON 解析失败,重则引发注入问题。正确做法是使用ObjectMapper构造Map,或者使用模型官方 SDK。上面的代码只是演示流程,实际接入时请用官方 SDK 代替RestTemplate。
5.2 token 计量与计费服务
这一步是整个系统的核心。我在代码中实现了“预扣 - 实扣 - 返还”三个动作,并且把每一步都记录到token_ledger账本中。
package com.example.aisaas.service; import com.example.aisaas.entity.TokenLedger; import com.example.aisaas.entity.User; import com.example.aisaas.mapper.TokenLedgerMapper; import com.example.aisaas.mapper.UserMapper; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; @Service public class TokenBillingService { private final UserMapper userMapper; private final TokenLedgerMapper ledgerMapper; public TokenBillingService(UserMapper userMapper, TokenLedgerMapper ledgerMapper) { this.userMapper = userMapper; this.ledgerMapper = ledgerMapper; } /** * 预扣 token,返回预扣后的用户对象。 */ @Transactional public User preDeduct(Long userId, int preToken) { User user = userMapper.selectById(userId); if (user == null || user.getStatus() != 1) { throw new RuntimeException("用户不存在或已被禁用"); } if (user.getBalanceToken() < preToken) { throw new RuntimeException("余额不足,当前余额=" + user.getBalanceToken() + ",需要=" + preToken); } user.setBalanceToken(user.getBalanceToken() - preToken); userMapper.updateById(user); TokenLedger ledger = new TokenLedger(); ledger.setUserId(userId); ledger.setBizType("CONSUME"); ledger.setPreToken(preToken); ledger.setBalanceAfter(user.getBalanceToken()); ledger.setRemark("预扣"); ledger.setCreatedAt(LocalDateTime.now()); ledgerMapper.insert(ledger); return user; } /** * 实扣:把预扣数调整为实际消耗数,并返还差额。 */ @Transactional public void settle(Long userId, int preToken, int actualToken) { if (actualToken > preToken) { // 实际消耗大于预扣,说明预扣少了,需要补扣 User user = userMapper.selectById(userId); int extra = actualToken - preToken; if (user.getBalanceToken() < extra) { throw new RuntimeException("实际扣费后余额不足,需要补扣=" + extra); } user.setBalanceToken(user.getBalanceToken() - extra); userMapper.updateById(user); TokenLedger ledger = new TokenLedger(); ledger.setUserId(userId); ledger.setBizType("CONSUME"); ledger.setPreToken(preToken); ledger.setActualToken(actualToken); ledger.setBalanceAfter(user.getBalanceToken()); ledger.setRemark("实扣补差额"); ledgerMapper.insert(ledger); } else if (actualToken < preToken) { // 实际消耗小于预扣,返还差额 User user = userMapper.selectById(userId); int refund = preToken - actualToken; user.setBalanceToken(user.getBalanceToken() + refund); userMapper.updateById(user); TokenLedger ledger = new TokenLedger(); ledger.setUserId(userId); ledger.setBizType("REFUND"); ledger.setPreToken(preToken); ledger.setActualToken(actualToken); ledger.setRefundToken(refund); ledger.setBalanceAfter(user.getBalanceToken()); ledger.setRemark("返还预扣差额"); ledgerMapper.insert(ledger); } } }这里有个容易被忽略的并发问题:如果用selectById查出用户余额,再在内存里做减法,最后updateById整体更新,在高并发下会出现超扣。更好的做法是在updateSQL 里使用原子更新:
UPDATE `user` SET balance_token = balance_token - #{preToken} WHERE id = #{userId} AND balance_token >= #{preToken}通过数据库层面的条件更新来防止并发超扣。上面 Java 代码为了展示完整流程,简化了这个细节,生产环境一定要补上这个限制。
5.3 生成接口 Controller
Controller 层负责把用户请求、AI 调用、计费结算串起来。
package com.example.aisaas.controller; import com.example.aisaas.entity.User; import com.example.aisaas.service.AiProxyService; import com.example.aisaas.service.TokenBillingService; import org.springframework.web.bind.annotation.*; import java.util.Map; @RestController @RequestMapping("/api/generate") public class GenerateController { private final TokenBillingService billingService; private final AiProxyService aiProxyService; public GenerateController(TokenBillingService billingService, AiProxyService aiProxyService) { this.billingService = billingService; this.aiProxyService = aiProxyService; } @PostMapping("/text") public Map<String, Object> generateText(@RequestAttribute("userId") Long userId, @RequestBody Map<String, String> request) { String topic = request.get("topic"); String style = request.get("style"); // 通过请求头 x-api-key 找到 userId // 实际逻辑在 ApiKeyAuthFilter 中完成 String prompt = "请以" + style + "风格,围绕“" + topic + "”生成一段文案。"; // 1. 估算 token,保守预扣 int preToken = prompt.length() * 2 + 200; billingService.preDeduct(userId, preToken); try { // 2. 调用 AI 接口 AiProxyService.AiResult result = aiProxyService.generate(prompt); // 3. 实扣结算 billingService.settle(userId, preToken, result.totalTokens()); return Map.of( "content", result.content(), "promptTokens", result.promptTokens(), "completionTokens", result.completionTokens(), "totalTokens", result.totalTokens() ); } catch (Exception e) { // 调用失败,返还全部预扣 billingService.settle(userId, preToken, 0); throw new RuntimeException("AI 生成失败", e); } } }这里有一个值得注意的失败场景:如果 AI 接口超时或者返回异常,已经预扣的 token 必须返还。所以我用settle(userId, preToken, 0)把 actualToken 传为 0,这样在结算逻辑里会走“实际消耗小于预扣”的分支,把预扣全额退回。
5.4 API Key 鉴权过滤器
既然是 SaaS,用户不应该是通过浏览器表单每次登录,而是使用 API Key 调用接口。这里我用一个过滤器统一处理:
package com.example.aisaas.filter; import com.example.aisaas.mapper.ApiKeyMapper; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; @Component public class ApiKeyAuthFilter extends OncePerRequestFilter { private final ApiKeyMapper apiKeyMapper; public ApiKeyAuthFilter(ApiKeyMapper apiKeyMapper) { this.apiKeyMapper = apiKeyMapper; } @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String path = request.getRequestURI(); if (path.startsWith("/api/auth")) { filterChain.doFilter(request, response); return; } String apiKey = request.getHeader("x-api-key"); if (apiKey == null || apiKey.isBlank()) { response.setStatus(401); response.getWriter().write("missing api key"); return; } var keyEntity = apiKeyMapper.selectByApiKey(apiKey); if (keyEntity == null || keyEntity.getStatus() != 1) { response.setStatus(401); response.getWriter().write("invalid api key"); return; } request.setAttribute("userId", keyEntity.getUserId()); filterChain.doFilter(request, response); } }API Key 的生成使用UUID.randomUUID().toString().replace("-", "")即可,不需要使用用户密码拼接。API Key 一旦泄露,不需要改密码,只需要在数据库里把对应的 key 状态置为 0,就能做到秒级失效。
6. 计费配置与部署运行
6.1 application.yml 配置
项目的核心配置如下:
server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/ai_saas?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: yourpassword driver-class-name: com.mysql.cj.jdbc.Driver mybatis-plus: configuration: map-underscore-to-camel-case: true ai: provider: base-url: "" api-key: "" model: "deepseek-chat"注意base-url和api-key默认留空,此时系统会走模拟生成,方便本地跑通计费链路。当你准备好真实模型服务时,再在环境变量或配置中心注入密钥即可。
6.2 快速运行与验证
首先初始化数据库,执行第 2 节的建表 SQL。然后启动应用,执行以下命令验证:
- 注册用户。
curl -X POST http://localhost:8080/api/auth/register \ -H "Content-Type: application/json" \ -d '{"username": "zhangsan", "password": "123456"}'- 登录并获取 API Key。
curl -X POST http://localhost:8080/api/auth/login \ -H "Content-Type: application/json" \ -d '{"username": "zhangsan", "password": "123456"}'登录接口返回结果中会包含apiKey:
{ "apiKey": "a1b2c3d4e5f6...", "balanceToken": 10000 }- 调用生成接口。
curl -X POST http://localhost:8080/api/generate/text \ -H "Content-Type: application/json" \ -H "x-api-key: a1b2c3d4e5f6..." \ -d '{"topic": "夏季护肤", "style": "小红书"}'预期返回:
{ "content": "AI生成结果:请以小红书风格,围绕“夏季护肤”生成一段文案。这是一段模拟生成内容。", "promptTokens": 23, "completionTokens": 46, "totalTokens": 69 }- 查看账本。
SELECT * FROM token_ledger WHERE user_id = 1 ORDER BY id;可以看到两条记录:一条预扣记录,一条返还记录。通过账本可以完整回溯这次调用的 token 变化。
7. 常见问题与排查思路
我在开发调试过程中遇到不少问题,这里整理成一张表,并挑几个典型问题详细展开。
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| 接口返回 401 Invalid API Key | API Key 拼写错误或已被停用 | 检查请求头参数名,数据库确认 key 状态 |
| 调用生成接口余额不变 | 模拟模式下 token 数量太小,或返还没被看到 | 查看账本表,确认预扣和返还两条流水 |
| 并发下余额被扣成负数 | 没有使用原子更新,出现超扣 | 改用UPDATE ... WHERE balance_token >= #{preToken} |
| AI 接口 403 | 模型服务方鉴权失败或区域受限 | 核对密钥、账号区域是否受服务商支持,查看服务商官方文档 |
| 实际 token 和预估差异大 | 模型版本更新或提示词变化 | 预扣时多预留 20% 到 50% 的 buffer |
| 用户投诉扣费偏高 | 返回内容包含隐藏 token | 记录 promptTokens、completionTokens 拆分明细,便于解释 |
7.1 关于 token exchange failed 报错
最近不少同学在使用第三方 AI 服务或 OAuth 登录时,遇到类似token exchange failed: token endpoint returned status 403的报错。这里要注意,这个报错里的 token 指的不是我们计费里的 token 消耗,而是 OAuth 授权流程中的“访问令牌交换”。二者概念完全不同。
常见的403原因包括:
- 客户端 ID 或密钥不匹配。
- 回调地址与配置不一致。
- 账号所在区域不在服务商支持范围内。
- 授权码已经过期或重复使用。
排查顺序建议是:先看服务端日志,确认是哪一步返回 403;再核对密钥和回调地址;最后检查账号区域与服务商支持列表。对于区域限制,正确做法是遵循服务商的服务条款,选择官方支持的合法使用方式,而不是尝试绕过限制。
7.2 计费不准确的排查思路
如果发现用户消耗的 token 和自己后台统计的不一致,可以从以下三个方向排查:
- 是否把请求和响应两部分的 token 都统计进去了。很多模型接口会返回
prompt_tokens和completion_tokens,需要加起来。 - 是否把重试请求重复计费了。如果 AI 接口超时后框架自动重试,每次重试都可能产生费用。
- 是否把流式输出遗漏了。使用 SSE 流式接口时,如果只在接收完才统计一次,某些模型需要自行累加每个分片的 token。
8. 最佳实践与工程建议
8.1 合规边界不能碰
再次强调:做 SaaS 克隆,只允许参考功能设计、数据结构、交互流程这些产品层面的公开信息,不允许复制源码、破解验证、绕过付费。“克隆”的目的是学习和验证,而不是做一个 1:1 的商业替代品。你在自己的项目中使用大模型生成代码时,也要注意输入给模型的提示词不能包含他人版权代码。
8.2 预扣比例要动态调整
预扣 token 不能拍脑袋写死。如果有历史调用数据,可以根据提示词长度、模型型号、历史平均消耗来设置预扣比例。我建议:
- 第一次调用没有历史数据时,预扣 = 预估 token × 2。
- 积累 100 条调用记录后,根据 P95 消耗值动态调整。
- 每次调用前先按照最新预扣策略试算,避免对低频用户造成资金占用。
8.3 对账机制不能省
无论 SaaS 做得再简单,对账机制一定要有。我在生产环境里的习惯是:
- 每个用户每天生成一张 token 流水汇总。
- 每日凌晨和上游模型账单对账。
- 如果发现账本余额和真实消耗偏差超过阈值,立即冻结计费任务并告警。
具体到代码层面,可以在token_ledger表增加一个biz_id字段,用于关联每次请求的唯一 ID,便于对账时定位每笔流水的产生原因。
8.4 防止刷单和滥用
按 token 计费的最大风险不是用户用得多,而是恶意用户绕过认证刷接口。建议从几个维度加防护:
- 每个 API Key 增加速率限制,例如每秒不超过 2 次请求。
- 每个用户设置单日最大消耗额度。
- 对连续失败请求做熔断,避免 AI 接口异常时不断扣费。
- 生产环境必须接入日志链路追踪,每次请求带上 requestId。
8.5 生产环境使用配置中心管理模型密钥
不建议把模型密钥直接写在application.yml里,更不要提交到 Git。生产环境应该通过环境变量或配置中心注入,并且定期轮换密钥。如果你在团队里开发,还要遵循最小权限原则:只有负责模型网关的同事有密钥管理权限,其他开发人员使用代理服务,接触不到真实密钥。
8.6 从 MVP 到生产还需要补齐什么
这个 Demo 的核心链路已经完整,但如果要真正上线,还需要补齐以下能力:
- 用户注册的验证码、密码找回。
- 支付宝/微信支付充值,或者对接已有的支付 SaaS。
- 用量看板和告警系统。
- 模型多租户隔离。
- 账单导出功能。
- 多模型路由,按价格和效果自动选择模型。
其中支付对接这块,小程序和 Web 端的坑不太一样,特别是回调验签、退款原路退回、幂等处理这三块,建议单独做一期总结。这里不展开,但你要知道,计费系统上线前,支付回调的幂等是必须测试的。
9. 写在最后的实在建议
这个项目给我最大的收获,并不是“用 AI 写代码有多快”,而是“AI 辅助开发 + 清晰架构 + 计量思维”的组合,确实能大幅降低从想法到 MVP 的成本。
如果你也准备做一个 AI 类型的 SaaS,我的建议很简单:先别急着画大而全的架构图,找一个最常用的功能,比如文案生成、翻译、改写、摘要,把它和 token 计量、用户余额、账本流水这条链路跑通。跑通之后,你对“按 token 付费”的理解会比读任何文章都深刻。
收费模式这件事,本质上是把成本和价值对齐。订阅制适合标准化服务,token 计费适合成本波动大的 AI 场景。如果你的产品同时包含批量处理和低频使用,也可以考虑混合计费:基础功能走订阅,深度 AI 能力按 token 消耗。
最后留一个作业给你:把上面的 Demo 跑起来之后,试着给token_ledger增加一个每日汇总统计接口,统计每个用户当天消耗的 token 总量和预估费用。这个功能虽然不复杂,但做完之后,你就掌握了 SaaS 计费系统最核心的那根链条。