分析LLM Agent系统成本随复杂度快速增长的规律,帮助开发者做成本评估。
快速问答:在编码 Agent 的上下文长度中,缓存读取会占到下一次 API 调用成本的一半是在什么时候?到 50,000 个 token 时,你的对话成本可能已经被缓存读取主导了。
让我们退一步。我们之前写过关于编码 Agent 如何工作的文章:它们把到目前为止的对话发送给 LLM,然后在一个循环中这样做,只要 LLM 请求工具调用就继续。当没有更多工具要运行时,循环等待用户输入,整个周期重新开始。视觉上是这样的:
def loop(llm):
msg = user_input()
while True:
output, tool_calls = llm(msg)
print("Agent: ", output)
if tool_calls:
msg = [handle_tool_call(tc)
for tc in tool_calls]
else:
msg = user_input()
LLM 提供商对输入 token、缓存写、输出 token 和缓存读分别计费。这有点复杂:你在 prompt 中指示缓存到某个点(通常是末尾),你会被按"缓存写"而非输入来计费。前一轮的输出成为下一轮的缓存写。视觉上是这样的:
LLM 调用中的 Token 成本
这里的颜色和数字表示第 n 次 LLM 调用产生的成本。随后的每次调用都从缓存中读取到目前为止的历史,将前一次调用的输出写入缓存(以及任何新输入),然后获得输出。面积代表成本,尽管在这个图表中,它的比例不太准确。把所有矩形加起来,就是总成本。
看到那个为缓存读浮现出来的三角形吗?那就是可怕的二次方!
二次方有多可怕?相当平方的!我拿了一个相当普通的功能实现对话,用上面的图表方式将其可视化。面积对应成本:每个矩形的宽度是 token 数,高度是每 token 的成本。随着对话的进行,越来越多的成本来自于底部那些对应缓存读的细长线。
整个对话的成本总计约 12.93 美元。你可以看到随着对话继续,缓存读主导了成本。在对话末尾,缓存读占总成本的 87%。在 27,500 个 token 时,它们已经是成本的一半!
这个对话只是一个例子。这是否普遍发生?exe.dev 的 LLM 网关追踪我们产生的成本。我们不存储过去的消息本身,但我们确实追踪 token 的数量。下面的图表显示了许多 Shelley 对话的"累积成本"可视化,不仅仅是我自己的。我随机从数据中采样了 250 个对话。
x 轴是上下文长度,y 轴是到该点的累积成本。左图是所有成本,右图仅是缓存读。你可以鼠标悬停在两个图表上找到给定的对话。下方的箱线图显示了输入 token 和输出 token 的分布。
成本曲线各不相同,因为每个对话都不同。有些对话写了很多代码,所以在昂贵的输出 token 上花费更多。有些对话读了很多代码库,所以在工具调用输出上花费,这看起来像缓存写。有些对话在缓存过期时等待用户,因此不得不重新将数据写入缓存。在我们的数据中,中位数输入约 285 个 token,中位数输出约 100 个,但分布相当广。
让我们看看一些对话如何达到 100,000 个 token。我们从同一数据集中采样,但排除了 20 个调用以下的短对话,也排除了没有达到 100,000 个 token 的对话。对话中的 LLM 调用次数影响很大。缓存读成本并不真的是 token 数的平方;它是 token 数乘以调用次数,不同的对话有非常不同的 LLM 调用次数!
回到我们最初的问题,我们可以构建一个小型模拟器。Anthropic 的费率是输入 x、缓存写 1.25x、输出 5x、缓存读 x/10,其中 x = 每百万 token 5 美元(Opus 4.5)。在模拟器的默认设置中,只需 20,000 个 token 就能到达缓存读主导的点。
作为编码 Agent 开发者和 Agent 循环用户,这个成本结构让我有很多值得思考的东西!
一个比喻是"惯性导航"。如果我们让 Agent 在不反馈的情况下导航一个长任务(以工具调用和大量来回的形式),它会更便宜,但另一方面,我们知道正是反馈让 Agent 找到正确的目的地。在其他条件相同的情况下,更少的 LLM 调用会更便宜,但 Agent 的内部指南针是否关闭了,它是否朝着错误的方向走?
一些编码 Agent(包括 Shelley!)拒绝在某个阈值后向 Agent 返回大型工具输出。这是一个错误:它会读取整个文件,不如在一次调用中完成,而不是五次。
子 Agent 和自己调用 LLM 的工具是在主上下文窗口之外进行迭代的方法。例如,Shelley 使用"关键字搜索"工具作为 LLM 辅助的 grep。
重新开始对话可能感觉会丢失太多上下文,但为重新建立上下文而花费的 token 很可能比继续对话花费的 token 更便宜,通常效果是一样的。重新开始一个新对话总是感觉浪费,但后来我想起,当我开始一项新任务时,我一直从我的 git 仓库重新开始新对话;为什么继续现有任务会有所不同?
成本管理、上下文管理和 Agent 编排真的都是同一个问题吗?像递归语言模型这样的工作是否是正确的方法?
这些问题在我们开发 exe.dev 和 Shelley 时一直在我们的脑海中。告诉我们你的想法!