Prompt Engineering:从提示词文案到 Agent 行为契约

AI Agent 工程化系列第二篇。本文以 Cherry Studio 的 Chat 与 Agent Session 运行时为例,讨论 Prompt 如何被组装、维护、测试和演进。文中的伪代码用于解释设计逻辑,不是可直接复制的生产代码。

写在前面:最先想到的方案,往往也是最先失控的方案

团队在做 AI 桌面端工具的 Chat 与 Agent Work 时,最先遇到的问题通常很直接:模型回答不够稳定,那就补 Prompt。

角色不够清楚?加一段身份说明。工具不会用?再加几条规则。用户想要特定格式?继续往 system prompt 里塞。

刚开始效果往往不错,但需求一多,Prompt 就会慢慢长成"谁也不敢动的大字符串":产品规则、用户偏好、工具说明、运行时状态和历史事实全挤在一起。

因此,本篇借 Cherry Studio 的实现,拆解怎样把 Prompt 从一段文案,变成可组合、可评审的行为契约。

1. Prompt Engineering 的目标不是"写得更长"

在普通问答中,Prompt 常被理解为一段改善回答质量的自然语言指令。进入 Agent 场景后,它承担的角色更接近行为契约

  • Agent 是谁,服务于谁。
  • 当前任务的目标、输出格式和质量标准是什么。
  • 在什么条件下应该使用什么工具。
  • 何时应询问用户、引用来源、保存记忆或停止执行。
  • 哪些能力不存在,不能假装已经完成。

但必须先明确边界:

Prompt 影响模型的决策倾向,不能成为安全控制面。

"未经确认不要删除文件"可以写在 Prompt 中;真正阻止删除的能力必须由工具权限、审批和运行时策略完成。这是 Prompt Engineering 与 Harness Engineering 的分界。

2. Prompt 的演化:从单层字符串到分层策略

2.1 单层 Prompt 的局限

最简单的实现是:

text 复制代码
system = "你是一个专业助手,请回答用户问题。"
response = model.generate(system, userMessage)

看起来能跑,但很快会失控:

  • 产品级规则、用户偏好和任务要求混在一起,无法分别演进。
  • 长期事实只能不断往 Prompt 追加,最终造成噪声和 token 成本膨胀。
  • 工具随运行时配置变化,Prompt 却仍声称具备已被禁用的能力。
  • 用户自定义指令和产品内置指令可能互相覆盖。

2.2 推荐的分层模型

一个可维护的 Agent Prompt 至少应拆为以下层:

text 复制代码
P = Identity
  + ProductPolicy
  + ToolStrategy
  + RuntimeContext
  + UserInstructions
  + DurableMemory
  + TaskSpecificGuidance

各层分别回答不同问题:

回答的问题 变化频率 典型所有者
Identity "你是谁?" 产品或 Agent 配置
ProductPolicy "产品要求你遵守什么?" 平台团队
ToolStrategy "何时、如何使用工具?" 工具/Agent 团队
RuntimeContext "你现在处在哪个环境?" 每次会话 运行时
UserInstructions "这个用户要求你怎样工作?" 用户
DurableMemory "过去长期有效的事实是什么?" Agent + 用户
TaskSpecificGuidance "这一轮任务有什么额外要求?" 请求构造层

分层不是为了让 Prompt 更复杂,而是为了让每层有明确的更新规则、测试方式和安全边界。

3. 回到 Cherry Studio:两条 Prompt 组装路径

Cherry Studio 不是所有对话都使用同一套 Prompt。这个细节很重要:普通 Chat 和长期运行的 Agent Session,面对的任务边界并不一样,硬塞进同一份模板只会让两边都别扭。

text 复制代码
普通 Chat
  → buildAgentParams
  → assembleSystemPrompt
  → AI SDK Agent

Agent Session
  → Claude Code settingsBuilder
  → PromptBuilder.buildSystemPrompt
  → Claude Code Runtime Driver

3.1 普通 Chat:最小化、按能力追加

普通 Chat 的系统提示词由 assembleSystemPrompt 组装,主要包含:

  1. Assistant 的 prompt,并替换日期、时间、模型名等变量。
  2. 当最终工具集包含 tool_search 时,追加延迟工具发现指引。
  3. 当本轮存在可引用的查询工具时,追加引用格式指引。

核心思路是:最终暴露什么能力,Prompt 才描述什么能力

伪代码如下:

ts 复制代码
function assembleSystemPrompt(input) {
  sections = []

  if (input.assistant.prompt exists) {
    sections.push(resolvePromptVariables(input.assistant.prompt, input.model))
  }

  if (input.finalTools includes "tool_search") {
    sections.push(buildDeferredToolsGuidance(input.deferredTools))
  }

  if (input.hasCitableTools) {
    sections.push(buildCitationGuidance())
  }

  return sections.isEmpty ? undefined : sections.join("\n\n")
}

这里有一个容易忽视的算法约束:判断依据必须是最终工具集,而不是用户最初选择的工具或配置里的工具。工具可能被 feature gate、权限策略、模型能力或 deferred exposition 移除。

ts 复制代码
// 错误:描述了配置中存在、但本轮实际不可调用的工具
if (assistant.enabledTools includes "tool_search") {
  prompt += toolSearchGuidance
}

// 正确:描述模型在本轮真实可见的工具
if (finalToolSet has "tool_search") {
  prompt += toolSearchGuidance
}

否则会产生"Prompt 说可以做,模型却找不到工具"的自相矛盾。

3.2 Agent Session:个人 Agent 的长期行为说明书

Agent Session 通常承担更长、更自主的任务,因此需要比普通 Chat 更丰富的 Prompt。PromptBuilder 负责其中的基础、工具策略、Bootstrap 和 Memory 部分;settingsBuilder 再叠加工作区、频道安全、引用、运行环境和用户 instructions。

简化的组装顺序为:

ts 复制代码
async function buildAgentSessionPrompt(session, agent, cwd) {
  base = await promptBuilder.buildSystemPrompt(
    cwd,
    agent.configuration,
    hasNonEmpty(agent.instructions),
    agent.dataPath
  )

  return joinNonEmpty([
    base,
    agent.instructions,
    workspaceBlock(cwd),
    channelSecurityBlock(session),
    citationsBlock(agent, session),
    artifactsBlock(),
    runtimeBlock(),
    languageInstruction()
  ])
}

其中的关键不是字符串拼接本身,而是每段都由运行时事实决定

  • 工作区路径来自当前 session,不能写死。
  • 引用指引只在可用的 Web 或知识库查询工具存在时添加。
  • 频道安全规则只在 Agent 连接外部频道时添加。
  • 运行环境指引由实际提供的 bunuvrg 等能力决定。

4. 关键算法一:先把优先级说清楚

Prompt 冲突最常见的来源是"谁覆盖谁"没有定义。一个典型例子是内置 Agent 的 instructions:

  • 内置角色有产品提供的默认定义。
  • 用户可以自定义 instructions。
  • 用户清空 instructions 后,希望重新使用产品默认值。

对应的决策逻辑不应靠字符串是否"看起来合理",而应使用明确状态:

ts 复制代码
function resolveInstructions(agent) {
  isBuiltin = agent.configuration.builtinRole exists
  userInstructions = trim(agent.instructions)

  if (!isBuiltin) {
    return userInstructions
  }

  if (userInstructions is not empty) {
    // 用户显式编辑后,用户拥有该段内容
    return userInstructions
  }

  // 空值代表回到随产品版本演进的内置定义
  return loadBuiltinDefinition(agent.configuration.builtinRole).instructions
}

这个规则带来两个工程收益:

  1. 产品升级可以更新未被用户覆盖的内置 Prompt。
  2. 用户自定义不会被升级静默覆盖。

Prompt Engineering 的难点往往不是"生成内容",而是像配置系统一样定义默认值、覆盖、继承和回退

5. 关键算法二:静态与动态 Prompt 分离

Prompt 缓存能够降低延迟和成本,但动态变量会破坏缓存命中。例如 {{time}} 每秒变化,如果它在可缓存的前缀中,缓存将持续失效。

可将 Prompt 抽象为:

text 复制代码
SystemPrompt = StaticPrefix + DynamicSuffix

其中:

  • StaticPrefix:身份、固定产品策略、稳定工具规则、长期事实。
  • DynamicSuffix:当前时间、工作区、会话状态、用户本轮要求。

伪代码:

ts 复制代码
function classifyPromptSections(sections) {
  staticSections = []
  dynamicSections = []

  for (section of sections) {
    if (section.containsVolatileVariable() || section.dependsOnSessionState()) {
      dynamicSections.push(section)
    } else {
      staticSections.push(section)
    }
  }

  return {
    cacheablePrefix: join(staticSections),
    requestSuffix: join(dynamicSections)
  }
}

