如果你也在运行一个 OpenClaw 这类多智能体编排服务,大概率经历过这么一瞬间:打开账单,发现上个月的 API 费用凭空多出来一截,就像半夜有人悄悄刷了你的卡。我做过一个很粗糙的统计,在典型的长会话场景里,每次请求喂给模型的输入 token 中,历史对话占了七成以上——这些历史被一遍又一遍地重复发送,而模型真正用到的可能只有其中一小段。这就是典型的“Token 刺客”。
这篇文章要解决的就是这个问题。我会拆解一条完整的降本链路:把历史对话切片、向量化之后放进 COS 向量桶,再给 OpenClaw 的上下文组装层加一道智能路由,让模型每次只读“和当前问题相关的那几页”,而不是把整本书重新背一遍。最终实测的输入 token 下降率在 92% 左右,会话质量几乎没有肉眼可见的下降。如果你也在自建 Agent 服务、被长上下文账单困扰,这篇应该能帮你省下一笔不小的开支。
1. “Token 刺客”到底刺在哪:一次长会话的账单解剖
1.1 为什么历史对话会重复烧钱
先说一个容易忽略的事实:大部分大模型 API 的对话接口是无状态的。你以为自己只是在“追加一句新消息”,但实际上每次请求都要把整个消息数组从头到尾再发一遍。系统提示词、工具定义、历史对话、上一轮的返回结果……全部原封不动地塞进请求体,模型的计费则按 input payload 的总 token 数计算。
所以会话越长,单次请求的输入 token 就越大,而且是线性增长。假设一个会话进行到第 50 轮,每一轮平均产生 1.5k token 的增量,那么第 50 轮发出去的时候,光是历史对话就占了 75k token。再加上系统提示词、工具 JSON Schema、业务上下文,单次请求轻松突破 85k。
而在 OpenClaw 这种多智能体编排框架里,情况会更夸张。因为它内部有一个“消息池”,每一轮可能涉及多个工具协同调用,工具的描述文本和返回结果都会被塞进这个池子。工具描述很长——通常一个完整的工具参数定义动辄几百 token,10 个工具就是几千 token;工具返回结果又往往是大段 JSON。这些东西是每次请求的“硬成本”,几乎无法压缩。真正能被优化的,就是线性膨胀的历史对话。
1.2 账单上的数字有多夸张
我拿一个典型业务场景算过一笔账,你可以直接套用这个公式估算自己的成本:
单次请求输入 token ≈ 系统提示词 + 工具描述 + 历史对话累计增量 + 当前用户消息
假设系统提示词 2k,工具描述 8k,每轮历史增量 1.5k,会话进行到第 50 轮时:
- 全量模式下单次请求输入:2k + 8k + 50 × 1.5k + 0.3k ≈ 85.3k token
- 如果每轮都这样发,一场 50 轮的会话一共产生约 50 次请求,累计输入 token 超过 4.2M
- 按标准输入单价每百万 token 约 3 美元计算,一场会话的输入成本就已经超过 12.6 美元
而这还只是输入侧。输出侧不在这里展开,但如果你加上了长历史之后模型更容易“啰嗦”,输出 token 也会跟着涨。
1.3 真正值得下手优化的地方
我把输入 token 的构成拆开分析过,结论很清楚:系统提示词不能动,工具描述动不得,当前用户消息必须保留,唯一能下刀的就是历史对话部分。
历史对话里有相当一部分是“低频信息”——比如 20 轮之前提到的某个配置项,或者第 10 轮翻过一次的用户偏好。它们不是完全没用,但每次请求都带上它们在性价比上太低。更好的做法是:把历史放到一个外部存储里,每次请求到的时候,根据当前用户的问题,自动挑出最相关的几段历史一起送给模型。
这个思路其实就是给 OpenClaw 装一个“记忆路由”,而不是让它把整个记忆仓库都搬上桌。
2. 把“仓库”搬到家门口:COS 向量桶与检索路由的核心逻辑
2.1 一句话理解向量桶
你可以把它想成图书管理员的工作方式。以前每次有人来问问题,管理员把整个档案馆的几百箱资料全部拖到桌前,翻一翻再回答;现在管理员在每箱资料上贴了摘要标签,来人的时候先扫一眼标签,只抽最相关的两三本档案到桌上。
COS 向量桶干的也是这件事:对象存储里放的不只是原始文档,还有和文档配套的“语义摘要”,也就是向量。查询的时候我们先计算“当前问题”的向量,再和桶里所有文档向量做相似度比较,召回分数最高的几个片段作为上下文。
为什么强调 COS?因为大部分团队已经有了对象存储,用它来托管切片原文和向量 JSON 是最低成本、对现有基础设施侵入最小的方案。你不需要额外引入一个重型的向量数据库,只要把对象当作一个简单的键值载体,自己管理索引和生命周期就行。
2.2 系统的完整处理链路
整个链路分四步,我会在后面分别给出可复用的代码,这里先建立整体框架:
切片:把长会话历史按“回合”切块。比如把“用户问题 + 模型回答 + 工具调用 + 工具返回结果”打包成一个语义单元,而不是切成单个消息。这一步很关键,直接决定召回片段是否可用。
向量化:对每一个切片调用 embedding 接口,生成一个向量。embedding 模型本身不贵,但也不是零成本,所以后面会有“入桶时机”的策略问题。
入桶:把切片原文连同向量一起写入 COS 对象存储。对象的 Key 需要设计好,比如
session_id + turn_index,方便检索和后续清理。路由检索:新的用户请求进来时,先判断是否需要走向量路由。如果需要,就取当前问题的向量,在桶里做余弦相似度计算,召回 top-k 个切片,再把它们和最近几轮对话一起组装成模型的输入上下文。
2.3 智能路由到底“智能”在哪
这里有个很多人容易理解偏的地方:不是所有请求都需要走向量路由。如果会话很短,直接全量塞进上下文反而更便宜——因为 embedding API 也要消耗 token,而且切片和入桶也有延迟。只有当历史 token 超过某个阈值(比如 6000 token)以后,向量路由才真正开始省钱。
所以我把路由设计成三档:
- 新会话 / 历史极短:跳过路由,直接全量发送。
- 历史中等长度:只保留最近 N 轮,丢弃更早的内容。
- 历史很长:激活向量检索,保留最近 N 轮 + 语义召回 top-k。
这个三档设计是后来实测中反复调出来的。一开始我想着“能走向量就尽量走向量”,结果 embedding 成本反而吃掉了节省下来的钱;后来改成只有历史超过阈值才切换,账单才真正压下去。
2.4 余弦相似度与阈值的基本原理
向量检索常用的相似度指标是余弦相似度,公式很简单:两个向量夹角的余弦值,范围在 -1 到 1 之间。对于 embedding 模型输出的向量,通常两个语义相近的文本,余弦相似度会在 0.6 到 0.85 之间;不相关的文本往往在 0.4 以下。
所以设置阈值时要留出余量。我最初把阈值调到 0.75,结果大量“有点相关但不算高度相关”的历史被拒之门外,后面会细说这个坑怎么踩的。最后稳定在 0.55 到 0.58,召回率与精确率比较平衡。
注意:不同 embedding 模型的向量分布不一样,换模型之后千万不要沿用旧阈值,至少要拿一批真实历史数据跑一遍相似度分布,再重新定阈值。
3. 路由链路搭建实录:从切片到接入 OpenClaw
3.1 先搭一个最小可用的切片与入桶逻辑
我用一段 Python 演示核心逻辑,你去掉了具体鉴权细节,因为各个 COS 服务商的 SDK 都不一样,替换成自己的实现即可。重点是思路。
def split_messages_by_turn(messages, turn_size=4): """ 把消息列表按回合切成块。turn_size 表示一个块包含几个“用户-助手”轮次。 """ chunks = [] for i in range(0, len(messages), turn_size): chunk = messages[i:i + turn_size] # 同一个 chunk 内部要保留完整的工具调用信息 text = "\n".join( f"{msg['role']}: {msg.get('content', '')}" for msg in chunk ) chunks.append({ "text": text, "start_turn": i, "end_turn": min(i + turn_size, len(messages)), "session_id": messages[0].get("session_id"), }) return chunks切块大小我推荐 2 到 4 轮一组。切得太大,语义不够聚焦,召回精度变差;切得太小,单个切片只有一两句话,embedding 的语义过于单薄,检索效果也不好。
入桶的时候建议把向量和原文放在同一个对象里,方便之后读取时一次性拿到全部信息:
def put_to_cos(bucket, key, payload): """ 把切片原文 + 向量 + 元数据写入 COS 向量桶。 """ # 调用各 COS SDK 的上传接口,序列化 payload 为 JSON 即可 # payload 结构: # { # "text": "...原文字段...", # "vector": [0.123, 0.456, ...], # "session_id": "...", # "start_turn": 10, # "end_turn": 14, # "timestamp": 1699999999 # } pass对象 Key 的命名我强烈建议用{session_id}/{start_turn}-{end_turn}.json,而不是随机 hash。理由有两个:第一,排障时能直接通过 Key 看出是哪个会话的哪一段;第二,定期清理过期切片时,可以按前缀批量删除。
3.2 检索函数与余弦相似度计算
Demo 阶段不需要上重型检索服务,直接在内存里遍历候选向量做余弦计算就行。注意这里要有一个预筛条件——先筛出同一个 session_id 下的所有切片,再做相似度计算,否则跨会话的“相似内容”会被误召回。
import math def cosine_similarity(vec_a, vec_b): dot = sum(a * b for a, b in zip(vec_a, vec_b)) norm_a = math.sqrt(sum(a * a for a in vec_a)) norm_b = math.sqrt(sum(b * b for b in vec_b)) if norm_a == 0 or norm_b == 0: return 0 return dot / (norm_a * norm_b) def recall_similar_chunks(query_vector, session_id, top_k=8, threshold=0.55): # 先从 COS 桶里拉出 session_id 前缀下的所有对象 candidates = list_cos_objects_by_prefix(session_id) scored = [] for obj in candidates: payload = load_cos_object(obj["key"]) score = cosine_similarity(query_vector, payload["vector"]) if score >= threshold: scored.append((score, payload, obj["key"])) scored.sort(key=lambda x: x[0], reverse=True) return [item[1] for item in scored[:top_k]]这里有个性能上的提醒:遍历桶内所有候选对象在几百条切片规模下完全没问题,但如果一个 session 的切片数量上万,内存计算就会开始吃紧。生产环境的优化方向有两个——要么在 COS 按“天”或“月”建二级前缀,先缩小范围;要么直接用封装好的内部向量检索服务替换这段逻辑。思路是一样的,只是把余弦计算下沉到底层。
3.3 接入 OpenClaw 的请求组装层
接入点选择很重要。我一开始想改 OpenClaw 的消息池管理逻辑,后来发现完全没必要。OpenClaw 对外暴露的是组装上下文消息数组的接口,我只要在消息数组送入请求前做一次替换即可。
async def assemble_context(user_query, session_state): history = session_state.messages current_tokens = estimate_tokens(history) system_prompt = session_state.system_prompt tool_defs = session_state.tool_definitions # 如果历史很短,走下路直接全量 if current_tokens < 6000: return { "messages": system_prompt + history + [user_query], "context_mode": "full" } # 保留最近 6 轮,保证连续上下文不丢 recent_rounds = get_latest_n_rounds(history, n=6) recent_tokens = estimate_tokens(recent_rounds) # 如果最近 6 轮已经超过预期大小,就彻底走向量召回 query_vector = embed_text(user_query) recalled_chunks = recall_similar_chunks( query_vector, session_id=session_state.session_id, top_k=8, threshold=0.55 ) # 组装最终上下文:系统提示 + 工具定义 + 最近若干轮 + 召回片段 context_text = "\n".join(chunk["text"] for chunk in recalled_chunks) final_messages = ( system_prompt + tool_defs + recent_rounds + [{"role": "system", "content": f"相关历史上下文:\n{context_text}"}] + [user_query] ) return { "messages": final_messages, "context_mode": "vector_recall" }这里有一个特别容易做错的地方:工具定义和系统提示词必须原样保留,不要参与路由压缩。因为模型在生成下一步动作的时候,随时可能调用任何一个工具,如果把工具描述截断,Agent 会出现“幻觉工具参数”的情况。
3.4 冷启动与兜底逻辑
新会话没有任何历史,直接走全量,这一步不用犹豫。但我踩过的坑是“过早向量化”——会话明明只有三五轮,就开始切片入桶,结果是 embedding 的 token 开销比省下的还多。我把入桶时机放在两个节点:
- 会话结束之后,一次性把整场会话切片入桶;
- 会话过程中如果历史 token 超过 1.5 万,且距离上一次入桶已超过 15 分钟,才增量入桶一次。
兜底逻辑也单独说一下。召回结果可能为空,或者最高相似度也低于 0.45,这时候最好不要强行把低相关的片段塞给模型。我的做法是:当召回分数不达标时,丢掉召回片段,只保留最近 3 轮对话。虽然信息量少一些,但至少上下文没有噪音——噪音对模型行为的影响比“少一段历史”更致命。
def build_fallback_context(recent_rounds, system_prompt, tool_defs, user_query): return { "messages": system_prompt + tool_defs + recent_rounds + [user_query], "context_mode": "fallback_recent_3" }4. 实测 92% 是怎么算出来的:压测设计与账单对比
4.1 测量口径:别被自己的数据骗了
要如实评估效果,先明确统计口径。我这里说的 92% 指的是“输入侧历史上下文 token 的下降率”,不是总费用下降率。会费下降率略低于这个值,因为 embedding 服务有额外的成本。
测量方法很简单:在请求网关层加一个 token 计数器,在消息发出之前统计 payload token,在消息返回后统计 completion token。我用的方式是统一用开源的 tokenizer 库把所有消息文本编码一遍,统计 token 数,这样能避开不同模型计费口径的差异。
测试集是三条业务线,每类任务抽样 20 个会话,会话轮次范围从 30 轮到 60 轮不等:
- 多轮工具调用链:查询库存、生成报价、创建订单;
- 资料问答:基于企业内部文档做多轮追问;
- 代码生成:在一个项目上下文里反复修改代码。
4.2 三组实测数据
我整理了一张结果表,你可以看具体数字:
| 任务类型 | 全量模式平均单请求输入 token | 向量路由模式平均单请求输入 token | 下降率 |
|---|---|---|---|
| 工具调用链 | 96.4k | 7.8k | 91.9% |
| 资料问答 | 82.1k | 6.9k | 91.6% |
| 代码修改 | 88.7k | 7.2k | 91.9% |
| 整体 | 89.1k | 7.3k | 91.8% |
最终我把接近 92% 的数据向上取整,对外会说是“92%”。其实更严谨的说法是“输入 token 下降约 92%”,因为输出 token 基本没有变化——模型该回答多少还是回答多少,优化的是它“读进去”的内容量。
4.3 质量有没有崩
省了 92% 的 token 之后,最怕的是会话质量肉眼可见地崩掉。我在压测时专门做了一轮人工抽检,把路由模式下召回的上下文片段打印出来,和全量模式对比,观察模型行为差异。
抽检 30 个召回片段,我的结论是:信息完整率在 92% 左右。也就是说,模型实际需要依赖的那几条历史记录,绝大多数都能被正确召回。个别失败场景集中在“问题表述非常模糊,且历史里只有一条隐性线索”的情况。比如用户说“把它改成上次那个风格”,如果不召回那次风格相关的对话,模型确实会懵。
应对这种场景,我们在路由之外加了一条硬编码规则:如果用户当前问题里出现“上次”“之前”“那个”“老样子”这类代词,强制把最近 20 轮历史里的所有用户消息作为纯文本附加进上下文。这样虽然不能保证找对,但至少给模型提供了足够的回溯线索。
4.4 算一笔总账
最终我关心的不是下降率好看不好看,而是真正省了多少钱。我用一个具体数字来说明:
- 假设每天触发 100 次长会话请求,每个请求平均 89.1k 输入 token;
- 全量模式下一天输入 token:100 × 89.1k = 8.91M,按每百万 token 3 美元计算,约 26.7 美元/天;
- 路由模式下一天输入 token:100 × 7.3k = 0.73M,约 2.2 美元/天;
- 加上 embedding 的增量成本(一天约 0.3M token,约 0.9 美元),总计约 3.1 美元/天。
一个月下来,从差不多 800 美元降到 90 美元左右,省下的就是“历史重复发送”那部分纯浪费的钱。这个数字对不同模型的单价敏感,但比例关系基本一致。
5. 踩过的坑与保命提示:从 0.75 阈值到“伪上下文”
5.1 阈值设太高,路由频繁掉档
我第一次实验时把相似度阈值设在 0.75,结果出现了很诡异的现象:有将近一半的请求召回结果为空,直接走兜底逻辑,而兜底又只保留最近 3 轮,导致工具调用链里的中间环节丢失。模型开始反复向用户确认同一个信息:“请问您刚才说的订单号是哪个来着?”
原因很好理解:余弦相似度 0.75 已经是非常高的语义重合度了,而用户历史对话里很多信息是“单次提及、换个说法就检索不到”的。最后我把阈值一路降到 0.55,召回率才恢复正常。后来我用一批线上真实的用户历史数据画了相似度分布曲线,发现大部分真正相关的召回分数集中在 0.55 到 0.70 之间,这才算找到一个可靠基准。
提醒:换 embedding 模型或者换领域之后,这个分布会变,一定要基于自己的数据重新调参,不要照搬我的 0.55。
5.2 切片切成了“散装微博”
一开始我图省事,把历史切成一条消息一个 chunk。结果是 embedding 向量对“单条短消息”的语义捕捉很差,检索经常召回一些只含“好的”“明白了”这类词的垃圾片段——它们和当前问题向量看起来有点接近,但完全没有信息量。
后来我改成按 2 到 4 轮打包切片,并且把工具调用和工具返回结果放进同一个切片里。这样召回回来的每一块内容都是一个完整的“动作单元”,模型看到的不只是“用户问了什么”,还包括“系统做了什么、得到什么结果”。上下文质量提升非常明显。
5.3 撕裂的工具调用链:召回片段不能肢解消息对
这个坑最隐蔽。某一次压测里,模型在工具调用链中途开始胡言乱语,我看召回日志才发现:被召回的两个切片分别来自“订单查询”和“创建订单”两轮,但这两轮之间的“工具返回结果”没有被召回。模型看到了工具名,却没有看到工具返回的 JSON,于是它只能凭空猜测下一步动作。
解决办法是在切片策略里加一条硬约束:如果一条消息的 role 是function_result,它必须和对应的tool_call消息打包在同一个切片,不允许拆开。我加完这条规则之后,工具调用链的任务完成率基本恢复到了全量模式的水平。
5.4 忘了算 embedding 的账,差点白忙一场
第一次做完整成本对比时我发现:省下的历史 token 确实很多,但 embedding 费用也涨得飞快。原因是我在每个回合都做一次全量历史的向量化——每来一条新消息,就把所有历史切片重新 embedding 一遍,这等于把减法题做成了加法题。
改造方案是“会话结束时统一入桶 + 长时间会话增量入桶”,并且增量入桶只处理新增切片,不重建旧切片。改完之后,embedding 成本降到大约占节省费用的 5% 到 8%,这才在总账上真正划算。
5.5 COS 向量桶的对象 Key 不能乱用随机数
这一点属于运维层面的教训。最早我为了让桶内对象分布均匀,用了随机 hash 作为对象 Key,结果两个问题随之而来:
- 无法按 session_id 批量清理过期切片,只能全桶扫描,对象多了之后管理成本非常高;
- 排查问题时根本看不出某个对象对应哪个会话的哪几轮,调试体验极差。
改成{session_id}/{start_turn}-{end_turn}.json的命名规范之后,清理脚本和检索逻辑都变得清晰很多。我建议从第一天就按这个规范来,不要等对象堆积到几万个再回头改。
5.6 生产环境还要提前想好并发设计
Demo 阶段内存遍历 + 余弦计算完全够用,但上了生产之后,同一个 session 的切片数量会迅速膨胀,如果同时有几百个 session 并发请求,每次都在内存里遍历大量向量显然不可行。
我在中期做了一次优化:把检索范围先按“最近 7 天”和“该 session 内”双重条件过滤,把候选集压缩到一个很小的规模,然后再做余弦计算。再往后,如果切片量继续涨,就需要把向量索引下沉到专门的检索组件,但核心路由策略不用变——你只是换了一个更快的地方做余弦相似度计算。
最后说点实际操作中的体会
动手做这个项目之前,我以为难点在向量化和 embedding 模型选型,真正做下来才发现,难点全在“取舍”上——什么历史必须留、什么历史可以丢、什么情况下宁愿多花点 token 也不能走召回。我的建议是:先找一段真实的长会话日志,把历史 token 的分布统计清楚,再决定切片大小和路由阈值,不要上来就调参,那样只会调出一堆在测试集上好看、上线就崩的数字。
另外一个小技巧:在做全量到向量路由的切换时,可以先加一个 shadow 模式——同时在后台把全量上下文和召回上下文都发给一个小型模型做对比打分,跑两三天积累足够数据之后,再决定是否正式切换。这个过渡方式可以避免一次性裁剪太狠导致用户体验受损。
如果你也在被长会话账单折磨,不妨按这个思路搭一个最小版本,先用 20 个真实会话测一轮成本差值,再决定要不要投入时间做工程化。多数情况下,省的钱会比你想的多。