历史对话该在什么时候压缩
对话压短以后,还要知道事情办到哪了
上一篇讲了怎样裁剪旧的工具结果。裁剪能省掉早期检索返回的大段文字,但用户的提问、助手的回答和模型的思考内容仍在积累。售后咨询聊得久了,供模型继续处理的会话记录还是会越来越长。
继续用比特严选的AirPods售后咨询来说明。
用户的AirPods Pro左耳无声,按客服建议重新连接、清理出声口、检查声道平衡、重置后重新配对,反馈都没有改善。中间,用户又问过AirPods Max的价格和佩戴感,最后说:
先不买新的了,我想了解这副AirPods Pro怎么送修,申请先别提交。
接着聊了寄修和到店检测的区别,用户又问:
那就先把要准备的东西列一下。
回答这句追问,智能体(Agent)要知道送修对象是哪一副耳机、用户已经试过什么、是否还考虑换购,以及申请还不能提交。至于之前每一款耳机的完整介绍、每一次知识检索的长篇回答,不必一直原样带着。
压缩做的就是这件事:把较早的对话整理成一条摘要并替换这些原文,最近一段对话原样保留。这里的最近一段不是只留最后一轮,而是从最新消息往前数出一定的字符量,按项目配置至少20% 的上下文预算,通常包含好几轮。压缩之后,模型回答时读到的是早期对话的摘要,加上最近一段原文,本次用户问题就是这段原文的最后一条。
摘要和最近原文各管一部分。摘要负责延续较早的信息:用户要办什么、提过哪些限制、事情进行到哪里。最近原文负责正在讨论的细节。比如用户说“那就先把要准备的东西列一下”,这里的“那就”接的是刚才对寄修和到店检测的比较。如果这几轮也被压成一句“用户在了解送修方式”,模型就看不到两种方式各要准备什么,只能重新问一遍。所以越靠近当前问题,越倾向于保留原文;更早的部分才整理成摘要。也可以把本次问题之前的历史全部压成摘要,这样压缩后更小,但所有历史都要经过一次有损整理,追问里引用的细节更容易丢。Ragent选择保留最近一段原文。
按比特严选压缩提示词的要求,上面的经历至少应留下这些信息。下面是示意,不是模型的实际输出:
用户的 AirPods Pro 左耳无声,已尝试重新连接、清理出声口、检查声道平衡、重置重配对,用户反馈均无效。
换购 AirPods Max——已放弃(原话:先不买新的了)。
限制:AirPods Pro 送修申请先别提交(原话:申请先别提交)。
这样,下一次追问送修准备事项时,模型就有依据继续处理,减少重复询问故障、再次建议已试过的步骤,或把话题带回换购。价格、库存和送修规则需要时仍然查询,摘要重点保留用户的情况和办事进度。
压缩是用一条摘要替换一段旧原文,改的是模型接下来要读的历史。从这一点出发,一次压缩要回答四个问题:
- 什么时候替换,才不和正在使用这份历史的处理过程冲突。
- 替换哪一段,才不会把相互对应的记录拆开。
- 摘要怎样生成,拿到什么结果才算可用,生成失败时原文不能先丢。
- 记录替换以后,当前这次回答怎样也用上新摘要。
压缩会省略细节,所以不能只看摘要短了多少,还要看用户的意思是否保住。
从Middleware看一次压缩经过哪些类
1. 每次推理前,Middleware先选路径
本文把用户提出一个问题,到Agent完成这次处理,称为一轮。模型可能先查订单,再检索售后规则,最后回答;如果中途需要用户确认某个操作,确认后还会继续处理原问题。
每次调用模型,让它根据现有信息决定回答什么或调用哪个工具,称为一次推理。一轮里可以有多次推理。下文的主Agent指负责接待用户、调用业务工具并组织回答的部分;摘要调用则专门整理历史。
代码用Msg表示一条消息,用MsgRole表示它的角色:USER是用户消息,ASSISTANT是助手消息,TOOL是工具返回的结果,SYSTEM则用于放系统说明。一轮之内,用户问题之后追加的都是助手的工具调用、工具结果和助手回答。
AgentScope允许在每次推理前插入一段自己的处理逻辑,这类扩展点叫Middleware。AgentContextCompactionMiddleware就是这样接进来的:框架每次调用模型前,先调用它的onReasoning(),它取出当前会话记录,再决定这次尝试压缩还是裁剪。
下面两个方法是它的入口和分支:
@Override
public Flux<AgentEvent> onReasoning(Agent agent, RuntimeContext context, ReasoningInput input,
Function<ReasoningInput, Flux<AgentEvent>> next) {
// 框架执行到这里时,再读取会话状态并选择处理路径
return Flux.defer(() -> dispatch(agent, context, input, next));
}
private Flux<AgentEvent> dispatch(Agent agent, RuntimeContext context, ReasoningInput input,
Function<ReasoningInput, Flux<AgentEvent>> next) {
// 取得当前会话中可以更新的消息列表
AgentState state = RuntimeContext.resolveAgentState(context, agent);
List<Msg> messages = state.contextMutable();
if (shouldCompact(messages)) {
// 满足条件时先处理压缩,得到本次输入后再继续推理
return compact(messages, input, context).flatMapMany(next);
}
// 不走压缩时,检查是否需要裁剪旧工具结果
return next.apply(trimToolResults(messages, input, context));
}
这里有两份相关的数据:messages是会话保存的历史,input是框架为这次模型调用准备的输入。历史被压缩后,本次输入也要更新,当前回答才能用上新摘要。next表示Middleware之后的处理流程;把处理好的输入交给它,框架才会继续往下执行。
compact()和trimToolResults()也是Middleware中自定义的方法,只负责安排压缩或裁剪,并把结果整理成本次模型输入。选压缩范围、调模型写摘要、裁剪工具结果这些具体工作,交给了别的类。
2. 各个类怎样分工
沿着调用往下看,分工如下。并不是每次推理都会经过全部这些类:不满足压缩条件时只走裁剪;压缩成功了,就不再裁剪。
| 类 | 谁调用它 | 在这次处理中负责什么 |
|---|---|---|
AgentContextCompactionMiddleware | AgentScope框架调用onReasoning() | 判断是否尝试压缩,安排压缩或裁剪,再把处理后的输入交回框架 |
AgentContextCompactor(压缩器) | Middleware的compact()调用compactInPlace() | 选出要压缩和要保留的消息,取得新摘要后替换会话记录 |
AgentConversationSummarizer(摘要器) | 压缩器调用summarize() | 准备历史素材、调用模型,检查摘要正文是否非空且正常生成结束 |
AgentPromptResolver | 摘要器调用render() | 取得当前智能体的压缩提示词,并填入摘要长度目标 |
AgentContextCompactionMapper | 压缩器替换记录后,通过audit()调用它 | 将本次摘要及压缩前后的字符数等信息写入数据库 |
AgentContextTrimmer | Middleware的trimToolResults()调用trimInPlace() | 按裁剪规则检查旧工具结果,符合条件才替换正文 |
另外两个类只提供计算:AgentContextChars统计消息的字符数;AgentMemoryProperties根据配置算出压缩触发线、最近原文的最低保留量和摘要长度目标。
三者的分工是:压缩器改会话记录,摘要器写摘要,Middleware让这次推理用上改过的记录。拿送修对话来说,记录够长时,压缩器先把较早的咨询和最近几轮追问分开,把较早的部分交给摘要器;摘要器交回一份可用的摘要,压缩器才替换历史;最后Middleware更新这次的模型输入。
摘要器调用的模型agentChatModel,和主Agent用的是同一个模型对象,只是这一次的任务是整理历史,用户刚发来的问题要等它结束后才处理。下面的时序图是满足压缩条件、并且拿到可用摘要时的完整路径;压缩没成功时,Middleware会改去尝试裁剪。
新问题加入记录后,先判断是否压缩
1. 为什么只在一轮开头压缩
还是看“那就先把要准备的东西列一下”这一轮。
Agent先调用search_knowledge查询送修要求,再根据查询结果整理清单,因此这一轮会经历两次推理。到第二次推理开始前,会话记录的末尾大致是:
……更早的对话……
[user] 那就先把要准备的东西列一下
[assistant] 调用 search_knowledge
[tool] 返回送修要求(可能有几千字)
如果这时进行压缩,能压掉多少内容?
可以把压缩理解成在会话记录里选一个切点:切点之前的内容换成摘要,之后的内容原样保留。这个切点只能放在某个用户问题之前,不能随意切在一轮对话中间。否则一次工具调用和它的返回结果可能被拆到切点两边。
所以上面这段记录里,切点最多只能放在“那就先把要准备的东西列一下”之前。本轮的用户问题、search_knowledge调用以及刚查回来的送修要求,都必须保留原文。能换成摘要的,只有本轮问题之前的对话,而这些在第一次推理前就能换。中途再压省不出更多空间,还可能让用户在看到“我先帮你查一下”之后,再停下来等一次摘要。
因此,Ragent只在一轮开始的第一次推理之前尝试压缩。这也意味着一种情况:本轮新查回来的工具结果很长,导致会话记录在执行过程中才超过压缩触发线。例如会话预算是100,000字符,80% 时触发压缩,也就是80,000字符。如果这一轮开始时还没到80,000,但执行过程中超过了,本轮不会再次启动压缩,而是等到用户下一次提问时再处理。
本轮剩余的空间主要靠两部分保证:
- 触发线和预算之间还留有20% 的余量,用来容纳本轮新增内容;
- 每次推理前仍会执行裁剪,把较早、允许裁剪的知识检索结果替换成省略说明。
还有一种思路,是上一轮回答结束后就在后台提前生成摘要,让下一轮用户不用等待,这就是异步压缩。
但异步压缩会带来并发问题:后台还在根据旧记录生成摘要时,用户可能已经发送了下一条消息。如果摘要完成后直接覆盖原记录,就可能把刚追加的新消息一起覆盖掉,因此还需要额外处理版本和冲突。
Ragent采用的是同步压缩:需要压缩时,先生成摘要,再继续本轮执行。它的代价也很直接——触发压缩的这一轮,会多等待一次摘要调用。摘要越长,这段等待通常也越长,因此摘要目标长度最终限制在最多6,000字符。
2. 代码怎样认出一轮的开头
Middleware每次推理前都会执行,它要从记录本身认出“这是一轮的第一次推理”,依据是框架追加消息的顺序。
普通新问题进入AgentScope 2.0.2框架后,框架先把用户消息加入记录,再开始推理。第一次进入Middleware时,列表最后一条正是用户刚发来的问题。若模型随后要求查资料,框架会把助手的工具调用、工具返回的结果依次追加进去。再次推理时,末条已经是TOOL。所以只要求末条是USER,就把压缩限制在了一轮的第一次推理。
确认卡是另一种情况。比如用户后来同意送修,Agent调用提交售后申请的工具apply_after_sale,这个工具要先经用户确认,页面弹出确认卡,这一轮就停在这里。此时会话记录的末尾是这样的:
[user] 可以了,帮我提交送修申请
[assistant] 调用 apply_after_sale(状态:等待用户确认)
用户点击“同意”后,前端会发起一次新的请求,但这不是一个新问题,而是让Agent接着执行刚才那次调用。startConfirmRun()把用户的选择交给框架,传入的消息是这样构造的:
// 不设置任何文字内容,只在附加信息里放确认结果
Msg resumeMsg = UserMessage.builder()
.metadata(Map.of(Msg.METADATA_CONFIRM_RESULTS, confirmResults))
.build();
confirmResults里每一项对应一个等待确认的工具调用,记着用户是同意还是拒绝。这条消息虽然是USER角色,但里面一个字也没有,用户在页面上点的“同意”只体现为附加信息里的一个布尔值。
框架收到它时,先检查记录里有没有等待确认的工具调用。有的话,框架只从附加信息里取出确认结果,把那次调用标成“已允许”(拒绝则补一条“用户拒绝”的工具结果),然后直接去执行工具,这条消息本身不会追加进会话记录。工具执行完,记录末尾多了一条工具结果;接下来那次推理前,末条是TOOL,不会触发压缩:
[user] 可以了,帮我提交送修申请
[assistant] 调用 apply_after_sale(状态:已允许)
[tool] 返回售后单号
所以,确认卡只是让原来那一轮接着跑,不算新的一轮,也就不会因为用户点了一次确认而重新压缩。
除了末条角色,还要看记录有多长。agent.memory.summary-enabled为true,agent.memory.context-window-chars为100,000。后者是项目为会话记录设置的字符预算:裁剪触发线取它的50%,压缩触发线取80%,也就是80,000字符。
shouldCompact()的判断如下:
private boolean shouldCompact(List<Msg> messages) {
// 摘要关闭、记录为空或末条不是用户消息时,不尝试压缩
if (!memoryProperties.isSummaryEnabled()
|| messages.isEmpty() || messages.get(messages.size() - 1).getRole() != MsgRole.USER) {
return false;
}
// 严格超过触发线才尝试,刚好等于还不压
return AgentContextChars.total(messages) > memoryProperties.resolveCompactTriggerChars();
}
AgentContextChars按消息里的文字、思考内容、工具名称与入参、工具结果等统计字符数。思考内容是模型生成正式回答或工具调用前的推演,与发给用户的回答分开存放。
注意,100,000是项目给会话记录定的字符预算,不是模型接口的窗口上限。人设、工具描述和长期记忆也会发给模型,但不算在这100,000里;模型窗口按token计,和字符数也不能直接换算。超过预算不会报错,这个数只用来决定什么时候裁剪、什么时候压缩。预算该定多大,下一篇再讲。