Cherry Studio 当前会在 Assistant Prompt 含有易变时间变量时,避免错误复用 Anthropic Prompt Cache。下一步可进一步将静态和动态 section 作为显式数据结构,而非在最终字符串生成后再猜测可缓存边界。

设计建议

  • 将变量按稳定性分类:构建时、会话级、请求级。
  • 所有可缓存片段应可独立哈希和测试。
  • 不要让时钟、随机数、临时 trace id 混入稳定系统指令。

6. 关键算法三:跨会话 Memory 回灌

长期记忆是 Prompt 的一部分,但不等于"把所有日志都放进 Prompt"。

Cherry Studio 使用四种文件分层:

text 复制代码
SOUL.md            Agent 身份
USER.md            用户偏好
memory/FACT.md     长期事实与经验
memory/JOURNAL.jsonl 事件日志

回灌策略:

ts 复制代码
async function buildMemorySection(agentDataPath) {
  soul = await readSafeFile(agentDataPath / "SOUL.md")
  user = await readSafeFile(agentDataPath / "USER.md")
  facts = await readSafeFile(agentDataPath / "memory/FACT.md")

  return renderKnownSections({
    soul,
    user,
    facts
  })
}

JOURNAL.jsonl 不自动注入。模型在用户提到过去事件时,应调用 memory 工具按关键词或标签检索。这是在 token 成本、回忆能力和事实新鲜度之间的平衡:

ts 复制代码
function decideMemoryWrite(event) {
  if (event.isDurableForSixMonths) {
    return memoryTool.updateFact(rewriteFactDocument(event))
  }

  return memoryTool.appendJournal({
    timestamp: now(),
    tags: event.tags,
    text: event.summary
  })
}

这里的重点是 updateFact 接收完整的 FACT 文档,而不是无限 append。这样 Agent 需要维护一份可读、去重、可纠正的长期知识,而不是把事实碎片永久堆积。

7. 关键算法四:Bootstrap 是有限状态机

新 Agent 需要建立身份和用户画像,但不能在每个会话重复询问。Cherry Studio 的 Bootstrap 决策可理解为有限状态机:

text 复制代码
ExplicitlyComplete  → 标准模式
ExplicitlyReset     → Bootstrap 模式
HasUserInstructions → 标准模式
HasSubstantialSoul  → 标准模式
Otherwise           → Bootstrap 模式

对应伪代码:

ts 复制代码
async function shouldRunBootstrap(config, agentDataPath, hasUserInstructions) {
  if (config.bootstrapCompleted === true) return false
  if (config.bootstrapCompleted === false) return true
  if (hasUserInstructions) return false

  soul = await readSafeFile(agentDataPath / "SOUL.md")
  if (hasSubstantialNonTemplateContent(soul)) return false

  return true
}

hasSubstantialNonTemplateContent 需要先去掉标题和模板引用等形式内容,再按最小长度判断。否则只有空标题的模板会被误判为已完成,或迁移来的已有 Agent 会被重复 Bootstrap。

Bootstrap 完成后,Agent 通过工具:

  1. 更新 SOUL.mdUSER.md
  2. 在 JOURNAL 写入 bootstrap 事件。
  3. bootstrap_completed 标为完成。

这使首次配置成为一个可恢复、可重置、可审计的流程,而不是一次不可追踪的聊天。

8. 关键算法五:安全加载与 mtime 缓存

Prompt 中的外部文件是输入边界,不能直接以普通 readFile 读取。Cherry Studio 的 PromptBuilder 在读取 system.md、SOUL、USER 和 FACT 时遵循以下顺序:

ts 复制代码
async function readPromptFile(filePath, expectedRoot) {
  stat = lstat(filePath)
  if (!stat.isRegularFile || stat.isSymbolicLink) return undefined

  resolvedRoot = realpath(expectedRoot)
  resolvedFile = realpath(filePath)
  if (!isInside(resolvedFile, resolvedRoot)) return undefined

  cache = cacheByPath.get(filePath)
  if (cache.mtimeMs === stat.mtimeMs) return cache.content

  handle = openNoFollow(filePath)
  if (!handle.stat().isRegularFile) return undefined

  content = trim(handle.readText())
  cacheByPath.set(filePath, { mtimeMs: stat.mtimeMs, content })
  return content
}

这段逻辑同时解决三个问题:

  • 路径安全:拒绝符号链接和根目录外的解析路径。
  • TOCTOU 缓解:检查后用 no-follow 方式重新打开文件。
  • 性能 :以 mtimeMs 作为缓存失效键,避免每轮重复读取稳定文件。

