凡人AI AI 咨询 · 定制开发 · 开发框架 把 AI 从 demo 做到敢商用
← 全部技术洞察
成本工程 AI Agent 上下文缓存系列 · 序篇 2026-07-20

Agent 越聊越贵,真凶不是"上下文太长"

一个 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 的中间件给了一个干净的钩子。AgentMiddlewarewrap_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 的手段,摆放的时机决定它是加法还是乘法。

边界:什么折不了

一般化:这是一类手法

抽象出来,是一条通用的上下文工程原则:上下文里维护"轻量标识 + 按需展开",而不是"全量常驻"。

方向上业界正收敛到同一点。而这套手法能放心上生产,靠的是守住一条红线:折的是冗余副本,不是信息。

适用边界:在什么缓存设计下有用

有人会问:这不就是靠"隐式前缀缓存"才成立吗?换成显式缓存的场景,是不是就不需要了?

先厘清一件事——省 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 上下文工程的实战细节。

这些坑,你的项目正在踩吗?
先聊 30 分钟——免费,不推销,聊完至少带走一条能用的建议。
预约免费诊断