上下文工程,不是把更多东西塞给模型

上下文窗口只规定模型最多能看到多少内容。真正的上下文工程,要处理材料的选择、压缩、排序、隔离和淘汰,还要给长任务留下可以继续工作的状态。

一个大模型功能很少在第一天就被上下文拖垮。

第一版通常很清爽:一段 system prompt,一个用户问题,一次模型调用。后来答错了一次,就补一份说明;不知道业务资料,就接上 RAG;需要查实时状态,再加几个 Tool;为了让它记得前文,又把历史消息和任务日志一起带上。

每一项单独看都合理。几个月后再抓一次请求,里面可能已经有规则、文档、搜索结果、工具返回、对话历史、用户偏好、失败记录和一段旧摘要。模型像坐在一张堆满文件的办公桌前。资料都在手边,真正要用的那页反而找不到了。

这时再说“模型上下文窗口很大,塞得下”,往往已经答错了问题。

散落的资料经过选择、压缩和排序,组成一轮模型真正使用的工作上下文

English version: Context Engineering Is Not About Adding More Context

《从一次模型调用到一个 Agent》介绍过 Prompt、Context、RAG、Memory 和 Agent 的关系。这篇往下走一步,只讨论一个更具体的问题:一轮模型调用之前,应用到底该把什么放进去,又该把什么留在外面。

窗口是容量,不是质量

上下文窗口(Context Window)规定一次推理最多能容纳多少 token。它像房间的面积,决定能搬进多少东西,却不保证东西摆得好,也不保证模型会看对地方。

长上下文至少会带来四类麻烦。

第一类很直接:输入越长,传输、预填充和推理通常越慢,费用也更高。Agent 每走一步都把前面的消息重新提交,单轮多出来的一点材料,会在后面的调用里反复付费。

第二类是干扰。十份检索结果里只有两份相关,其余八份不是免费的陪衬,它们会和正确证据争夺注意力。过期规则、重复摘要和互相矛盾的记录尤其麻烦,模型往往会挑一个读起来顺的说法,而不是系统最希望它信的那一个。

第三类是位置。Lost in the Middle一类研究指出,模型对长输入中不同位置的信息利用并不均匀。关键证据确实在上下文里,不代表它能被同样可靠地使用。把重要约束埋在中间,再用一句“请严格遵守以上要求”收尾,只能算一种愿望。

第四类更容易被忽略:窗口还要容纳输出。输入把预算吃满以后,模型没有足够空间写答案、代码或结构化结果。有些接口会直接报错,有些运行时会从历史消息里截断一段。截的是闲话还是关键规则,要看运气。

所以,长窗口解决的是“放不下”,上下文工程处理的是“该放什么、怎样摆、何时换”。

一轮调用里,究竟装了什么

Context 不只是用户输入的那句话。对一个带工具的应用来说,一轮调用常常包含这些部分:

  • 系统指令与安全规则
  • 当前任务和用户刚刚补充的要求
  • 从文档、数据库或搜索引擎取回的证据
  • Tool 的返回结果
  • 最近几轮对话与任务状态
  • 少量长期记忆
  • 输出格式示例或 schema

最后还要预留输出空间。

上下文窗口被分成规则、当前任务、证据、工具结果、近期状态与输出预留

这些材料的地位并不相同。系统规则通常稳定,当前任务必须完整,检索证据可以按相关性筛选,工具日志往往只需保留结论,历史对话则会随着任务推进逐步失效。把它们拼成一个字符串当然能跑,只是出了问题很难知道该删谁。

更实用的做法是先分区,再给每个分区定预算和淘汰规则。预算不必一开始就精确到 token,可以先按字符数或消息条数做硬边界;但“所有材料共享一个总上限,超了就从最早消息开始砍”通常不够。最早那条消息里,可能正好放着任务目标。

我更习惯先为输出留位置,再安排输入。规则和当前任务占固定区,证据与工具结果竞争弹性区,历史状态只保留继续工作需要的部分。这个顺序有点保守,却比模型写到 JSON 一半突然收工好得多。

上下文工程在做五件事

