第 18 课:动态节详解 — 记忆、MCP 指令与环境

0 阅读6分钟

模块六:System Prompt | 前置依赖:第 17 课 | 预计学习时间:65 分钟


学习目标

完成本课后,你将能够:

  1. 描述 MEMORY.md 的加载、截断与提示注入机制
  2. 解释 MCP 指令的格式化规则及增量通知优化
  3. 说明 computeSimpleEnvInfocomputeEnvInfo 的区别和使用场景
  4. 理解 scratchpad 目录的权限模型
  5. 列举 token budget、函数结果清理等功能性动态节的作用

18.1 记忆注入(Memory)

Claude Code 的记忆系统基于文件 — MEMORY.md 是入口文件,按主题组织的 topic 文件存放具体内容。

文件结构

~/.claude/projects/<project-slug>/memory/
├── MEMORY.md              ← 入口索引(每次加载到 system prompt)
├── user_preferences.md    ← 用户偏好
├── project_structure.md   ← 项目结构
├── feedback_testing.md    ← 测试反馈
└── ...

MEMORY.md 入口截断

入口文件有双重大小限制:

// memdir/memdir.ts
export const ENTRYPOINT_NAME = 'MEMORY.md'
export const MAX_ENTRYPOINT_LINES = 200     // 行数上限
export const MAX_ENTRYPOINT_BYTES = 25_000  // 字节上限(~125 字符/行 x 200 行)

截断逻辑保证不会在行中间截断:

export function truncateEntrypointContent(raw: string): EntrypointTruncation {
  const trimmed = raw.trim()
  const contentLines = trimmed.split('\n')
  
  const wasLineTruncated = contentLines.length > MAX_ENTRYPOINT_LINES
  const wasByteTruncated = trimmed.length > MAX_ENTRYPOINT_BYTES
  
  if (!wasLineTruncated && !wasByteTruncated) {
    return { content: trimmed, ... }
  }
  
  // 先按行截断
  let truncated = wasLineTruncated
    ? contentLines.slice(0, MAX_ENTRYPOINT_LINES).join('\n')
    : trimmed
  
  // 再按字节截断(在最后一个换行处切割)
  if (truncated.length > MAX_ENTRYPOINT_BYTES) {
    const cutAt = truncated.lastIndexOf('\n', MAX_ENTRYPOINT_BYTES)
    truncated = truncated.slice(0, cutAt > 0 ? cutAt : MAX_ENTRYPOINT_BYTES)
  }
  
  // 附加警告信息
  const reason = wasByteTruncated && !wasLineTruncated
    ? `${formatFileSize(byteCount)} (limit: ${formatFileSize(MAX_ENTRYPOINT_BYTES)})
       — index entries are too long`
    : ...
  
  return {
    content: truncated + `\n\n> WARNING: ${ENTRYPOINT_NAME} is ${reason}.
      Only part of it was loaded.`,
    ...
  }
}

记忆提示生成

buildMemoryLines 函数生成完整的记忆行为指导:

export function buildMemoryLines(
  displayName: string,
  memoryDir: string,
  extraGuidelines?: string[],
  skipIndex = false,
): string[] {
  return [
    `# ${displayName}`,
    '',
    `You have a persistent, file-based memory system at \`${memoryDir}\`.
     ${DIR_EXISTS_GUIDANCE}`,
    '',
    'You should build up this memory system over time...',
    '',
    'If the user explicitly asks you to remember something, save it immediately...',
    '',
    ...TYPES_SECTION_INDIVIDUAL,     // 四种记忆类型(user/feedback/project/reference)
    ...WHAT_NOT_TO_SAVE_SECTION,     // 不应保存的内容
    ...howToSave,                    // 保存步骤
    ...WHEN_TO_ACCESS_SECTION,       // 何时访问记忆
    ...TRUSTING_RECALL_SECTION,      // 信任度指引
  ]
}

记忆类型分类

四种记忆类型(闭合分类法):

1. user     — 用户偏好、工作风格、协作方式
2. feedback — 用户对模型行为的反馈
3. project  — 项目特定的上下文信息
4. reference — 参考资料(API 用法、配置说明等)

明确排除:
- 可从代码/git 历史推导的信息
- 临时性上下文
- 代码模式和架构(这些属于 CLAUDE.md)

两步保存流程

保存记忆:

Step 1 — 写 topic 文件:
  user_preferences.md
  ---
  name: 用户偏好
  description: 用户的编码风格和协作偏好
  type: user
  ---
  - 偏好使用 TypeScript
  - 要求 80% 以上测试覆盖

Step 2 — 更新 MEMORY.md 索引:
  - [用户偏好](user_preferences.md) — 编码风格和协作方式

目录预创建

系统在加载 prompt 时预先确保目录存在:

export async function ensureMemoryDirExists(memoryDir: string): Promise<void> {
  const fs = getFsImplementation()
  try {
    await fs.mkdir(memoryDir)  // recursive: true, EEXIST 内部处理
  } catch (e) {
    // 只记录日志,不阻塞 prompt 构建
    logForDebugging(`ensureMemoryDirExists failed: ${e}`)
  }
}

提示中包含 DIR_EXISTS_GUIDANCE

export const DIR_EXISTS_GUIDANCE =
  'This directory already exists — write to it directly with the Write tool
   (do not run mkdir or check for its existence).'

这避免了模型浪费一个 turn 来 lsmkdir -p


18.2 MCP 指令格式化

getMcpInstructions

function getMcpInstructions(mcpClients: MCPServerConnection[]): string | null {
  // 1. 过滤已连接的客户端
  const connectedClients = mcpClients.filter(
    (client): client is ConnectedMCPServer => client.type === 'connected',
  )
  
  // 2. 过滤有 instructions 的客户端
  const clientsWithInstructions = connectedClients.filter(
    client => client.instructions,
  )
  
  if (clientsWithInstructions.length === 0) return null
  
  // 3. 格式化
  const instructionBlocks = clientsWithInstructions
    .map(client => `## ${client.name}\n${client.instructions}`)
    .join('\n\n')
  
  return `# MCP Server Instructions

The following MCP servers have provided instructions for how to use
their tools and resources:

${instructionBlocks}`
}

输出格式示例

# MCP Server Instructions

The following MCP servers have provided instructions...

## github
Use the gh CLI for all GitHub operations...

## database
Always use parameterized queries. Never expose connection strings...

增量通知优化

MCP 指令被标记为 DANGEROUS_uncachedSystemPromptSection,因为服务器可能在 turn 之间连接/断开。但频繁重算会破坏 prompt cache。

mcp_instructions_delta 特性启用时,系统使用 attachment 增量通知代替:

DANGEROUS_uncachedSystemPromptSection(
  'mcp_instructions',
  () => isMcpInstructionsDeltaEnabled()
    ? null                            // 返回 null,不注入 system prompt
    : getMcpInstructionsSection(mcpClients),
  'MCP servers connect/disconnect between turns',
)

增量模式下,新连接的 MCP 服务器的指令通过 attachments.ts 作为消息附件发送,而不是嵌入 system prompt。这样 system prompt 保持稳定,缓存不被破坏。


18.3 环境信息计算

Claude Code 有两个版本的环境信息函数:

computeSimpleEnvInfo — 主 Agent 用

export async function computeSimpleEnvInfo(
  modelId: string,
  additionalWorkingDirectories?: string[],
): Promise<string> {
  const [isGit, unameSR] = await Promise.all([getIsGit(), getUnameSR()])
  
  const envItems = [
    `Primary working directory: ${cwd}`,
    isWorktree ? `This is a git worktree — ...` : null,
    [`Is a git repository: ${isGit}`],
    additionalWorkingDirectories?.length > 0
      ? `Additional working directories:` : null,
    additionalWorkingDirectories?.length > 0
      ? additionalWorkingDirectories : null,   // 作为子列表
    `Platform: ${env.platform}`,
    getShellInfoLine(),          // Shell: zsh
    `OS Version: ${unameSR}`,   // Darwin 24.6.0
    modelDescription,            // You are powered by Claude Opus 4.6...
    knowledgeCutoffMessage,      // Assistant knowledge cutoff is May 2025.
    // Claude Code 产品信息
    `The most recent Claude model family is Claude 4.5/4.6...`,
    `Claude Code is available as CLI, desktop app, web app, IDE extensions.`,
    `Fast mode uses the same model with faster output...`,
  ]
  
  return ['# Environment', ...prependBullets(envItems)].join('\n')
}

computeEnvInfo — 子 Agent 用