需要注意,缓存优化不应改变安全规则。文件每次命中缓存之前,仍应保持足够的路径验证策略,避免把"缓存命中"变成绕过输入校验的捷径。

9. Prompt 的测试策略

Prompt 不应只依赖人工试用。至少应有三类测试。

9.1 组合单元测试

验证输入状态与最终 Prompt section 的关系:

ts 复制代码
test("tool_search 不可见时不注入延迟工具指引", () => {
  prompt = assembleSystemPrompt({
    tools: {},
    deferredEntries: [...]
  })

  expect(prompt).not.toContain("tool_search")
})

9.2 属性与边界测试

验证"任何输入都不能违反"的性质:

  • 不存在任何 section 时返回 undefined,而不是空字符串。
  • 用户 instructions 不能被内置 Prompt 覆盖。
  • 不可信路径、目录和符号链接永不进入系统提示词。
  • 仅包含模板内容的 SOUL 文件不能跳过 Bootstrap。

9.3 行为回归测试

建立最小场景集并人工或通过评测模型审查:

场景 期望行为
用户提到上次约定 先检索 JOURNAL,而不是要求用户重复说明。
用户纠正长期项目事实 更新 FACT,而不是仅在本轮回答中记住。
工具不可用 说明能力边界,不假装已完成。
知识库查询 先按需检索,再带来源回答。
新 Agent 完成一次 Bootstrap 后不再重复采访用户。

10. 这些 Prompt 坑,最好提前绕开

把权限写进 Prompt

text 复制代码
错误:Prompt 说"不要删除文件",但工具默认允许删除。
正确:Prompt 解释何时需要删除;Harness 要求审批并限制目录范围。

用事实堆满 System Prompt

text 复制代码
错误:每次将所有对话日志、知识库 chunk、任务历史全部注入。
正确:长期事实内联,事件日志和知识库按需检索。

Prompt 描述与运行时能力脱节

text 复制代码
错误:Prompt 总是说"你可以搜索网页",而 Web 工具已被禁用。
正确:根据最终工具集条件化注入工具策略。

无覆盖规则的拼接

text 复制代码
错误:内置 Prompt、用户 Prompt、工作区 Prompt 的顺序随实现细节变化。
正确:定义来源优先级、空值语义与回退规则,并用测试固化。

为了缓存删除必要的动态信息

text 复制代码
错误:为了提高缓存命中而省略当前工作区、权限或用户本轮要求。
正确:分离静态前缀和动态后缀;优化缓存而不是牺牲正确性。

11. 小结

对于工程团队,Prompt Engineering 的成熟标志不是拥有一份很长的系统提示词,而是具备以下能力:

  1. Prompt 有清晰分层、来源和优先级。
  2. Prompt 只描述模型真实可见的能力。
  3. 稳定知识与易变事实使用不同的加载策略。
  4. Bootstrap、Memory 和工具策略都是可测试的状态转换。
  5. 安全边界和副作用控制不依赖 Prompt。

一句话总结:

好 Prompt 不是替模型"想得更多",而是让模型在正确的边界内,理解自己是谁、当前要做什么,以及应该如何调用系统能力。

继续阅读

相关推荐
小码哥0681 小时前
2026陪诊小程序与APP开发技术分析
大数据·人工智能·小程序
观远数据1 小时前
决策闭环的第三公里:从洞察到行动之间,AI能补上什么
大数据·数据库·人工智能
一次旅行1 小时前
RLHF全链路深度解析:Reward Model数学推导+PPO完整实战,对比GRPO轻量化方案
人工智能·算法·机器学习
HIT_Weston1 小时前
164、【Agent】【OpenCode】TuiThreadCmd(工厂设计对比)
人工智能·agent·opencode
Wang's Blog1 小时前
AI Agent白手起家29: Few Shot 提示词工程实战
人工智能·算法
杰佛史彦明 本王是暴君1 小时前
PyTorch KernelAgent 源码解读 ---(2)--- 总体流程
人工智能·pytorch·python
澜舟孟子开源社区2 小时前
从流程自动化到认知智能化:LangClaw 携手澜舟智库打造业务决策型数字专家
人工智能
云端漫步19872 小时前
HarmonyOS NEXT AI 智能生活助手:AI 代码解释
人工智能·华为·生活·harmonyos
console.log('npc')2 小时前
OptMem 使用教程
人工智能·ai编程·记忆