上下文工程不是一个新的 Prompt 技巧合集。Prompt 主要解决指令怎样写清楚;上下文工程面对的是材料从哪里来、怎样进入一轮调用,以及任务变长以后如何退出。

落到代码里,大致是五个动作。

选择。 先根据当前问题找候选材料,再判断哪些值得进入工作集。用户问订单物流,不需要顺便附上退款制度全文;排查数据库超时,也不必把所有前端日志一起搬来。

压缩。 原始材料过长时,保留能支持判断的事实、来源和时间。压缩不是把十段文字改写成一段漂亮的废话。错误码、金额、版本号、文件路径这类信息一旦丢失,后面很难凭摘要找回来。

排序。 稳定规则放在稳定位置,当前任务明确标出,关键证据靠近需要使用它的地方。多份材料有时间或权威性差异时,顺序和标签都要说清楚,不能让模型自己猜哪一份更新。

隔离。 不同子任务使用不同工作集。查资料的模型不一定需要写作风格说明,负责生成最终答复的模型也不必看到工具调试日志。隔离能减少干扰,也能缩小敏感数据暴露的范围。

删除。 过期计划、重复 observation、已经被新结论替代的摘要,应当主动退出上下文。删除不是异常兜底,而是长任务的正常动作。只进不出的 Memory,最后只是另一种日志仓库。

这五件事没有哪个听起来像魔法。真正麻烦的是,它们不能只在 prompt 模板里完成,还需要检索、状态存储、预算计算和运行时一起配合。

RAG、Memory 和 Tool Result 各有去处

几种常见材料容易混在一起,处理方式却不该一样。

来源 适合放什么 进入 Context 的方式 常见淘汰条件
Prompt / Instructions 目标、规则、输出格式 每轮稳定保留,必要时分层 任务结束或规则更新
RAG 与当前问题相关的外部事实 按查询取回、去重并标注来源 问题变化、证据过期或相关性下降
Memory 用户偏好、长期事实、历史结论 先检索,再选少量加入 被更新、失去可信度或不再相关
Tool Result 当前步骤刚取得的观察 保留原始关键字段,旧结果逐步压缩 已提取结论、被新结果替代
Runtime State 步数、阶段、失败次数、待办项 用紧凑结构表达 任务完成或进入新阶段

RAG 的关键不只是“搜到几段”,而是搜什么、取几条、怎样去重,最后有没有把来源和时效带回来。Memory 也不是把所有历史聊天永久保存;保存只是入库,重新拿回当前任务才算使用。

Tool Result 往往最容易失控。日志接口一次返回几百行,模型读完又调用第二个工具,运行时把两份原文连同解释全部追加到消息里。三四轮以后,真正的任务目标只占上下文的一小角。比较稳妥的做法是保留最近 observation 的原文,同时把较早结果压成带证据指针的阶段摘要。

长任务从第二次调用开始变难

一次调用的上下文可以手工拼出来。到了第二次,问题才真正出现:上一轮输出留多少,Tool Result 是否原样带回,失败尝试要不要保留,新证据和旧结论冲突时怎样处理。

假设每轮都新增一段长度相近的 observation,而且下一轮把全部历史重新发送。第 10 轮付费的不是第 10 段,而是前 10 段的总和。整个任务的累计输入会近似按平方增长。循环没有边界时,窗口、延迟和账单会一起变胖。

因此,长任务需要的不是一条不断加长的聊天记录,而是一套上下文生命周期。

长任务在检索、行动、观察、压缩、检查点和继续执行之间循环

最近一两步可以保留细节,较早步骤压成 checkpoint。Checkpoint 至少要回答:目标是什么,已经确认了哪些事实,做过哪些动作,哪些尝试失败了,下一步还缺什么。它应当能让任务从这里恢复,而不是只写一句“前面进展顺利”。

子任务完成后,它的过程材料可以退出主上下文,只把结论、证据和未决问题交回来。遇到新的阶段,再重新检索需要的资料。这样做看似多了几次装配,实际上让每轮调用有了清楚的工作台。

一轮 Context 可以怎样装配

实现不必从复杂框架开始。先让“收集材料”和“调用模型”变成两个明确步骤,很多问题已经会暴露出来。

