Skip to content

计量与配额 ​

适用端:后端 | 类型:机制

定制功能

本页全部内容属于本项目自研的 enlev-module-agent,上游 yudao 没有对应实现。 上游 enlev-module-ai 只有 ai_chat_message / ai_chat_conversation 这类内容表, 既没有「按 token 计价并留收费证据」的计费表,也没有任何配额概念。

定位:说清「一次模型调用怎么变成一行账」——谁在什么时机写、按什么价算、 token 数从哪来(厂商回传还是本地估算)、四个维度的配额各自查哪张表、 以及为什么这套东西没有计数器、没有锁、也没有 Redis。

前置阅读:ReAct 循环与流式对话(四条路由分别在哪儿记账)、 工作流引擎(agent_node_execution 这张 trace 表)。 本页反复出现的一个区分是「计费表 agent_usage_log」与「trace 表 agent_node_execution」—— 它们不是同一份数据的两个视图,而是两条互不相交的写入路径,这是本页最要紧的一件事。

它解决什么问题 ​

一次「帮我建个采购申请表」在账单上不是一次调用。它会产生意图分类一轮、 DSL 编排若干轮(每轮都可能自修正)、主对话一轮、工具循环每轮各一条—— 5~10 行计量记录。如果只统计主对话,用户看到的是「对话 1 次却扣了十几倍 token」, 无从判断是被多收了还是本来就该这么贵。

所以这套设计的第一目标不是「省钱」,而是让每一分消耗都能被解释:

图上那条从 工作流 llm 节点 出发、绕开计费表的线是本页的核心缺陷面: 它意味着 TENANT / USER 两个维度的配额对工作流执行的消耗完全看不见。 详见 坑与注意 第 1 条。

代码在哪 ​

后端(enlev-module-agent/src/main/java/cn/enlev/ainb/module/agent/):

文件行数职责
service/usage/AgentUsageLogService.java146计量 Service 接口,内嵌 UsageRecord(写入用)与 UsageGroupBO(看板用)两个 BO
service/usage/AgentUsageLogServiceImpl.java294落库、计价、快照、分组汇总、清过期原文
service/quota/AgentQuotaService.java128配额接口,内嵌 QuotaUsageBO(9 个字段)
service/quota/AgentQuotaServiceImpl.java465四维度评估、超限判定、预警通知、维度归一化
dal/mysql/usage/AgentUsageLogMapper.java223汇总 SQL、GroupDimension 枚举、全模块唯一的一条 UPDATE
dal/mysql/usage/AgentModelPriceMapper.java42selectEffectivePrice:按调用时刻取生效价
dal/mysql/quota/AgentQuotaMapper.java71四个维度各一个 selectOne
dal/mysql/workflow/AgentNodeExecutionMapper.java94sumTokensByWorkflowCode:WORKFLOW 维度唯一的取数口
dal/dataobject/usage/AgentUsageLogDO.java124计费表 DO,类注释写清「收费证据」的三条定位
dal/dataobject/usage/AgentModelPriceDO.java72单价表 DO,extends BaseDO 而非 TenantBaseDO
dal/dataobject/quota/AgentQuotaDO.java91配额表 DO,dimension 是判别归属的权威列
enums/AgentUsageSceneEnum.java787 个计量场景
enums/AgentQuotaDimensionEnum.java644 个配额维度 + costSupported 标志
enums/AgentQuotaPeriodEnum.java60DAY / MONTH,含 periodStart 与 periodKey
framework/agent/core/chat/AgentTokenEstimator.java96字符级 token 估算(截历史与计量兜底共用)
framework/agent/config/AgentUsageProperties.java39只有一个字段:inputTextRetentionDays = 180
job/AgentUsageLogRetentionJob.java56quartz JobHandler,清过期 input_text
controller/admin/usage/AgentUsageController.java1033 个只读端点(汇总 / 自查配额 / 明细)
controller/admin/config/AgentConfigController.java:139-157—配额 CRUD 3 个端点(挂在这里,不在 usage 控制器下)

计量写入点一共 6 处,分布在 5 个文件:

写入点场景是否带 conversationId是否带 messageId
service/chat/AgentAssistantServiceImpl.java:1465CHAT✅✅(唯一一处)
framework/agent/core/chat/AgentIntentClassifier.java:220INTENT✅❌
framework/agent/core/chat/AgentChatToolLoop.java:331TOOL_LOOP✅❌
service/plan/WorkflowPlannerServiceImpl.java:565PLAN_DSL🔴 ❌❌
controller/admin/diagnostic/AgentRagChatDiagnosticController.java:91CHAT(成功)❌❌
controller/admin/diagnostic/AgentRagChatDiagnosticController.java:86CHAT(失败)❌❌

前端(ainb-ui-admin-vue3/src/):

文件行数职责
api/agent/usage/index.ts613 个 VO + 3 个请求函数
views/agent/usage/index.vue228只读看板:我的配额告警条 + 下钻汇总 + 明细表

前端没有配额管理界面

listQuotas / saveQuota / deleteQuota 三个方法在后端齐备、端点也注册了 (/agent/config/quota/{list,save,delete}),但 views/agent/usage/index.vue 只读不写, 整个 src/views/agent/ 下也没有配额 CRUD 页面。 配额的增删改目前只能通过接口调用完成。

核心机制 ​

1. 计量写入永不打断业务,但也不静默 ​

record() 的方法体整个包在 try/catch 里(AgentUsageLogServiceImpl:65-107):

java
        } catch (Exception ex) {
            // 🔴 计量失败绝不能让业务调用失败:用户问了话、模型也答了,
            // 结果因为写日志出错收到「系统异常」—— 那是本末倒置。
            // 但也不能静默吞掉:写不进去意味着账单有空洞,所以打 ERROR 让它在监控里可见。
            log.error("[record][计量日志写入失败,账单将出现空洞] scene={} user={} tokens={}",
                    record.getScene(), record.getUserId(), record.getTotalTokens(), ex);
        }

接口 javadoc(AgentUsageLogService:13-18)把这条纪律重复了一遍—— 这是全模块少数几处「同一条规则在接口和实现上各写一次」的地方,说明它被踩过。

同一条思路在计价上还有一层(AgentUsageLogServiceImpl:169-178):