export async function computeEnvInfo(
  modelId: string,
  additionalWorkingDirectories?: string[],
): Promise<string> {
  const [isGit, unameSR] = await Promise.all([getIsGit(), getUnameSR()])
  
  return `Here is useful information about the environment you are running in:
<env>
Working directory: ${getCwd()}
Is directory a git repo: ${isGit ? 'Yes' : 'No'}
${additionalDirsInfo}Platform: ${env.platform}
${getShellInfoLine()}
OS Version: ${unameSR}
</env>
${modelDescription}${knowledgeCutoffMessage}`
}

两者对比

┌────────────────────┬──────────────────────┬────────────────────┐
│ 特性               │ computeSimpleEnvInfo │ computeEnvInfo     │
├────────────────────┼──────────────────────┼────────────────────┤
│ 使用场景           │ 主 Agent system prompt│ 子 Agent 增强      │
│ 格式               │ # Environment + 列表 │ <env>...</env> 标签│
│ Git worktree 检测  │ 是                   │ 否                 │
│ 产品信息           │ 是(模型、产品线)   │ 否                 │
│ 模型描述           │ 是                   │ 是                 │
│ Knowledge cutoff   │ 是                   │ 是                 │
└────────────────────┴──────────────────────┴────────────────────┘

Knowledge Cutoff 映射

function getKnowledgeCutoff(modelId: string): string | null {
  const canonical = getCanonicalName(modelId)
  if (canonical.includes('claude-sonnet-4-6'))  return 'August 2025'
  if (canonical.includes('claude-opus-4-6'))    return 'May 2025'
  if (canonical.includes('claude-opus-4-5'))    return 'May 2025'
  if (canonical.includes('claude-haiku-4'))     return 'February 2025'
  if (canonical.includes('claude-opus-4') ||
      canonical.includes('claude-sonnet-4'))    return 'January 2025'
  return null
}

Shell 信息

function getShellInfoLine(): string {
  const shell = process.env.SHELL || 'unknown'
  const shellName = shell.includes('zsh') ? 'zsh'
    : shell.includes('bash') ? 'bash' : shell
    
  if (env.platform === 'win32') {
    return `Shell: ${shellName} (use Unix shell syntax, not Windows
      — e.g., /dev/null not NUL, forward slashes in paths)`
  }
  return `Shell: ${shellName}`
}

Undercover 模式

当 Anthropic 内部的 "undercover" 模式启用时,所有模型名称和 ID 都从环境信息中移除,防止泄露未发布的模型信息:

if (process.env.USER_TYPE === 'ant' && isUndercover()) {
  // suppress — 不注入任何模型描述
} else {
  modelDescription = `You are powered by the model named ${marketingName}...`
}

18.4 Scratchpad 目录

Scratchpad 是一个会话级临时目录,模型可以自由写入而不触发权限弹窗。

export function getScratchpadInstructions(): string | null {
  if (!isScratchpadEnabled()) return null
  
  const scratchpadDir = getScratchpadDir()
  
  return `# Scratchpad Directory

IMPORTANT: Always use this scratchpad directory for temporary files
instead of \`/tmp\` or other system temp directories:
\`${scratchpadDir}\`

Use this directory for ALL temporary file needs:
- Storing intermediate results or data during multi-step tasks
- Writing temporary scripts or configuration files
- Saving outputs that don't belong in the user's project
- Creating working files during analysis or processing
- Any file that would otherwise go to \`/tmp\`

Only use \`/tmp\` if the user explicitly requests it.

The scratchpad directory is session-specific, isolated from the
user's project, and can be used freely without permission prompts.`
}

Scratchpad 的目录名包含 tengu_scratch(tengu 是 Claude Code 的内部代号),位于项目的 session 存储目录下。


18.5 函数结果清理(Function Result Clearing)

当上下文变长时,旧的工具调用结果会被自动清理以释放空间:

function getFunctionResultClearingSection(model: string): string | null {
  if (!feature('CACHED_MICROCOMPACT') || !getCachedMCConfigForFRC) {
    return null
  }
  const config = getCachedMCConfigForFRC()
  if (!config.enabled || !config.systemPromptSuggestSummaries ||
      !isModelSupported) {
    return null
  }
  
  return `# Function Result Clearing

Old tool results will be automatically cleared from context to free up
space. The ${config.keepRecent} most recent results are always kept.`
}

