做 AI 应用时,为什么上下文管理比模型选型更容易决定成败

去年做一个 AI 写作辅助功能的时候,我们前后换了两次模型。第一次是因为结果不稳,第二次是因为延迟偏高。换完以后效果确实有变化,但那种“有时很好,有时很飘”的感觉一直没有消失。

最开始团队里很自然地把问题归到模型头上。毕竟结果是模型吐出来的,出问题先怀疑模型很正常。可后来排查得越多,我越觉得另一个东西更像根源:上下文。

那个功能的目标并不复杂,用户给一个主题,系统结合历史记录、模板偏好和参考资料,帮他起草一段内容。听上去很像是一个“选模型 + 写 prompt”的问题,实际做下来,最难控的反而是给模型喂什么。

一次线上回放,把问题从模型身上挪开了

那次用户问的是一个非常具体的问题,理论上系统应该直接围绕当前主题写。结果模型先引用了上一次会话里的偏好,又混进了一段不相干的参考资料,最后写出来的内容语气对、格式也对,但方向完全偏了。

如果只看最终结果,你会觉得是模型在胡说。可把整条输入链路拆开看,其实它只是很认真地在错误材料上做推断。

那次排查我做了一件后来一直保留的事:把发给模型的完整 messages 数组原样落到日志里,连同最终 prompt 的 token 数一起打出来。结果一眼就看明白了——那条请求里塞了快 6000 token,真正属于“当前主题”的内容只有不到 400,剩下全是历史会话和检索资料。模型不是不听话,是我们让它在一堆背景噪声里去找那 400 token 的重点。在那之前我们连“一次请求到底喂了多少东西进去”都说不清楚,全靠感觉调 prompt,调好调坏都不知道为什么。

模型根本不“知道自己在哪个产品里”,它只知道当前这坨上下文长什么样。

你给它干净输入,它就大概率干净输出。

你给它混乱输入,它再强也很难一直稳。

上下文管理最容易出问题的地方是乱

最开始我们总怕模型信息不够,于是策略非常朴素:尽量多给。

历史对话带上。 用户偏好带上。 参考资料多拿几段。 系统规则写长一点。 模板说明也顺手塞进去。

表面上看很周全,实际效果却越来越不稳定。拆开来看,这种“尽量多给”的思路有两个明显问题。

第一,重要信息比例被稀释了。

用户当前真正的问题,可能只占整个上下文的一小截。模型当然能看到它,但也同样看到了很多次要甚至过期的信息。

第二,旧信息会持续污染新任务。

一旦历史里有过错误判断、过时意图或者已经无效的中间结论,它们就可能继续影响当前回答。你以为自己是在做“多轮记忆”,实际上是在把噪声不断续上去。

我后来还观察到一个更隐蔽的现象:信息一多,模型对系统规则的遵守度反而会下降。同样一句“输出必须是 JSON”,放在一个干净的短上下文里,几乎不会出错;可一旦前面堆了几千 token 的历史和资料,它偶尔就会在 JSON 外面多一句寒暄,或者把格式带跑偏。这背后有个说法叫 lost in the middle——放在上下文中间那一大段的内容,模型的注意力本来就最弱。你“尽量多给”的那些料,恰恰大量落在它最容易忽略的位置。这也是为什么我现在宁可把关键约束放在最前或最后,也不愿让它淹在中间。

后来我们做的第一件事,是把信息拆成层级

那次改完之后,我们不再把所有信息简单拼成长文本,而是先把上下文按层次拆开。

我现在做 AI 应用时,基本都会先把输入分成这几层:

  1. 系统规则
  2. 当前任务目标
  3. 用户本轮输入
  4. 历史记忆
  5. 外部检索或工具结果

这几层最重要的价值,不是格式好看,而是你终于能问自己一句:这个信息到底为什么出现在这里?

比如:

  • 系统规则是长期不变的规矩,不该混进具体任务噪声
  • 当前任务目标应该比历史会话优先级更高
  • 历史记忆只保留必要背景,不该变成聊天记录堆积
  • 检索结果必须为当前问题服务,而不是“顺手都带上”

一旦这么拆,很多以前糊在一起的问题会立刻显形。

后来我们甚至把上下文拼装写成了一个很朴素的结构,而不是在业务代码里到处拼字符串:

1function buildContext({ task, userInput, memory, retrievedDocs }) {
2  return [
3    {
4      role: "system",
5      content: "你是写作助手,必须优先完成当前任务。"
6    },
7    {
8      role: "user",
9      content: `当前任务:${task}\n\n用户输入:${userInput}`
10    },
11    {
12      role: "user",
13      content: `长期记忆:\n${memory.join("\n") || "无"}`
14    },
15    {
16      role: "user",
17      content: `参考资料:\n${retrievedDocs.map((doc) => doc.summary).join("\n\n")}`
18    }
19  ];
20}

