Agent 怎样只保留真正需要的上下文

这篇写给两类人:一类是刚开始搭 Agent、被「上下文」这个词绕晕的初学者;一类是已经在用 LangGraph / LlamaIndex 之类框架,但总觉得「输出不稳定、模型老捡到不该看的东西」的人。我会尽量把每个术语第一次出现时讲清楚,也会给一个不依赖任何框架、30 行就能跑的最小例子,让你自己复现文章的核心结论。

一、报告里出现了一句不该出现的话

我的项目里有一条产业诊断链,跑完会输出一份诊断草稿。有一次我在草稿里读到一句大意如下的话:

综合前述预警情况,建议将该问题纳入政策议程。

问题是,这条链根本没做过预警分析。预警是运行监测链的活儿,产业诊断只负责画产业画像。模型不知道从哪儿捡来了「预警」这个词,然后一本正经地把它编进了结论里。

我去翻了当时喂给它的上下文,原因很好笑:我图省事,把整个状态对象一股脑塞了进去。为了让你有直观感受,我把当时那坨上下文的结构简化还原成下面这样(省略了大量真实数据,但字段结构是真的):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 喂给「产业诊断」节点的上下文(错误示范)
{
"request": { "period": "2025Q3", "question": "..." },
"workflow": { "workflow_id": "industry_diagnosis", "step": 2 },
"roles": { "...": "已选的 3 个专家角色提示词,约 1200 token" },
"data": { "industry": { "...": "产业指标包,约 900 token" } },
"analysis": {
"monitoring": { // ← 这一整块都不该在这里
"summary": "本季度 GDP 增速回落……",
"warnings": [ // ← 罪魁祸首
"工业用电量连续两月负增长,存在下行预警",
"..."
]
}
},
"evidence": { "...": "证据卡若干,约 1500 token" },
"trace": { "...": "十几个节点的执行轨迹,约 2000 token" }
// ……还有另外几个分区
}

产业诊断节点真正需要的,只有 requestworkflowrolesdata.industry 这几块。可我把 analysis.monitoringevidencetrace 全塞进去了。那个 analysis.monitoring.warnings 是上一次调试监测链时留下的残留数据,它安安静静躺在状态里,模型看见了,就用上了。

它没有撒谎,它只是看见了不该看见的东西。

我粗略数了一下,这段上下文里大约有六成 token 和「产业诊断」这个任务毫无关系。而问题不只是浪费:那六成噪声里,偏偏有一条能把结论带偏的「预警」。

这件事让我意识到,我一直以为的「上下文管理」,也就是等它变长了再想办法压缩,完全搞反了顺序。

二、先分清:什么会进入上下文

初学者常把「上下文」等同于「聊天记录」。其实对一个 Agent 来说,模型每次推理时能看见的信息,通常包括这么几类:

  • 系统提示(system prompt):给模型的角色设定、总体指令。
  • 工具定义(tool definitions):告诉模型有哪些函数可以调、参数是什么。
  • 外部数据 / 检索结果:RAG 拉回来的文档、数据库查询结果。
  • 消息历史(message history):之前每一轮的对话和模型的回复。
  • 任务状态与工具结果:Agent 跑到一半时积累的中间状态、上一步工具返回的东西。

这些全都要塞进同一个「上下文窗口」里,也就是模型这一次调用能看见的全部文字。窗口是有大小上限的(比如 20 万 token),但更重要的是:就算没到上限,塞得越多,模型也未必看得越清。这是下一节要讲的重点。

开头那个 bug 就是最后一类(任务状态)出的问题:残留的 warnings 字段混在状态里,跟着状态一起进了窗口。

三、上下文是一种有限资源

先补一点理论,但我会尽量让它可感。

过去几年,做 LLM 应用的焦点是提示工程(prompt engineering):怎么把指令写清楚、系统提示怎么组织。但当 Agent 开始在更长的时间跨度、跨多轮推理里工作时,要管的就不只是那段提示词了,而是整个上下文状态:系统指令、工具定义、外部数据、消息历史、检索结果,所有会进入窗口的东西。

管理这整坨东西,就是上下文工程(context engineering):在推理阶段,策划与维护「最优的 token 集合」。(这个说法来自 Anthropic 的工程博客 Effective context engineering for AI agents,值得一读。)

