如果你也在运行一个 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 这种多智能体编排框架里情况会更夸张。因为它内部有一个“消息池”每一轮可能涉及多个工具协同调用工具的描述文本和返回结果都会被塞进这个池子。工具描述很长——通常一个完整的工具参数定义动辄几百 token10 个工具就是几千 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. 路由链路搭建实录从切片到接入 OpenClaw3.1 先搭一个最小可用的切片与入桶逻辑我用一段 Python 演示核心逻辑你去掉了具体鉴权细节因为各个 COS 服务商的 SDK 都不一样替换成自己的实现即可。重点是思路。def split_messages_by_turn(messages, turn_size4): 把消息列表按回合切成块。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_k8, threshold0.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(keylambda x: x[0], reverseTrue) 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, n6) recent_tokens estimate_tokens(recent_rounds) # 如果最近 6 轮已经超过预期大小就彻底走向量召回 query_vector embed_text(user_query) recalled_chunks recall_similar_chunks( query_vector, session_idsession_state.session_id, top_k8, threshold0.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.4k7.8k91.9%资料问答82.1k6.9k91.6%代码修改88.7k7.2k91.9%整体89.1k7.3k91.8%最终我把接近 92% 的数据向上取整对外会说是“92%”。其实更严谨的说法是“输入 token 下降约 92%”因为输出 token 基本没有变化——模型该回答多少还是回答多少优化的是它“读进去”的内容量。4.3 质量有没有崩省了 92% 的 token 之后最怕的是会话质量肉眼可见地崩掉。我在压测时专门做了一轮人工抽检把路由模式下召回的上下文片段打印出来和全量模式对比观察模型行为差异。抽检 30 个召回片段我的结论是信息完整率在 92% 左右。也就是说模型实际需要依赖的那几条历史记录绝大多数都能被正确召回。个别失败场景集中在“问题表述非常模糊且历史里只有一条隐性线索”的情况。比如用户说“把它改成上次那个风格”如果不召回那次风格相关的对话模型确实会懵。应对这种场景我们在路由之外加了一条硬编码规则如果用户当前问题里出现“上次”“之前”“那个”“老样子”这类代词强制把最近 20 轮历史里的所有用户消息作为纯文本附加进上下文。这样虽然不能保证找对但至少给模型提供了足够的回溯线索。4.4 算一笔总账最终我关心的不是下降率好看不好看而是真正省了多少钱。我用一个具体数字来说明假设每天触发 100 次长会话请求每个请求平均 89.1k 输入 token全量模式下一天输入 token100 × 89.1k 8.91M按每百万 token 3 美元计算约 26.7 美元/天路由模式下一天输入 token100 × 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 个真实会话测一轮成本差值再决定要不要投入时间做工程化。多数情况下省的钱会比你想的多。