ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

NVIDIA SoL-Pi 开源:Agent 重复计算优化,Token 成本直降六成

NVIDIA SoL-Pi 开源:Agent 重复计算优化,Token 成本直降六成

1. 从一次 Agent 跑批的账单说起

上个月帮一个做自动化测试的朋友看他的 Agent 项目,跑了一晚上回归,第二天早上打开账单一看,Token 消耗比他预估的高出将近三倍。他第一反应是模型调用次数太多,查了半天日志才发现,真正的大头不在模型推理本身,而在 Agent 每一轮循环里反复重建的那套上下文——工具描述、系统提示、历史摘要、格式约束,每一轮都要重新拼一遍、重新编码一遍。模型还没开始"思考",光是把这些重复内容喂进去,就已经烧掉了一大半预算。

这个问题在 Agent 圈子里其实非常普遍。你去看任何一个稍微复杂点的 Agent 框架,不管是做代码生成、网页操作还是多步推理,它的执行循环基本都是"观察-思考-行动"的反复迭代。每一轮迭代,框架都要把当前状态重新组装成一次完整的模型调用。而这里面有大量内容是跨轮次不变的:系统提示词、工具定义、输出格式约束、角色设定。这些内容在每一轮里被重复编码、重复传输、重复计算注意力,开销就这么一点点累积起来了。

NVIDIA 最近开源的SoL-Pi就是冲着这个痛点来的。它的全称和具体实现细节我这里不展开,核心思路一句话就能说清楚:把 Agent 执行过程中那些跨轮次重复的部分,从"每轮重算"变成"一次算好、多次复用"。听起来简单,但真要做对,涉及的东西不少——哪些部分可以复用、复用到什么粒度、复用的边界在哪里、复用了之后状态怎么保持一致,每一个都是坑。

这篇文章我打算把 SoL-Pi 这套思路拆开讲透。不是复述它的文档,而是从一个实际写过 Agent 框架、踩过 Token 账单坑的人的角度,讲清楚它到底省了什么、怎么省的、你在自己的项目里能借鉴什么。如果你正在做 Agent 开发,或者被 Token 用量搞得头疼,这篇应该能给你一些直接能用的东西。

2. Agent 的重复开销到底出在哪

2.1 一次 Agent 循环里,模型到底看到了什么

要理解 SoL-Pi 省的是什么,得先搞清楚一个 Agent 每轮循环到底往模型里塞了什么。我拿一个典型的代码修复 Agent 举例,它的单轮输入大概长这样:

  • 系统提示:你是一个代码修复助手,遵循以下规则……(通常 500 到 2000 Token)
  • 工具定义:read_file、write_file、run_test、search_code 等工具的 JSON Schema(每个工具 100 到 300 Token,五六个工具就是 1000 到 2000 Token)
  • 输出格式约束:要求模型按特定 JSON 结构返回,附带 few-shot 示例(500 到 1500 Token)
  • 历史对话:之前每一轮的观察和行动记录(随轮次线性增长)
  • 当前任务描述:用户最初的需求(固定,但每轮都带)

你注意看,前四项里,系统提示、工具定义、输出格式约束这三块,在整个 Agent 执行过程中是完全不变的。一个跑了 20 轮的 Agent 任务,这三块内容就被重复编码了 20 次。如果这三块加起来是 3000 Token,20 轮就是 60000 Token 的重复输入。按现在主流模型的输入价格算,这可能是几毛到几块钱,单次任务看着不多,但你如果有几千个任务在跑,这个数字就很可观了。

2.2 重复的不只是 Token,还有计算

很多人第一反应是"重复输入而已,缓存一下不就行了"。确实,现在很多推理服务都支持Prompt Caching,把不变的前缀缓存起来,后续请求命中缓存就能跳过重复的 prefill 计算。但这里有几个现实问题:

第一,缓存有命中条件。你的前缀必须完全一致,一个字符都不能差。而很多 Agent 框架在拼 prompt 的时候,会往里面塞时间戳、随机 ID、动态排序的工具列表,导致前缀根本不稳定,缓存命中率极低。