它为什么重要?因为有个叫上下文腐蚀(context rot)的现象:随着窗口里的 token 变多,模型准确回忆信息的能力反而下降。

这句话有点抽象,我给你一个能想象的画面。研究者做过这样的实验(Chroma 团队的 Context Rot 报告里有系统测量):把同一句关键事实(比如「小明的生日是 3 月 7 日」)藏进一段长文本,然后问模型这句事实。

  • 当这句话埋在 2000 token 的文本里,模型几乎百分百答对;
  • 当同一句话埋在 5 万、10 万 token 的文本里,答对率明显往下掉,哪怕那句话一字没改,只是周围的「干扰」变多了。

不同模型的衰减曲线陡峭程度不同,但这个趋势几乎普遍存在。信息没变少,但它被淹没了。

根源不是一条公式能完全解释的。标准的全注意力计算会随序列长度呈 级增长(每个 token 都要「看」其他所有 token,长度翻倍,计算量翻四倍),而模型训练方式、位置编码、实际注意力如何分配,也都会影响长上下文表现。工程上更可靠的表述是:上下文越长,相关信息越容易被噪声淹没。所以要把它当作有限资源来设计,而不是假定模型会同等重视每个字段。

所以结论是:上下文必须被当作一种有限资源,而且边际收益递减。你可以把它想成模型有一笔「注意力预算」,每多一个 token 就花掉一点;花在噪声上的,就没法花在信号上。

这正好解释了我那个 bug:我塞进去的 warnings 字段,不只是浪费了预算,它还主动污染了结论,模型把预算花在了一条它本不该看见的信息上。

四、有效上下文长什么样

在「注意力预算有限」的前提下,目标就变成了:用尽可能少、但信号密度尽可能高的 token,最大化拿到期望结果的概率。

具体到几个组件:

系统提示有两个极端误区。一是过度硬编码,在提示里写复杂脆弱的 if-else 逻辑,维护成本极高;二是过于空泛,只给宏观目标,缺少对期望输出的具体信号。合理的做法是分区组织(背景信息、指令、工具指引、输出描述),追求能完整勾勒期望行为的「最小必要信息集」。注意,「最小」不等于「最短」。该给的细节一个不能少,多余的一个不要。

工具定义了 Agent 与外部世界的契约。最常见的失败模式是「臃肿工具集」:功能边界模糊,导致「该选哪个工具」这个决策本身就是含混的。这里有句话我印象很深:

如果人类工程师都说不准该用哪个工具,就别指望 Agent 做得更好。

所以要精心甄别一个最小可行工具集(minimal viable toolset)。

示例(few-shot)则要精挑细选一组多样且典型的,而不是把所有边界情况罗列进去。三五个覆盖典型情况的好例子,胜过二十个把提示撑爆的琐碎例子。

总的指导思想八个字:信息充分,但要紧致。

五、两种拿到上下文的方式

工程实践正在从「推理前一次性检索」转向即时(JIT,Just-In-Time)上下文。这两个词初学者容易混,我用做菜打个比方:

  • 预检索(pre-retrieval):像出门前把可能用到的食材全买回家堆冰箱里,提前把所有可能相关的数据都塞进上下文;
  • JIT:像手里只攥一张购物清单,做到哪一步、缺什么才去买,上下文里只维护轻量引用(文件路径、查询语句、URL),运行时用工具动态加载真正的数据。

JIT 更贴近人的认知方式:我们不会把所有信息背下来,而是用文件系统、书签、收件箱这些外部索引按需提取。

而且引用本身的元数据就在传递信息:目录层级、命名约定、时间戳,都在隐含地表达用途和时效。tests/test_utils.pysrc/core/test_utils.py 给人的暗示就完全不同,一个是测试代码,一个可能是核心逻辑里一个叫这名字的模块。模型看到路径,不用打开文件就已经获得了信息。

这带来了渐进式披露(progressive disclosure):每一步交互产生新上下文,反过来指导下一步,文件大小暗示复杂度,命名暗示用途,时间戳暗示相关性。Agent 逐层构建理解,只在「工作记忆」里保留当前必要的子集。

