Ironclaw 01 - Prompt 的写法、存储与渲染
这篇主要回答一个问题:Ironclaw 里的 prompt 到底是怎么写、怎么存、又怎么最终送给模型的?
先说结论。这个仓库里和 prompt 相关的设计,基本可以分成 3 层:
- 写法层(Authoring):主要在 Rust 代码里直接写多行字符串,通过
format!和条件拼接来组织 prompt,强调“规则块 + 上下文块 + 场景块”。 - 存储层(Storage):长期 prompt 素材不完全写死在代码里,而是放在 Workspace 文档中,例如
AGENTS.md、SOUL.md、USER.md、IDENTITY.md、MEMORY.md,并持久化到数据库。 - 渲染层(Rendering):先在
Reasoning中组装统一的 system prompt,再根据不同 LLM provider 转换消息格式,例如 Anthropic 的顶层system字段、Rig 的preamble,最后清洗模型输出标签。
1. 作者如何写 Prompt
核心在 src/llm/reasoning.rs。主 system prompt 不是静态模板文件,而是通过代码动态拼接出来的:
pub fn build_system_prompt_with_tools(&self, tools: &[ToolDefinition]) -> String {
let tools_section = if tools.is_empty() {
String::new()
} else {
let tool_list: Vec<String> = tools
.iter()
.map(|t| format!(" - {}: {}", t.name, t.description))
.collect();
format!(
"\n\n## Available Tools\nYou have access to these tools:\n{}\n\nCall tools when they would help accomplish the task.",
tool_list.join("\n")
)
};
// ...
let tool_guidance = if tools.is_empty() {
String::new()
} else {
"\n- Call tools when they would help accomplish the task\n\
- Do NOT call the same tool repeatedly with similar arguments; ..."
.to_string()
};
}
这种写法有几个很明显的特点:
- 结构是分块的,例如
Guidelines、Safety、Tool Call Style、Response Format、Channel Formatting。 - 内容是按条件注入的:有没有工具、是不是群聊、来自哪个渠道、模型是否支持 native thinking,都会影响最终 prompt。
- 同一套引擎支持多种场景:除了主对话 prompt,还能覆盖
planning、evaluation、summarize、heartbeat、job、container等专用 prompt。
例如,Response Format 会根据模型能力切换:
let response_format = if has_native_thinking {
r#"## Response Format
Respond directly with your answer. Do not wrap your response in any special tags.
Your reasoning process is handled natively — just provide the final user-facing answer."#
} else {
r#"## Response Format — CRITICAL
ALL internal reasoning MUST be inside <think>...</think> tags.
...
Only text inside <final> is shown to the user; everything else is discarded."#
};
也就是说,这里的 prompt 不是“固定模板”,而更像一套按条件拼装的规则系统。
2. Prompt 模板如何存储
2.1 代码内模板:短期、场景化 Prompt
很多 prompt 直接硬编码在业务代码里,例如:
src/llm/reasoning.rssrc/worker/job.rssrc/worker/container.rssrc/agent/commands.rssrc/agent/compaction.rssrc/agent/heartbeat.rs
这一层更适合放稳定的行为约束,以及和运行逻辑高度耦合的场景模板。
2.2 Workspace 文档模板:长期人格与记忆
更有意思的是,系统 prompt 里“人格”和“长期上下文”的一部分,不是从代码里来,而是从 Workspace 文件里读出来的,而且这些内容可以被用户定制:
/// Build the system prompt from identity files.
/// Loads AGENTS.md, SOUL.md, USER.md, IDENTITY.md, and (in non-group
/// contexts) MEMORY.md to compose the agent's system prompt.
...
let identity_files = [
(paths::AGENTS, "## Agent Instructions"),
(paths::SOUL, "## Core Values"),
(paths::USER, "## User Context"),
(paths::IDENTITY, "## Identity"),
];
...
if !is_group_chat
&& let Ok(doc) = self.read(paths::MEMORY).await
&& !doc.content.is_empty()
{
parts.push(format!("## Long-Term Memory\n\n{}", doc.content));
}
...
Ok(parts.join("\n\n---\n\n"))
另外,项目在初始化时会 seed 默认模板;如果设置了 WORKSPACE_IMPORT_DIR,还支持优先从磁盘导入自定义模板:
// Import workspace files from disk FIRST if WORKSPACE_IMPORT_DIR is set.
// ... custom templates take priority over generic seeds.
if let Ok(import_dir) = std::env::var("WORKSPACE_IMPORT_DIR") {
...
}
match ws.seed_if_empty().await {
Ok(_) => {}
}
这说明作者实际上把“prompt 模板”拆成了两类:
- 稳定行为约束:放在代码层。
- 可演化的人格与记忆:放在 Workspace 文档层。
3. Prompt 是如何渲染并送给模型的
整个渲染链路大致可以概括为:
dispatcher先加载 workspace system prompt、skill context 和 channel context。Reasoning生成本轮完整的 system prompt,并缓存with-tools/no-tools版本。- 把会话里已有的 system 消息合并成一条,以兼容更严格的 provider。
- 调用 provider,由 provider 再做一次格式适配,例如 OpenAI、Anthropic、Rig。
- 模型返回文本后,再经过
clean_response()清洗<think>、<final>、工具标签等内容。
关键代码大致如下:
let system_prompt = match context.system_prompt {
Some(ref prompt) => prompt.clone(),
None => self.build_system_prompt_with_tools(&context.available_tools),
};
let system_prompt = merge_system_messages(system_prompt, &context.messages);
let mut messages = vec![ChatMessage::system(system_prompt)];
messages.extend(
context.messages
.iter()
.filter(|m| m.role != Role::System)
.cloned(),
);
这里的 merge_system_messages() 很关键。它的作用是防止 system 消息散落在中间位置,从而导致某些 provider 拒绝请求:
fn merge_system_messages(primary: String, context_messages: &[ChatMessage]) -> String {
let extra: Vec<&str> = context_messages
.iter()
.filter(|m| m.role == Role::System)
.map(|m| m.content.as_str())
.collect();
if extra.is_empty() {
return primary;
}
format!("{}\n\n---\n\n{}", primary, extra.join("\n\n"))
}
不同 provider 还会继续做各自的 system 适配。
Anthropic 会把 system 内容抽到顶层字段:
fn convert_messages(messages: Vec<ChatMessage>) -> (Option<String>, Vec<AnthropicMessage>) {
let mut system_parts: Vec<String> = Vec::new();
...
match msg.role {
Role::System => {
if !msg.content.is_empty() {
system_parts.push(msg.content);
}
}
}
}
Rig 适配器则会把 system 变成 preamble:
fn convert_messages(messages: &[ChatMessage]) -> (Option<String>, Vec<RigMessage>) {
let mut preamble: Option<String> = None;
...
crate::llm::Role::System => {
match preamble {
Some(ref mut p) => {
p.push('\n');
p.push_str(&msg.content);
}
None => preamble = Some(msg.content.clone()),
}
}
}
最后,输出清洗也做得比较完整,避免 <think> 或工具 XML 泄露给用户:
fn clean_response(text: &str) -> String {
// strip thinking/final/tool tags
...
collapse_newlines(&result)
}
4. 这套设计的优点与潜在风险
4.1 优点
- Prompt 可以按场景组合和裁剪,工程化程度很高。
- 长期记忆与人格模板从代码中解耦,用户可编辑。
- Provider 差异被适配层吸收,主逻辑可以保持统一。
- 有专门的输出清洗流程,能减少“思维泄露”和工具标记污染。
4.2 风险与代价
- 主要依赖字符串拼接,而不是强约束的模板 DSL;规模再变大时,容易出现冲突指令。
- System prompt 来源很多,例如
base + workspace + skills + nudge + runtime,必须持续关注优先级和 token 膨胀问题。 - 这套方案依赖
clean_response()的正确性,像未闭合标签、异常输出之类的边界 case,需要持续测试。
5. 写系统级 Prompt 的实战技巧
结合这个项目,我觉得至少有 8 条经验是可以直接借鉴的:
- 分层写法。把
Core Safety、Task Policy、Tool Policy、Channel Policy分块,不要写成一大坨长文。 - 显式优先级。像它对 skill 的声明一样,明确写出“补充指导不得覆盖核心安全规则”。
- 条件注入,而不是一刀切。根据渠道、群聊状态、模型能力、是否有工具,动态拼接 prompt。
- 把长期上下文外置成文档。人格、用户偏好、记忆放进可持久化文件,不要全部写死在代码里。
- 提供失败策略。工具失败、无结果、超时之后怎么退化,在 prompt 里要提前写清楚。
- 限制循环行为。防止重复调用工具,设置迭代上限,并在必要时强制回退到
text-only。 - 做跨 provider 兼容。尽量把 system 合并到最前面,避免平台对消息顺序的严格限制。
- 做输出后处理。对 reasoning 标签、tool 标签做统一清洗,不要把内部协议暴露给用户。