java
        } catch (Exception ex) {
            // 🔴 计价只是<b>给计量记录做增强</b>,绝不能反过来把记录本身弄丢。
            //    实测踩过:租户拦截器给不分租户的单价表拼了 tenant_id,
            //    抛出的 SQL 异常一路冒到 record(),结果那次对话【一条收费证据都没留下】。
            //    token 已经烧掉了,账上却查无此事 —— 这比算错金额严重得多。
            log.warn("[applyPrice][取单价失败,本条按未计价记录:{}]", ex.getMessage());
            logDO.setPriceSnapshot("{\"priced\":false,\"reason\":\"取单价失败\"}");
            logDO.setCost(BigDecimal.ZERO);
            return;
        }

注释里那次事故已经用配置修掉了:agent_model_price 是 enlev-server/src/main/resources/application.yaml:404-408 里 ignore-tables 的唯一一项。

2. 单价按「调用时刻」取,并把价目行快照进账单 ​

AgentModelPriceMapper.selectEffectivePrice:31-40 的判据是 effective_from <= 调用时间 里最新的一行,不是「最新的一行」:

java
    default AgentModelPriceDO selectEffectivePrice(String platform, String modelName,
                                                   LocalDateTime at) {
        List<AgentModelPriceDO> list = selectList(new LambdaQueryWrapperX<AgentModelPriceDO>()
                .eqIfPresent(AgentModelPriceDO::getPlatform, platform)
                .eq(AgentModelPriceDO::getModelName, modelName)
                .le(AgentModelPriceDO::getEffectiveFrom, at)
                .orderByDesc(AgentModelPriceDO::getEffectiveFrom)
                .last("LIMIT 1"));
        return list.isEmpty() ? null : list.get(0);
    }

配套纪律是改价用新增、不要 UPDATE(AgentModelPriceDO:20-22): UPDATE 旧行会让历史账单跟着变,而账单的第一属性是可复算。 price_snapshot 列因此不是冗余——价目表是「现在什么价」,快照是「这笔账当时按什么价算的」, 快照里连价目行的 id 与 effectiveFrom 都带着(AgentUsageLogServiceImpl:195-201), 对账时能直接定位到是哪一条价目。

单价口径是元 / 1000 token(THOUSAND 常量,:50)。 AgentModelPriceDO:46-49 解释了为什么不按每 token:厂商报价普遍是千 token 口径, 换算过去会得到 0.000004 这种数,人核对时极易数错位数。

3. token 数:厂商回传优先,估算兜底,但来源必须记下来 ​

token_source 这一列只有两个取值,PROVIDER / ESTIMATED。 每个写入点都是同一个三目形状(以 AgentAssistantServiceImpl:1459-1462 为例):

java
            completionTokens = fromProvider && providerUsage.getCompletionTokens() != null
                    ? providerUsage.getCompletionTokens() : AgentTokenEstimator.estimate(answer);
            totalTokens = fromProvider && providerUsage.getTotalTokens() != null
                    ? providerUsage.getTotalTokens() : promptTokens + completionTokens;

AgentTokenEstimator(96 行,final 类 + 私有构造)的算法是字符级近似: CJK 字符按 1 字 1 token,其余按 4 字符 1 token(:43、:67)。 类注释里两段话值得原文抄下来,因为它们决定了这个数能不能拿来对账:

🔴 刻意往多了估……估多了 → 历史截得更狠一点,少带几轮上下文。代价是回答略微不连贯; 估少了 → 超出模型窗口,整次调用直接失败,而且报错来自厂商、信息含糊, 事后很难定位是哪一轮撑爆的。两种代价不对等,所以按上界估。

真正的 tokenizer(jtokkit 之类)没引:它对中文与通义的词表并不准, 引一个「看起来精确其实也不准」的依赖,反而会让人误以为这个数字可以拿来对账。 账要对得准只能用厂商回传值 —— 那正是 token_source 这一列存在的理由。

它同时服务于两件事(:8-15):历史截断(按 token 预算裁掉过老轮次)与计量兜底。 两处共用一份实现,否则会出现「按 A 口径截断、按 B 口径计费」, 用户看到「明明没说几句话却扣了很多」而无法解释。

isCjk 只判 6 个区段(:87-94)——统一表意文字、扩展 A、中文标点、全角、假名、韩文。 注释说得很直白:判全所有扩展区会让方法复杂几倍,而对结果的影响小于本来就存在的误差。

4. 配额没有计数器:每次请求现算一遍 live SUM ​

这是整套配额最反直觉的一点。AgentQuotaServiceImpl.evaluate:288-341 在每一次对话入口 被调用,做的事是「按周期起点跑一条聚合 SQL」:

没有 used_tokens 累加列、没有原子 UPDATE ... SET x = x + ?、没有 Redis 计数键。 selectUsageSum 的 javadoc(AgentUsageLogMapper:82-88)解释了为什么必须在 SQL 里聚合:

配额检查在每一次对话入口都要跑一遍。一个活跃租户一个月能攒出几万行, 全查回来在内存里求和,意味着每次提问前都要把这几万行读一遍—— 用户感觉到的是「助手越用越慢」,而且慢在发问之前,最难受的位置。

没有 reset job 也是这个设计的直接结果:周期起点由 AgentQuotaPeriodEnum.periodStart(now)(:44-48)当场算出来 (DAY = 今天 00:00,MONTH = 本月 1 号 00:00),SUM 的 WHERE begin_time >= ? 天然只覆盖本周期, 不需要谁在月底把计数器清零。

周期选自然周期而不是滚动窗口,理由写在 AgentQuotaPeriodEnum:14-18:

滚动窗口(「过去 30 天」)在数学上更公平,但用户算不清自己还剩多少—— 额度会随着时间推移一点点回来,没法回答「我这个月还能用几次」。 而配额的首要作用是让人能预期,其次才是控制成本。

5. 四个维度按取数表分流,WORKFLOW 是那条岔路 ​

evaluate():296-317 的 switch 是这套设计的分水岭:

维度取数来源token金额costSupported
TENANTagent_usage_log 全表 SUM(user_id 不加条件)✅✅true
USERagent_usage_log WHERE user_id✅✅true
AGENTagent_usage_log JOIN agent_conversation JOIN agent_def,按 code✅✅true
WORKFLOW🔴 agent_node_execution JOIN agent_workflow_execution JOIN agent_workflow_def,按 code✅恒为 0false

WORKFLOW 那条 SQL(AgentNodeExecutionMapper:87-92)与它上面的注释是本页最该逐字读的一段:

🔴 只有 token、没有金额:工作流用量来自本表(节点级 trace),而本表没有 cost 列—— 成本只在计费表 agent_usage_log 上算,且工作流的 LLM 节点用量不写进计费表 (见 LlmNodeExecutor:落 ctx.metrics → 本表)。 按用户选定「不改计费语义」,本期不给工作流新增计费写入口径, 故工作流配额只能限 token;AgentQuotaDimensionEnum.WORKFLOW.costSupported=false, 金额上限对它是死配置(前后端都禁用)。

