☰
别急着换模型:上下文工程才是Agent效果的天花板
2026/10/31 8:29:24 网站建设 项目流程

做AI Agent一年多了,中间踩过最大的坑不是模型选型,而是上下文。

很多人拿到一个Agent项目,第一反应是"换个更强的模型"。模型换了三四个,效果却纹丝不动:该跑题还是跑题,该忘事还是忘事,工具调用该错还是错。后来我才慢慢意识到,Agent的效果天花板,很大程度由上下文工程决定——你怎么组织喂给模型的那一堆材料,比模型本身更能决定质量。

这篇文章不聊虚的理论,就聊我实际搭建Agent时跟上下文死磕的那些事:它是什么、包含什么、怎么压缩、怎么检索、怎么扛并发,以及哪些坑是文档里不会写的。不管你现在用的是LangChain、LangGraph、Spring AI,还是Rust生态自己撸一套Agent底座,核心思路都是通用的。因为上下文工程解决的是LLM自身的机制问题,不是某个框架的API问题。

1. 别急着换模型,先看看你的上下文"脏不脏"

1.1 Agent的上下文到底是个什么东西

先做个类比。LLM本身就像一个只有工作台、没有仓库的厨子。你每次点菜,都得把食材、菜谱、锅碗瓢盆全堆到这个工作台上,他才能开始做菜。做完了,台子清空,下次再重新摆。这个"工作台"就是上下文窗口,而你在台子上摆什么、怎么摆、摆多少,就是上下文工程。

放到Agent场景里,上下文窗口里装的东西比单纯聊天要多得多。一个典型的Agent任务,光是一次工具调用往返,就会产生好几轮消息:系统提取意图、生成调用参数、工具返回结果、模型根据结果继续推理。这些中间产物全都会沉淀进上下文里。

再往里看,还得算上系统提示词、用户原始输入、历史对话、检索回来的知识片段、以及Agent自己的思考轨迹。这些东西叠在一起,很快就能把窗口撑爆。

我见过最夸张的一个案例,是同事做的一个自动运营类Agent,每轮任务要调用三次工具,跑完一轮上下文从2K涨到15K,跑了五轮就逼近窗口上限。这就是典型的上下文爆炸:不是单条消息大,而是消息轮次多。

1.2 为什么窗口大了,效果反而更差了

现在主流模型的上下文窗口动辄128K、200K,甚至到了1M的量级。但窗口大,不等于你能用好。

第一个问题是注意力分散。模型对超长上下文里的信息并不是"雨露均沾"的。有研究做过实验:关键信息放在开头和结尾,模型记住的概率明显高于放在中间。上下文越长,中间位置的信息越容易被"忽略"。这就是业内常说的Lost in the Middle现象。你辛辛苦苦塞进去的资料,模型可能根本没看。

第二个问题是噪声污染。上下文里塞了太多无关内容,模型的指令遵循能力会明显下降。就好比你在嘈杂的菜市场里喊话,厨师容易听岔。系统提示词里要求"只回答与产品相关的问题",但历史对话里全是闲聊,模型很可能被带偏。

第三个问题是成本。现在的API按token计费,输入token和输出token都算钱。上下文每多一万token,成本就肉眼可见地涨。一个每天调用几万次的Agent服务,上下文管控得好不好,直接决定月底账单是五位数还是六位数。

所以上下文工程的核心目标其实很朴素:在有限的工作台上,把最重要的信息放到模型最容易看到的位置,把不重要的信息压缩、裁剪或者挪到仓库里去。

2. 上下文工程的四块基石

2.1 系统提示词:给Agent立规矩

系统提示词是上下文的起点,也是很多人最不重视的部分。我见过不少项目,系统提示词就一句话:"你是一个 helpful assistant。"这等于没设。

一个好的系统提示词,至少要做四件事:定义角色、明确目标、给出约束、约定输出格式。角色决定语气和视角,目标决定行为方向,约束决定哪些事不能做,输出格式决定结果好不好解析。

我自己的习惯是把它写成结构化文本,而不是一段话。举个例子:

# 角色 你是一个电商客服Agent,负责处理售前咨询和售后问题。 # 目标 - 准确回答商品参数、库存、价格相关问题 - 对退换货需求,引导用户提供订单号 # 约束 - 不知道的信息不要编造,礼貌告知用户稍后由人工处理 - 严格遵守退货政策时间窗口,超过30天不予受理 - 全程使用简体中文 # 输出格式 每次回复必须包含:结论、依据、下一步建议,三个部分。

这样写,模型很容易抓住重点。别小看这个设计,它其实是上下文工程里成本最低、收益最高的一个动作。