代价是:运行时探索比预计算检索慢,而且需要有主见的工程设计,否则模型会误用工具、追死胡同,反而浪费上下文。所以很多场景下混合策略更好:前置加载少量高价值上下文保证速度,再允许按需探索。

六、长时程任务的三件套

当任务长到超出上下文窗口(大型代码库迁移、跨小时的研究),有三种手段:

手段 做法 适合
压缩整合(compaction) 接近上限时高保真总结,用摘要重启新窗口 需要长对话连续性、强调「接力」的任务
结构化笔记(note-taking) 定期把关键信息写到上下文外的持久存储,按需拉回 有里程碑的迭代式开发与研究
子代理架构(sub-agents) 主代理规划,子代理在干净窗口里深挖,只回传凝练摘要 复杂研究分析,能从并行探索获益

这三个词都比较抽象,给你一个「压缩整合」最小画面:假设一轮对话已经积累了 50 轮问答、逼近窗口上限,你可以让模型把这 50 轮总结成一段 500 字的「目前进展 + 关键决定 + 待办」,然后开一个新窗口,只带着这段摘要继续,旧的 50 轮全部丢弃。这就是「用摘要重启新窗口」。

压缩的调参顺序值得记:先优化召回(别漏关键信息),再优化精确度(剔冗余)。宁可摘要里多带点、也别漏掉关键的一句。一种安全的轻触式压缩,是只清理深层历史里的工具调用与结果,那些往往是最占地方、又最没有长期价值的。

七、先别急着上手段:怎么知道自己的上下文臃肿了

上面全是「该怎么办」。但初学者更该先问一句:我怎么知道自己的上下文是不是已经臃肿、被污染了?手段用错地方,比不用还糟。我自己踩坑后固定下来几个笨办法:

  1. 把喂进去的上下文原样打印出来。这是最有用的一步,却最常被跳过。在真正调用模型前,把即将发送的完整消息 dump 到日志或文件里,自己用眼睛扫一遍。开头那个 warnings bug,我就是这样一眼看到「咦,产业诊断怎么带着监测的字段」。
1
2
3
4
5
6
import json

def debug_context(messages: list[dict]) -> None:
payload = json.dumps(messages, ensure_ascii=False, indent=2)
print(f"[context] 共 {len(payload)} 字符")
print(payload)
  1. 数 token,而不是凭感觉。「感觉有点长」是不可靠的。用分词器实际数一下,你常会被吓一跳。
1
2
3
4
5
6
import tiktoken

enc = tiktoken.get_encoding("cl100k_base") # 近似计数即可

def count_tokens(text: str) -> int:
return len(enc.encode(text))

数完你可以进一步问:这些 token 里,有多少是和当前任务真正相关的?我给开头那段上下文按分区数了一遍,发现约六成 token 属于「和产业诊断无关」的分区,这个比例本身就是一个刺眼的信号。

  1. 给「不该出现的东西」写断言。观测之外,还要防回归。既然产业诊断节点不该看到监测数据,那就在测试里把这条规矩写死:
1
2
3
4
def test_diagnosis_context_has_no_monitoring():
ctx = build_diagnosis_context(sample_state)
assert "monitoring" not in ctx["analysis"], "产业诊断不该带监测数据"
assert "warnings" not in json.dumps(ctx), "warnings 字段泄漏了"

这条断言如果早写半年,我开头那个 bug 根本活不过一次 CI。

先能「看见」和「量出」问题,再谈用哪种手段,顺序不能反。

八、我的项目实际怎么做的

理论讲完,回到我那个 bug。先交代一下背景,不然后面的代码会显得突兀。

我做的是一个县域经济分析系统:给定一个地区和一个时间周期,它要产出正式的经济分析报告。整个流程不是一次问答,而是多条「业务链」接力:监测链先算宏观形势和预警,产业诊断链画产业画像,归因链找原因,规划链出政策建议。每条链内部又是多个「节点」串起来的(一个节点约等于一次带特定职责的模型调用或数据处理)。系统跑在 LangGraph 上,你可以把它理解成一个「带状态的流程图框架」:节点是图上的点,它们共享同一个状态对象,一个节点跑完把结果写回状态,下一个节点接着读。