LlmNodeExecutor:86-93 就是那个「落 ctx.metrics」的现场:

java
        // 度量:实际模型 + 真实 usage(可能为 null —— 厂商没回传时按 0 记,来源保持 ESTIMATED)
        AiChatRespDTO.Usage usage = resp.getUsage();
        if (usage != null) {
            ctx.getMetrics().recordUsage(resp.getModelId(), usage.getPromptTokens(),
                    usage.getCompletionTokens(), usage.getTotalTokens(), true);
        } else {
            ctx.getMetrics().setModelId(resp.getModelId());
        }

NodeMetrics(45 行,framework/agent/core/engine/context/NodeMetrics.java)是瞬态对象, 类注释解释了为什么不塞进 state:state 会被 SAA checkpoint 序列化, 里面只能放 JSON 可序列化值;度量是「执行完就落 trace 表」的临时数据, 放进 state 既污染 checkpoint、又会被粘滞的 tokenUsage 问题连累。

6. AGENT / WORKFLOW 的 target_key 存 code 不存 id ​

AgentQuotaDimensionEnum:17-21 与 AgentQuotaDO:47-53 两处都写了同一条理由:

agent_def 与 agent_workflow_def 都是版本化的:同一个 code 有多行(每版一行), numeric id 指向单一版本行。若配额绑 id,作者一发新版(新 id),旧配额就静默失效—— 正是本平台头号缺陷「配置保存成功但不生效且无提示」。

按 code 归因还带来一个正面效果:JOIN ... WHERE ad.code = #{code} 能把该定义 所有版本的用量合并计入本周期,否则一发新版旧版本的用量就从统计里漏掉。

7. AGENT 维度刻意不过滤软删 ​

AgentUsageLogMapper:124-129 是一段反刷额度的纪律,值得整段抄:

⚠️ 刻意不过滤 c.deleted / ad.deleted:会话可被用户软删,但 token 已经真的烧掉了。 若按 c.deleted=0 过滤,用户删掉会话就能「洗掉」该 Agent 的配额占用—— 与「中途取消照样计量」是同一条反刷额度纪律。软删的会话/定义行物理仍在, JOIN 照样命中、照读其 agent_def_id/code,故用量如实计入。 只对计费行自身加 ul.deleted=0(防御,该表本无删除入口)。

同一条纪律的另一半在 selectUsageSum 的注释里(:87-88): 统计口径含失败与中断的调用,「不含的话会留下一个刷额度的口子: 中途取消的请求照样消耗了厂商的 token」。这也是 recordFailure 存在的理由 (AgentUsageLogService:45-46:只记成功会让中断的流式对话不计费,那是可以被刷的)。

8. 唯一键拦不住重复,判重只能在应用层 ​

agent_quota 的唯一键是 uk_tenant_user_deleted(tenant_id, user_id, deleted)。 user_id 可为 NULL,而 MySQL 里 NULL 不与其他 NULL 相等,所以这个唯一键 对「同维度同目标」的重复行完全无效。saveQuota:138-143 因此自己判一遍:

java
        // 🔴 同维度同目标不得重复:唯一键含可为 NULL 的列、拦不住,放两条进去会让 selectOne 抛
        //    TooManyResultsException(该维度一提问就 500)。见 AGENT_QUOTA_DUPLICATE 注释。
        AgentQuotaDO existing = findSameTarget(quota);
        if (existing != null && !Objects.equals(existing.getId(), quota.getId())) {
            throw exception(AGENT_QUOTA_DUPLICATE, describeDimensionTarget(quota));
        }

演示库里正好躺着这条缺陷的活证据:sql/mysql/ainb-init.sql:6494-6499 有 6 行 agent_quota,dimension 全是 AGENT、target_key 全是 e2e_quota_ui_agent、 tenant_id 全是 1、user_id 全是 NULL——它们能共存,正是因为唯一键里那个 NULL。 (这 6 行 deleted 都是 b'1',是 E2E 测试残留,见 坑与注意 第 6 条。)

9. clearableColumnsWrapper:MyBatis-Plus 的 NOT_NULL 策略 ​

saveQuota 更新时不用 updateById,而是走 clearableColumnsWrapper:188-198。 方法上的 javadoc(:159-187)是本项目里写得最细的一段坑记录,核心是:

MyBatis-Plus 的默认字段策略是 NOT_NULL,实体上为 null 的字段会被整个从 SET 子句里省掉—— 那在 SQL 里的含义是「别动这一列」,而不是「把这一列清成 null」。 全仓没有任何模块改过 update-strategy,所以这条默认值到处生效。

而 normalize():210-234 干的全部工作就是把不该有的字段抹成 null (TENANT 抹 userId/targetKey,USER 抹 targetKey,AGENT/WORKFLOW 抹 userId, 不支持金额的维度抹 costLimit),管理员把上限改回「不限」同样是靠传 null。 走 updateById 的话这些清空一个都落不了库:接口返回成功、日志照打、库里旧值原封不动。

后果最重的一条是维度切换:selectTenantQuota 以 isNull(user_id) 为判据, 一行配额从 USER 改成 TENANT 之后 user_id 残留,这条配额就再也查不出来—— 配额静默失效,等于不限流。

也不能改成在 DO 上打 @TableField(updateStrategy = ALWAYS)

maybeWarn:383-386 用 new AgentQuotaDO() 只填 id + warnedPeriod 做局部更新, ALWAYS 会让那一次更新把 userId / targetKey / tokenLimit / costLimit 一并清成 null—— 把一个潜伏缺陷换成一个每次预警都触发的现行缺陷。 局部更新与全量更新共用同一个 DO,所以「哪些列允许写 null」只能在调用点说清楚。

同一个方法里 setWarnedPeriod("") 却是好使的(:147):空串不是 null,默认策略会带上。 javadoc 特意点了这句——「真假两种写法挨在一起,正是这个坑最难在 review 里看出来的原因」。 与 FormDesignServiceImpl#clearableColumnsWrapper 同一口径。

10. 预警按周期去重,且先落标记再发通知 ​

maybeWarn:369-410 的去重标记 warned_period 落在配额行上(不是内存、不是 Redis), 所以跨重启有效。为什么必须去重,:360-363 说得很清楚:

越过预警线之后,每一次请求都满足预警条件。不去重的话管理员一天收几百条, 结果一定是他把通知关掉——于是真正触顶时也没人知道。