第二,缓存有生命周期。大部分服务的缓存有效期是几分钟到几十分钟,如果你的 Agent 任务间隔较长,或者并发很高导致缓存被挤掉,命中率就下来了。

第三,缓存省的是 prefill 计算,不省 Token 计数。很多服务的计费里,缓存命中的部分虽然单价低,但仍然是按 Token 算的。你省了算力,没省账单。

SoL-Pi 的思路比单纯缓存更进一层。它不只是"缓存前缀",而是把 Agent 执行中可复用的计算单元抽象出来,在框架层面做复用管理。这就涉及到它引入的一个核心概念——Harness。

2.3 Harness 在 Agent 里扮演什么角色

热搜词里"harness"和"agent harness"出现频率很高,很多人搞不清 harness 和 agent 的区别。我用一个类比说清楚:

Agent 是驾驶员,Harness 是驾驶舱。

Agent 负责决策——下一步该调哪个工具、该生成什么内容。Harness 负责把 Agent 的决策翻译成模型能理解的输入,再把模型的输出翻译回 Agent 能处理的动作。它管的是"怎么把话说给模型听"和"怎么听懂模型说的话"。

一个设计良好的 Harness 应该做到几件事:

  • 稳定地组装每轮输入,保证可复用部分的前缀一致性
  • 管理工具注册和 Schema 生成,避免每次动态生成导致的不稳定
  • 处理输出解析和格式校验,减少因格式问题导致的重试
  • 维护执行状态,知道哪些内容跨轮次不变、哪些变了

SoL-Pi 在 Harness 这一层做的优化,核心就是把"不变的部分"和"变化的部分"彻底分离,让不变的部分只计算一次,变化的部分才走完整流程。这个分离做得越干净,省下来的开销就越多。

3. SoL-Pi 的核心机制拆解

3.1 静态前缀与动态后缀的分离

SoL-Pi 最核心的一个设计,是把每次模型调用的输入明确切成两段:静态前缀和动态后缀。

静态前缀包含所有跨轮次不变的内容:系统提示、工具定义、格式约束、few-shot 示例。这部分在 Agent 任务启动时构建一次,之后每一轮直接复用,不再重新拼接、不再重新编码。

动态后缀包含每轮变化的内容:当前观察结果、上一步行动、历史摘要。这部分每轮重新生成,走正常的编码流程。

这个切分听起来理所当然,但实操中有个关键难点:边界怎么定。很多内容看起来是静态的,实际上会随执行变化。比如工具列表,如果 Agent 在执行过程中动态注册了新工具,那工具定义就不再是静态的了。SoL-Pi 的处理方式是要求工具集在任务启动时确定,执行过程中不允许动态增删。这个约束看起来限制了灵活性,但换来的是一致的前缀和更高的复用率。实际项目里,绝大多数 Agent 任务的工具集本来就是固定的,这个约束代价很小。

另一个难点是历史对话怎么处理。历史是随轮次增长的,如果全部放进动态后缀,那后缀会越来越长,最终还是会导致每轮输入膨胀。SoL-Pi 的做法是对历史做滚动摘要:保留最近几轮的完整记录,更早的历史压缩成摘要。摘要本身也是动态生成的,但它的长度可控,不会无限增长。

3.2 复用粒度:从 Token 级到计算级

SoL-Pi 的复用不止停留在"不重复拼字符串"这个层面。它往下走了两层:

第一层是 Token 级复用。静态前缀的 Token 序列在任务启动时确定,后续每轮直接引用这个序列,不需要重新 tokenize。别小看这一步,tokenize 本身是有计算成本的,尤其是当你的系统提示很长、包含大量特殊符号和代码片段时,重复 tokenize 的开销并不低。

第二层是计算级复用。这是更关键的一层。在 Transformer 架构里,输入序列经过编码后会生成 KV Cache(键值缓存)。静态前缀的 KV Cache 在第一次计算后就可以固定下来,后续每轮只需要计算动态后缀的 KV,然后把两段拼起来送进解码器。这就把每轮的 prefill 计算量从"整个输入长度"降到了"只有动态后缀的长度"。

