Ironclaw 01 - Prompt 的写法、存储与渲染

这篇主要回答一个问题:Ironclaw 里的 prompt 到底是怎么写、怎么存、又怎么最终送给模型的?

先说结论。这个仓库里和 prompt 相关的设计,基本可以分成 3 层:

  • 写法层(Authoring):主要在 Rust 代码里直接写多行字符串,通过 format! 和条件拼接来组织 prompt,强调“规则块 + 上下文块 + 场景块”。
  • 存储层(Storage):长期 prompt 素材不完全写死在代码里,而是放在 Workspace 文档中,例如 AGENTS.mdSOUL.mdUSER.mdIDENTITY.mdMEMORY.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()
    };
}

这种写法有几个很明显的特点:

  • 结构是分块的,例如 GuidelinesSafetyTool Call StyleResponse FormatChannel Formatting
  • 内容是按条件注入的:有没有工具、是不是群聊、来自哪个渠道、模型是否支持 native thinking,都会影响最终 prompt。
  • 同一套引擎支持多种场景:除了主对话 prompt,还能覆盖 planningevaluationsummarizeheartbeatjobcontainer 等专用 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.rs
  • src/worker/job.rs
  • src/worker/container.rs
  • src/agent/commands.rs
  • src/agent/compaction.rs
  • src/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 是如何渲染并送给模型的

整个渲染链路大致可以概括为:

  1. dispatcher 先加载 workspace system prompt、skill context 和 channel context。
  2. Reasoning 生成本轮完整的 system prompt,并缓存 with-tools / no-tools 版本。
  3. 把会话里已有的 system 消息合并成一条,以兼容更严格的 provider。
  4. 调用 provider,由 provider 再做一次格式适配,例如 OpenAI、Anthropic、Rig。
  5. 模型返回文本后,再经过 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 条经验是可以直接借鉴的:

  1. 分层写法。把 Core SafetyTask PolicyTool PolicyChannel Policy 分块,不要写成一大坨长文。
  2. 显式优先级。像它对 skill 的声明一样,明确写出“补充指导不得覆盖核心安全规则”。
  3. 条件注入,而不是一刀切。根据渠道、群聊状态、模型能力、是否有工具,动态拼接 prompt。
  4. 把长期上下文外置成文档。人格、用户偏好、记忆放进可持久化文件,不要全部写死在代码里。
  5. 提供失败策略。工具失败、无结果、超时之后怎么退化,在 prompt 里要提前写清楚。
  6. 限制循环行为。防止重复调用工具,设置迭代上限,并在必要时强制回退到 text-only
  7. 做跨 provider 兼容。尽量把 system 合并到最前面,避免平台对消息顺序的严格限制。
  8. 做输出后处理。对 reasoning 标签、tool 标签做统一清洗,不要把内部协议暴露给用户。