还有个很实用的技巧:把系统提示词放在消息列表最前面,并且可以在关键规则后面加一句"以上规则必须严格遵守",对部分模型有强化效果。实测下来,Claude系列对这一类明确指令的响应比其他模型更稳定。

2.2 对话历史:留什么、丢什么、压什么

对话历史是上下文体积增长的主要来源。很多人以为历史越长,Agent越"聪明",其实恰恰相反。

先算一笔账。假设用户每轮输入平均200个中文字符,模型回复平均800个字符。中文字符和token的换算大概是一个字占1到2个token,也就是说一轮对话大约消耗1200到2000个token。如果做工具调用,过程消息可能翻倍。十轮对话下来,光历史就要吃掉2万token。

所以对话历史的取舍,核心是分级处理:短期历史保留原文,中期历史压缩成摘要,长期历史只保留结构化的事实点。

短期为什么保留原文?因为模型需要精确理解用户最近的意图,原文丢失会导致理解偏差。中期为什么压缩?因为中间轮次的价值在于"发生过什么",而不在于"每个字怎么说的"。长期为什么不保留对话、只留事实点?因为用户三天前说过"我喜欢简洁的回答风格",你需要的是这条偏好,不是那天的整段聊天记录。

这也是最近很多团队做"记忆模块"的底层逻辑。不要一上来就上向量库,先把对话分级策略想清楚,比工具重要得多。

2.3 工具描述:决定Agent"会不会用"工具

多工具Agent里,模型每次要决定调哪个工具、传什么参数。这个决定完全基于上下文里的工具定义。工具描述写得好不好,直接影响调用的准确率。

工具描述最核心的是给每个工具起一个"一眼看懂"的名字,以及一段交代清楚"什么时候用、什么时候不用"的说明。不要只写这个工具是干什么的,要写它适合处理什么任务、不适合处理什么任务。

举个例子。如果有一个查询天气的工具,光写"查询天气"是不够的。更好的写法是:

工具:get_weather 描述:查询指定城市当天的实时天气和未来三天预报。 仅在用户明确询问天气、气温、降雨、出行建议等场景使用。 如果用户问的是"明天要不要带伞"这类隐含天气需求,也调用此工具。 不要用此工具回答与天气无关的问题。

参数定义同样重要。如果参数是一个JSON Schema,记得把每个字段的格式、单位、取值范围写清楚。模型传参的时候,如果字段描述含糊,很容易传错类型。比如"温度"字段,你写"温度值(摄氏度)",模型就会传28这样合理数字;如果你只写"温度",它可能传个字符串"28度"过去,后端直接解析失败。

工具描述在上下文里占据的空间不大,但它的性价比极高。很多团队优化Agent效果时喜欢加更多工具,我反而是建议减少工具数量、把每个工具的语义写透。十个含糊的工具,不如五个清晰的工具。

2.4 外部记忆:把知识仓库与对话接起来

当信息量超出上下文窗口能容纳的范围时,就得把一部分内容"存到仓库里",在需要的时候取出来放回工作台。这就是外部记忆,也是RAG(检索增强生成)和向量数据库在做的事情。

但我想先泼一盆冷水:如果你的知识库只有几十条FAQ,别上向量数据库,直接把这些条目塞进系统提示词或者按关键词匹配就够了。向量检索的价值在大规模知识库和语义模糊查询场景,小体量项目引入它会平白增加一层复杂度和延迟。

真正需要接外部记忆的场景,通常是这几类:企业知识库动辄几千份文档、用户历史数据需要跨会话复用、Agent需要访问实时数据(比如库存、价格、订单状态)。这时候可以走混合检索:先按关键词做一次精确匹配,再做一次向量相似度检索,把两路结果合并去重后拼进上下文。

这里有个细节:检索回来的片段不是越多越好。我见过有人一次检索Top 20篇文档全塞进上下文,结果Agent被各种矛盾信息干扰,回答质量反而下降。合理的做法是每个问题先取Top 5到Top 8,然后对片段做一轮重排序(rerank),只把最相关的Top 2到Top 3放进去。宁可喂得少,也要喂得准。

3. 手把手落地:一个可用的上下文管理系统

3.1 先写一个不依赖框架的上下文管理器

框架只是工具,理解了原理你才不会在框架的封装里迷路。我先写一个极简的上下文管理器,帮你把核心逻辑看明白。

