上下文工程:不只是"把 Prompt 写好"
如果你读过 Anthropic 的 Agent 最佳实践,会看到这样一句话:
"The most important skill in building agents is context engineering --- the art of getting the right information into the model's context window at the right time."
上下文工程(Context Engineering)比 Prompt 工程更底层,管的是整个上下文窗口的内容构成与预算分配:放什么、放多少、按什么顺序、预算不够时怎么取舍。
本篇从三个维度拆解它:
- 上下文五来源 + Token 成本剖析:上下文窗口里住着哪些东西
- 预算约束下的动态组装:Token 告急时怎么做取舍
- 溢出策略三选一:截断、摘要、检索,同一问题下答案质量的真实差异
上下文的五个来源
一个完整的 Agent 上下文由五类内容组成,每类都有不同的生命周期和成本特征:
sql
┌─────────────────────────────────────────────────────────┐
│ Agent 上下文构成 │
├──────────────────┬──────────────────────────────────────┤
│ ① System Prompt │ 固定载入,定义 Agent 角色和行为准则 │
│ │ 特征:稳定,适合 Prompt Caching │
├──────────────────┼──────────────────────────────────────┤
│ ② 工具定义 │ 按需加载,当前任务相关的工具 Schema │
│ │ 特征:工具越多膨胀越快(每个 ~50-200t) │
├──────────────────┼──────────────────────────────────────┤
│ ③ 对话历史 │ 最近 K 轮,随对话增长 │
│ │ 特征:线性增长,需要截断/摘要控制 │
├──────────────────┼──────────────────────────────────────┤
│ ④ 检索内容 │ 动态注入,当前问题相关的知识库片段 │
│ │ 特征:质量决定生成质量,需相关度过滤 │
├──────────────────┼──────────────────────────────────────┤
│ ⑤ 当前输入 │ 用户当前 Turn 的问题 │
│ │ 特征:永远最后载入,不可省略 │
└──────────────────┴──────────────────────────────────────┘
Token 成本实测
对一个典型的客服 Agent 做 Token 剖析(128K 窗口,4K 输出预留,可用 124K):
erlang
来源 Token数 占预算% 用途
────────────────────────────────────────────────────────────
① System Prompt 155 0.1% 固定载入
② Tool Definitions 174 0.1% 按需加载(当前任务相关工具)
③ Conv. History (8轮) 263 0.2% 最近 N 轮(可截断/摘要)
④ Retrieved Content 200 0.2% 动态(相关度过滤)
⑤ Current Input 22 0.0% 当前 Turn
────────────────────────────────────────────────────────────
合计 814 0.7%
剩余 Buffer 123,186
看起来 8 轮对话只用了 0.7%,问题在哪?两个增长因子:
因子 1:对话轮数增长
- 8 轮历史 = 263 tokens;100 轮历史 ≈ 3,200 tokens;1000 轮历史 ≈ 32,000 tokens
- 长期使用的 Agent(客服/助手)不加控制会在几百轮后遭遇溢出
因子 2:工具数量增长
- 4 个工具 = 174 tokens;20 个工具 ≈ 860 tokens;100 个工具 ≈ 4,300 tokens
- MCP Agent 接入大量工具时,工具定义本身就会吃掉几 K tokens
Token 计数工具(使用 tiktoken,cl100k_base 编码作为近似):
python
import tiktoken
_enc = tiktoken.get_encoding("cl100k_base")
def count_tokens(text: str) -> int:
"""统计文本 Token 数(对中文是近似值,偏低估)"""
return len(_enc.encode(text))
def msg_tokens(msg) -> int:
"""计算单条消息 Token 数(含 4 个 overhead tokens)"""
return count_tokens(msg.content) + 4
预算约束下的动态上下文组装
核心思路:按优先级加载,预算告急时高优先级内容永远完整,低优先级内容弹性裁剪。
优先级模型
sql
P0 System Prompt --- 永远完整载入(角色定义不能丢)
P1 Current Input --- 永远完整载入(没有问题什么都没有)
P2 Recent History --- 从最新轮倒序加入,直到撑不下(可压缩)
P3 Retrieved Docs --- 按相关度从高到低加入(可截断)
P4 Tool Defs --- 只加载当前任务相关工具(可按需裁剪)
ContextBudgetManager 实现
python
class ContextBudgetManager:
def __init__(self, total_budget: int = 128_000, output_reserve: int = 4_000):
self.available = total_budget - output_reserve
self.used = 0
self.items: list[dict] = []
def add(self, name: str, content: str, priority: int) -> bool:
"""尝试完整载入,成功返回 True,预算不足返回 False"""
t = count_tokens(content)
if t <= self._remaining():
self.used += t
self.items.append({"name": name, "tokens": t, "priority": priority})
return True
return False
def add_with_trim(self, name: str, content: str, priority: int) -> int:
"""尽量多载入:超预算时按比例截断,返回实际载入 tokens"""
t = count_tokens(content)
if t <= self._remaining():
self.used += t
self.items.append({"name": name, "tokens": t, "priority": priority})
return t
budget_left = self._remaining()
if budget_left <= 0:
return 0
ratio = budget_left / t
trimmed = content[:int(len(content) * ratio)]
actual_t = count_tokens(trimmed)
self.used += actual_t
self.items.append({"name": name, "tokens": actual_t, "priority": priority,
"trimmed": True})
return actual_t
实测:两种场景
场景 A:正常对话(12K 预算)
erlang
来源 Tokens % 状态
────────────────────────────────────────────────
System Prompt 155 1.6% ✓ 完整
Current Input 22 0.2% ✓ 完整
History Turn -1 62 0.6% ✓ 完整
History Turn -2 47 0.5% ✓ 完整
History Turn -3 61 0.6% ✓ 完整
History Turn -4 60 0.6% ✓ 完整
Retrieved Docs 200 2.0% ✓ 完整
Tool Defs 174 1.7% ✓ 完整
────────────────────────────────────────────────
已用 781 7.8%
剩余 9,219 92.2%
场景 B:预算紧张(3K 预算,模拟工具密集型 Agent)
erlang
来源 Tokens % 状态
────────────────────────────────────────────────
System Prompt 155 7.8% ✓ 完整
Current Input 22 1.1% ✓ 完整
Recent History (1轮) 40 2.0% ✓ 完整
Retrieved Docs 200 10.0% ✓ 完整
Tool Defs 174 8.7% ✓ 完整
────────────────────────────────────────────────
已用 591 29.5%
剩余 1,409 70.5%
预算紧张时的优先级体现:System Prompt + 当前输入永远完整,对话历史只保留最近 1 轮,其余按剩余预算弹性载入。核心信息不丢,辅助信息随预算弹性调整。
上下文溢出三策略对比
当对话历史超过 Token 上限,有三种应对方式。用同一个场景测试三种策略:
测试设定:
- 10 个 Python 主题的对话历史(20 条消息,510 tokens)
- 历史 Token 上限设为 300(模拟窗口告急)
- 测试问题:「我最开始问的 Python 列表推导式是什么?能给我一个实际的使用例子吗?」
- 目标:答案中能否体现第 1 轮对话中学过列表推导式这个事实
策略一:截断(Truncation)
python
def strategy_truncation(history: list, max_tokens: int) -> tuple[list, int]:
"""从最新消息开始,倒序累积直到超预算"""
kept = []
used = 0
for msg in reversed(history):
t = msg_tokens(msg)
if used + t > max_tokens:
break
kept.insert(0, msg)
used += t
return kept, used
实测结果:保留了 11/20 条消息(294 tokens),最早可见消息是第 5 轮(__enter__ 和 __exit__ 的上下文管理器),第 1 轮的列表推导式内容已被丢弃。
markdown
保留消息数:11/20 | 使用 tokens:294/300
最早可见:'实现 __enter__ 和 __exit__ 方法,或用 @contextmanager...'
回答(靠 LLM 通用知识):
Python 列表推导式是一种简洁且强大的方式,用于创建列表。
[表达式 for 变量 in 列表 if 条件] ← 正确但没有引用对话历史
策略二:摘要(Summarization)
python
summary = llm.invoke([
SystemMessage("将以下 Python 学习对话压缩为简洁摘要,"
"保留所有已讨论的技术主题和关键结论(不超过 150 字):"),
HumanMessage(history_text),
])
实测结果:510 tokens → 99 tokens,压缩比 5.2x。摘要保留了所有 10 个主题:
python
原始历史:510 tokens → 摘要:99 tokens(压缩比 5.2x)
摘要内容:
Python列表推导式简化for循环,字典推导式生成dict,生成器节省内存,
装饰器包装函数,上下文管理器实现with语句,GIL限制线程,
IO密集型用threading,CPU密集型用multiprocessing,
async/await适合IO密集,dataclass减少样板代码,Pydantic用于数据验证。
回答(基于摘要):
当然可以。Python 列表推导式是一种简洁且强大的语法结构,
[x**2 for x in range(5)] ← 摘要中有列表推导式,生成了具体例子
策略三:检索(Retrieval)
python
# 把每轮对话变成 Document,构建向量索引
history_docs = [
Document(page_content=f"Q: {q}\nA: {a}", metadata={"turn": i+1})
for i, (q, a) in enumerate(history_topics)
]
history_store = Chroma.from_documents(history_docs, embeddings)
history_retriever = history_store.as_retriever(search_kwargs={"k": 2})
relevant_docs = history_retriever.invoke(test_question)
# → 精准命中 Turn 1(列表推导式)和 Turn 10(Pydantic)
实测结果:从 20 条消息中精准检索出 2 条(118 tokens),直接命中 Turn 1:
ini
检索到 2 条相关历史(118 tokens):
Turn 1: Python 列表推导式是什么? ← 精准命中
Turn 10: Pydantic 和 dataclass...
回答(基于检索到的历史):
当然可以。Python 列表推导式是一种简洁的方式来创建列表。
squared_numbers = [x**2 for x in numbers] ← 有具体例子,引用了历史内容
三策略对比汇总
策略 历史Token使用 第1轮是否可见 实现难度
──────────────────────────────────────────────────────
截断 294 ✗ 已丢弃 极低
摘要 99 ✓ 在摘要中 中
检索 118 ✓ 精准命中 高(需向量索引)
截断的第 1 轮列表推导式内容已被丢弃。LLM 靠自身通用知识作答------答案是对的,但那是 LLM 自己知道的,不是"根据我们的对话历史"。
摘要在 510→99 tokens 的压缩下,保留了全部 10 个主题,能体现"我们讨论过列表推导式"。代价是细节被泛化(只有主题名,没有原始问答内容)。
检索用最少的 tokens(118)精准拿到了第 1 轮内容,答案质量最高。代价是需要构建和维护历史向量索引。
上下文工程设计清单
Token 预算规划
- 确定模型上下文窗口大小(Claude Sonnet 200K,GPT-4o 128K,GLM-4 128K)
- 预留足够输出空间(推荐 4K-20K,取决于任务输出长度)
- 为每个来源估算 Token 上限:System Prompt < 2K,工具定义按需加载,历史上限 20K,检索上限 30K
优先级组装
- P0/P1(System Prompt + 当前输入)永远完整,不参与裁剪
- P2(历史)从最新轮倒序加,超预算时停止而不是随机删
- P3(检索内容)按相关度评分过滤,分数低的不载入
- P4(工具定义)只加载当前 Turn 可能用到的工具,不全量载入
溢出策略选择
- 对话轮数 < 20:截断即可,实现简单
- 对话轮数 20-100 或需保留全局脉络:用摘要(建议 5-10 轮触发一次)
- 对话轮数 > 100 或有"找早期特定内容"需求:用检索(向量索引历史对话)
监控与告警
- 记录每次请求的实际 Token 消耗(输入/输出/缓存命中)
- 设置告警:单次请求超过窗口 80% → 说明压缩策略需要调整
- 记录截断/摘要触发频率,频繁触发说明阈值设置过低
本篇小结
几个核心结论:
- 上下文工程 ≠ Prompt 工程:后者只管 System Prompt 怎么写,前者管整个上下文窗口的内容、预算、优先级
- 128K 不是永远够用的:对话历史是最大的增长变量,不加控制的 Agent 在几百轮后会触发溢出
- 按优先级组装是核心原则:System Prompt + 当前问题永远优先,历史和检索内容弹性分配
- 截断最简单但会丢失早期内容:摘要和检索都能保留第 1 轮信息,检索精准度最高
- 摘要 5.2x 的压缩比是真实数据:99 tokens 替代 510 tokens,保留了全部 10 个主题名
下一篇:多 Agent 架构设计模式------什么时候需要多 Agent、Supervisor 模式 vs Pipeline 模式的取舍、以及 LangGraph 的 Subgraph 实现。
参考资料
- Anthropic: Building Effective Agents
- LangGraph Context Management
- tiktoken 文档
- 本系列完整代码:agent-07-context-engineering
欢迎来我的个人主页找到更多有用的知识和有趣的产品