配套的通用提示:

const SUMMARIZE_TOOL_RESULTS_SECTION =
  `When working with tool results, write down any important information
   you might need later in your response, as the original tool result
   may be cleared later.`

这提醒模型:不要假设之前的工具结果一直可用,重要信息应该在回复中记录下来。


18.6 Token Budget 注入

...(feature('TOKEN_BUDGET')
  ? [
      systemPromptSection(
        'token_budget',
        () => 'When the user specifies a token target (e.g., "+500k",
          "spend 2M tokens", "use 1B tokens"), your output token count
          will be shown each turn. Keep working until you approach the
          target — plan your work to fill it productively. The target
          is a hard minimum, not a suggestion. If you stop early, the
          system will automatically continue you.',
      ),
    ]
  : []),

注意这是一个 systemPromptSection(可缓存),而不是 DANGEROUS。虽然 token budget 在 turn 之间可能变化,但提示文本本身是静态的 — 它描述的是"如果有 budget 怎么做"的规则,而不是"当前 budget 是多少"。实际的 token 计数通过其他机制注入。

这个设计选择的注释说明了原因:

// Cached unconditionally — the "When the user specifies..." phrasing
// makes it a no-op with no budget active. Was DANGEROUS_uncached
// (toggled on getCurrentTurnTokenBudget()), busting ~20K tokens per
// budget flip. Not moved to a tail attachment: first-response and
// budget-continuation paths don't see attachments.

18.7 工具使用指引生成

getUsingYourToolsSection 根据当前可用工具动态生成指引:

function getUsingYourToolsSection(enabledTools: Set<string>): string {
  // REPL 模式:精简指引
  if (isReplModeEnabled()) {
    // 只保留 TodoWrite 指引
    return [...]
  }
  
  // 检查是否有嵌入式搜索工具(ant 构建内置 bfs/ugrep)
  const embedded = hasEmbeddedSearchTools()
  
  const providedToolSubitems = [
    `To read files use ${FILE_READ_TOOL_NAME} instead of cat, head, tail, or sed`,
    `To edit files use ${FILE_EDIT_TOOL_NAME} instead of sed or awk`,
    `To create files use ${FILE_WRITE_TOOL_NAME} instead of cat with heredoc...`,
    // 嵌入式搜索构建不需要这两条
    ...(embedded ? [] : [
      `To search for files use ${GLOB_TOOL_NAME} instead of find or ls`,
      `To search the content of files, use ${GREP_TOOL_NAME} instead of grep or rg`,
    ]),
    `Reserve using the ${BASH_TOOL_NAME} exclusively for shell execution...`,
  ]
  
  return [`# Using your tools`, ...prependBullets([
    `Do NOT use the ${BASH_TOOL_NAME} to run commands when a relevant
     dedicated tool is provided...`,
    providedToolSubitems,
    taskToolName ? `Break down and manage your work with ${taskToolName}...` : null,
    `You can call multiple tools in a single response...`,
  ])].join('\n')
}

并行工具调用指引

这是一条经过精心措辞的指引,平衡效率与正确性:

You can call multiple tools in a single response. If you intend to call
multiple tools and there are no dependencies between them, make all
independent tool calls in parallel. Maximize use of parallel tool calls
where possible to increase efficiency. However, if some tool calls
depend on previous calls to inform dependent values, do NOT call these
tools in parallel and instead call them sequentially.

18.8 Language 与 Output Style

语言偏好

function getLanguageSection(languagePreference: string | undefined): string | null {
  if (!languagePreference) return null
  return `# Language
Always respond in ${languagePreference}. Use ${languagePreference} for all
explanations, comments, and communications with the user. Technical terms
and code identifiers should remain in their original form.`
}

语言设置来自 settings.language,由用户在配置中指定。

输出风格

function getOutputStyleSection(outputStyleConfig: OutputStyleConfig | null): string | null {
  if (outputStyleConfig === null) return null
  return `# Output Style: ${outputStyleConfig.name}
${outputStyleConfig.prompt}`
}

当设置了自定义输出风格时,还会影响 Intro Section 的身份定义:

function getSimpleIntroSection(outputStyleConfig): string {
  return `You are an interactive agent that helps users ${
    outputStyleConfig !== null
      ? 'according to your "Output Style" below...'
      : 'with software engineering tasks.'
  }`
}

