#!/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 想成 6 段:
1. 核心身份和行为说明
2. 工具列表
3. skills 元信息
4. memory 内容
5. CLAUDE.md 指令链
6. 动态环境信息
然后按顺序拼起来:
core
+ tools
+ skills
+ memory
+ claude_md
+ dynamic_context
= final system prompt
因为这样会有三个问题:
你很难知道:
哪一段来自哪里
该修改哪一部分
哪一段是固定说明,哪一段是临时上下文
如果 system prompt 是一大坨文本,你很难分别测试:
工具说明生成得对不对
memory 是否被正确拼进去
CLAUDE.md 是否被正确读取
一些稳定内容其实不需要每轮大变。
一些临时内容又只该活一轮。
这就要求你把“稳定块”和“动态块”分开思考。
最重要的一组边界是:
稳定的系统说明
每轮临时变化的提醒
这两类东西不应该混为一谈。
system prompt 适合放:
身份
规则
工具
长期约束
system reminder 适合放:
这一轮才临时需要的补充上下文
当前变动的状态
所以更清晰的做法是:
主 system prompt 保持相对稳定
每轮额外变化的内容,用单独的 reminder 方式追加
教学版可以先这样分:
静态部分:
- core
- tools
- skills
- memory
- CLAUDE.md
动态部分:
- date
- cwd
- model
- current mode
如果你还想再清楚一点,可以加一个边界标记:
=== DYNAMIC_BOUNDARY ===
它的作用不是神秘魔法。
它只是提醒你:
上面更稳定,下面更容易变。
因为它的角色不是“某一次任务的临时上下文”,而是更稳定的长期说明。
教学仓里,最容易理解的链条是:
用户全局级
项目根目录级
当前子目录级
然后全部拼进去,而不是互相覆盖。
这样读者更容易理解“规则来源可以分层叠加”这个思想。
因为 memory 的本质是:
把跨会话仍然有价值的信息,重新带回模型当前的工作环境。
如果保存了 memory,却从来不在系统输入中重新呈现,那它就等于没被真正用起来。
所以 memory 最终一定要进入 prompt 组装链条。
这会让读者看不到系统是如何长大的。
这会把稳定说明和临时提醒搅在一起。
它们都可能进入 prompt,但来源和职责不同:
skills:可选能力或知识包
memory:跨会话记住的信息
CLAUDE.md:长期规则说明
一句话记住:system prompt 的关键不是“写一段很长的话”,而是“把不同来源的信息按清晰边界组装起来”。
角色策略、工作区状态、工具目录、memory、任务焦点都应该作为显式片段按顺序装配。这样模型输入才可审计,控制平面也才讲得清楚。
替代方案
一整段大字符串在代码里看起来更省事,但没人能说清每部分从哪来、顺序为什么这样。Prompt pipeline 才符合真实系统结构。
当稳定规则和每轮运行时数据分离后,指令层级会清晰很多,也更不容易出现提示词结构漂移。每一段输入都更容易单独测试。
替代方案
小 demo 里把所有东西揉在一起还能跑,但一旦 memory、任务状态、恢复提示都要加入输入,混写方式很快就失控。