顺序也是刻意的(:381-386):先落去重标记再发, 「反过来的话,发送成功但落标记失败,下次又会发一遍。宁可漏一条预警,也不要每次请求都轰一条」。

发送失败只 log.warn(:407-409):预警是附加价值不是主流程, 通知发不出去(模板被删、收件人被停用)不该让用户连提问都提不了。

收件人的确定方式(:396-397):用户级配额直接通知本人, 租户级配额通知租户联系人——resolveTenantAdmin:417-420 的注释承认 「没有专门的『租户管理员』概念,联系人是最接近的那个」。

11. 保留期在写入时快照,清理只置空不删行 ​

record():97-99 把当时的配置值抄进行里:

java
            // 保留期在写入时【快照】下来,而不是清理时现读配置。
            // 理由:保留期是「当时对用户承诺了什么」,不该被后来改的配置追溯改写
            log.setRetentionDays(usageProperties.getInputTextRetentionDays());

清理通道是全模块唯一的一条 UPDATE(AgentUsageLogMapper:54-58):

java
    @Update("UPDATE agent_usage_log SET input_text = NULL "
            + "WHERE retention_days > 0 AND input_text IS NOT NULL "
            + "AND create_time < DATE_SUB(NOW(), INTERVAL retention_days DAY) "
            + "LIMIT #{limit}")
    int clearExpiredInputText(@Param("limit") Integer limit);

只清 input_text 一列,行本身与全部计量字段一个都不动—— 两者保留诉求正好相反(原文属个人信息、计量字段是计费证据),所以只能置空不能删行。 这也是 Mapper 类注释那条规矩的延续(:24-28):

🔴 刻意不提供任何删除方法。 计费证据不允许被删—— 靠「压根没有删除入口」保证,比靠约定或代码评审可靠。 BaseMapperX 继承来的 deleteById 等方法在技术上仍然存在,但本模块不会去调; 架构测试可以在将来补一条扫描来钉死这点。

12. 看板的分组列是枚举出来的,不接受外部字符串 ​

selectUsageGroupBy:169-187 的 GROUP BY 表达式来自 GroupDimension 枚举自带的常量列名。 :157-161 的理由:

这个方法的入参最终会进 SQL 的 GROUP BY。让调用方传任意字符串, 等于开了一个注入口子——而且是在一个管钱的查询上。

四个维度:USER(user_id) / SCENE(scene) / MODEL(model_name) / DAY(DATE(begin_time))。 parse 认不出来时兜底成 SCENE(:217-219)—— 「它是最能解释『这些 token 花在哪了』的维度」。

汇总结果里那个 NPE 的疤

AgentUsageLogServiceImpl:224-229 有一段必须先判 null 的代码:

⚠️ 非用户维度的 groupKey 不是数字(如 CHAT、qwen3.7-plus), parseLongOrNull 会返回 null,而 Map.of() 是不可变 Map, getOrDefault(null, ...) 直接抛 NPE。必须先判。

Map.of() 返回的不可变 Map 对 null 键抛 NPE,这一点在按 USER 分组时不会暴露 (键都是数字),只在切到 SCENE / MODEL / DAY 时才炸。

13. 权限点刻意拆成两个,且前端跟着拆 ​

AgentUsageController:31-39 的类注释是这套权限设计的说明书:

  • agent:usage:query —— 看聚合用量:谁用了多少、花在哪类场景上
  • agent:usage:detail —— 看输入原文:用户当时具体问了什么

后者是隐私敏感的。看账单不需要读别人问过什么, 而「为了对账把所有人的提问都翻一遍」是个很容易滑过去的越界。 初期可以都授给租户管理员,但不要合并成一个权限点—— 合了以后再拆要改代码、改授权数据、改前端。

前端 views/agent/usage/index.vue:4-7 把同一条纪律抄在了文件头注释里, 并且「调用明细」那一整段 ContentWrap 挂了独立的 v-hasPermi="['agent:usage:detail']"(:111), 没有该权限的人连标签页都看不到;loadRecords 的 catch(:208-212)把 403 当预期静默处理。

my-quota 端点用的是助手权限而不是看板权限(:70),:72-73 的理由: 「这是自查,『我还能问几次』不该要求一个管理权限才看得到」。

数据模型 ​

agent_usage_log(计费表 · 收费证据) ​

AgentUsageLogDO extends TenantBaseDO。DDL 见 sql/mysql/ainb-init.sql:6721 起。

列类型说明
idbigint PRI编号
user_idbigint NOT NULL用户编号
conversation_idbigint NULL, MUL会话编号,弱引用:会话删除后不清理、不置空、不级联
message_idbigint NULL消息编号,同样弱引用;只有 CHAT 场景会写
model_idbigint NULL实际使用的模型编号(故障切换后与请求的可能不同)
platform / model_namevarchar(32) / varchar(64)如 TongYi / qwen-plus
scenevarchar(24) NOT NULL场景,见下面的枚举表
prompt_tokens / completion_tokens / total_tokensinttoken 三段
token_sourcevarchar(16) NOT NULLPROVIDER / ESTIMATED
price_snapshotvarchar(512)调用当时的单价快照 JSON(含价目行 id 与生效时间)
costdecimal(12,6)费用
input_texttext用户输入原文快照
begin_time / end_time / duration_ms—时间与耗时
statusvarchar(16) NOT NULLSUCCESS / FAILURE / CANCELLED
error_msgvarchar(500)失败原因
retention_daysint保留天数,写入时快照,0 表示不清理
tenant_idbigint NOT NULL, MUL租户编号

索引:idx_conversation_id、idx_tenant_scene_time(tenant_id,scene,begin_time)、 idx_tenant_user_time(tenant_id,user_id,begin_time)。

DO 类注释(:15-26)把「收费证据」这个定位拆成三条可执行的要求:

  1. 数据必须完整——每次 LLM 调用一行,包括失败与中断的。 流式对话在 doOnComplete/doOnError/doOnCancel 三处都要落库,否则中断的对话不计费、可被刷
  2. 不可被用户抹掉——用户删对话,收费证据不能跟着没。所以 conversationId/messageId 只是弱引用,不建外键、不级联删除;本模块也不提供任何删除入口
  3. 能还原到单次调用——所以 inputText 存快照,不靠 join 消息表。 消息删了还要能出示「这次扣费对应的是哪句话」

input_text 是一个未被裁定的隐私面

AgentUsageLogDO:101-103 自己写着:

⚠️ 它意味着「租户管理员能看到本租户用户跟 AI 说过的每一句话」。 访问策略已决定推迟(方案 §8.4),但正式对外前要过一遍法务的告知同意义务。

