Agent 的 Token 都花在哪了:一次 ReAct 循环的账单拆解

举报
yd_232401777 发表于 2026/10/28 17:01:21 2026/10/28
【摘要】 Agent 的费用和单轮对话完全不是一个量级,原因不在模型贵,而在"每一轮都要重发上文"。一个 20 个工具、跑 5 轮的 Agent,光"告诉模型有哪些工具"这一项就要花掉几万 token。这篇文章把一次 ReAct 循环的 token 拆成四块算清楚,再给出五个压降手段和一套度量方法。

前言

单轮对话的成本很好理解:输入多少字、输出多少字,乘单价。做 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
15,5005,500
26,38011,880
37,26019,140
48,14027,280
59,02036,300
1013,42094,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 的成本拆开看了一遍,发现三件事和直觉不一致:

  1. 工具定义(20 个)比 system prompt 贵 2.6 倍,而且每轮都重复付
  2. 第 5 轮的 input 是首轮的 1.6 倍,但累计已经是首轮的 6.6 倍
  3. 一次"读文档"工具调用,返回量顶得上前面所有轮次历史的总和

对应的改动是:工具按意图分三组挂载、工具返回超 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 这个数字最能说明问题:它接近每轮工具返回的平均体积。如果它突然变大,说明某个工具开始返回超量内容了。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。