我见过不少团队,优化 Prompt 优化到怀疑人生:指令写得越来越长、越来越绕,例子上了一堆,few-shot 都塞到顶,模型该犯错还是犯错。尤其是一上 Agent,问题更明显——系统明明接了知识库、接了工具、接了多轮对话状态,但模型就像个记性很差的新员工,你说一句他做一步,稍微绕点弯就断片。后来我复盘了一圈项目,得出来的结论很扎心:大多数人把精力放在 Prompt 上,但真正卡住 Agent 表现的,是上下文工程。
上下文工程是什么?简单说,就是管理“模型能看到什么、按什么顺序看、看完之后记住什么、忘掉什么”这一整套机制。Prompt 只是其中很小的一块,它负责“说话的内容”,上下文工程负责“信息怎么进来、怎么组织、怎么留、怎么走”。Agent 不是单次问答,它是一个多轮、多工具、多信息源协作的复杂系统,每一轮都要决定哪些历史保留、哪些工具结果值得放进来、哪些用户细节该记住,这个决策链和质量直接决定 Agent 是“像个老手”还是“像个失忆的实习生”。
这篇不是理论科普,我尽量把我实操里验证过的上下文工程框架、核心维度、代码级的搭建路径和踩坑记录都摊开来讲。适合正在做 Agent 开发、或者已经把 Prompt 调到没脾气的同学参考。
1. 上下文工程到底解决了什么问题
1.1 Prompt 是说明文档,上下文工程才是系统设计
我们通常聊的 Prompt,本质是“给模型的一次性说明”:你是谁、你要干什么、输出格式是什么、有哪些注意事项。模型每次调用都只看到这几十行字,稍微好一点的会拼上历史消息,仅此而已。两年前这样做没问题,因为那时候的 LLM 应用就是“单轮问答 + 少量记忆”。但现在 Agent 架构完全不一样:
- 模型要自主决策调用哪些工具,工具返回结果还要继续传入下一轮;
- 多轮对话里,用户中途改需求、补充条件,模型要能跟踪状态;
- 外部知识库检索结果需要和对话历史、当前任务目标做融合,不能直接堆上去;
- 长时间运行的任务可能需要跨天、跨会话的“记忆”;
- 多个子 Agent 之间的产出还要互相引用。
这些都不是 Prompt 能覆盖的。Prompt 写得再好,也只是“内容层”的优化。上下文工程是在“系统层”决定信息流:哪些字段进、哪些字段出、以什么结构存、在什么时机注入、超长之后怎么压缩淘汰。
1.2 为什么窗口变大了,问题反而变严重
有人会觉得:模型上下文窗口都到 128K 甚至 200K 了,把东西全塞进去不就行了?我一开始也这么干过,结果很惨。首先是成本,token 按输入计费,哪怕回答只有几百字,每轮带着几十 K 的历史记录跑,成本直接起飞。其次是延迟,上下文越长,首 token 延迟越明显,真实 Agent 场景下用户等不了那么久。再一个是注意力稀释,模型很难从一大坨无关信息里准确抓住关键信息,可能用一个陈旧状态覆盖了当前目标,也可能漏掉中段某句关键指令。窗口大不等于“记得住”,更不等于“用得准”。这就像你的工位从 1 平方变成 20 平方,不等于你找东西更快,反而可能更难,必须靠整理、分类、做索引。
1.3 上下文工程的目标,一句话概括
从工程角度,上下文工程的目标是:在每次模型调用前,构建一个“最小且充分”的上下文集合,让模型又快又准地完成这一步决策。它包含四个动作:选择性提取、结构化组织、时序性注入、动态维护。选择是指不把全量数据搬进窗口;组织是指不管内部怎么存,给模型时必须是清晰的 blocks;注入是指按正确的顺序和格式拼装;维护是指一轮结束后,哪些进长期存储、哪些丢弃、哪些压缩。这部分往往被叫作 Agent 的“记忆系统”,但记忆只是上下文工程的一部分,完整的上下文工程还要考虑每轮怎么从记忆和外部源中组装出最优输入。
2. 四个核心维度:把上下文当成产品来做
2.1 结构化:别再让信息在对话历史里裸奔
我以前看很多团队的 Agent 代码,上下文基本是 chat_history 走天下:把所有 messages 原样拼起来,最多加个 system prompt。短期对话没问题,一旦任务复杂,问题就来了:工具调用结果是一段文本塞在 assistant 消息里,用户历史需求分散在不同轮次,模型必须自己从一堆原始文本里找信息,效果全看运气。
我的做法是,先给上下文建立 Schema。每条进入上下文体系的信息,都按域拆开并标记。一般我会分这几个域:
- 用户画像/偏好:长期稳定的喜好、称谓、常用术语等;
- 任务状态:当前目标、已完成步骤、当前正在执行的函数、待确认的中间结果;
- 交互历史:最近 N 条原始对话,严格设上限;
- 事实与参考资料:知识库检索内容、工具返回数据、文档片段,每条带来源和时效;
- 外部环境:当前时间、用户设备类型等辅助信息。
有了这个分域 Schema 之后,组装上下文就不是“拼接字符串”,而是“填结构体”。模型看到的不是一堆无差别文本,而是分好区、标好索引的信息面板。这一步对效果提升有多大?我测过同一个 Agent,只是把上下文从纯对话历史改成结构化 blocks,工具调用的准确率大概提升了十几个点,主因是模型能明确知道“当前任务状态在哪一段”,而不是自己在文本里猜。
2.2 记忆:短期工作记忆和长期外部记忆缺一不可
Agent 的“记忆”不是把历史消息读一遍就行,它得分两层。短期工作记忆就是“当前任务轮次内模型所需的信息”,通常放在上下文窗口内,但窗口有限,所以要做取舍和压缩。长期外部记忆指的是存到向量库、KV 存储或数据库里的持久化信息,需要时再检索召回。这两层必须分开设计,否则要么窗口爆掉,要么跨会话能力为零。
我见过最典型的错误是:把长期记忆全部塞进 System Prompt。比如每次对话前,把所有历史摘要、用户所有偏好、之前所有任务结果全部写在 system 里。这样确实“记得”了,但信息量大到模型根本用不上,还容易让模型把不相关的历史偏好当成当前约束。正确做法是:长期记忆要有检索机制,跟用户当前意图相关的部分才被动态召回进窗口,不相关的留在库外,不占窗口、不干扰注意力。
2.3 动态选择:上下文压缩与淘汰策略
上下文管理还要解决一个问题:对话轮次多了之后,必然超窗。我不能控制用户聊多少轮,但可以控制“哪些历史值得保留”。实践中最稳健的策略是三段式:
- 最近 N 轮对话原文:保留完整对白,这是短期工作记忆;
- 更早对话的摘要:每轮或每几轮结束后,用模型生成一句带关键决策点的摘要,存入长期区;
- 任务状态快照:每一轮结束后保存一个当前状态结构体(目标、进度、下一步计划)。
模型每次调用时,上下文由“最近 N 轮原文 + 旧轮摘要 + 当前状态结构体 + 检索到的相关知识”组成。这比全量历史进窗稳定得多。N 值一般取 5~10 轮,超过就触发摘要压缩。另外,检索到的知识也不是全丢进去,需要按当前问题做重排,取 top-k,k 一般控制在 3~5 条,除非任务明确需要大量资料对比。
2.4 注入顺序:顺序本身就是一种语义
模型的注意力机制对输入顺序非常敏感,上下文塞的顺序不对,效果也会打折。我的常用顺序是:
- System(角色 + 任务边界 + 输出约束)
- 当前任务状态(目标、进度、下一步计划)
- 相关长期记忆/用户画像(只放和当前相关的)
- 相关检索资料(知识库/工具返回值,带来源标注)
- 最近 N 轮对话原文
- 当前用户输入
这个顺序背后的逻辑是:先让模型建立“我是谁、我现在要干嘛”的锚点,再给它参考材料,然后才进入对话情境。如果对话原文放太前面,模型的角色锚定会被用户的上轮表述带偏;如果检索资料放在最后,模型可能把它当作“待回复内容”而不是背景参考。另外,多个信息源同时存在时,尽量用 XML 标签或 Markdown 分隔标题标清楚,比如knowledge_base、tool_result。你可以试试两版对比,效果差异很明显。
3. 实操落地:一套最小可用的上下文管线怎么搭
3.1 第一步:定义 Context Schema,先把数据格式定下来
不管底层用什么框架,第一步一定是定义上下文的数据结构。我用 Python 写一个简化版,方便理解,实际项目里可以换成 Pydantic 或 dataclass。
@dataclass class ContextState: user_profile: dict # 用户偏好 task_goal: str # 当前任务目标 task_progress: list # 已完成步骤 next_plan: str # 下一步计划 last_tool_result: str # 最近一次工具返回 @dataclass class ContextPackage: system_prompt: str state: ContextState memory_segments: list # 检索到的长期记忆 knowledge_refs: list # 知识库检索结果 recent_chat: list # 最近N轮对话 current_input: str这个 Schema 的关键在于:把“状态”和“历史”分离。状态是结构化的、可覆盖的,历史是线性增长的,需要裁剪/摘要。这样组装时,我可以保证当前任务状态永远是最新的,不受历史轮次干扰。
3.2 第二步:实现组装器,把 Schema 转成模型输入
组装器就是负责把 ContextPackage 变成模型真正看到的 messages 数组。核心逻辑是“按序拼接 + 标签标记”。
def assemble_messages(pkg: ContextPackage) -> list: system = pkg.system_prompt + "\n\n## 当前任务状态\n" system += f"目标:{pkg.state.task_goal}\n" system += f"进度:{len(pkg.state.task_progress)}步已完成\n" system += f"下一步:{pkg.state.next_plan}" messages = [{"role": "system", "content": system}] for mem in pkg.memory_segments: messages.append({"role": "system", "content": f"<memory>{mem}</memory>"}) for ref in pkg.knowledge_refs: messages.append({"role": "system", "content": f"<knowledge>{ref}</knowledge>"}) for msg in pkg.recent_chat: messages.append(msg) messages.append({"role": "user", "content": pkg.current_input}) return messages注意几个细节:长期记忆和知识库检索结果都用 system role 注入,因为它们都是背景信息,不该以 user 身份出现,否则模型可能把它们当作需要回复的新对话;每条 background 都带标签,这样模型能感知信息来源,避免把知识库内容当成自己的记忆或任务历史。
3.3 第三步:加入记忆管理层,处理超长会话
这里需要两个组件:剪枝器和摘要器。剪枝器负责在每轮结束后检查窗口长度,超出阈值就触发摘要。摘要器负责把早期对话转成结构化摘要。示例:
class MemoryManager: def __init__(self, max_recent_turns=10, max_total_tokens=60000): self.history = [] self.summary = "" self.state = ContextState(...) def add_turn(self, user_msg, assistant_msg, tool_results=None): self.history.append({"user": user_msg, "assistant": assistant_msg}) if tool_results: self.state.last_tool_result = tool_results[-1] if len(self.history) > self.max_recent_turns: self._summarize_oldest() def _summarize_oldest(self): oldest_batch = self.history[:-self.max_recent_turns] batch_text = format_batch(oldest_batch) new_summary = summarize_model(batch_text, self.summary) self.summary = new_summary self.history = self.history[-self.max_recent_turns:]这里没有用具体 Token 计数做精确处理,实际项目中建议接入 tiktoken 或其他 tokenizer 做统计。触发压缩的时机可以基于轮数,也可以基于累计 token 数。我的经验是轮数触发简单可靠,token 数触发更精准,但会增加每次检查的开销。建议先用轮数,稳定后再升级。
3.4 第四步:把上下文管线接进 Agent 主循环
最后是整合。Agent 主循环不再直接调大模型,而是走一遍“状态检查 → 检索 → 组装 → 调用 → 更新状态”的流程。
def agent_loop(user_input): pkg = ContextPackage( system_prompt=SYSTEM_PROMPT, state=memory.state, memory_segments=retrieve_memory(user_input), knowledge_refs=retrieve_knowledge(user_input), recent_chat=memory.history[-5:], current_input=user_input ) messages = assemble_messages(pkg) response = call_llm(messages) memory.add_turn(user_input, response) update_task_state(memory.state, response) return response执行链路看起来很简单,但隐藏了两个重要设计:检索发生在组装前,且检索 query 用的是当前用户输入加上任务目标,而不仅仅是当前输入。因为 Agent 场景里,用户可能只说“继续吧”,这时必须参考当前任务状态才知道该检索什么,这是很多人忽略的点。
4. 我踩过的坑:上下文工程的常见翻车现场
4.1 上下文越用越长,成本直接失控
项目初期,我图省事,把所有对话历史都带进上下文,最多跑到几十轮,费用翻了快三倍,效果反而变差,因为模型把前面的错误或过时状态当成了新信息。后来做裁剪发现,很多历史细节根本无用,比如用户中途问了一句“现在几点”,这条信息几轮后毫无价值,但它的 token 一直被重复计费。
排查思路:
- 先记录每一轮调用时的 prompt token 消耗,看平均和峰值;
- 再对比不同轮数上限下,用户可感知的正确率;
- 最终选一个性价比最高的轮数。建议从 10 轮起步,效果不足再加到 20 轮,但不要无上限。
4.2 关键信息被历史信息淹没
用户中途补充了关键约束,比如“记住,我不用某某 API”,结果后面 Agent 还在调用那个 API。原因很可能是这条约束藏在了第 15 轮,模型注意力没抓住。解决办法是我上面说的状态结构体:把关键约束抽取到任务状态里,而不是留在对话原文中。每轮结束后,用模型把对话里的新约束、变更点抽出来,覆盖/合并进状态字段。这样不管用户什么时候说的,模型每次调用都能在“当前任务状态”这个前瞻位置看到。
4.3 摘要压缩导致信息失真
自动摘要早期版本很容易丢决策细节,尤其当原始对话是“用户要求 A,后来改成 B”时,摘要模型只保留“用户提出要求 B”,丢掉了“为什么不选 A 的原因”,结果 Agent 在后续步骤里出现逻辑跳变。我的做法是:摘要模板里强制包含“变更原因”字段;同时摘要不是覆盖式追加,而是每次生成新的摘要时,带上旧摘要和最新批次,保证信息连贯性。
4.4 模型分不清知识库和用户输入
另一个高频坑是知识库检索内容和用户输入混在同一个 user 消息里,模型会尝试把知识库内容当成用户的提问来回复。解决方式就是上面提到的:所有参考资料用 system role 注入并打标签。实测最明显的一点是,模型会更少把知识库内容原样输出来,而是转化为自己的行为依据。
4.5 上下文注入顺序不稳定,同一 Prompt 结果波动大
如果你在快速迭代过程中发现 Agent 表现不稳定,先别急着怀疑模型随机性,检查一下上下文组装是否保持固定顺序。特别要注意:工具调用结果必须紧跟触发它的那轮对话之后,不能统一堆到末尾,否则模型“记不清”哪个结果对应哪次调用。封装好组装器后,任何一次上下文变更都应该是 schema 层面的变更,而不是手写字符串。
| 问题现象 | 可能原因 | 排查步骤 | 解决方案 |
|---|---|---|---|
| 成本随轮数暴涨 | 全量历史进窗 | 统计 token 消耗曲线 | 窗口裁剪 + 摘要压缩 |
| 关键约束被忽略 | 约束埋藏在历史中 | 输出中间环节状态字段 | 结构化抽取约束到任务状态 |
| 摘要丢决策原因 | 摘要模板太简单 | 对比摘要前后决策点 | 强制提取变更原因字段 |
| 知识库内容被当提问 | 参考资料和 user 消息混用 | 检查 messages 消息角色 | 参考资料用 system + 标签注入 |
| 工具调用对不上结果 | 结果堆积末尾 | 检查工具结果位置 | 工具结果紧跟触发轮次 |
5. 一些值得长期坚持的细节习惯
上下文工程没有一劳永逸的方案,它需要跟着任务复杂度、模型能力持续调整。我个人的习惯是:每一个 Agent 项目都保留一个上下文调试面板,能实时看到本次调用 messages 里每个 block 的 token 占比、来源标签、位置顺序。出了问题先看面板,而不是改 Prompt。只要能实时看见信息流,绝大多数上下文问题都能快速定位。
另一个小技巧是给上下文版本化。每次调整组装逻辑、摘要模板或状态 Schema,都像发版一样记录一个上下文配置版本。不同版本放在同一个 Agent 背后做 AB 测试,用用户反馈或任务成功率评估。这一套做下来,上下文工程会从“碰运气调参”变成“可迭代的产品模块”。
踩过几次坑之后我的感觉是,Prompt 是入场券,上下文工程才是 Agent 真正的竞技场。前者决定了模型“能不能听懂一句话”,后者决定了模型“在复杂的真实任务里能不能持续正确行动”。如果你现在还在反复调 Prompt 但效果没有质变,建议把一半精力挪过来审查上下文体系,大概率能找到真正的瓶颈。