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 组装,主要包含:
- Assistant 的
prompt,并替换日期、时间、模型名等变量。 - 当最终工具集包含
tool_search时,追加延迟工具发现指引。 - 当本轮存在可引用的查询工具时,追加引用格式指引。
核心思路是:最终暴露什么能力,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 连接外部频道时添加。
- 运行环境指引由实际提供的
bun、uv、rg等能力决定。
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
}
这个规则带来两个工程收益:
- 产品升级可以更新未被用户覆盖的内置 Prompt。
- 用户自定义不会被升级静默覆盖。
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 通过工具:
- 更新
SOUL.md和USER.md。 - 在 JOURNAL 写入 bootstrap 事件。
- 将
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 的成熟标志不是拥有一份很长的系统提示词,而是具备以下能力:
- Prompt 有清晰分层、来源和优先级。
- Prompt 只描述模型真实可见的能力。
- 稳定知识与易变事实使用不同的加载策略。
- Bootstrap、Memory 和工具策略都是可测试的状态转换。
- 安全边界和副作用控制不依赖 Prompt。
一句话总结:
好 Prompt 不是替模型"想得更多",而是让模型在正确的边界内,理解自己是谁、当前要做什么,以及应该如何调用系统能力。