这也是它独立成列、不与其他字段拼进同一个 JSON 的原因(:98-99): 「将来要清理/脱敏/迁走只动这一列。这是零成本的后路,现在不留以后要改代码。」

agent_model_price(单价表 · 不分租户) ​

AgentModelPriceDO extends BaseDO 而不是 TenantBaseDO。DDL 见 ainb-init.sql:5716 起。

列类型说明
idbigint PRI编号
platformvarchar(32) NOT NULL, MUL平台
model_namevarchar(64) NOT NULL模型标识
prompt_price / completion_pricedecimal(12,6) NOT NULL元 / 1000 token
currencyvarchar(8) NOT NULL币种
effective_fromdatetime NOT NULL生效时间(含)
remarkvarchar(255)备注,如「厂商 2026-09 调价」

索引:idx_model_effective(platform,model_name,effective_from)。没有 tenant_id 列。

AgentModelPriceDO:15-18 解释了为什么不分租户:

单价是平台与厂商之间的事实,与租户无关——同一个模型不会因为换个租户就换个进价。 做成分租户的话,新开一个租户就得把全部单价复制一遍, 漏了的表现是「这个租户的账单金额全是 0」。

不分租户 = 必须在 ignore-tables 里登记

没有 tenant_id 列的表若被 TenantLineInnerInterceptor 拦到,SQL 会拼出 tenant_id = ? 从而报 Unknown column 'tenant_id'。 enlev-server/src/main/resources/application.yaml:404-408 的 ignore-tables 因此只有这一项:

yaml
    ignore-tables:
      # 模型单价是平台与厂商之间的事实,与租户无关 —— 同一个模型不会因为换个租户就换个进价。
      # 不登记在这里的话,租户拦截器会给它拼 tenant_id,而该表没有这一列,
      # 表现是「Unknown column 'tenant_id'」并且【把整条计量记录一起丢掉】
      - agent_model_price

新建任何不分租户的表都要在这里加一行,否则表现是「整条计量记录一起丢掉」—— 不是报错,是账上少一行。

agent_quota(配额表) ​

AgentQuotaDO extends TenantBaseDO。DDL 见 ainb-init.sql:6470 起。

列类型说明
idbigint PRI编号
user_idbigint NULL用户编号;TENANT / AGENT / WORKFLOW 三个维度都为 NULL
dimensionvarchar(16) NOT NULL🔴 判别归属的权威列:TENANT/USER/AGENT/WORKFLOW
target_keyvarchar(191) NULL仅 AGENT/WORKFLOW 用,存定义的 code
periodvarchar(8) NOT NULLDAY / MONTH
token_limitbigint NULLtoken 上限,NULL = 不限
cost_limitdecimal(12,6) NULL金额上限,NULL = 不限;WORKFLOW 维度强制清空
warn_ratiodecimal(4,3) NOT NULL预警比例
warned_periodvarchar(16) NULL已发过预警的周期标识,如 2026-09
statustinyint NOT NULL⚠️ 0 是开启、1 是关闭,与直觉相反
tenant_idbigint NOT NULL, MUL租户编号

索引:PRIMARY(id)、uk_tenant_user_deleted(唯一,tenant_id+user_id+deleted)。

AgentQuotaDO:28-30 解释了为什么用 NULL 而不是 0 表达「不限定用户」:

0 是个合法的用户编号取值,拿它当哨兵值,将来一定会有人写出 user_id = 0 的查询, 然后查出一条本不属于任何用户的租户级配额。

而 :32-33 补了一句自 P7 起的关键变化: 「『是不是用户级』不再单看这一列是否为空——Agent 级、工作流级配额的 user_id 同样是 null。 判别一律以 dimension 为准。」这也是 AgentQuotaMapper.selectTenantQuota:23-29 必须同时叠 dimension=TENANT 与 isNull(user_id) 两个条件的原因。

agent_node_execution(trace 表 · WORKFLOW 维度唯一取数口) ​

本页只用到它的三列:total_tokens、execution_id、begin_time。 完整结构见 工作流引擎 · 数据模型。 它没有 cost 列——这就是 WORKFLOW 维度不支持金额上限的全部原因。

场景枚举与写入现状 ​

AgentUsageSceneEnum 声明了 7 个场景,但只有 4 个真的有写入点:

枚举值中文名写入点单列出来而并进 CHAT 的理由(摘自枚举 javadoc)
CHAT对话AgentAssistantServiceImpl:1476、AgentRagChatDiagnosticController:92主对话:用户直接提问、AI 直接回答
INTENT意图分类AgentIntentClassifier:227它是每条消息都会发生的固定开销……单列出来才能判断「这个分类器值不值得留着」——单次极便宜,乘以消息总数就未必
TOOL_LOOP工具循环AgentChatToolLoop:338Agent 循环里的每一轮 LLM 调用(规划、观察后重规划)
PLAN_DSL流程DSL生成WorkflowPlannerServiceImpl:577输出结构更长、单次更贵……单列出来,账单上才能看清「生成一份可执行流程草稿」到底烧了多少 token
SUGGESTION建议生成🔴 无方案 §9.2 定的是「与主回答同一次调用返回」,那样不会单独产生记录
VALIDATE_RETRY校验自修正🔴 无生成后校验失败的自修正轮次
EMBEDDING向量化🔴 无文档灌库、查询向量化

SUGGESTION 为什么没有写入点,AgentAssistantServiceImpl:1474-1475 就写在记账代码的上一行:

建议与主回答是同一次调用产出的,所以计入 CHAT 而不是 SUGGESTION。 SUGGESTION 这个场景留给「确实需要单独调一次」的情况(方案 §8.3)

枚举类注释(:9-15)是这整个场景维度存在的理由:

🔴 只统计主对话会让账对不上。 一次「帮我建个采购申请表」可能产生 5~10 条计量记录: 主对话一条、规划与重规划各若干条、生成后校验失败的自修正轮次若干条、最后还有建议生成一条。 用户看到账单若只有「对话 1 次」却扣了十几倍 token,会以为被多收了。 场景维度就是为了让这些消耗能被解释清楚。

接口清单 ​

用量看板(/agent/usage,AgentUsageController) ​

方法路径权限说明
GET/agent/usage/summaryagent:usage:query用量汇总,dimension 默认 SCENE,可带 beginTime/endTime/userId
GET/agent/usage/my-quotaagent:assistant:use我的配额自查(不是看板权限)
GET/agent/usage/recentagent:usage:detail最近调用明细,limit 默认 50、上限 200,含 inputText

