两周前我把OpenClaw实例的session压缩阈值从0.75调低到0.55,想着“省token嘛,压缩得越勤越好”。第二天客服机器人就开始在“失忆”的边缘反复横跳:用户第五轮给的订单号,第八轮再问,模型已经完全不记得;更夸张的是,有些会话在同一轮里触发了两次压缩,延迟从1.2秒飙到4.5秒。我差点直接回滚版本,但冷静下来把日志翻了一遍,发现我对这套Session压缩机制的理解基本是错的。
这篇不是OpenClaw官方文档的翻译,而是这次调优之后沉淀下来的实战笔记:压缩机制到底怎么决定保留什么,默认配置里藏着哪些低概率但高破坏的坑,以及一份可以直接抄的调参模板。无论你在跑的是客服机器人、代码助手还是闲聊session,只要多轮对话会超过模型窗口,这些经验大概率用得上。先说明白,文中涉及的参数名和默认值,来自我在本地模拟环境里实际观测到的行为,不同小版本的实现可能有出入,但排查思路是通用的。
1. 压缩到底在解决什么:一段会话的膨胀曲线与Token上限
1.1 从“会话膨胀”到“窗口越界”的完整过程
我在本地模拟环境里统计过一条典型的客服会话:系统提示约1200 tokens,工具schema约800 tokens,用户开头打招呼200 tokens,助手首次回复300 tokens。看起来一点都不大。但从第五轮开始,每轮用户消息平均350 tokens、助手回复平均600 tokens、工具调用及结果平均700 tokens。到第15轮,累计已经到了12000 tokens;到第25轮,算上各种历史残留,轻轻松松突破19000 tokens。而模型窗口如果只有32768 tokens,你还要留下至少4096 tokens给模型生成回复,实际可用的输入只有28672。也就是说,25轮左右就已经到了临界点。
如果没有压缩机制,你会看到两种结局:一是请求直接报错,提示input length exceeded;二是OpenClaw的兜底逻辑帮你把最旧的消息从上下文里删掉。第二种表面能跑,但用户问“我刚才说的那个型号怎么样”时,模型彻底懵了。兜底截断是粗鲁的,压缩才是真正有语义的收敛。压缩机制的核心目标,就是在上下文即将塞满之前,用更少的token保留住“未来仍然用得上的信息”。
1.2 压缩究竟保留了哪几样东西
OpenClaw的session压缩不是把一段长对话揉成一段普通摘要,而是保留三类东西:系统级指令、最近的完整对话轮次、以及一份面向后续生成的结构化摘要。这份结构化的摘要又分成三个子块:事实清单、用户偏好、未完成任务。事实清单记录订单号、时间、地址、产品型号;用户偏好记录“收货地址是公司”这类信息;未完成任务记录“用户还等着改签”。这样设计是为了保证压缩后的上下文不是“一篇小作文”,而是方便模型快速查找的索引式文本。
我一开始觉得摘要里根本不需要分结构,只要把历史总结一遍就行。实际跑下来发现,结构化摘要的字段命名比自然语言段落重要得多。因为模型在后续回答时,是根据字段名去检索的。比如摘要里写“商品价格:399元,原价599,差价需补”,模型很容易引用;如果写“用户询问了价格差异,我们讨论了几分钟”,模型只知道有这回事,不知道具体值。所以你在调优时,真正应该花精力的是“怎么让摘要保留业务关键字段”,而不是反复调整压缩触发阈值。
1.3 压缩触发条件:一条简单乘法背后的设计动机
触发条件默认是:session中累积的token数大于等于max_context_tokens * compact_threshold。这个max_context_tokens由你选择的模型窗口决定,OpenClaw从模型配置里自动读取,不需要手填。为什么用乘法而不是固定阈值?因为不同模型窗口差异太大,固定数字会让压缩策略跟窗口完全不匹配。我用过一个7B模型窗口只有8192,曾经手动设置固定阈值6000,结果每次压缩后剩余空间仍然不够后续几轮,马上就再触发;改成0.75比例后,系统根据窗口动态调整,情况好了很多。
需要提醒的是,参与比较的token数不仅包含消息列表,还包含当前pending tools的临时结果和未提交的状态变量。这个细节在第3.3节会具体展开。总之,你配置的threshold越小,触发越早,压缩越频繁,越容易抖动。调优不是“阈值越低越省”,而是一个成本和效果的平衡问题。
2. 消息筛选、摘要替换与不可逆索引:压缩机制的黑盒内部
2.1 一条消息的存留优先级打分逻辑
压缩器会遍历会话的所有消息,为每条消息算一个存留分数,按分数从高到低决定消息去向:分数高的保留原文,中间的归入摘要,低的最多只保留一个占位符。打分主要看五个维度:角色权重、消息时间、消息长度、是否被钉选、是否包含声明式事实。
以默认权重为例,系统消息和钉选消息直接跳过打分、永远保留;工具调用消息如果还处于“被引用”状态(比如后续某条assistant消息显式引用了它的message_id),则会获得一个大的加成;普通用户消息的年龄衰减比助手消息慢,因为用户说过的话更容易在之后被再次引用。时间衰减用的是半衰期函数,默认半衰期是30分钟(按会话内相对时间)。也就是说,一条30分钟前的消息,其时间权重只剩初始的一半。这个衰减值在慢速长会话里很敏感,我调优时把half_life改成了120,否则早晚高峰的长会话开头内容基本全部被降权。
这里有个容易忽略的点:消息长度评分用的是min(1, tokens/max_tokens)这种饱和函数。也就是说,一条特别长的消息,长度分并不会一直涨,达到一定长度后对优先级的影响就不变了。这么设计是为了避免“越长越重要”的误解——长消息里通常有大量废话,只有很短的关键事实才需要保留。
2.2 摘要生成不是“总结全文”,而是面向可用性重构
OpenClaw的摘要器并不会把整段历史一次性丢给摘要模型。它会把需要压缩的消息按原顺序切成块,每块控制在约3500 tokens以内,然后逐块生成“片段摘要”,最后再把片段摘要合并成一条虚拟消息,替换原消息列表。我在调试日志里看过一次50轮历史的压缩:共切成12块,摘要模型跑了12次。如果你不自定义summary_model,这12次都会走你配置的主对话模型,延迟和成本就是这么上去的,这也是第3.2节陷阱的直接来源。
摘要提示词默认已经在往“可用性”方向靠了:删除寒暄、压缩重复表达、保留具体数值和专有名词、保留未完成任务,并以known_facts / user_preferences / pending_tasks / recent_turn_summary四个段落的格式输出。我强烈建议不要大改这个四段式,只补充你业务特有字段,比如订单状态、会话意图标签。原因很简单,后续的检索提示词是围绕这四个段落设计的,你把结构改了,模型反而不知道该去哪里找信息。
2.3 压缩后的不可逆性与索引残留
很多第一次用OpenClaw的人以为压缩后的摘要会跟原文有关联,模型可以随时“想起来”。实际上摘要一旦写入上下文,原始消息就从当前上下文消失了,模型不可能凭摘要还原原文。OpenClaw在持久化层保留了一个message_archive索引,记录每条原始消息的hash、原始内容和压缩批次号。这个索引默认保留30天,之后自动清理。索引不在模型的上下文窗口里,但它对业务审计和纠错非常有用。
我吃过一个亏:某次用户问“我记得你之前说发货地是上海,怎么现在显示苏州”,模型只看到摘要里写shipping_origin: maybe_region,完全没法解释。后来我查了索引,才发现原文里助手说的是“预计从上海仓发出,如果苏州仓有货则直发”,被摘要模型简化成了模糊值。从那以后我调高了摘要模型的max_tokens,并且暂停了auto_purge_index,至少保留90天。客服纠纷需要追责时,这个索引就是唯一证据。
3. 四个让我差点回滚版本的陷阱与完整排查链路
3.1 陷阱一:阈值调太低,导致“压缩抖动”
现象很直观:把compact_threshold从0.75调到0.55之后,日志里的compaction事件频率肉眼可见变高,客服会话几乎没有哪两轮能安稳跑完。用户每说一句话,系统就触发一次压缩,回复延迟从1.5秒升到4秒,而且模型经常在下一轮就忘记上一轮自己的措辞。
我去翻日志,把每个会话的压缩事件按时间戳列出来,发现规律惊人:单会话每两三轮就压缩一次。原因其实不复杂——这个客服助手的系统提示加工具schema本身就有2000多tokens,而模型窗口是32768,0.55的阈值意味着只要累积到18022 tokens就开始压缩。压缩完成后,摘要消息约1200 tokens,加上保留的最近10轮,可能降到9000 tokens。但接下来只要用户再补两三条长消息,加上工具结果,很快就又撞破18022这条线,于是再次压缩。高频压缩不是“省token”,而是每次都在切断完整对话流,模型只能靠越来越薄的摘要作答。
修复很简单:把阈值升回0.70,同时打开min_compaction_size,我这里设成4096。min_compaction_size的作用是:当前上下文tokens低于这个值时,即使达到阈值比例也不触发压缩。这就避免了一开始系统提示+工具schema就触发的尴尬情况。抖动消失后,我又试过0.65,发现除非把保留轮数调到6以内,否则还是会偶发抖动。所以我最后停在0.70不是偶然,而是实测下来的稳定点。
3.2 陷阱二:摘要模型复用主对话模型,延迟和成本同时爆炸
第一次调优时我没管summary_model,以为摘要用主模型跑更准。结果压缩一次要等3到5秒,客服用户明显感觉“话说完了但机器人还在转圈”。费用账单也让人肉疼——12块摘要片段的成本,几乎等于平时三四十轮对话的开销。
排查时我先看了OpenClaw的性能仪表盘,确认卡在summary生成阶段,再把summary_model显式改成一个小型号的快速推理模型。这个小模型的语义能力确实比主力模型弱,但对于“把一段对话压缩成300字摘要”这种任务完全够用。我同时把summary_max_tokens限制到800,防止摘要模型一次生成太长,拖慢响应。
改完以后,单次压缩耗时降到1.2秒左右,成本大概是原先的四分之一。摘要质量有没有下降?我用离线回放对比过,关键数据丢失率没有明显升高。这里的关键是分段摘要:每块只有3500 tokens,小模型不需要处理超长上下文,注意力不容易涣散。所以不要一看到摘要质量不行就换回大模型,先检查是否因为超长文本一次性输入导致小模型崩溃。
3.3 陷阱三:工具调用的临时变量被当成普通消息清掉
这个坑藏得比较深。客服机器人接入了订单查询工具,用户要求查订单状态,系统调用了get_order_detail,返回了一长串结构化字段:当前状态、物流公司、包裹号、商品明细、收件地址。这些工具结果作为消息进入session,看起来跟普通回复一样。压缩发生时,OpenClaw默认把这些tool消息按普通消息参与打分,中间偏长的部分被归入摘要。结果用户下一句说“帮我改一下收货地址”,模型需要拿订单ID、原地址、商品清单去调update_address工具,但摘要里只留下“用户查询了订单,状态正常”这种概括,结构化字段全部丢了,工具调用直接失败。
排查链路是这样的:首先打开debug日志,发现报错是工具参数校验失败,缺少address和order_id字段;再往前翻,看到压缩事件刚把包含工具结果的原始消息替掉;最后我确认,role=tool的消息没有被任何preserve规则保护。根因是压缩器默认的优先级基于自然语言重要性,而不是基于结构化执行依赖。它不知道这个工具结果还在被后续流程当“状态变量”用。
解决办法分两步。第一,在配置里把preserve_roles加上"tool",让所有工具消息至少保留原文,不参与摘要降权。第二,针对会产生临时状态的业务工具,开启tool_result_summary: false,让工具结果只压缩重复部分,而不是整体替换成摘要。经过这么改,工具调用链连续了,订单改地址也能正常完成。这个坑给我的教训是:凡是涉及工具调用的会话,压缩前一定要检查哪些字段是“执行下一跳任务”的必要上下文,不能只信默认的语义打分。
3.4 陷阱四:token估算误差引发同一轮循环压缩
最后这个坑最隐蔽,因为它会误导你“压缩机制失效了”。具体现象是:日志里已经显示压缩完成,按理说上下文tokens降到了安全水平,但下一次请求前,token计数依然超过阈值,于是再次触发压缩。极端情况下,同一个用户回合连续触发两次压缩,第一次摘要还没被模型看热,就被第二次摘要覆盖了。
排查时我先怀疑是阈值设太紧,但调到0.65后仍然偶发。后来我对比了OpenClaw日志里的token统计,发现它对摘要消息的token估算用的是“字符数乘以0.25”的启发式算法。这个估算对英文文本误差不大,但一旦摘要里混入大量代码、JSON、订单号,实际token会比估算值高出一大截。压缩器以为摘要只占800 tokens,实际可能1400,多出来的部分直接让上下文越过了触发线,于是再次压缩。
修复办法是开启exact_token_count: true,让系统用tokenizer逐条消息实时计数,而不是用启发式估算。同时要给压缩目标留出buffer,不要追求“刚好压到阈值以下”。我最终把压缩目标比例设成0.60,也就是说,压缩完成后上下文tokens最好控制在窗口的60%以内,给后续对话留足余量。这样即便摘要token估算有少量偏差,也不会触发循环压缩。
4. 可抄作业的配置清单:参数、示例与场景化推荐
4.1 核心参数速查表
下面这张表是我调优过程中重点关注的参数。默认值基于OpenClaw 2.x系列,不同版本用词可能有差异,但对应的含义应该差不多。
| 参数 | 默认值 | 我的建议值 | 作用与注意点 |
|---|---|---|---|
enabled | true | true | 是否启用会话压缩,不建议关掉 |
threshold | 0.75 | 0.70 | 触发压缩的上下文占比阈值,越小越频繁 |
min_compaction_size | 0 | 4096 | 上下文低于该值不压缩,避免开场抖动 |
keep_recent_rounds | 10 | 10 | 保留最近完整对话轮数,太长会占用窗口 |
summary_model | 复用主模型 | 快速模型 | 单独指定摘要模型,延迟和成本差别巨大 |
summary_max_tokens | 1024 | 800 | 单次摘要生成上限,防止摘要过长 |
preserve_roles | system | system, tool | 强制保留的系统或工具角色消息 |
preserve_patterns | 空 | 业务正则 | 命中这些模式的消息不参与摘要 |
exact_token_count | false | true | 用真实tokenizer计数,避免估算偏差 |
compression_target_ratio | 0.75 | 0.60 | 压缩后目标占比,留buffer防循环压缩 |
auto_purge_index | true | false | 是否自动清理原始消息索引,业务审计建议关闭 |
4.2 一份经过验证的OpenClaw配置示例
我现在的生产配置长这样,直接贴出来供参考。不同业务建议先照着跑两天,再根据监控微调。
session_compaction: enabled: true threshold: 0.70 min_compaction_size: 4096 keep_recent_rounds: 10 summary_model: "summary-fast" summary_max_tokens: 800 preserve_roles: - "system" - "tool" preserve_patterns: - "订单号[::]?[A-Z0-9]{8,}" - "address[\\"']?\\s*[=:]\\s*[\\"'][^\\"']+[\\"']" - "task_id\\s*=\\s*[a-f0-9]{24}" exact_token_count: true compression_target_ratio: 0.60 auto_purge_index: falsepreserve_patterns里的正则可以让包含订单号、地址、任务ID的消息跳过摘要,直接保留原文。正则匹配的是消息文本,不是字段名,所以你需要按自己日志里实际出现的格式来写。我为了匹配工具调用里常见的JSON参数,写了几条比较宽的正则,宁可多保留几条消息,也不能让关键参数被压掉。
4.3 三种典型场景的调参差异
智能客服最看重事实连续性和可追溯性。我把keep_recent_rounds保持在12,preserve_roles加上user,因为用户消息里的关键信息(地址、偏好、诉求)经常在压缩后没有出现在摘要里。preserve_patterns会覆盖手机号、订单号、地址等高频业务模式。
代码助手类场景,核心矛盾是工具调用上下文太长。建议把threshold放宽到0.80,尽量用完整对话轮次覆盖“调用栈还没有结束”的窗口期,同时一定开启exact_token_count,因为代码片段和JSON的token估算偏差最大。摘要模型可以选稍微强一点的型号,因为函数签名、报错信息这类内容压缩难度高,弱模型容易把关键参数弄丢。
闲聊陪伴类场景,用户更在意情感连续性,而不是具体事实。我会把keep_recent_rounds调到14,摘要提示词里增加“保留用户情绪状态、未说完的话题”,不需要保留太多订单号之类的硬事实。compression_target_ratio可以放宽到0.65,反正上下文不太可能一次性塞满,早压缩反而破坏对话氛围。
4.4 我的调优顺序建议
我最开始犯的错是同时改好几个参数,出问题根本定位不到是谁的锅。后来总结出一个顺序:先确定模型窗口上限,再设置阈值、观察压缩事件频率;然后调keep_recent_rounds和min_compaction_size,确认压缩间隔稳定;接着独立指定摘要模型,观察延迟和成本;再补preserve_patterns,测试业务关键信息是否完整;最后开启exact_token_count并收缩compression_target_ratio。每一步只动一个变量,观察至少一个完整高峰时段,再决定下一步。
5. 验证这套压缩策略是否健康:监控指标与自检脚本
5.1 离线回放:用历史会话模拟压缩效果
我上线新配置前,会先从生产环境录制几十条真实会话,存成回放文件,然后用不同参数组合在本地跑一遍压缩。重点不是看压缩后还剩多少tokens,而是看摘要能不能支撑后续问题。
做法很简单:从原历史中选出10条关键信息,比如订单号、金额、地址、用户偏好,把它们从原上下文中删掉,只给模型看压缩后的摘要,然后用预设问题挨个问。比如“用户刚才的收货地址是什么?”、“订单对应的物流公司是哪家?”。模型回答正确的比例,我称为“信息召回率”。我给自己定的标准是:核心业务字段召回率不低于90%。如果低于90%,优先补preserve_patterns,而不是修改摘要prompt,因为正则保留比摘要生成更精准。
5.2 三个在线指标
上线后我主要盯三个指标。第一个是压缩事件频率,统计每个会话压缩一次平均间隔多少轮。如果间隔小于4轮,说明阈值或保留轮数配得太紧,需要回退。第二个是压缩后回复的一致性,这个没法完全自动化,我会每天随机抽50条多轮对话,人工看模型是否还记得压缩前出现过的关键信息,大概能发现系统性失忆。第三个是用户反馈里的“你刚才不是说过吗”这类投诉量,这个指标最直接,一旦回升说明压缩配置伤害了体验。
这三个指标要有常态告警。我在日志系统里挂了两个阈值:单会话压缩次数超过每小时5次,或者压缩后有效信息召回率低于85%,就触发复核。不要等用户来投诉,那已经晚了。
5.3 自检脚本的核心逻辑
为了方便每周检查,我写了个小工具。核心逻辑是读取会话日志,解析压缩事件,检查压缩间隔抖动率、压缩前后token变化量是否合理。这里贴一个简化版伪代码,你可以按自己日志格式改。
def check_compaction_health(session_log): events = load_compaction_events(session_log) if not events: return "never triggered" intervals = [ events[i + 1]["trigger_at"] - events[i]["trigger_at"] for i in range(len(events) - 1) ] if not intervals: return "single event" avg_interval = sum(intervals) / len(intervals) min_interval = min(intervals) jitter_ratio = 1 - (min_interval / avg_interval) if avg_interval else 0 token_before = [e["token_before"] for e in events] token_after = [e["token_after"] for e in events] reduction = [ (b - a) / b for b, a in zip(token_before, token_after) if b > 0 ] avg_reduction = sum(reduction) / len(reduction) if reduction else 0 return { "avg_interval": avg_interval, "min_interval": min_interval, "jitter_ratio": jitter_ratio, "avg_reduction": avg_reduction, "compaction_count": len(events), } # 判定预警 health = check_compaction_health(session_log) if health["jitter_ratio"] > 0.6: print("alert: compaction jitter detected") if health["avg_reduction"] < 0.2: print("alert: compaction reduction too small")这里的抖动率计算用的是最小间隔除以平均间隔,如果某两次压缩隔得很近,说明上下文刚被压下去又涨上来,是我们最不想看到的“压缩抖动”。平均缩减率低于20%也说明压缩没起到省空间的作用,可能是min_compaction_size设得太大,导致触发时上下文本来就小。
5.4 后续值得尝试的优化方向
这次调优基本够用了,但我还在试两个方向。第一个是动态阈值:不再用固定0.70,而是根据会话话题复杂度动态变化。比如工具密集型的会话,阈值放宽到0.8,让执行链完整保存;闲聊会话则收紧到0.65,保证响应速度。第二个是把摘要索引升级成可检索的向量索引,而不仅仅是线性文本摘要。现在摘要是文本块,模型看整个摘要才能找到信息;如果能把关键实体抽出来放进向量库,回复时先做一步局部检索,压缩后的信息密度还能再提高。
不过这两个方向都还在实验阶段,生产环境没有上,暂时给不了你成熟的配置答案。但“先保住业务关键信息,再追求省token”这个原则是一直成立的。
最后再分享一个我自己的土办法:每次调完参,拿三条最长历史会话反复回放,把压缩后的摘要单独提取出来,自己先读一遍。如果读完之后你还说得清“用户从哪里来、要办什么事、走到了哪一步”,那这个压缩策略大概率是健康的;如果连你都要翻原始日志,那模型更不可能靠摘要干活。这个判断方法土,但真的比任何指标都灵。