🎯 课程主题
使用 ContextEditingMiddleware 中间件自动裁剪上下文中的冗余 Token,以控制多轮对话的成本。
📝 核心知识点
1. 上下文(Context)与 Token 成本
- 概念说明:上下文是指在多轮对话中,将前几次的输入和输出信息一并传递给大模型的历史消息。随着对话轮次增多,上下文体积不断膨胀。
- 关键细节:传递给大模型的 Token 越多,调用成本越高。频繁调用产生大量文本的工具(如代码执行、网页爬取)时,历史消息会急速膨胀。
2. ContextEditingMiddleware 的作用
- 概念说明:通过编辑(裁剪)上下文来控制 Token 数量,从而降低调用成本。
- 关键细节:
- 使用该中间件前后,消息列表的消息条数不会变化——有裁剪后仍是相同条数。
- 验证方式:观察每次调用时
usage_metadata中 Token 数增长幅度是否被抑制,来判断裁剪是否生效。 - 适用于优化工具返回结果在上下文中的体积,相当于给上下文做"抽脂手术"。
3. 核心参数配置
- 概念说明:通过
edits参数配置编辑策略,是一个可迭代的结构。 - 关键细节:
trigger:触发编辑的 Token 阈值(默认值较大),达到该数量即触发裁剪。示例中设为 50。keep:保留策略,示例中keep=0表示工具调用结果全部清除、一个不留。- 除清理工具外,该中间件还支持其他编辑行为,本节课主要演示工具清理。
4. 实验对照法验证效果
- 概念说明:通过对比"使用中间件"与"不使用中间件"两组实验的 Token 增长曲线来验证效果。
- 关键细节:
- 对照组:去掉
ContextEditingMiddleware,其他代码完全相同。 - 观察三次调用中输入 Token 的变化(如 280 → 316 → 352),使用中间件后增幅明显变小。
- 对照组:去掉
🏗️ 架构与工作流
┌─────────────────────────────────────────────────────┐
│ Agent 多轮调用流程(InMemorySaver 保持会话记忆) │
│ │
│ 第1次 invoke ──► 工具返回大量文本 ──► 历史消息存储 │
│ 第2次 invoke ──► 上下文 = 第1次输入+输出+本次输入 │
│ │ Token 达50触发裁剪 ──► 保留核心 │
│ 第3次 invoke ──► 上下文 = 前两次(已裁剪)+本次输入 │
│ │
│ 每轮通过 response.usage_metadata 观察 Token 用量 │
└─────────────────────────────────────────────────────┘
💻 代码实战
from langchain.agents import create_agent
from langchain.agents.middleware import ContextEditingMiddleware
from langchain.memory import InMemorySaver
from langchain_core.messages import HumanMessage, AIMessage
# 模型
from langchain_openai import ChatOpenAI
model = ChatOpenAI(model="gpt-4o")
# 定义一个返回大量文本的工具
from langchain_core.tools import tool
@tool
def get_weather(city: str) -> str:
"""查询天气(故意返回大量文本,模拟长文本工具调用)"""
return f"{city}的天气信息:" + "这是一段非常长的描述文本," * 200
tools = [get_weather]
# ContextEditingMiddleware:trigger=50, keep=0 表示Token数达50即清除所有工具结果
context_edit_middleware = ContextEditingMiddleware(
edits=[
{"trigger": 50, "keep": 0, "tool_name": None} # tool_name=None 表示所有工具
]
)
# 短期记忆:保持同一会话中的历史消息
memory = InMemorySaver()
agent = create_agent(
model=model,
tools=tools,
middleware=[context_edit_middleware],
checkpointer=memory,
)
# 多轮调用(同一 thread_id 保持会话)
config = {"configurable": {"thread_id": "session-1"}}
# 第1次调用
resp1 = agent.invoke(
{"messages": [HumanMessage(content="北京天气怎么样?")]},
config=config
)
print("第1次 input tokens:", resp1.get("usage_metadata", {}))
# 第2次调用
resp2 = agent.invoke(
{"messages": [HumanMessage(content="上海呢?")]},
config=config
)
print("第2次 input tokens:", resp2.get("usage_metadata", {}))
# 第3次调用
resp3 = agent.invoke(
{"messages": [HumanMessage(content="广州呢?")]},
config=config
)
print("第3次 input tokens:", resp3.get("usage_metadata", {}))
# 对照组:去掉 ContextEditingMiddleware,其余相同,对比 Token 增长
⚠️ 常见问题与避坑指南
trigger阈值的单位是 Token 数,不是字符数或消息条数,设置时需要结合模型 Tokenizer 估算。- 如果工具返回的文本量不大,
trigger设得较高时可能永远不触发裁剪。 keep=0是极端策略,实际开发中可设置保留最近 N 条工具结果作为上下文参考。
💡 个人总结与延伸
ContextEditingMiddleware 专门解决多轮 Agent 对话中上下文膨胀导致的成本问题,通过可配置的编辑策略自动裁剪冗余内容。验证效果时不应只看消息条数,而应对比 usage_metadata 中的 Token 数据。该中间件与 InMemorySaver 等记忆组件配合使用,可在保持会话连续性的同时控制成本。