import json class ContextManager: """一个极简的上下文管理器,纯手写版,方便看清原理""" def __init__(self, max_tokens=8000, compress_at=6000): self.max_tokens = max_tokens self.compress_at = compress_at self.summary = "" # 历史摘要 self.recent = [] # 近期原始消息 def add(self, role: str, content: str): self.recent.append({"role": role, "content": content}) if self._estimate_tokens() > self.compress_at: self.compress() def _estimate_tokens(self) -> int: total = len(self.summary) for msg in self.recent: total += len(msg["content"]) # 中英混合文本粗略估算:约1.5~2字符一个token # 生产环境建议直接用模型的tokenizer,这里是演示 return int(total / 1.5) def compress(self): # 把最早的一半消息交给LLM转成摘要 old_messages = self.recent[: len(self.recent) // 2] remaining = self.recent[len(self.recent) // 2:] prompt = ( "请把下面的对话压缩成简洁的中文摘要。" "必须保留:用户明确表达的偏好、已经完成的事项、未完成的事项、" "关键数字、用户身份信息。\n" + json.dumps(old_messages, ensure_ascii=False) ) # 这里用一个你接入的LLM来生成摘要 self.summary = llm_complete(prompt) self.recent = remaining def build_prompt(self, user_input: str) -> list: messages = [{"role": "system", "content": SYSTEM_PROMPT}] if self.summary: messages.append({"role": "system", "content": f"历史摘要:{self.summary}"}) messages.extend(self.recent) messages.append({"role": "user", "content": user_input}) return messages

这个管理器的核心逻辑就是两条:一是估算token数量,二是超限时把旧消息压缩成摘要。虽然粗糙,但你需要理解的就是这个思路:不是"全部保留",也不是"全部抛弃",而是分级处理。

注意这里的token估算是示意。生产环境里,不同模型的tokenizer差异很大,一个中文字符在有些模型里占1个token,在另一些模型里占1.5个token甚至更多。你最好直接用官方tokenizer库去统计,而不是像我这样用字符数除以一个固定值。这个估算误差会导致压缩时机不准,直接影响效果。

3.2 摘要压缩的两种策略与参数选择

压缩本身也有不同的策略。我实际用过两种:一种是上述的"触发式压缩",上下文达到阈值才触发;另一种是"轮次式压缩",每跑完N轮对话就强制压缩一次。

触发式的好处是省token,但坏处是触发的时候往往已经比较晚,摘要的内容可能很仓促。轮次式的好处是节奏稳定,Agent不容易膨胀,但坏处是轮次少而对话长的场景下会过早丢细节。

我建议按场景选:偏向深度对话、单轮内容多的场景,用轮次式,每3到5轮压缩一次;偏向短平快交互、轮数很多的场景,用触发式,阈值设置在最大窗口的70%左右。

压缩时的摘要提示词也有讲究。不要给一句"总结一下对话",而是要明确列出"必须保留什么"。我上面代码里的写法是实际验证过的:列清楚保留项,模型输出的摘要质量会有明显提升。特别是"未完成的事项"这一条,对Agent下一步决策帮助极大。比如用户上文提到"我要对比A和B两个套餐,回头给我推荐",如果你没保留这个待办,Agent之后就完全忘了,用户只能重新解释一遍。

另外,压缩完成后建议把旧的对话轮次从recent里真正移除,而不是留在消息列表里当作"参考"。很多人压缩后仍然保留原文,理由是怕摘要丢信息,结果上下文体积根本没降下来,等于白做。

3.3 混合检索:让上下文带上"实时知识"

接上外部记忆之后,上下文就不再只是"对话记录"了,而是变成"对话记录加相关知识"的动态组装。

我在实际项目里常用的组装流程大概是这样的:

  1. 拿到用户最新输入,提取检索query。注意:不要直接用用户原话去检索,先让LLM把query改写成适合检索的形式,去掉口语词,补全指代。比如用户说"它支持多语言吗",改写成"该产品是否支持多语言"。

  2. 同时跑两路检索:一路是BM25关键词检索(用ES或数据库全文索引即可),一路是向量检索(Embedding加上向量库)。

  3. 合并结果后做rerank,把最相关的Top K个片段取出来。这一步可以用一个小的交叉编码器模型,效果比纯向量相似度好很多,代价是多几十毫秒延迟,值得。

  4. 把检索片段和系统提示词放在同一层,通常放在对话历史之前。为什么要放前面?因为前面说过,模型对上下文开头和结尾的内容注意力更强。检索出来的知识是当前这轮任务的主依据,理应放在"黄金位置"。

这里还有一个容易忽略的点:检索片段要标注来源。在片段前面加上"【文档1】"之类的标识,并且在系统提示词里约定"引用信息请标注来源编号"。这能显著减少模型把检索内容和自己的幻觉混在一起的情况。反正我不能说完全杜绝幻觉,但至少肉眼可见地降低。