/recent 的响应是手拼的 LinkedHashMap(:84-99),11 个键, 不是直接返回 DO——这是 input_text 那道权限分离能成立的前提。

配额管理(/agent/config,AgentConfigController) ​

方法路径权限说明
GET/agent/config/quota/listagent:quota:query配额列表,返回 DO 原样
POST/agent/config/quota/saveagent:quota:update新增或更新,请求体是 AgentQuotaDO
DELETE/agent/config/quota/deleteagent:quota:update删除,该维度恢复为不限制

配额 CRUD 直接收 DO

saveQuota(@Valid @RequestBody AgentQuotaDO quota)(AgentConfigController:149) 没有 ReqVO 层,前端能直接写 dimension、warnedPeriod、status、tenantId 等全部列。 normalize() 会归一化维度专属字段、saveQuota 会强制清空 warnedPeriod, 但 status 与 tenantId 没有保护——tenantId 靠 TenantBaseDO 的自动填充兜住, status 则由调用方随意给。

诊断(/agent/diagnostic,AgentRagChatDiagnosticController) ​

方法路径权限说明
GET/agent/diagnostic/usage-logs🔴 agent:usage:query返回最近 20 条完整 AgentUsageLogDO

这个端点绕开了上面那道权限分离,详见 坑与注意 第 9 条。

配额检查的调用点 ​

checkQuota 系列不是端点,而是三个内部闸门:

调用点方法时机
AgentAssistantServiceImpl:255checkQuota(userId)聊天入口,USER + TENANT 两档
AgentAssistantServiceImpl:301checkAgentQuota(agentDef.getCode())解析出可执行定义之后、路由之前
AgentWorkflowExecutionServiceImpl:147checkWorkflowQuota(def.getCode())工作流执行入口

三处都在请求入口,一旦放行这一轮就跑完,中途不再查(checkTargetQuota:100 的注释明写这条)。 AgentAssistantServiceImpl:255 附近的注释给了理由:拒绝新请求,不中断进行中的会话。

怎么扩展 ​

加一个计量场景 ​

  1. 在 AgentUsageSceneEnum 加枚举值(带中文名与 javadoc——枚举注释是账单可读性的一部分)
  2. 在写入点构造 UsageRecord 时 .scene(新枚举)
  3. 决定要不要带 conversationId:不带的话这一行会从 AGENT 维度的配额统计里消失 (那条 SQL 是 INNER JOIN),见 坑与注意 第 8 条
  4. 决定 inputText 写什么:它会被 truncateForColumn 截到 10000 字符后落库, 而 /agent/usage/recent 会把它原样展示给有 agent:usage:detail 的人

不需要改任何 SQL:GroupDimension.SCENE 是按列分组的,新场景值自动出现在看板里。

加一个配额维度 ​

  1. AgentQuotaDimensionEnum 加值,并决定 costSupported—— 取数表没有 cost 列就必须填 false,否则会出现「配了金额上限却从不生效」的死配置
  2. AgentQuotaMapper 加一个 selectOne,条件必须同时叠 dimension 与目标列 (只按 isNull(user_id) 会把其他维度误捞出来)
  3. AgentQuotaServiceImpl 三处:normalize() 的 switch 加分支(清掉不该有的字段 + 校验目标齐全)、 findSameTarget() 的 switch 加分支(否则判重形同虚设)、evaluate() 的 switch 加分支
  4. 在执行入口调 checkXxxQuota(code)——记住它只拦新请求,不中断进行中的
  5. target_key 存 code 不存 id,理由见 核心机制 6

AgentQuotaDimensionEnum.parse:57-62 认不出来时返回 null 而不兜底, 这与 AgentQuotaPeriodEnum.parse:36-38 兜底成 MONTH 刻意相反:

周期认错顶多是宽严之差,而维度认错会把「给 A 的限额」套到「B」头上, 是管钱的错配,宁可返回 null 让调用方显式处理。

加维度时不要「顺手统一」这两个 parse 的兜底行为。

加一个模型的单价 ​

INSERT 新行,不要 UPDATE 旧行(AgentModelPriceDO:20-22)。 platform + model_name 必须与 AiChatRespDTO 回传的值逐字一致—— 匹配用的是 record.getModelName(),它来自 resp.getModel()(厂商回传的型号串), 不是 ai_model 表的 model 列,两者不保证相同。

新表若不分租户,记得在 application.yaml 的 ignore-tables 里登记。

换掉 token 估算器 ​

AgentTokenEstimator 有两个消费者:历史截断与计量兜底。 换实现时两处会一起变,这是刻意的(:8-15)。若只想改计量那一侧, 必须先想清楚「按 A 口径截断、按 B 口径计费」这个用户可见的矛盾。

真要引 tokenizer,注意类注释里那句警告:引一个「看起来精确其实也不准」的依赖, 反而会让人误以为这个数字可以拿来对账。token_source 这一列的意义正在于 把不可信的数标出来,而不是把它变准。

坑与注意 ​

