用 Rust 从零构建 AI Agent(二):结构化 Prompt 架构与缓存边界

从单个字符串到结构化系统提示词
Part 1 的系统提示词只有约六十个单词,一个字符串字面量:
你是 Eugene,一个回答 Rust 项目问题的助手。如果不知道答案,请调用
read_file读取项目文件。
这种写法在只有一个工具、一个用户、一种输出格式时完全没有问题。但如果你再加一个工具,模型就需要知道什么时候先 list_files、什么时候再 read_file;加第三个工具,就要教会它调用顺序;如果要求输出格式,比如“必须引用你读过的文件”,就需要一个专门的地方放这条规则;如果要做回归测试,还需要检测提示词是否被意外修改。
更现实的问题是:当提示词变长,单字符串会让 Anthropic 的 Prompt Caching 无法工作——因为每轮请求都重新发送整个 persona,缓存命中率为零。
这篇续作给出一种最小但可生存的结构:四层提示词 + 一个 typed builder + 缓存边界 + 指纹回归。Eugene v0.2 的循环本身没变,变化的是 system 字段里的内容。
为什么结构如此重要
提示词也是代码。它控制模型行为。句子之间会互相竞争模型的注意力。一堵没有分段的文字墙,模型在训练时可能见过,但推理时只会部分采纳;而一份带有明确标题的短清单,会被更认真地执行。
这听起来像玄学,但它是经验事实。Claude 这类生产模型在训练时见过大量“系统提示词带章节、用户请求清晰、助手回复明确”的示例。当你在自己的提示词中复现这种结构时,模型对每个部分的含义更确定,幻觉更少、工具选择更准、输出更紧凑。
下面四层不是唯一正确的结构,而是最小能支撑真实 Agent 演进的结构。Claude Code CLI 为自己组装的系统提示词,几乎也是这个结构。这不是巧合,而是长期与真实用户接触后,存活下来的 Agent 都会收敛到的形态。