理论上它是典型的「长时程任务」,该上压缩、笔记、子代理三件套。但实际做下来,我用的几乎全是另一条路。下面逐个拆。

8.1 状态黑板:先有骨架,才有分区

整个系统围绕一个状态对象流转,它有固定的分区:

1
2
3
4
5
6
7
8
9
10
11
class CountyEconomyState(TypedDict, total=False):
request: dict[str, Any] # 用户请求、周期、问题
workflow: dict[str, Any] # 当前业务链与步骤
roles: dict[str, Any] # 已选专家角色
data: dict[str, Any] # 指标、产业、政策、规划输入包
evidence: dict[str, Any] # 证据卡、来源、缺口
analysis: dict[str, Any] # 各类分析结果
draft: dict[str, Any] # 草稿正文与章节
review: dict[str, Any] # 质量门禁结果
trace: dict[str, Any] # 节点轨迹、警告、工具调用
outputs: dict[str, str] # 产物路径

TypedDict 是 Python 里给字典标注「有哪些字段、每个字段是什么类型」的方式,total=False 表示这些字段都可选。你不必熟悉它,只要知道:这是一张固定的分区表,所有数据必须落进某个已命名的抽屉里。

这其实就是教科书里那个「固定骨架的分区模板」的翻版,只不过它不是一段提示词模板,而是一个数据结构。分区固定带来的好处是一样的:好调试、好断言、好评估。你永远知道监测结果在 analysis.monitoring,永远知道产物路径在 outputs,不会满世界找。

但真正的关键在下一步。

8.2 不是压缩,是不给

我最早的错误就是:状态里有 10 个分区,我把 10 个都喂给了模型。(就是第一节那坨上下文。)

现在的做法是,每个节点只读它该读的那两三个分区。产业诊断节点读 data.industry,压根不去碰 analysis.monitoring。那个「预警」字段就算躺在状态里,也进不了它的上下文。

具体到代码,就是每个节点在拼上下文时,显式地只投影自己声明的分区:

1
2
3
4
5
6
7
8
def build_diagnosis_context(state: CountyEconomyState) -> dict[str, Any]:
# 白名单式投影:只取这几块,其余一律不进
return {
"request": state["request"],
"workflow": state["workflow"],
"roles": state["roles"],
"data": {"industry": state["data"]["industry"]},
}

这个转变的心智模型是这样的:

教科书里的压缩整合,解决的是「上下文已经太长了怎么办」。而结构化的状态让这个问题在很多节点上根本不会发生。

这不是说压缩没用,长对话场景里它是刚需。只是在我这种「多节点接力、每个节点职责明确」的系统里,结构比压缩更根本。

8.3 最小可行工具集,是声明出来的

第四节说要精心甄别一个最小可行工具集,还引了那句「人类都说不准用哪个工具,别指望 Agent」。

我的处理方式是:根本不让模型面对这个选择题。每条业务链在注册表里自己声明它能用哪些工具:

1
2
3
4
5
6
{
"workflow_id": "model_analysis",
"roles": ["chief_economist", "model_analyst", "report_editor"],
"tools": ["load_dataset", "run_model", "render_model_report"],
...
}

模型分析链只看得见这三个工具。它不知道政策检索工具的存在,自然也不会误用。最小可行工具集不是模型选出来的,是声明出来的。

角色提示词也一样:intake 节点匹配到业务链后,只加载这条链声明的那几个角色的 markdown。没被选中的角色,提示词一个字都不会进上下文,这就是第五节说的渐进式披露,只不过触发它的是注册表,不是模型的自主探索。

8.4 确定性压缩:一份手写的字段白名单

链与链之间要交接(监测 → 诊断 → 归因 → 规划),这就是第六节说的「压缩整合」场景:把上游一大堆结果,浓缩成下游需要的那部分。

教科书的做法是让模型高保真总结。我的做法是写一个函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
def build_analysis_package(state: CountyEconomyState) -> dict[str, Any]:
monitoring = state.get("analysis", {}).get("monitoring", {})
outputs = state.get("outputs", {})
evidence = state.get("evidence", {})

