Rust AI智能体的结构化系统提示词:分层设计、缓存机制与偏差检测功能。
为Rust智能体构建分层系统提示词:包括身份信息、操作指令、输出规则、示例与最新上下文,以及用于持续集成环境的缓存边界和指纹标识。
初级的智能体通常只需要一个由单个字符串构成的系统提示,长度大概在六十词左右:回答关于Rust项目的问题,需要在查看文件内容时调用read_file函数。这种方式可行是因为此时只有一个工具、一个用户,且没有关于答案格式的任何规定。但问题会逐渐增多:出现第二个工具后,模型就必须学会何时列出文件、何时读取文件;第三个工具则要求明确操作顺序。像“引用你阅读过的文件”这样的输出规则也需要有对应的处理方式。而用于检测在固定输入下提示是否仍能产生相同答案的回归测试,则需要办法来识别是否有有人意外修改了提示内容。提示缓存机制则让开发者需要关注每轮对话中哪些部分会发生变化,哪些不会。而单纯的字符串提示根本无法优雅地处理这些问题。
以下是为名为 Eugene 的智能体设计的结构化提示模板,该智能体能够处理此类任务:包含四层结构、带格式的构建模块、每次请求对应的上下文、一个少量示例、用于标识内容偏移的哈希值,以及静态/动态边界设定,这样 Anthropic 的提示缓存只需为该角色生成一次内容,而无需在每轮对话中都重新生成。智能体的处理流程保持不变,只有系统消息的形式会发生变化。
为何结构化有助于提升效果
提示语本质上就是代码,因为它用于控制行为,其中的每一句话都在争夺模型的注意力。冗长且缺乏区分度的内容块会被部分忽略,而简短且有明确标签的分段则会得到仔细阅读。
这听起来像是迷信,但实际上是一种观察所得的行为模式。Claude及其同类模型都是根据熟悉的指令格式进行训练的:分段的系统提示、用户提出简单请求的回复、助手给出直接答案的回应。只要遵循这些格式,模型就能更清晰地理解各部分含义,从而减少幻觉现象,让输出更加精准,并能做出更好的工具选择。
下面的四层结构并非唯一的可行方案,而是经过实际应用考验后仍能保留的最简结构。Claude Code CLI为自己生成的系统提示也几乎采用相同的架构,这并非巧合——那些能经受住真实用户考验的智能体往往都会趋向于相同的结构布局。
各层结构
身份说明是关于发言者身份的段落,用于明确语气、专业背景及立场。例如:尤金是一名负责Rust项目的细心研究助理,他更愿意直接阅读源代码而非猜测。此部分不应包含任何规则或事实。
操作指南是行为规范,每条均为可验证的陈述:在阅读之前使用list_files查看项目中的内容,或不要重复调用已返回错误的函数。随着智能体的发展,这些指南会不断增多。建议以列表形式保存,因为项目符号比段落更便于添加和删除。
输出约束描述的是答案的格式而非内容:纯文本,不得使用Markdown格式,需以括号形式注明所参考的文件,回复字数不超过200字。无论用户提出什么请求,这些约束都必须遵守。将它们与指令分开处理,可以在不影响功能的前提下更改格式,反之亦然。
示例展示了该智能体的实际表现。少样本示例仅需一行即可完成指令需要三行才能实现的任务,因为它能呈现良好的输出结果,并包含正确的引用格式。示例是构成智能体身份的一部分,因此会与身份信息及指令一同置于静态前缀中。
Context用于存储每次运行时都会变化的元素:日期、用户的区域设置、项目根目录以及当前提供的技能。由于这些内容会变动,因此不能存储在静态字符串中。它在缓存边界之下拥有独立的存储层,并会在每次请求时重新构建。
Rust中的类型化构建器
在Rust中,该结构会变成一个构建器,其中的每个方法对应一个层。它能够识别哪些层是静态的,哪些是动态的,并呈现它们之间的边界。
let prompt = SystemPromptBuilder::new()
.identity("You are Eugene, a careful research assistant who answers \
questions about a Rust project. You prefer reading the source \
over guessing.")
.instruction("Use `list_files` to discover what is in the project before reading.")
.instruction("Use `read_file` to inspect a specific file. Do not call it on \
paths you have not seen listed.")
.instruction("If a tool returns an error, do not retry the same call.")
.output_constraints("Answer in plain prose. Cite the file you read in parentheses, \
for example: (src/main.rs).")
.example("What edition does Cargo.toml use?",
"I'll check Cargo.toml directly. (Cargo.toml) The project uses Rust edition 2024.")
.context(format!("<env>\ntoday: {today}\nproject_root: {sandbox}\n</env>"))
.build();
编译器在此处无法进行太多检查,但可读性会大幅提升。你可以浏览整个链式结构,从而了解代理将会看到什么。前五种方法构成静态前缀,最后一种方法则用于生成动态后缀。生成的静态前缀如下所示:
## Identity
You are Eugene, a careful research assistant ...
## Instructions
- Use `list_files` to discover what is in the project before reading.
- Use `read_file` to inspect a specific file. Do not call it on paths ...
- If a tool returns an error, do not retry the same call.
## Output
Answer in plain prose. Cite the file you read in parentheses ...
## Examples
Example 1:
User: What edition does Cargo.toml use?
Assistant: I'll check Cargo.toml directly. (Cargo.toml) ...
后缀更短,且完全动态生成:
## Context
<env>
today: 2026-05-22
project_root: /Users/me/code/eugene
</env>
标题具有特定含义。“## Instructions”告诉模型下方的列表是规则,而“## Context”则表明下方的内容是事实。如果没有这些标题,这两类内容就会混淆,模型可能会将事实误认为是指令,反之亦然。有了标题后,工具的选择会更加清晰。
用于环境信息的 XML 风格标签也遵循相同原理。<env> 标签是模型在训练过程中见过数千次的标记,因此它会将标签内的内容视为运行时状态而非指导信息。Claude Code CLI 就是通过这种方式在提示语中包含当前目录、Git 状态、操作系统以及模型编号,这是一种值得借鉴的惯例。
身份赋予声音,而非真相
这一层是人们最常修改的部分。初学者会在此填写各种头衔,比如在大型科技公司工作了二十年的资深 Rust 工程师。但仅仅告诉模型它是专家并不会让它真正成为专家。身份决定了表达方式:细心的研究助理说话前会谨慎斟酌,干练高效的工程师则回答简短直接,而耐心的教师则会详细解释。
它还决定了面对不确定性时的态度。若指定让模型优先阅读源文件而非猜测,这将产生实际影响——因为有这样的指示时,模型确实会更频繁地调用 read_file 函数,因为这会影响其回复的契合度。应将身份视为角色的行为指引,而非简历内容。
指令即列表
每条指令都应能够独立判断为通过或失败。“在读取之前使用list_files”这一条是可以检查的,因为模型要么会先调用它,要么不会。而“对用户有帮助”这一条则无法检查,因此应将其删除。
当规则之间存在冲突时,顺序很重要,因为模型是从上到下读取的,先出现的规则起到约束作用,后出现的规则则用于进一步明确要求。如果两条规则真的相互矛盾,说明提示有误,模型将会随机选择执行。应修正提示中的冲突,而非在推理时再做调整。
对于小型智能体来说,三到四条规则就足够了。十条规则已经过多,二十条则根本无法处理,因为模型无法同时记住所有规则,只能悄悄忽略其中一些。过长的规则列表通常意味着缺少输出约束或背景信息,这些本可以使得部分规则变得不再必要。
输出约束将格式与行为分开
假设有一个提示,要求用简洁的散文风格礼貌作答,在括号中注明文件来源,并确保字数不超过200字。这实际上融合了三个要求:礼貌性属于身份设定部分,而引用和字数限制则属于输出内容范畴。当有人要求更改引用格式时,只需修改对应层级的某一行即可,无需重新阅读其他内容。
结构化输出也在此处发挥作用。如果下游系统需要JSON格式,输出层就会要求按照给定的架构以JSON形式回应;若用户要求列出步骤,则该层级会要求为每个步骤编号。在格式不断调整的同时,系统的行为方式仍保持不变。
示例胜于解释
一个示例往往就能替代多条指令:
.example(
"What edition does Cargo.toml use?",
"I'll check Cargo.toml directly. (Cargo.toml) The project uses Rust edition 2024.",
)
这个例子展示了引用格式,说明简短的致谢即可,同时明确了预期长度,并演示了工具调用与引用之间的关联。这些内容并未出现在说明中,也不需要出现。
存在的风险是过拟合。如果所有示例都涉及 Cargo.toml,模型就会异常地专注于它。应使用一两个涵盖实际输入范围的示例。如果无法编写具有代表性的示例,那么提示词中的其他部分很可能描述得不够充分。
每次请求都会重新构建上下文
起初大家都会忽略这一点。模型存在知识截止日期,不知道今天的日期、项目的名称或存在哪些文件。它会进行猜测,而这些猜测虽然听起来合理,但实际上是错误的。“大概是2024年中期”这样的日期并没有实际意义。
解决办法很简单:每次都提供相关事实。
let today = OffsetDateTime::now_utc()
.date()
.format(&Iso8601::DATE)
.unwrap_or_else(|_| "unknown".into());
let sandbox = sandbox_root()?.display().to_string();
let prompt = system_prompt(&today, &sandbox);
有两个关键事实:数据量约为半千字节,且智能体现在知道了自身的时间和位置。其成本可以忽略不计,而在涉及时间或路径敏感的问题上,准确率会有显著提升。
真实智能体的上下文层会快速扩展:包括用户名称和项目名称、当前使用的技能、正在查看的页面、最近的错误信息以及最新的提交哈希值。这些都不是主观意见或指令,而是为单个请求准备的数据,由代码提供。
缓存边界
这正是区分业余级智能体与可负担运营的智能体的关键所在。Anthropic的API提供了提示词缓存功能:可将system字段作为文本块数组而非单个字符串传递,为某个文本块添加cache_control: {"type": "ephemeral"}标签,该标签之后的所有内容都将被缓存5分钟。后续发送相同前缀的请求会直接从缓存中获取数据,这些已缓存的输入内容费用仅为常规价格的十分之一左右。
对于采用六轮循环、拥有千个标记角色设定的智能体而言,第一轮之后的每轮输入费用可降低约5倍,同时延迟也会更低。节省的费用程度取决于提示词中稳定内容的比例——角色设定几乎总是稳定的,而当前日期则并非如此。
let blocks = prompt.into_system_blocks();
// blocks[0] = { type: "text", text: <static prefix>, cache_control: {type: "ephemeral"} }
// blocks[1] = { type: "text", text: <dynamic suffix> } // no cache_control
构建器会自然地分为两半。身份、指令、输出约束及示例都是静态的,而上下文则是动态的,每一半都会独立渲染并带有各自的标题。只有第一部分包含缓存标记,因此每轮次中第二部分都需要重新进行令牌化处理,而角色设定则会被缓存下来。
正是基于这种逻辑,Claude Code 的源代码中存在一个名为 SYSTEM_PROMPT_DYNAMIC_BOUNDARY 的标记,其缓存逻辑就是以此为界进行分割的。所有在轮次之间会发生变化的内容,比如会话中的 MCP 连接、模型配置更改或语言偏好设置,都位于该标记之下。而其上方的静态角色设定则可以在构建允许的情况下在不同用户之间被缓存。
有两条规则能保持缓存的有效性。首先,各次请求的前缀必须完全一致,哪怕是一个额外的空格、指令顺序的改变或日期格式的不同都会导致哈希值变化,进而需要重新生成缓存。其次,前缀的内容长度必须达到1024个标记,Anthropic才会对其进行缓存。简短的描述几乎无法带来任何好处,而详细的描述则能带来显著优势。如果你的描述过短,应添加有实际价值的内容而非填充文字。
响应中会包含一份记录:
[turn 0] in=4 cache_read=0 cache_create=1247 out=89
[turn 1] in=3 cache_read=1247 cache_create=0 out=42
第一次执行时会创建缓存,之后的每次执行都会从中读取数据。在开发过程中应打印使用统计信息,因为除非进行测量,否则缓存的状态是不可见的。
缓存动态部分内容
缓存边界有助于保存令牌。使用更小的进程内版本可以节省计算资源。某些动态上下文的生成成本很高,比如扫描文件系统、加载内存快照或调用远程设置存储。每次都重新执行这些操作会浪费时间并使日志变得混乱。
按名称为每个动态部分创建缓存,仅在出现明确的有效性失效情况时才重新计算,例如新的用户请求、/clear 或 /compact 指令。Claude Code 的代码将可能被缓存的部分(systemPromptSection)与易变部分(DANGEROUS_uncachedSystemPromptSection)分开处理,后者需要提供一个reason参数,以便开发者说明为何要打破缓存。如此醒目的名称实至名归:因为有一个会在每轮对话中变化的易变部分,它会导致该请求之后的所有内容都失效。
在 Rust 代理中,对应的是在构建提示语时解析的 Section { name, compute_fn, cache_break } 条目的注册表,每个结果都以轮次 ID 作为键。目前 eugene-prompt 库保持较为简单的设计,每次构建时都会重新计算所有动态部分。如果某些部分的计算成本较高,可将其封装在自己的缓存中并传入值,因为该库的功能是处理布局而非实现记忆化。
版本控制与回归测试
最棘手的提示语错误往往是那些非预期的错误。有人修改某句话以解决其他问题,结果导致行为发生变化,破坏了某些未被关注的使用场景,而由于不存在相关测试,因此也没有任何测试失败。
指纹技术能起到帮助作用。构建器会对渲染后的文本进行哈希处理,并将其公开两次:prefix_fingerprint 用于仅包含前缀的哈希值,即缓存键的依据;fingerprint 用于整个提示语的哈希值。
const EXPECTED_PROMPT_FINGERPRINT: u64 = 0; // set after first successful run
if EXPECTED_PROMPT_FINGERPRINT != 0
&& prompt.fingerprint() != EXPECTED_PROMPT_FINGERPRINT
{
eprintln!(
"warning: system prompt fingerprint drifted (was {EXPECTED_PROMPT_FINGERPRINT}, \
is {}). Update the constant if the change was intentional.",
prompt.fingerprint()
);
}
在持续集成中,会将前缀指纹与某个常量进行比较。一旦两者出现差异,持续集成系统就会发出警告,此时你需要要么接受该变更(更新常量并向团队说明原因),要么拒绝它(恢复原状)。无论哪种情况都会被记录下来。用于防止功能退化的同一指纹还能准确显示缓存前缀何时发生变化,也就是下一次请求无法从缓存中获取数据的时间。
指纹是必要的,但还不够。另一关键部分是行为测试:固定的输入、预期的固定行为,以及用于将输出与预期结果进行比较的评估机制。指纹能够捕捉到90%的轻微偏差。
Eugene中的结果
循环部分并未被修改。不同之处在于结构化的提示语以及它如何通过网络传输:
let prompt = system_prompt(&today, &sandbox);
let system_blocks = prompt.into_system_blocks();
let response = send(&http, &api_key, &system_blocks, &tools, &messages).await?;
由于有list_files和read_file可用,相关说明与示例教会了智能体如何将它们串联起来。当被问及src文件夹中有什么时,它会列出该目录,选择一个可能的文件,读取后附上引用信息回复。当被问及Cargo.toml中的版本号时,智能体首先表示会打开该配置文件,然后给出版本号(2024),并在括号中注明文件名。引用信息的放置属于输出层的职责,而通过检查而非猜测来获取信息则是指令的要求,至于引用格式则由示例规定。由于有缓存限制,其计算成本仅为没有该限制时的很小一部分。
要点总结
硬编码的文本在某些情况下尚可,但一旦遇到问题就会显得力不从心。当需要使用多种工具、多种输出格式,还有多名工程师参与提示词编辑或存在预算限制时,结构化的设计就会发挥重要作用。这些层次并非随意设定:身份对应“谁”,指令对应“做什么”,输出对应“如何呈现”,而上下文则对应“当前情况”。如果某部分不符合这些分类,那只是因为智能体的需求不够明确,并非结构本身的问题。
缓存机制能让这种结构化设计带来实际效益。Rust 提供的类型化构建工具正是免费的解决方案,而指纹功能则能实现低成本的回归测试。二者结合,就能让你在周五下午轻松编辑系统提示词,而无需感到压力。
这里的两个工具仍只是match语句中的临时功能,没有统一的接口,也不生成架构定义,不存在重试机制,第三方也无法提供代理能够使用的工具。接下来的自然发展方向是引入Skill特性,即一个负责管理调度表以及能够从Rust结构体中生成JSON Schema的schemars的注册系统,这样代理就能通过组合方式扩展功能,而非逐步累积。