四层结构:Identity、Instructions、Output、Examples、Context
1. Identity — 声音与姿态
Identity 用一段话说明 Agent 是谁。它建立声音、专业度与姿态。
You are Eugene, a careful research assistant who answers questions about a Rust project. You prefer reading the source over guessing.
注意:Identity 不是给模型写一份简历。你写“你是一位拥有二十年 Rust 经验的大厂专家”,模型不会真的变成专家。Identity 控制的是声音:
- “careful research assistant” 会倾向于先确认再回答;
- “fast no-nonsense engineer” 会给出 terse 回答;
- “patient teacher” 会解释得更详细。
它还能控制模型面对不确定性时的姿态。例如 prefer reading the source over guessing 会显著提高模型调用 read_file 的频率,因为这让它觉得“读文件”更符合人设。
2. Instructions — 行为规则
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 you have not seen listed.
- If a tool returns an error, do not retry the same call.
规则必须是可测试的。比如 Use list_files before reading 可以测试:模型是否先调用了 list_files?Be helpful 则无法测试,应该删掉。
规则顺序很重要。当规则冲突时,模型会按从上到下的顺序理解。早期规则起锚定作用,后期规则做补充。如果两条规则真的冲突,问题不在模型,而在提示词本身。
三到四条规则适合小场景;十条是异味;二十条本身就是失败模式,因为模型会悄悄丢弃一部分。
3. Output Constraints — 输出格式
Output 描述答案的形状,而不是内容:
- Answer in plain prose, no markdown.
- Cite the file you read in parentheses, e.g. (src/main.rs).
- Keep replies under 200 words.
把格式从行为中剥离的好处是:当业务方要求“引用格式改成脚注”时,你只需要改 Output 层的一行,不用重读全部规则。如果下游需要 JSON,也在 Output 层给出 schema;如果用户要求“step by step”,也在 Output 层说“给每一步编号”。
4. Examples — 少样本示范
一个示例往往胜过几条指令。它一次性展示:
- 引用格式长什么样;
- 回答应该多长;
- 工具调用与引用的关系;
- 简短确认是可接受的。
Example 1:
User: What edition does Cargo.toml use?
Assistant: I'll check Cargo.toml directly. (Cargo.toml) The project uses Rust edition 2024.
但示例要小心过拟合。如果所有示例都围绕 Cargo.toml,模型会莫名其妙地执着于 Cargo.toml。用一两个能覆盖真实输入空间的示例即可。
5. Context — 每轮新鲜的运行时数据
Context 是所有人都容易忘记的一层。模型有知识截止日期,不知道今天日期、项目名、当前目录文件。它会猜,而猜测常常是“合理但错误”的。
修复方式很机械:每次请求都把事实传进去。
<env>
today: 2026-05-22
project_root: /Users/me/code/eugene
</env>
真实的 Context 层会快速增长:用户名、项目名、可用技能、当前页、最近错误、最新 commit hash。它们不是观点或指令,而是单次请求的数据,由代码提供。
Rust 实现:Typed Builder
在 Rust 中,这个结构可以变成一个 builder。每个方法对应一层,builder 自己记住哪些是静态的、哪些是动态的,并渲染出缓存边界。
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 you have not seen listed.
- 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>
标题和 XML 标签很重要。## Instructions 告诉模型下面是规则;## Context 告诉模型下面是事实;<env> 是模型在训练数据中见过无数次的运行时状态标记。没有这些结构,事实和指令会混在一起,模型容易把事实当指令或把指令当事实。
缓存边界:把结构变成钱
这是区分“玩具 Agent”和“能付得起账单的 Agent”的关键。
Anthropic API 支持 Prompt Caching:把 system 字段作为文本块数组,在某个块上标记 cache_control: { type: "ephemeral" },API 会缓存该块及之前的所有内容五分钟。后续请求发送相同前缀时命中缓存,缓存输入 token 大约只按正常价格的 10% 计费。
对于一个六轮循环、persona 一千 token 的 Agent,这意味着从第二轮开始,输入成本大约降低为原来的五分之一,同时延迟也会下降。节省多少取决于前缀有多稳定。而 persona 几乎总是稳定的;今天日期永远不是。
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
Builder 天然分区:Identity、Instructions、Output、Examples 是静态;Context 是动态。静态前缀带缓存标记,动态后缀不带,因此每轮重新 tokenize 的只有后缀。
Claude Code CLI 源码中甚至有一个字面量 SYSTEM_PROMPT_DYNAMIC_BOUNDARY,缓存逻辑就按这个标记拆分。任何会在回合间变化的内容(MCP 连接、模型覆盖、语言偏好)都放在边界之下;静态 persona 放在边界之上,尽可能全局缓存。
保持缓存命中的两条规则:
- 前缀必须逐字节一致。一个空格、一条重排的指令、日期格式变化,都会改变 hash 并重新计费。
- 前缀至少要有 1024 token。太小的 persona 缓存不划算;大的 persona 收益显著。
API 响应会返回使用统计,开发时务必打印:
[turn 0] in=4 cache_read=0 cache_create=1247 out=89
[turn 1] in=3 cache_read=1247 cache_create=0 out=42
Turn 0 创建缓存,之后从缓存读取。不测量就看不见缓存效果。
Section Memoization:进程内的缓存边界
Prompt Caching 处理的是 token 成本。但还有一些动态上下文计算成本很高:扫描文件系统、加载内存快照、调用远程设置服务。每轮重复做这些事情既浪费墙钟时间,也污染 tracing。
解决方式是按名称 memoize 每个动态 section,只在显式失效时(新用户请求、/clear、/compact)重新计算。Claude Code 的源码用 systemPromptSection 声明可缓存 section,用 DANGEROUS_uncachedSystemPromptSection 声明不可缓存 section,后者甚至要求作者写一个 reason 来解释为什么要打破缓存——因为一次 volatile section 的漂移会让整个请求的下游缓存失效。
在 Rust 中,你可以用一个 Section { name, compute_fn, cache_break } 注册表,在提示词构建时解析,并按 turn id 缓存结果。如果某 section 计算很重,先自己包一层缓存,再传给 builder。builder 负责布局,不负责 memoization。
回归指纹:防止无声的 Prompt 漂移
最难 debug 的 bug 是没人想改提示词,但改了一句话却导致另一个场景崩了。没有测试,就没有失败。
Fingerprint 是廉价的防御。Builder 渲染完提示词后计算 hash,暴露两个值:
prefix_fingerprint:仅静态前缀,也就是缓存 key;fingerprint:完整提示词。
const EXPECTED_PROMPT_FINGERPRINT: u64 = 0; // 首次运行后设置
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()
);
}
在 CI 中,检查前缀 fingerprint 是否与常量匹配。如果不匹配,要么接受变更(更新常量并说明原因),要么回滚。同时,这个 fingerprint 也能准确告诉你下一次请求会不会命中缓存。
Fingerprint 只防 90% 的 trivial drift,剩下 10% 是行为评估(eval)的事,那是 Part 4 的话题。
Eugene v0.2 实际运行
Part 1 的循环没有变,变的是调用方式:
let prompt = system_prompt(&today, &sandbox);
let system_blocks = prompt.into_system_blocks();
let response = send(&http, &api_key, &system_blocks, &tools, &messages).await?;
Agent 现在有两个工具 list_files 和 read_file,并且通过指令和示例知道如何组合它们。你问 “What’s in the src folder?”,它会先列目录、再挑文件、再读取、再引用文件名回答。
再问 “What edition does Cargo.toml use?”,回答更接近:
I’ll check Cargo.toml directly. (Cargo.toml) The project uses Rust edition 2024.
引用来自 Output 层;决定读文件而不是猜测来自 Identity + Instructions;引用格式来自 Example;成本低于没有缓存边界时。每一层都发挥了作用。
这揭示了什么
一个 const 字符串在工具单一、格式单一、只有一个人维护时是好用的。一旦工具超过一个、格式会改、有多人参与、或者对成本有要求,就必须引入结构。
四层结构不是 arbitrary:
- Identity = 是谁
- Instructions = 做什么
- Output = 怎么做
- Context = 现在的情况
当你发现某个信息放不进这四层时,通常意味着需求本身还不清晰,而不是结构有问题。
缓存边界把结构变成成本优势;typed builder 是 Rust 天然支持的形状;fingerprint 是廉价的回归防线。三者结合,让你敢在周五下午改系统提示词,而不至于周日晚上心慌。
下一步:Part 3 将引入 Skill trait
Part 2 的两个工具仍然是用 match 临时接入的 ad-hoc 函数。没有共享接口、没有 schema 生成、没有重试机制、也没有办法让第三方扩展工具。
Part 3 会引入 Skill trait、拥有分发表的注册表,以及用 schemars 从 Rust 结构体直接派生 JSON Schema。Agent 将不再通过“堆积”成长,而是通过组合成长。
相关参考
- Anthropic prompt caching 文档
- 用 Rust 从零构建 AI Agent(一):工具调用循环详解
- Claude Code 系统提示词解析
- Eugene v0.2 — Prompt Architecture Gist
中国开发者落地建议
原版示例使用 Anthropic API,在中国大陆网络环境下可能无法直接访问。你可以:
- 使用兼容国内模型的接口:通义千问、智谱 GLM-5 等已支持工具调用,只需将
send函数中的端点、认证头与请求体格式调整为对应模型。 - 本地推理 + 工具层:通过 Ollama 等本地部署 Qwen 等开源模型,在外层自己实现同样的工具调用循环与提示词结构。
- 代理转发:通过已搭建的代理服务将兼容 Anthropic 协议的请求转发到可用的后端模型。
如果这篇内容对你有帮助,欢迎 点赞、转发、收藏!也欢迎关注「全栈之巅-梦兽编程」公众号,每周更新 Rust 技术深度文章与 AI 编程实战干货。
也欢迎了解 梦兽编程 AI 编程助手服务 ,帮你把 AI 编程工具真正用到日常开发里。
有 Rust AI Agent 相关的问题,欢迎在评论区留言讨论,我们会在第一时间回复。