return {
"period": request.get("period", ""),
"workflow_id": workflow.get("workflow_id", ""),
"monitoring": {
"summary": monitoring.get("summary", ""),
"pressures": monitoring.get("pressures", ""),
"data_gaps": monitoring.get("data_gaps", ""),
"warnings": monitoring.get("warnings", []),
"boundary": monitoring.get("boundary", ""),
},
"evidence": {
"sources": evidence.get("sources", []),
"cards": evidence.get("cards", []),
"gaps": evidence.get("gaps", []),
},
"handoff": {
"recommended_next_workflow": "one_county_one_policy_outline",
"requires_human_review": ...,
},
}

这里要停一下,回答一个你可能已经发现的矛盾:开头那个害我出 bug 的 warnings,怎么这里又被我主动放进白名单了?

因为该不该看一个字段,取决于节点的职责,而不是字段本身。warnings 是监测链的产出,它本就该流进「规划链」,规划政策时当然要参考预警。它不该出现的地方,是产业诊断链,那是画产业画像的,跟预警无关。同一个字段,在这条交接里是必需品,在那条节点里是污染源。白名单的意义正在于此:它逼我为每一次交接单独回答「这一步到底需要什么」,而不是「大概都带上吧」。

这份白名单和 LLM 压缩的差别很实在:

LLM 压缩 字段白名单
关键信息会不会丢 可能(要调召回) 不会,字段是写死的
结果稳不稳定 每次可能不同 完全确定
能不能测 难,得断言语义 纯函数,直接断言
成本 一次 LLM 调用
缺点 加字段容易忘,得手工维护

最后那条是真代价。加了新分析字段却忘了往包里加,下游就拿不到,这种 bug 不会报错,只会让报告少一段。我是靠交接流程的测试兜住的(就是第七节那种断言)。

选择标准我总结成一句:上游结构已知,就用白名单;上游是自由文本,才用 LLM 压缩。我的上游是结构化分析结果,所以白名单更合适;如果你的上游是一大段用户自由输入的会议纪要,那还是得靠模型去压。

8.5 干净窗口:不是清空,是重开

链之间交接时,我没有「清理」上一条链的状态,而是从头建一个新的:

1
2
3
4
5
6
7
8
9
def build_planning_handoff_state(monitoring_state, industry_state, request=None):
state = default_state(base_request) # 全新的干净状态
state["analysis"]["monitoring"] = dict(...) # 只搬该搬的
state["analysis"]["industry_diagnosis"] = dict(...)
state["outputs"].update({ # 前缀白名单
k: v for k, v in monitoring_state.get("outputs", {}).items()
if k.startswith("monitoring_")
})
...

default_state() 起一个空白状态,然后按白名单往里搬。默认是「什么都不带」,带什么要显式写出来。

这跟「清空不需要的」是两种相反的默认值,而默认值决定了出错时会往哪边倒:

  • 默认全带、手动删 → 忘了删 = 脏数据泄漏到下游(就是我开头那个 bug)
  • 默认不带、手动加 → 忘了加 = 下游少一段内容,测试能抓到

同样是「忘了」,后者的后果轻得多。一个是静默的错误结论,一个是显眼的内容缺失。工程上,我们永远愿意让错误往「吵闹且可测」的那边倒。

8.6 结构化笔记:trace

trace.steps 记录每个节点的执行轨迹:哪个节点、什么状态、什么时候、附带元数据。它不参与模型推理,纯粹是给人看的。

这算教科书里的「结构化笔记」,但我的用法更窄:不是给模型跨窗口拉回的记忆,而是排查问题时的现场记录。开头那个「预警」bug,就是靠 trace 才定位到是哪个节点、在什么状态下捡走了那个字段。可以说,第七节那套「先看见问题」的能力,一半是靠这份 trace 撑起来的。

九、动手:30 行复现「结构比压缩更根本」

前面全在说我的真实项目,但它依赖 LangGraph、又是特定业务,你没法照着跑。所以这一节给一个不依赖任何框架、能直接粘进 Python 跑的玩具,把核心结论亲手复现一遍。

