读懂 browser-use 用量统计层:算了什么、漏了什么、哪步最贵
本文基于 browser-use 仓库 commit f0aa3a8(2026-07-27)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/browser-use/browser-use 最新代码与文档为准。
browser-use 的 browser_use/tokens/ 是一个「token 计数默认开、折算成钱默认关」的两段式设计——它记的是每次模型调用返回的 usage,不是你在服务商后台看到的那笔账。 搞混这一点,你会花一晚上排查「为什么成本是 0」,而答案往往只是某个开关没打开、或者你的模型名在价格表里查不到。
这篇只拆这一个模块,讲它的取价链路、拦截点和归因能力上限。站内另有几篇讲通用做法:想自己从零搭一套用量统计看 token 统计怎么做,想给失控的 Agent 加闸看 Agent 成本失控的控制手段,想把单次调用的开销压下去看 token 成本优化。本篇是「拿一个真实开源项目当样本,看它的统计层做到了哪、停在哪」。
一、先分清两件事:token 计数与折算成钱
统计层的入口是 browser_use/tokens/service.py 里的 TokenCost 类。它的构造函数只有两个参数:
def __init__(self, include_cost: bool = False, pricing_url: str | None = None):
self.include_cost = include_cost or os.getenv('BROWSER_USE_CALCULATE_COST', 'false').lower() == 'true'
self.pricing_url = pricing_url or CONFIG.BROWSER_USE_MODEL_PRICING_URL or self.DEFAULT_PRICING_URL
include_cost 默认是 False,也可以用环境变量 BROWSER_USE_CALCULATE_COST 打开。这个开关只管「要不要折算成钱」——add_usage() 往 usage_history 里追加记录这件事是无条件执行的,calculate_cost() 则在 include_cost 为假时直接 return None。
分开的意义在于代价不同。计数是纯本地的加法,把服务商返回的 usage 结构塞进一个内存列表;折算成钱要先有一张价格表,而价格表得联网拉。所以 initialize() 里只有 include_cost 为真时才会去 _load_pricing_data()。
从 Agent 那一侧看,这个开关叫 calculate_cost,仓库自带的说明文档 skills/open-source/references/monitoring.md 给的用法是:
agent = Agent(task="...", llm=llm, calculate_cost=True)
history = await agent.run()
# Access usage data
usage = history.usage
# Or via service
summary = await agent.token_cost_service.get_usage_summary()
browser_use/agent/service.py 里对应的一行是 self.token_cost_service = TokenCost(include_cost=calculate_cost, pricing_url=pricing_url),run 结束时把 get_usage_summary() 的结果赋给 self.history.usage,并调一次 log_usage_summary()。也就是说,你拿到的 history.usage 是一个跑完之后现算出来的汇总对象,不是运行期一直在累加的账。
二、价格从哪来:查价链路的先后顺序与一天的本地缓存
get_model_pricing(model_name) 是整个折算环节的心脏,它的查找顺序是有先后的,而且第一层就是硬编码:
# Check custom pricing first
if model_name in CUSTOM_MODEL_PRICING:
data = CUSTOM_MODEL_PRICING[model_name]
return ModelPricing(
model=model_name,
input_cost_per_token=data.get('input_cost_per_token'),
output_cost_per_token=data.get('output_cost_per_token'),
...
)
browser_use/tokens/custom_pricing.py 就是这张硬编码表,文件头的注释写明「Prices are per token (not per 1M tokens)」,结构刻意对齐了 LiteLLM 那份 JSON 的字段名。表里同时收了裸模型 id 和带 anthropic/ 前缀的写法两种键,因为这里的匹配是精确字符串比较,不做任何规范化——你传进来的名字长什么样,就得在表里长什么样。文件末尾还有两行别名赋值,把 bu-latest 和 smart 直接指向了另一个条目的同一个字典对象:
CUSTOM_MODEL_PRICING['bu-latest'] = CUSTOM_MODEL_PRICING['bu-2-0']
CUSTOM_MODEL_PRICING['smart'] = CUSTOM_MODEL_PRICING['bu-2-0']
查不到硬编码表,接下来分岔:模型名以 openrouter/ 或 openrouter- 开头的,先走 OpenRouter 那条取价线(下面单说);其余的才轮到远端价格文件。这个先后关系在源码里就是 get_model_pricing() 的语句顺序,读的时候别把它当成「远端优先」。DEFAULT_PRICING_URL 指向 LiteLLM 仓库里那份 model_prices_and_context_window.json,取回来的整份 JSON 被包成 CachedPricingData(带 timestamp 和 source_url 两个字段)写进本地缓存目录,目录名常量是 CACHE_DIR_NAME = 'browser_use/token_cost',挂在 xdg_cache_home() 下面,而 xdg_cache_home() 优先读配置里的 XDG_CACHE_HOME(且要求它是绝对路径),否则退回 Path.home() / '.cache'。缓存有效期常量是 CACHE_DURATION = timedelta(days=1)。
缓存这块有两个设计细节值得单独记住。一个是 _get_cache_status() 返回的是 (is_valid, should_delete) 二元组:过期的文件会被顺手删掉,解析失败的文件也被判为可删。另一个是 _cache_source_matches()——只有来源 URL 一致的缓存才会被复用,源码注释写的是「Keep caches from other sources so different pricing URLs don’t delete each other」。所以你把 pricing_url 指向自建的价格 JSON 时,不会把默认来源的缓存冲掉;旧格式里 source_url 为 None 的文件,只在你用的正是默认 URL 时才被认领。清理旧文件走 clean_old_caches(keep_count=3),同样只清理同源文件。
名字对不上怎么办?browser_use/tokens/mappings.py 就是那块补丁,而它小得让人意外,整个文件只有一张三条目的表:
# Mapping from model_name to LiteLLM model name
MODEL_TO_LITELLM: dict[str, str] = {
'gemini-flash-latest': 'gemini/gemini-flash-latest',
'gemini-3-flash-preview': 'gemini/gemini-3-flash-preview',
'gemini-3.1-flash-lite': 'gemini/gemini-3.1-flash-lite-preview',
}
三条里有一条连后缀都不一样,说明这张表纯粹是「哪个名字对不上就手工补一条」的产物,不是什么完备的映射规则。browser_use/llm/ 下有 15 个 provider 目录,各家的模型 id 命名口径本来就不统一,这张表明显只覆盖了被人踩到过的那几个。
回头说刚才跳过的 OpenRouter 那条线,它被单独放在 browser_use/tokens/openrouter_pricing.py:is_openrouter_pricing_model() 判断名字是不是以 openrouter/ 或 openrouter- 开头,_normalize_openrouter_model_id() 剥掉前缀并要求剩下的部分里必须含 /,然后从 OPENROUTER_MODELS_URL 拉一份模型元数据,按 id 建索引缓存在进程内,有效期常量是 OPENROUTER_MODELS_CACHE_SECONDS = 60 * 60。转换函数 model_pricing_from_openrouter_metadata() 把对方的 prompt、completion、input_cache_read、input_cache_write 映射成本项目的字段,把 context_length 和 top_provider.max_completion_tokens 填进上限字段。文件开头的注释点明了这条线存在的理由:让新模型在 LiteLLM 那份价格文件还没跟上时也能算价。
这几步都没命中,还有一个兜底:get_model_pricing() 的最后一行是无条件再试一次 get_openrouter_model_pricing(model_name),名字看起来不像 OpenRouter id 的话,那次调用会因为归一化失败而返回 None。
三、一次调用是怎么被记下的:包住 ainvoke 的那一层
register_llm(llm) 是拦截点。它用 str(id(llm)) 做键存进 registered_llms,避免同一个实例被包两次,然后做了一件很直白的事——把实例上的 ainvoke 换成自己的包装版:
async def tracked_ainvoke(messages, output_format=None, **kwargs):
# Call the original method, passing through any additional kwargs
result = await original_ainvoke(messages, output_format, **kwargs)
if result.usage:
usage = token_cost_service.add_usage(llm.model, result.usage)
...
替换动作用的是 object.__setattr__(llm, 'ainvoke', tracked_ainvoke),源码注释解释了原因:这样 Pydantic 支撑的模型对象才不会拒绝这次运行期打补丁。
两个后果直接从这段代码读得出来。第一,只有走 ainvoke 的调用会被记;browser_use/llm/base.py 里 BaseChatModel 是个 Protocol,ainvoke 就是它约定的调用入口,你如果绕过实例方法直连服务商 SDK,统计层看不到。第二,只有 result.usage 为真的调用会被记——服务商没回 usage 就静默跳过,add_usage() 都不会被调到。
记账的桶键是 llm.model,不是实例 id。Agent 初始化时一口气注册了主模型、page_extraction_llm、judge_llm,以及 settings.message_compaction.compaction_llm(存在时),后面切换降级模型时还会补注册 self._fallback_llm。这几个角色如果配的是同一个模型 id,它们的用量在 by_model 里会合成一桶。同理,_pricing_model_names[llm.model] = self._get_pricing_model_name(llm) 这一行也是按模型名写的,同名不同接入方式会互相覆盖。
_get_pricing_model_name() 处理的正是「同名但价不同」这个麻烦:当 llm.provider == 'openrouter' 或者 base_url 去掉尾斜杠后精确等于 OpenRouter 的 v1 地址时,给模型名加上 openrouter/ 前缀,让它走 OpenRouter 那条取价线,避免误用上游同名模型的价。
真正的算钱在 calculate_cost(),它做的第一件事是把服务商给的口径掰正:
uncached_prompt_tokens = usage.prompt_tokens - (usage.prompt_cached_tokens or 0)
pricing_multiplier = usage.pricing_multiplier or 1.0
browser_use/llm/views.py 里 ChatInvokeUsage 的字段注释写得很清楚,prompt_tokens 是含缓存命中部分的,算钱时得把缓存那块减掉。缓存写入还分了 5 分钟和 1 小时两档(prompt_cache_creation_5m_tokens / prompt_cache_creation_1h_tokens),1 小时档没有单独单价时回落到普通缓存写入单价。pricing_multiplier 是个整体倍率,字段注释举的例子是某些服务商的区域性定价差异。输出结果是 TokenCostCalculated,把新增 prompt、缓存读、缓存写、completion 四段分别记成本,并提供 prompt_cost 和 total_cost 两个属性做合计。
| 组成部分 | 它负责什么 | 仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
TokenCost 服务类 | 拦截调用、累加用量、按需算钱、打日志 | browser_use/tokens/service.py | 开 calculate_cost=True、或直接读 agent.token_cost_service 时 |
| 硬编码价格表 | 给远端价格文件里没有的模型兜底单价 | browser_use/tokens/custom_pricing.py | 你用的模型算出来成本是 None,怀疑查不到价时 |
| 模型名映射表 | 把本地模型名翻译成远端价格文件里的键 | browser_use/tokens/mappings.py | 模型名和价格文件里的写法不一致时 |
| OpenRouter 取价 | 从 OpenRouter 模型元数据现取单价并进程内缓存 | browser_use/tokens/openrouter_pricing.py | 走 OpenRouter 接入、或新模型远端价格还没收录时 |
| 数据结构定义 | ChatInvokeUsage 之外的用量/价格/汇总模型 | browser_use/tokens/views.py | 你要把 history.usage 落库或对接自己的看板时 |
| usage 原始结构 | 服务商回传的 token 明细口径 | browser_use/llm/views.py | 你要搞清「prompt_tokens 到底含不含缓存」时 |
| Agent 侧接线 | 注册各角色模型、收尾时汇总并写进 history | browser_use/agent/service.py | 你想知道哪些模型被自动纳入统计时 |
四、怎么定位「哪一步最贵」
先说清能力上限:TokenUsageEntry 只有 model、timestamp、usage 三个字段。没有步骤序号,没有动作名,没有当时的页面地址。所以「哪一步最贵」不是这个模块直接回答的问题,你得用它给的三样东西自己拼——模型名、时间戳、以及打开 debug 之后的逐次调用日志。
按模型名分账。 这是成本最低也最有效的一招。把 page_extraction_llm、judge_llm、压缩用的模型分别配成不同的模型 id,get_usage_summary() 返回的 by_model 就自然分成了几桶,每桶带 prompt_tokens、completion_tokens、invocations 和 average_tokens_per_invocation。这几个数放一起看信息量不小:invocations 异常高通常意味着循环没收住,average_tokens_per_invocation 异常高通常意味着上下文在膨胀,两者是完全不同的病,处方也不一样。
为什么抽取环节值得单独盯?看 browser_use/tools/service.py 里那个注册名为 extract 的动作就明白了——它的职责是让模型从页面 markdown 里抽结构化数据,动作描述里那句「LLM extracts structured data from page markdown」已经说明输入是整页正文,代码里还专门定义了一个字符上限常量来截断。页面正文进 prompt,天然就是单次输入体量最大的那类调用。
按时间窗切片。 get_usage_summary(model=None, since=None) 的 since 参数会过滤 timestamp >= since 的记录。你在关键动作前后各记一个时间点,就能算出这段区间的增量。想更干净地隔离,clear_history() 可以把内存里的历史清空,代价是之前的记录也没了,只适合手工排查场景,不适合长跑。
打开 debug 看逐次调用。 这是唯一能看到「单次调用长什么样」的途径。_log_usage() 会输出一行带模型名、输入分解、输出 token 的紧凑信息,输入部分由 _build_input_tokens_display() 拼装:有缓存信息时拆成新增 / 缓存读 / 缓存写三段分别显示,没有缓存信息时退回显示一个总数。token 数被 _format_tokens() 压成 k / M / B 后缀,开了成本的话每段后面还跟一个金额。
坑在于这些行全部走 cost_logger.debug(),而 cost_logger = logging.getLogger('cost') 是个独立于 browser_use 命名空间的 logger。日志级别没调到 debug,你什么都看不到——仓库的 issue 模板里也是让人设 BROWSER_USE_LOGGING_LEVEL=DEBUG 再贴日志的。另外 _log_usage() 是通过 create_task_with_error_handling(..., name='log_token_usage', suppress_exceptions=True) 异步发出去的,异常被吞掉,日志缺了几行不会有任何报错提示你。
汇总日志 log_usage_summary() 的行为也有个小前提:总计那一行只在 len(summary.by_model) > 1 时才打,单模型跑的时候你只会看到按模型的那一行(带 invocations 次数和每次平均 token)。
五、边界与代价:它明确不管的事
这个设计换来的是「零外部依赖、随手能开」,放弃的东西也很具体。
它统计的是调用侧的 usage,不是账单。 服务商侧的折扣、预付、批量定价、区域差异、免费额度,这里一概不知道(pricing_multiplier 只是把服务商回传的倍率乘上去而已)。各家规则不同且会调整,以官方最新说明为准。用它做「相对比较」和「趋势观察」是称手的,用它去跟财务对账会对不上。
取价失败是静默降级的。 _fetch_and_cache_pricing_data() 的异常分支只写一句 logger.debug,然后把 _pricing_data 置成空字典。查不到价时 get_model_pricing() 返回 None,calculate_cost() 跟着返回 None,最终汇总里那部分成本就是没有。表现出来就是「跑完了,token 有数,钱是 0」,而且没有任何显眼的告警。
没有步骤级归因。 前面说过,用量记录里没有步骤号和动作名。想要「第 7 步的 extract 动作花了多少」这种结构化归因,得靠外部链路追踪。仓库的 skills/open-source/references/monitoring.md 里列了 OpenLIT(OpenTelemetry 方向)和 Laminar 两条集成路线,那是另一套东西,不在本模块职责内。想了解按步骤摊成本的通用思路,可以看 Agent 成本按步骤分摊。
图像 token 有字段但不单独计价。 ChatInvokeUsage 里有 prompt_image_tokens,字段注释标明这是某家服务商特有的口径,且这部分已经包含在 prompt_tokens 里。UsageSummary 没有对应的汇总字段,calculate_cost() 也不对它单独取价。结论是:你没法从这个汇总里看出「截图占了多少钱」。
它不管浏览器那一侧的任何开销。 浏览器进程、代理、托管浏览器服务、网络流量、重试等待的时间成本,统计层完全不涉及。它的名字就叫 tokens,边界很诚实。
它更不管这次操作在真实世界里的后果。 这类工具会驱动真实浏览器,可能带着你的登录态在真实账号上点击提交,也会访问第三方站点。目标站点的使用条款是否允许自动化访问、会不会触发验证码或反自动化机制、账号会不会被判定为异常,都得你自己判断和承担。这里还有一层和成本直接相关的风险:extract 那类动作会把页面正文送进模型,那么最贵的一步往往同时也是外发数据最多的一步——如果那个页面是后台、订单详情或者内部系统,账单只是次要问题。这也是我建议按模型名分账的另一个理由,把抽取环节单独拎出来,你顺手也就看清了「哪些内容被送出去过」的量级。
六、上手与避坑清单
成本明细一行都看不到。 会踩是因为默认的日志级别过滤掉了 debug,而这个模块的全部输出都在 debug 级别,还挂在名为 cost 的独立 logger 上。避法是把日志级别调到 debug,或者单独给这个 logger 设级别,别指望开了 calculate_cost=True 就自动有输出。
token 有数但成本是 0 或 None。 会踩是因为查价三层都没命中,而失败路径只写 debug 日志。避法是在正式跑之前单独 await 一次 get_model_pricing() 传入你的模型名,看返回是不是 None;这一步几秒钟,能省掉一晚上的猜。
走自建网关或中转,模型名对不上。 会踩是因为 MODEL_TO_LITELLM 只有三条手工补丁,你自定义的模型名不可能在里面。避法有两条:把条目加进硬编码价格表,或者用 pricing_url / BROWSER_USE_MODEL_PRICING_URL 指向一份你自己维护的、结构对齐的 JSON。后者更适合团队长期用。
OpenRouter 接入却按上游价算了。 会踩是因为 _get_pricing_model_name() 的判定条件很窄:provider 等于 openrouter,或者 base_url 去掉尾斜杠后精确等于那个 v1 地址。你的 base_url 写法只要有一点出入,前缀就不会加上,于是同名模型按上游取价。避法是直接把模型名写成带 openrouter/ 前缀的形式,让 is_openrouter_pricing_model() 一眼认出来。
几个角色的开销分不开。 会踩是因为记账桶键是模型名,主模型和抽取、评判、压缩共用一个模型 id 时会合并。避法是给不同角色配不同模型 id(本来也该按难度分层配),或者退一步用 since 切时间窗手工隔离。
自己加总时把缓存成本算重了。 会踩是因为 UsageSummary 里 total_prompt_cached_cost 和 total_prompt_cache_creation_cost 这两项,已经含在 total_prompt_cost 里了(TokenCostCalculated.prompt_cost 就是三段之和)。避法是直接用 total_cost,别自己挑字段相加。
换了价格源却还是旧价。 会踩是因为缓存按天算有效期,而且不同来源的缓存文件互不覆盖。避法是显式调 refresh_pricing_data() 强制重取(它内部也看 include_cost,开关没开时是空转);顺手了解一下 clean_old_caches(keep_count=3) 的清理口径,它只动同源文件。
把 usage_history 当审计账本。 会踩是因为它就是一个内存 list,进程结束即消失;而且 get_usage_summary() 是逐条记录现算成本的,历史长了这段循环不便宜。避法是每轮跑完就把 history.usage 落到你自己的存储里,长期分析在你那边做,不要指望这个模块替你保存。
收个尾
判断这个模块够不够你用,三个问题就能问完:你的模型名在查价链路里能命中吗(不能就自己补表);你要区分的几个角色用的是不同模型 id 吗(不是就分不开账);你需要的是相对比较还是财务对账(后者它给不了)。
想往下读代码,顺序建议是 browser_use/llm/views.py 的 ChatInvokeUsage 先看——口径不清楚,后面的公式都是白读;然后 browser_use/tokens/service.py 的 register_llm 和 calculate_cost 两个方法,一个是拦截点一个是算式;最后翻 browser_use/tokens/views.py,把汇总结构对着你自己的看板字段捋一遍。至于这套统计该怎么接进团队的日常成本纪律,那是另一个话题,Agent 成本失控的控制手段那篇讲得更完整。
本篇属于一个把开源浏览器操作 Agent 项目 browser-use逐层拆开讲的系列,整体地图见 browser-use 是什么;沿着这条线往下,还可以看 browser-use 容器化拆解 和 browser-use 跑不动时的排查顺序。