组装 · 缓存 · 注入防护 — 从 20 个 Agent 项目源码提炼的提示词工程方法论
系统提示词(system prompt)是 Agent 行为的灵魂。它决定了模型"是谁"、"能做什么"、"怎么做"。20 个 Agent 项目展示了从简单到复杂的四种组装范式:
Smolagents Agno
最简单的方式——直接在代码中定义系统提示词字符串,作为类属性或常量。优点是简单直接,缺点是无法动态适配环境。
# Smolagents: smolagents/agent.py SYSTEM_PROMPT = """You are a helpful agent that can use tools to answer questions.""" # 没有变量注入,没有分层,一个字符串到底
Aider
Aider 的每个 Coder 子类(15 种 edit format)都有独立的 prompt 类,使用 Python .format() 模板注入运行时变量:
# Aider: coders/editblock_prompts.py
class EditBlockPrompts(CoderPrompts):
main_system = """Act as an expert software developer.
Always use best practices when coding.
{final_reminders}
Take requests for changes to the supplied code.
{shell_cmd_prompt}
"""
变量在 fmt_system_prompt() 中注入——fence 类型、用户语言、平台信息、shell 命令提示等:
# Aider: coders/base_coder.py → fmt_system_prompt()
def fmt_system_prompt(self, prompt):
final_reminders = []
if self.main_model.lazy:
final_reminders.append(self.gpt_prompts.lazy_prompt)
if self.main_model.overeager:
final_reminders.append(self.gpt_prompts.overeager_prompt)
user_lang = self.get_user_language()
if user_lang:
final_reminders.append(f"Reply in {user_lang}.\n")
# ... 注入 platform_text, shell_cmd_prompt, language 等
prompt = prompt.format(
fence=self.fence,
final_reminders=final_reminders,
platform=platform_text,
shell_cmd_prompt=shell_cmd_prompt,
language=language,
)
return prompt
Hermes 是唯一显式实现三层分离的项目,这是提示词缓存的前提条件:
Hermes 的 Context Tier 实现了一个优先级链:.hermes.md > AGENTS.md > CLAUDE.md > .cursorrules。先找到谁用谁——只加载一个项目上下文文件。这避免了多个文件重复注入相同信息导致的 token 浪费和指令冲突。
# prompt_builder.py → build_context_files_prompt()
project_context = (
_load_hermes_md(cwd_path) # 最高优先级:walk to git root
or _load_agents_md(cwd_path) # cwd only
or _load_claude_md(cwd_path) # cwd only
or _load_cursorrules(cwd_path) # 最低:cwd only
)
SOUL.md 可以出现在两个位置:作为 Stable Tier 的身份(来自 HERMES_HOME),或作为 Context Tier 的项目文件(来自 cwd)。当它已在 Stable 层加载时,Context 层跳过(skip_soul=True),避免重复注入。
OpenClaw 将提示词组装抽象为 ContextEngine 接口,支持可插拔的上下文管理引擎:
// OpenClaw: context-engine/types.ts
export interface ContextEngine {
assemble(params: {
sessionId: string;
messages: AgentMessage[];
tokenBudget?: number; // ← Token 预算驱动
availableTools?: Set; // ← 工具感知
model?: string; // ← 模型感知
prompt?: string; // ← 当前用户输入
}): Promise<AssembleResult>;
compact(params: { ... }): Promise<CompactResult>;
maintain?(params: { ... }): Promise<ContextEngineMaintenanceResult>;
// ... bootstrap, ingest, afterTurn, subagent lifecycle
}
AssembleResult 返回有序消息列表、估计 token 数、可选的 systemPromptAddition 和 contextProjection 生命周期管理:
export type AssembleResult = {
messages: AgentMessage[];
estimatedTokens: number;
promptAuthority?: "assembled" | "preassembly_may_overflow";
systemPromptAddition?: string;
contextProjection?: ContextEngineProjection; // "per_turn" | "thread_bootstrap"
};
contextProjection 是独有设计:"thread_bootstrap" 模式允许后端线程只注入一次上下文,后续轮次复用直到 epoch 变化。这对持久化后端线程(如 Anthropic 的 server-side threads)至关重要——避免了每轮重新传输相同的系统提示词前缀。提示词缓存是 Agent 系统中最容易被忽视、但影响最大的优化。20 个项目中只有 3 个有系统性的缓存策略:
Aider 使用 Anthropic 的 cache_control API,在消息的特定位置标记缓存断点:
# Aider: coders/chat_chunks.py
@dataclass
class ChatChunks:
system: List = field(default_factory=list)
examples: List = field(default_factory=list)
done: List = field(default_factory=list)
repo: List = field(default_factory=list) # ← RepoMap
readonly_files: List = field(default_factory=list)
chat_files: List = field(default_factory=list) # ← 当前编辑的文件
cur: List = field(default_factory=list)
reminder: List = field(default_factory=list)
def add_cache_control_headers(self):
# 断点1: examples(或 system)
if self.examples:
self.add_cache_control(self.examples)
else:
self.add_cache_control(self.system)
# 断点2: repo(readonly_files + repomap)
if self.repo:
self.add_cache_control(self.repo)
else:
self.add_cache_control(self.readonly_files)
# 断点3: chat_files
self.add_cache_control(self.chat_files)
关键设计:done 消息(历史对话)没有被缓存——因为它们每轮都在变化(新消息加入)。而 repo(RepoMap)和 chat_files 在同一编辑会话中相对稳定,适合缓存。
Hermes 使用更简洁的 4 断点策略——system prompt + 最后 3 条非系统消息:
# Hermes: agent/prompt_caching.py
def apply_anthropic_cache_control(
api_messages: List[Dict[str, Any]],
cache_ttl: str = "5m", # ← 支持 5m 和 1h 两种 TTL
native_anthropic: bool = False,
) -> List[Dict[str, Any]]:
"""system_and_3 策略:system + 最后 3 条非系统消息"""
messages = copy.deepcopy(api_messages)
marker = _build_marker(cache_ttl)
breakpoints_used = 0
# 断点1: system prompt
if messages[0].get("role") == "system":
_apply_cache_marker(messages[0], marker, native_anthropic=native_anthropic)
breakpoints_used += 1
# 断点2-4: 最后 3 条非系统消息
remaining = 4 - breakpoints_used
non_sys = [i for i in range(len(messages))
if messages[i].get("role") != "system"]
for idx in non_sys[-remaining:]:
_apply_cache_marker(messages[idx], marker, native_anthropic=native_anthropic)
return messages
OpenClaw 是唯一实现了 提示词缓存可观测性 的项目——不仅能设缓存,还能诊断缓存为什么失效:
// OpenClaw: prompt-cache-observability.ts
type PromptCacheSnapshot = {
provider: string;
modelId: string;
cacheRetention?: "none" | "short" | "long";
streamStrategy: string;
transport?: string;
systemPromptDigest: string; // ← SHA-256 摘要
toolDigest: string; // ← 工具集摘要
toolCount: number;
toolNames: string[];
};
// 自动检测缓存断裂原因
function diffSnapshots(previous, next): PromptCacheChange[] {
if (previous.systemPromptDigest !== next.systemPromptDigest)
changes.push({ code: "systemPrompt", detail: "system prompt digest changed" });
if (previous.toolDigest !== next.toolDigest)
changes.push({ code: "tools", detail: "..." });
if (previous.cacheRetention !== next.cacheRetention)
changes.push({ code: "cacheRetention", detail: "..." });
// 还检测 model、streamStrategy、transport 变化
}
此外,OpenClaw 还实现了 normalizeStructuredPromptSection(),在组装前对提示词进行规范化——统一换行符、去除行尾空格、trim 首尾空白。这确保微小格式差异不会导致缓存失效:
// OpenClaw: prompt-cache-stability.ts
export function normalizeStructuredPromptSection(text: string): string {
return text
.replace(/\r\n?/g, "\n") // 统一换行
.replace(/[ \t]+$/gm, "") // 去行尾空白
.trim(); // 去首尾空白
}
当对话超过模型上下文窗口时,必须压缩。20 个项目中只有 5 个有成熟的压缩方案:
Hermes 的压缩算法是所有项目中最精细的:
迭代摘要是关键创新——不是每次从头重新摘要,而是 在上一次摘要的基础上增量更新:
# context_compressor.py → compress()
summary_idx, summary_body = self._find_latest_context_summary(
messages, summary_search_start, compress_end)
if summary_idx is not None:
if summary_body and not self._previous_summary:
self._previous_summary = summary_body # ← 继承之前的摘要
turns_to_summarize = messages[max(compress_start, summary_idx + 1):compress_end]
压缩后,system prompt 中追加一条通知:
"[Note: Some earlier conversation turns have been compacted into a handoff summary to preserve context space. The current session state may still reflect earlier work, so build on that summary and state rather than re-doing work. Your persistent memory (MEMORY.md, USER.md) remains fully authoritative regardless of compaction.]"
OpenClaw 将压缩也抽象为 ContextEngine 的一部分,支持:
compactionTarget: "budget" | "threshold" — 压缩目标是预算还是阈值rewriteTranscriptEntries() — 安全的 transcript 重写(branch-and-reappend)ownsCompaction — 引擎可以接管自己的压缩生命周期turnMaintenanceMode: "foreground" | "background" — 前台或后台维护// OpenClaw: context-engine/types.ts
export type CompactResult = {
ok: boolean;
compacted: boolean;
reason?: string;
result?: {
summary?: string;
firstKeptEntryId?: string;
tokensBefore: number;
tokensAfter?: number;
sessionId?: string; // ← 压缩后可能轮转 session
sessionFile?: string;
};
};
sessionId 在压缩结果中意味着:压缩后可以 轮转到新 session,旧 session 归档。这比 Hermes 的"原地压缩"更彻底——新 session 从干净状态开始,只带摘要和必要上下文,避免了压缩残留的边缘情况。Aider 使用单独的弱模型(weak model)做摘要,不消耗主模型的上下文窗口:
# Aider: base_coder.py
# 初始化时选择摘要模型
self.summarizer = ChatSummary(
[self.main_model.weak_model, self.main_model], # ← 优先用 weak model
self.main_model.max_chat_history_tokens,
)
关键:done_messages(已完成的消息)通过 summarizer 压缩后替换,保持总消息长度在 max_chat_history_tokens 以内。这与 ChatChunks 的缓存策略配合——压缩只影响 done 区块,不破坏 system/examples/repo 的缓存。
Agent 从外部加载文件(AGENTS.md、.cursorrules、用户消息)时,这些内容会被注入到系统提示词中——这为 prompt injection 攻击打开了大门。20 个项目中只有 Hermes 有系统性的防护:
Hermes 在 prompt_builder.py 中实现了双层检测:
# Hermes: agent/prompt_builder.py
# 第一层:10 个正则模式检测
_CONTEXT_THREAT_PATTERNS = [
(r'ignore\s+(previous|all|above|prior)\s+instructions', "prompt_injection"),
(r'do\s+not\s+tell\s+the\s+user', "deception_hide"),
(r'system\s+prompt\s+override', "sys_prompt_override"),
(r'disregard\s+(your|all|any)\s+(instructions|rules|guidelines)', "disregard_rules"),
(r'act\s+as\s+(if|though)\s+you\s+(have\s+no|don\'t\s+have)\s+(restrictions|limits|rules)',
"bypass_restrictions"),
(r'', "html_comment_injection"),
(r'<\s*div\s+style\s*=\s*["\'][\s\S]*?display\s*:\s*none', "hidden_div"),
(r'translate\s+.*\s+into\s+.*\s+and\s+(execute|run|eval)', "translate_execute"),
(r'curl\s+[^\n]*\$\{?\w*(KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL|API)', "exfil_curl"),
(r'cat\s+[^\n]*(\.env|credentials|\.netrc|\.pgpass)', "read_secrets"),
]
# 第二层:不可见 Unicode 字符检测
_CONTEXT_INVISIBLE_CHARS = {
'\u200b', # Zero-width space
'\u200c', # Zero-width non-joiner
'\u200d', # Zero-width joiner
'\u2060', # Word joiner
'\ufeff', # BOM
'\u202a', # Left-to-right embedding
'\u202b', # Right-to-left embedding
'\u202c', # Pop directional formatting
'\u202d', # Left-to-right override
'\u202e', # Right-to-left override
}
def _scan_context_content(content: str, filename: str) -> str:
"""扫描上下文文件内容。返回净化后的内容。"""
findings = []
# 检查不可见 unicode
for char in _CONTEXT_INVISIBLE_CHARS:
if char in content:
findings.append(f"invisible unicode U+{ord(char):04X}")
# 检查威胁模式
for pattern, pid in _CONTEXT_THREAT_PATTERNS:
if re.search(pattern, content, re.IGNORECASE):
findings.append(pid)
if findings:
logger.warning("Context file %s blocked: %s", filename, ", ".join(findings))
return f"[BLOCKED: {filename} contained potential prompt injection
({', '.join(findings)}). Content not loaded.]"
return content
Hermes 还有独立的消息净化模块 message_sanitization.py,处理发送给 API 前的编码问题:
# Hermes: agent/message_sanitization.py
# 问题:某些模型(xiaomi/mimo, kimi, glm)的 reasoning 输出
# 包含孤立的 surrogate code points,会导致 json.dumps 崩溃
def _sanitize_surrogates(text: str) -> str:
"""替换 lone surrogate 为 U+FFFD"""
if _SURROGATE_RE.search(text):
return _SURROGATE_RE.sub('\ufffd', text)
return text
# 递归清理嵌套结构(reasoning_details 等)
def _sanitize_structure_surrogates(payload: Any) -> bool:
"""递归替换 dict/list 中的 surrogate"""
# ... walk nested dicts/lists, replace in-place
上下文工程(Context Engineering)是提示词工程的进化版——不仅关注"说什么",更关注"给什么信息"。两个项目有突出的上下文工程设计:
Aider 使用 PageRank 算法从代码仓库中自动提取最相关的上下文:
关键洞察:不是把整个代码仓库塞进提示词,而是用 PageRank 选出最有信息量的部分。这让 Agent 能"看到"相关代码而不消耗全部上下文窗口。
Hermes 的 Skills 提示词组装有一个精巧的两层缓存设计:
# Hermes: agent/prompt_builder.py → build_skills_system_prompt()
# Layer 1: 进程内 LRU 缓存(8 个条目)
_SKILLS_PROMPT_CACHE_MAX = 8
_SKILLS_PROMPT_CACHE: OrderedDict[tuple, str] = OrderedDict()
# Layer 2: 磁盘快照(.skills_prompt_snapshot.json)
# → 验证 mtime/size manifest
# → 冷启动时避免全量文件系统扫描
def build_skills_system_prompt(available_tools, available_toolsets):
cache_key = (skills_dir, external_dirs, tools, toolsets, platform, disabled)
# 尝试 Layer 1: 内存缓存
with _SKILLS_PROMPT_CACHE_LOCK:
cached = _SKILLS_PROMPT_CACHE.get(cache_key)
if cached is not None:
return cached
# 尝试 Layer 2: 磁盘快照
snapshot = _load_skills_snapshot(skills_dir)
if snapshot is not None:
# 使用预解析的 metadata
...
else:
# 冷路径:全量文件系统扫描 + 写入快照
...
# 存入 Layer 1
with _SKILLS_PROMPT_CACHE_LOCK:
_SKILLS_PROMPT_CACHE[cache_key] = result
Skills 索引还实现了条件激活——某些 skill 只在特定工具可用时才显示:
def _skill_should_show(conditions, available_tools, available_toolsets):
# fallback_for: 当主工具可用时隐藏备用 skill
for ts in conditions.get("fallback_for_toolsets", []):
if ts in available_toolsets:
return False
# requires: 当必需工具不可用时隐藏
for t in conditions.get("requires_tools", []):
if t not in available_tools:
return False
return True
不同模型有不同的行为特征——有的需要强制使用工具,有的倾向于"描述而非执行",有的有特殊的 role 约定。Hermes 的提示词系统有最完善的模型适配:
# Hermes: agent/system_prompt.py → build_system_prompt_parts()
# 1. Tool Use Enforcement — 只在工具可用时注入
if agent.valid_tool_names:
_enforce = agent._tool_use_enforcement
# "auto" → 按 TOOL_USE_ENFORCEMENT_MODELS 匹配
# "gpt", "codex", "gemini", "gemma", "grok", "glm" → 需要强制引导
if _inject:
stable_parts.append(TOOL_USE_ENFORCEMENT_GUIDANCE)
# 2. Per-Model 执行纪律
if "gemini" in _model_lower or "gemma" in _model_lower:
stable_parts.append(GOOGLE_MODEL_OPERATIONAL_GUIDANCE)
# → 绝对路径、先验证、并行调用、非交互命令
if "gpt" in _model_lower or "codex" in _model_lower:
stable_parts.append(OPENAI_MODEL_EXECUTION_GUIDANCE)
# → 工具持久性、前置检查、验证、反幻觉
Hermes 发现 OpenAI 的 GPT-5 和 Codex 模型对 developer role 的权重高于 system role,因此在这些模型上自动交换:
# Hermes: agent/prompt_builder.py
DEVELOPER_ROLE_MODELS = ("gpt-5", "codex")
# 在 _build_api_kwargs() 中:
if any(m in model_lower for m in DEVELOPER_ROLE_MODELS):
# 将 system role 替换为 developer role
# 内部消息表示统一用 "system"
# API 边界层才做角色名映射
Hermes 有 17 种平台的提示词模板,每种描述不同的格式限制和媒体能力:
| 平台 | 关键约束 | 媒体能力 |
|---|---|---|
| 无 markdown | MEDIA: 图片/视频/文档 | |
| Telegram | 标准 markdown,无表格 | MEDIA: 照片/语音/视频 |
| Discord | 标准 markdown | MEDIA: 照片/文件附件 |
| CLI | 纯文本,无 MEDIA: | 仅文本路径 |
| Cron | 无用户交互 | 自动投递到目标 |
| 纯文本 | 文件附件 | |
| SMS | ~1600 字符限制 | 无 |
| Yuanbao | Markdown + 贴纸 | 照片/文档 + TIMFaceElem |
| 维度 | OpenClaw | Hermes | Aider | GPT Researcher | AutoGen |
|---|---|---|---|---|---|
| 提示词组装 | ContextEngine 接口 + tokenBudget | 三层 Stable/Context/Volatile | ChatChunks 7 区块 | PromptFamily 类继承 | 常量 + 配置注入 |
| 缓存策略 | Cache Boundary + 可观测性 + retention | system_and_3 + TTL (5m/1h) | 3 个 cache_control 断点 | 无 | 无 |
| 注入防护 | Policy Pipeline (工具层面) | 10 正则 + Unicode + 文件截断 | 无 | 无 | 无 |
| 上下文压缩 | ContextEngine.compact() + session 轮转 | 4 阶段管道 + 迭代摘要 | Weak model 摘要 | 无(短对话场景) | 无 |
| 上下文工程 | tokenBudget 驱动 + ContextEngine 可插拔 | 条件 Skill 索引 + 平台适配 | PageRank RepoMap | PromptFamily 多版本 | 系统消息常量 |
| 模型适配 | per-model contextProjection | Per-Model 指导 + Developer Role + 17 平台 | per-Coder prompt + lazy/overeager | PromptFamily 子类 | 配置注入 |
| 规范化 | normalizeStructuredPromptSection + digest | system_prompt_invariant 文档 | choose_fence() | 无 | 无 |
| 系统提示词报告 | buildSystemPromptReport (chars, skills, tools) | 无 | 无 | 无 | 无 |
问题:混合变化的系统提示词导致缓存频繁失效。
方案:按变化频率将系统提示词分为三层。Stable(会话内不变)→ Context(项目级变化)→ Volatile(每轮可能变化)。缓存断点设在层边界。
权衡:增加组装复杂度,但节省的缓存成本远超开发成本。
问题:缓存命中率低但不知道为什么。
方案:每轮记录 snapshot(provider/model/system prompt digest/tool digest),API 返回后比较 cacheRead 变化,检测断裂原因(6 种)。
权衡:轻微的内存开销(512 session tracker),但换来了可诊断性。
问题:AGENTS.md/.cursorrules 等外部文件可能包含 prompt injection。
方案:加载前扫描——10 种正则模式 + 10 种不可见 Unicode。检测到威胁则替换为 BLOCKED 消息。
权衡:正则模式可能误报(如代码示例中的 "ignore previous"),但安全优先于便利。
问题:消息列表中哪些部分该缓存?
方案:将消息分为 7 个区(system/examples/done/repo/readonly/chat_files/cur),在 system、repo、chat_files 三个稳定区设缓存断点。
权衡:依赖 Anthropic 的 cache_control API,跨 provider 不可用。
问题:每次压缩从头重新摘要会丢失之前摘要中的信息。
方案:找到上一次摘要,在其基础上增量更新。新摘要 = 旧摘要 + 新增 turns 的摘要。
权衡:摘要质量可能随迭代次数下降(信息压缩的信息压缩),但比从头摘要更稳定。
问题:代码仓库太大,无法全部塞进上下文。
方案:用 tree-sitter 解析 → 构建 call graph → PageRank 排序 → 选择 top-N 相关定义 → 生成精简摘要。
权衡:需要语言解析器支持,但信息密度远高于"最近 N 行"。
问题:行尾空格、CRLF/LF 差异导致 SHA-256 digest 不同,缓存失效。
方案:在 digest 计算前统一规范化——统一换行符、去除行尾空白、trim。确保语义相同的内容产生相同 digest。
权衡:几乎无成本,但极易被忽略。是"细节决定成败"的典型案例。
推荐:Level 1-2(硬编码或简单模板)
不需要:分层组装、缓存策略、注入防护
理由:对话短,缓存收益低;无外部文件加载,无注入风险。
推荐:Level 3(分区缓存 + 上下文工程)
推荐:Level 4(三层分离 + 缓存可观测 + 注入防护)
推荐:Level 3(迭代摘要 + PromptFamily)
_cached_system_prompt),只在压缩事件后重建。每轮重建 = 每轮缓存失效 = 白烧钱。
build_system_prompt(),即使内容没变。这会让 Anthropic/Gemini 的前缀缓存每轮都失效。正确做法是缓存构建结果,只在压缩/配置变更时重建。
源码分析基于 OpenClaw、Hermes Agent、Aider、GPT Researcher、AutoGen 等项目 · 2026-05