做 Agent 做久了会发现:卡住你的往往不是「模型不够聪明」,而是喂进去的东西不对。工具结果越滚越长、历史对话越堆越多,最后模型要么抓不住重点,要么每一轮都在为无关内容付费。这就是上下文工程(Context Engineering)要解决的问题——它比「提示词工程」大一圈:不只是问什么,而是每一步该让模型看到什么、看不到什么。
🧠 本教程适合:已经能把 Agent 跑起来、但被「越跑越贵 / 越跑越糊」困扰的开发者。需要 Python 3.10+。本篇不评测任何框架,只给一套可以直接搬走的代码与判断标准。
先诊断:上下文是怎么坏掉的
在动手前,先认识四种典型失效模式,对号入座才知道该修哪块:
| 失效模式 | 表现 | 典型来源 |
|---|---|---|
| 上下文污染 | 模型抓住一个错误结论反复强化 | 某次工具返回了错误数据,之后一直被引用 |
| 上下文分心 | 注意力被大量无关内容稀释,漏掉关键信息 | 把整张表、整篇文档原样塞进窗口 |
| 上下文混乱 | 多余内容多到影响推理质量 | 工具越挂越多(几十个),描述互相干扰 |
| 上下文冲突 | 前后信息互相矛盾,模型无所适从 | 不同阶段抓到的信息打架,旧的没被清理 |
对应四类手段:卸载(offload)、检索(retrieve)、隔离(isolate)、压缩(compress)。下面用一份代码把它们落地。
Step 1:给上下文设预算
1 先定份额,再谈优化
上下文窗口是有限资源,得像分预算一样分配。先写一个粗估 token 的函数和一个预算表:
# ctx_budget.py
def rough_tokens(text: str) -> int:
"""粗略估算 token 数:中文按约 1.5 字/token,英文按约 4 字符/token。
仅用于预算分配,真实计费以模型官方 tokenizer 为准。"""
cn = sum(1 for ch in text if '\u4e00' <= ch <= '\u9fff')
other = len(text) - cn
return int(cn / 1.5 + other / 4)
# 预算份额(按比例,换模型只需改总额)
BUDGET = {
"system": 0.08, # 系统指令:永远保留,写精炼
"history": 0.35, # 对话历史
"tools": 0.30, # 工具结果(写代码类 Agent 可调高)
"retrieved": 0.22, # 检索到的资料
"reserve": 0.05, # 留给输出的余量
}
def alloc(total_tokens: int) -> dict:
return {k: int(total_tokens * v) for k, v in BUDGET.items()}
💡 不同任务该有不同的份额:写代码的 Agent 需要更多工具结果空间;做调研的需要更多检索资料空间。别照抄一份比例用到所有场景。
Step 2:压缩——滚动摘要老历史
2 最近几轮留原文,更早的压成摘要
KEEP_RECENT = 4 # 最近 4 轮保留原文
def compress_history(history: list, summarize_fn, limit_tokens: int) -> list:
"""history 是 [{"role","content"}...]。
最近 KEEP_RECENT 条原样保留,更早的交给 summarize_fn 压成一段摘要。"""
if len(history) <= KEEP_RECENT:
return history
old, recent = history[:-KEEP_RECENT], history[-KEEP_RECENT:]
old_text = "\n".join("%s: %s" % (m["role"], m["content"]) for m in old)
summary = summarize_fn(
"把下面的对话压成不超过 200 字的要点,"
"务必保留:目标、硬性约束、已确认的事实、未解决的问题。\n" + old_text
)
out = [{"role": "system", "content": "【早期对话摘要】" + summary}]
out += recent
# 若仍超预算,继续砍最近轮次里最旧的一条
while sum(rough_tokens(m["content"]) for m in out) > limit_tokens and len(out) > 2:
out.pop(1)
return out
摘要一定会丢信息,所以摘要指令要「点名保留什么」。把目标、约束、已确认事实、未决问题写进提示词,比笼统说「总结一下」可靠得多。涉及金额、权限、日期的关键约束,建议在系统指令里再存一份,不要只依赖摘要。
Step 3:卸载与按需检索
3 窗口里只留「句柄」,要用再取
大块内容不要整段塞进上下文,而是先存到外部,窗口里只留一个标识,需要时再按标识取回。这就是 offload + retrieve:
STORE = {} # 真实项目里换成文件 / Redis / 数据库
def offload(content: str, key: str) -> str:
"""把大内容存到外部,返回给模型看的句柄(很短)。"""
STORE[key] = content
return "[已存档 %s,共 %d 字,需要时用 fetch 工具按 key 取回]" % (key, len(content))
def retrieve(key: str, max_chars: int = 1500) -> str:
"""按需取回,并做长度截断,避免把窗口再撑爆。"""
raw = STORE.get(key, "")
return raw[:max_chars] + ("...(已截断)" if len(raw) > max_chars else "")
再配合「工具结果清理」:一个工具调用的原始返回在历史里滚过几轮之后,价值迅速下降,可以只保留一句结论摘要,原文卸载掉。
Step 4:隔离——多 Agent 别共享一锅粥
4 该共享的共享,该私有的私有
# 共享上下文:大家都需要,只存一份
shared = {"user_profile": {...}, "task_goal": "..."}
# Agent 私有上下文:各自的工作记忆与工具结果,不互相污染
agent_a_ctx = {"working": [...], "tools": [...]}
agent_b_ctx = {"working": [...], "tools": [...]}
def build_prompt(shared, private, system_prompt, user_input):
# 静态内容放前面(利于提示词缓存命中),动态内容放后面(利用近因效应)
static_part = system_prompt + "\n[共享] " + str(shared)
dynamic_part = "[私有] " + str(private) + "\n[本轮输入] " + user_input
return [{"role": "system", "content": static_part},
{"role": "user", "content": dynamic_part}]
把「静态」和「动态」分开还有省钱的意义:很多模型对前缀相同的请求有缓存优惠,稳定内容放在前面能提高缓存命中率、降低费用。但如果你频繁改动前缀(比如每次都重排历史),缓存就会失效——压缩和缓存之间要权衡。
Step 5:怎么验证改动真的有用
5 用同一批任务做前后对比
改上下文策略和改代码一样,要有回归集:
- 固定一批任务(覆盖「信息缺失」「含无关材料」「来源冲突」三种情况);
- 改动前后各跑一遍,记录:答案正确率、平均 token、平均延迟、是否引用到关键约束;
- 不要只看最终答案对错——把「检索质量 → 上下文质量 → 推理质量 → 答案质量」分开看,才知道问题出在哪一层。
📊 一个实用的自查指标:「有依据的正确率」——答案既正确、又能指出来自哪段上下文的比例。它比单纯看准确率更能暴露「蒙对」的情况。
常见问题速查
| 现象 | 大概率原因 & 解决 |
|---|---|
| 越到后面越不听话 | 关键约束被淹没了,把硬性规则固定放在系统指令里,别只留在历史里 |
| 成本随轮次线性上涨 | 历史没压缩、工具结果没清理,按本篇 Step 2/3 处理 |
| 改了压缩策略后变差了 | 摘要丢了关键信息,检查摘要指令是否点名保留目标与约束 |
| 开了缓存却不见省钱 | 前缀不稳定,把静态内容前置并保持顺序稳定 |
| 多个 Agent 互相干扰 | 上下文没隔离,私有工作记忆分开维护 |
📌 记住一句话:上下文工程的目标不是「塞得多」,而是「每一步只给它真正需要的」。先定预算,再谈压缩与卸载,最后用回归集验证——这套流程对任何框架都适用。