别再写死你的系统提示了——从 672 行代码中提炼出的 Prompt 组装模式
从 OpenClaw 的系统提示词构建器中,提炼出模块化、条件渲染的 Prompt 组装框架
你的系统提示词有多少行?如果超过 100 行,你大概率在用字符串拼接维护它。然后某天你加了一个功能,提示词崩了,你花了 2 小时排查——原来是少了一个换行符。
问题
在前两篇文章里,我从 OpenClaw 提炼出了 LLM 容错层和上下文管理三级防线。这次我要看的是另一个核心文件——system-prompt.ts,672 行。
这个文件只做一件事:组装系统提示词。
672 行,就为了生成一段 system prompt。
你可能觉得夸张。但当你的 AI 应用有 10+ 种工具、3 种运行模式、动态上下文文件、条件性的技能描述、运行时环境信息……你的系统提示词就不再是一个字符串常量了。它是一个需要被编排的复杂输出。
大多数人是怎么做的:
const systemPrompt = `You are a helpful assistant.
${tools.length > 0 ? `## Tools\n${tools.map(t => `- ${t.name}`).join('\n')}` : ''}
${isAdvanced ? 'You have access to advanced features.' : ''}
${contextFiles.map(f => `## ${f.path}\n${f.content}`).join('\n\n')}
${runtime ? `Runtime: os=${runtime.os} model=${runtime.model}` : ''}
Be concise and helpful.`
看起来还行?等你加到第 15 个条件分支的时候再说。
这种写法的三个致命问题:
- 不可维护 — 嵌套的三元表达式、模板字符串、换行控制,改一处崩三处
- 不可测试 — 整个提示词是一个巨大的表达式,你没法单独测试”工具列表部分是否正确”
- 不可复用 — 每个项目从头写一遍,重复造轮子
OpenClaw 的做法:Section Builder 模式
OpenClaw 的 672 行 system-prompt.ts 不是一个巨大的模板字符串。它是20+ 个独立的 section(8 个独立 builder 函数 + 十余个内联条件块),每个 section 负责提示词的一个部分:
buildAgentSystemPrompt(params)
│
├─ identity section → "You are Claude Code..."
├─ tool list section → "## Tools\n- read\n- exec\n..."
├─ tool documentation → 每个工具的详细用法
├─ context files section → CLAUDE.md、.cursorrules 等
├─ skills section → 可用的技能描述
├─ memory section → 持久化记忆内容
├─ git status section → 当前 git 状态
├─ runtime info section → 操作系统、模型、shell 信息
├─ ...(还有十余个 sections)
│
└─ 合并 → filter(Boolean) → join("\n")
每个 section builder 都是一个独立函数,接收上下文参数,返回 string[](行数组)或空数组。
// OpenClaw 中的一个 section builder(简化)
function buildToolListSection(tools: Tool[]): string[] {
if (tools.length === 0) return [] // 没有工具?跳过这个 section
const seen = new Set<string>()
const lines = ['## Tools']
for (const tool of tools) {
const key = tool.name.toLowerCase()
if (seen.has(key)) continue // 去重
seen.add(key)
lines.push(`- ${tool.name}: ${tool.summary}`)
}
return lines
}
注意三个关键设计:
- 返回空数组 = 跳过 — 不需要外层 if/else 判断
- 返回行数组 — 最终由框架 join,不需要手动管理换行
- 独立函数 — 可以单独测试
这就是我要提炼的模式。
提炼:三个概念
从 OpenClaw 672 行中剥离掉 20+ 个具体 section 和 OpenClaw 特有逻辑后,剩下的通用框架只有三个概念:
1. Section — 提示词的积木块
每个 section 有三种方式提供内容:
// 静态内容 — 永不变化的部分
{ name: 'identity', content: 'You are a helpful assistant.' }
// 动态 builder — 根据上下文生成
{ name: 'tools', builder: (ctx) => ctx.tools.map(t => `- ${t.name}`) }
// 条件渲染 — 满足条件才包含
{
name: 'advanced',
content: 'You have access to advanced features.',
when: (ctx) => ctx.isAdvanced,
}
静态内容用于固定不变的部分(身份、基本规则)。动态 builder 用于需要根据运行时数据生成的部分(工具列表、上下文文件)。条件渲染用于”有时需要有时不需要”的部分(高级功能、调试信息)。
2. Assembler — 编排器
把所有 sections 按顺序处理,合并成最终的 prompt:
sections.forEach(section => {
if (section.when && !section.when(ctx)) → 跳过
if (section.builder) → 执行 builder
else → 使用静态 content
收集结果
})
→ join(separator)
→ 最终 prompt string
3. Section Helpers — 可复用的格式化函数
OpenClaw 中有几个 section builder 的逻辑在所有 LLM 应用中通用:
- 工具列表格式化 — 几乎所有 Agent 都需要告诉 LLM “你有哪些工具”
- 上下文文件注入 — CLAUDE.md、.cursorrules 这类项目配置文件
- 运行时信息 — OS、模型名称、Node 版本等环境信息
这些可以直接提取为通用 helper。
对比:改造前后
用一个真实场景来对比。假设你在做一个 AI 编码助手,系统提示词需要包含:身份、工具列表、项目文件、运行时信息、以及可选的高级功能描述。
改造前:模板字符串地狱
function buildSystemPrompt(
tools: Tool[],
files: File[],
runtime: Runtime,
isMinimal: boolean,
): string {
let prompt = 'You are a coding assistant.\n'
if (tools.length > 0) {
prompt += '\n## Tools\n'
const seen = new Set<string>()
for (const tool of tools) {
const key = tool.name.toLowerCase()
if (!seen.has(key)) {
seen.add(key)
prompt += tool.summary
? `- ${tool.name}: ${tool.summary}\n`
: `- ${tool.name}\n`
}
}
}
if (files.length > 0) {
for (const file of files) {
prompt += `\n## ${file.path}\n\n${file.content}\n`
}
}
if (!isMinimal) {
prompt += '\nYou have access to advanced features.\n'
}
const runtimeParts: string[] = []
if (runtime.os) runtimeParts.push(`os=${runtime.os}`)
if (runtime.model) runtimeParts.push(`model=${runtime.model}`)
if (runtimeParts.length > 0) {
prompt += `\nRuntime: ${runtimeParts.join(' ')}\n`
}
prompt += '\nBe concise. Follow best practices.'
return prompt
}
40 行,而且只有 5 个 section。想象一下 20+ 个 section 时的样子。
改造后:Section Builder 模式
import {
createPromptAssembler,
formatToolList,
formatContextFiles,
formatRuntimeInfo,
} from '@yuyuqueen/prompt-assembler'
type MyContext = {
tools: ToolEntry[]
files: ContextFile[]
runtime: RuntimeInfo
isMinimal: boolean
}
const prompt = createPromptAssembler<MyContext>({
sections: [
{ name: 'identity', content: 'You are a coding assistant.' },
{
name: 'tools',
builder: (ctx) => formatToolList(ctx.tools),
when: (ctx) => ctx.tools.length > 0,
},
{
name: 'context',
builder: (ctx) => formatContextFiles(ctx.files),
when: (ctx) => ctx.files.length > 0,
},
{
name: 'advanced',
content: 'You have access to advanced features.',
when: (ctx) => !ctx.isMinimal,
},
{
name: 'runtime',
builder: (ctx) => formatRuntimeInfo(ctx.runtime),
},
{ name: 'rules', content: 'Be concise. Follow best practices.' },
],
})
// 一行调用
const systemPrompt = prompt.build({
tools: [...],
files: [...],
runtime: { os: 'Darwin', model: 'claude-opus-4' },
isMinimal: false,
})
同样的功能,但每个 section 的边界清清楚楚。加一个 section?加一行。删一个?删一行。改条件?改 when。不用在 40 行的字符串拼接里翻找。
附:通用 Section Checklist
从 OpenClaw 的 20+ 个 section 中,有 11 个是任何 LLM Agent 都可以借鉴的通用模式(其余是 OpenClaw 的产品特有逻辑,如消息路由、心跳检测等)。搭建你自己的 Agent system prompt 时,可以参考这个清单:
| Section | 作用 | 适用场景 |
|---|---|---|
| Identity | 角色定义(“你是一个…”) | 所有 Agent |
| Tooling | 工具列表 + 摘要,自动去重 | 有工具调用的 Agent |
| Tool Call Style | 何时解释操作、何时静默执行 | 有工具调用的 Agent |
| Safety | 安全护栏(不自主扩权、不绕过审查) | 所有 Agent |
| Memory Recall | 回答前先搜记忆库 | 有持久记忆的 Agent |
| Workspace | 工作目录声明 | 文件/编码类 Agent |
| User Identity | 用户身份和偏好 | 个性化 Agent |
| Date & Time | 时区和当前时间 | 时间敏感的 Agent |
| Context Files | 项目配置文件注入(CLAUDE.md 等) | 编码/项目类 Agent |
| Runtime | OS、模型、Node 版本等环境快照 | 所有 Agent |
| Reasoning Format | thinking tag 格式控制 | 使用推理模型时 |
不是每个 Agent 都需要全部 11 个——根据你的场景按需选取。但如果你在做一个 coding agent 或 AI 助手,大概率需要其中 7-8 个。
你的 LLM 应用今天就能用上
npm install @yuyuqueen/prompt-assembler
GitHub: github.com/yuyuqueen/llm-toolkit — Star
核心 API
import { createPromptAssembler } from '@yuyuqueen/prompt-assembler'
const prompt = createPromptAssembler({
sections: [
// 静态
{ name: 'identity', content: 'You are a helpful assistant.' },
// 动态
{ name: 'tools', builder: (ctx) => [`Tools: ${ctx.toolCount}`] },
// 条件
{ name: 'debug', content: 'Debug mode on.', when: (ctx) => ctx.debug },
],
separator: '\n', // section 间的分隔符
})
const result = prompt.build({ toolCount: 5, debug: true })
内置 Section Helpers
三个从 OpenClaw 提取的通用格式化函数:
import {
formatToolList,
formatContextFiles,
formatRuntimeInfo,
} from '@yuyuqueen/prompt-assembler'
// 工具列表(自动去重,大小写不敏感)
formatToolList([
{ name: 'read', summary: 'Read file contents' },
{ name: 'Read', summary: 'Duplicate' }, // 被去重
{ name: 'exec', summary: 'Run commands' },
])
// → ["## Tools", "- read: Read file contents", "- exec: Run commands", ""]
// 上下文文件
formatContextFiles([
{ path: 'CLAUDE.md', content: '# Project\nRules here.' },
])
// → ["## CLAUDE.md", "", "# Project\nRules here.", ""]
// 运行时信息(自动过滤 undefined)
formatRuntimeInfo({
os: 'Darwin',
model: 'claude-opus-4',
node: undefined, // 被过滤
})
// → ["Runtime: os=Darwin model=claude-opus-4"]
调试与 Token 估算
// 逐 section 查看输出(调试用)
const sections = prompt.buildSections(ctx)
for (const [name, content] of sections) {
console.log(`[${name}] ${content.length} chars`)
}
// → [identity] 28 chars
// → [tools] 156 chars
// → [context] 2340 chars
// 估算 token 数
const tokens = prompt.estimateTokens(ctx)
console.log(`System prompt ≈ ${tokens} tokens`)
buildSections 返回 Map<string, string>,让你精确知道每个 section 贡献了多少内容。当提示词过长时,你可以快速定位是哪个 section 太大了——而不是在几百行的字符串里 ctrl+F。
配合 resilient-llm 和 llm-context-kit
三个库组合,就是完整的 LLM 应用基础设施:
import { createPromptAssembler } from '@yuyuqueen/prompt-assembler'
import { createContextBudget } from '@yuyuqueen/llm-context-kit'
import { createResilientLLM } from '@yuyuqueen/resilient-llm'
// 1. 组装系统提示词
const prompt = createPromptAssembler({ sections: [...] })
const systemPrompt = prompt.build(ctx)
// 2. 检查 token 预算(系统提示词也要算进去)
const budget = createContextBudget({ contextWindowTokens: 200_000 })
const status = budget.check(messages) // messages 包含系统消息
// 3. 容错调用
const resilient = createResilientLLM({ providers: [...] })
await resilient.call(async (rCtx) => {
return {
response: await anthropic.messages.create({
model: rCtx.model,
system: systemPrompt,
messages,
}),
}
})
系统提示词组装 (prompt-assembler)
│
▼
上下文管理 (llm-context-kit)
│ 预算检查 → 工具截断 → 会话压缩
▼
容错调用 (resilient-llm)
│ Key 轮换 → Provider 降级 → 指数退避
▼
LLM API
改造前后
| 场景 | 改造前 | 改造后 |
|---|---|---|
| 加一个 section | 在 40 行模板字符串中找位置插入 | 加一行 section 定义 |
| 删一个 section | 小心翼翼删除代码和换行 | 删一行或加 when: () => false |
| 测试某个 section | 无法单独测试 | buildSections(ctx).get('tools') |
| 查看 token 分布 | 手动计算 | estimateTokens(ctx) + buildSections |
| 条件渲染 | 嵌套三元表达式 | when: (ctx) => ctx.condition |
| 工具列表去重 | 手写 Set 去重逻辑 | formatToolList(tools) 内置 |
| 多人协作 | 冲突地狱(同一文件同一函数) | 每人改自己的 section |
设计原则
和前两个库一样:
- 零依赖 — 纯 TypeScript,无运行时依赖
- 泛型上下文 —
createPromptAssembler<YourContext>提供完整类型安全 - Provider-agnostic — 输出纯字符串,不绑定任何 LLM SDK
- 可组合 — section helpers 可独立使用,也可组合到 assembler 中
结论
系统提示词不是一个字符串,它是一个需要被工程化管理的产品。
当你的提示词超过 50 行,你需要 section 化。当你的提示词有条件分支,你需要条件渲染。当你的提示词有动态数据,你需要 builder 模式。
OpenClaw 用 672 行代码管理它的系统提示词,因为一个好的 AI 产品的提示词就是这么复杂。你不需要写 672 行——但你需要一个框架来管理这个复杂度。
→ @yuyuqueen/prompt-assembler on npm → @yuyuqueen/llm-context-kit on npm → @yuyuqueen/resilient-llm on npm → GitHub 源码
这篇文章是「从开源项目中提炼工具库」系列的第三篇(完结)。
关注我获取更新 → Twitter @YuYuQueen_ · GitHub