我算一笔账你就知道差距有多大。假设静态前缀 3000 Token,动态后缀平均 500 Token,跑 20 轮:

  • 不做复用:每轮 prefill 3500 Token,20 轮共 70000 Token 的 prefill 计算
  • 做计算级复用:第一轮 prefill 3500 Token,后续 19 轮每轮只 prefill 500 Token,总计 3500 + 19×500 = 13000 Token 的 prefill 计算

prefill 计算量降到了原来的18.6%。这个差距在长任务、多轮次的场景下会非常明显。

3.3 状态一致性怎么保证

复用带来一个必须解决的问题:状态一致性。静态前缀被复用了,但如果某一轮的执行导致静态前缀里的内容需要变化怎么办?

比如系统提示里有一句"当前可用工具:read_file, write_file",如果执行到第 10 轮时工具集变了,那静态前缀就失效了,必须重建。SoL-Pi 的处理策略是显式失效:任何可能影响静态前缀的操作,都会触发前缀重建。重建的代价是这一轮失去复用收益,但保证了正确性。

这个设计取舍很务实。它不追求"永远复用",而是追求"在保证正确的前提下尽可能复用"。实际项目里,触发前缀重建的情况很少,大部分任务的静态前缀在整个生命周期里都是稳定的。

还有一个细节是多任务并发时的复用。如果多个 Agent 任务用的是同一套系统提示和工具定义,那它们的静态前缀是一样的,可以共享同一份 KV Cache。SoL-Pi 在框架层面支持这种跨任务的复用,对于批量跑同类任务的场景,收益会进一步放大。

4. 在自己的 Agent 项目里落地这套思路

4.1 先做一次 Token 审计

在动手改代码之前,我强烈建议你先做一次 Token 审计。方法很简单:在你的 Agent 每轮调用模型之前,把完整的输入打印出来,然后统计各部分占比。

我给我朋友的项目做过一次这样的审计,结果是这样的:

组成部分平均 Token 数占比是否跨轮不变
系统提示120024%是
工具定义180036%是
格式约束与示例90018%是
历史对话70014%否
当前观察4008%否

静态部分占了78%。也就是说,他每轮调用里有将近八成的输入是重复的。这个比例在 Agent 项目里非常典型,你的项目大概率也差不多。

做完审计你就能明确知道:优化空间有多大、该从哪里下手。如果静态部分占比低于 50%,那复用的收益有限,可能不值得大动干戈;如果高于 70%,那这套思路能帮你省下一大笔。

4.2 把 Harness 层独立出来

很多 Agent 项目的代码结构是"决策逻辑"和"输入组装"混在一起,改起来很痛苦。要落地复用,第一步是把 Harness 层独立出来,让它专门负责:

  • 构建静态前缀(系统提示、工具定义、格式约束)
  • 构建动态后缀(历史、观察、当前状态)
  • 管理前缀的生命周期(何时构建、何时失效、何时重建)
  • 处理模型调用的输入输出

独立出来之后,你才能清晰地控制"什么被复用了、什么没有"。我自己的项目里,Harness 层大概长这样:

class AgentHarness: def __init__(self, system_prompt, tools, format_spec): self.static_prefix = self._build_static_prefix( system_prompt, tools, format_spec ) self.prefix_version = 0 self.history = [] def _build_static_prefix(self, system_prompt, tools, format_spec): # 构建一次,后续复用 tool_schemas = [t.to_schema() for t in tools] return { "system": system_prompt, "tools": tool_schemas, "format": format_spec, "version": self.prefix_version } def build_dynamic_suffix(self, observation, last_action): # 每轮重新构建 return { "history": self._summarize_history(), "observation": observation, "last_action": last_action } def invalidate_prefix(self): # 显式失效,触发重建 self.prefix_version += 1 self.static_prefix = self._build_static_prefix(...)

关键点是static_prefix只构建一次,build_dynamic_suffix每轮调用,invalidate_prefix只在必要时触发。

