一个
wrap_model_call中间件、几十行纯函数,把长会话里最烧钱的那条路堵死——附核心代码。
做长上下文 Agent 时,你迟早会盯着账单发问:一次对话十几轮,怎么就烧掉几十万 token?
拆开日志看,多半会发现一个反直觉的真相:贵的不是上下文"太长",是同一份内容被重发了太多次。
问题:一次读入,N 次重放
设想一个文档研究 Agent。某一轮它调 read_document 读进一份 8 万字符的长文,模型据此回答。这份长文现在留在了消息历史里。下一轮、再下一轮……只要没触发上下文压缩,它就会跟着整段历史一起,每次 LLM 调用都全量重发一遍。
轮次 1: [… read_document 结果 8万字符 …] → 发 8万
轮次 2: [… read_document 结果 8万字符 …][新问答] → 又发 8万
轮次 3: [… read_document 结果 8万字符 …][新问答][新问答] → 再发 8万
…
模型消化完这份文档、早就不需要逐字看原文了,但它还在那儿,被一遍遍搬运。成本不在"它进来一次",在"它留下来被重放 N 次"。
转念:历史里的大块内容,很多是"冗余副本"
直觉会说"上下文太长,得压缩 / 截断"。但压缩有损、截断丢内容——对质量敏感的场景是灾难。
换个角度:那份 8 万字符的原文,在别处本来就有权威副本(它来自某个文档库 / 文件 / URL,重新调一次工具就能取回)。历史里的这一份,是冗余的。
既然是冗余副本,就不必"压缩信息",只需要别重复搬运同一份——把历史里的它换成一个"去哪能取回"的引用。模型看得见"这里有什么、去哪拿全的",几百字取代了 8 万字符。
"更短"不靠"更少信息"实现,靠"不重复搬运"实现。
切入点:LangChain 的 wrap_model_call 中间件
要在"发给模型之前"改这份历史,LangChain 1.x 的中间件给了一个干净的钩子。AgentMiddleware 的 wrap_model_call 包裹了每一次模型调用——你能拿到 request,变换后再交给 handler 发出去:
from typing import Callable
from langchain.agents.middleware import AgentMiddleware
from langchain.agents.middleware.types import ModelRequest, ModelResponse
class MyMiddleware(AgentMiddleware):
def wrap_model_call(self, request, handler):
new_messages = transform(request.messages)
# override 产生一份新的 request,只改这次调用看到的 messages
return handler(request.override(messages=new_messages))注意 request.override(...) 的语义:它返回一份新的请求视图,底层的 state / checkpoint 一个字不动。这正是我们要的——变换只作用于"发出去的那一份",不碰"存着的那一份"。
核心:引用化投影中间件
from langchain.agents.middleware import AgentMiddleware
from langchain_core.messages import ToolMessage
KEEP_RECENT = 6 # 最近 N 条消息原样保留(保护"当前推理链")
MAX_INLINE_CHARS = 4000 # 超过此长度的工具结果才折叠
# 哪些工具结果"可重新取回"——内容在别处有权威副本,或重调即得。
# 这是安全闸门:只折叠可找回的,不可找回的一律不动。
RECOVERABLE_TOOLS = {"search", "read_document", "fetch_url"}
def _fold_to_reference(msg: ToolMessage) -> ToolMessage:
"""把超长工具结果折成"引用 + 头尾预览",保留全部身份字段。"""
text = msg.content if isinstance(msg.content, str) else ""
lines = text.splitlines()
head, tail = "\
".join(lines[:5]), "\
".join(lines[-5:])
reference = (
f"[reference] {msg.name} 结果原文 {len(text)} 字符,已折叠。\
"
f"--- head ---\
{head}\
... [中段省略] ...\
--- tail ---\
{tail}\
"
f"如需完整内容,请重新调用 {msg.name}。"
)
# 只换 content,tool_call_id / name / id / status 必须保留——
# 否则破坏 tool_call ↔ tool_result 配对(OpenAI 兼容 API 硬要求)。
return ToolMessage(
content=reference, tool_call_id=msg.tool_call_id,
name=msg.name, id=msg.id, status=msg.status,
)
class ReferenceProjectionMiddleware(AgentMiddleware):
"""发给模型前,把 keep 窗口外的超长'可重取回'工具结果折成引用。
只作用于本次请求视图,不改 state —— 纯投影,可回滚,零信息损失。"""
def wrap_model_call(self, request, handler):
messages = request.messages
cutoff = max(0, len(messages) - KEEP_RECENT) # 最近 N 条不折叠
projected = []
for i, msg in enumerate(messages):
if (i < cutoff and isinstance(msg, ToolMessage)
and msg.name in RECOVERABLE_TOOLS
and isinstance(msg.content, str)
and len(msg.content) > MAX_INLINE_CHARS):
projected.append(_fold_to_reference(msg))
else:
projected.append(msg)
return handler(request.override(messages=projected))挂上它,就这些:
agent = create_agent(model, tools=[...], middleware=[ReferenceProjectionMiddleware()])但让它从"能用"变"靠谱",藏着四个值得说的决策。
四个让它站得住的决策
① 投影,不是改写。 变换只作用于 handler(request.override(...)) 这一次请求,state / checkpoint 里的完整原文一个字不动。下一轮框架重新水化完整历史、再投影一遍。收益是三件事一起来:零信息损失、零持久化风险、回滚只是摘掉中间件。
② 只折"可找回"的,这是安全底线。 RECOVERABLE_TOOLS 白名单划定了"内容在别处有权威副本"的边界。一份只存在于历史里的东西,折了就是真丢。判据只有一条:能不能确定性地重新取回。能→可折叠;不能→碰不得。
③ 保护最近 N 条。 最近几条往往是模型当前正在推理引用的内容(刚读进来、正准备处理的那份),折了会让它当场失忆。老消息才是"消化完不再需要原文"的。
④ 替换文本必须是确定性纯函数。 _fold_to_reference 里只用了消息自身的字段,没有时间戳、没有随机值。很多模型服务按"最长匹配前缀"做 prompt 缓存,历史中段任何一个字节变了,其后全部前缀缓存都失效、按全价重算。折叠后的消息每轮渲染字节完全相同,缓存照常命中。
还有一个时机上的乘法效应:如果你同时挂了做摘要 / 压缩的中间件,把引用化放在它之前——省下的 token 会让摘要触发点推后,摘要更晚、更少、每次更小。两个优化不是相加,是相乘。
省 token 的手段,摆放的时机决定它是加法还是乘法。
边界:什么折不了
- 模型当前正在生成的输出、需要逐字新写的内容——它本身就是唯一副本。
- "内容即载荷"的消息(比如一段还没落库的新草稿)。
- 能折的永远只是冗余副本:别处有权威源、能确定性取回的那一类。
一般化:这是一类手法
抽象出来,是一条通用的上下文工程原则:上下文里维护"轻量标识 + 按需展开",而不是"全量常驻"。
- 本文:老工具结果 →
[reference id],需要时重调工具展开。 - 不建索引的 agentic search:维护路径 / 查询等轻量标识,grep 时才展开。
- 一些框架的 context editing:老 tool result 自动换占位符——同一思路(区别只在占位可不可逆:换成可找回的引用比不可逆占位符更安全)。
方向上业界正收敛到同一点。而这套手法能放心上生产,靠的是守住一条红线:折的是冗余副本,不是信息。
适用边界:在什么缓存设计下有用
有人会问:这不就是靠"隐式前缀缓存"才成立吗?换成显式缓存的场景,是不是就不需要了?
先厘清一件事——省 token 有两个正交的轴:
| 轴 | 降什么 | 谁负责 |
|---|---|---|
| 量 | 一次请求发出去、让模型处理多少 token | 引用化投影 |
| 单价 | 发出去的 token,命中缓存那部分按几折计费 | 缓存(隐式 / 显式) |
投影压"量",缓存压"单价"——两者相乘,不是二选一。 隐式还是显式,只是在"单价"这个轴上换实现,并不会让"量"那个轴消失。分三种缓存设计看:
① 隐式前缀缓存(OpenAI、阿里云 DashScope 等自动前缀缓存) 缓存只压单价:那份 8 万字符即便命中缓存,也仍以缓存价 × N 轮计费,还占着上下文窗口、把摘要触发点提前。投影把"量"也砍掉,和缓存相乘——这里收益最大。前提是折叠文本确定性(见上一节),否则每折一次就打掉它之后的前缀缓存。
② 显式断点缓存(如 Anthropic 的 cache_control 断点) 依然互补,但多一个坑:在历史中段把一条折成引用,会让断点之下的缓存失效、触发一次重写。做法是让折叠边界落在缓存断点之下——只折已经不在稳定前缀里的动态尾部,别去动断点覆盖的前缀。
③ 显式上下文缓存(如 Gemini 的 CachedContent:大块内容缓存成一个带 TTL 的对象,用 handle 引用,压根不进逐轮消息历史) 这一档,那个"不需要"的直觉是对的:对已经被缓存成对象、以 handle 引用、不在历史里全量重放的那块内容,投影是多余的——因为显式上下文缓存本身就是同一条原则的框架级实现("留个 handle,别把大块常驻上下文"),它替你做掉了。
但第 ③ 档只对"进了缓存的那块"成立。Agent 真正烧钱的,往往是动态生长的工具调用尾部:一轮轮新来的、大小不一、数量众多的工具结果——它们过不了显式缓存的门槛(最小 token 数、断点数量上限、TTL、写入成本),照样躺在历史里被重放。投影收拾的正是这条尾巴,和显式缓存覆盖的是不同区域,极少真到"用不上"。
缓存压单价,投影压量;显式上下文缓存是"投影"的框架级版本——它够得着的地方就用它,够不着的动态尾部才轮到自己做投影。越依赖隐式前缀缓存,自己做投影的边际价值越高。
好的上下文工程,很多时候不是"让模型看更少",是**"让同一份东西别在上下文里躺着被重放 N 次"**——把它折成一个随时能展开的引用,权威副本安安静静待在它该在的地方。
一个 wrap_model_call 中间件、几十行纯函数,换来长会话里数量级的 token 下降。杠杆常常就藏在**"发出去之前"**这一层。
如果这篇对你有用,欢迎关注 / 在看,后面会继续拆更多 Agent 上下文工程的实战细节。