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 Context | RAG 检索结果、工具执行结果、外部文档 | 重排、裁剪、压缩 |
| Conversation History | 之前轮次的问题与回答、已完成步骤 | 摘要、滑动窗口、交接 |
| Working State | 当前任务描述、临时变量、子目标 | 轻量化、符号化 |
| Current Input | 用户最新消息 | 几乎不可压缩 |
这里有个反直觉但极其重要的认知:上下文越多 ≠ 越好。我把这种现象叫做「上下文腐烂(Context Rot)」:当无关信息、重复日志、低质量输出混进窗口,模型的注意力被稀释,反而会在关键决策上翻车。
所以上下文工程的三条铁律是:
- 压缩噪声(扔掉无关和重复);
- 强化信号(把约束、决定、关键事实放到最显眼的位置);
- 约束输出空间(用清晰的格式和规则限定模型能往外吐什么)。
这三条做到位,你甚至不用改一行模型权重、不用调一个 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 Prompt | 12% | 角色 + 硬约束,稳定,可缓存 |
| 工具结果 | 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.92 | 31% | 1.8s |
| 仅滑动窗口 | 9,200 | $0.46 | 19% | 1.6s |
| 滑动窗口 + 摘要压缩 | 6,100 | $0.31 | 7% | 1.5s |
| + 工具裁剪 + Prompt Cache | 3,400 | $0.11 | 4% | 0.6s |
几个优化心法:
- 噪声比控制:定期统计窗口里「与当前任务无关」的 token 占比,超过 40% 立即触发压缩。
- 语义锚点:把关键约束写成固定的「硬规则块」放在 System Prompt 尾部(离生成最近的位置,注意力权重最高),且不参与压缩。
- 延迟优化:Prompt Cache 命中后首 token 延迟从 1.8s 降到 0.6s,对交互式 Agent 体验是质变。
- 成本结构:工具结果通常占 40% 预算,是裁剪 ROI 最高的地方——先砍它,再砍历史。
还有一个反直觉的点:别盲目上更大的窗口。1M token 窗口听起来爽,但「上下文腐烂」会让你 1M 里 90% 都是噪声,模型照样翻车,账单还更贵。把窗口当 Buffer 管理,比堆长度有用得多。
六、总结与展望:Agent Harness Engineering 元年
2026 年,业内开始把这套东西正式命名为 Agent Harness Engineering(智能体线束工程)。Harness 是包裹在 Agent 推理核心之外的基础设施层——它不替模型思考,但决定了模型「能看见什么、记住什么、忘掉什么」。
回看本文的主线,其实就一句话:LLM 只忠于你给它的那段文本。这段话怎么构建,决定了 Agent 是神还是鬼。
给你一份可以直接落地的行动清单:
- 先上 TokenBudget:给每个区块设硬上限,超了就触发裁剪,别再无脑 append。
- 工具结果必裁:JSON 留头、日志留首尾,这是 ROI 最高的优化。
- 历史摘要要保约束:压缩 prompt 必须显式要求保留「禁止项与决定」,否则等于主动喂遗忘。
- 长期记忆做交接:压缩不是删除,是归档;新 session 要能捞回。
- 稳定前缀打缓存:System + 工具定义标
cache_control,成本砍一个数量级。 - 别迷信大窗口:管理比长度重要,噪声比超过 40% 就要动手。
当模型能力边界逐渐清晰,胜负手就从「选哪个模型」转移到了「怎么喂上下文」。从「与模型对话」到「为模型构建世界」——这,就是上下文工程要你完成的范式迁移。
参考资料:Anthropic 上下文工程指南、腾讯云 Agent Memory 分层架构、Claude Code /context 实测、多个开源 Agent 项目的上下文管理实践。本文代码为框架无关的通用实现,可适配任意 LLM 客户端。