s10_system_prompt
知无涯学有涯
2026年04月13日 06:37
代码块
Python
自动换行
复制代码
#!/usr/bin/env python3
# Harness: assembly -- the system prompt is a pipeline, not a string.
"""
s10_system_prompt.py - System Prompt Construction

This chapter teaches one core idea:
the system prompt should be assembled from clear sections, not written as one
giant hardcoded blob.

Teaching pipeline:
  1. core instructions
  2. tool listing
  3. skill metadata
  4. memory section
  5. CLAUDE.md chain
  6. dynamic context

The builder keeps stable information separate from information that changes
often. A simple DYNAMIC_BOUNDARY marker makes that split visible.

Per-turn reminders are even more dynamic. They are better injected as a
separate user-role system reminder than mixed blindly into the stable prompt.

Key insight: "Prompt construction is a pipeline with boundaries, not one
big string."
"""

import datetime  # 新增:用于在动态上下文中插入当前日期
import json
import os
import re
import subprocess
from pathlib import Path

from anthropic import Anthropic
from dotenv import load_dotenv

load_dotenv(override=True)

if os.getenv("ANTHROPIC_BASE_URL"):
    os.environ.pop("ANTHROPIC_AUTH_TOKEN", None)

WORKDIR = Path.cwd()
client = Anthropic(base_url=os.getenv("ANTHROPIC_BASE_URL"))
MODEL = os.environ["MODEL_ID"]

# 新增:静态部分与动态部分之间的边界标记。
# 在真实 Claude Code 中,静态前缀会被缓存以节省 token,动态后缀每轮刷新。
DYNAMIC_BOUNDARY = "=== DYNAMIC_BOUNDARY ==="


