Agent系列(八):上下文工程——让每个 Token 都用在刀刃上

上下文工程:不只是"把 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 工程更底层,管的是整个上下文窗口的内容构成与预算分配:放什么、放多少、按什么顺序、预算不够时怎么取舍。

本篇从三个维度拆解它:

  1. 上下文五来源 + Token 成本剖析:上下文窗口里住着哪些东西
  2. 预算约束下的动态组装:Token 告急时怎么做取舍
  3. 溢出策略三选一:截断、摘要、检索,同一问题下答案质量的真实差异

上下文的五个来源

一个完整的 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% → 说明压缩策略需要调整
  • 记录截断/摘要触发频率,频繁触发说明阈值设置过低

本篇小结

几个核心结论:

  1. 上下文工程 ≠ Prompt 工程:后者只管 System Prompt 怎么写,前者管整个上下文窗口的内容、预算、优先级
  2. 128K 不是永远够用的:对话历史是最大的增长变量,不加控制的 Agent 在几百轮后会触发溢出
  3. 按优先级组装是核心原则:System Prompt + 当前问题永远优先,历史和检索内容弹性分配
  4. 截断最简单但会丢失早期内容:摘要和检索都能保留第 1 轮信息,检索精准度最高
  5. 摘要 5.2x 的压缩比是真实数据:99 tokens 替代 510 tokens,保留了全部 10 个主题名

下一篇:多 Agent 架构设计模式------什么时候需要多 Agent、Supervisor 模式 vs Pipeline 模式的取舍、以及 LangGraph 的 Subgraph 实现。


参考资料


欢迎来我的个人主页找到更多有用的知识和有趣的产品

相关推荐
饼干哥哥1 小时前
罗福莉说的“伪多Agent”,我试了OmniWork后发现,真全干专家长这样
人工智能
NiceCloud喜云2 小时前
Claude Code Routines 实战:三种触发器跑通云端自动化编码
android·运维·数据库·人工智能·自动化·json·飞书
海兰2 小时前
【文字三国志:第三篇】天命重构,数据模型设计
人工智能·游戏
心疼你的一切2 小时前
高效内容生产:如何实现规模化创作
大数据·人工智能·ai·ai编程·ai写作
QYR-分析3 小时前
智能化重构仓储物流:仓储人形机器人行业全景解析
人工智能·重构·机器人
AI 小老六3 小时前
Claude Code 如何压缩上下文:Microcompact、Prompt Cache 与 cache_edits 工程拆解
数据库·人工智能·ai·语言模型·架构·系统架构
侃谈科技圈3 小时前
多门店数据孤岛破局:零售连锁一体化系统2026选型
人工智能·零售
lqqjuly3 小时前
注意力机制完全详解
人工智能·语言模型