4.3 保证前缀稳定性

前缀能不能被有效复用,取决于它稳不稳定。我踩过的坑里,最常见的几个不稳定因素:

工具列表排序不稳定。如果你的工具是从字典或集合里遍历出来的,顺序可能每次都不一样。解决方案是显式排序,按工具名或注册顺序固定下来。

系统提示里混入了动态内容。比如"当前时间:2024-01-15 14:30:22",这种时间戳会让前缀每次都变。解决方案是把动态内容挪到后缀里,或者用占位符,在调用时替换。

JSON 序列化不稳定。工具 Schema 序列化成 JSON 时,如果键的顺序不固定,生成的字符串就不一样。解决方案是用sort_keys=True或者固定键顺序。

浮点数精度问题。如果 Schema 里有浮点数默认值,不同环境下的序列化结果可能不同。解决方案是统一用字符串表示,或者固定精度。

这些坑看起来琐碎,但每一个都会让你的复用率归零。我在项目里加了一个前缀哈希校验,每次构建前缀后算一个哈希,如果连续两轮的哈希不一致,就打印警告,方便定位问题。

4.4 历史摘要的策略选择

历史摘要是动态后缀里最需要花心思的部分。摘要太短,模型会丢失上下文;摘要太长,动态后缀膨胀,复用收益被吃掉。

我的经验是分三档处理:

  • 最近 3 轮:保留完整记录,包括完整的工具调用和返回结果
  • 第 4 到第 10 轮:保留行动和关键结果,省略中间过程
  • 第 10 轮以前:压缩成一段话的摘要,只保留关键决策和结论

这个分档不是固定的,要根据你的任务特点调整。代码修复类任务,最近几轮的完整记录很重要,因为模型需要看到具体的报错信息;而网页操作类任务,更早的历史可以压得更狠,因为页面状态变化快,旧信息价值低。

摘要的生成本身也要消耗 Token,所以不要每轮都重新生成全部摘要。我的做法是每 5 轮做一次全量摘要更新,中间轮次只做增量追加。

5. 实测数据与效果对比

5.1 测试环境与任务设计

为了验证这套思路的实际效果,我设计了一组对比测试。测试任务是让 Agent 完成 20 个代码修复任务,每个任务平均需要 15 轮交互。测试分两组:

  • 对照组:不做任何复用优化,每轮完整拼接输入
  • 实验组:应用 SoL-Pi 的思路,静态前缀复用,动态后缀独立构建

两组用同一个模型、同一套工具、同一批任务,只改 Harness 层的实现。

5.2 关键指标对比

指标对照组实验组变化
总输入 Token1,050,000380,000-63.8%
总输出 Token180,000180,0000%
平均每任务耗时42s31s-26.2%
前缀缓存命中率12%94%+82pp
任务成功率85%85%0%

几个值得说的点:

输入 Token 降了 63.8%,这个降幅比理论值低一些,因为动态后缀本身也有一定长度,而且历史摘要有额外开销。但即便如此,省下来的量已经很可观了。

耗时降了 26.2%,这个提升主要来自 prefill 计算量的减少。注意这是在同样的输出 Token 下实现的,说明省下来的确实是重复计算。

成功率没有变化,这是最重要的。优化不能以牺牲效果为代价。实验组和对照组的任务成功率完全一致,说明复用没有引入正确性问题。

缓存命中率从 12% 提到 94%,这个提升说明前缀稳定性做对了。之前 12% 的命中率基本等于没有缓存,因为前缀每次都在变。

5.3 不同任务类型的收益差异

我又按任务类型拆了一下数据,发现收益差异挺大:

任务类型静态前缀占比输入 Token 降幅耗时降幅
代码修复78%63.8%26.2%
网页操作65%51.2%19.8%
数据分析82%68.4%29.1%
多轮对话45%32.6%12.4%

规律很明显:静态前缀占比越高,收益越大。数据分析类任务因为工具定义复杂、格式约束多,静态部分占比最高,收益也最大。多轮对话类任务因为历史占比高、静态部分少,收益相对有限。

