11 · 系统提示与上下文注入:每轮喂给模型的那坨东西,到底是怎么拼的
上下文远不止消息历史。每轮发给模型的 prompt,是由系统提示、环境信息、项目记忆、动态附件层层拼装出来的。讲清各层该放什么、放哪里,以及一个贯穿始终的分界原则——静态的放高处、动态的放低处,这直接决定了下一篇 Prompt 缓存的成败。
11 · 系统提示与上下文注入
上一篇我们学会了量上下文的大小。但在谈怎么给它“减负”之前,得先看清:每一轮实际发给模型的那坨东西,到底由哪些部分拼成? 很多人以为“上下文 = 消息历史”,这只对了一小半。
一、背景:模型需要“知道自己身处何地”
一个纯粹的消息历史,缺了很多模型干活必需的信息:
- 它在哪个目录下工作?当前是什么操作系统?今天几号?
- 这个项目有没有特定约定(用 pnpm 不用 npm、测试命令是什么、代码风格)?
- 用户这次提到的那个文件,内容是什么?
这些都不在“用户说的话”里,但模型必须知道,否则它只能瞎猜(还记得01 篇那个“连今天几号都不知道”的裸模型吗)。所以真实 Agent 每一轮都会把一堆信息注入进上下文。搞清楚注入了什么、注入在哪,是上下文工程的必修课。
二、解决方案:分层拼装一个 prompt
每一轮发给模型的完整 prompt,大致由这几层拼成,从“最静态”到“最动态”排列:
┌─ 系统提示(system)———————————— 最静态、最大、最该被缓存
│ · 角色设定、行为准则
│ · 工具使用指南、输出规范
├─ 环境上下文 ——————————————— 每次会话基本不变
│ · cwd、操作系统、日期、shell
├─ 项目记忆 / 用户偏好 ———————— 每个项目固定
│ · 项目约定(测试命令、风格)、用户长期偏好
├─ 消息历史 ————————————————— 随对话增长
│ · user / assistant / tool_use / tool_result …
└─ 动态附件 ————————————————— 每轮可能变化,最该放在最后
· 本轮引用的文件、检索到的相关记忆、目录结构每一层各司其职:
- 系统提示:定义“你是谁、怎么干活”。它往往很大(几千 token 的行为规范和工具指南),而且基本不变。
- 环境上下文:让模型知道“此时此地”。通常追加在系统提示之后,或作为一条会话开头的元信息。
- 项目记忆 / 用户偏好:项目级的约定(那种
AGENTS.md/CLAUDE.md之类的文件)、用户的长期偏好。通常前置注入到对话开头。 - 消息历史:就是前面一直在滚的那个
messages。 - 动态附件:本轮才需要的、易变的东西——用户刚引用的文件、按相关性临时检索出的记忆片段。
三、决定性原则:静态放高处,动态放低处
上面那个“从静态到动态”的排列不是随便排的。它遵循一条贯穿整个上下文工程的黄金法则:
把不变的东西放前面,把易变的东西放后面。 这条原则有两个理由,一个比一个重要:
① 缓存(下一篇的主角):LLM API 的 prompt 缓存是前缀匹配的——只有从头开始、一字不差的那段前缀才能命中缓存。所以你把大而稳定的系统提示放最前面,它就能在每一轮里被缓存复用,省下大笔重复计算的钱和延迟。反过来,只要你在靠前的位置塞一个每轮都变的东西(比如把当前时间戳放进系统提示开头),它后面的一切缓存全部作废。
② 模型注意力:近期的、靠后的信息,模型通常给予更高的权重。把“本轮最相关的文件”放在最靠近提问的位置,比埋在几千 token 的历史中间,更容易被模型抓住。
这条原则会在下一篇Prompt 缓存里变成实打实的钱和延迟,这里先把它刻下来。
四、实现细节:注入的两种姿势与一个陷阱
姿势一:追加到系统提示
环境信息这类“整场会话不变”的东西,适合拼到系统提示末尾:
function buildSystemPrompt(base: string, env: EnvInfo): string {
return `${base}
<env>
工作目录: ${env.cwd}
操作系统: ${env.os}
今天日期: ${env.date}
</env>`
}注意:环境信息在一次会话内通常不变(你不会中途换操作系统),所以放进系统提示不会破坏缓存。但日期是个微妙的例外——如果你精确到秒,它每轮都变,就成了缓存杀手;精确到“天”就安全。注入动态信息前,先想想它多久变一次。
姿势二:作为消息注入
用户引用的文件、检索到的记忆,则更适合作为消息注入到历史里(通常紧挨着用户的提问):
const messagesWithContext = [
{ role: 'user', content: `<file path="src/x.ts">\n${fileContent}\n</file>` }, // 附件
...conversationHistory,
]一个贯穿始终的陷阱:别原地改要复用的消息
注入上下文时,常见的做法是“往消息数组里加东西”。但你要非常小心:凡是会流回下一轮 API 的消息,一旦被你原地修改,就可能悄悄毁掉Prompt 缓存。 因为缓存是靠字节级前缀匹配的——你把某条历史消息的内容改动一个字节(哪怕只是“顺手规范化一下路径格式”),从那条消息往后的缓存就全部失效。所以注入时的正确姿势是:生成新的消息数组用于这次请求,而不去改动那些要长期留在历史里、要复用缓存的原始消息。 这个“读时拼装、不改原件”的纪律,是缓存能稳定命中的前提——下一篇会把它讲透。
五、小结:上下文是“拼”出来的,不是“堆”出来的
这一篇的核心认知:发给模型的上下文,是每一轮精心拼装的产物,而不是一个只增不减的消息堆。 它分层、有序、各有各的注入姿势,而贯穿其中的是一条铁律——静态放高处、动态放低处。
搞清楚了“上下文由什么拼成”,两个方向自然打开:
- 往“省”的方向:把靠前的静态部分缓存起来,别每轮重算——这是下一篇 Prompt 缓存。
- 往“减”的方向:当靠后的动态部分(主要是历史)膨胀到装不下,把它压缩掉——这是第 13 篇 自动压缩。
我们先走“省”这条路。下一篇,Prompt 缓存——整个 Agent 里省钱省延迟最立竿见影的一招,以及它那条不容触碰的“字节稳定性”红线。
本篇对应代码:配套仓库把静态 SYSTEM_PROMPT 升级为“基础指令 + 环境上下文”的拼装(src/agent.ts)。git checkout article-11。