拆.
上下文与记忆——AI 怎么'记得' · 第 02

System Prompt 6 步拼装链

system prompt 不是静态文本,是动态组装

4400读完约 23 分钟craft:B+发布于 2026-06-21

一、原理:system prompt 不是静态文本

新手做 AI 产品时常常这样写 system prompt:

SYSTEM_PROMPT = """You are a helpful coding assistant.
Help users solve programming problems...
"""

response = llm.chat(
  system=SYSTEM_PROMPT,
  messages=conversation_history
)

一个全局常量字符串,每次请求都用同一份。

这种写法在简单 Chatbot 场景下够用——产品的"人设"不变、能力不变、环境不变。

但在 AI Agent 场景下完全不够用。因为 Agent 的 system prompt 必须包含:

  • 当前 agent 模式(plan vs build)—— 用户切换 mode 时不一样
  • 当前环境(cwd、git status、time)—— 每个 turn 都可能变
  • 当前模型(Claude vs GPT)—— 用户切模型时不一样
  • 当前可用工具——agent 类型决定可用 tool 不同
  • 用户的项目特定指令(AGENTS.md)—— 每个项目不同

如果你用静态字符串,这些"动态信息"没地方放。结果是 LLM 不知道自己在哪、要做什么、能做什么——决策质量崩。

正确的做法是——每次请求时动态组装 system prompt

具体说,system prompt 由几个"层"叠加:

完整 system prompt = 
  agent 人设
+ 项目级指令 (AGENTS.md)
+ 用户级指令 (CLAUDE.md)  
+ skill 内容 (如果加载了)
+ 环境信息
+ mode 切换提醒 (如果刚切了 mode)

每个层独立来源——产品代码、项目文件、用户配置、运行时状态。每次请求时按顺序拼起来。

这种"动态组装"看似复杂——但它是 AI Agent 唯一能正确工作的方式。把这套组装逻辑做对,产品就对了一半。

opencode 的组装逻辑叫做6 步拼装链。我们看具体怎么做。

二、案例:opencode 的 6 步拼装链

opencode 的 system prompt 组装在 packages/opencode/src/session/llm/request.ts 第 58-66 行。简化版本:

system = [
  ...(input.agent.prompt 
    ? [input.agent.prompt] 
    : SystemPrompt.provider(input.model)),
  ...input.system,
  ...(input.user.system ? [input.user.system] : []),
]

这 5 行代码里隐含 6 个步骤。我们一个个解释。

步骤 1:选 agent 人设 或 provider 默认

input.agent.prompt 
  ? [input.agent.prompt] 
  : SystemPrompt.provider(input.model)

如果当前 agent 有自定义 prompt——用 agent 的。这适用于用户在 .opencode/agents/ 里自定义的 agent——它们有自己的人设。

如果 agent 没自定义——用 SystemPrompt.provider(model)。这个函数返回 5 张脸(anthropic.txt / gpt.txt / gemini.txt / beast.txt / default.txt)中的一个,根据当前模型族选。

这是第一步——选定基础人设。

步骤 2:拼项目级指令

...input.system

input.system 数组包含了:

  • AGENTS.md 内容(项目根 + 父目录里向上找的第一个)
  • CLAUDE.md 内容(同样是查找规则)
  • CONTEXT.md 内容(备选)
  • config.instructions 里指定的额外文件内容(用户自己加的)

如果你的项目根有 AGENTS.md,里面写"用 TypeScript、不写 var、测试用 vitest"——这些规则会被读进来,附加到 system prompt。

步骤 3:拼用户级指令(如果有)

...(input.user.system ? [input.user.system] : [])

input.user.system 是用户自己临时加的指令。可以是 slash command、可以是 plugin、可以是其他动态来源。

如果用户在当前对话里发出 /strict-mode 命令——这条命令的内容可能被加到 input.user.system,附加到 system prompt 末尾。

这是步骤 3——动态用户级。

步骤 4:Plugin hook 转换

const finalSystem = await plugins.transform(
  "experimental.chat.system",
  rawSystem
)

opencode 有 plugin 系统——允许第三方 plugin 拦截 system prompt 做修改。Plugin 可以在中间插入自己的指令、可以修改原指令、可以加 metadata。

这是步骤 4——给 plugin 留出口子。

步骤 5:环境信息追加

finalSystem.push(SystemPrompt.environment(ctx))

SystemPrompt.environment() 返回当前环境信息:

You are powered by the model named claude-3-5-sonnet. 
The exact model ID is anthropic/claude-3-5-sonnet.

Here is some useful information about the environment you are running in:
<env>
  Working directory: /Users/lee/project
  Workspace root folder: /Users/lee/project
  Is directory a git repo: yes
  Platform: darwin
  Today's date: Sun Jun 21 2026
</env>

每次请求都重新生成——cwd、git status、date 可能变了。

放在 system prompt 末尾——LLM 学会"末尾有 env 信息可查"。固定位置 + 动态内容。