const candidates = await collectCandidates(task, state)
const evidence = selectAndRank(candidates, task)
const recent = keepRecentObservations(state.events, 2)
const checkpoint = compactOlderEvents(state.events)

const context = assemble({
  instructions: stableInstructions,
  task: normalizeTask(task),
  evidence: fitToBudget(evidence, budgets.evidence),
  recent,
  checkpoint,
  outputSchema,
  reserveForOutput: budgets.output,
})

const result = await model.generate(context)
await persistResultAndState(result, state)

这里最值得留下的不是函数名,而是边界:候选材料不等于最终 Context;近期 observation 和历史状态使用不同保留策略;输出预算在调用前就参与装配;模型返回以后,结果和状态要保存到 Context 之外。

再往前走,可以给每段材料附上 sourcetimestamppriorityttlsensitivity。它来自哪里、什么时候产生、多久过期、能不能发送给当前模型,都不该只藏在正文里。

怎么知道上下文改对了

“Token 变少了”是一个指标,不是最后答案。删得太狠,模型会更快地答错。

我会同时看三组数据。

第一组是成本与延迟:每轮输入 token、整个任务累计 token、首 token 时间、总耗时,以及 p50 和 p95。平均值很容易把少数特别肥的任务藏起来。

第二组是任务质量:固定一批真实问题,检查答案是否引用了正确证据,结构化输出能否通过校验,任务完成率和人工采纳率有没有变化。对于 Tool,还要看模型取了哪些资料,而不只是最终答案读起来顺不顺。

第三组是运行过程:检索了多少候选、最终选入多少、截断发生在哪个分区、摘要被复用了几轮、任务为什么停止。没有这些记录,Context 出错时只剩一句“模型不稳定”,这句话通常什么也修不了。

评估集不必很大,先收集二三十个真正出过问题的任务就有价值。重要的是固定输入、期望证据和通过条件。否则每换一个模型或策略,都只能靠聊天窗口里的几次试用下结论。

几个常见误区

最常见的是把“模型可能用得上”当成“每轮都要带上”。可能有用的材料应该留在可检索的存储里,只有当前问题需要时才进入 Context。

第二个误区是过早总结。为了省 token,系统先把原文压成没有出处的概括,后面再也找不回数字和细节。摘要应该带来源指针;高风险事实最好保留原文片段,必要时允许模型重新读取。

第三个误区是给每条材料同样的信任。用户刚提供的信息、数据库实时查询、三个月前的聊天摘要和模型自己上轮的猜测,不该排成同一级。来源、时效和置信度必须显式存在。

还有一种做法是只在超限时报错,然后粗暴截掉最早消息。它能让接口继续返回 200,却可能悄悄删掉目标和约束。真正有用的降级策略应该按分区执行:先去重,再删低相关证据,再压缩旧 observation,最后才考虑缩减稳定指令。

我在一个长任务里踩过的坑

我做的小说应用 Novevia 有一个“整理全书”功能。早期版本把作品设定、章节卡片、事实和记忆尽量压缩后,一次性发给模型。书越长,第一个请求越大,两次模型调用曾跑到八分多钟。

后来我没有继续研究怎样写一个更厉害的大 Prompt,而是改变了 Context 的装配方式。第一轮只给作品 manifest,模型缺什么再查章节、路线或事实;每次请求设独立预算,过长时保留规则、manifest 和近期 Tool Result;任务状态留在数据库,模型只看到继续判断所需的部分。

它也因此形成了一个有限的 ReAct-style Loop,但 ReAct 不是解决超时的咒语。真正起作用的是工作集变小了,检索有了时机,历史开始淘汰,循环也知道何时停。

《AI 整理全书为什么不能只靠一个大 Prompt》记录了这段实现,包括只读工具、步数限制、校验、恢复和回滚。那是一个具体项目;放到别的大模型应用里,资料名称会变,装配问题不会消失。

上下文不能太穷,缺了证据,模型只能猜。也不能把仓库搬进窗口,再期待模型自己整理货架。

比较实际的标准只有一个:每轮调用都让模型看到完成当前一步所需的材料,同时没有多到妨碍它工作。做到这一点,才算真正开始做上下文工程。

正在加载讨论...