动态 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 里?两个原因:

  1. 语义分离rolePromptBuilder 定义"你是谁",environmentPromptBuilder 描述"现在是什么情况"。一个影响 LLM 的行为模式,一个提供决策所需的上下文。
  2. 缓存优化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
Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