3.4 状态持久化:用Redis扛住多会话并发

上面聊的都是单会话内的上下文处理。一旦你把Agent做成一个多用户服务,情况就变了:每个用户的上下文是独立的,你不能用一个全局变量去存所有人的对话历史。

我自己在FastAPI里搭过一个简单的服务,核心就是Redis加序列化。每个用户一个Context对象,用JSON序列化后存进Redis,key是session_id,value是包含摘要和最近消息的字典。

from fastapi import FastAPI, HTTPException from pydantic import BaseModel import redis import json app = FastAPI() cache = redis.from_url("redis://localhost:6379/0") # 每个session存一个上下文对象,用压缩策略控制体积 SESSION_TTL = 60 * 60 * 24 # 24小时没有交互就清理 class ChatRequest(BaseModel): session_id: str message: str def load_context(session_id: str) -> dict: raw = cache.get(f"agent:ctx:{session_id}") if raw is None: return {"summary": "", "recent": []} return json.loads(raw) def save_context(session_id: str, ctx: dict): cache.set(f"agent:ctx:{session_id}", json.dumps(ctx, ensure_ascii=False), ex=SESSION_TTL) @app.post("/chat") async def chat(req: ChatRequest): ctx = load_context(req.session_id) # 在这里把user_input加入上下文,判断是否要压缩 ctx_manager = ContextManager(max_tokens=8000, compress_at=6000) ctx_manager.summary = ctx["summary"] ctx_manager.recent = ctx["recent"] ctx_manager.add("user", req.message) prompt = ctx_manager.build_prompt(req.message) response = llm_complete_turbo(prompt) ctx_manager.add("assistant", response) save_context(req.session_id, {"summary": ctx_manager.summary, "recent": ctx_manager.recent}) return {"reply": response}

注意几个工程细节:Redis的过期时间别设太短,不然用户隔半天回来,Agent就什么都忘了;也别设太长,否则会话积累无限膨胀,后面再压缩就很被动了。24小时是个比较合理的默认值。

另外,读写Redis的时机要小心。如果你在多进程环境里用Gunicorn跑FastAPI,每个worker进程都会有自己的Context状态,不能只存内存,必须统一放Redis这类共享存储。不然同一个用户第一次请求落在worker A、第二次落在worker B,上下文就彻底断裂,Agent会表现得像得了失忆症。

4. 扛并发的正确姿势:上下文工程的服务化细节

4.1 会话隔离:每个用户的上下文不能串台

并发场景下最忌讳的是上下文串台。用户A的对话摘要跑到了用户B的上下文里,这种事故一旦发生,你不用指望线上能救回来。

隔离的关键在于:所有上下文读写都基于session_id进行,而且session_id的生成要严格校验。我见过有人直接拿用户传上来的字符串当key,导致用户可以构造特殊key去读别人的上下文。正确做法是服务端生成一串不可预测的session_id,比如UUID4,每次请求校验它的格式,并且与用户身份做绑定校验。

还有一层隔离容易被忽略:同一个用户同时打开两个页面、两个设备,会有多个session。这时候要决定是共享一个上下文,还是各聊各的。从工程角度,我建议各聊各的,干净利落;如果一定要多端同步,那就要专门做状态合并,复杂度会明显上升,一般场景不值得。

4.2 上下文缓存:省的不只是钱,还有延迟

扛并发不只是把请求堆到服务器上就行,上下文工程在性能层面同样能做文章。

一个热门的省钱技巧是提示词缓存(Prompt Caching)。现在不少模型服务商都支持对相同的前缀内容做缓存,命中的部分按折扣价计费,甚至免费。这意味着,你可以把系统提示词、工具定义、长摘要这些高频不变的内容放到消息列表的最前面。这样每次请求,这部分内容直接命中缓存,既省token又省首token延迟。

实际效果我测下来,长会话场景能省30%到40%的输入token成本,首token响应时间也能降几百毫秒。前提条件是你得把"稳定不变"的内容和"频繁变动"的内容明确分开,不要让动态内容插在静态内容中间,否则缓存就失效了。

对OpenAI系的模型,一般是把静态内容放在对话开头,动态内容追加在后面;对Anthropic系则有更细的缓存标记控制。具体语法各家在变,建议每次升级SDK时看一眼官方变更记录。

4.3 多模型路由:简单问题别浪费大窗口

并发压力大的时候,另一种思路是"分层路由":简单请求用轻量小模型,复杂请求才用大模型长上下文。

