Agent 的 Token 都花在哪了:一次 ReAct 循环的账单拆解
前言
单轮对话的成本很好理解:输入多少字、输出多少字,乘单价。做 Agent 之后,账单常常莫名其妙涨到十几倍,而且找不到原因。
原因其实很朴素:Agent 是循环,每一轮都要把全部上文重新发一次。
本文用一个具体例子把账算开。所有数字都基于可推导的估算(token 数按经验比例折算),不依赖任何特定模型的报价——重点是结构,不是绝对值。
一、一次调用的四块 token
把任何一次 Agent 模型调用的 input 拆开,都是这四块:
| 块 | 内容 | 每轮变化 |
|---|---|---|
| 固定开销 | system prompt + 工具定义(JSON Schema) | 不变,但每轮都重发 |
| 累积开销 | 之前的 assistant 输出 + 工具返回 | 单调增长 |
| 当前输入 | 用户本次问题 / 工具结果 | 每轮不同 |
| 生成 | 模型 output(思考、工具调用、最终答案) | 每轮不同 |
四块里最容易被忽略的是前两块——它们不产生"新信息",但每一轮都在计费。
1.1 固定开销:工具定义是隐形大头
一个工具的 JSON Schema 里包含名称、描述、参数说明、枚举、必填项。写得规范一点,单个工具 150~300 token 很常见。
按 20 个工具、平均 200 token 算:
工具定义 ≈ 20 × 200 = 4000 token system prompt ≈ 1500 token 固定开销合计 ≈ 5500 token
这 5500 token 在每一轮都要重发一次。跑 10 轮,就是 55000 token 花在"重复介绍自己有哪些工具"上。
对比一下:只挂 5 个工具时,工具定义约 1000 token,固定开销降到 2500。工具数量对成本的影响是乘法级的,不是加法级的。
这也是为什么"把所有能想到的工具都挂上"是个昂贵的设计。模型每次只看得到有限的工具,挂 20 个和挂 5 个,实际用起来往往只差一两个,但账单差一倍。
1.2 累积开销:历史的平方效应
假设每轮的工具返回平均 800 token(一个中等规模的查询结果),assistant 的工具调用输出平均 80 token。
第 n 轮的历史长度 ≈ (n-1) × 880。
于是第 n 轮的 input ≈ 5500 + (n-1) × 880。
单轮看是线性增长,但总成本是求和,就成了平方级:
| 轮次 | 单轮 input | 累计 input |
|---|---|---|
| 1 | 5,500 | 5,500 |
| 2 | 6,380 | 11,880 |
| 3 | 7,260 | 19,140 |
| 4 | 8,140 | 27,280 |
| 5 | 9,020 | 36,300 |
| 10 | 13,420 | 94,900 |
跑 5 轮,累计 input 约 36K;跑 10 轮,约 95K。轮数翻倍,成本涨 2.6 倍——这就是平方效应的直观表现。
很多 Agent 在生产里跑十几轮才收敛,账单自然就上去了。
1.3 不可控项:工具返回的体积
上面假设每轮工具返回 800 token。但如果工具是"抓网页正文"或"读整个文件",返回 5000~20000 token 都正常。
把 800 换成 5000 重新算,5 轮的累计 input 变成约 78K,是原来的 2.1 倍。
工具返回是 Agent 成本里唯一不受你控制的部分,因为它取决于外部系统返回多少。所以这一块必须做边界约束,不能靠"希望它小"。
1.4 生成:相对便宜但不是零
生成的 token 单价通常高于输入,但量小——每轮几百。真正的成本在 input 的累积上。
除非模型有很长的思维链(每轮几千 token 的思考),那就另当别论了。
二、五个压降手段
按性价比排序。
2.1 工具按需挂载
不要一次性把所有工具都暴露给模型。按场景分组,只挂当前场景需要的:
TOOL_GROUPS = {
"order": ["query_order", "cancel_order", "refund"],
"logistics": ["track_shipment", "estimate_arrival"],
"policy": ["search_policy", "get_return_rules"],
}
def pick_tools(intent: str) -> list:
"""按第一阶段识别出的意图,只挂相关分组"""
return [t for g in TOOL_GROUPS.get(intent, []) for t in ALL_TOOLS[g]]
这一步通常直接把固定开销砍掉一半以上,而且往往还能提升准确率——工具少了,模型选错的概率也低了。
2.2 工具返回做截断 + 引用
不要把原始大结果整个塞进上下文,改成"摘要 + 句柄":
def fetch_page(url: str, max_chars: int = 2000) -> dict:
text = crawl(url)
if len(text) <= max_chars:
return {"content": text}
# 超长时只返回开头 + 一个句柄,需要细节时再按句柄取
return {
"summary": text[:max_chars],
"truncated": True,
"handle": store_cache(text), # 完整内容存外部
"hint": "内容已截断,需要更多请用 get_cached(handle, offset)",
}
关键在 hint:明确告诉模型"被截断了、怎么拿剩下的"。没有这句话,模型会以为看到的就是全部,然后基于残缺信息回答。
2.3 历史压缩:摘要 + 结构化暂存
不要保留完整历史。两种做法可以叠加:
做法一:滚动摘要。 超过 N 轮后,把早期轮次压成一段摘要,只保留结论和已完成动作。
做法二:结构化 scratchpad。 让模型维护一个显式的状态对象,替代流水账式历史:
# ❌ 流水账:每轮都完整保留,线性增长
history.append({"role": "assistant", "content": "我先查了订单 A,状态已发货,然后又查了物流..."})
# ✅ 结构化:只保留决策需要的字段
state = {
"intent": "查订单物流",
"collected": {"order_id": "SO-20261026-0001", "carrier": "SF"},
"open_questions": ["预计到达时间"],
}
结构化状态的好处是体积可控——不管跑多少轮,状态对象的字段是固定的。代价是要设计字段,需要想清楚这个任务到底需要记住什么。
2.4 前缀缓存
固定开销(system prompt + 工具定义)在每轮都完全一样,这是缓存命中的理想场景。
多数厂商都有 prompt caching:缓存命中的部分单价显著低于未命中,不同厂商折扣从 5 折到 1 折都有。对于固定开销占大头的 Agent,这一项省下来很可观。
要命中缓存,前提是前缀完全稳定。两个常见的破坏者:
- 把时间戳、随机 ID、用户昵称拼在 system prompt 开头 → 每轮都不同,永远不命中
- 工具列表顺序不稳定(比如从 set 里遍历)→ 序列化结果变了,缓存失效
正确做法是稳定内容在前、变化内容在后:
[system prompt(固定)] [工具定义(固定,顺序确定)] [历史(增长)] [当前问题(变化)]
2.5 子 Agent 隔离
当一个子任务会产生大量中间 token(比如"读完这 10 个文件,告诉我哪个有问题"),把它分给一个子 Agent 跑,只把结论返回主上下文。
子 Agent 用独立的上下文窗口,那 10 个文件的全文只在子上下文里存在,主上下文只收到一句"第 3、7 个文件有问题,因为..."。
这是最有效的降本手段之一,代价是多一次调用(多一份固定开销)。判断标准:中间结果的总量是否远大于结论。是,就隔离。
三、装一个 token 仪表盘
没有度量就没法优化。最少要记录四个数:每轮的 input、output、累计 input、以及工具返回的字符数。
有了这四个数,就能回答"钱花在哪"这个问题:
- 固定开销占比高 → 减工具、上缓存
- 累积开销占比高 → 压历史、上子 Agent
- 工具返回占比高 → 截断 + 引用
一个实用判据:如果累计 input 里固定开销(首轮 input)占比超过 50%,说明轮数还没跑起来,成本主要被工具定义吃掉了。
做了什么
按上面的结构把一个内部 Agent 的成本拆开看了一遍,发现三件事和直觉不一致:
- 工具定义(20 个)比 system prompt 贵 2.6 倍,而且每轮都重复付
- 第 5 轮的 input 是首轮的 1.6 倍,但累计已经是首轮的 6.6 倍
- 一次"读文档"工具调用,返回量顶得上前面所有轮次历史的总和
对应的改动是:工具按意图分三组挂载、工具返回超 2000 字符改摘要 + 句柄、历史从流水账换成结构化状态。
一些体会
Agent 的成本是结构问题,不是模型问题。 换个更便宜的模型能省 30%,改对上下文结构能省 60%,后者还顺带提升准确率。
"把工具都挂上让模型自己选"是很贵的偷懒。 省下的是产品设计(想清楚用户到底要干什么),花掉的是每轮 4000 token 的固定开销,还降低了选对工具的概率。
缓存不是免费的午餐,它要你先做对前缀设计。 时间戳打进 system prompt 开头这种小习惯,能让缓存命中率归零,而且从账单上看不出来——只会觉得"这个模型怎么这么贵"。
截断一定要带 hint。 只截断不说明,等于给模型半截信息还让它别问,这是幻觉的温床。
代码
一个最小的 token 计量包装器,四个数都能记下来:
import time
from dataclasses import dataclass, field
@dataclass
class TurnStat:
round: int
input_tokens: int
output_tokens: int
tool_chars: int = 0
@dataclass
class AgentMeter:
turns: list = field(default_factory=list)
def record(self, rnd: int, resp) -> None:
u = resp.usage # 各 SDK 字段名不同,取 input/output
self.turns.append(TurnStat(
round=rnd,
input_tokens=getattr(u, "input_tokens", 0),
output_tokens=getattr(u, "output_tokens", 0),
))
def record_tool(self, chars: int) -> None:
if self.turns:
self.turns[-1].tool_chars += chars
def report(self) -> dict:
if not self.turns:
return {}
first = self.turns[0].input_tokens
total_in = sum(t.input_tokens for t in self.turns)
total_out = sum(t.output_tokens for t in self.turns)
return {
"rounds": len(self.turns),
"first_input": first,
"total_input": total_in,
"total_output": total_out,
# 固定开销占比:首轮 input 大致等于 system + 工具定义
"fixed_ratio": round(first / total_in, 3) if total_in else 0,
# 平均每轮增量,用于估算历史增长速度
"avg_growth": round((self.turns[-1].input_tokens - first) / max(1, len(self.turns) - 1), 0),
}
用法:
meter = AgentMeter()
for rnd in range(1, MAX_ROUNDS + 1):
resp = llm.invoke(messages) # messages 里已含完整上文
meter.record(rnd, resp)
for call in resp.tool_calls:
result = run_tool(call)
meter.record_tool(len(result)) # 记录工具返回体积
messages.append(tool_msg(result))
print(meter.report())
# {'rounds': 5, 'first_input': 5520, 'total_input': 36300,
# 'total_output': 1420, 'fixed_ratio': 0.152, 'avg_growth': 880.0}
avg_growth 这个数字最能说明问题:它接近每轮工具返回的平均体积。如果它突然变大,说明某个工具开始返回超量内容了。
- 点赞
- 收藏
- 关注作者
评论(0)