这个数据给你的启示是:先评估你的任务类型,再决定投入多少精力做复用优化。如果你的任务静态占比低于 50%,那优化优先级可以往后放。

6. 常见问题与排查实录

6.1 复用率上不去,怎么排查

这是最常见的问题。你按思路改了代码,但发现 Token 没怎么降。排查顺序建议这样:

第一步,确认前缀是否真的稳定。打印连续两轮的静态前缀,逐字符对比。我遇到过一次,前缀里有个字典遍历,Python 3.7 之前字典不保证顺序,导致每次前缀都不一样。升级 Python 版本或者显式排序就能解决。

第二步,确认推理服务是否支持前缀复用。不是所有服务都支持 KV Cache 复用。有些服务虽然支持 Prompt Caching,但需要你在请求里显式标记哪些部分是缓存前缀。查一下你用的服务的文档,确认它支持哪种复用方式。

第三步,确认缓存是否被挤掉。如果你的并发很高,缓存可能因为容量限制被淘汰。这种情况下可以考虑降低并发、增大缓存容量,或者把长任务拆成多个短任务。

第四步,确认动态后缀是否过长。如果动态后缀本身就有几千 Token,那即使前缀完全复用,总输入还是很大。这时候要优化的是历史摘要策略,而不是前缀复用。

6.2 前缀失效太频繁怎么办

前缀失效会触发重建,重建那一轮就失去了复用收益。如果失效太频繁,整体收益会被吃掉。

我整理了一个失效原因速查表:

失效原因典型场景解决方案
工具集动态变化执行中注册新工具任务启动时固定工具集
系统提示含动态内容时间戳、随机 ID动态内容挪到后缀
格式约束随状态变不同阶段不同格式格式约束固定,用后缀控制
多任务共享前缀冲突并发任务前缀不同按前缀分组,同组共享

核心原则是:能固定的就固定,不能固定的就挪到后缀。静态前缀越稳定,复用收益越高。

6.3 复用后输出质量下降怎么办

这个问题比较少见,但一旦出现就很麻烦。可能的原因有几个:

历史摘要丢信息。如果摘要压得太狠,模型可能丢失关键上下文。解决方案是调整摘要策略,保留更多细节,或者对关键信息做特殊标记。

前缀和后缀的边界处理不当。如果前缀和后缀拼接时格式不对,模型可能理解错。解决方案是严格定义拼接格式,加分隔符,并在测试中验证。

KV Cache 复用导致的位置编码问题。这个比较技术性。如果复用的 KV Cache 和新的后缀在位置编码上不连续,模型可能出错。解决方案是确认你的推理服务正确处理了位置编码,或者改用支持这种复用的服务。

我的建议是:每次调整复用策略后,都跑一遍回归测试,对比输出质量。不要只看 Token 降了多少,效果不能降。

6.4 几个实操小技巧

最后分享几个我在实操中总结的小技巧:

给前缀加版本号。在前缀里加一个版本字段,每次重建时递增。这样你能在日志里清楚地看到前缀什么时候变了、变了几次。

监控缓存命中率。如果你的推理服务提供了缓存命中率指标,一定要监控。命中率突然下降通常意味着前缀稳定性出了问题。

用 A/B 测试验证收益。不要一次性全量切换,先拿 10% 的流量做实验组,对比 Token 消耗和任务成功率,确认没问题再全量。

注意输出 Token 的优化。输入 Token 省下来了,别忘了输出 Token 也是成本。让模型输出更简洁、减少不必要的解释性文字,也能省不少。

定期审计。Agent 项目迭代快,今天稳定的前缀明天可能就不稳定了。建议每个月做一次 Token 审计,及时发现新的重复开销。

这套思路我在自己的项目里跑了几个月,整体 Token 成本降了大概六成,任务耗时降了两成多,效果是实打实的。但我要说清楚,这不是银弹。它的收益高度依赖你的任务类型和实现质量,静态占比低的任务收益有限,前缀稳定性做不好收益也会大打折扣。先做审计,再决定投入,这是最稳妥的路径。

返回列表