这段代码不复杂,但它让上下文从“一个长字符串”变成了“可检查的数据结构”。后面要做日志、回放、裁剪、排序和 A/B 测试都会方便很多。

这里有个实践上的小决定值得说一下:长期记忆和参考资料,到底是塞进 system 里,还是单独发一条 user 消息?我们两种都试过。早期图省事,把记忆和资料一股脑拼进 system prompt,结果发现一旦内容长,模型对系统里那几条硬规则的执行就开始打折,规则和资料糊在一起,它分不清哪句是“必须遵守的硬规则”,哪句只是“可以参考的背景”。后来改成 system 只放稳定不变的规则和角色设定,把会随每轮变化的记忆、资料拆成独立的 user 消息,硬规则的执行力明显回来了。一个朴素的原则是:system 放“规则”,user 放“数据”,不要让规则和数据混在同一段里。

后来我又补了一个细节:上下文里的每一块信息最好有来源、优先级和有效期。

1const contextItems = [
2  {
3    type: "task",
4    content: task,
5    source: "current_request",
6    priority: 100,
7  },
8  {
9    type: "memory",
10    content: "用户偏好中文输出",
11    source: "user_profile",
12    priority: 60,
13    expiresAt: null,
14  },
15  {
16    type: "retrieved_doc",
17    content: doc.summary,
18    source: doc.url,
19    priority: doc.score,
20    expiresAt: doc.expiredAt,
21  },
22];

这不是为了把代码写复杂,而是为了后面能解释:某段内容为什么会进入 Prompt,它从哪里来,应该排在什么位置,什么时候应该被丢掉。

AI 应用出问题时,很多排查都卡在这里。你只看到最终 Prompt 是一大段文本,却不知道里面哪句话来自用户本轮输入,哪句话来自长期记忆,哪句话是检索系统带进来的旧资料。没有来源信息,上下文就很难治理。

有了 priority 和来源之后,拼装这一步就能写成一个可控的流程,而不是硬编码的字符串拼接。我们后来的组装器大概是这个样子:先按优先级排序,再带着 token 预算从高到低往里塞,塞不下的低优先级内容直接丢掉,并且把丢掉了什么记进日志。

1function assembleContext(items, tokenBudget) {
2  const sorted = [...items].sort((a, b) => b.priority - a.priority);
3  const picked = [];
4  const dropped = [];
5  let used = 0;
6
7  for (const item of sorted) {
8    if (item.expiresAt && item.expiresAt < Date.now()) {
9      dropped.push({ ...item, reason: "expired" });
10      continue;
11    }
12    const cost = estimateTokens(item.content);
13    if (used + cost > tokenBudget) {
14      dropped.push({ ...item, reason: "over_budget" });
15      continue;
16    }
17    picked.push(item);
18    used += cost;
19  }
20
21  return { picked, dropped, used };
22}

estimateTokens 不用太精确,早期我直接拿字符数除以一个经验系数(中文大概 1.5 到 2 个字符一个 token,英文 4 个字符左右)就够用,等到要卡成本了再换成真正的 tokenizer。关键不是估得多准,而是“裁剪”这件事变成了显式逻辑:哪段进了、哪段因为预算被砍了、哪段因为过期被砍了,全都落在 dropped 里。后来线上出现输出变差,我第一反应就是去翻这次请求的 dropped,十有八九是某段该进的内容被低优先级的资料挤掉了。

历史记忆不是越长越好,这一点我后来感受很深

做对话型 AI 功能时,最容易陷进去的就是“记忆越多越聪明”。

实际并不是这样。

我们后面专门抽了一批失败样本看,发现最容易出问题的往往不是信息太少,而是带着一堆不该留下来的历史过程:

  • 上一轮试错里的错误结论
  • 已经被用户否定的方向
  • 某次临时偏好
  • 中间工具返回的半成品数据

这些东西只要留在上下文里,模型就有可能继续把它们当真。

后来我们把历史记忆改成“只保留长期偏好和关键事实”,效果反而稳了很多。比如保留:

  • 用户偏好中文输出
  • 用户正在做 React 项目
  • 当前会话在写技术博客

而不是把整段过程都背下去。

我现在会把记忆分成三种:

  • 长期偏好:语言、语气、常用框架、输出习惯
  • 当前会话状态:这次正在做什么、已经确认了什么
  • 临时过程信息:工具中间结果、试错方向、被否定的方案

真正值得长期保留的通常只有第一类,第二类只在当前会话里有效,第三类大多数时候不应该进入长期记忆。尤其是“被用户否定的方案”,如果不清理,后面很容易反复出现,用户会觉得系统不长记性。

一个简单的记忆写入规则可以是:

1只有满足以下条件才写入长期记忆:
21. 用户明确表达长期偏好
32. 该信息未来多个任务都会用到
43. 不包含临时判断、敏感信息或错误结论

记忆系统更值钱的能力,是判断什么不该记。

实现上还有个坑我踩过:记忆写入如果完全交给模型自己判断“这条要不要记”,它会过度热情,什么都想存。我们最早让模型在每轮结束后自己抽取要记的事实,结果记忆表很快就被各种一次性的东西灌满了——“用户这次想写 React”“用户提到了周五要交稿”,过两天全是垃圾。后来改成两道关卡:模型只负责“提名”候选事实,真正写不写库,由一段确定的规则代码来卡,比如必须命中“偏好/长期事实”这类预设类型,且不能和已有记忆冲突。这一改,记忆表干净多了。还有一点,记忆别只进不出,得有衰减或复核机制,比如长期没被任何任务用到的记忆,定期降权甚至清掉,不然时间一长还是会积成另一种噪声。

上下文压缩不是简单改写

上下文窗口变大之后,很多人会下意识觉得“能塞就塞”。但真实项目里,成本、延迟和注意力分散仍然存在。于是压缩上下文会变成一个绕不开的问题。

我一开始以为压缩就是摘要,后来发现不够。

比如一段历史对话可以被压成:

1用户想写一篇 React 性能优化文章。

这句话太粗了。它丢掉了很多关键约束:用户是不是已经否定过某个方向,是否要求偏个人经验,是否提过不要写成教程,是否确认过目标读者。

更好的压缩应该保留决策和约束:

1{
2  "goal": "改写 React 性能优化文章",
3  "confirmedConstraints": [
4    "保留原文主旨",
5    "增加个人项目复盘",
6    "不要写成纯教程"
7  ],
8  "rejectedDirections": [
9    "不要泛泛介绍 memo 和 useMemo"
10  ],
11  "sourceMessageIds": ["msg_12", "msg_15", "msg_18"]
12}

压缩后的上下文要能继续服务当前任务,也要能追溯来源。否则压缩会变成另一种污染:看起来更短了,但模型拿到的是模糊甚至错误的摘要。

检索上下文也一样,重点在贴题,不在数量

RAG 场景下这个问题会更明显。

以前我们的做法是召回多一点,觉得总比漏掉好。结果经常出现一种情况:模型拿到了十段资料,里面只有两段真有用,剩下八段只是“看着也有点关系”。这些噪声不会直接报错,但会把注意力拉散。

后来我越来越认可一个更朴素的判断标准:

  • 这段内容是不是和当前问题强相关
  • 它能不能减少模型猜测

如果答案都是否定的,那它就不该进上下文。

这也是我现在看检索系统时最在意的地方。它不是负责“多给内容”,而是负责“别把不该给的内容也塞进去”。

一个实用做法是给检索结果加“进入上下文前的门槛”。比如先按相关性召回 20 条,再通过重排或规则筛到 3-5 条,最后只把摘要和必要引用放进 Prompt。

1检索结果进入上下文前需要满足:
2- 和当前问题直接相关
3- 来源可信或可追溯
4- 内容能补充事实,而不是重复用户问题
5- 时间上没有明显过期

尤其是技术类问答,时间很重要。2025 年很多框架、模型、工具链变化都很快,如果把 2021 年的旧资料直接塞给模型,它很可能输出“语气很确定但内容已经过时”的答案。

工具调用结果如果不清洗,也是在制造噪声

另一个很容易被低估的点,是工具结果。

很多 Agent 类应用会把工具输出原样拼回模型,图省事,但这一步经常会埋雷。

例如一个搜索工具可能返回:

  • 大量无关字段
  • 多个候选结果
  • 冗长元数据
  • 不统一的格式

模型当然可以继续读,但这等于又把一轮筛选工作丢给它了。这样不只是浪费上下文窗口,更会让后续推理开始飘。

我们后来处理工具结果时,会先做一轮清洗:

  • 只保留关键字段
  • 统一格式
  • 必要时做小摘要

这一步做完之后,模型输出明显稳了一截。不是因为它突然更聪明了,而是因为前面的输入终于更像人给它整理过的材料。

我会把工具结果清洗成类似这样的结构:

1{
2  "tool": "searchDocs",
3  "status": "success",
4  "items": [
5    {
6      "title": "React useContext reference",
7      "summary": "Context 更新会让读取该 Context 的组件重新渲染。",
8      "url": "https://react.dev/reference/react/useContext"
9    }
10  ]
11}

如果工具失败,也不要把原始异常堆给模型,而是统一成可理解的错误:

1{
2  "tool": "searchDocs",
3  "status": "error",
4  "errorCode": "TIMEOUT",
5  "message": "检索超时,当前没有可用参考资料"
6}

