TokenTracker:给AI调用装个计费器
做AI应用开发有个很现实的问题------你不知道每次分析到底烧了多少钱。
SmartInspector跑一次完整分析,涉及意图路由、性能分析、源码归因、报告生成等多个LLM调用环节。如果不做Token追踪,成本完全是个黑箱。今天聊聊我们是怎么实现TokenTracker的,以及它在实际项目中怎么帮你控制成本。
为什么需要Token追踪
先看一个真实场景。SmartInspector的完整分析pipeline包含这些LLM调用:
- orchestrator(意图路由)------ 判断用户想做什么,max_tokens=5
- perf_analyzer(性能分析)------ 分析CPU热点、帧数据
- attributor(源码归因)------ 把耗时方法关联到项目源码
- reporter(报告生成)------ 输出结构化分析报告
- frame_analyzer(帧分析)------ 分析具体帧的渲染问题
跑一次完整分析,少说5-8次LLM调用,多的十几次。如果不追踪,你根本不知道:
- 哪个阶段最费Token?
- 一次分析大概多少钱?
- 有没有某个阶段在浪费Token?
这些问题不能靠猜,得靠数据。
设计思路:够用就行
TokenTracker的设计原则很简单:线程安全、按阶段统计、全局单例。
为什么是全局单例而不是依赖注入?因为SmartInspector的调用链比较深------orchestrator调agent,agent里调LLM,LLM返回结果。如果用依赖注入,tracker要一层层传下去,侵入性太大。全局单例虽然不够"优雅",但在这种场景下最实用。
python
# 全局单例
_tracker = TokenTracker()
def get_tracker() -> TokenTracker:
return _tracker
需要重置的时候(比如开始新一轮分析),调reset()就行。
核心实现
TokenTracker的完整代码不到150行,核心逻辑更少。看一下关键部分:
数据结构
每个阶段(stage)维护三个指标:input_tokens、output_tokens、调用次数。
python
def __init__(self):
self._lock = threading.RLock()
self._stages: dict[str, dict] = {}
用dict[str, dict]而不是自定义类,是因为这个结构足够简单,没必要过度设计。threading.RLock保证线程安全------SmartInspector有些场景下会并发调用LLM。
记录一次调用
python
def record(self, stage: str, usage: dict | None) -> None:
if not usage:
return
input_tokens = usage.get("input_tokens") or usage.get("prompt_tokens") or 0
output_tokens = usage.get("output_tokens") or usage.get("completion_tokens") or 0
with self._lock:
if stage not in self._stages:
self._stages[stage] = {"input_tokens": 0, "output_tokens": 0, "calls": 0}
self._stages[stage]["input_tokens"] += input_tokens
self._stages[stage]["output_tokens"] += output_tokens
self._stages[stage]["calls"] += 1
这里有个细节:key的兼容处理 。不同LLM provider返回的Token用量字段名不一样------OpenAI兼容接口用prompt_tokens/completion_tokens,Anthropic用input_tokens/output_tokens。两个都取,哪个有值用哪个。
从LangChain消息中提取
LangChain的AIMessage对象把Token信息藏在两个地方:usage_metadata和response_metadata.token_usage。所以封装了一个便捷方法:
python
def record_from_message(self, stage: str, message: object) -> None:
um = getattr(message, "usage_metadata", None)
if um:
self.record(stage, um)
return
rm = getattr(message, "response_metadata", {})
tu = rm.get("token_usage") if rm else None
if tu:
self.record(stage, tu)
优先取usage_metadata,这是LangChain标准的Token用量字段。如果没有(某些provider不支持),降级到response_metadata。
实际接入:每个Agent都在用
接入非常简单,每个Agent在拿到LLM响应后加一行:
python
# orchestrator.py
from smartinspector.token_tracker import get_tracker
response = self.llm.invoke(messages)
get_tracker().record_from_message("orchestrator", response)
python
# attributor.py
get_tracker().record_from_message("attributor", response)
python
# perf_analyzer.py - 甚至区分了重试
get_tracker().record_from_message("perf_analyzer", response)
# 重试时用不同的stage名
get_tracker().record_from_message("perf_analyzer_retry", retry_response)
用不同的stage名,这样在统计里能看到重试消耗了多少Token。这个设计在排查成本异常时特别有用------如果perf_analyzer_retry的Token占比异常高,说明这个Agent的prompt需要优化,导致频繁重试。
输出:一目了然的成本报表
TokenTracker的summary()方法输出一个表格:
markdown
Token usage:
Stage Input Output Total Calls
------------------------------------------------------
orchestrator 120 5 125 1
perf_analyzer 2.3k 856 3.2k 2
attributor 1.8k 412 2.2k 1
reporter 3.1k 1.2k 4.3k 1
frame_analyzer 1.5k 380 1.9k 1
------------------------------------------------------
TOTAL 8.8k 2.9k 11.7k 6
一眼就能看出:
- reporter最费Token------输入3.1k,因为要把所有分析结果喂进去
- orchestrator几乎不花钱------max_tokens=5的效果,单次才125个Token
- 总消耗约12k Token------按GPT-4的定价,大约几毛钱一次
这个表格在两个地方展示:pipeline结束时自动打印,以及用户执行/token命令时手动查看。
用TokenTracker做成本优化
有了数据,优化就有方向了。举几个实际例子:
1. 意图路由的极致压缩
orchestrator阶段单次只消耗125个Token,input 120 + output 5。这不是巧合,而是刻意设计的------意图路由只需要返回一个标签,所以把max_tokens设成5,prompt也尽量精简。
如果没有TokenTracker,你可能不会意识到这个优化空间。看到数据后就会想:还有什么地方可以像意图路由一样压缩?
2. 发现重试成本
perf_analyzer_retry这个stage单独记录重试的Token消耗。有一次发现重试消耗占总Token的30%,原因是prompt里给的上下文不够精确,导致LLM输出格式不对,触发了JSON解析失败→重试。优化prompt后,重试率降到了5%以下。
3. 报告生成的Token占比
reporter阶段的input_tokens是最高的,因为它要接收前面所有Agent的分析结果。这启发我们做了消息窗口裁剪------不是把所有原始数据都喂给reporter,而是先做一轮摘要。
换算成钱
TokenTracker目前只统计Token数量,不做金额换算。原因是不同模型价格差异太大,硬编码不合适。但在to_dict()输出的数据基础上,做换算很简单:
python
tracker = get_tracker()
data = tracker.to_dict()
# 假设用GPT-4o: input $2.5/1M tokens, output $10/1M tokens
cost = data["total_input"] * 2.5 / 1_000_000 + data["total_output"] * 10 / 1_000_000
print(f"本次分析成本: ${cost:.4f}")
这是我们下一步打算加的功能------根据当前使用的模型自动估算成本。
一些设计取舍
为什么不做实时监控? 没必要。一次分析通常几十秒就跑完了,结束时打印汇总就够了。如果要做成本预警(比如单次分析超过$1就报警),可以在record里加个阈值检查,但目前还没这个需求。
为什么用线程锁而不是asyncio锁? 因为LangChain的invoke是同步调用,整个pipeline跑在线程池里。如果以后改成全async,换成asyncio.Lock就行,接口不变。
为什么不做持久化? 每次CLI会话创建新的tracker,会话结束数据就丢了。如果要做历史趋势分析,可以把to_dict()的结果写到数据库或文件里。目前没有这个需求,保持简单。
小结
TokenTracker不是什么复杂的东西,但它解决了一个真实问题:让AI调用的成本可见。
150行代码,全局单例,线程安全,按阶段统计。接入成本几乎为零------每个Agent加一行代码就行。但带来的收益是实打实的:你能看到每个环节花了多少Token,哪个环节需要优化,一次分析到底多少钱。
做AI应用,Token就是你的"电费"。不装电表,你怎么知道该关哪盏灯?
本文是「SmartInspector架构解读」系列第6篇,源码在 GitHub,核心文件:
src/smartinspector/token_tracker.py