# 11 · 系统提示与上下文注入：每轮喂给模型的那坨东西，到底是怎么拼的

> 上下文远不止消息历史。每轮发给模型的 prompt，是由系统提示、环境信息、项目记忆、动态附件层层拼装出来的。讲清各层该放什么、放哪里，以及一个贯穿始终的分界原则——静态的放高处、动态的放低处，这直接决定了下一篇 Prompt 缓存的成败。

- 作者：David（道雾轩）
- 专栏：从 LLM 到 Coding Agent（https://daiw.org/manual/llm-to-agent.md）
- 最后更新：2026-09-13
- 原文：https://daiw.org/manual/llm-to-agent/context-injection
- 转载与引用：请注明出处并附原文链接（https://daiw.org/about/copyright）

# 11 · 系统提示与上下文注入

[上一篇](https://daiw.org/manual/llm-to-agent/token-budget)我们学会了量上下文的大小。但在谈怎么给它“减负”之前，得先看清：**每一轮实际发给模型的那坨东西，到底由哪些部分拼成？** 很多人以为“上下文 = 消息历史”，这只对了一小半。

## 一、背景：模型需要“知道自己身处何地”

一个纯粹的消息历史，缺了很多模型干活必需的信息：

- 它在**哪个目录**下工作？当前是什么**操作系统**？**今天几号**？
- 这个项目有没有**特定约定**(用 pnpm 不用 npm、测试命令是什么、代码风格)？
- 用户**这次**提到的那个文件，内容是什么？

这些都不在“用户说的话”里，但模型必须知道，否则它只能瞎猜(还记得[01 篇](https://daiw.org/manual/llm-to-agent/raw-llm-api)那个“连今天几号都不知道”的裸模型吗)。所以真实 Agent 每一轮都会把一堆信息**注入**进上下文。搞清楚注入了什么、注入在哪，是上下文工程的必修课。

## 二、解决方案：分层拼装一个 prompt

每一轮发给模型的完整 prompt，大致由这几层拼成，从“最静态”到“最动态”排列：

```text title="一次请求的上下文分层(从静态到动态)"
┌─ 系统提示(system)———————————— 最静态、最大、最该被缓存
│    · 角色设定、行为准则
│    · 工具使用指南、输出规范
├─ 环境上下文 ——————————————— 每次会话基本不变
│    · cwd、操作系统、日期、shell
├─ 项目记忆 / 用户偏好 ———————— 每个项目固定
│    · 项目约定(测试命令、风格)、用户长期偏好
├─ 消息历史 ————————————————— 随对话增长
│    · user / assistant / tool_use / tool_result …
└─ 动态附件 ————————————————— 每轮可能变化,最该放在最后
     · 本轮引用的文件、检索到的相关记忆、目录结构
```

每一层各司其职：

- **系统提示**：定义“你是谁、怎么干活”。它往往很大(几千 token 的行为规范和工具指南)，而且**基本不变**。
- **环境上下文**：让模型知道“此时此地”。通常**追加在系统提示之后**，或作为一条会话开头的元信息。
- **项目记忆 / 用户偏好**：项目级的约定(那种 `AGENTS.md` / `CLAUDE.md` 之类的文件)、用户的长期偏好。通常**前置注入到对话开头**。
- **消息历史**：就是[前面](https://daiw.org/manual/llm-to-agent/agent-loop)一直在滚的那个 `messages`。
- **动态附件**：本轮才需要的、易变的东西——用户刚引用的文件、按相关性临时检索出的记忆片段。

## 三、决定性原则：静态放高处，动态放低处

上面那个“从静态到动态”的排列不是随便排的。它遵循一条**贯穿整个上下文工程**的黄金法则：

<Callout type="warn">
  **把不变的东西放前面，把易变的东西放后面。** 这条原则有两个理由，一个比一个重要：

  **① 缓存**(下一篇的主角)：LLM API 的 prompt 缓存是**前缀匹配**的——只有从头开始、一字不差的那段前缀才能命中缓存。所以你把大而稳定的系统提示放最前面，它就能在每一轮里被缓存复用，省下大笔重复计算的钱和延迟。反过来，只要你在靠前的位置塞一个每轮都变的东西(比如把当前时间戳放进系统提示开头)，它后面的一切缓存**全部作废**。

  **② 模型注意力**：近期的、靠后的信息，模型通常给予更高的权重。把“本轮最相关的文件”放在最靠近提问的位置，比埋在几千 token 的历史中间，更容易被模型抓住。
</Callout>

这条原则会在下一篇[Prompt 缓存](https://daiw.org/manual/llm-to-agent/prompt-caching)里变成实打实的钱和延迟，这里先把它刻下来。

## 四、实现细节：注入的两种姿势与一个陷阱

### 姿势一：追加到系统提示

环境信息这类“整场会话不变”的东西，适合拼到系统提示末尾：

```ts title="系统提示 = 基础指令 + 环境上下文"
function buildSystemPrompt(base: string, env: EnvInfo): string {
  return `${base}

<env>
工作目录: ${env.cwd}
操作系统: ${env.os}
今天日期: ${env.date}
</env>`
}
```

注意：环境信息在**一次会话内**通常不变(你不会中途换操作系统)，所以放进系统提示不会破坏缓存。但**日期**是个微妙的例外——如果你精确到秒，它每轮都变，就成了缓存杀手；精确到“天”就安全。**注入动态信息前，先想想它多久变一次。**

### 姿势二：作为消息注入

用户引用的文件、检索到的记忆，则更适合作为**消息**注入到历史里(通常紧挨着用户的提问)：

```ts title="把附件作为消息前置注入"
const messagesWithContext = [
  { role: 'user', content: `<file path="src/x.ts">\n${fileContent}\n</file>` }, // 附件
  ...conversationHistory,
]
```

### 一个贯穿始终的陷阱：别原地改要复用的消息

<Callout type="warn">
  **注入上下文时，常见的做法是“往消息数组里加东西”。但你要非常小心：凡是会流回下一轮 API 的消息，一旦被你原地修改，就可能悄悄毁掉[Prompt 缓存](https://daiw.org/manual/llm-to-agent/prompt-caching)。** 因为缓存是靠**字节级前缀匹配**的——你把某条历史消息的内容改动一个字节(哪怕只是“顺手规范化一下路径格式”)，从那条消息往后的缓存就全部失效。所以注入时的正确姿势是：**生成新的消息数组用于这次请求，而不去改动那些要长期留在历史里、要复用缓存的原始消息。** 这个“读时拼装、不改原件”的纪律，是缓存能稳定命中的前提——下一篇会把它讲透。
</Callout>

## 五、小结：上下文是“拼”出来的，不是“堆”出来的

这一篇的核心认知：**发给模型的上下文，是每一轮精心拼装的产物，而不是一个只增不减的消息堆。** 它分层、有序、各有各的注入姿势，而贯穿其中的是一条铁律——**静态放高处、动态放低处**。

搞清楚了“上下文由什么拼成”，两个方向自然打开：

- 往“省”的方向：把靠前的静态部分**缓存**起来，别每轮重算——这是[下一篇 Prompt 缓存](https://daiw.org/manual/llm-to-agent/prompt-caching)。
- 往“减”的方向：当靠后的动态部分(主要是历史)膨胀到装不下，把它**压缩**掉——这是[第 13 篇 自动压缩](https://daiw.org/manual/llm-to-agent/auto-compaction)。

我们先走“省”这条路。下一篇，Prompt 缓存——整个 Agent 里**省钱省延迟最立竿见影**的一招，以及它那条不容触碰的“字节稳定性”红线。

---

**本篇对应代码**：配套仓库把静态 `SYSTEM_PROMPT` 升级为“基础指令 + 环境上下文”的拼装(`src/agent.ts`)。`git checkout article-11`。
