AI Agent 运行时间一长,真正拖垮它的往往不是模型能力,而是上下文越来越脏。
一个典型的 Agent 对话历史会逐渐变成这样:
system prompt
tool descriptions
user prompt
assistant: 计划调用工具
tool call 1
tool result 1
assistant: 根据结果继续调用工具
tool call 2
tool result 2
assistant: 发现问题,继续搜索
tool call 3
tool result 3
...
如果任务简单,这种结构没有问题。麻烦出现在复杂任务里:Agent 可能连续调用几十次工具,搜索网页、读取文件、分析错误日志、抓取页面内容、修改代码、再运行测试。越往后,前面的很多工具结果已经不再重要,但它们仍然被塞在大语言模型 LLM(Large Language Model)的上下文窗口里。
这会带来三个直接问题:
| 问题 | 表现 | 后果 |
|---|---|---|
| 上下文窗口被占满 | 历史工具结果、网页内容、日志反复进入请求 | 更早的关键信息被挤掉 |
| 推理负担变大 | 模型每轮都要在大量低价值文本里找重点 | 更容易遗漏用户约束或当前目标 |
| token 成本升高 | 每次调用都携带大量历史内容 | 延迟和费用一起上升 |
上下文工程要解决的核心问题很简单:只把当前决策需要的信息放进 LLM 上下文,把暂时不需要但可能有用的信息放到外部,并保留可恢复路径。
这里有一个关键点:清理上下文不是粗暴删除。粗暴删除会让 Agent 在后续步骤需要某段历史时找不回来,造成信息丢失。更稳妥的做法是把上下文拆成多层:当前上下文、摘要历史、外部工作流记忆、工具输出缓存。
flowchart TD
A[完整对话与工具调用] --> B{是否还需要逐字保留}
B -->|当前推理需要| C[保留在 LLM 上下文]
B -->|历史过程有价值| D[压缩成上下文摘要]
B -->|可复用任务经验| E[保存为工作流记忆]
B -->|内容很大但可能要回查| F[工具输出缓存]
F --> G[用唯一 ID 检索完整结果]
围绕这个目标,可以把 Agent 的上下文清洗拆成三类技术:上下文摘要、工作流记忆、工具输出缓存。
1. 上下文摘要:把支线任务压回背景里
Agent 做复杂任务时,经常会被一个局部问题拖走。
例如,让 Agent 构建一个文本转表情应用,要求包括:
- 接收用户输入文本;
- 调用文本生成图像模型;
- 展示生成的表情;
- 把结果写入 PostgreSQL 数据库;
- 加入用户登录能力。
任务进行到一半,图像写入数据库失败。Agent 开始查错误、搜版本差异、看文档、尝试不同写法。这个调试过程可能持续十几轮工具调用。等问题修完,LLM 上下文里已经堆满了数据库错误、搜索结果和调试日志,反而把“要构建完整应用”这个主目标冲淡了。
上下文摘要的作用,就是在这种时候把对话历史压缩成当前仍然有用的信息。
flowchart LR
A[主任务: 构建应用] --> B[遇到数据库写入 bug]
B --> C[搜索资料]
C --> D[读取文档]
D --> E[尝试修复]
E --> F[确认根因]
F --> G[生成摘要]
G --> H[保留: 主目标、已完成部分、根因、修复方式、剩余任务]
G --> I[丢弃: 冗余搜索结果、重复报错、无效尝试]
摘要不是简单地“变短”,而是要保留任务继续执行所需的状态。一个好的摘要至少应该包含这些内容:
| 摘要字段 | 作用 |
|---|---|
| 用户目标 | 防止 Agent 忘记最初要完成什么 |
| 明确约束 | 例如技术栈、输出格式、不能使用的方案 |
| 已完成工作 | 避免重复做已经完成的步骤 |
| 当前进展 | 记录任务正处在哪个阶段 |
| 未完成事项 | 让 Agent 能继续推进 |
| 关键发现 | 例如 bug 根因、接口限制、环境差异 |
| 决策记录 | 为什么选择某个方案,为什么放弃某个方案 |
可以把摘要格式设计成结构化 Markdown:
## 用户目标
构建一个文本转表情应用,支持文本输入、图像生成、结果展示、PostgreSQL 存储和用户登录。
## 已完成工作
- 已完成基础 UI(用户界面)页面。
- 已接入文本生成图像接口。
- 已实现表情预览。
## 关键问题与结论
- 图像无法写入 PostgreSQL 的原因是字段类型不适合直接存储二进制内容。
- 更稳妥的方式是把图像保存到对象存储或文件系统,只在数据库中保存 URL 和元数据。
## 剩余任务
- 调整数据库 schema。
- 修改保存逻辑。
- 增加登录后的用户资源隔离。
- 补充失败重试和错误提示。
什么时候触发摘要
摘要触发得太早,会丢掉还没处理完的细节;触发得太晚,上下文已经被污染。常见触发条件有四类:
| 触发条件 | 适用场景 |
|---|---|
| token 使用量超过阈值 | 例如上下文窗口使用超过 70% 或 80% |
| 一个子任务结束 | bug 修复完成、资料搜索完成、文件分析完成 |
| Agent 明显偏离主任务 | 大量轮次集中在支线问题上 |
| 需要跨会话继续 | 把当前会话状态传给新的 Agent 或下一次运行 |
自动摘要可以放在 Agent 运行循环里:
def maybe_summarize_context(agent_state, llm):
used_tokens = count_tokens(agent_state.messages)
limit = agent_state.context_limit
if used_tokens / limit < 0.8:
return agent_state
protected_messages = select_protected_messages(agent_state.messages)
old_messages = select_messages_to_summarize(agent_state.messages)
summary = llm.generate(
system="你是上下文压缩器,只保留继续完成任务所必需的信息。",
user=build_summary_prompt(old_messages)
)
agent_state.messages = (
protected_messages.system_and_tools
+ protected_messages.recent_user_constraints
+ [{"role": "assistant", "content": summary}]
+ protected_messages.recent_messages
)
return agent_state
这里有两个细节很重要。
第一,系统提示、工具描述、最近的用户约束不能随便摘要掉。系统提示决定 Agent 的行为边界,工具描述决定它能调用什么,最近的用户消息往往包含最新要求。
第二,不要完全依赖 LLM 摘要。摘要可能遗漏细节,也可能把不确定信息写得过于肯定。更稳妥的做法是保留少量高价值原始消息,例如最近几条用户指令、最新工具结果、关键错误信息,再把更早的大段历史压缩掉。
2. 工作流记忆:把一次任务中的经验变成可复用策略
上下文摘要解决的是“当前会话怎么继续”。工作流记忆解决的是另一个问题:未来遇到类似任务时,Agent 能不能少走弯路。
例如,让 Agent 搜集机器学习数学方向的免费书籍,并整理成 CSV(Comma-Separated Values,逗号分隔值)文件。任务过程中,Agent 发现 archive.org 上部分书籍无法直接读取,于是尝试不同访问路径、搜索镜像、调整解析方式,花了几分钟才找到有效方法。
这段探索过程如果只留在当前上下文里,任务结束后就没了。下次再做类似资料整理,Agent 还会重新踩一遍坑。
工作流记忆要保存的不是完整对话,而是可复用的任务经验:
flowchart TD
A[执行任务] --> B[遇到失败]
B --> C[尝试修复]
C --> D[找到有效策略]
D --> E[任务完成]
E --> F[生成工作流记忆]
F --> G[未来相似任务加载]
G --> H[直接采用已验证策略]
工作流记忆应该记录什么
工作流记忆不能太细,否则只适用于同一个任务;也不能太粗,否则对未来没有帮助。比较实用的结构如下:
| 字段 | 说明 |
|---|---|
| 任务标题 | 用简短通用的标题描述任务类型 |
| 任务描述 | 只写用户想要的结果,不写太多实现细节 |
| 解决步骤 | 用有序步骤记录可复用操作 |
| 使用工具 | 记录工具名称、用途、为什么有帮助 |
| 失败与恢复策略 | 保存错误症状、原因、修复方式、验证方法 |
| 备注与观察 | 保存未来执行时必须知道的特殊点 |
| 标签 | 用于匹配类似任务,例如 web-research、csv-generation |
一个工作流记忆可以写成这样:
# workflow: collect-free-ml-math-books
## 任务描述
收集机器学习数学方向的免费书籍,整理每本书的描述、主题、前置知识、访问链接,并导出为 CSV 文件。
## 解决步骤
1. 确定书籍筛选标准:免费、可公开访问、与机器学习数学基础相关。
2. 从多个来源搜索候选书籍,包括大学课程页、开放教材站点、电子书归档站点。
3. 验证每个链接是否可访问,优先保留官方页面或稳定镜像。
4. 提取书籍主题、适合人群、前置知识和简介。
5. 统一字段格式并生成 CSV。
6. 对 CSV 做重复项检查和链接可用性检查。
## 使用工具
- search_web:发现候选书籍和官方页面。
- fetch_page:读取网页正文与元信息。
- write_file:生成 CSV 文件。
- validate_url:检查链接可访问性。
## 失败与恢复策略
- 症状:archive.org 页面无法直接读取正文。
- 可能原因:页面结构复杂或需要不同资源路径。
- 修复方式:优先查找书籍详情页、PDF 直链或官方镜像。
- 验证方式:确认链接无需登录即可访问。
## 标签
web-research, open-education, csv-generation, machine-learning, math-books
工作流记忆和 RAG 不是一回事
RAG(Retrieval-Augmented Generation,检索增强生成)通常会把大量文档切块、向量化,再根据相似度检索。工作流记忆不一定需要这么复杂。
如果每个 Agent 只维护少量高质量记忆文件,用文件名、角色名、标题、描述和标签就能完成匹配。这样更可控,也减少了向量检索带来的不确定性。
一个简单加载流程可以这样设计:
flowchart LR
A[新任务输入] --> B[读取当前 Agent 的工作流记忆索引]
B --> C[展示标题、描述、标签]
C --> D[LLM 选择最多 N 个相关工作流]
D --> E[加载完整工作流内容]
E --> F[注入当前上下文]
F --> G[执行任务]
对应的索引可以非常简单:
[
{
"id": "collect-free-ml-math-books",
"title": "收集免费机器学习数学书籍",
"description": "搜索公开可访问书籍并整理为 CSV",
"tags": ["web-research", "csv-generation", "machine-learning"]
},
{
"id": "weekly-slack-reminder",
"title": "创建 Slack 每周会议提醒",
"description": "根据会议时间配置周期提醒",
"tags": ["slack", "automation", "calendar"]
}
]
当工作流记忆越来越多时,应该先考虑清理和合并,而不是立即引入复杂检索。工作流记忆的价值来自“少量、准确、可复用”,不是把所有历史都存下来。
3. 工具输出缓存:用 ID 替代大段过期结果
工具调用是 Agent 的核心能力,但工具输出也是上下文污染的主要来源。
搜索工具可能返回几千字符的搜索结果;文件读取工具可能返回上万字符的 Markdown 文档;浏览器自动化工具可能返回整个网页的 DOM(Document Object Model,文档对象模型)。如果这些结果永久留在上下文里,每一轮 LLM 调用都会重复携带它们。
# 搜索工具返回大量结果
tool_result_1 = search_google("AI agent frameworks")
# 可能包含多个标题、摘要、URL、元数据
# 文件工具读取长文档
tool_result_2 = read_file("documentation.md")
# 可能返回上万字符 Markdown
# 浏览器工具读取页面结构
tool_result_3 = browser_snapshot()
# 可能返回完整 DOM 或复杂可访问性树
很多工具结果只在刚返回时最有用。Agent 看过、提取完关键信息后,完整结果就不需要一直留在上下文里。工具输出缓存的思路是:
- 大工具结果保存到上下文外部,例如磁盘、对象存储或数据库;
- 为每个结果生成唯一 ID;
- 上下文中只保留摘要、预览和 ID;
- 如果后续确实需要完整内容,Agent 通过检索工具按 ID 取回。
sequenceDiagram
participant Agent
participant Tool as 工具
participant Store as 外部缓存
participant LLM
Agent->>Tool: 调用搜索/读取/浏览器工具
Tool-->>Agent: 返回大段结果
Agent->>Store: 保存完整结果并生成 tool_result_id
Agent->>LLM: 注入预览、摘要、ID、检索说明
LLM-->>Agent: 判断是否需要完整结果
Agent->>Store: 必要时按 ID 取回
Store-->>Agent: 返回完整工具输出
上下文里可以保留这样的引用:
工具结果已缓存。
- id: tool_result_20260607_001
- tool: search_google
- query: AI agent frameworks
- size: 4250 characters
- preview: "Top results include CAMEL, LangGraph, AutoGen..."
- summary: 搜索结果主要包含多个 Agent 框架的项目页、文档页和对比文章。
- retrieve: 如需完整搜索结果,调用 `load_tool_result("tool_result_20260607_001")`。
一个简化版实现如下:
from dataclasses import dataclass
from pathlib import Path
import hashlib
import json
import time
@dataclass
class CachedToolResult:
result_id: str
tool_name: str
preview: str
summary: str
path: str
size: int
class ToolOutputStore:
def __init__(self, root: str = ".agent_tool_cache"):
self.root = Path(root)
self.root.mkdir(exist_ok=True)
def save(self, tool_name: str, content: str, summary: str) -> CachedToolResult:
digest = hashlib.sha256(content.encode("utf-8")).hexdigest()[:12]
result_id = f"{tool_name}_{int(time.time())}_{digest}"
path = self.root / f"{result_id}.txt"
path.write_text(content, encoding="utf-8")
return CachedToolResult(
result_id=result_id,
tool_name=tool_name,
preview=content[:160],
summary=summary,
path=str(path),
size=len(content),
)
def load(self, result_id: str) -> str:
matches = list(self.root.glob(f"{result_id}.txt"))
if not matches:
raise FileNotFoundError(result_id)
return matches[0].read_text(encoding="utf-8")
对消息列表做压缩时,可以只处理较旧且较大的工具结果:
def compact_tool_results(messages, store, max_inline_chars=2000, keep_recent=2):
tool_message_indexes = [
i for i, msg in enumerate(messages)
if msg.get("role") == "tool"
]
recent_tool_indexes = set(tool_message_indexes[-keep_recent:])
for i in tool_message_indexes:
if i in recent_tool_indexes:
continue
content = messages[i].get("content", "")
if len(content) <= max_inline_chars:
continue
summary = summarize_tool_output(content)
cached = store.save(
tool_name=messages[i].get("name", "unknown_tool"),
content=content,
summary=summary,
)
messages[i]["content"] = {
"cached": True,
"result_id": cached.result_id,
"tool_name": cached.tool_name,
"size": cached.size,
"preview": cached.preview,
"summary": cached.summary,
"instruction": f"需要完整内容时调用 load_tool_result('{cached.result_id}')"
}
return messages
工具输出缓存的风险
工具输出缓存最容易犯的错误,是只看 token 节省,不看准确性。
缓存之后,Agent 看到的是摘要和预览,而不是完整内容。它可能误以为预览已经足够,然后基于不完整信息做决定。更糟的是,Agent 还要额外记住哪些 ID 对应哪些结果、什么时候该取回完整内容,这会增加认知负担。
| 风险 | 例子 | 缓解方式 |
|---|---|---|
| 信息丢失 | 完整搜索结果里第 8 条才是正确答案,预览只展示前几条 | 缓存时生成结构化摘要,保留关键候选项 |
| 错误判断 | Agent 认为不用读取完整文件,直接根据摘要修改代码 | 当操作依赖细节时强制取回完整内容 |
| ID 混乱 | 多个工具结果相似,Agent 调错缓存 ID | ID 中包含工具名、时间、hash,并保留 query/path |
| 缓存过度 | 小结果也被替换成引用,反而增加操作复杂度 | 设置长度阈值,只缓存大结果 |
| 最新状态丢失 | 最近一次测试输出被缓存,Agent 看不到失败细节 | 保留最近 N 个工具结果原文 |
比较稳妥的策略是:
- 最近一两次工具结果保持完整;
- 超过阈值的大结果才缓存;
- 缓存引用必须包含摘要、预览、来源、大小、检索方式;
- 一旦需要基于被省略细节做决策,必须取回完整结果;
- 对测试日志、错误堆栈、数据库迁移结果等高风险内容要更保守。
在工程实践里,工具输出缓存不能只作为“压缩功能”存在,它还需要配套评估。至少要比较两组 Agent:一组保留完整工具结果,一组启用缓存,观察任务成功率、错误率、调用次数和 token 成本。只有成本下降且成功率不明显受损,缓存策略才算可靠。
4. 三种方法的分工
上下文摘要、工作流记忆、工具输出缓存看起来都在“减少上下文”,但它们处理的是不同类型的信息。
| 方法 | 处理对象 | 保存形式 | 主要目标 | 典型触发点 |
|---|---|---|---|---|
| 上下文摘要 | 当前会话历史 | 结构化摘要 | 保留当前任务状态 | token 接近上限、子任务结束 |
| 工作流记忆 | 已完成任务经验 | Markdown / JSON 记忆文件 | 复用未来相似任务策略 | 任务完成后、新任务开始前 |
| 工具输出缓存 | 大段工具结果 | 外部存储 + ID 引用 | 避免工具结果污染上下文 | 工具结果过大、结果已被处理 |
| 原文保留 | 当前关键消息 | LLM 上下文 | 保证最新信息不丢 | 最近几轮对话、关键用户约束 |
一个完整的 Agent 上下文管理流程可以设计成这样:
flowchart TD
A[用户输入任务] --> B[加载相关工作流记忆]
B --> C[LLM 规划下一步]
C --> D[调用工具]
D --> E[获得工具结果]
E --> F{结果是否过大}
F -->|是| G[保存完整结果到外部缓存]
G --> H[上下文保留摘要、预览、ID]
F -->|否| I[直接保留结果]
H --> J{上下文是否接近阈值}
I --> J
J -->|是| K[生成上下文摘要]
J -->|否| L[继续执行]
K --> L
L --> M{任务是否完成}
M -->|否| C
M -->|是| N[生成或更新工作流记忆]
这套流程的重点不是把上下文压到越短越好,而是让不同信息进入正确的位置:
- 当前必须参与推理的信息,留在上下文;
- 当前不需要逐字阅读、但影响任务状态的信息,变成摘要;
- 未来可复用的经验,沉淀为工作流记忆;
- 可能需要回查的大块原始资料,放进工具输出缓存。
5. 实现时容易踩的坑
摘要提示太模糊
如果只告诉模型“总结以上内容”,得到的结果通常不稳定。摘要提示应该明确字段、保留标准和删除标准。
可以要求模型按固定结构输出:
请把对话历史压缩为任务继续执行所需的状态,不要写无关过程。
必须保留:
1. 用户目标和明确约束
2. 已完成工作
3. 当前正在处理的问题
4. 关键决策及原因
5. 未完成事项
6. 会影响后续执行的错误、限制和外部依赖
必须删除:
1. 重复搜索结果
2. 已验证无效的尝试细节
3. 与任务目标无关的闲聊
4. 不影响后续执行的中间输出
工作流记忆太像流水账
工作流记忆不是运行日志。不要保存“第 3 轮搜索了什么、第 4 轮点了哪个页面”这种细节,而要保存“遇到类似站点读取失败时,优先查找 PDF 直链或官方镜像”这种可迁移策略。
判断一条记忆是否值得保存,可以问两个问题:
- 未来相似任务会不会再次遇到这个问题?
- 这条经验能不能减少未来探索成本?
如果答案都是否定的,就不应该写入工作流记忆。
工具缓存没有恢复机制
只把工具结果替换成“已省略”是危险的。缓存必须提供可调用的恢复工具,例如:
def load_tool_result(result_id: str) -> str:
"""根据工具结果 ID 读取完整输出。"""
return tool_output_store.load(result_id)
还要在 Agent 的系统提示中明确规则:
当回答、修改代码、生成结论依赖某个已缓存工具结果的完整细节时,
必须先调用 load_tool_result(result_id) 读取完整内容,
不能只根据 preview 或 summary 做最终判断。
没有评估就上线压缩策略
上下文清洗会改变 Agent 看到的信息,因此它不是纯粹的性能优化。每一种压缩策略都应该评估:
| 指标 | 关注点 |
|---|---|
| 任务成功率 | 清洗后是否更容易失败 |
| 平均 token 消耗 | 是否真的降低上下文成本 |
| 平均工具调用次数 | 是否因为信息丢失导致反复检索 |
| 任务完成时间 | 是否因频繁摘要或回查变慢 |
| 错误类型 | 是否出现“根据不完整信息决策”的新错误 |
如果 token 降了但成功率也降了,就需要调整摘要字段、缓存阈值或恢复策略。
6. 推荐的上下文分层设计
把 Agent 记忆设计成四层,会比只维护一个巨大消息列表更稳。
| 层级 | 内容 | 生命周期 | 是否进入 LLM 上下文 |
|---|---|---|---|
| 活跃上下文 | 最近用户指令、最新工具结果、当前计划 | 当前几轮 | 是 |
| 压缩摘要 | 主目标、已完成事项、关键约束、剩余任务 | 当前任务 | 是 |
| 工具缓存 | 搜索结果、长文件、网页 DOM、大日志 | 当前任务或短期 | 默认否,需要时按 ID 取回 |
| 工作流记忆 | 可复用步骤、失败恢复策略、工具使用经验 | 长期 | 新任务匹配后进入 |
这也是上下文工程最实用的原则:不要让所有信息都挤在同一个窗口里。
LLM 上下文适合放“马上要用来推理的信息”;外部存储适合放“可能要回查的原始资料”;长期记忆适合放“以后还能复用的方法”。当这三类信息被混在一起,Agent 就会在无关细节里消耗注意力;当它们被拆开管理,Agent 更容易围绕当前目标行动。