Prompt Engineering 关心「话怎么说」,Context Engineering 关心「给它看什么」。前者影响措辞,后者决定判断依据是否齐全。而在有成本约束的系统里,上下文还是一项必须做预算的资源——什么常驻、什么按需、什么必须压缩,这三件事构成效果与成本的分水岭。
编辑部注 · 本章是全站篇幅最长的一章,因为它同时影响效果上限与成本下限。建议读完后来回看两遍图 1。
一个被反复验证的现象:把提示词从「请仔细分析」改成更花哨的措辞,效果几乎不动;而把该给的数据补齐、把无关的噪音删掉,效果会明显变化。原因很朴素——模型只能基于看到的信息做判断,你不知道的信息它也不知道。
一、上下文的四种成分
把一次请求的上下文拆开,实际上只有四类内容。分清这四类,预算该给谁就有答案了。
| 成分 | 内容 | 变化频率 | 建议策略 |
|---|---|---|---|
| 指令 | 角色设定、行为约束、输出格式要求 | 几乎不变 | 常驻头部,保持字节稳定(前缀缓存友好) |
| 能力声明 | 工具定义、可用数据源说明 | 很少变 | 常驻,但要精简——工具描述本身占掉的 token 常被低估 |
| 知识 | 检索到的资料、知识库片段、历史结论 | 每轮可能变 | 按需注入,必须带来源与相关性标注 |
| 状态 | 已完成的步骤、当前进度、中间产物、错误记录 | 每轮都变 | 外部化 + 摘要,不要全量堆在上下文里 |
一个反直觉的观察:真正吃掉上下文的大头通常是「状态」而不是「知识」。检索来的资料一般几千 token,而十几轮之后的对话历史 + 工具返回结果可以轻松到几十万 token。
二、顺序会影响成本:前缀稳定性
在第 5 章(KV Cache)里我们已经知道了规则,这里给出它在 Harness 里的落地版排序原则:
| 位置 | 放什么 | 为什么放这里 |
|---|---|---|
| ① 最前 | 系统指令、行为约束 | 最稳定,几乎永不变化,缓存命中率最高 |
| ② | 工具定义(顺序固定) | 变化少;必须保证序列化后字节稳定 |
| ③ | 长期知识 / 知识库摘要 | 更新频率低(天级即可) |
| ④ | 本任务的目标与约束 | 一轮内不变 |
| ⑤ | 已完成步骤的结构化摘要 | 每轮会变,但内容被压缩,增长可控 |
| ⑥ 最后 | 本轮新信息(用户输入、最新工具结果) | 变化最频繁,放尾部让前面全部命中 |
把变化频率当成排序键:变化越少越靠前,变化越多越靠后。
这条规则同时满足两个目标——前缀缓存命中率最大化(省成本),以及「离问题近的信息更被关注」(提效果)。当你不确定某段内容该放哪时,问自己是「每轮都变」还是「几乎不变」就够了。
三、给知识加来源标注
检索到的内容如果不带来源,模型无从判断可信度,也无法在回答里给出可验证的引用。最小可行的做法是给每段知识打三个标签:
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· 待办:见任务目标未覆盖的部分"
)四、什么时候该压缩:三个触发信号
不要等上下文满了才压——那时候往往已经来不及(压缩本身要花一次模型调用,而你已经没有余量了)。三个提前量信号:
| 信号 | 阈值建议 | 动作 |
|---|---|---|
| 用量占比 | 达到窗口的 60% | 触发阶段摘要,把最早的一批步骤压成结构化条目 |
| 单条超长 | 任一工具返回超过窗口的 10% | 立即做「头尾保留 + 中间省略」的裁剪,并标注省略了多少字符 |
| 阶段边界 | 一个子任务完成时 | 主动固化结论、清理过程性内容(如中间试错的输出) |
- 给你的 Harness 加一个「上下文占用监控」:每轮记录
tokens_used / window,跑一个长任务画出曲线; - 对比「60% 触发压缩」与「90% 触发压缩」两种策略下的任务完成率与总成本;
- 写一段脚本验证前缀稳定性:连续两轮请求的字节前缀,第一个不同的位置应该出现在 90% 之后。
验收标准:能说出你的系统在 128k 窗口下、第 20 轮时的上下文构成(四类成分各占多少),并说明压缩在什么位置触发。
五、自测
六、小结
| 议题 | 结论 |
|---|---|
| 上下文四成分类 | 指令 / 能力声明 / 知识 / 状态;治理重点在「状态」 |
| 排序原则 | 按变化频率,稳定的靠前,动态的靠后 |
| 压缩策略 | 滚动窗口(短任务)+ 阶段摘要(通用)+ 状态外置(长任务) |
| 压缩触发 | 用量 60%、单条超 10%、阶段边界,三个提前量信号 |
| 知识注入 | 必须带来源、更新时间、可信层级,否则无法判断与引用 |
效果上限不是由提示词的精妙程度决定的,而是由「该给的信息有没有给、不该给的噪音有没有删」决定的。本刊编辑部
下一章处理一个更长期的问题:如果这些信息不该每一轮都重新塞进去,那该存在哪里、什么时候取出来。
◇ 面试官会怎么问
共 4 条 · 其中 3 条高频 · 先自己答一遍,再展开对照
Prompt Engineering 和 Context Engineering 的区别是什么? 高频
前者是措辞、格式、few-shot 的组织、角色与约束的表达;后者是决定模型的可见信息集合——召回哪些内容、裁剪掉哪些、什么时候注入记忆、历史压缩到什么粒度、静态与动态部分怎么排列。
工程上后者的权重更大,因为它决定了效果上限:提示词写得再好,如果关键信息根本没进上下文,模型只能猜。而且 Context Engineering 直接决定成本(token 量、缓存命中率)与延迟,是可量化、可优化的部分。
Schema 有几百个字段,你会全塞进 prompt 吗? 高频
分层做法:
① 常驻层——工具名、少量最高频字段、以及"如何获取字段清单"的元工具(让模型能主动查);
② 按需召回层——先用一次轻量检索(关键词或向量)从字段库里召回候选字段子集,再放进上下文;
③ 外置层——完整 schema 放在可查询的资源里,模型需要时通过工具查全量定义。
更稳的做法是两阶段:先让模型输出"我要用到哪些字段",程序校验后把对应 schema 注入,再让它生成最终条件。这样既省 token,也让每一步的生成空间可控、可校验。
追问链
- 那召回错了怎么办?
- 这个两阶段设计会多花一轮,成本上划算吗?
上下文快满了,你怎么知道?压缩策略是什么? 高频
压缩按"损伤从小到大"分三级:
① 裁剪——工具结果只保留关键字段,丢弃冗长的原始响应、日志、重复内容。这一级几乎无损,应该常态化执行;
② 摘要化——把早期历史替换成摘要,只保留决策与结论,丢掉推理过程;
③ 外置——把大块内容(长文档、大 JSON、已完成产物)落盘,上下文里只留指针(路径、ID、摘要),需要时通过工具回读。
关键设计是摘要里必须保留指针,否则信息真丢了。压缩要记日志、最好对用户可见,并且可回滚。
为什么上下文的排列顺序会影响成本?
稳定内容放前面(静态 system prompt、工具列表按固定顺序、few-shot 固定不变),动态内容放后面(当前时间、用户信息、本轮检索结果、新增历史)。
常见的反面写法很隐蔽:把当前时间戳放在 system prompt 开头、每次按使用频率重排工具列表、把召回片段按相关度排序(顺序每次不同)、在开头注入会话 ID。这些都会让整段缓存作废,成本可能差好几倍,而日志上完全看不出来——只能靠监控命中率发现。
结论先行(一句话给出取舍)→ 机制(为什么是这样,涉及哪条链路)→ 代价或边界(这么做放弃了什么)→ 你的实践或数字(真实场景里怎么落地)。 只讲机制不讲代价,是背题;只讲取舍没有数字,是空谈。