上下文工程在这里的意义是:为了让小模型也能处理一些请求,你得主动构造一个更精简的上下文,只保留最必要的信息。比如对于"查个订单状态"这种请求,根本不需要塞进最近三十轮的对话历史,只需要把用户身份、订单号、几条关键规则放进去就够。

我搭过一个简单的路由规则:先让一个小模型对用户输入做一次意图分类,分成"简单查询""多轮咨询""复杂任务"三档。简单查询直接走小模型,上下文精简到几百token;多轮咨询走中档模型,配上轻量摘要;复杂任务才上大模型加完整上下文。这样平均token消耗大概能降一半以上,响应速度也快不少。

路由的代价是大模型做意图分类也要花一次推理,但因为它输入短、输出短,成本极低。整体算下来仍然是划算的。

5. 常见问题与排查技巧实录

5.1 五个高频翻车现场速查表

下面这几个问题,是我在实际运营Agent时反复遇到的,整理成一张速查表,方便你对照排查:

现象大概率原因排查方向
Agent回答跑题历史对话噪声太多,指令被淹没检查系统提示词位置、历史是否过度压缩、检索片段是否相关
关键信息被遗忘上下文有Lost in the Middle检查关键信息是否被放在了中间位置,尝试挪到开头或结尾
工具调用频繁报参错误工具描述里参数说明含糊逐字段检查JSON Schema描述,补充格式、单位、取值范围
上下文体积爆炸没有压缩策略或压缩后仍保留原文检查压缩阈值、压缩后是否真正移除了旧消息
多实例部署后同一用户失忆上下文只存了本地内存检查是否接入了Redis等共享会话存储

这个表基本覆盖了我遇到过的八成问题。遇到Agent表现异常,不要急着调模型参数,先把手伸到上下文里看一圈,往往问题比想象中明显。

5.2 三个不写进文档的避坑经验

第一个经验是:不要把所有工具定义都塞进上下文。我试过一次性给Agent定义二十个工具,结果模型在各种工具之间频繁犹豫,调用准确率直线下降。后来改成"按需注册",先根据用户意图动态选择可能相关的5到8个工具,再把定义注入上下文。效果立竿见影,准确率至少提了10个百分点。这个思路在工具数量多的项目里特别值得尝试。

第二个经验是:摘要的生成时机要避开响应高峰。压缩摘要本身也要调用LLM,如果放在用户请求链路里同步执行,会明显拉长这次请求的响应时间。我后来改成异步机制:主链路判断"需要压缩"时,先返回当前上下文能处理的响应,同时扔一个后台任务去做压缩,压缩完成后把新摘要写回存储,下一轮请求再用新摘要。这样用户无感知,系统也稳定。

第三个经验是:给检索片段加"保质期"。很多Agent接入外部知识库之后,会把检索到的内容一直留在上下文里,哪怕这些内容已经过时。我处理过的一个期货行情辅助Agent,就是上下文里残留了前一天的价格数据,导致回答明显偏离当天行情。后来我在检索结果前面拼上一行时间戳,并在系统提示词里明确要求"回答时优先采用最新时间戳的数据,并说明数据日期"。这个小改动让回答的时效性有明显改善。

这个思路对做自动化内容运营Agent(比如定时生成并发布内容到小红书的场景)特别受用——你检索到的平台规则、素材模板、账号信息都要标注版本和生效时间,否则规则一变,Agent还在按旧规则执行。

5.3 上下文工程的学习路径建议

最后给想系统学习的朋友一条顺手的路线。先别急着读框架源码,把"提示词设计、消息结构、token计算"这三样基本功打牢。然后从本节里的这个极简ContextManager开始,自己动手实现一遍摘要压缩,理解触发时机和内容取舍。接着再上手RAG和工具语义优化,最后才去研究服务化框架里的状态管理、并发路由这些东西。

工具方面,LangChain的Memory模块和LangGraph的State机制都值得摸一遍,但我始终建议带着批判眼光去看——它们的封装有时候会掩盖底层逻辑,出了问题你反而不知道从哪排查。

我个人在实际操作中的体会是:上下文工程不是一个一次性设计完就结束的东西,它更像一套需要持续调优的运营系统。你换一个模型、加一个工具、改一个业务规则,上下文的组装方式都得跟着调整。所以别幻想有一套参数能一劳永逸。花时间把上下文的组装逻辑做成可观测、可调整的模块,比什么都值得。

最后再分享一个小技巧:每次修改上下文工程之后,记得保留一组固定的回归测试用例,用同一批问题测效果。不要靠肉眼感受"好像变好了",用同样的输入对比输出质量,你才能知道改动到底有没有用。这比任何经验都来得可靠。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询