你的Agent上线了,但Token消耗是0——不是没花钱,是框架bug让你看不到
今天跑了一整天Agent监控的代码,写完TraceListener、质量评估器、成本分析器,满心欢喜看Token统计——全是0。
不是没花钱。SiliconFlow后台清清楚楚扣了几百Token的钱。但你的监控代码告诉你:Token消耗=0。
这就好比你的车油表永远显示满格,不是真的满,是油表传感器坏了。你照样在烧油,只是不知道烧了多少。
问题根因?LangChain4j agentic beta版的一个bug——AgentInvocationHandler.invokeStandaloneAgent()在通知AgentListener时,把chatResponse和chatRequest都传了null。于是你从AgentResponse里拿到的TokenUsage永远是null,Token永远是0。
说白了,框架帮你调了LLM、帮你扣了钱,但没把LLM返回的Token数据传给你。你被蒙在鼓里。
这篇文章记录我怎么踩到这个坑,又怎么绕过它——从手敲TraceListener开始,到发现Token=0的bug,到注入ChatModelListener层级的TokenAccumulator解决问题。全程真实代码,真实运行数据,真实踩坑。
第一件:TraceListener——Agent执行的全生命周期追踪
Agent不是一次LLM调用就完事的。一次Agent执行,可能经历:收到输入→调LLM→决定用哪个工具→执行工具→拿到结果→再调LLM→最终输出。这中间每一个步骤,你都需要知道发生了什么。
LangChain4j agentic提供了AgentListener接口,7个hook覆盖Agent执行的全生命周期:
agent_start → Agent开始执行(拿到输入参数)
agent_end → Agent结束执行(拿到最终输出)
tool_start → 工具开始执行(拿到工具名+参数)
tool_end → 工具结束执行(拿到结果+耗时)
error → 出错了(拿到异常信息)
start_context → 进入上下文(拿到agenticScope)
end_context → 退出上下文(拿到agenticScope)
七个hook,从进到出,从工具调用到异常捕获,全覆盖。
手敲一个TraceListener,核心逻辑很直白:每个hook记录一条TraceRecord,存到ArrayList里,最后汇总输出轨迹报告。
关键设计点有两个:
1. inheritedBySubagents()返回true。Agent调用子Agent时,如果不继承Listener,子Agent的执行轨迹你就看不到了。这个方法让子Agent自动继承父Agent的Listener。
2. TraceRecord用record类型。Java 14+的record,一行代码定义一个不可变数据结构,比写class+getter简洁太多:
public record TraceRecord(
String type, // agent_start/tool_end/error...
String agentName, // "chat" 或 "chat/getWeather"
String content, // 事件内容
long durationMs, // 耗时(-1表示无数据)
int tokenCount // Token消耗(-1表示无数据)
) {}
跑一下纯对话场景:
🔍 [TRACE] Agent开始: chat | 输入: {userMessage:你好,请用一句话介绍你自己}
✅ [TRACE] Agent结束: chat | Token:N/A | 输出: 你好!我是一个AI智能助手...
TraceRecord拿到了agentName、拿到了output、拿到了时间戳。但Token那里显示N/A——这就是坑的起点。
第二件:Token=0的bug——agentic模块把你蒙在鼓里
看到Token:N/A的时候,第一反应是:是不是我代码写错了?从AgentResponse里拿chatResponse,再从chatResponse里拿tokenUsage,逻辑没问题啊。
于是加了一段DEBUG诊断代码,把AgentResponse的所有字段全打印出来:
🔍 [DEBUG] AgentResponse字段诊断:
agentName: chat
output: 你好!我是一个AI智能助手...
chatResponse: null ← 这就是Token=0的原因!
chatRequest: null
agenticScope: 存在(memoryId=7aaf8056-...)
agent: 存在(name=chat)
inputs: {userMessage:你好,请用一句话介绍你自己}
chatResponse: null——不是我没拿到,是框架压根没传给我。
翻到LangChain4j的GitHub源码,找到AgentInvocationHandler.java,看invokeStandaloneAgent()方法:
// 源码(github已确认)
Object result = agent.invoke(standaloneAgenticScope);
ListenerNotifierUtil.afterAgentInvocation(
agentListener, standaloneAgenticScope, this, namedArgs, result);
// ↑ 这是5参数版本
// 内部调用: afterAgentInvocation(listener, scope, agent, inputs, output, null, null);
// chatResponse和chatRequest都是null!
5参数版本内部调用了6参数版本,最后两个参数——chatResponse和chatRequest——直接传了null。这两个null就是Token=0的根因。
而LLM API明明返回了Token数据:
{"usage":{"prompt_tokens":456,"completion_tokens":60,"total_tokens":516}}
数据在HTTP响应里,清清楚楚。但agentic模块在包装成AgentResponse时,把chatResponse扔掉了。你的AgentListener拿到的AgentResponse,Token永远是0。
这bug影响多大?
生产环境里,Token就是钱。SiliconFlow GLM-5.1大概¥0.001/千Token,看着便宜,但Agent一次执行可能调用3-5次LLM,一天跑几千次,一个月下来几百块。如果Token统计永远是0,你根本不知道成本,也无法优化。
更严重的是——你不知道哪个工具调用最耗Token。一次"上海和北京温度差"的查询,LLM调用3次,消耗1755 Token。如果Token=0,你连这个数字都看不到,更别说分析瓶颈了。
那怎么解决?
agentic模块的bug不是我能修的(那是LangChain4j团队的事)。但Token数据不在agentic层,在ChatModel层。ChatModel每次调用LLM都会触发ChatModelListener,这个Listener能拿到完整的ChatResponse,包括TokenUsage。
思路很直白:绕过agentic模块,直接从ChatModel层拦截Token数据。
第三件:TokenAccumulator——绕过bug,从ChatModel层拿真实数据
写一个TokenAccumulator,实现ChatModelListener接口(不是AgentListener),注入到ChatModel的listeners里。
核心逻辑只有两个方法:
public class TokenAccumulator implements ChatModelListener {
private final AtomicInteger inputTokens = new AtomicInteger(0);
private final AtomicInteger outputTokens = new AtomicInteger(0);
private final AtomicInteger totalTokens = new AtomicInteger(0);
private final AtomicInteger callCount = new AtomicInteger(0);
@Override
public void onResponse(ChatModelResponseContext context) {
// 从ChatResponse直接拿TokenUsage——这里是真实数据,不是null
TokenUsage usage = context.chatResponse().tokenUsage();
if (usage != null) {
inputTokens.addAndGet(usage.inputTokenCount());
outputTokens.addAndGet(usage.outputTokenCount());
totalTokens.addAndGet(usage.totalTokenCount());
callCount.incrementAndGet();
}
}
}
为什么用AtomicInteger?因为Agent可能并发调用多个LLM请求(比如同时查两个城市的天气),线程安全是必须的。
注入方式也很简单——OpenAiChatModel.builder()有.listeners()方法:
TokenAccumulator tokenAccumulator = new TokenAccumulator();
ChatModel chatModel = OpenAiChatModel.builder()
.baseUrl("https://api.siliconflow.cn/v1")
.apiKey(apiKey)
.modelName("Pro/zai-org/GLM-5.1")
.listeners(tokenAccumulator) // ← 关键!注入到ChatModel层
.build();
跑一下,Token终于有数据了:
🔢 [TOKEN] LLM调用#1 | 输入:456 | 输出:60 | 总:516 Token
🔢 Token统计: LLM调用1次 | 输入Token:456 | 输出Token:60 | 总Token:516
数据流对比:
❌ 原路径(agentic bug):
LLM API → ChatResponse → agentic(传null) → AgentResponse(chatResponse=null) → TraceListener → Token=0
✅ 新路径(ChatModelListener):
LLM API → ChatResponse → ChatModelListener.onResponse() → TokenAccumulator → Token=516
一条线被agentic模块截断了,另一条线直接从ChatModel层拿到真实数据。两条线并行,互不影响。
这个bug什么时候能修?LangChain4j agentic还是beta版,正式版应该会修——但beta阶段就是踩坑的阶段,你得自己想办法。
第四件:质量评估+成本分析——Agent的体检报告
Token数据有了,下一步是评估Agent每次执行的质量和成本。
质量评估(AgentQualityEvaluator):5维度加权评分,每个维度独立打分,最后加权汇总:
| 维度 | 权重 | 判断逻辑 |
|---|---|---|
| 完整性 | 0.30 | Agent有没有返回结果?输出是不是空? |
| 工具成功率 | 0.25 | 工具调用成功率多少?失败率高=Agent不靠谱 |
| 效率 | 0.15 | 实际步骤数 vs 理论最少步骤数,越接近1越好 |
| 耗时合理性 | 0.15 | 总耗时在合理范围内吗? |
| 错误率 | 0.15 | 有没有异常?错误越多分数越低 |
评级标准:A(≥90)、B(70-89)、C(50-69)、D(30-49)、F(<30)。
成本分析(CostAnalyzer):Token费用+时间分布+瓶颈识别+速度分级。
速度分级挺有意思——把每步耗时分成4档:
| 耗时 | 评级 | 含义 |
|---|---|---|
| <2s | ⚡快 | 几乎无感 |
| 2-5s | 🟢正常 | 用户能接受 |
| 5-10s | 🟡慢 | 用户开始不耐烦 |
| >10s | 🔴很慢 | 用户可能放弃 |
这个分级标准来自真实用户体验数据:2秒以内几乎无感,5秒以上开始烦躁,10秒以上会考虑关掉页面。
三次测试的真实数据对比:
| 测试场景 | LLM调用次数 | 总Token | 质量评分 | 评级 | 瓶颈 |
|---|---|---|---|---|---|
| 纯对话 | 1 | 516 | 100 | A | 无 |
| 单工具(天气) | 2 | 1079 | 70.5 | B | getWeather(10s,🔴很慢) |
| 多工具串联 | 3 | 1755 | 91.8 | A | getWeather(5.9s,🟡慢) |
几个发现挺有意思的:
-
单工具测试质量只有B——因为getWeather那个外部API失败了(SSL握手错误),工具成功率直接变0%,拉低了整体评分。这正好说明质量评估能帮你发现问题——光看输出感觉Agent还行,但工具成功率是0%,Agent其实不靠谱。
-
多工具串联反而评分更高(A)——虽然步骤更多,但所有工具调用都成功了(天气API第二次成功了),效率分稍低但其他维度满分。
-
瓶颈永远在getWeather——3次测试,最耗时的步骤都是getWeather。这说明你需要优化这个外部API调用:加超时限制、加缓存、或者并行调用。
还踩了另一个坑:Java 15+移除了Nashorn JS引擎
写calculate工具时用了ScriptEngine("js")来计算数学表达式。跑起来直接报NullPointerException——ScriptEngineManager.getEngineByName("js")返回null。
查了一下才知道:Java 15正式移除了Nashorn JavaScript引擎。之前Oracle把它当独立模块提供,15之后就彻底没了。你的代码如果还依赖ScriptEngine("js"),在Java 17+上跑不动。
解决方案:手敲了一个SimpleCalculator——纯Java递归下降解析器,支持加减乘除、取模、幂运算。不依赖任何外部引擎,任何Java版本都能跑。虽然功能比Nashorn少(不支持变量、函数),但Agent的calculate工具只需要算简单表达式,够用了。
// 递归下降解析器核心逻辑
double parseExpression() {
double left = parseTerm();
while (pos < expr.length()) {
char op = expr.charAt(pos);
if (op == '+' || op == '-') {
pos++;
double right = parseTerm();
left = (op == '+') ? left + right : left - right;
} else break;
}
return left;
}
递归下降解析器是编译原理的经典算法,表达式解析是它的入门题。手敲一遍,既解决了实际问题,又复习了基础知识。
三个踩坑经验
-
agentic beta版AgentResponse.chatResponse()=null → Token统计永远是0 → 用ChatModelListener绕过,直接从LLM API拦截数据。beta版就是踩坑版,别指望框架给你完整数据。
-
Java 15+移除Nashorn → ScriptEngine(“js”)返回null → 手敲递归下降解析器替代。Java版本升级前,先检查你代码依赖的引擎是不是已经被移除了。
-
质量评估不是看输出"还行"就完事了 → 工具成功率0%但输出看起来正常 → 必须用数据量化,不能靠直觉。Agent的输出"还行"不代表执行过程没问题。
说白了,Agent监控的核心不是"能看到Agent执行了",而是"能看到Agent执行的过程出了什么问题"。Token=0不是没花钱,工具成功率0%不是Agent还行——数据才是真相,直觉不靠谱。
下一篇预告:Week6 Day7——Agent生产部署,怎么把你的Agent从Demo变成能跑在生产环境的东西。持续更新LLM实战踩坑实录。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐


所有评论(0)