静默 / fail-open 方向(配了不生效、账上看不出) ​

  1. 🔴 工作流 LLM 节点的 token 永不进 agent_usage_log。LlmNodeExecutor:88-93 把 usage 写进 ctx.getMetrics(),最终落 agent_node_execution。 后果:TENANT 与 USER 两个维度的配额对工作流执行的消耗完全看不见—— 一个租户可以把全部消耗挪进工作流,绕过租户级配额。 只有 WORKFLOW 维度自己(按 code 单独配)能拦住它。 AgentNodeExecutionMapper:68-72 明写这是「按用户选定『不改计费语义』」的自觉取舍,不是遗漏。 失效方向:静默。看板上的金额与 token 都是真实的,只是不完整。

  2. 🔴 AgentUsageLogRetentionJob 从未被注册,永远不会运行。 它 implements JobHandler(quartz 处理器),需要 infra_job 表里有一行才能被调度。 sql/mysql/ainb-init.sql 里 INSERT INTO infra_job 只有 3 行(id 25/26/27, 全是上游的访问日志/错误日志/任务日志清理),全仓 sql/ 下搜 agentUsageLogRetentionJob 0 命中。 即便注册了,本地 profile 也跑不起来——application-local.yaml:9 排除了 QuartzAutoConfiguration(见 infra 域开发)。 失效方向:静默。input_text 会无限增长,没有任何告警。

  3. 🔴 保留期的口径在三处不一致。AgentUsageProperties:37 的默认值是 180; AgentUsageLogRetentionJob:22 的注释说「判据是每行自己的 retention_days,默认 0 表示不清理…… 这是 2026-09-23 之前的行为」; AgentUsageLogMapper:47-49 也说「retention_days = 0 表示不清理(当前默认值)」。 实际行为以代码为准:新写入的行快照到 180,历史行仍是各自入库时的值。 两处注释都停在 2026-09-23 之前,读它们会得出「什么都不清理」的错误结论。 失效方向:文档与代码分岔(代码是对的)。

  4. 🔴 演示库唯一一行单价指向一个不存在的模型。ainb-init.sql:5737 只有 1 行:TongYi / qwen3.7-plus, prompt_price=0.000800、completion_price=0.002000、CNY、 effective_from='2026-01-01'、备注「初始价,上线前按实际合同价订正」。 而 ai_model 表 93 行里有 55 行存活(38 行 deleted=b'1'), 其中 platform='TongYi' 的存活模型有 10 个—— qwen3.7-text-embedding、qwen-max、glm-5.1、qwen-mt-flash、qwen3.5-flash、 qwen-plus、qwen-mt-lite、qwen-mt-plus 等,没有 qwen3.7-plus。 全库搜 qwen3.7-plus 只有 3 处命中:两处 DDL 注释 + 这一行价目本身。 结果:演示库上每一条计量记录都走 未配置该模型的单价 分支,cost 恒为 0。 前端对此有兜底提示(views/agent/usage/index.vue:99-107):

    费用列全为 0,是因为还没有配置模型单价。token 用量是准确的,金额需要配置单价后才有意义。

    失效方向:静默,但有 UI 提示。token 账是准的,金额账是空的。

  5. 7 个场景里 3 个零写入点,DDL 注释还停在 5 个。SUGGESTION / VALIDATE_RETRY / EMBEDDING 在 src/main 下没有任何 record 调用点 (全仓搜这三个标识符,命中的全是枚举自身、AgentUsageLogMapper:200 的注释、 以及与计量无关的 SUGGESTION_PROMPT / MAX_SUGGESTIONS 等同名常量)。 同时 agent_usage_log.scene 的列注释写的是 「CHAT/SUGGESTION/TOOL_LOOP/VALIDATE_RETRY/EMBEDDING」—— 既列了 3 个不写的,又漏了 2 个在写的(INTENT、PLAN_DSL)。 失效方向:静默。看板的「按场景」下钻永远不会出现那 3 行, 而读 DDL 注释的人会以为 EMBEDDING 有账。

  6. 出厂即「四维度全不限」,因为 6 行配额种子全是软删的 E2E 残留。ainb-init.sql:6494-6499 的 6 行 agent_quota 全部 deleted=b'1', target_key 全是 e2e_quota_ui_agent,token_limit 全是 12345,创建时间集中在 2026-09-29 ~ 2026-10-01。也就是说演示库上没有任何生效的配额行, isEnabled() 全部返回 false,四个维度一律放行。 这与 isEnabled:266-275 的刻意设计一致(未配置 = 不限制),但要注意: 装好就是不限流的,想验证配额得自己配。 失效方向:静默放行(设计如此)。

  7. 配额不是硬闸:无锁、无原子更新、可并发穿透。evaluate() 是「查 SUM → 比较 → 放行」三步,中间没有任何锁或 CAS。 两个请求同时到达、都读到「还差一点没超」,就都能过。 加上「只在入口查、放行后这一轮跑完」的设计,一个长流式对话可以在超限后继续烧完全程。 这与 isEnabled 注释里那句定位是一致的(:269-270):

    默认放行而不是默认拦截:配额是成本控制手段,不是安全边界—— 没配就把所有人挡在门外,只会让人以为系统坏了。

    不要把它当限流器用。 真正的匿名面限流在 AgentWebhookRateLimiter(Redisson,见 安全与提示注入)。

  8. 🔴 PLAN_DSL 行的 conversation_id 是 NULL,AGENT 维度配额统计不到它。WorkflowPlannerServiceImpl:565-587 构造 UsageRecord 时设了 userId/tenantId/modelId/platform/modelName/scene/token 三段/inputText/时间/status/errorMsg, 唯独没有 conversationId。而 selectUsageSumByAgent 是 JOIN agent_conversation c ON ul.conversation_id = c.id(INNER JOIN)—— conversation_id 为 NULL 的行匹配不上任何会话,直接从 AGENT 维度的统计里消失。 编排恰恰是最贵的场景(一次可能 6 轮),所以这个漏计量在 AGENT 维度上不是小数。 TENANT / USER 维度不受影响(它们不 JOIN)。 失效方向:静默少算——配额偏松,账单上的 token 总数仍是对的(看板不 JOIN)。

  9. 🔴 /agent/diagnostic/usage-logs 用聚合权限返回了原文。AgentRagChatDiagnosticController:103-107:

    java
        @GetMapping("/usage-logs")
        @PreAuthorize("@ss.hasPermission('agent:usage:query')")
        public CommonResult<List<...AgentUsageLogDO>> usageLogs() {
            return success(usageLogService.getRecentLogs(20));
        }

    返回类型是完整 DO,包含 inputText;权限却是 agent:usage:query(聚合档)。 这正好绕开了 AgentUsageController 类注释里那段「不要合并成一个权限点」的设计: 拿到聚合权限的人从这个端点就能读到用户提问原文, 而前端为此专门挂的 v-hasPermi="['agent:usage:detail']" 也就白挂了。 失效方向:fail-open(权限分离被旁路)。

  10. AgentTokenEstimator 没有测试文件。src/test 下搜 *Estimator* 0 命中。它是 96 行的纯函数、 同时服务于历史截断与计量兜底两条路,却没有一条用例钉住 「CJK 1 字 1 token / 其余 4 字符 1 token / 空串返回 0」这三个口径。 改动它不会有任何测试报警。

  11. status 列声明了 3 个取值,代码只写 2 个。AgentUsageLogServiceImpl:46-47 定义了 STATUS_SUCCESS / STATUS_FAILURE 两个常量, DO 与 DDL 注释都写「SUCCESS / FAILURE / CANCELLED」, 但 agent_usage_log.status 没有任何写入点用 CANCELLED——流式对话被取消时走的是 recordFailure(doOnCancel 里),落的是 FAILURE。 想按「用户主动取消」筛账单的话,现在筛不出来。

    ⚠️ 别把这条推广成「全仓没有 CANCELLED」:agent_message.status 是会写 CANCELLED 的 (AgentAssistantServiceImpl:88 定义 STATUS_CANCELLED,settleDraft:535 在 doOnCancel 里落一条「本次规划已中断,可以重新提问」;WorkflowExecutionStatusEnum:109 也有 CANCELLED)。 也就是说同一次「用户点停止」,消息表记 CANCELLED、账单表记 FAILURE—— 两张表口径不一致,而账单表那一侧是丢信息的那个。

  12. 前端 AgentQuotaUsageVO 比后端 QuotaUsageBO 少 3 个字段——这是对的。 后端 BO 有 9 个字段(level/dimension/targetKey/period/usedTokens/tokenLimit/ usedCost/costLimit/exceeded),前端 VO 只声明 6 个,缺 dimension 与 targetKey, 且把 level 窄化成 'TENANT' | 'USER'。 不是漏写:getQuotaUsage:114-127 只查 selectUserQuota + selectTenantQuota, AGENT / WORKFLOW 走各自的 check 方法、不进用户自查看板 (QuotaUsageBO:88-91 的 javadoc 明写这条)。 但 level 的赋值是 usage.setLevel(dim.getDimension())(:320), 所以如果将来把 AGENT/WORKFLOW 也塞进自查,前端那个联合类型会立刻变成谎言。