class SystemPromptBuilder:
    """
    新增:系统提示词构建器,从独立部分组装系统提示词。

    教学目标是清晰性:每个部分有单一的来源和单一的责任。
    这使得提示词更易推理、测试,并随着代理能力增长而演进。
    """

    def __init__(self, workdir: Path = None, tools: list = None):
        self.workdir = workdir or WORKDIR
        self.tools = tools or []
        self.skills_dir = self.workdir / "skills"
        self.memory_dir = self.workdir / ".memory"

    # -- Section 1: Core instructions --
    def _build_core(self) -> str:
        """
        核心指令部分:描述代理的基本身份和行为准则。
        这一部分几乎从不改变,是提示词中最稳定的基石。
        """
        return (
            f"You are a coding agent operating in {self.workdir}.\n"
            "Use the provided tools to explore, read, write, and edit files.\n"
            "Always verify before assuming. Prefer reading files over guessing."
        )

    # -- Section 2: Tool listings --
    def _build_tool_listing(self) -> str:
        """
        工具列表:将工具的 name、参数和 description 以人类可读形式列出。
        这有助于模型在没有调用工具之前就能理解自己的能力边界。
        注意:这里不是替代工具调用的 schema,而是补充说明。
        """
        if not self.tools:
            return ""
        lines = ["# Available tools"]
        for tool in self.tools:
            props = tool.get("input_schema", {}).get("properties", {})
            params = ", ".join(props.keys())
            lines.append(f"- {tool['name']}({params}): {tool['description']}")
        return "\n".join(lines)

    # -- Section 3: Skill metadata (layer 1 from s05 concept) --
    def _build_skill_listing(self) -> str:
        """
        技能元数据列表(延续 s05 的按需加载理念)。
        只列出技能的名称和简短描述,不加载完整技能内容,保持提示词轻量。
        """
        if not self.skills_dir.exists():
            return ""
        skills = []
        for skill_dir in sorted(self.skills_dir.iterdir()):
            skill_md = skill_dir / "SKILL.md"
            if not skill_md.exists():
                continue
            text = skill_md.read_text()
            # 解析 frontmatter 获取 name 和 description
            match = re.match(r"^---\s*\n(.*?)\n---", text, re.DOTALL)
            if not match:
                continue
            meta = {}
            for line in match.group(1).splitlines():
                if ":" in line:
                    k, _, v = line.partition(":")
                    meta[k.strip()] = v.strip()
            name = meta.get("name", skill_dir.name)
            desc = meta.get("description", "")
            skills.append(f"- {name}: {desc}")
        if not skills:
            return ""
        return "# Available skills\n" + "\n".join(skills)

    # -- Section 4: Memory content --
    def _build_memory_section(self) -> str:
        """
        记忆内容:从 .memory 目录加载所有记忆文件,并将完整内容嵌入系统提示词。
        与 s09 不同,这里直接内嵌而非仅加载索引,便于模型直接访问完整信息。
        """
        if not self.memory_dir.exists():
            return ""
        memories = []
        for md_file in sorted(self.memory_dir.glob("*.md")):
            if md_file.name == "MEMORY.md":
                continue
            text = md_file.read_text()
            match = re.match(r"^---\s*\n(.*?)\n---\s*\n(.*)", text, re.DOTALL)
            if not match:
                continue
            header, body = match.group(1), match.group(2).strip()
            meta = {}
            for line in header.splitlines():
                if ":" in line:
                    k, _, v = line.partition(":")
                    meta[k.strip()] = v.strip()
            name = meta.get("name", md_file.stem)
            mem_type = meta.get("type", "project")
            desc = meta.get("description", "")
            memories.append(f"[{mem_type}] {name}: {desc}\n{body}")
        if not memories:
            return ""
        return "# Memories (persistent)\n\n" + "\n\n".join(memories)

    # -- Section 5: CLAUDE.md chain --
    def _build_claude_md(self) -> str:
        """
        加载 CLAUDE.md 文件链,按优先级顺序合并(全部包含):
        1. ~/.claude/CLAUDE.md          (用户全局指令)
        2. <project-root>/CLAUDE.md     (项目指令)
        3. <current-subdir>/CLAUDE.md   (当前子目录特定指令)

        这种层次化设计允许在不同作用域定义指令,且都会生效。
        """
        sources = []

        # 用户全局
        user_claude = Path.home() / ".claude" / "CLAUDE.md"
        if user_claude.exists():
            sources.append(("user global (~/.claude/CLAUDE.md)", user_claude.read_text()))

        # 项目根目录
        project_claude = self.workdir / "CLAUDE.md"
        if project_claude.exists():
            sources.append(("project root (CLAUDE.md)", project_claude.read_text()))

        # 子目录 —— 真实 CC 中会从当前目录向上遍历直到项目根
        # 教学版:检查当前工作目录是否不同于项目根
        cwd = Path.cwd()
        if cwd != self.workdir:
            subdir_claude = cwd / "CLAUDE.md"
            if subdir_claude.exists():
                sources.append((f"subdir ({cwd.name}/CLAUDE.md)", subdir_claude.read_text()))

        if not sources:
            return ""
        parts = ["# CLAUDE.md instructions"]
        for label, content in sources:
            parts.append(f"## From {label}")
            parts.append(content.strip())
        return "\n\n".join(parts)

    # -- Section 6: Dynamic context --
    def _build_dynamic_context(self) -> str:
        """
        动态上下文:包含每轮都可能变化的信息,如当前日期、工作目录、模型、平台。
        这些信息放在 DYNAMIC_BOUNDARY 之后,便于缓存静态部分。
        """
        lines = [
            f"Current date: {datetime.date.today().isoformat()}",
            f"Working directory: {self.workdir}",
            f"Model: {MODEL}",
            f"Platform: {os.uname().sysname}",
        ]
        return "# Dynamic context\n" + "\n".join(lines)

    # -- Assemble all sections --
    def build(self) -> str:
        """
        组装完整系统提示词。

        静态部分(1-5)与动态部分(6)由 DYNAMIC_BOUNDARY 分隔。
        真实 Claude Code 中,静态前缀跨轮次缓存以节省 token。
        """
        sections = []

        core = self._build_core()
        if core:
            sections.append(core)

        tools = self._build_tool_listing()
        if tools:
            sections.append(tools)

        skills = self._build_skill_listing()
        if skills:
            sections.append(skills)

        memory = self._build_memory_section()
        if memory:
            sections.append(memory)

        claude_md = self._build_claude_md()
        if claude_md:
            sections.append(claude_md)

        # 静态/动态边界
        sections.append(DYNAMIC_BOUNDARY)

        dynamic = self._build_dynamic_context()
        if dynamic:
            sections.append(dynamic)

        return "\n\n".join(sections)


