schoober-ai-sdk:设计动态 Prompt 构建
动态 Prompt 构建技术解析 本文介绍了 Agent 框架中动态 prompt 构建的核心机制,主要包含两个关键部分: 双 prompt 注入系统: systemPrompt(系统角色定义)作为 system 消息注入 environmentPrompt(实时上下文)作为末尾 user 消息注入 这种分离设计考虑了缓存优化和语义区分 systemPrompt 的四层组合结构: 基础角色定义和核
动态 Prompt 构建
github:Schoober AI SDK GitHub 仓库
各位看官求🌟一下,小的先在此谢过
Prompt 是 Agent 的"灵魂"——它决定了 LLM 如何理解任务、使用工具、与用户交互。但在 Agent 框架中,prompt 不能是静态的。工具会动态注册/注销,任务状态在不断变化,子 Agent 的信息需要按需注入。schoober-ai-sdk 的 prompt 构建系统需要在每轮 ReAct 循环中实时组装一个反映当前状态的 systemPrompt。
1. 两个注入点:systemPrompt 与 environmentPrompt
在 ReAct 引擎的每一轮 executeStep() 中,会构建两段不同的 prompt:
// ReActEngine.executeStep()
// 1. 系统提示词:作为 system 角色的消息
const systemPrompt = await this.callbacks.buildSystemPrompt(taskState);
// 2. 环境变量提示词:作为消息队列末尾的 user 消息
const envPrompt = await this.callbacks.buildEnvironmentPrompt(taskState);
它们的注入位置完全不同:
LLM 请求结构:
┌─ system ─────────────────────────────────────┐
│ systemPrompt(角色 + 行为规范 + 工具定义...) │
└──────────────────────────────────────────────┘
┌─ user ───────────────────────────────────────┐
│ "帮我查一下北京的天气" │
└──────────────────────────────────────────────┘
┌─ assistant ──────────────────────────────────┐
│ "我来帮你查询..." + tool_use: get_weather │
└──────────────────────────────────────────────┘
┌─ user ───────────────────────────────────────┐
│ tool_result: {temperature: 22} │
└──────────────────────────────────────────────┘
... 更多对话轮次 ...
┌─ user ───────────────────────────────────────┐ ← 末尾追加
│ environmentPrompt(实时上下文) │
└──────────────────────────────────────────────┘
为什么要分成两个?因为它们的语义和作用不同:
| systemPrompt | environmentPrompt | |
|---|---|---|
| 角色 | system |
user(消息队列末尾) |
| 内容 | 角色定义、行为规范、工具列表、子 Agent 信息 | 实时上下文(时间、环境变量、运行时状态) |
| 稳定性 | 相对稳定(除非工具变化) | 每轮都可能变化 |
| 对 LLM 的影响 | 定义"你是谁、能做什么" | 提供"现在是什么情况" |
| 缓存友好 | 是(变化少,适合 LLM API 的 prompt caching) | 否(放在末尾,不影响前面内容的缓存) |
将 environmentPrompt 放在消息队列末尾有一个实际的工程考量:prompt caching。很多 LLM API(如 Anthropic)会缓存请求的前缀部分,如果 systemPrompt 不变,后续请求可以命中缓存,减少 token 计费。environmentPrompt 因为每轮都变,放在末尾不会破坏前面的缓存。
代码中也体现了这一点:
// ReActEngine.executeStep()
if (envPrompt && envPrompt.trim().length > 0) {
const envMessage: ApiMessage = {
id: `env-${Date.now()}`,
role: 'user',
content: envPrompt,
source: 'system',
};
finalMessages = [...messages, envMessage];
// 缓存控制索引设置为环境变量提示词之前
cacheControlIndex = messages.length - 1;
}
cacheControlIndex 告诉 LLM Provider:这个索引之前的消息可以被缓存,之后的(即 environmentPrompt)不缓存。
2. systemPrompt 的组合顺序
Agent.buildSystemPrompt() 按固定顺序组合四层内容:
async buildSystemPrompt(taskState: TaskState, additionalTools?: Tool[]): Promise<string> {
// Layer 1: 基础提示词(角色定义 + 核心行为规范)
let composedPrompt = this.baseSystemPrompt;
// Layer 2: 角色提示词(动态生成)
if (this.rolePromptBuilder) {
const rolePrompt = await this.rolePromptBuilder(taskState);
composedPrompt = `${composedPrompt}\n\n${rolePrompt}`;
}
// Layer 3: 工具定义(Zod Schema → 可读文本)
const allTools = [...filteredSystemTools, ...registeredTools];
const toolsPrompt = await generateToolsPrompt(allTools);
composedPrompt = `${composedPrompt}\n\n${toolsPrompt}`;
// Layer 4: 子 Agent 信息
if (this.subAgents.size > 0) {
const subAgentsPrompt = await generateSubAgentsPrompt(this.subAgents);
composedPrompt = `${composedPrompt}\n\n${subAgentsPrompt}`;
}
return composedPrompt;
}
Layer 1: 基础提示词
基础提示词在 Agent 构造时就确定了,后续不再变化:
// Agent 构造函数中
this.baseSystemPrompt = buildSystemPrompt(config.description, config.coreSystemPrompt);
buildSystemPrompt 做的事情很简单——拼接角色定义和核心行为规范:
function buildSystemPrompt(roleDescription?: string, corePrompt?: string): string {
return (roleDescription || defaultRole) + '\n' + (corePrompt ?? coreSystemPrompt);
}
其中 coreSystemPrompt 是 SDK 内置的行为规范,定义了 Agent 的基本工作模式:
消息风格与格式
- 保持直接、专业,避免寒暄式表达
- 使用 Github 风格的 Markdown
任务执行方法论
- 迭代式完成任务:分析 → 执行 → 等待结果 → 迭代
- 证据先于答案:工具是唯一信息来源,禁止在没有工具输出的情况下推测
工具使用
- 每条消息必须包含至少一次工具调用
- 写入工具必须单独调用
- 工具调用后消息立即结束
这些规范确保 LLM 以 ReAct 模式工作——必须调用工具获取信息,不能凭空推测。
如果开发者想完全替换这些规范(比如你不需要"每条消息必须包含工具调用"的约束),可以通过 coreSystemPrompt 配置项覆盖:
const agent = new Agent({
name: 'custom',
coreSystemPrompt: '你的自定义行为规范...',
// ...
});
Layer 2: 角色提示词(rolePromptBuilder)
rolePromptBuilder 是一个函数,接收当前 TaskState,返回一段额外的提示词文本:
type RolePromptBuilder = (taskState: TaskState) => string | Promise<string>;
它的核心价值是让 prompt 能够响应任务状态的变化。来看一个典型场景:
const agent = new Agent({
name: 'coder',
rolePromptBuilder: (taskState) => {
const retryCount = taskState.context?.retryCount || 0;
const parts: string[] = [];
// 根据重试次数调整策略
if (retryCount > 0) {
parts.push(`注意:这是第 ${retryCount + 1} 次尝试。`);
parts.push('请仔细分析之前失败的原因,采用不同的策略。');
}
// 根据 token 使用情况提醒节省
const tokenUsage = taskState.context?.tokenUsage;
if (tokenUsage && tokenUsage.totalTokens > 50000) {
parts.push('当前 token 使用较高,请尽量精简输出。');
}
// 注入工作区信息
const workspace = taskState.context?.custom?.workspace;
if (workspace) {
parts.push(`当前工作目录: ${workspace}`);
}
return parts.join('\n');
},
});
因为每轮 ReAct 循环都会重新调用 buildSystemPrompt,所以 rolePromptBuilder 的输出会实时反映最新状态。第一次执行时可能没有任何额外提示,第三次重试时 LLM 会看到"请采用不同的策略"。
Layer 3: 工具定义
工具定义的生成在工具系统设计一文中已经详细讨论过。这里补充一个细节:用户注册的工具会覆盖同名的系统工具:
const registeredTools = await this.toolRegistry.list();
const registeredToolNames = new Set(registeredTools.map(t => t.name));
// 过滤掉被用户覆盖的系统工具
const filteredAdditionalTools = additionalTools
? additionalTools.filter(t => !registeredToolNames.has(t.name))
: [];
const allTools = [...filteredAdditionalTools, ...registeredTools];
这保证了 prompt 中的工具定义和 ToolManager 中实际执行的工具始终一致——不会出现 prompt 里描述的是系统工具 A,但执行时用的是用户工具 A 的情况。
Layer 4: 子 Agent 信息
如果 Agent 注册了子 Agent,会在 prompt 末尾追加子 Agent 的描述信息:
async function generateSubAgentsPrompt(subAgents: Map<string, IAgent>): Promise<string> {
const sections = ['# Available Sub-Agents', ''];
sections.push('You have access to the following sub-agents for task delegation...');
for (const [name, agent] of subAgents.entries()) {
sections.push(`## ${name}`);
sections.push(`Description: ${agent.description || 'No description available'}`);
}
sections.push('**Important**: When calling the `new_task` tool, ' +
'you MUST use one of the agent names listed above.');
return sections.join('\n');
}
生成的 prompt 类似:
# Available Sub-Agents
You have access to the following sub-agents for task delegation...
## codeReviewer
Description: 专注于代码质量分析
## deployer
Description: 负责部署到各种环境
**Important**: When calling the `new_task` tool, you MUST use one of
the agent names listed above for the `agentName` parameter.
末尾的强调是必要的——LLM 可能会"发明"不存在的 Agent 名称。通过在 prompt 中明确约束,减少这种幻觉的概率。
3. environmentPromptBuilder:实时上下文注入
environmentPromptBuilder 的签名与 rolePromptBuilder 相同,但用途不同——它专门用于注入实时、高频变化的上下文信息:
type EnvironmentPromptBuilder = (taskState: TaskState) => string | Promise<string>;
典型用法:
const agent = new Agent({
name: 'assistant',
environmentPromptBuilder: (taskState) => {
const lines: string[] = [];
// 当前时间(每轮都不同)
lines.push(`当前时间: ${new Date().toISOString()}`);
// 操作系统信息
lines.push(`操作系统: ${process.platform} ${process.arch}`);
// 当前工作目录
lines.push(`工作目录: ${process.cwd()}`);
// 打开的文件(IDE 集成场景)
const openFiles = taskState.context?.custom?.openFiles;
if (openFiles?.length) {
lines.push(`当前打开的文件: ${openFiles.join(', ')}`);
}
return lines.join('\n');
},
});
为什么不把这些信息放在 rolePromptBuilder 里?两个原因:
- 语义分离:
rolePromptBuilder定义"你是谁",environmentPromptBuilder描述"现在是什么情况"。一个影响 LLM 的行为模式,一个提供决策所需的上下文。 - 缓存优化:
rolePromptBuilder的输出在 systemPrompt 中(位置靠前),environmentPromptBuilder的输出在消息队列末尾。如前所述,后者不影响 prompt caching。
4. 为什么每轮都重新生成
一个常见的疑问:systemPrompt 在很多情况下并不会变化(比如没有动态注册工具、没有 rolePromptBuilder),为什么不缓存它?
原因是正确性优先于性能。在 schoober-ai-sdk 中,以下情况都会导致 prompt 内容变化:
| 变化场景 | 影响的 Layer |
|---|---|
| 工具动态注册/注销 | Layer 3(工具定义) |
| 任务重试(retryCount 增加) | Layer 2(rolePromptBuilder) |
| 子任务完成(状态变化) | Layer 2(rolePromptBuilder) |
| Token 使用超过阈值 | Layer 2(rolePromptBuilder) |
| 时间变化 | environmentPrompt |
| 用户打开新文件(IDE 场景) | environmentPrompt |
如果引入缓存,就需要精确追踪所有可能导致 prompt 变化的因素,并在变化时失效缓存。这比每轮重新生成更复杂,也更容易出错。
实际测试中,prompt 生成的耗时在毫秒级(主要是 generateToolsPrompt 中的 Zod Schema 转换),相比 LLM 请求的数百毫秒到数秒,可以忽略不计。
5. 完整的 Prompt 生命周期
将以上内容串联起来,一个完整的 prompt 从定义到生效的流程:
Agent 构造时(一次性):
config.description + coreSystemPrompt
→ buildSystemPrompt()
→ this.baseSystemPrompt(固定不变)
每轮 ReAct 循环:
┌─ Agent.buildSystemPrompt(taskState) ───────────────────────┐
│ │
│ Layer 1: this.baseSystemPrompt │
│ "You are Schoober,一个技术专家..." │
│ "消息风格与格式 / 任务执行方法论 / 工具使用..." │
│ ↓ │
│ Layer 2: rolePromptBuilder(taskState) │
│ "这是第 2 次尝试,请采用不同的策略" │
│ ↓ │
│ Layer 3: generateToolsPrompt(allTools) │
│ "# Available Tools\n## get_weather\n..." │
│ ↓ │
│ Layer 4: generateSubAgentsPrompt(subAgents) │
│ "# Available Sub-Agents\n## codeReviewer\n..." │
│ │
└─────────────────────────────────────────────────────────────┘
↓
composedPrompt(system 角色)
┌─ Agent.buildEnvironmentPrompt(taskState) ──────────────────┐
│ "当前时间: 2024-01-15T10:30:00Z" │
│ "工作目录: /Users/dev/project" │
└─────────────────────────────────────────────────────────────┘
↓
envPrompt(user 角色,追加到消息队列末尾)
┌─ 最终发送给 LLM 的请求 ────────────────────────────────────┐
│ │
│ system: composedPrompt │
│ user: "帮我查一下北京的天气" │
│ assistant: "..." + tool_use │
│ user: tool_result │
│ ... │
│ user: envPrompt ← cacheControlIndex 之后 │
│ │
└─────────────────────────────────────────────────────────────┘
6. 小结
动态 Prompt 构建的设计遵循了几个原则:
- 分层组合:baseSystemPrompt(静态)→ rolePromptBuilder(半动态)→ 工具定义(按需变化)→ 子 Agent 信息(按需变化),每一层有明确的职责和变化频率
- 双注入点分离:systemPrompt 定义"你是谁、能做什么",environmentPrompt 描述"当前环境是什么",语义清晰且对缓存友好
- 覆盖而非继承:
coreSystemPrompt配置项可以完全替换内置的行为规范,给开发者最大的控制权 - 每轮重新生成:牺牲微小的性能(毫秒级),换取 prompt 与任务状态始终一致的正确性保证
- 缓存感知:environmentPrompt 放在消息末尾,配合
cacheControlIndex,不破坏 LLM API 的 prompt caching
更多推荐



所有评论(0)