fail-closed 方向(拦住、报错、拒绝) ​

  1. 计量写失败 → 业务照常成功。 这不是 fail-open 而是刻意取舍: record() 的 catch 打 log.error 并吞掉异常(:101-107)。 监控上要盯的就是这条 ERROR——「账单将出现空洞」是它自己的措辞。

  2. 配额行缺失或 status 非 0 → 放行。 isEnabled:272-275。 注意 status 的 0/1 与直觉相反(AgentQuotaDO:87)。

  3. 超限 → 抛 AGENT_QUOTA_EXCEEDED,文案按维度换主语。checkQuota:72-75:USER 维度说「您」,TENANT 维度说「本租户」; AGENT / WORKFLOW 走 checkTargetQuota:108-109,主语是「Agent(code)」/「工作流(code)」。

  4. 同维度同目标重复 → 抛 AGENT_QUOTA_DUPLICATE。 saveQuota:141-143。 数据库唯一键拦不住(NULL 不互撞),这道应用层判重是唯一的防线。

  5. 配额缺目标 → 抛 AGENT_QUOTA_TARGET_REQUIRED。 normalize:220-228。 缺目标的配额行永远匹配不到用量、等于静默失效,所以保存时就拦下。

  6. input_text 与 error_msg 都走 truncateForColumn 而不是 StrUtil.maxLength。AgentUsageLogServiceImpl:92-96 的注释:

    后者返回 n+3 个字符(末尾补三个点),写进 varchar(500) 会插入失败。 而这一行在异常路径上——报错信息越长越写不进去, 恰好在最需要留证据的时候丢掉整条计量记录。

    同理 price_snapshot 也再截一次(:32-38): 「超长时 MySQL 抛的是 Data truncation,而它会把【整条计量记录】一起带走—— token 烧了、账上查无此事。列宽是容量,截断是保险,两层都要有」。

  7. 改配额会清掉预警去重标记。 saveQuota:144-147:

    额度从 10 万调到 100 万之后,旧的「本月已预警过」会让新额度的预警一直发不出来—— 而这正是最需要预警的时候(额度刚放宽,用量往往会涨)。

  8. 统计口径含失败与中断的调用,且不过滤软删的会话/定义。 两条都是反刷额度的刻意设计,见 核心机制 7。 想「优化」成只统计成功调用、或加上 c.deleted=0,都会开出一个洗配额的口子。

→ 本节是贴着上下文的提醒;按症状查的全域清单见坑位索引。

测试 ​

文件@Test行数覆盖什么
src/test/.../service/quota/AgentQuotaServiceImplTest.java24506四维度评估、超限判定、归一化、判重、预警去重
src/test/.../service/usage/AgentUsagePricingTest.java5153计价:取生效价、快照、取不到价算 0、取价异常不丢记录
src/test/.../service/chat/AgentAssistantServiceImplUsageTest.java4223聊天路径的记账:PROVIDER/ESTIMATED 分流、失败也记
合计33902—

配额那一支的密度明显高于计量(24 vs 9),与「配额是管钱的、算错方向会拦人」的定位一致。

三个空白

  • AgentTokenEstimator 零测试(96 行纯函数,两条路共用)
  • AgentUsageLogRetentionJob 零测试(而它从未被注册,见坑 2)
  • AgentUsageLogMapper 的两条原生 @Select(selectUsageSumByAgent)与一条 @Update (clearExpiredInputText)没有 DB 测试——enlev-module-agent 没有 src/test/resources/application-unit-test.yaml,跑不了 BaseDbUnitTest(见 架构总览)。这三条 SQL 里的 JOIN 与 LIMIT 语义目前只靠人工核对。

未证实 ​

读源码读到、但没能取证的部分:

  • selectUsageSum / selectUsageSumByAgent / sumTokensByWorkflowCode 三条聚合 SQL 的 实际执行计划与耗时。三条都在对话入口的热路径上,且 selectUsageSumByAgent 是三表 JOIN;agent_usage_log 上有 idx_tenant_scene_time 与 idx_tenant_user_time 两个复合索引, 但 JOIN 条件走的是 conversation_id(只有单列索引 idx_conversation_id)。 没有 EXPLAIN 输出,无法判断「活跃租户几万行」时的真实开销。
  • agent_model_price 的 platform 值与 AiChatRespDTO.getPlatform() 回传值是否逐字一致。 匹配是 eqIfPresent(platform) + eq(modelName),任何大小写或别名差异都会导致取不到价。 未启动实测。
  • CANCELLED 这个状态值是否曾被写入过历史数据(代码里现在没有写入点, 但 DDL 注释与 DO 注释都列了它,可能是早期版本留下的存量行)。

推理得出、未取证的部分:

  • 「TENANT/USER 配额对工作流消耗全瞎」是从 LlmNodeExecutor:88-93 与 AgentNodeExecutionMapper:68-72 两处源码直接推出的, 没有实际跑一次工作流再去查 agent_usage_log 验证。 两处注释都明写了这个取舍,所以推断的可信度高,但仍属推理。
  • 「演示库上每条记录 cost 恒为 0」是从「唯一价目行的 model_name 在 ai_model 里不存在」 推出的。前提是 resp.getModel() 回传的型号串与 ai_model.model 同源—— 这一前提未验证。若某厂商回传的正是 qwen3.7-plus,那一行就会命中。
  • 「配额可并发穿透」是从「无锁 + 三步非原子」推出的,没有做并发压测取证。

相关页面 ​