def build_system_reminder(extra: str = None) -> dict:
    """
    新增:构建用于每轮动态内容的系统提醒用户消息。

    教学版本将提醒放在稳定系统提示词之外,避免短暂上下文混入长期指令。
    返回一个 user 角色的消息字典,可插入到对话历史中。
    实际循环中未直接使用,但展示了高级模式。
    """
    parts = []
    if extra:
        parts.append(extra)
    if not parts:
        return None
    content = "<system-reminder>\n" + "\n".join(parts) + "\n</system-reminder>"
    return {"role": "user", "content": content}


# -- Tool implementations --
# 以下工具实现与 s09 完全相同,无新增逻辑。
def safe_path(p: str) -> Path:
    path = (WORKDIR / p).resolve()
    if not path.is_relative_to(WORKDIR):
        raise ValueError(f"Path escapes workspace: {p}")
    return path


def run_bash(command: str) -> str:
    dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"]
    if any(d in command for d in dangerous):
        return "Error: Dangerous command blocked"
    try:
        r = subprocess.run(command, shell=True, cwd=WORKDIR,
                           capture_output=True, text=True, timeout=120)
        out = (r.stdout + r.stderr).strip()
        return out[:50000] if out else "(no output)"
    except subprocess.TimeoutExpired:
        return "Error: Timeout (120s)"


def run_read(path: str, limit: int = None) -> str:
    try:
        lines = safe_path(path).read_text().splitlines()
        if limit and limit < len(lines):
            lines = lines[:limit] + [f"... ({len(lines) - limit} more)"]
        return "\n".join(lines)[:50000]
    except Exception as e:
        return f"Error: {e}"


def run_write(path: str, content: str) -> str:
    try:
        fp = safe_path(path)
        fp.parent.mkdir(parents=True, exist_ok=True)
        fp.write_text(content)
        return f"Wrote {len(content)} bytes"
    except Exception as e:
        return f"Error: {e}"


def run_edit(path: str, old_text: str, new_text: str) -> str:
    try:
        fp = safe_path(path)
        content = fp.read_text()
        if old_text not in content:
            return f"Error: Text not found in {path}"
        fp.write_text(content.replace(old_text, new_text, 1))
        return f"Edited {path}"
    except Exception as e:
        return f"Error: {e}"


# 工具调度映射(无变化)
TOOL_HANDLERS = {
    "bash":       lambda **kw: run_bash(kw["command"]),
    "read_file":  lambda **kw: run_read(kw["path"], kw.get("limit")),
    "write_file": lambda **kw: run_write(kw["path"], kw["content"]),
    "edit_file":  lambda **kw: run_edit(kw["path"], kw["old_text"], kw["new_text"]),
}

