公司动态

用Spring AI搭建AI应用收银台:模型调用与Credits计量实战

📅 2026/8/30 12:19:17
用Spring AI搭建AI应用收银台:模型调用与Credits计量实战
大厂 AI 解锁新“收银台”这个说法最近在 AI 工程圈被反复提到。它真正指向的不是线下支付设备而是 AI 应用从模型调用、用量计量、配额扣费到智能体业务闭环的一整套后端底座。对普通开发者来说与其关心某家大厂的商业策略不如把“收银台”理解成一套可以复用的 AI 工程能力模型接入、Credits 计量、Agent 编排和运维观察。这篇文章会做一件很具体的事用 Spring AI 从零搭一个最小可运行的 AI 应用底座跑通模型对话、Credits 扣费和简单 Agent 路由再讨论生产环境必须补齐的日志、并发、幂等和安全问题。整个过程不绑定特定大模型思路可以迁移到任何提供 OpenAI 兼容接口的模型服务上。1. 先拆解大厂 AI 的“收银台”到底由什么构成1.1 “收银台”是一种比喻本质是 AI 服务的计费与交付闭环传统软件的收入模型比较直观用户购买许可证、订阅套餐或者为某个具体功能付费。AI 应用不太一样调用一次模型需要消耗算力回答质量依赖模型参数规模成本随 token 数、上下文长度和工具调用次数波动。如果只是简单地在数据库里存一个“剩余调用次数”很难覆盖不同模型的成本差异。大厂把 AI 能力封装成产品时需要一个能把“模型消耗”翻译成“用户可理解的配额”的系统。这个系统承担的角色就是“收银台”用户发起请求系统统计消耗换算成额度扣减余额记录流水。它不关心模型内部如何生成 token只关心每一次业务请求花了多少资源、应该向哪个账户收多少费用。理解这一点之后就会发现“收银台”不是某个单独接口而是一条数据链路。从用户请求进入网关开始到模型返回结果、用量解析、额度扣减、流水落库最后到账单和对账每一环都缺一不可。1.2 四个核心模块模型网关、用量计量、业务结算、服务治理一个完整的 AI 收银台可以拆成四个模块每个模块对应一类工程问题。模型网关负责统一管理模型端点、API Key、超时、重试和流式响应。它屏蔽了不同模型提供方的差异业务代码只需要面向一个抽象接口编程底层模型可以随时切换。用量计量负责在模型调用完成后把返回的 usage 信息解析成可计算的数值。这里的关键是 prompt_tokens、completion_tokens 这类原始 token 数据以及模型单价、换算比例等配置。业务结算负责把 token 用量换算成 Credits执行余额预检、扣减、流水写入和幂等控制。它解决的是“这笔费用到底扣没扣、扣多少、会不会重复扣”的问题。服务治理负责承载上层的稳定性需求包括限流、熔断、审计日志、内容安全过滤和调用链路追踪。没有这一层前面的计量和结算再精确也没法在线上长期运行。四个模块组合在一起才能支撑一次完整的 AI 调用闭环。1.3 为什么 Credits 成了 AI 平台的通用语言热词“credits 在 ai 里指什么”背后其实是平台定价策略问题。不同模型对 token 的定义可能一致但单价、上下文窗口、响应速度差别很大。如果直接让用户按 token 付费用户需要理解每一个模型的技术参数这对非技术用户并不友好。Credits 是平台在模型成本之上抽象出的统一配额单位。一个用户账户里有多少 Credits就代表它能消耗多少模型资源。平台可以按模型单价、调用时长、工具执行复杂度等维度动态计算 Credits再在后台维护一张模型成本映射表。换算关系通常是这样的某次调用的 Credits 模型单价系数 × token 消耗量。不同模型的 token 和 Credits 兑换比例可以不同但用户只感知 Credits 这一个数字。对平台来说Credits 还方便做营销赠送、套餐包、阶梯定价和风控比赤裸裸展示 token 数更灵活。这篇文章后面的示例会默认 Credits 为整数类型用“预扣 实扣”的方式避免超卖和重复扣费。2. 环境准备与依赖选择先让模型调用变得可观察2.1 开发环境与运行环境要求在开始写代码前先确认环境。这里以 Java 技术栈为例因为 Spring AI 在 Spring Boot 项目里集成成本最低也比较容易扩展到现有业务系统。依赖项推荐配置说明JDK17 及以上Spring Boot 3.x 要求 JDK 17Spring Boot3.xSpring AI 官方适配版本Spring AI按官方稳定版starter 命名和 API 随版本变化模型服务OpenAI 兼容接口本地部署模型或云端模型均可数据库MySQL 或 PostgreSQL额度表和流水表需要事务支持缓存Redis可选用于限流和热点账户版本方面需要特别说明Spring AI 的 starter 命名在不同版本中调整过落地前一定要以当时代理仓库里的实际依赖为准。下面的示例使用 OpenAI 兼容方式接入模型因为大量本地部署框架和服务商都提供兼容接口替换成本低。2.2 引入 Spring AI 依赖并配置模型端点先创建一个普通 Spring Boot 项目然后在 pom.xml 里加入 Spring AI 依赖。这里只写核心依赖实际项目还需要引入 web、数据库、连接池等模块。parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version3.3.0/version relativePath/ /parent properties java.version17/java.version spring-ai.version1.0.0/spring-ai.version /properties dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-starter-model-openai/artifactId version${spring-ai.version}/version /dependency /dependenciesspring-ai.version 需要确认仓库中实际存在。新版 Spring AI 已经开始使用 BOM 管理依赖写法可以调整但核心思路不变。如果模型服务不是 OpenAI 官方而是本地部署的兼容服务配置 base-url 指向本地地址即可。application.yml 配置模型端点、模型名和自定义参数spring: ai: openai: base-url: ${AI_BASE_URL:http://localhost:8000/v1} api-key: ${AI_API_KEY:sk-local} chat: options: model: ${AI_MODEL:local-model} temperature: 0.7 max-tokens: 1024 server: port: 8088这里的 base-url 指向一个提供 OpenAI 兼容接口的本地模型服务。api-key 在本地环境可以使用占位值生产环境必须通过环境变量或密钥管理服务注入不能写死在配置文件里。2.3 用最小代码跑通一次模型对话引入依赖并配置完成后下一步是写一个最简的模型调用服务。Spring AI 提供了 ChatModel 作为统一入口通过注入调用即可。package com.example.aipay.service; import org.springframework.ai.chat.ChatResponse; import org.springframework.ai.chat.prompt.Prompt; import org.springframework.ai.chat.model.ChatModel; import org.springframework.stereotype.Service; Service public class ChatService { private final ChatModel chatModel; public ChatService(ChatModel chatModel) { this.chatModel chatModel; } public String chat(String message) { ChatResponse response chatModel.call(new Prompt(message)); return response.getResult().getOutput().getText(); } }这里演示的是同步调用。模型返回后ChatResponse 中除了文本结果还包含 usage 元数据里面有 promptTokens、completionTokens 和 totalTokens。这正是 Credits 计量要用的核心数据。3. 实现 Credits 计量把 Token 用量变成可扣费数字3.1 数据模型设计账户额度表、模型单价表、流水表计量扣费需要至少三张表。账户额度表保存用户当前余额模型单价表保存每个模型的换算系数流水表记录每一次额度变更。流水表必须能通过业务幂等键去重。CREATE TABLE account_credit ( account_id BIGINT PRIMARY KEY, balance_credits BIGINT NOT NULL DEFAULT 0, version INT NOT NULL DEFAULT 0, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ); CREATE TABLE model_rate ( model_name VARCHAR(64) PRIMARY KEY, prompt_unit_price DECIMAL(12,8) NOT NULL COMMENT 每千 token 对应的 credits, completion_unit_price DECIMAL(12,8) NOT NULL, enabled TINYINT NOT NULL DEFAULT 1 ); CREATE TABLE credit_ledger ( id BIGINT AUTO_INCREMENT PRIMARY KEY, account_id BIGINT NOT NULL, change_type VARCHAR(32) NOT NULL COMMENT RECHARGE / DEDUCT / REFUND, change_amount BIGINT NOT NULL COMMENT 正数充值负数扣费, balance_after BIGINT NOT NULL, biz_id VARCHAR(64) NOT NULL COMMENT 业务幂等键, model_name VARCHAR(64), prompt_tokens BIGINT, completion_tokens BIGINT, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_biz_id (biz_id) );biz_id 是关键。它通常由用户 ID、请求 ID、模型名和调用时间拼接而成保证同一笔业务不会重复扣费。model_rate 表里面的单价不是真实货币价格而是“每千 token 消耗多少 Credits”方便平台层做统一计价。3.2 在模型调用链路里埋点计量逻辑不应该散落在业务代码里。比较推荐的方式是做一个 ModelCallService 包装类统一封装模型调用、用量解析和计量逻辑。业务方只调用这个包装类不直接操作 ChatModel。package com.example.aipay.service; import com.example.aipay.entity.ModelRate; import com.example.aipay.repository.ModelRateRepository; import org.springframework.ai.chat.ChatResponse; import org.springframework.ai.chat.prompt.Prompt; import org.springframework.ai.chat.model.ChatModel; import org.springframework.stereotype.Service; Service public class ModelCallService { private final ChatModel chatModel; private final ModelRateRepository rateRepository; private final CreditService creditService; public ModelCallService(ChatModel chatModel, ModelRateRepository rateRepository, CreditService creditService) { this.chatModel chatModel; this.rateRepository rateRepository; this.creditService creditService; } public String call(Long accountId, String bizId, String message) { ChatResponse response chatModel.call(new Prompt(message)); Long promptTokens response.getMetadata().getUsage().getPromptTokens(); Long completionTokens response.getMetadata().getUsage().getCompletionTokens(); String modelName resolveModelName(); ModelRate rate rateRepository.findByModelName(modelName); long costCredits calculateCredits(rate, promptTokens, completionTokens); creditService.deduct(accountId, costCredits, bizId, modelName, promptTokens, completionTokens); return response.getResult().getOutput().getText(); } private long calculateCredits(ModelRate rate, Long promptTokens, Long completionTokens) { long promptCredits Math.round(promptTokens / 1000.0 * rate.getPromptUnitPrice()); long completionCredits Math.round(completionTokens / 1000.0 * rate.getCompletionUnitPrice()); return promptCredits completionCredits; } private String resolveModelName() { return local-model; } }这里需要说明几个细节。usage 在部分模型提供方可能为空所以解析前要做空值判断否则计量 NPE 会导致整个请求失败。calculateCredits 方法里使用每千 token 为单位避免小数和浮点误差堆积。生产环境建议用 BigDecimal 而不是 double 计算金额示例里为了简洁用了 long实际项目要结合自身的精度要求调整。3.3 扣费与并发安全扣费是整个收银台最敏感的操作。最容易出现的问题是并发下余额判断与扣减不一致两个请求同时读到余额剩余 100都判断可以扣费结果一共扣了 120余额变成负数。解决方式是用一条带条件的 UPDATE 语句原子扣减让数据库来判断余额是否充足UPDATE account_credit SET balance_credits balance_credits - #{amount}, version version 1 WHERE account_id #{accountId} AND balance_credits #{amount};这条语句影响行数为 0 时说明余额不足。Java 层的 CreditService 再配合事务和幂等检查写入流水表。package com.example.aipay.service; import com.example.aipay.repository.AccountCreditRepository; import com.example.aipay.repository.CreditLedgerRepository; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; Service public class CreditService { private final AccountCreditRepository accountCreditRepository; private final CreditLedgerRepository ledgerRepository; public CreditService(AccountCreditRepository accountCreditRepository, CreditLedgerRepository ledgerRepository) { this.accountCreditRepository accountCreditRepository; this.ledgerRepository ledgerRepository; } Transactional public void deduct(Long accountId, long amount, String bizId, String modelName, Long promptTokens, Long completionTokens) { if (ledgerRepository.existsByBizId(bizId)) { return; } int updated accountCreditRepository.deductIfEnough(accountId, amount); if (updated 0) { throw new InsufficientCreditException(accountId); } AccountCredit account accountCreditRepository.findById(accountId).orElseThrow(); ledgerRepository.insert( accountId, DEDUCT, -amount, account.getBalance(), bizId, modelName, promptTokens, completionTokens ); } }这里有两个核心原则。模型调用本身不能放在扣费事务里否则模型超时会让数据库事务长时间占用连接。扣费前先检查 bizId 是否已存在流水是幂等控制避免网络重试带来的重复扣费。如果对分布式一致性要求更高可以把流水表插入和额度扣减放在本地事务中并通过消息表把变更同步到下游账务系统。4. 用 Agent 编排让“收银台”承接多步骤任务4.1 什么场景需要 Agent从单次问答到多工具协作单次问答只需要调用一次模型收银台只要统计一次 token 消耗即可。但实际 AI 应用往往不是一次调用就能结束。用户可能先输入一个意图系统需要判断该调用研究类模型还是生成类模型生成结果前可能需要查询数据库生成后还可能做摘要、翻译、内容审核等多步处理。Agent 的核心价值是把多步模型调用、工具调用和业务逻辑编排成一个有状态的任务。每一次子调用都会产生 token 消耗收银台需要把整个任务的所有消耗汇总向用户账户扣一次费。如果任务中途失败还要考虑部分 Credits 是否退回。这里说的是技术编排思路不涉及具体 Agent 框架的商业包装。任何用代码循环控制模型调用、工具返回和终止条件的系统本质上都是 Agent。4.2 一个最小 Agent 示例意图识别加模型路由用一段最简代码说明 Agent 编排如何与 Credits 计量结合。这里不引入复杂框架直接通过 ChatModel 完成意图识别再路由到不同模型。package com.example.aipay.service; import org.springframework.ai.chat.ChatResponse; import org.springframework.ai.chat.prompt.Prompt; import org.springframework.ai.chat.model.ChatModel; import org.springframework.stereotype.Service; Service public class RoutingAgentService { private final ChatModel intentModel; private final ChatModel researchModel; private final ChatModel generationModel; public RoutingAgentService(ChatModel intentModel, ChatModel researchModel, ChatModel generationModel) { this.intentModel intentModel; this.researchModel researchModel; this.generationModel generationModel; } public String run(Long accountId, String sessionId, String userInput) { String intent classifyIntent(userInput); String bizId sessionId : intent; if (RESEARCH.equals(intent)) { return modelCallService.call(accountId, bizId, researchPrompt(userInput)); } else { return modelCallService.call(accountId, bizId, generationPrompt(userInput)); } } private String classifyIntent(String userInput) { String prompt 你是意图分类器。只输出 RESEARCH 或 GENERATION不要输出其他内容。用户输入 userInput; ChatResponse response intentModel.call(new Prompt(prompt)); return response.getResult().getOutput().getText().trim(); } }实际项目中classifyIntent 的 prompt 会加入 few-shot 示例并对输出做白名单校验。intent 判断失败时要设置默认路由不能让 Agent 因为一次分类异常直接崩溃。bizId 把 sessionId 和意图拼接起来保证同一个会话内同一类任务不会被重复扣费。4.3 多模型路由下 Credits 怎么算多模型路由带来的计量难题是单价不一致。前面示例里的 resolveModelName 方法在实际项目中必须从配置或模型路由表中读取当前实际使用的模型。模型名不能从用户输入里拼接出来否则会带来模型注入风险。计算规则可以统一成每次模型调用的 Credits promptToken 数 × prompt 单价系数 completionToken 数 × completion 单价系数。不同模型只改 model_rate 表中的单价业务代码不需要变。这样收银台的价格策略就实现了“配置与代码分离”调价时只需更新数据库或配置中心。5. 部署、观察与生产化收银台要能对账5.1 学习环境与生产环境的差异本地跑通示例只是第一步。生产环境的 AI 收银台必须考虑模型服务稳定性、账户安全、数据一致性和可观测性。以下表格对两类环境做了对比。维度学习环境生产环境模型服务本地单机或临时 API独立部署多副本或云服务需要监控配置管理application.yml 写死环境变量、配置中心、密钥管理数据库H2 或本地 SQLiteMySQL/PostgreSQL 主从定期备份扣费一致性单事务即可幂等、乐观锁、流水对账、分布式事务方案日志控制台输出结构化日志、TraceId、全链路追踪安全本地测试 Key网关鉴权、IP 白名单、内容安全过滤、审计限流基本不设置应用层限流、网关层限流、模型侧配额管理初学者容易犯的错误是直接把本地配置原样带到生产环境。生产环境的模型服务地址、API Key、数据库账号等敏感信息必须通过环境变量或配置中心注入不能提交到代码仓库。5.2 日志、监控与限流日志是排查“扣费扣错了”的第一手资料。建议每笔模型调用都输出一条结构化日志包含 TraceId、accountId、bizId、modelName、promptTokens、completionTokens、costCredits、耗时和返回状态。{ traceId: 0ad1348f9c6e4a, accountId: 10001, bizId: session-123:RESEARCH, modelName: local-model, promptTokens: 320, completionTokens: 180, costCredits: 12, elapsedMs: 860, status: SUCCESS }监控指标至少包含四类模型调用 QPS、平均响应时间、错误率、Credits 扣费总量。当错误率上升或扣费量异常增加时可以快速定位是模型服务故障、业务攻击还是配置错误。限流可以在网关层按账户维度设置防止单个用户高频调用导致额度被刷。5.3 安全边界输入校验、内容过滤与审计AI 应用对外提供服务后输入和输出都可能包含风险内容。收银台所在的位置决定了它最容易成为攻击目标攻击者可以通过伪造请求绕过扣费也可以在模型调用阶段发送超大 token 文本消耗平台资源。建议在模型调用前完成输入长度限制、账户鉴权、内容和频次检查模型返回后再做输出内容过滤防止生成内容直接透传给用户。审计日志需要保留完整的调用链信息至少覆盖谁在什么时间调用了什么模型、消耗了多少 Credits、结果是否合规。6. 常见问题排查从模型无返回到扣费异常6.1 问题和排查方向总览AI 应用的问题往往横跨模型服务、应用代码和数据库三个层面。下面的表格整理了收银台最常见的几类问题。问题现象常见原因检查方式处理建议模型返回成功但 Credits 没有扣减usage 解析失败或没有走包装类查看日志中的 usage 字段是否为空增加空值判断补充默认计量策略同一笔业务被重复扣费重试请求没有使用同一 bizId查询 credit_ledger 中 bizId 出现次数客户端生成幂等键流水表加唯一索引余额充足但扣费失败UPDATE 条件中使用余额字段时精度或类型不一致检查 SQL 中的金额类型和索引使用 DECIMAL 类型确认 account_id 索引Agent 任务执行时间过长多步模型调用串行执行总耗时累计查看全链路追踪中每步耗时增加超时控制部分步骤可以并行或并发调用模型返回报 429 或超时模型服务限流或底层资源不足查看模型服务监控和错误日志应用层加熔断、重试和降级扣费后模型调用仍失败扣费时机放在模型调用之前查看事务边界和代码顺序建议先记录待扣费流水模型成功后确认扣费失败则退款6.2 一条从日志到账单的排查链路实际排查时不要东看一眼西看一眼建议按固定顺序走第一步根据用户提供的请求时间或 TraceId在应用日志里找到对应的模型调用记录确认请求是否到达应用层。第二步查看日志中的 usage 字段是否正常返回。如果 usage 为空问题在模型服务或 Spring AI 版本兼容性。第三步根据 bizId 查询 credit_ledger 表确认流水是否存在。如果流水不存在说明扣费逻辑没有执行到。第四步根据 account_id 查询 account_credit 表确认余额是否减少。余额未减少但流水存在说明事务没有提交成功。第五步检查数据库锁等待和连接池配置排除并发扣费导致的锁竞争。这条链路把“应用日志 - 流水表 - 余额表”串起来绝大多数扣费问题都可以精准定位。6.3 防止 Credits 扣错的三个工程手段第一模型调用结果必须解析成功后才能扣费。解析失败时不扣费但要记录错误日志避免用户被扣钱却拿不到结果。第二扣费使用幂等键。每个业务请求在进入网关时就生成唯一 requestId并透传到流水表。重试请求沿用同一个 requestId数据库唯一索引阻止重复插入。第三定期对账。每天凌晨跑一次对账任务把模型调用日志的 Credits 总量与 credit_ledger 的扣费总量做比对差值超过阈值触发告警。这样即使个别请求扣费异常也能在第二天发现并修正。7. 一页纸落地清单与扩展方向7.1 最小可运行版本要确认的事项如果是从零开始落地一个 AI 收银台建议按下面这个顺序自查模型调用是否统一经过包装类业务代码没有直接使用 ChatModel。usage 解析是否有空值兜底。account_id 是否建立了索引。流水表是否有唯一的幂等键。扣费 UPDATE 是否带余额条件是否使用乐观锁。模型调用是否设置超时和重试重试是否安全。日志是否包含 TraceId、accountId、bizId、tokens 和 credits。生产环境敏感配置是否通过环境变量注入。是否限制了单次请求的最大 token 数和单账户的最大 QPS。是否设计了日终对账任务。第 6 点尤其容易被忽略。模型调用超时后直接重试模型服务端可能已经成功生成内容只是响应丢失客户端再次扣费就会造成“模型生成了用户也扣了钱但没拿到结果”的尴尬情况。解决方案是重试时继续使用同一个 bizId让服务端流水表去重。7.2 值得继续扩展的方向文章最后给出几个可以继续深入的方向适合作为下一阶段实战项目。多租户计量在账户表上增加租户维度同一套模型服务为多个业务线服务各自独立计费和报表。动态定价把 model_rate 表扩展为按时间、按套餐、按用户等级定价Credits 换算规则可以通过配置中心热更新。Agent 可观测性把 Agent 的每一步意图判断、工具调用和子模型调用都纳入全链路追踪方便定位消耗异常。模型灰度发布新模型先在内部账号灰度运行收集质量数据和 Credits 消耗数据后再逐步放量。收银台的模型路由表正好可以作为灰度规则载体。对新手来说最有价值的练习不是去复刻大厂的复杂平台而是先把这个最小闭环跑通一次模型调用、一笔 Credits 扣费、一条流水记录、一份可查日志。四个点形成一个闭环后再往里面加 Agent、加多租户、加灰度发布思路会顺很多。收银台这个比喻很容易理解真正常被忽略的是它背后的数据一致性、幂等控制和可观测性建设这三件事才是 AI 应用商业化能否长期稳定运行的关键。