一、从一个失控的账单说起
设想一个真实场景:你给公司做了一个客服 Agent,上午上线,下午财务就来找你了——API 账单比预期高了一个数量级。你打开日志一看,问题很直观:会话进行到第五十轮时,每一次请求都要把前面四十九轮的完整历史原封不动地塞给模型。历史越滚越长,输入 token 像滚雪球一样越滚越大,哪怕你的模型上下文窗口有 128K,也架不住这种线性膨胀的消耗方式。
更隐蔽的是工具调用带来的膨胀。Agent 每调用一次工具,消息列表里就会沉淀下三样东西:AI 的 tool_calls 决策、工具返回的完整结果、AI 基于结果的回复。如果你的工具是爬虫、代码执行器或者返回大段 JSON 的接口,一次调用就可能塞进去几千 token 的"原始素材"。而这些素材在十轮对话之后,模型大概率已经用不上了——它们只是安安静静躺在那里,每轮都重复计费一次。
这就是上下文管理的"双刃剑":历史保留得越全,模型记性越好、回答越连贯,但费用越高、越容易撞上下文窗口上限;裁得越狠,账单越好看,但模型会"失忆",用户体验直线下滑。好在 LangChain 1.x 的中间件体系给了我们两个开箱即用的手术刀——SummarizationMiddleware(把旧历史压缩成摘要)和 ContextEditingMiddleware(把过期的工具调用痕迹清理掉)。本文就把这两把刀的构造、用法和适用场景彻底讲清楚。
本文解决什么:SummarizationMiddleware 六个核心参数(model/trigger/keep/token_counter/summary_prompt/trim_token_to_summarize)的语义与坑位,两个完整可运行示例的输出解读,ContextEditingMiddleware 清理工具痕迹的思路与实测对比,以及"摘要/裁剪/清理"三者的选型判断。
本文不展开什么:多轮记忆的长期存储(跨会话记忆库)、手动编写 before_model 中间件做消息治理的完整教程、以及模型上下文窗口本身的底层机制。
读完能完成什么:你能为任意 Agent 配置一套自动摘要策略(含触发阈值调优),能用 ContextEditingMiddleware 削掉工具调用的 token 开销,并且面对"长会话"时知道该选摘要、裁剪还是清理。
二、压缩的总体思路:摘要是怎么发生的
在动手写代码之前,先建立一个清晰的心智模型。SummarizationMiddleware 的工作流程可以概括为三步:监控、触发、替换。它挂在 Agent 的"调用模型前"环节,每次请求模型之前先检查当前消息列表,一旦达到触发条件,就调用一个指定的模型把旧历史压成一段摘要文本,然后用"摘要 + 保留的近期消息"替换掉原来的消息列表。
#mermaid-svg-F3Ct0Q6yPYVfVsB5{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-F3Ct0Q6yPYVfVsB5 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-F3Ct0Q6yPYVfVsB5 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-F3Ct0Q6yPYVfVsB5 .error-icon{fill:#552222;}#mermaid-svg-F3Ct0Q6yPYVfVsB5 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-F3Ct0Q6yPYVfVsB5 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-F3Ct0Q6yPYVfVsB5 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-F3Ct0Q6yPYVfVsB5 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-F3Ct0Q6yPYVfVsB5 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-F3Ct0Q6yPYVfVsB5 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-F3Ct0Q6yPYVfVsB5 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-F3Ct0Q6yPYVfVsB5 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-F3Ct0Q6yPYVfVsB5 .marker.cross{stroke:#333333;}#mermaid-svg-F3Ct0Q6yPYVfVsB5 svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-F3Ct0Q6yPYVfVsB5 p{margin:0;}#mermaid-svg-F3Ct0Q6yPYVfVsB5 .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-F3Ct0Q6yPYVfVsB5 .cluster-label text{fill:#333;}#mermaid-svg-F3Ct0Q6yPYVfVsB5 .cluster-label span{color:#333;}#mermaid-svg-F3Ct0Q6yPYVfVsB5 .cluster-label span p{background-color:transparent;}#mermaid-svg-F3Ct0Q6yPYVfVsB5 .label text,#mermaid-svg-F3Ct0Q6yPYVfVsB5 span{fill:#333;color:#333;}#mermaid-svg-F3Ct0Q6yPYVfVsB5 .node rect,#mermaid-svg-F3Ct0Q6yPYVfVsB5 .node circle,#mermaid-svg-F3Ct0Q6yPYVfVsB5 .node ellipse,#mermaid-svg-F3Ct0Q6yPYVfVsB5 .node polygon,#mermaid-svg-F3Ct0Q6yPYVfVsB5 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-F3Ct0Q6yPYVfVsB5 .rough-node .label text,#mermaid-svg-F3Ct0Q6yPYVfVsB5 .node .label text,#mermaid-svg-F3Ct0Q6yPYVfVsB5 .image-shape .label,#mermaid-svg-F3Ct0Q6yPYVfVsB5 .icon-shape .label{text-anchor:middle;}#mermaid-svg-F3Ct0Q6yPYVfVsB5 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-F3Ct0Q6yPYVfVsB5 .rough-node .label,#mermaid-svg-F3Ct0Q6yPYVfVsB5 .node .label,#mermaid-svg-F3Ct0Q6yPYVfVsB5 .image-shape .label,#mermaid-svg-F3Ct0Q6yPYVfVsB5 .icon-shape .label{text-align:center;}#mermaid-svg-F3Ct0Q6yPYVfVsB5 .node.clickable{cursor:pointer;}#mermaid-svg-F3Ct0Q6yPYVfVsB5 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-F3Ct0Q6yPYVfVsB5 .arrowheadPath{fill:#333333;}#mermaid-svg-F3Ct0Q6yPYVfVsB5 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-F3Ct0Q6yPYVfVsB5 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-F3Ct0Q6yPYVfVsB5 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-F3Ct0Q6yPYVfVsB5 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-F3Ct0Q6yPYVfVsB5 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-F3Ct0Q6yPYVfVsB5 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-F3Ct0Q6yPYVfVsB5 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-F3Ct0Q6yPYVfVsB5 .cluster text{fill:#333;}#mermaid-svg-F3Ct0Q6yPYVfVsB5 .cluster span{color:#333;}#mermaid-svg-F3Ct0Q6yPYVfVsB5 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-F3Ct0Q6yPYVfVsB5 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-F3Ct0Q6yPYVfVsB5 rect.text{fill:none;stroke-width:0;}#mermaid-svg-F3Ct0Q6yPYVfVsB5 .icon-shape,#mermaid-svg-F3Ct0Q6yPYVfVsB5 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-F3Ct0Q6yPYVfVsB5 .icon-shape p,#mermaid-svg-F3Ct0Q6yPYVfVsB5 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-F3Ct0Q6yPYVfVsB5 .icon-shape .label rect,#mermaid-svg-F3Ct0Q6yPYVfVsB5 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-F3Ct0Q6yPYVfVsB5 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-F3Ct0Q6yPYVfVsB5 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-F3Ct0Q6yPYVfVsB5 :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
否
是
Agent 收到新请求
中间件检查当前消息列表
是否达到 trigger 触发条件?
直接把消息列表传给大模型
按 trim_token_to_summarize截取待摘要的历史消息
调用 summary_prompt 组装摘要请求{messages} 占位符注入历史
摘要模型生成摘要文本
用摘要生成 HumanMessage放到消息列表头部
按 keep 条件保留近期原始消息拼接在摘要之后
替换后的新消息列表= 摘要 + 保留消息 + 新输入
大模型基于压缩后的上下文回复
这里有一个关键细节:摘要结果不是简单的字符串,而是一条完整的 HumanMessage,以"Here is a summary of the conversation to date"这类格式开头,放在消息列表的最前面。后面的章节我们会从实际输出里看到它的样子。
三、SummarizationMiddleware:六个参数逐一讲透
3.1 model:谁来执行摘要
第一个参数 model 指定用哪个模型做摘要。它既可以是模型名称字符串,也可以是初始化好的模型对象;如果传字符串,底层会自动调用 init_chat_model 帮你初始化。
实战中有两个建议:第一,摘要模型和主模型可以解耦——主对话用能力强的模型,摘要这种"总结归纳"任务用一个便宜的小模型(比如 gpt-4o-mini 或 deepseek 的 flash 系列)就够了,能显著摊薄摘要本身的成本;第二,摘要也是一次真实的模型调用,也会计费,只是它把"每轮重复计费的完整历史"换成了"偶尔触发一次的压缩调用",总体是划算的。
3.2 trigger:什么时候触发摘要
trigger 是摘要中间件的核心,它定义"多长的历史才算太长"。它是一个列表,每个元素是一个条件元组,任意一个条件满足就会触发摘要(OR 语义)。支持三种度量方式:
| tokens | ("tokens", 4000) | 历史消息的累计 token 数达到 4000 触发 |
| messages | ("messages", 30) | 历史消息条数达到 30 条触发 |
| fraction | ("fraction", 0.7) | 历史 token 数达到模型 max_input_tokens × 0.7 触发 |
三种度量各有适用场景:tokens 最精确,直接对应计费和窗口限制,但阈值要人工估;messages 最直观,适合消息长度比较均匀的对话;fraction 最"自适应",它按模型上下文窗口的比例动态计算,换模型不用改阈值。
除了列表形式,trigger 还支持字典形式(TriggerClause):{"tokens": 4000, "messages": 30},字典内多个条件是 AND 语义——所有条件同时满足才触发。结合一下就是:列表元素之间是"或",字典字段之间是"与"。比如你想表达"token 很长且消息也很多才压缩",就用字典;想表达"随便哪个超了都压",就用列表。这一点官方文档有明确说明。
3.3 Deepseek profile 为空的坑
这里必须单独拎出一个坑:fraction 度量要求模型的 profile 里包含 max_input_tokens。框架靠这个值来计算"窗口 × 比例"的阈值。而 Deepseek 平台的模型 profile 是空的——max_input_tokens 拿不到值,此时只要你用了 fraction 条件就会直接报错。
解决办法是初始化模型时手动补上这个配置。比如 Deepseek-V3.2 的上下文长度是 128K,就显式传入 profile={"max_input_tokens": 128_000}。这个问题不只影响 Deepseek,任何 profile 信息不全的模型(包括各种第三方 OpenAI 兼容端点)都适用同样的补救方式:手动声明,别指望框架猜。后文的示例代码里你会反复看到 custom_profile 这个写法,原因就在这里。
3.4 keep:摘要时保留哪些"近期原文"
摘要会丢细节,所以中间件允许你在压缩旧历史的同时,把最近的一小段对话原样保留——这就是 keep 参数。它支持 ("tokens", n)、("messages", n)、("fraction", f) 三种条件。
注意 keep 和 trigger 有一个微妙差别:trigger 是列表(多个条件任一满足),keep 同一时间只接收一种条件。语义上也合理——"什么时候触发"可以是多条件或逻辑,但"保留多少"必须是唯一明确的。实战中 ("messages", 2) 到 ("messages", 6) 是常见区间:保留太少,模型对"刚才说了什么"没有短期记忆,回答容易前言不搭后语;保留太多,压缩效果打折。
3.5 token_counter:token 数是怎么数出来的
trigger 和 keep 里的 tokens 度量,都需要先回答一个问题:token 数怎么算?默认答案是 LangChain 提供的 count_tokens_approximately——一个纯估算函数,不需要真的调用模型的 tokenizer。
它的大致原理是:对纯文本消息,先统计字符数(len(字符串)),再除以"每个 token 大致对应的字符数"换算成粗略 token 数,最后加上每条消息的固定额外开销。这个设计的取舍很清晰:够快、零成本、离线可用,但不是精确值。对中文来说估算误差会比英文大一些(中文一个字往往接近一个 token,而按字符数除系数的算法可能低估或高估)。
一般不需要更改这个默认值。但你要明白一个推论:既然是估算,trigger 的阈值就不要贴着模型上限设。比如窗口 128K,threshold 设 100K 出头是危险的——估算偏差加上工具输出的突发膨胀,可能在你摘要触发之前就先把窗口撑爆了。留 20%~30% 余量是稳妥的做法。
3.6 summary_prompt:自定义摘要怎么写
summary_prompt 允许你接管摘要模型的提示词。唯一硬性要求是:提示词里必须包含 {messages} 占位符,历史消息列表会被序列化后填进这个位置。不指定就使用内置的英文摘要模板(输出里那些 SESSION INTENT、ARTIFACTS、NEXT STEPS 小节就来自它)。
自定义的价值在于:你可以要求摘要用中文写、按你的业务结构组织(比如"已确认的需求 / 未决问题 / 用户偏好"三段式)、或者强调保留某些特定信息(订单号、用户 ID)。这是把"摘要质量"握在自己手里的关键参数。
3.7 trim_token_to_summarize:摘要输入的保险丝
最后一个参数 trim_token_to_summarize 限定"参与摘要的历史消息最大 token 数",超出部分会被裁剪掉,默认 4000。它存在的意义是给摘要调用本身上保险:万一历史膨胀得太猛,摘要请求自己先超了模型限制,那就得不偿失了。
这里有一个必须特别注意的联动坑:如果 trigger 用 token 作为度量,调大触发阈值时,trim_token_to_summarize 要相应调大,否则会丢信息。比如你把 trigger 设成 ("tokens", 20000),但 trim 还是默认 4000——触发摘要时,只有最近 4000 token 的历史参与摘要,更早的 16000 token 直接被裁掉、永远消失。这不是"压缩",是"截断"。记住这个配对关系:trigger 定"什么时候压",trim 定"压的时候最多带多少料",两者要成比例地动。
四、示例一:默认摘要提示词,看懂输出长什么样
先看第一个完整示例,验证 trigger 和 keep 的行为。我们构造一段六条消息的短对话,把触发阈值压得极低(100 token / 6 条消息 / 0.001 比例),确保一次 invoke 就能触发摘要:
from langchain.chat_models import init_chat_model
from dotenv import load_dotenv
import os
# 从 .env 文件中加载环境变量
load_dotenv(override=True)
# Deepseek 模型的 profile 为空,手动补 max_input_tokens
# 否则使用 fraction 度量时会报错
custom_profile = {
"max_input_tokens": 128_000
}
model = init_chat_model(
model="deepseek-v4-flash",
model_provider="deepseek",
profile=custom_profile, # 手动补全模型配置信息
api_key=os.getenv("DEEPSEEK_API_KEY"),
base_url=os.getenv("DEEPSEEK_BASE_URL")
)
from langchain.agents import create_agent
from langchain.agents.middleware import SummarizationMiddleware
from langchain.messages import SystemMessage, HumanMessage, AIMessage
messages = [
SystemMessage("你是个非常友好的 AI 助手"),
HumanMessage("你好啊,我是老王,你是谁?"),
AIMessage("你好老王,我是小王"),
HumanMessage("好的小王,很高兴认识你"),
AIMessage("你高兴得太早了"),
HumanMessage("呵呵,你什么意思")
]
agent = create_agent(
model="deepseek-v4-flash",
middleware=[
SummarizationMiddleware(
model=model, # 用上面补全了 profile 的模型做摘要
trigger=[
("tokens", 100), # 度量一:历史累计 token 达 100
("messages", 6), # 度量二:历史消息达 6 条
("fraction", 0.001) # 度量三:达窗口的 0.1%
], # 列表 = 任一满足即触发(OR 语义)
keep=("messages", 2) # 摘要后保留最新 2 条原始消息
)
]
)
response = agent.invoke({
"messages": messages
})
for msg in response["messages"]:
msg.pretty_print()
运行后,返回的消息列表是这样的(节选关键部分):
================================ Human Message =================================
Here is a summary of the conversation to date:
## SESSION INTENT
用户(老王)与 AI(小王)进行初次问候和介绍。没有明确的后续任务目标,
会话目前处于社交开场阶段。
## SUMMARY
用户自称"老王",向 AI 问好并询问 AI 的身份。AI 回应,自我介绍为"小王"。
用户随后表示很高兴认识 AI。这是一段简短的社交性对话开端……
## ARTIFACTS
None
## NEXT STEPS
等待用户提出具体的请求或任务……
================================== Ai Message ==================================
你高兴得太早了
================================ Human Message =================================
呵呵,你什么意思
================================== Ai Message ==================================
"你高兴得太早了"是一句常见的网络调侃用语……需要我帮忙处理具体事务时,
可以随时告诉我哦 😄
逐条解读这份输出,四个信息点值得注意:
五、示例二:自定义 summary_prompt,换一种摘要风格
第二个示例把 summary_prompt 换成中文极简版,看摘要风格的变化:
from langchain.chat_models import init_chat_model
from dotenv import load_dotenv
import os
load_dotenv(override=True)
# 这次演示 gpt 模型,同样手动声明 profile
custom_profile = {
"max_input_tokens": 1_000_000
}
model = init_chat_model(
model="gpt-5.4-mini",
model_provider="openai",
profile=custom_profile,
api_key=os.getenv("OPENAI_API_KEY"),
base_url=os.getenv("OPENAI_BASE_URL")
)
from langchain.agents import create_agent
from langchain.agents.middleware import SummarizationMiddleware
from langchain.messages import SystemMessage, HumanMessage, AIMessage
messages = [
SystemMessage("你是个非常友好的 AI 助手"),
HumanMessage("你好啊,我是老王,你是谁?"),
AIMessage("你好老王,我是小王"),
HumanMessage("好的小王,很高兴认识你"),
AIMessage("你高兴得太早了"),
HumanMessage("呵呵,你什么意思,你是谁?")
]
agent = create_agent(
model="deepseek-v4-flash",
middleware=[
SummarizationMiddleware(
model=model,
trigger=[
("tokens", 100),
("messages", 6),
("fraction", 0.0001)
],
keep=("messages", 2),
# 自定义摘要提示词,{messages} 占位符是硬性要求
summary_prompt="对历史消息摘要,消息列表如下\\n{messages}"
)
]
)
response = agent.invoke({
"messages": messages
})
for msg in response["messages"]:
msg.pretty_print()
输出与示例一对比,摘要部分的形态完全变了——不再有 SESSION INTENT 那套英文模板,而是直接按我们给的指令生成一段中文摘要,开头也没有"Here is a summary of the conversation to date"的固定前缀。两个结论:
六、ContextEditingMiddleware:清理工具调用的"抽脂手术"
摘要解决的是"对话历史太长",但还有一类膨胀它管不了也不该管:工具调用痕迹。Agent 每轮调用工具,历史里就多一组"tool_calls 决策 + 工具返回的大段结果"。这些返回值往往在第 N+5 轮就毫无用处了,却每轮都跟着计费。对它们做全文摘要既浪费(内容本来就没信息量),也可能破坏格式。
ContextEditingMiddleware 的思路完全不同:它不改写、不总结,而是通过更改发送给模型的消息列表来控制成本——典型操作就是把过期工具调用的返回值直接清空。注意一个重要特性:它不会更改底层存储的消息列表,只在"发给模型之前"这个视图上做编辑。因此我们没法从返回的 messages 里直接看到裁剪痕迹,只能通过 token 用量的变化来推测它是否生效。
它通过 edits 参数接收一组编辑规则,最常用的是 ClearToolUsesEdit:当上下文超过阈值(trigger,按 token 数)时,把工具调用的返回内容清掉,keep 指定保留最近几次工具调用的结果。直接看实验代码:
from langchain.agents import create_agent
from langchain.agents.middleware import ContextEditingMiddleware, ClearToolUsesEdit
from langchain.messages import HumanMessage, AIMessage
from langgraph.checkpoint.memory import InMemorySaver
from dotenv import load_dotenv
load_dotenv()
count = 0
@tool
def get_weather(city: str):
"""查询指定城市天气"""
global count
# 故意返回一大段凑字数的文本,模拟爬虫/代码执行类的"大体量"工具输出
return (f"当前是第 {count} 次调用工具,{city} 今天天气晴朗 "
f"天气非常好,北风,非常适合出行,盼望着,盼望着, "
f"春天来了。我喜欢春天,你喜欢吗,天气真的很不错 "
f"万里无云,天气晴朗,春和景明,哈哈哈哈哈哈,这是凑字数的 "
f"真不错,天气非常好,适合出行,这里 token 挺多的 "
f"可以出门玩,可以跑步,钓鱼,爬山,一切都很好哈哈哈")
agent = create_agent(
model="deepseek-chat",
tools=[get_weather],
middleware=[
ContextEditingMiddleware(
edits=[
ClearToolUsesEdit(
trigger=50, # 上下文超过 50 token 就开始清理工具返回
keep=0 # 保留最近 0 次工具结果,即全部清掉
),
],
),
],
checkpointer=InMemorySaver() # 内存记忆,配合 thread_id 跨轮找回历史
)
config = {"configurable": {"thread_id": "1"}}
for i in range(3):
print("=" * 30, f"当前是第 {i + 1} 轮调用", "=" * 30)
count = i + 1
response = agent.invoke({
"messages": [HumanMessage(f"第 {i + 1} 次询问:今天北京天气如何,一句话回答")]
}, config=config)
print("—- 本次返回的 messages —-")
for msg in response["messages"]:
if isinstance(msg, AIMessage):
if not msg.tool_calls:
# 精简掉 audio/cache_read/reasoning 等冗余字段,只看核心三项
u = msg.usage_metadata
print(f"本次 token 用量:input={u['input_tokens']}, "
f"output={u['output_tokens']}, total={u['total_tokens']}")
三轮调用下来,每轮实际请求的 input_tokens 变化如下(usage_metadata 中的 audio、cache_read、reasoning 等字段与本实验无关,已精简):
| 第 1 轮最终请求 | 169 | 280 | 111 |
| 第 2 轮最终请求 | 239 | 460 | 221 |
| 第 3 轮最终请求 | 309 | 640 | 331 |
规律一目了然:实验组每轮只增长 70 token(一条新对话的体量),对照组每轮增长 180 token(新对话 + 上一次完整的工具返回文本)。三轮下来累计节省了 331 token 的输入开销,而且轮数越多、工具输出越大,差距是线性放大的——爬一百个网页的 Agent 用不用这个中间件,账单完全是两个量级。
对照组的代码就是去掉 middleware 配置,其余完全相同,这里不重复贴。两组对照说明了 ContextEditingMiddleware 的价值:大模型多轮对话时,如果频繁调用产生大量文本的工具(如代码执行、网页爬取),历史记录会急剧膨胀。这个中间件就像一个"上下文抽脂手术"——在不影响当前对话的前提下,自动在后台删掉之前沉淀的工具调用废话,从而极大地节省 token 费用并防止超出模型最大上下文窗口。
另外注意 InMemorySaver 的作用:它在内存中开辟了一个存储空间,第二、三轮提问时,Agent 能通过 thread_id 自动找回前几轮的记忆。正因为有跨轮记忆,历史才会累积、清理才有意义——如果你的 Agent 本身不带 checkpointer,每轮都是全新上下文,也就无所谓清理了。
七、中间件自动化 vs 手动治理:一张表看清分工
用 before_model 中间件手写治理逻辑(trim_messages 裁剪、RemoveMessage 删除、自定义过滤等)是另一条路子。这里先给一个定位对比,帮你分清"什么时候该用内置中间件,什么时候值得自己动手":
| 上手成本 | 一行配置,开箱即用 | 需要自己写中间件函数、处理消息 ID 与 Reducer 逻辑 |
| 信息保留 | 保语义不保原文,旧历史压成摘要 | 裁剪/删除是硬截断,被删内容彻底不可见 |
| 灵活性 | 触发和保留可配置,但策略固定 | 任意策略:按消息类型过滤、按业务规则保留、删除特定消息 |
| 摘要额外成本 | 触发时多一次摘要模型调用 | 无额外模型调用 |
| 状态影响 | 摘要后真实替换消息列表(长期生效) | before_model 裁剪只改"发给模型的视图",after_model 删除才改真实状态 |
| 适用场景 | 长会话、客服、多轮任务型 Agent | 成本极度敏感、需要精确控制上下文结构的场景 |
一句话总结:摘要是"无损体验的有损压缩",裁剪是"无损成本的有损体验"。两者不互斥,成熟的生产系统往往摘要打底、再叠加自定义过滤。
八、选型建议:摘要、裁剪还是清理工具痕迹
三种手段的目标不同,选型判断其实不难:
- 选摘要(SummarizationMiddleware):对话轮次多、旧上下文里"结论"比"原文"重要(客服、咨询、长程任务规划)。模型需要知道"我们聊过什么、定了什么",但不需要逐字复现。
- 选裁剪(trigger_messages 手动裁剪 / keep 策略):成本极度敏感、旧上下文依赖弱。比如翻译、写作类单任务 Agent,前三轮的历史对第五轮基本无用,直接裁掉最省钱。
- 选清理工具痕迹(ContextEditingMiddleware):工具调用频繁且返回体量大(爬虫、代码执行、大 JSON 接口),而对话本身轮数不多。此时瓶颈不是"聊得太多"而是"工具废话太多",摘要反而绕远路。
三者的典型参数画像也可以直接抄作业:摘要的 trigger 建议用 fraction(记得给 Deepseek 补 profile)或留足余量的 tokens;keep 保留 2~6 条近期消息平衡成本与连贯性;ClearToolUsesEdit 的 trigger 设得比摘要阈值低(比如几千 token),keep 保留最近 1~2 次工具结果以防当前推理还需要它们。
九、上线之后:阈值怎么定,要不要盯着它
参数都会配了,还有两个高频疑问需要回答。
第一个问题:tokens 触发阈值到底该设多大? 没有标准答案,但有可套用的经验公式——按模型上下文窗口打七五折:窗口 4K 设 3000,窗口 8K 设 6000,窗口 16K 设 12000。留出来的余量不是浪费,而是给系统提示词、工具定义、新输入和 token 估算误差准备的缓冲。结合 3.5 节说的 count_tokens_approximately 是估算值这一点,这个折扣就更不能省了。
第二个问题:摘要要不要持续关注? 要,而且方法很简单——看摘要触发频率。策略是:频繁触发就提高阈值(说明你的余量给少了,摘要调用本身也在花钱),从不触发就降低阈值(阈值形同虚设,历史一直在无压缩地膨胀)。把触发频率当作一个监控指标接进日志,比事后复盘账单要主动得多。
至于摘要本身会不会丢信息:会丢细节,但姓名、关键事实这类重要信息会保留,最近的消息又被 keep 完整保留,对绝大多数场景足够。摘要在超过阈值时才触发,且可以用便宜模型执行,相比每轮传输完整历史,总体通常更便宜——这也是它成为官方推荐方案的原因。
小结
本文围绕上下文压缩这个"双刃剑"问题,讲透了两个内置中间件。SummarizationMiddleware 用六个参数提供了完整的摘要策略空间:trigger 决定何时压(列表 OR、字典 AND),keep 决定保留什么,token_counter 的估算原理提醒我们阈值要留余量,summary_prompt 的 {messages} 占位符让我们完全掌控摘要质量,trim_token_to_summarize 则要和 trigger 联动调整、避免"假压缩真截断";Deepseek 的空 profile 需要手动补 max_input_tokens 是使用 fraction 的前置条件。ContextEditingMiddleware 则从另一个角度切入:不做摘要,直接清掉过期的工具调用返回,实测三轮对话节省 331 token 输入开销,且随轮数线性放大。最后我们用两张表厘清了自动化中间件与手动治理的分工边界,以及"摘要 / 裁剪 / 清理"的三选一判断标准。
上下文管住了,下一道关是"行为怎么管"——Agent 拿到工具权限后,哪些操作该自动执行,哪些必须停下来等人点头?人工审批(Human-in-the-loop)与安全防护,是 Agent 工程化最难啃也最有价值的环节之一——用中间件给 Agent 装上"刹车"和"安全带"。
网硕互联帮助中心





评论前必须登录!
注册