# 工具定义(无变化)
TOOLS = [
    {"name": "bash", "description": "Run a shell command.",
     "input_schema": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}},
    {"name": "read_file", "description": "Read file contents.",
     "input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "limit": {"type": "integer"}}, "required": ["path"]}},
    {"name": "write_file", "description": "Write content to file.",
     "input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}},
    {"name": "edit_file", "description": "Replace exact text in file.",
     "input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}},
]

# 全局提示词构建器实例,后续循环中调用其 build() 方法生成系统提示词
prompt_builder = SystemPromptBuilder(workdir=WORKDIR, tools=TOOLS)


def agent_loop(messages: list):
    """
    修改:代理循环现在每次迭代都通过 prompt_builder 组装系统提示词。
    在真实 CC 中,静态前缀会被缓存,仅动态后缀每轮刷新以节省 token。
    """
    while True:
        system = prompt_builder.build()
        response = client.messages.create(
            model=MODEL, system=system, messages=messages,
            tools=TOOLS, max_tokens=8000,
        )
        messages.append({"role": "assistant", "content": response.content})

        if response.stop_reason != "tool_use":
            return

        results = []
        for block in response.content:
            if block.type != "tool_use":
                continue
            handler = TOOL_HANDLERS.get(block.name)
            try:
                output = handler(**(block.input or {})) if handler else f"Unknown: {block.name}"
            except Exception as e:
                output = f"Error: {e}"
            print(f"> {block.name}: {str(output)[:200]}")
            results.append({
                "type": "tool_result",
                "tool_use_id": block.id,
                "content": str(output),
            })

        messages.append({"role": "user", "content": results})


if __name__ == "__main__":
    # 新增:启动时显示组装后的提示词长度和大致节数,教学用途
    full_prompt = prompt_builder.build()
    section_count = full_prompt.count("\n# ")
    print(f"[System prompt assembled: {len(full_prompt)} chars, ~{section_count} sections]")

    # 新增:/prompt 命令显示完整组装好的系统提示词
    # 新增:/sections 命令显示提示词的大纲(各级标题和边界)
    history = []
    while True:
        try:
            query = input("\033[36ms10 >> \033[0m")
        except (EOFError, KeyboardInterrupt):
            break
        if query.strip().lower() in ("q", "exit", ""):
            break

        if query.strip() == "/prompt":
            print("--- System Prompt ---")
            print(prompt_builder.build())
            print("--- End ---")
            continue

        if query.strip() == "/sections":
            prompt = prompt_builder.build()
            for line in prompt.splitlines():
                if line.startswith("# ") or line == DYNAMIC_BOUNDARY:
                    print(f"  {line}")
            continue

        history.append({"role": "user", "content": query})
        agent_loop(history)
        response_content = history[-1]["content"]
        if isinstance(response_content, list):
            for block in response_content:
                if hasattr(block, "text"):
                    print(block.text)
        print()
复制成功

这一章为什么重要

很多初学者一开始会把 system prompt 写成一大段固定文本。

这样在最小 demo 里当然能跑。

但一旦系统开始长功能,你很快会遇到这些问题:

  • 工具列表会变

  • skills 会变

  • memory 会变

  • 当前目录、日期、模式会变

  • 某些提醒只在这一轮有效,不该永远塞进系统说明

所以到了这个阶段,system prompt 不能再当成一块硬编码文本。

它应该升级成:

由多个来源共同组装出来的一条流水线。

先解释几个名词

什么是 system prompt

system prompt 是给模型的系统级说明。

它通常负责告诉模型:

  • 你是谁

  • 你能做什么

  • 你应该遵守什么规则

  • 你现在处在什么环境里

什么是“组装流水线”

意思是:

  • 不同信息来自不同地方

  • 最后按顺序拼接成一份输入

它不是一个死字符串,而是一条构建过程。

什么是动态信息

有些信息经常变化,例如:

  • 当前日期

  • 当前工作目录

  • 本轮新增的提醒

这些信息不适合和所有稳定说明混在一起。

最小心智模型

最容易理解的方式,是把 system prompt 想成 6 段:

代码块
PlainText
自动换行
复制代码
1. 核心身份和行为说明
2. 工具列表
3. skills 元信息
4. memory 内容
5. CLAUDE.md 指令链
6. 动态环境信息
复制成功

然后按顺序拼起来:

代码块
PlainText
自动换行
复制代码
core
+ tools
+ skills
+ memory
+ claude_md
+ dynamic_context
= final system prompt
复制成功

为什么不能把所有东西都硬塞进一个大字符串

因为这样会有三个问题:

1. 不好维护

你很难知道:

  • 哪一段来自哪里

  • 该修改哪一部分

  • 哪一段是固定说明,哪一段是临时上下文

2. 不好测试

如果 system prompt 是一大坨文本,你很难分别测试:

  • 工具说明生成得对不对

  • memory 是否被正确拼进去

  • CLAUDE.md 是否被正确读取

3. 不好做缓存和动态更新

一些稳定内容其实不需要每轮大变。

一些临时内容又只该活一轮。

这就要求你把“稳定块”和“动态块”分开思考。

这一章最关键的结构化边界

边界 1:稳定说明 vs 动态提醒

最重要的一组边界是:

  • 稳定的系统说明

  • 每轮临时变化的提醒

这两类东西不应该混为一谈。

边界 2:system prompt vs system reminder

system prompt 适合放:

  • 身份

  • 规则

  • 工具

  • 长期约束

system reminder 适合放:

  • 这一轮才临时需要的补充上下文

  • 当前变动的状态

所以更清晰的做法是:

  • 主 system prompt 保持相对稳定

  • 每轮额外变化的内容,用单独的 reminder 方式追加

一个实用的教学版本

教学版可以先这样分:

代码块
PlainText
自动换行
复制代码
静态部分:
- core
- tools
- skills
- memory
- CLAUDE.md

动态部分:
- date
- cwd
- model
- current mode
复制成功

如果你还想再清楚一点,可以加一个边界标记:

代码块
PlainText
自动换行
复制代码
=== DYNAMIC_BOUNDARY ===
复制成功

它的作用不是神秘魔法。

它只是提醒你:

上面更稳定,下面更容易变。

CLAUDE.md 为什么要单独一段

因为它的角色不是“某一次任务的临时上下文”,而是更稳定的长期说明。

教学仓里,最容易理解的链条是:

  1. 用户全局级

  2. 项目根目录级

  3. 当前子目录级

然后全部拼进去,而不是互相覆盖。

这样读者更容易理解“规则来源可以分层叠加”这个思想。

memory 为什么要和 system prompt 有关系

因为 memory 的本质是:

把跨会话仍然有价值的信息,重新带回模型当前的工作环境。

如果保存了 memory,却从来不在系统输入中重新呈现,那它就等于没被真正用起来。

所以 memory 最终一定要进入 prompt 组装链条。

初学者最容易混淆的点

1. 把 system prompt 讲成一个固定字符串

这会让读者看不到系统是如何长大的。

2. 把所有变化信息都塞进 system prompt

这会把稳定说明和临时提醒搅在一起。

3. 把 CLAUDE.md、memory、skills 写成同一种东西

它们都可能进入 prompt,但来源和职责不同:

  • skills:可选能力或知识包

  • memory:跨会话记住的信息

  • CLAUDE.md:长期规则说明

一句话记住:system prompt 的关键不是“写一段很长的话”,而是“把不同来源的信息按清晰边界组装起来”。


系统提示词应被实现成装配流水线

角色策略、工作区状态、工具目录、memory、任务焦点都应该作为显式片段按顺序装配。这样模型输入才可审计,控制平面也才讲得清楚。

替代方案

一整段大字符串在代码里看起来更省事,但没人能说清每部分从哪来、顺序为什么这样。Prompt pipeline 才符合真实系统结构。

稳定策略与运行时状态必须分开

当稳定规则和每轮运行时数据分离后,指令层级会清晰很多,也更不容易出现提示词结构漂移。每一段输入都更容易单独测试。

替代方案

小 demo 里把所有东西揉在一起还能跑,但一旦 memory、任务状态、恢复提示都要加入输入,混写方式很快就失控。