这是步骤 5——环境注入。

步骤 6:转换成 provider 期望格式

不同 provider 期望的 system prompt 格式不一样:

  • Anthropic:单独的 system 字段
  • OpenAI:放在 messages 数组的第一项 (role: "system")
  • Gemini:放在 systemInstruction 字段
  • Bedrock:单独的 system 字段

opencode 的 adapter 层把统一的 system 数组翻译成 provider 期望的格式。这是 protocol adapter 层做的(第 6 篇会讲)。

这是步骤 6——provider 适配。

6 步串起来

每个 LLM 请求都按这 6 步组装:

  1. 选基础人设(agent prompt 或 5 张脸之一)
  2. 拼项目级指令(AGENTS.md 等)
  3. 拼用户级指令(如果有)
  4. plugin hook 修改
  5. 追加环境信息
  6. 翻译成 provider 格式

完整的 system prompt 是这 6 层叠加的结果。每次请求都重新算——不缓存基础人设、不缓存项目指令、不缓存环境。

为什么不缓存?因为任何一层都可能变

  • 切换 model → 步骤 1 变
  • 用户改了 AGENTS.md → 步骤 2 变
  • 用户发了 slash command → 步骤 3 变
  • 安装了新 plugin → 步骤 4 变
  • 改了系统时间或 cd 到别的目录 → 步骤 5 变
  • 切换 provider → 步骤 6 变

每次都重组保证实时性——LLM 看到的永远是当下最新的状态。

代价是性能——每次组装要读文件、查环境、跑 plugin hook。但单次组装才几毫秒——不是主要成本。

主要成本是失去 prompt caching——如果 system prompt 不变,Anthropic 的 prompt cache 能命中 90% 折扣。但每次都重组的话——cache 命中率低。

opencode 怎么平衡?通过结构稳定——尽管内容每次重算,但位置不变:人设永远第一、AGENTS 永远第二、env 永远最后。这种"稳定结构"让 cache 命中率能保持在 60-70%——不完美但够用。

三、设计启示:每个 turn 都重组的设计判断

这一章的核心论点:system prompt 是动态组装的——不要把它当静态字符串

如果你做 AI 产品,下面几条原则有用:

1. 设计组装函数而非编辑字符串

不要在代码里写一个 SYSTEM_PROMPT = "..." 全局常量。写一个 buildSystemPrompt(context) 函数——每次请求时根据当前状态生成。

这种姿态从一开始就让产品可扩展——以后加 mode 切换、加 plugin、加 user override 都自然。

2. 分层组织 system prompt

把 system prompt 拆成几个独立的层:

  • 静态人设(变化频率低)
  • 项目指令(按项目变)
  • 用户指令(按 session 变)
  • 环境信息(按 turn 变)

每层独立来源、独立维护。组装函数把它们叠加。

3. 重要内容前置

固定的核心约束("don't lie"、"don't generate URLs")放最前面——开头 attention 强。

灵活的环境信息(cwd、time)放末尾——LLM 学会"末尾查环境"。

中间填充"次要细节"——容忍 lost in the middle。

4. plugin hook 留口子

哪怕你现在没有 plugin 系统——预留 hook。让 system prompt 组装有个"中间步骤可以修改"的口子。

以后想加新功能(API key 验证、用户层级、个性化指令)有地方插入。

5. 监控 prompt caching 命中率

如果你用 Anthropic,prompt caching 能省 90% input cost。监控 cache 命中率——低了找原因(哪一段经常变)、优化结构。

opencode 通过"稳定位置"保持 60-70% 命中率——可以参考。

6. 不要害怕每次重组

每次请求重组 system prompt 听起来浪费——但实际几毫秒。换来的是 LLM 看到最新状态——值得。

不要为了"省 0.5 毫秒"缓存 system prompt——会导致 LLM 看到过期信息。

7. provider 适配在底层

让组装函数返回"统一的中间格式"(数组或对象),让 protocol adapter 翻译成 provider 期望的格式。

这种分离让你的产品能跨 provider——加新 provider 只需要加 adapter,不改组装逻辑。

8. 测试每一层独立可工作

每个组装步骤要可测——给定输入,输出确定。

不要让组装函数依赖全局状态、随机数、外部 IO——会让测试困难。

最后一个观察。System prompt 组装看似"工程细节"——但它是 AI 产品最底层、最频繁、最影响质量的代码。每次 LLM 调用都跑一次组装,每次产品行为都受组装结果影响。

把组装做好——简单、清晰、可扩展——是 AI 产品工程的"硬功夫"。Demo 里看不到,但生产环境每天救你十次。

opencode 的 6 步拼装链是个不错的模板。你的产品可能不需要 6 步——可能 3 步够用。但分层组装的思路值得借鉴——把 system prompt 当数据流处理,不当静态字符串。

下一章 4.3 我们看 6 步拼装链里特别的一步——AGENTS.md / CLAUDE.md 的加载。这是用户给 AI"长期记忆"的机制。