Context Engineering 实战:别再往 context 里塞东西了
Context Engineering 实战:别再往 context 里塞东西了
为什么 token 塞满反而让 LLM 变蠢?四种核心策略 + Python 代码实现
Agent 跑到第 15 步,突然开始做蠢事。
它把已经检查过的文件又检查了一遍,给出了和第 3 步完全矛盾的结论,还重复调用了两次同一个工具。我以为是模型问题,换了更贵的模型,没用。以为是 prompt 问题,把 system prompt 改得更详细,更没用。
最后发现根本原因:context 里塞了太多东西。
那 15 步里积累的工具调用历史、中间状态、重复输出——把真正重要的信息彻底淹没了。这就是 context rot,一个你在教程里很少听到但在生产环境里天天遇到的问题。
本文说的就是怎么系统性地解决它。
一、Context 不是暂存区,是注意力预算
很多工程师把 context window 当暂存区用——把所有可能有用的信息都塞进去,让模型自己判断哪个重要。这个直觉是错的。
1.1 为什么 context 越长模型越蠢
Transformer 的注意力机制计算的是每个 token 对其他所有 token 的关系,复杂度是 O(n²)。这意味着当 context 变长时,模型需要在更多的 token pair 之间分配有限的注意力。
Chroma 做了一个实测:对 18 个主流 LLM(含 GPT-4.1、Claude 3.7、Gemini 2.5)做了 context 长度对准确率的影响测试。结论:所有模型在 context 增长时性能都会下降,没有例外。GPT-4.1 在 1K token 的 context 里准确率约 95%,放大到 128K 时降到约 60%。
这不是模型的 bug,是注意力机制的数学决定的。
1.2 Lost in the Middle:中间位置最危险
Stanford 的研究发现了一个规律:模型对 context 开头和结尾的信息记忆更准,中间部分最容易丢失。这个现象叫 “lost in the middle”。
实际影响:如果你把最重要的约束或状态放在对话历史的中间,模型在长 context 下大概率会忽视它。
1.3 四种典型的 context 失败模式
Context Poisoning:错误信息进入了 context。最危险的是工具调用返回了错误结果,Agent 把它当作事实用于后续推理,越跑越偏。
Context Distraction:过多的历史信息让 Agent 倾向于重复历史模式,而不是根据当前状态重新推理。你会看到 Agent 在第 10 步"记起"第 2 步的做法并重复,即使当时的状态已经完全不同了。
Context Confusion:塞了太多工具 schema 或文档内容,模型在选工具时开始犹豫,选错率上升。
Context Clash:context 里存在互相矛盾的信息(比如第 3 步的结论和第 9 步的结论相反),模型行为变得不可预测。
二、四种 Context 管理策略
LangChain 的 context engineering 博客里把策略分成四类,我觉得这个框架非常实用:Write、Select、Compress、Isolate。每种策略解决不同类型的问题。
2.1 Write:控制写进 context 的内容质量
System prompt 的 altitude 问题
Anthropic 的工程师用了一个好词:altitude(高度)。太低了——system prompt 列了一堆具体规则,Agent 在规则没覆盖到的边缘场景完全不知道怎么办。太高了——只写了"你是一个有用的助手",Agent 行为过于随意。
工具返回结果的控制
工具调用是 context 膨胀的最大来源。一次 API 调用可能返回 5000 字,但 Agent 真正需要的可能只有 3 个字段。
fromtypingimportAnydefcompress_tool_result(result:Any,tool_name:str)->str:""" 把工具返回结果压缩成 Agent 真正需要的内容 不要把原始 JSON 塞进 context """iftool_name=="search_files":# 只保留文件路径和关键摘要,丢弃原始内容ifisinstance(result,list):return"\n".join([f"-{item['path']}:{item.get('summary',item.get('content','')[:100])}"foriteminresult[:10]# 最多 10 条])iftool_name=="run_code":# 只保留最后 50 行输出,截断中间lines=str(result).split("\n")iflen(lines)>50:returnf"[前{len(lines)-50}行已截断]\n"+"\n".join(lines[-50:])returnstr(result)iftool_name=="read_file":# 大文件只返回前后各 500 字符 + 行数content=str(result)iflen(content)>2000:return(f"[文件共{len(content)}字符]\n"f"[前 500 字符]\n{content[:500]}\n"f"...\n"f"[后 500 字符]\n{content[-500:]}")returncontent# 默认:字符串化并截断returnstr(result)[:2000]``` 这个函数做的事很简单:在工具结果进入 context 之前先过滤一遍。大多数教程里的 Agent 代码直接 `str(tool_result)` 塞进去,这是 context 膨胀最常见的原因。### 2.2 Select:决定什么放进 context不是所有历史对话都有用,不是所有 few-shot 例子都需要。**动态 few-shot 选择器**静态 few-shot 的问题:你放了5个例子,实际上对当前请求有用的可能只有1个,另外4个在白占 token。 ```pythonimportnumpyasnpfromsentence_transformersimportSentenceTransformerfromsklearn.metrics.pairwiseimportcosine_similarityclassDynamicFewShotSelector:""" 根据当前请求动态选择最相关的 few-shot 例子 用 embedding 相似度替代静态选择 """def__init__(self,examples:list[dict],model_name:str="all-MiniLM-L6-v2"):self.examples=examples self.model=SentenceTransformer(model_name)# 预计算所有例子的 embeddingtexts=[ex["input"]forexinexamples]self.embeddings=self.model.encode(texts)defselect(self,query:str,top_k:int=3)->list[dict]:"""选择与 query 最相关的 top_k 个例子"""query_embedding=self.model.encode([query])similarities=cosine_similarity(query_embedding,self.embeddings)[0]# 按相似度排序top_indices=np.argsort(similarities)[::-1][:top_k]return[self.examples[i]foriintop_indices]defformat_for_context(self,query:str,top_k:int=3)->str:"""直接返回格式化好的 few-shot 文本"""selected=self.select(query,top_k)parts=[]forexinselected:parts.append(f"Input:{ex['input']}\nOutput:{ex['output']}")return"\n\n".join(parts)``` 实际上大多数场景用不到复杂的 embedding,BM25 检索已经够用了。关键是不要用静态的 few-shot 列表。### 2.3 Compress:压缩现有 context这是实际工程里用得最多的策略。三种主要手段:-**Summarization**:用 LLM 把对话历史摘要成更短的文本--**Truncation**:直接丢弃最早的消息(最简单,但会丢失重要状态)--**Distillation**:提取关键事实,丢弃推理过程(最精确,成本最高) 关键问题不是选哪种,而是**什么时候触发,压缩到多长**。应该基于 context 使用率触发,而不是每 N 轮触发一次。 ```pythonimportanthropicfromdataclassesimportdataclass@dataclassclassContextConfig:max_tokens:int=100_000# context window 上限compress_threshold:float=0.65# 超过 65% 时触发压缩target_ratio:float=0.40# 压缩后目标是 40%summary_max_tokens:int=400# summary 本身不超过 400 tokenpreserve_last_n:int=6# 最近 N 条消息不压缩classContextManager:""" 生产可用的 Context 管理器 基于 token 使用率自动触发压缩 """def__init__(self,config:ContextConfig=None):self.config=configorContextConfig()self.client=anthropic.Anthropic()self.messages:list[dict]=[]self.current_token_count:int=0defadd_message(self,role:str,content:str)->None:self.messages.append({"role":role,"content":content})# 粗估:每个字符约 0.5 tokenself.current_token_count+=int(len(content)*0.5)# 检查是否需要压缩usage_ratio=self.current_token_count/self.config.max_tokensifusage_ratio>self.config.compress_threshold:self._compress()def_compress(self)->None:"""执行 sliding window summarization"""iflen(self.messages)<=self.config.preserve_last_n:return# 分离出需要压缩的历史和需要保留的最近消息split_point=len(self.messages)-self.config.preserve_last_n to_compress=self.messages[:split_point]to_keep=self.messages[split_point:]# 调用 LLM 生成摘要(用便宜的模型)compress_prompt=("以下是对话历史,请提取关键事实、决策和状态,"f"压缩成不超过{self.config.summary_max_tokens}token 的摘要。""只保留后续推理必需的信息,丢弃推理过程。\n\n")history_text="\n".join([f"{m['role']}:{m['content']}"forminto_compress])response=self.client.messages.create(model="claude-3-5-haiku-20241022",# 用便宜的模型做压缩max_tokens=self.config.summary_max_tokens,messages=[{"role":"user","content":compress_prompt+history_text}])summary=response.content[0].text# 重建 messages:summary + 最近 N 条self.messages=[{"role":"system","content":f"[对话历史摘要]\n{summary}"},*to_keep]# 重新估算 token 数total_content=" ".join([m["content"]forminself.messages])self.current_token_count=int(len(total_content)*0.5)defget_messages(self)->list[dict]:returnself.messages ``` 有几个细节值得注意:1.**用便宜模型做压缩**。`claude-3-5-haiku` 做 summarization 完全够用,不需要用 Opus。2.2.**summary 的 token 上限**要控制严格,否则你会发现压缩完了 summary 比原始内容还长。3.3.**最近 N 条不压缩**。刚发生的对话对当前推理最重要,不要动它。### 2.4 Isolate:给子任务一个干净的 context当 Agent 需要处理相对独立的子任务时,最好给它一个全新的 context,而不是带着父 Agent 的全部历史。 ```pythonimportasyncioimportanthropicclassIsolatedSubAgent:""" 每个子任务运行在独立的 context 中 父 Agent 只传递必要的任务描述,不传递完整历史 """def__init__(self,task_description:str,tools:list=None):self.client=anthropic.Anthropic()self.task=task_description self.tools=toolsor[]# 子 Agent 有自己干净的 contextself.messages:list[dict]=[]asyncdefrun(self,max_steps:int=10)->str:""" 运行子任务,返回结果摘要 子 Agent 的全部历史不会泄漏到父 Agent """self.messages=[{"role":"user","content":self.task}]forstepinrange(max_steps):response=self.client.messages.create(model="claude-opus-4-5",max_tokens=4096,tools=self.tools,messages=self.messages)ifresponse.stop_reason=="end_turn":# 只返回最终结果,不是整个对话历史returnself._extract_result(response)# 处理工具调用...(简化示意)self.messages.append({"role":"assistant","content":response.content})return"子任务达到步数上限"def_extract_result(self,response)->str:"""从响应中提取关键结果,丢弃推理过程"""text_blocks=[b.textforbinresponse.contentifhasattr(b,"text")]return"\n".join(text_blocks)asyncdefrun_parallel_subtasks(parent_context:str,subtasks:list[str])->list[str]:""" 并行运行多个子任务,每个都有独立的 context 父 Agent 的 context 不会传给子任务,只传任务描述 """agents=[IsolatedSubAgent(task_description=f"任务背景:{parent_context}\n\n具体任务:{task}")fortaskinsubtasks]results=awaitasyncio.gather(*[agent.run()foragentinagents])returnlist(results)``` Isolate 策略的核心思想:子任务不需要知道父 Agent 的全部历史,只需要知道它要做什么。把父 Agent 的全部 context 传给子 Agent 是最常见的架构错误之一。---## 三、一个决策树:什么时候用哪种策略理论讲完了,实际工程里你面对的问题是:**现在应该做什么**?当前 context 使用率 > 65%?
├── 否 → 继续,监控
└── 是 → 最近 N 步是否有重复/冗余工具调用?
├── 是 → Write 策略:改进 compress_tool_result,减少单次写入
└── 否 → 任务是否可以分解成独立子任务?
├── 是 → Isolate 策略:用 IsolatedSubAgent
└── 否 → 历史消息是否超过 20 条?
├── 是 → Compress 策略:触发 summarization
└── 否 → Select 策略:减少 few-shot / RAG 召回数量
```
65% 这个阈值不是拍脑袋的——留 35% 的余量是为了给模型的推理和输出留空间。很多工程师等到 90% 才触发压缩,这时候 context rot 已经发生了。
四、生产环境的几个工程坑
坑 1:把压缩 token 当压缩信息
Summary 写得太短会丢失关键状态。一个 Agent 在 30 步任务里可能有很多中间决策(“决定不用方案 A 因为 X”、“发现文件 Y 已存在”),如果 summary 只有 50 字,这些状态全丢了。
经验值:每 10 条对话历史,summary 保留 150-300 token。少于这个,信息损失太大。
坑 2:忘记工具 schema 也占 token
很多教程只统计对话消息的 token,但工具定义本身也会占用大量 context。如果你注册了 20 个工具,每个 schema 200 token,光工具定义就占了 4000 token。
做法:动态注册工具。Agent 在不同阶段只激活当前阶段需要的工具,而不是一次性注册所有工具。
坑 3:sub-agent 的 context 隔离破了
常见错误:父 Agent 把整个self.messages列表直接传给子 Agent。子 Agent 带着父 Agent 几千 token 的历史跑,隔离完全没有意义。
正确做法:只传任务描述+必要的输入数据,任务描述用自然语言写清楚,不要把对话历史序列化进去。
坑 4:Long-term Memory 的写时机错了
很多实现是在每轮对话结束后都往 long-term memory 里写一遍,导致 memory 里全是重复和噪音。
写入时机应该是:有新的、值得记住的状态变化时才写。比如"发现了一个新的 API 端点"、“确认了文件结构”,而不是每次对话都写。
小结
Context Engineering 这个词最近很流行,但它说的不是什么新技术,本质是一种工程习惯:像管理内存一样管理 context。
具体到行动:
- Write:工具返回结果在进 context 前先过滤,不要裸
str(result) - Select:few-shot 用动态选择,不用静态列表;RAG 召回数量保守点
- Compress:在 65% 使用率触发,用便宜模型做 summarize,summary 保留 150-400 token
- Isolate:子任务用独立 context,只传任务描述不传历史
最后一点:不要等到 context 爆了再处理。context rot 是渐进的,模型不会突然失效,它会越来越蠢,而你可能以为是别的问题。主动监控 context 健康度,是 LLM 应用生产化里最容易被忽视的一个环节。
- Isolate:子任务用独立 context,只传任务描述不传历史
参考资料
- Anthropic: Effective context engineering for AI agents
- LangChain: Context Engineering for Agents
- Weaviate: Context Engineering
- JetBrains Research: Efficient Context Management (NeurIPS 2025)
- Karpathy, A. (2025). “Context Engineering is the new Prompt Engineering.” [Twitter/X]
