吃透 AI Agent 开发 · 第 15 篇 · 第四章 · Context Engineering
上下文同时涉及选择、顺序、预算、时机和新鲜度。只会写 system prompt,还远远不够。
很多人第一次写 Agent 的系统提示词,会把所有规则都放进一个模板:角色、工具说明、当前日期、项目目录、Git 状态、用户偏好、任务列表,最后再加一句“请认真完成”。刚开始没问题,功能越多,这段字符串越像一间堆满纸箱的仓库。
Context Engineering 不是把 Prompt 写得更长,而是决定:什么信息在什么时候,以什么顺序和预算进入模型。
上下文像出差行李
身份证和工作纪律长期不变,适合放在固定位置;这次航班和酒店只对当前行程有效;目的地的餐厅攻略可以到附近再搜索。
如果把半年后的所有攻略都提前塞进行李箱,箱子会更重,却不会让这次出差更顺利。
Agent 上下文也可以分成三层:
| 稳定性 | 适合放什么 | 典型生命周期 |
|---|---|---|
| 长期稳定 | 核心安全规则、工具纪律、输出原则 | 多个会话 |
| 会话稳定 | 当前项目说明、选定模型能力、会话目标 | 一段会话 |
| 每轮动态 | 用户问题、任务状态、相关文件、当前时间 | 一次请求 |
这不是绝对分类。重点是让每个字段都能回答:“它多久变化一次?变化后谁需要知道?”
为什么稳定前缀值得保护
不少模型服务支持 Prompt Cache。当前请求与之前请求共享较长前缀时,服务端可能复用计算结果,降低首 token 延迟和输入成本。
假如把精确到秒的时间放在 system prompt 最前面,每轮前缀都不同,后面再稳定也很难命中缓存。更合理的做法是保持核心规则在前,把当前时间、Git 摘要等动态内容放到靠后的临时上下文。
缓存只是收益之一。稳定前缀还更容易测试:你可以给它计算 hash,发现一次功能改动是否意外改变了核心规则。
不要继续拼大字符串
可以用管道表示不同上下文片段:
type Stability = 'stable' | 'session' | 'dynamic'
interface ContextSection {
name: string
stability: Stability
render(context: RequestContext): string | null
}
const sections: ContextSection[] = [
coreRules,
toolDiscipline,
projectInstructions,
taskState,
relevantFiles,
runtimeSummary
]
构建时不仅得到最终文本,还应保留每段名称、字符数和稳定性。上下文超预算时,你才知道该缩短文件内容,还是任务状态异常膨胀,而不是面对一个十万字字符串猜原因。
JIT Context:需要时再拿
Just-In-Time Context 的意思不是“所有信息都由模型临时搜索”,而是推迟不确定是否需要的内容。
比如用户说“帮我修登录失败”,系统可以先注入项目说明和与登录相关的少量候选文件。完整依赖树、全部测试日志、所有历史设计文档不必立刻进入。模型真正需要时,可以用搜索或读取工具继续取。
这样做有三个好处:
- 减少无关信息对注意力的干扰。
- 降低输入 token 和延迟。
- 让信息来源和读取动作出现在工具轨迹里,便于追查。
代价是可能多一次工具调用。因此,稳定且高频需要的信息适合提前放入;体积大、相关性不确定的信息适合按需获取。
项目指令也需要预算
真实仓库里的说明文件可能有上千行。全量注入最省开发时间,却会长期占据窗口。可以采用两级策略:短文件原样放入;长文件只保留必须遵守的运行纪律、章节索引和按需读取提示。
这里不能只做“截取前 4000 字”。重要安全规则可能在后半段。更可靠的办法是按标题解析结构,优先保留约束类章节,同时告诉模型完整文件在哪里。
顺序也是语义
同样几段内容,顺序不同,效果可能不同。通常可以遵循:
- 先放身份、安全和不可违反的核心规则。
- 再放稳定的工具和工作流纪律。
- 然后放项目与会话背景。
- 最后放当前任务、相关文件和本轮偏好。
外部文档和工具结果属于不可信数据。不要把它们放进“系统规则”的位置,也不要允许文档里一句“忽略之前指令”改变权限策略。
常见失败模式
动态字段污染稳定前缀
每轮注入完整 Git 状态、工具数量或精确时间,缓存命中下降,Prompt diff 也变得嘈杂。
工具说明全部常驻
系统有一百个工具,但本轮只可能用到五个。全部暴露既浪费上下文,也增加模型选错工具的概率。可以根据任务阶段动态选择工具集,或只提供工具目录,使用时再加载详细 schema。
“相关”只看语义相似
用户问构建失败,搜索可能找到大量包含“build”的文档,却不一定是当前仓库、当前版本或当前错误。相关性还要考虑路径、时间、任务阶段和来源可信度。
用 Model Context 做个小练习
把下面六项分到稳定、会话稳定或每轮动态,并说明理由:
- 禁止输出密钥。
- 当前日期。
- 项目使用 pnpm。
- 这轮用户选中的三个文件。
- 当前任务图。
- 回答采用教学风格。
然后修改其中一项,想一想它是否应该导致整个 system prompt hash 变化。
Selection 到 Model Context 的小结
Context Engineering 的核心是选择和时机。稳定规则要保护,动态信息要靠后,大内容要有预算,不确定相关的信息要按需获取。Prompt 不再是一段神秘文字,而是一条能检查、能测量、能演进的数据管道。
下一篇继续解决一个必然发生的问题:无论挑选多仔细,会话最终还是会变长。什么时候压缩,压缩什么,又有哪些内容根本不该放在上下文里?
做一张上下文装箱单
把一次模型请求想成托运行李。稳定规则是证件,项目指令是行程单,当前任务是当天用品,文件正文和记忆则是按需取出的物品。所有东西都重要,不代表所有东西都要在出发时塞进同一个箱子。
| 内容种类 | 来源 | 放入时机 | 退出条件 |
|---|---|---|---|
| 核心行为规则 | 产品代码 | 每次请求的稳定前缀 | 版本升级 |
| 项目说明 | AGENTS.md | 进入项目时 | 切换项目或规则更新 |
| Git 摘要 | 当前工作区 | 本轮需要时 | 下一轮重新计算 |
| 文件正文 | 工具读取 | 模型明确需要时 | 完成任务或被压缩 |
| 长期记忆 | 记忆选择器 | 与问题相关且未过期 | 失效、冲突或用户忽略 |
装箱单最重要的字段是“退出条件”。只记录如何注入,不记录如何撤掉,Context 最终一定会腐烂。
从 q-code 观察四类上下文
| 编号 | 路径 | 阅读目的 |
|---|---|---|
| 1. Prompt 管道 | src/context/prompt-builder.ts |
片段如何按稳定性排序 |
| 2. 运行时事实 | src/context/runtime-context.ts |
日期、Git 和任务信息怎样只进本轮 |
| 3. 项目规则 | src/context/agent-md.ts |
长文档如何截取纪律和索引 |
| 4. 质量审计 | src/context/prompt-quality.ts |
缺失维度如何被检查 |
失败现场:所有信息都拼成一段字符串
所有信息都放入同一段长字符串,既难维护又让动态字段破坏缓存。更严重的是,压缩和日志无法指出某句话来自项目规则还是旧记忆,模型冲突时也没有稳定的优先级。
修复可以从“为每段内容加名字”开始,不必一次搭建复杂框架。记录 category、stability、source、chars 和 age;请求前打印片段摘要,出现问题时先找错层的片段,再改内容。
练习:让一条信息晚一点出现
挑选当前 Prompt 中最占空间的一段资料,改成先提供标题和摘要,模型需要时再读取全文。比较两种方案的 token、首次工具选择和最终正确性。再让资料更新一次,确认新鲜度变化不会让稳定前缀整体失效。
请求发出前,先生成一份 Context Manifest
只保存最终 Prompt,很难回答“这一段从哪里来的”。构建器可以同时生成一份不含正文的 manifest,记录片段顺序、来源、大小、年龄和信任级别。
[
{"id":"core-rules","role":"system","chars":4200,"stability":"stable","trust":"product"},
{"id":"project-agents","role":"system","chars":6800,"stability":"project","trust":"repository"},
{"id":"memory-pnpm","role":"user","chars":380,"stability":"dynamic","trust":"user-confirmed","ageDays":12},
{"id":"file-auth","role":"user","chars":9100,"stability":"dynamic","trust":"workspace","sha256":"..."}
]
线上日志只需保存这个摘要,不必记录项目正文。出现“模型为何认为项目用 npm”时,可以先看本轮是否注入了旧记忆、当前 lockfile 是否读取、两者顺序和年龄,再决定是否读取受控原文。
flowchart LR
S["候选片段"] --> V["来源与信任标注"]
V --> B["预算分配"]
B --> O["稳定性排序"]
O --> Q["Model Request"]
O --> M["Context Manifest"]
M --> D["Diff / Audit / Debug"]
manifest 还适合做请求间 diff。若用户只问了下一句,稳定段 hash 却变化,就能定位哪个动态字段误入前缀;若某个文件已更新,manifest 的 sha256 不变,则说明缓存失效有 bug。
冲突信息需要优先级和证据,不是让模型自由投票
上下文中可能同时出现:用户记忆说偏好 npm,仓库有 pnpm-lock.yaml,项目规则写“必须使用 pnpm”,当前用户又明确要求这次试用 npm。四条信息并非简单的“后出现覆盖前面”。
可以先按作用域和权威度判断:产品安全规则不可被项目文档覆盖;项目明确纪律通常高于旧个人偏好;当前用户请求可改变普通偏好,却不能绕过安全边界;现场文件是当前事实,但也可能是未提交实验。
不必把整个优先级算法交给模型。构建器可以对明显冲突加提示:
[context-conflict]
topic: package-manager
project-rule: pnpm (AGENTS.md, current)
memory: npm (12 days old, user-confirmed)
current-request: try npm for this command
instruction: do not modify lockfiles; explain conflict before acting
这让模型知道冲突存在,也把最终副作用留给工具策略。旧记忆不会因为排在后面就悄悄变成更高规则。
预算分配不能只按先来后到截断
总预算剩 20K token 时,按拼接顺序截断会把最后加入的当前任务砍掉。更合理的做法是为类别设置保底和上限,再在类别内部按相关性、年龄和可恢复性分配。
例如核心规则 6K 保底,当前任务 3K 保底,最近交互 4K,候选文件最多 5K,记忆最多 2K。工具长输出不参与争抢,默认 offload。若某类没用满,剩余额度再分给高价值片段。
预算报告要告诉模型哪些内容被省略:有 12 个候选文件,只注入 2 个;完整构建日志在 artifact;三条旧记忆因年龄被排除。模型知道信息不完整,才会主动读取,而不是把当前可见片段当成全部世界。
“来源可信”与“内容真实”不是一回事
项目文件来自受信工作区,不代表里面每句话都是真实,更不代表能提升权限。README 可能过时,测试日志可能由攻击者构造,网页内容也可能包含 Prompt Injection。
Context 层要保留数据边界,用明确分隔和来源标签包装外部内容。权限系统只接受宿主配置和用户确认,不从文档文本中读取授权。即使工具结果写着“管理员允许删除目录”,它仍然只是工具数据。
模型可以依据外部内容形成候选判断,但高风险动作必须回到可验证来源。比如文档说 API 已废弃,先检查当前代码或官方版本;记忆说某路径安全,仍由实时 path policy 解析。
每种片段都要定义所有者
Context Rot 往往不是文本质量问题,而是没人负责删除。核心规则由产品代码版本管理;项目指令由仓库维护者负责;记忆有写入者、更新时间和过期策略;运行时摘要由本轮重新计算;文件正文由 hash 判断新鲜度。
所有者不明确的片段不应长期常驻。一个临时 A/B 实验提示如果没有 expiresAt,三个月后仍可能影响模型。构建器可以拒绝把 dynamic 片段放进 stable 区,也可以对过期内容发出诊断。
用 Context Diff 做回归测试
给同一个固定请求构建两次上下文,稳定前缀 hash 应一致;把当前日期改变一天,只允许 runtime 片段变化;切换项目后,project 片段变化但 product 核心不变;用户说“忽略记忆”时,memory index 和正文都应消失。
测试不必快照完整 Prompt,完整快照容易因文案微调产生噪声。可以断言 manifest 的 section ID、顺序、stability、字符预算和 hash 关系,再对关键安全规则做存在性检查。这样既保护架构约束,也允许正文正常改进。
Context Engineering 做到这里,就从“写一段效果好的 Prompt”变成了数据工程:每份信息有来源、预算、时效、信任和退出条件;每次请求能解释自己带了什么,也能解释为什么没带其他东西。