这样模型至少知道当前资料不可用,而不是在一堆 stack trace、状态码和 HTML 片段里猜发生了什么。工具结果也是上下文的一部分,必须有统一错误处理和格式约束。

还有个容易被忽略的点:工具返回的体量得设上限。我见过一个分页接口一次返回上百条记录,原样拼回去直接把上下文撑爆,后面几轮对话全在为这一坨数据买单。我们现在的规矩是工具层就做截断,比如列表类结果最多回 5 到 10 条,并明确告诉模型“还有更多,可再次调用并翻页”,把“要不要继续取”的决定权留给模型,而不是一次性把全量数据砸进上下文。多轮 Agent 里,工具结果是会累积的,每一轮不清洗,到第五轮第六轮上下文就已经被历史工具输出占满了。

我现在看 AI 应用,越来越像在看一个信息编排系统

以前我会更多地把注意力放在模型本身:哪个版本更强,哪个延迟更低,哪个价格更合适。

现在还是会看这些,但我更先问的是:

  • 当前任务到底需要哪些信息
  • 这些信息谁先谁后
  • 哪些该长期保留,哪些只对本轮有效
  • 哪些内容可能污染后续回答

如果这些问题没想清楚,换再好的模型也很难彻底解决“有时很好,有时很怪”的波动。

上下文还要有预算意识。上下文窗口变大确实缓解了很多问题,但窗口越大,不代表越应该塞满。长上下文会带来成本、延迟和注意力分散,工程上仍然要做取舍。

我现在会给每层上下文一个大概预算:

1系统规则:尽量短,只放长期不变的规则
2当前任务:必须完整,优先级最高
3历史记忆:只放和当前任务有关的事实
4检索资料:少而准,保留来源
5工具结果:清洗后放关键字段

预算最好也进入日志。比如一次请求里系统规则用了多少 token,历史记忆用了多少,检索资料用了多少,最终被裁掉的是哪一段。这样当输出变差时,团队才知道是模型问题、检索问题,还是上下文预算被某一层挤占了。

这和写文章有点像。材料很多不等于文章好,关键是结构和取舍。AI 应用也是一样,真正影响质量的经常不是“有没有信息”,而是“信息以什么顺序、什么密度、什么可信度出现”。

上下文的拼装顺序,还会直接影响你的成本

有一件事是我做成本优化时才真正重视起来的:上下文里各块内容的“稳定性”,决定了 prompt 缓存能不能命中。

现在主流模型都支持 prompt caching——前缀只要和上一次请求完全一致,这部分就能走缓存,又快又便宜。但它的前提是“前缀逐字节相同”,只要前面有一个字符变了,后面整段缓存全部失效。我们早期没注意,把每轮都在变的当前任务和用户输入放在最前面,稳定不变的系统规则和长期记忆反而排在后面,结果缓存几乎从来没命中过。

后来把顺序调过来:最稳定的放最前面(系统规则、长期不变的角色设定、相对固定的长期记忆),把每轮都在变的当前任务、用户输入、检索结果挪到后面。改完之后,那段长系统 prompt 几乎每次都能命中缓存,平均延迟和单次成本都掉了一截。

1缓存友好的排列(从前到后):
21. 系统规则 / 角色设定      —— 几乎不变,放最前
32. 长期偏好记忆            —— 变化很慢
43. 当前会话状态           —— 每会话变
54. 当前任务 / 用户本轮输入  —— 每轮都变
65. 检索资料 / 工具结果      —— 每轮都变,放最后

这条原则和前面“按优先级排序”有时会打架——优先级高的当前任务你想往前放,但缓存又希望它往后放。我的取舍是:把“喂给模型时的呈现顺序”和“拼装时的优先级裁剪逻辑”分开看。裁剪用 priority 决定谁进谁出,进了之后的物理排列再按缓存友好来摆。两件事不必用同一个顺序。

延伸阅读

推荐看 OpenAI Cookbook 关于 RAG、tool calling 和 evals 的文章,Anthropic 关于 long context 和 prompt caching 的实践内容,以及 LangChain、LlamaIndex 文档里关于 memory、retriever、reranker 的章节。阅读这些资料时不必急着套框架,更值得学习的是它们如何把上下文拆成可管理的工程组件。

回头看这大半年,模型换了两次,真正让系统稳下来的那次改动,其实是拆信息层级、加优先级和预算这一步,跟换模型没什么关系。

很多所谓“模型不稳定”的问题,最后都能追溯到输入组织得不够干净。谁能把对的内容、按对的层级、在对的时机交给模型,谁的系统通常就更稳。模型选型会变,上下文怎么组织、怎么裁剪、怎么排列,才是接下来还要长期打磨的事。