Prompt Engineering 关心「话怎么说」,Context Engineering 关心「给它看什么」。前者影响措辞,后者决定判断依据是否齐全。而在有成本约束的系统里,上下文还是一项必须做预算的资源——什么常驻、什么按需、什么必须压缩,这三件事构成效果与成本的分水岭。
编辑部注 · 本章是全站篇幅最长的一章,因为它同时影响效果上限与成本下限。建议读完后来回看两遍图 1。
一个被反复验证的现象:把提示词从「请仔细分析」改成更花哨的措辞,效果几乎不动;而把该给的数据补齐、把无关的噪音删掉,效果会明显变化。原因很朴素——模型只能基于看到的信息做判断,你不知道的信息它也不知道。
一、上下文的四种成分
把一次请求的上下文拆开,实际上只有四类内容。分清这四类,上下文工程(Context Engineering) 的 上下文预算(Context Budget) 该给谁就有答案了——而真实判断依据的占比,就是 信噪比(Signal-to-Noise Ratio):
| 成分 | 内容 | 变化频率 | 建议策略 |
|---|---|---|---|
| 指令 | 角色设定、行为约束、输出格式要求 | 几乎不变 | 常驻头部,保持字节稳定(前缀缓存友好) |
| 能力声明 | 工具定义、可用数据源说明 | 很少变 | 常驻,但要精简——工具描述本身占掉的 token 常被低估 |
| 知识 | 检索到的资料、知识库片段、历史结论 | 每轮可能变 | 按需注入,必须带来源与相关性标注 |
| 状态 | 已完成的步骤、当前进度、中间产物、错误记录 | 每轮都变 | 外部化 + 摘要,不要全量堆在上下文里 |
一个反直觉的观察:真正吃掉上下文的大头通常是「状态」而不是「知识」。检索来的资料一般几千 token,而十几轮之后的对话历史 + 工具返回结果可以轻松到几十万 token。
二、顺序会影响成本:前缀稳定性
在第 5 章(KV Cache)里我们已经知道了规则,这里给出它在 Harness 里的落地版排序原则——排序的核心目的之一是让 前缀缓存(Prefix Cache) 命中率最大化:
| 位置 | 放什么 | 为什么放这里 |
|---|---|---|
| ① 最前 | 系统指令、行为约束 | 最稳定,几乎永不变化,缓存命中率最高 |
| ② | 工具定义(顺序固定) | 变化少;必须保证序列化后字节稳定 |
| ③ | 长期知识 / 知识库摘要 | 更新频率低(天级即可) |
| ④ | 本任务的目标与约束 | 一轮内不变 |
| ⑤ | 已完成步骤的结构化摘要 | 每轮会变,但内容被压缩,增长可控 |
| ⑥ 最后 | 本轮新信息(用户输入、最新工具结果) | 变化最频繁,放尾部让前面全部命中 |
把变化频率当成排序键:变化越少越靠前,变化越多越靠后。
这条规则同时满足两个目标——前缀缓存命中率最大化(省成本),以及「离问题近的信息更被关注」(提效果)。当你不确定某段内容该放哪时,问自己是「每轮都变」还是「几乎不变」就够了。
三、给知识加来源标注
检索到的内容如果不带来源,模型无从判断可信度,也无法在回答里给出可验证的引用。最小可行的做法是给每段知识打三个标签,也就是按 知识层级(Knowledge Tier) 标注它是事实、推断还是观点:
from dataclasses import dataclass
import json, time
@dataclass
class KnowledgeChunk:
source: str # 来源标识(可点开的链接或文件路径)
updated_at: str # 更新时间:模型据此判断时效
tier: str # fact(可溯源事实)| inference(推断)| opinion(观点)
text: str
score: float = 0.0 # 相关性得分,用于排序与截断
def render_knowledge(chunks: list[KnowledgeChunk], budget_chars: int = 6000) -> str:
"""
按相关性排序、按预算截断,并且**每一条都带来源与层级**。
来源不是装饰,它是模型判断「该不该相信」的依据。
"""
if not chunks:
return "(本次未检索到相关资料。若信息不足,请明确说明而不是猜测。)"
ordered = sorted(chunks, key=lambda c: -c.score)
lines, used = [], 0
for c in ordered:
block = (f"[{c.tier}] {c.text.strip()}\n"
f" 来源:{c.source} 更新时间:{c.updated_at}")
if used + len(block) > budget_chars:
break # 硬性截断,防单条超长资料挤爆预算
lines.append(block)
used += len(block)
dropped = len(ordered) - len(lines)
tail = f"\n(另有 {dropped} 条相关资料因预算限制未纳入。)" if dropped else ""
return "\n\n".join(lines) + tail
def build_messages(
instructions: str,
tool_defs: list[dict],
task: str,
progress_summary: str,
knowledge: list[KnowledgeChunk],
new_input: str,
) -> list[dict]:
"""
严格按「稳定度递减」排列,保证前缀缓存命中率最大化。
注意:instructions 与 tool_defs 里绝对不能出现时间戳等动态内容。
"""
stable_head = json.dumps(tool_defs, ensure_ascii=False, sort_keys=True)
return [
{"role": "system", "content": instructions}, # ① 最稳定
{"role": "system", "content": f"可用工具:\n{stable_head}"}, # ② 顺序固定
{"role": "user", "content": f"任务目标:{task}"}, # ④ 一轮内不变
{"role": "user", "content": f"已完成步骤:\n{progress_summary}"}, # ⑤ 压缩后
{"role": "user", "content": f"参考资料:\n{render_knowledge(knowledge)}"}, # ③/⑤
{"role": "user", "content": new_input}, # ⑥ 变化最频繁,放最后
]
def summarize_progress(steps: list[dict]) -> str:
"""
阶段摘要必须结构化。自由文本摘要会越摘越糊。
固定四段:目标 / 已完成 / 关键结论 / 待办
"""
done = [s for s in steps if s.get("status") == "ok"]
failed = [s for s in steps if s.get("status") not in ("ok", None)]
return (
f"· 已完成({len(done)} 步):"
+ ";".join(f"{s['tool']}({s.get('brief','')})" for s in done[-8:])
+ f"\n· 失败/放弃({len(failed)} 步):"
+ ";".join(f"{s['tool']}→{s.get('status')}" for s in failed[-5:])
+ "\n· 待办:见任务目标未覆盖的部分"
)四、什么时候该压缩:三个触发信号
不要等上下文满了才压——那时候往往已经来不及(压缩本身要花一次模型调用,而你已经没有余量了)。三个提前量信号对应三种策略:短任务用 滚动窗口(Rolling Window),中长任务用 阶段摘要(Phase Summary),长任务还要叠加 状态外置(State Externalization);单条超长结果则做 头尾保留裁剪(Head-Tail Truncation)。这些都属于 压缩触发信号(Compression Trigger):
| 信号 | 阈值建议 | 动作 |
|---|---|---|
| 用量占比 | 达到窗口的 60% | 触发阶段摘要,把最早的一批步骤压成结构化条目 |
| 单条超长 | 任一工具返回超过窗口的 10% | 立即做「头尾保留 + 中间省略」的裁剪,并标注省略了多少字符 |
| 阶段边界 | 一个子任务完成时 | 主动固化结论、清理过程性内容(如中间试错的输出) |
- 给你的 Harness 加一个「上下文占用监控」:每轮记录
tokens_used / window,跑一个长任务画出曲线; - 对比「60% 触发压缩」与「90% 触发压缩」两种策略下的任务完成率与总成本;
- 写一段脚本验证前缀稳定性:连续两轮请求的字节前缀,第一个不同的位置应该出现在 90% 之后。
验收标准:能说出你的系统在 128k 窗口下、第 20 轮时的上下文构成(四类成分各占多少),并说明压缩在什么位置触发。
五、常见误区与追问
5.1 误区:等上下文快满了再压缩也来得及
错在哪:把压缩当成一个不花成本的本地操作。为什么自然:压缩就是删内容,删东西怎么会来不及。判据:压缩本身要发起一次模型调用,而它要读的恰恰是那段已经很满的上下文——压缩的输入就是那个逼近上限的输入。数字:128k 窗口下,60% 触发时还有约 51k 余量,从容;90% 触发时只剩 12.8k,而摘要必须把 115k 读一遍,一旦按总长校验就直接失败。所以取值依据不是「还能塞多少」,而是「压缩动作本身要花掉多少」——按 60% 触发,并为摘要预留一次完整调用。
5.2 误区:窗口到 1M 了,上下文越长效果越好
错在哪:把「模型收得下」当成「模型用得上」。为什么自然:上下文越多,可用信息看起来越多,何况现在真有一百万的窗口。判据:效果变量是真实判断依据的占比,不是总长度——把十万 token 里真正被引用的那部分数出来,占比从约 18% 被稀释到 10% 时,任务从第 15 轮前后开始跑偏。可操作的验证:同一个任务跑两组,A 组带全量历史,B 组只带结构化结论摘要;如果完成率没有下降,说明你原来塞的那些过程性内容本来就没被用到——直接砍掉,既提效果又降成本。
5.3 误区:上下文治理的重点是「知识」,所以先去优化检索
错在哪:把注意力放在检索片段上,放过了真正的大头。为什么自然:检索结果看起来才是「内容」,历史消息只是聊天记录,不像能吃预算的东西。判据:先量再说,按四类成分逐个数 token 占比。典型分布是:一次检索 3–8k token,而十几轮之后的历史消息加工具返回值可以到 20 万 token 量级,指令与工具定义合计往往不到 10%。所以第一刀应该砍向状态:中间产物外置、历史压成结构化摘要、单条超长结果做头尾保留裁剪——先优化检索是小马拉大车。
5.4 误区:压缩就是把内容变短
错在哪:把压缩当成一次自由发挥的改写。为什么自然:摘要嘛,写短一点就算完成了任务。判据:自由文本摘要会「越摘越糊」——每压一次丢一部分细节,压上三次往往连任务目标都变形了。正确做法是让摘要结构化,固定四段:目标 / 已完成 / 关键结论 / 待办;并且必须保留可回查的指针(文件路径、轮次号、来源链接),需要细节时能按指针回读。判据:压完之后拿摘要做一次「无原历史」的任务续跑,如果模型还能说清任务目标与已完成的步骤,这次压缩合格;如果说不出,说明它压掉的不是过程而是结论。
5.5 误区:状态外置就是把中间产物写进文件
错在哪:只做了「写文件」,没做「让模型知道文件在哪、要不要读」。为什么自然:外置听起来就是找个地方存起来。判据:外置的验收标准有两条——上下文里只剩指针,且指针是可读、可发现的。文件名如果叫 tmp_001.json,模型既不知道里面是什么,也没理由去读,等于没外置;叫 orders-2025-删除影响分析.md,模型看一眼就知道该不该打开。数字:20 万 token 的中间产物外置之后,上下文里应该只剩一份三五行的目录清单(约 200 token)。
5.6 误区:标了来源就不会有引用幻觉
错在哪:把「给了判断依据」当成「已经校验过」。为什么自然:每条知识都带了来源与层级,看起来已经足够严谨。判据:来源只让幻觉变得可被发现,并不阻止它发生——模型完全可能标注一个证据清单里根本不存在的来源,或者把 opinion 层的内容当成 fact 用。所以要补一步后置校验:生成完成后,把回答里每一条引用回查一遍,看它在证据清单里是否真实存在、层级有没有被升格;对不上就降级或删掉引用。判据:随机抽 20 条带引用的回答逐条点开核对,如果存在对不上的编号,说明你的引用体系目前只是装饰。
六、自测
七、小结
| 议题 | 结论 |
|---|---|
| 上下文四成分类 | 指令 / 能力声明 / 知识 / 状态;治理重点在「状态」 |
| 排序原则 | 按变化频率,稳定的靠前,动态的靠后 |
| 压缩策略 | 滚动窗口(短任务)+ 阶段摘要(通用)+ 状态外置(长任务) |
| 压缩触发 | 用量 60%、单条超 10%、阶段边界,三个提前量信号 |
| 知识注入 | 必须带来源、更新时间、可信层级,否则无法判断与引用 |
效果上限不是由提示词的精妙程度决定的,而是由「该给的信息有没有给、不该给的噪音有没有删」决定的。本刊编辑部
下一章处理一个更长期的问题:如果这些信息不该每一轮都重新塞进去,那该存在哪里、什么时候取出来。
八、参考与延伸
这一层的实践材料比理论多。下面按「先看原则、再看机制、最后看极端案例」排好。全站不做原文转载,这里只登记链接与「为什么值得读」。
先看原则(该给什么、不该给什么)
- Anthropic · Effective Context Engineering for AI Agents —— 把上下文工程从「提示词怎么写」里彻底拆出来,讲成一件「在有限的注意力预算下做取舍」的事,并且明确说上下文会「腐烂」(信息还在,但已经不该信了)。本章第一节的四类成分与它是同一件事的两种表述;如果你要给自己的项目定一份预算比例,先看它给的原则再看自己的数。
- Anthropic · Prompt Engineering Overview —— 与上一份对着读:哪些属于措辞问题、哪些属于信息供给问题。如果你的团队还在用「优化提示词」去解决「该给的信息没给」的问题,把这一页发给对方,比争论有用。
再看机制(前缀到底怎么被复用)
- vLLM · Automatic Prefix Caching —— 服务端视角的前缀复用:以块为单位做哈希命中,能命中多少取决于前缀里有多少个完整块逐字节相同。本章第二节说「把变化频率当排序键」,这里能让你看到它背后的匹配粒度——为什么首部改一个字符不是「慢一点」,而是整段作废。
- vLLM 博客:用 PagedAttention 把大模型服务做到又快又省 —— 重点看显存碎片与块管理那几张图。理解「前缀会被淘汰」之后,你就不会把「又长又稳定」当成缓存友好的默认答案:长前缀反而更容易在并发压力下被换出。
最后看极端案例(长上下文到底能干什么)
- Efficient Memory Management for Large Language Model Serving with PagedAttention(arXiv:2309.06180) —— 只读它的 KV 块管理一节即可。它解释了一个反直觉现象的成因:为什么你在应用层把前缀写得再稳定,命中率依然不完全由你决定——调度与淘汰也在你之外发生。
◇ 面试官会怎么问
共 7 条 · 其中 4 条高频 · 先自己答一遍,再展开对照
Prompt Engineering 和 Context Engineering 的区别是什么? 高频
前者是措辞、格式、few-shot 的组织、角色与约束的表达;后者是决定模型的可见信息集合——召回哪些内容、裁剪掉哪些、什么时候注入记忆、历史压缩到什么粒度、静态与动态部分怎么排列。
工程上后者的权重更大,因为它决定了效果上限:提示词写得再好,如果关键信息根本没进上下文,模型只能猜。而且 Context Engineering 直接决定成本(token 量、缓存命中率)与延迟,是可量化、可优化的部分。
Schema 有几百个字段,你会全塞进 prompt 吗? 高频
分层做法:
① 常驻层——工具名、少量最高频字段、以及"如何获取字段清单"的元工具(让模型能主动查);
② 按需召回层——先用一次轻量检索(关键词或向量)从字段库里召回候选字段子集,再放进上下文;
③ 外置层——完整 schema 放在可查询的资源里,模型需要时通过工具查全量定义。
更稳的做法是两阶段:先让模型输出"我要用到哪些字段",程序校验后把对应 schema 注入,再让它生成最终条件。这样既省 token,也让每一步的生成空间可控、可校验。
追问链
- 那召回错了怎么办?
- 这个两阶段设计会多花一轮,成本上划算吗?
上下文快满了,你怎么知道?压缩策略是什么? 高频
压缩按"损伤从小到大"分三级:
① 裁剪——工具结果只保留关键字段,丢弃冗长的原始响应、日志、重复内容。这一级几乎无损,应该常态化执行;
② 摘要化——把早期历史替换成摘要,只保留决策与结论,丢掉推理过程;
③ 外置——把大块内容(长文档、大 JSON、已完成产物)落盘,上下文里只留指针(路径、ID、摘要),需要时通过工具回读。
关键设计是摘要里必须保留指针,否则信息真丢了。压缩要记日志、最好对用户可见,并且可回滚。
为什么上下文的排列顺序会影响成本?
稳定内容放前面(静态 system prompt、工具列表按固定顺序、few-shot 固定不变),动态内容放后面(当前时间、用户信息、本轮检索结果、新增历史)。
常见的反面写法很隐蔽:把当前时间戳放在 system prompt 开头、每次按使用频率重排工具列表、把召回片段按相关度排序(顺序每次不同)、在开头注入会话 ID。这些都会让整段缓存作废,成本可能差好几倍,而日志上完全看不出来——只能靠监控命中率发现。
任务跑到第 20 轮,上下文接近窗口上限,模型开始答非所问。你会先压缩历史还是先压缩知识?为什么? 高频
量法:把当前上下文按四类成分切开数 token。典型分布是知识只有几千 token,而二十轮的历史消息加工具返回值可以到 20 万 token 量级,指令与工具定义合计往往不到 10%。
压状态的三个动作:① 中间产物外置,上下文只留文件名清单与指针;② 阶段摘要,固定四段字段(目标 / 已完成 / 关键结论 / 待办),只丢过程不丢结论;③ 单条超长结果做头尾保留裁剪,超过窗口 10% 就裁,并标注省略了多少字符。
判据:压完之后重跑失败样本,完成率不掉,说明被删掉的内容本来就没被用到。
追问链
- 怎么判断一段历史属于「过程」还是「结论」?
- 摘要压三次之后开始丢任务目标,你怎么防?
同样的内容,只调整排列顺序,成本能差多少?给一个可以验证的判据。
判据是相邻两轮请求体的字节前缀:把第 N 轮与第 N+1 轮的请求体转成字节串,从第一位开始比对,找到第一个不同的偏移量。
偏移量落在前 1%(总长 100k 时即前 1k 以内)→ 缓存基本全废,每轮都在重算全部历史;偏移量落在 90% 之后 → 前面被复用,这一轮的输入成本约等于新增的那一段。
所以纪律是按变化频率排序:稳定的靠前(指令、工具定义、任务目标),每轮都变的放最后(用户输入、最新工具结果)。把时间戳、记忆片段、随机 id 放进头部,等于每轮主动把缓存清零。
附带的收益是:新信息在尾部离生成位置更近,相关性也更好——成本和效果在这里是同向的。
追问链
- 工具定义顺序不稳定会怎样?怎么保证序列化后字节稳定?
- 如果业务上必须在头部放用户名,有什么折中方案?
上下文窗口已经到 1M token,「把整个代码库塞进去」这件事还需要检索吗?
① 成本:注意力是平方复杂度,序列翻倍算力约翻四倍;一次任务发十五轮,就等于把整个代码库重复处理十五次。窗口变大不会让这件事变便宜。
② 信噪比:1M token 里与当前问题真正相关的可能不到 1%。真实判断依据的占比从 18% 被稀释到 10%,模型会「越跑越笨」——收得下不等于用得上。
③ 判断依据要可核对:检索来的片段能带来源与层级(fact / inference / opinion),全库直塞则没有任何可信度信息,模型只能一视同仁地采信。
所以窗口变大改变的是「检索可以少而精,还是可以多塞一点」,不改变「先检索再注入」这条结构。
追问链
- 要做「长上下文直塞」与「检索注入」的 A/B,你会怎么设计对照组?
- 什么样的任务确实更适合全量直塞?给一个判据。
结论先行(一句话给出取舍)→ 机制(为什么是这样,涉及哪条链路)→ 代价或边界(这么做放弃了什么)→ 你的实践或数字(真实场景里怎么落地)。 只讲机制不讲代价,是背题;只讲取舍没有数字,是空谈。