本文是"AI 工程化实测"系列第 2 篇:这一篇只讲 token 计量这一件事。 实测环境:LangChain4j 1.22.0 + 1.22.0-beta32,JDK 21,Spring Boot 3.x,数字来自我们 Agent 项目的真实日志。
上线的第一个月,模型账单是供应商控制台里的一团雾:知道一共花了多少,不知道花在哪条请求上。 控制台的账只有总量,排障要问的却是"这条请求为什么烧了 2000 token",这个粒度只有自己的日志能回答。所以我给 LangChain4j 挂上计量器,一行日志同时解决排障和记账两件事。装完之后真正值钱的是两个发现:意图路由那次模型调用的 token 根本不在这条账里,失败调用的 token 你也一样要付。所以根据开发踩的坑, 我把两层计量器的挂法、真实日志的读法和单价口径一次整理出来,数字全部来自我们仓的实际运行。
先把计量器挂上:两层各管一段
LangChain4j 的 token 计量有两个挂点,管的地盘不一样。
第一层是 ChatModelListener,挂在模型的 builder 上,属于模型层出口。它的好处是所有调用都逃不掉,因为不管这次调用来自 AiServices 的编排,还是你在业务代码里直接 model.chat(prompt) 的裸调用,都要从模型层过:
java
@Bean
public ChatLanguageModel chatLanguageModel() {
return QwenChatModel.builder()
.apiKey(apiKey)
.modelName(chatModelName)
.listeners(new ChatModelListener() {
public void onResponse(ChatModelResponseContext ctx) {
var usage = ctx.chatResponse().tokenUsage();
if (usage != null) {
log.info("[计量] tokens: in={} out={} total={}",
usage.inputTokenCount(),
usage.outputTokenCount(),
usage.totalTokenCount());
}
}
public void onError(ChatModelErrorContext ctx) {
log.warn("[计量] error: {}", ctx.error().getMessage());
}
public void onRequest(ChatModelRequestContext ctx) { }
})
.build();
}
两个细节决定这日志能不能用。tokenUsage 要判空,流式和一些错误路径下它就是 null;回调里别做阻塞操作,日志一行就行。
第二层是 AiServiceListener,挂在 AiServices.registerListener() 上,属于业务层事件流。它拿到的是 AiServiceCompletedEvent,能从 Result 里取 tokenUsage(),打出来的日志能跟具体业务动作对上:
java
private AiServiceListener<AiServiceCompletedEvent> completedListener() {
return new AiServiceListener<>() {
public Class<AiServiceCompletedEvent> getEventClass() {
return AiServiceCompletedEvent.class;
}
public void onEvent(AiServiceCompletedEvent event) {
Object result = event.result().orElse(null);
if (result instanceof Result<?> r && r.tokenUsage() != null) {
TokenUsage tu = r.tokenUsage();
log.info("[可观测] tokens: in={} out={} total={}",
tu.inputTokenCount(),
tu.outputTokenCount(),
tu.totalTokenCount());
}
}
};
}
他们的分工:模型层管一笔不漏,业务层管账能对上业务。
一条日志能读出什么:真实读数
这是我们 Agent 项目某天的真实输出:
text
[可观测] tokens: in=1350 out=628 total=1978
单轮带两个工具调用的请求,total 落在 1500 到 2000 之间:输入 1100 到 1350,输出 350 到 630,浮动主要来自工具结果和检索片段的长度。读这条日志的关键在 in 远大于 out 这个形态上,1350 对 628 是 2.15 倍,它是 Agent 的普遍规律:工具结果和检索片段都要进上下文,输入侧天生是大头。成本治理的杠杆几乎全在输入侧,工具结果裁剪、检索片段限量、记忆窗口收紧,每一个都是砍 in 的闸门。
工具异常那次也要看。同一天另一条日志:
text
[可观测] tokens: in=1166 out=370 total=1536
那是一次工具异常、但模型仍然正常收场的调用,1500 多个 token 已经烧掉了(这组读数在仓库里标了待重测)。把两条放一起看,信息更多:
| 读数 | 带两个工具那次 | 工具异常那次 | 差值 |
|---|---|---|---|
| in | 1350 | 1166 | -184 |
| out | 628 | 370 | -258 |
| total | 1978 | 1536 | -442 |
两条不是同一请求的对照,只能看形态:工具异常这条的 in 仍然压在 1100 以上,一分没少,上下文在工具执行前就搭完了,输入侧的 token 照烧。按次计费的世界里没有失败不收费这回事,错误处理策略里得把这条算进去,重试上限和失败兜底都是成本变量。
单价口径:in 和 out 是两个价
供应商的账单公式是输入、输出分开计价的,别拿 total 乘一个单价了事:
text
请求成本 = 输入单价 × in / 1_000_000 + 输出单价 × out / 1_000_000
用仓库里的价目表(输入 1 元/百万 token、输出 2 元/百万 token):上面那条 total=1978 的请求是 1350 × 1 ÷ 100 万 + 628 × 2 ÷ 100 万 ≈ 0.0026 元,一千轮约 2.6 元。单条看都是零钱,账单是一天天累加出来的:按天把 in 和 out 分别求和,各乘各的单价再相加,才是当天的账。
有一个口径差异要先记着:如果供应商有 prompt 缓存且命中,输入部分按缓存价计,同样的 in 实际更便宜。基类 TokenUsage 不带缓存字段,OpenAI 系要用 OpenAiTokenUsage.inputTokensDetails().cachedTokens() 取,不取就会按全价算,估算一直比真实账单偏高。
两个漏账的地方
第一,裸调用逃逸。我们的 Agent 有一个意图路由,每次请求先跑一次小模型做分类(IntentRouter.java:60,就一行 routerModel.chat(prompt)),走的是裸 ChatModel,不产生 AiService 事件。只挂第二层计量器的话,路由的 token 完全不在账里。按公式摆:一次用户提问的真实调用次数是 1 次路由加 1 次主调用加若干工具轮,漏掉路由至少漏掉三分之一。模型层计量器的意义就在这,两层一起挂,账才闭合。
第二,双注册翻倍。listener 别在 yml 自动装配和 @Bean 手工装配两条路同时挂,同一调用计量翻倍,账直接错一倍。挂之前全局搜一遍 listeners 和 ChatModelListener,确认只有一处注册。
读数变了的两种情况
计量跑稳之后,每天先看形态有没有突变,绝对值反而次要。两种变化各有指向。
in 突然变大,先查上下文膨胀:是不是有人把检索的 topK 调大了、工具结果变长了、或者某个会话的记忆一直没清理,历史消息越滚越长。in 的上涨是线性叠加的:一段 500 字的检索片段每轮都进上下文,十轮下来就是 5000 字,换成 token 还得再乘一个系数,中文尤其吃亏。
out 突然变大,先查回答行为:模型是不是开始输出超长答案,或者陷入了某种重试循环,一次请求打了好几轮。out 按输出计价,单价常见是输入价的 2 到 4 倍(各家价目表不同),它涨一块,账单涨得比 in 快得多。
两种异动都能从那条一行日志里直接看出来,前提是你把日志按天落了表。没落表,异动只能靠月底账单惊觉。
落地建议
Spring Boot 项目里就三步。第一步,模型 bean 上挂 ChatModelListener,兜住全量调用。第二步,AiServices 上挂完成事件监听,日志跟业务动作对齐。第三步,建一张最简单的表:日期、场景、in、out、total 五个字段,日志落表,接上账单公式,月底的账就是一条 GROUP BY 日期 的 SUM 的事。
上线第一周建议只看不动,先摸清自己请求的 token 形态:in/out 比例多少、哪个场景的单轮最重,然后再决定先拧哪个闸门。否则顺序反了,优化就是靠猜了。
相关阅读:同一套环境下的 RAG 评测实测;《LangChain4j Could not resolve dependencies:稳定模块与 beta 实验模块版本成对升级》(CSDN,版本配对的坑)。仓库:hengdboy/langchain4j-in-production,tag 1.22.0-verified。