如果 outputStyleConfig.keepCodingInstructions 为 false,甚至会跳过整个 "Doing Tasks" 静态节。


18.9 动态节组装的完整数据流

getSystemPrompt()
  │
  ├─ 并行获取:
  │  ├─ getSkillToolCommands(cwd)
  │  ├─ getOutputStyleConfig()
  │  └─ computeSimpleEnvInfo(model, dirs)
  │
  ├─ 获取设置:
  │  └─ getInitialSettings() → settings.language
  │
  ├─ 构建工具集:
  │  └─ new Set(tools.map(t => t.name))
  │
  ├─ 注册动态节:
  │  ├─ session_guidance    ← 依赖 enabledTools, skillToolCommands
  │  ├─ memory              ← loadMemoryPrompt()
  │  ├─ ant_model_override  ← ant-only
  │  ├─ env_info_simple     ← cwd, git, platform, model
  │  ├─ language            ← settings.language
  │  ├─ output_style        ← outputStyleConfig
  │  ├─ mcp_instructions    ← DANGEROUS, mcpClients
  │  ├─ scratchpad          ← isScratchpadEnabled()
  │  ├─ frc                 ← feature('CACHED_MICROCOMPACT')
  │  ├─ summarize_tool_results ← 固定文本
  │  ├─ numeric_length_anchors ← ant-only
  │  ├─ token_budget        ← feature('TOKEN_BUDGET')
  │  └─ brief               ← feature('KAIROS')
  │
  ├─ 解析动态节:
  │  └─ resolveSystemPromptSections(dynamicSections)
  │     ├─ 检查缓存(非 DANGEROUS)
  │     ├─ 执行 compute()
  │     └─ 写入缓存
  │
  └─ 组装最终数组:
     [
       introSection,
       systemSection,
       doingTasksSection?,
       actionsSection,
       usingToolsSection,
       toneSection,
       outputEfficiencySection,
       DYNAMIC_BOUNDARY?,
       ...resolvedDynamicSections,
     ].filter(s => s !== null)

课后练习

练习 1:记忆系统探索

阅读 memdir/memdir.ts 中的 loadMemoryPrompt 函数。回答:

  • 如果 MEMORY.md 有 300 行但每行只有 50 字符,会触发哪种截断?
  • 如果 MEMORY.md 有 150 行但总共 30KB,会触发哪种截断?
  • 截断警告信息中包含哪些有用的建议?

练习 2:环境信息对比

手动执行以下操作并对比结果:

  • 在一个 git 仓库中调用 computeSimpleEnvInfo(预期输出什么?)
  • 在一个非 git 目录中调用 computeSimpleEnvInfo(有什么区别?)
  • 在 git worktree 中调用时会多出什么信息?

练习 3:MCP 指令优化

假设你有 3 个 MCP 服务器,其中一个在对话中途断开连接:

  • 非 delta 模式下,system prompt 如何变化?
  • delta 模式下,system prompt 如何保持稳定?
  • 分析两种模式对 prompt cache 命中率的影响

练习 4:动态节优先级

如果需要添加一个新的动态节(例如"当前 git 分支信息"),你应该:

  • 使用 systemPromptSection 还是 DANGEROUS_uncachedSystemPromptSection
  • 放在动态节列表的哪个位置?
  • 如果信息可能每个 turn 变化(用户切换分支),你的决策是否改变?

本课小结

要点内容
记忆系统MEMORY.md 索引 + topic 文件,200 行 / 25KB 截断限制
记忆类型user / feedback / project / reference 四种闭合分类
MCP 指令过滤已连接 + 有 instructions 的服务器,## 格式输出
MCP 优化delta 模式通过 attachment 增量更新,避免缓存破坏
环境信息Simple(主 Agent,列表格式)vs Full(子 Agent,env 标签)
Scratchpad会话级临时目录,免权限写入
Token Budget静态规则文本(可缓存),实际计数通过其他机制注入
FRC函数结果清理提示 + 通用"保存重要信息"提醒

下一课预告

第 19 课:安全指令与 Undercover 模式 — 深入 Claude Code 的安全防线,包括 CYBER_RISK_INSTRUCTION 的设计意图、undercover 模式的信息隐藏策略、prompt injection 防护、以及工具结果中的外部数据信任模型。