编程 Context Engineering 深度实战:当 Agent 越跑越蠢,锅不在模型而在上下文窗口——从 Token 预算到分层记忆的工程全链路拆解

2026-08-18 05:41:25 +0800 CST views 11

Context Engineering 深度实战:当 Agent 越跑越蠢,锅不在模型而在上下文窗口——从 Token 预算到分层记忆的工程全链路拆解

一、背景:为什么你的 Agent 越跑越蠢

先讲一个几乎每个用过 AI 编码工具的人都踩过的坑。

你让 Agent 修一个 bug,前 20 轮对话里你反复强调「不要改 config/ 目录下的任何文件」「用 Redis 而不是数据库缓存」。它乖乖照做了。可干到第 80 轮,它突然把 config/ 里一个核心文件改了,还一本正经地跟你解释说「我决定引入一层数据库缓存来提升性能」。你气得想砸键盘——这不是它变笨了,是它把你早说过的话忘干净了

同样的现象还有:刚修好的错误过一会儿又原样冒出来;你三个小时前否掉的方案,它又当成新主意提了一遍;对话越长,输出越离谱。

很多人第一反应是「模型不行」「上下文窗口不够长」。但真相往往更扎心:窗口够长,只是被填满了,系统开始把旧历史折叠成摘要,关键约束在折叠里丢了。

有个很直观的数据。Claude Code 的上下文窗口标称 20 万 token,但你跑一下 /context 命令就会看到真相:约 22.5% 被预留,10.2% 被系统提示占用,再扣掉 MCP 服务器、子代理、规则文件,真正留给你的其实只有大约 12 万 token。更关键的是——无论是否接近上限,塞进上下文的东西越多,模型输出质量就越差。这不是玄学,是注意力机制的数学:噪声会稀释信号。

这就引出了本文的主角:Context Engineering(上下文工程)

它和 Prompt Engineering 不是一回事。Prompt Engineering 管的是「怎么把单次问题问好」——指令设计、Few-Shot、思维链、输出格式约束。而 Context Engineering 管的是全链路:对话历史怎么管、工具调用结果怎么传、记忆怎么存、任务怎么分解、检索到的知识怎么筛。一句话——Prompt Engineering 优化单次查询,Context Engineering 优化整个输入序列。

LLM 推理的本质,是对一段输入文本序列做续写。一切的上下文工程,都是围绕「如何构建这段输入序列」展开的。


二、核心概念:上下文到底由什么组成

要设计上下文,先得拆清楚一段发给模型的文本里到底塞了些什么。一个完整的上下文窗口通常由下面几块拼出来:

组成内容优化空间
System Prompt角色定义、行为约束、输出格式精简、结构化、打缓存
Retrieved ContextRAG 检索结果、工具执行结果、外部文档重排、裁剪、压缩
Conversation History之前轮次的问题与回答、已完成步骤摘要、滑动窗口、交接
Working State当前任务描述、临时变量、子目标轻量化、符号化
Current Input用户最新消息几乎不可压缩

这里有个反直觉但极其重要的认知:上下文越多 ≠ 越好。我把这种现象叫做「上下文腐烂(Context Rot)」:当无关信息、重复日志、低质量输出混进窗口,模型的注意力被稀释,反而会在关键决策上翻车。

所以上下文工程的三条铁律是:

  1. 压缩噪声(扔掉无关和重复);
  2. 强化信号(把约束、决定、关键事实放到最显眼的位置);
  3. 约束输出空间(用清晰的格式和规则限定模型能往外吐什么)。

这三条做到位,你甚至不用改一行模型权重、不用调一个 temperature,就能把任务失败率从两位数打到个位数。


三、架构分析:把上下文当成一个有限的结构化 Buffer

最危险的误区,是把上下文当成一个「无限大的文本框」,想到什么往里 append 什么。正确的心智模型是:上下文是一个有限、昂贵、且会腐烂的结构化 Buffer

3.1 四层渐进式记忆管道

参考腾讯云、Anthropic 以及一批开源 Agent 项目的实践,我把生产级 Agent 的上下文拆成四层:

L0  原始层   工具原始输出、完整日志、原始对话  → 外部文件系统 refs/*.md(不进窗口)
L1  摘要层   步骤级摘要、原子事实提取          → jsonl,按需注入
L2  状态层   当前任务画布、Mermaid 状态图      → 轻量,常驻窗口尾部
L3  画像层   用户偏好、长期知识、决策记录      → 向量库 / 关系库,检索注入

核心思想:上层承载判断和方向,下层承载证据和精确性。推理时只把 L2/L3 的轻量摘要常驻窗口,需要细节时再按 node_id 钻取 L0 原文。这既省 token,又保完整。

3.2 Token 预算分配

把 20 万 token 当成一个固定预算来分配,而不是随手花:

区块占比说明
System Prompt12%角色 + 硬约束,稳定,可缓存
工具结果40%最大头,最需要裁剪
对话历史30%滑动窗口 + 摘要
检索上下文15%RAG 重排后只留 top-k
当前输入3%用户消息

注意这只是一个起点比例。当上下文长度 > 32k token 且存在 3 类以上数据源时,必须引入分层缓存——否则检索上下文会把历史挤爆。

3.3 滑动窗口 vs 压缩:什么时候触发什么

  • 滑动窗口:简单裁掉最老的几轮。便宜,但会丢「早期定下的约束」。
  • 摘要压缩:用 LLM 把旧历史提炼成结构化摘要。贵一点,但能保住约束和决定。
  • 交接(handoff):压缩时把关键信息落盘到长期记忆,新 session 启动时优先读回。

经验阈值:当已用 token 超过总预算的 80%,触发压缩 + 交接;低于此值时只做工具结果裁剪。


四、代码实战:从零搭一个生产级 Context Manager

下面这套代码是框架无关的(只依赖 tiktoken 和一个你自己的 LLM 客户端接口),你可以直接塞进任何 Agent 里。我把每个组件都讲透。

4.1 Token 预算器

import tiktoken

class TokenBudget:
    """把上下文窗口当固定预算来分配。"""
    def __init__(self, model: str = "gpt-4o", total_tokens: int = 200_000):
        # 非 OpenAI 模型退化到 cl100k_base,估算够用
        try:
            self.enc = tiktoken.encoding_for_model(model)
        except KeyError:
            self.enc = tiktoken.get_encoding("cl100k_base")
        self.total = total_tokens
        self.plan = {
            "system": 0.12,
            "tools": 0.40,
            "history": 0.30,
            "retrieval": 0.15,
            "current": 0.03,
        }

    def count(self, text: str) -> int:
        return len(self.enc.encode(text or ""))

    def budget_for(self, key: str) -> int:
        return int(self.total * self.plan[key])

    def fits(self, key: str, text: str) -> bool:
        return self.count(text) <= self.budget_for(key)

这里的关键不是「数 token」,而是给每个区块设硬上限。超了就触发裁剪逻辑,而不是无脑 append。

4.2 上下文压缩器(摘要压缩)

class ContextCompressor:
    """用 LLM 把旧历史提炼成结构化摘要,而不是简单删除。"""
    def __init__(self, llm, target_tokens: int = 2000):
        self.llm = llm          # 任意实现了 .complete(prompt)->str 的客户端
        self.target = target_tokens

    async def compress(self, messages: list[dict], query: str) -> list[dict]:
        raw = "\n".join(
            f"{m['role']}: {m['content']}"
            for m in messages
            if m.get("role") != "system"
        )
        prompt = (
            f"将以下对话压缩为不超过 {self.target} token 的结构化摘要,"
            f"必须保留:\n"
            f"1) 用户明确提出的约束与禁止项\n"
            f"2) 已经做出的决定和已完成的步骤\n"
            f"3) 关键事实与待办项\n"
            f"忽略闲聊、重复确认和报错细节。\n\n{raw}"
        )
        summary = await self.llm.complete(prompt)
        return [{"role": "system", "content": "[历史摘要] " + summary}]

注意压缩 prompt 里我强制要求保留约束与禁止项——这正是「越跑越蠢」的根因所在。如果你只让模型「总结对话」,它多半会把「不要改 config/」这种约束当闲聊丢掉。

4.3 工具结果裁剪器

工具返回动辄几千上万字符(比如 cat 一个文件、grep 一大片),全塞进窗口是浪费。按类型裁剪:

class ToolResultTrimmer:
    """对工具结果按类型做差异化裁剪。"""
    def __init__(self, max_chars: int = 4000):
        self.max = max_chars

    def trim(self, result: str, kind: str = "text") -> str:
        if len(result) <= self.max:
            return result

        if kind == "json":
            # 结构化数据:只留 key 列表 + 前 3 条记录
            import json
            try:
                data = json.loads(result)
                if isinstance(data, list):
                    head = json.dumps(data[:3], ensure_ascii=False, indent=2)
                    return f"keys 示例: {list(data[0].keys()) if data else []}\n前3条:\n{head}\n...[共 {len(data)} 条,已截断]"
            except json.JSONDecodeError:
                pass

        if kind == "log":
            # 日志:去重 + 取首尾
            lines = list(dict.fromkeys(result.splitlines()))
            if len(lines) > 40:
                kept = lines[:20] + ["...[中间省略]..."] + lines[-20:]
                return "\n".join(kept) + f"\n...[共 {len(lines)} 行,已去重截断]"

        return result[: self.max] + f"\n...[已截断 {len(result) - self.max} 字符]"

kind 字段很重要:JSON 和日志的裁剪策略完全不同。一刀切地「截断前 N 字符」会丢掉最有价值的尾部报错信息,所以日志我特意取首尾而非只取头。

4.4 分层记忆存储

短期工作记忆用内存,长期记忆落盘 + 向量检索:

import sqlite3, json, time, uuid

class MemoryStore:
    """L0~L3 的分层记忆。短期在内存,长期在 SQLite + 向量。"""
    def __init__(self, db_path: str = "agent_memory.db"):
        self.conn = sqlite3.connect(db_path)
        self.conn.execute(
            """CREATE TABLE IF NOT EXISTS memories(
                   id TEXT, type TEXT, content TEXT,
                   embedding BLOB, ts REAL)"""
        )
        self.conn.commit()
        self.working: list[dict] = []   # 工作记忆,常驻

    def add_episodic(self, content: str, embedding=None):
        """情节记忆:某次交互发生了什么。"""
        mid = str(uuid.uuid4())
        self.conn.execute(
            "INSERT INTO memories VALUES (?,?,?,?,?)",
            (mid, "episodic", content,
             self._pack(embedding), time.time()),
        )
        self.conn.commit()

    def handoff(self, messages: list[dict]):
        """交接:把即将被压缩的历史沉淀为情节记忆。"""
        blob = json.dumps(
            [{"role": m["role"], "content": m["content"]} for m in messages],
            ensure_ascii=False,
        )
        self.add_episodic(f"[handoff] {blob}")

    def search(self, query_embedding, topk: int = 5) -> list[str]:
        rows = self.conn.execute(
            "SELECT content FROM memories WHERE type='episodic' ORDER BY ts DESC LIMIT 200"
        ).fetchall()
        # 真实场景用向量相似度排序;这里演示用最近邻召回
        return [r[0] for r in rows[:topk]]

    @staticmethod
    def _pack(vec) -> bytes:
        import pickle
        return pickle.dumps(vec) if vec is not None else b""

生产里长期记忆应当接 Qdrant / pgvector 做真正的语义检索,但这里用 SQLite 把机制讲清楚:交接时不是删除,而是归档——新 session 能通过 search() 把旧上下文捞回来。

4.5 自动压缩触发器

把上面几个组件串起来,做成每次 step 都跑的守门员:

class ContextManager:
    def __init__(self, budget, compressor, trimmer, memory, threshold: float = 0.8):
        self.budget = budget
        self.compressor = compressor
        self.trimmer = trimmer
        self.memory = memory
        self.threshold = threshold

    async def step(self, messages: list[dict], query: str = "") -> list[dict]:
        system = [m for m in messages if m.get("role") == "system"]
        rest = [m for m in messages if m.get("role") != "system"]

        # 1) 工具结果先裁剪
        for m in rest:
            if m.get("role") == "tool":
                m["content"] = self.trimmer.trim(m["content"], m.get("kind", "text"))

        used = sum(self.budget.count(m.get("content", "")) for m in rest)
        if used > self.budget.total * self.threshold:
            # 2) 超阈值:压缩历史 + 交接长期记忆
            self.memory.handoff(rest)
            compressed = await self.compressor.compress(rest, query)
            # 3) 重建:system + 摘要 + 最近 5 轮 + 当前输入
            recent = rest[-5:]
            messages = system + compressed + recent
        return messages

这段代码就是「滑动窗口 + 摘要压缩 + 交接」三件套的落地。rest[-5:] 保证最近的几轮对话(往往包含用户最新约束)永远在窗口里,不会被摘要吞掉。

4.6 Prompt Caching:把稳定前缀变成钱

上下文工程省下来的不只是质量,还有真金白银。Anthropic / 部分 OpenAI 接口支持 Prompt Caching:把不变的前缀(System Prompt、工具定义)打上缓存断点,命中后这部分 token 计费能降到 1/10,首 token 延迟也大幅下降。

SYSTEM_PROMPT = "你是一个严谨的后端工程师 Agent……(硬约束写这里)"
TOOL_DEFS = "[tool] read_file … [tool] grep … [tool] run_shell …"

messages = [
    {"role": "system", "content": SYSTEM_PROMPT,
     "cache_control": {"type": "ephemeral"}},
    {"role": "user", "content": TOOL_DEFS,
     "cache_control": {"type": "ephemeral"}},
    # 下面是每次都变的部分,不缓存
    {"role": "user", "content": user_query},
]

顺序至关重要:缓存断点必须放在稳定内容之后、变化内容之前。把 System + 工具定义放在最前并标记,每次请求只重新计费变化的对话部分。在多轮 Agent 里,这一招能把成本砍掉一个数量级。


五、性能优化:让 Agent 又快又省又稳

光写对还不够,得上指标。下面是我在一套内部 Agent(长文档梳理任务,平均 120 轮对话)上的实测对比(数据为示意,但量级真实):

策略平均 token/轮单任务总成本约束遗忘率首 token 延迟
无管理(纯 append)18,400$0.9231%1.8s
仅滑动窗口9,200$0.4619%1.6s
滑动窗口 + 摘要压缩6,100$0.317%1.5s
+ 工具裁剪 + Prompt Cache3,400$0.114%0.6s

几个优化心法:

  1. 噪声比控制:定期统计窗口里「与当前任务无关」的 token 占比,超过 40% 立即触发压缩。
  2. 语义锚点:把关键约束写成固定的「硬规则块」放在 System Prompt 尾部(离生成最近的位置,注意力权重最高),且不参与压缩
  3. 延迟优化:Prompt Cache 命中后首 token 延迟从 1.8s 降到 0.6s,对交互式 Agent 体验是质变。
  4. 成本结构:工具结果通常占 40% 预算,是裁剪 ROI 最高的地方——先砍它,再砍历史。

还有一个反直觉的点:别盲目上更大的窗口。1M token 窗口听起来爽,但「上下文腐烂」会让你 1M 里 90% 都是噪声,模型照样翻车,账单还更贵。把窗口当 Buffer 管理,比堆长度有用得多。


六、总结与展望:Agent Harness Engineering 元年

2026 年,业内开始把这套东西正式命名为 Agent Harness Engineering(智能体线束工程)。Harness 是包裹在 Agent 推理核心之外的基础设施层——它不替模型思考,但决定了模型「能看见什么、记住什么、忘掉什么」。

回看本文的主线,其实就一句话:LLM 只忠于你给它的那段文本。这段话怎么构建,决定了 Agent 是神还是鬼。

给你一份可以直接落地的行动清单:

  1. 先上 TokenBudget:给每个区块设硬上限,超了就触发裁剪,别再无脑 append。
  2. 工具结果必裁:JSON 留头、日志留首尾,这是 ROI 最高的优化。
  3. 历史摘要要保约束:压缩 prompt 必须显式要求保留「禁止项与决定」,否则等于主动喂遗忘。
  4. 长期记忆做交接:压缩不是删除,是归档;新 session 要能捞回。
  5. 稳定前缀打缓存:System + 工具定义标 cache_control,成本砍一个数量级。
  6. 别迷信大窗口:管理比长度重要,噪声比超过 40% 就要动手。

当模型能力边界逐渐清晰,胜负手就从「选哪个模型」转移到了「怎么喂上下文」。从「与模型对话」到「为模型构建世界」——这,就是上下文工程要你完成的范式迁移。


参考资料:Anthropic 上下文工程指南、腾讯云 Agent Memory 分层架构、Claude Code /context 实测、多个开源 Agent 项目的上下文管理实践。本文代码为框架无关的通用实现,可适配任意 LLM 客户端。

推荐文章

一键压缩图片代码
2024-11-19 00:41:25 +0800 CST
Go 单元测试
2024-11-18 19:21:56 +0800 CST
程序员茄子在线接单