场景简化成:一个「状态」里有两块数据,weather(天气链算出来的,含一条 alert 预警)和 industry(产业数据)。现在有一个「产业诊断」节点,它只该看产业数据。我们对比两种喂法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# --- 一个玩具状态 ---
state = {
"weather": {
"summary": "本月降水偏多",
"alert": "发布暴雨橙色预警", # ← 产业诊断不该看到它
},
"industry": {
"output": "规上工业增加值 +4.2%",
},
}

# 假装这是模型:它会「注意到」上下文里任何抢眼的词
def fake_llm(context: dict) -> str:
text = str(context)
if "预警" in text:
return "诊断结论:综合预警情况,建议纳入政策议程。" # 被污染
return "诊断结论:工业保持温和增长。" # 正常

# --- 做法 A:图省事,整个 state 塞进去 ---
print(fake_llm(state))
# → 诊断结论:综合预警情况,建议纳入政策议程。 ❌ 捡到了 alert

# --- 做法 B:只投影产业诊断该看的那一块 ---
def project_for_diagnosis(s: dict) -> dict:
return {"industry": s["industry"]} # 白名单:只有 industry

print(fake_llm(project_for_diagnosis(state)))
# → 诊断结论:工业保持温和增长。 ✅ alert 从未进入

fake_llm 是我用一个 if "预警" in text 硬编码来模拟「模型会被上下文里抢眼的词带跑」。做法 A 把整个 state 塞进去,alert 里的「预警」被捡到,结论被污染;做法 B 只投影 industry,那个词从一开始就不在上下文里,也就没有任何东西需要「压缩」或「过滤」。

你可以在做法 B 之后,再想象一个「事后压缩」的补丁:让另一个模型去删掉结论里的「预警」二字。它可能有效,但你多花了一次调用、多了一处可能出错的环节,而这一切,只是因为那个词一开始就不该进来。这就是全文那句话的可运行版本:最好的压缩,是那些 token 压根没进来。

十、几点落差

方法讲完,也得诚实说说我和教科书那套的差距,免得你以为「照书全做才对」。

  1. GSSC 我只做了前三个。教科书那套上下文处理流水线叫 GSSC:获取(Get)、选择(Select)、结构化(Structure)、压缩(Compress)。我的实现里压缩那步基本是空的,因为前三步做扎实之后,节点上下文本来就不长,没什么可压的。压缩是兜底,不是主力。

  2. 子代理我没用。教科书推荐子代理架构来隔离上下文。LangGraph 的节点本身不会自动替我隔离输入;是我在节点里显式投影 State 的少数分区后,才获得了类似「干净窗口」的效果。对子问题需要独立探索时,子代理仍然有价值,只是我的业务是固定流水线,用不上并行深挖。

  3. JIT 我用得很保守。我没让模型自主用 glob/grep 去探索,而是让节点按固定路径去取。这牺牲了灵活性,换来了可预测性。对一个要出正式分析报告的系统来说,这笔买卖划算。第五节也说了,运行时探索需要「有主见的工程设计」,否则模型会追死胡同。我选择把主见写进代码,而不是写进提示词。

  4. 最大的收获是把顺序摆正了。我原来以为上下文工程是「等它变长了怎么办」,是个优化问题。现在我认为它首先是个结构问题:先想清楚每个环节该看见什么,再去谈压缩。顺序反了,你会一直在给自己制造需要压缩的上下文。

十一、小结

上下文是有限资源,会腐蚀,有注意力预算,这是前提。想验证它是否臃肿,先把上下文打印出来、数一遍 token、给「不该出现的字段」写上断言。

教科书给的手段是压缩整合、结构化笔记、子代理,都很有用。但在我的项目里,真正解决问题的是另一件事:让每个节点从一开始就只看得见它该看见的。

  • 状态分区固定,节点只读自己那几块;
  • 工具在注册表里声明,模型不面对「选哪个」;
  • 角色提示词按需加载;
  • 链间交接用手写的字段白名单,确定性压缩,且逼你逐次回答「这一步到底需要什么」;
  • 新链从空白状态重开,默认什么都不带。

回到开头那句「综合前述预警情况」。我最后没有靠优化提示词来解决它,也没有靠压缩上下文,只是让产业诊断节点不再读得到那个字段。

最好的压缩,是那些 token 压根没进来。


参考资料