对话进行到一半要加规矩、换工具:用对话中途的系统消息,不动开头,不丢缓存
长对话中途改顶层的 system 或 tools,前面的缓存全部作废。Claude 5.5 这一代可以在消息列表里追加一条 system 消息来加指令、增减工具。通过中转站实测:加指令能用、缓存保住了;按名字增减工具报 400。
本站已实测,测试日期:2026-10-13。
本页目录(12 节)
这篇教程解决一个问题:自己写的智能体已经跑了几十轮,这时候要给它加一条新规矩,或者多给它一个工具,怎么做才不会让前面攒下的缓存全部作废?
办法是不去改请求开头的 system 和 tools,而是在消息列表里追加一条角色为 system 的消息。这是比较新的功能,只有 Claude 5.5 这一代和少数几个模型支持。
| 实测环境 | |
|---|---|
| 日期 | 2026-10-13 |
| 方式 | Python:anthropic SDK 1.13.0,部分实验直接发 HTTP 请求 |
| 模型 | Opus 5.5 为主,Sonnet 5.5、Sonnet 5 做对比 |
| 连接方式 | 通过酷站AI 中转站,不是直连官方 |
先说实测的结论:
- 追加
system消息来加指令,在 Opus 5.5 和 Sonnet 5.5 上能用,前面的缓存原样命中。 - 它和用户的话冲突时,模型听
system消息的。 - Sonnet 5 不支持:同样的写法,指令没有生效,模型还把它当成了可疑内容。
- 中途增减工具(beta):把新工具的完整定义写在消息里能用;按名字去加、去减已有的工具报 400。
- 官方文档说的几条位置规则,这家中转站没有校验。
一、为什么改开头会丢缓存
提示词缓存是按“前缀”算的:tools、system、messages 依次排下来,从头开始逐字节相同的那一段才能命中。顶层的 system 排在很前面,tools 更前。在这两处改一个字,后面的全部算新内容,哪怕历史消息一条没动。
本站量了一次。一段约 5,800 个 token 的系统提示词开着缓存,先暖一遍,然后用两种办法加同一句话(“从现在起,所有回答都用英文,并且以 Note: 开头”):
| 做法 | 缓存读 | 缓存写 | 新输入 |
|---|---|---|---|
| 不加任何东西,原样再发一次 | 5,797 | 0 | 4 |
在消息列表末尾追加一条 system 消息 |
5,797 | 0 | 32 |
把这句话加到顶层 system 的开头 |
0 | 5,824 | 4 |
第二种只多付了那一句话的 28 个 token,其余全部从缓存读。第三种把 5,800 多个 token 按缓存写入的价钱重新付了一遍。对话越长,这个差距越大。
缓存本身的用法见《提示词缓存》。
二、怎么写
在 messages 数组里加一项,role 写 system,content 和普通消息一样可以是字符串或内容块:
messages.append({"role": "user", "content": "可以对 develop 强推吗?"})
messages.append({"role": "system", "content": "从现在起,所有回答都用英文,并且以 “Note:” 开头。"})
规则:
- 位置:必须紧跟在一条
user消息后面(带工具结果的user消息也算),并且它后面要么是assistant消息,要么没有别的消息。不能放在第一条,不能夹在工具调用和它的结果之间。 - 生效范围:从它所在的位置往后。
- 优先级:后面的
system消息压过前面的;中途的system消息压过顶层的system。 - 不用 beta 头。
- 顶层的
system照常用,放从头到尾都适用的指令。中途的只放“后来才知道要加”的。
支持的模型:Fable 5.1、Mythos 5.1、Fable 5、Mythos 5、Opus 5.5、Opus 5、Opus 4.8、Sonnet 5.5、Haiku 5.5。Sonnet 5 不支持,在它上面只能用顶层的 system。
下面是一段完整的四轮对话,原样跑过。前两轮正常问答,第三轮追加 system 消息,第四轮把它留在历史里继续问:
import anthropic
client = anthropic.Anthropic()
RULES = "你是团队的 Git 助手,用中文回答,回答尽量短。\n下面是团队的 Git 约定:\n" + "\n".join(
f"{i}. 约定第 {i} 条:功能分支从 develop 切出,命名用 feature/ 前缀加工单号;提交信息写清楚改了什么和为什么;"
f"合并前先 rebase 到最新的 develop 并跑完全部测试;不允许对公共分支强推。" for i in range(1, 61))
system = [{"type": "text", "text": RULES, "cache_control": {"type": "ephemeral"}}] # 长的系统提示词,开缓存
messages = [{"role": "user", "content": "合并之前要做什么?"}]
def ask(label):
reply = client.messages.create(model="claude-opus-5-5", max_tokens=1500, system=system, messages=messages,
metadata={"user_id": "demo-user-0001"})
u = reply.usage
text = "".join(b.text for b in reply.content if b.type == "text")
print(f"{label}:缓存读 {u.cache_read_input_tokens},缓存写 {u.cache_creation_input_tokens},"
f"新输入 {u.input_tokens} | {text[:60]!r}")
messages.append({"role": "assistant", "content": reply.content})
ask("第 1 轮")
messages.append({"role": "user", "content": "分支怎么命名?"})
ask("第 2 轮")
# 对话进行到这里,要加一条新规矩。不改顶层的 system,在用户消息后面追加一条 system 消息
messages.append({"role": "user", "content": "可以对 develop 强推吗?"})
messages.append({"role": "system", "content": "从现在起,所有回答都用英文,并且以 “Note:” 开头。"})
ask("第 3 轮(追加了一条 system 消息)")
messages.append({"role": "user", "content": "提交信息要写什么?"}) # system 消息留在历史里,原样重发
ask("第 4 轮")
输出:
第 1 轮:缓存读 0,缓存写 5780,新输入 4 | '合并前要做两件事:\n\n1. **先 rebase 到最新的 develop**:\n ```bash\n git f'
第 2 轮:缓存读 0,缓存写 5957,新输入 4 | '用 `feature/` 前缀加工单号,并从 develop 切出:\n\n```bash\ngit checkout -b '
第 3 轮(追加了一条 system 消息):缓存读 5780,缓存写 260,新输入 32 | 'Note: No. develop is a public branch, and the team conventio'
第 4 轮:缓存读 5957,缓存写 245,新输入 4 | 'Note: Your commit message should clearly state **what** you '
第 3 轮起回答变成了英文并以 Note: 开头,第 4 轮没有再提这条规矩,它仍然照做。第 3 轮的缓存读是 5,780:追加的 system 消息没有破坏前面的缓存。
第 2 轮的“缓存读 0”不是这个功能造成的。通过这家中转站缓存命中本来就不稳定,原因和对策见《提示词缓存》那一篇;上一节那张表是另一次单独的测试,更干净。
已经发出去的 system 消息不要再改、不要删。 它成了历史的一部分,动它和动任何一条旧消息一样,从那里往后的缓存全部失效。规矩变了,就再追加一条新的。
一个小观察:在 Sonnet 5.5 上,追加了 system 消息的那次请求多出了一段思考,输出从一百个 token 上下涨到了 343 个。本站第一次测时 max_tokens 只给了 300,结果只拿到一个思考块,没有正文。max_tokens 留够。
三、它和“在 user 消息里写指令”有什么不同
把同一句话放进一条普通的 user 消息,模型多半也会照做。区别在身份:user 消息被当作最终用户说的话,system 消息被当作你这个应用的开发者说的话。两者冲突时,system 优先。
实测让两者正面冲突:用户消息里写“用中文回答,不要用英文”,紧跟的 system 消息要求用英文并以 Note: 开头。Opus 5.5 的回答是英文的,以 Note: 开头。
文档给了几条写法上的建议:
- 陈述事实,不要下“无视用户”式的命令。 写“用户刚才补充了一句:……”“剩余的 token 预算是 ……”,让模型自己决定怎么办。Claude 被训练成会抵制那些看起来和用户作对的指令,这层保护对
system角色同样有效,所以“忽略用户说的话”这种写法反而不好使。 - 不要把外来的内容放进
system消息。 工具的原始输出、检索到的文档、网页内容,放进去就等于给了它们开发者级别的权威。这些内容留在工具结果里。
适合用它的场合,文档举了这些:
- 会话中途的规矩或人设变化(“从现在起 SQL 一律用参数化查询”)。
- 每一轮都在变、但需要系统级分量的信息:数据的新鲜度、会话的截止时间、剩余预算。
- 你的程序观察到的状态变化:磁盘上的文件变了、用户打开了自动批准、可用的工具变了。
- 用户在智能体干活的中途又打了一句话(见下一节)。
四、在工具循环里
智能体的循环里,system 消息放在送回工具结果的那条 user 消息后面:
messages.append({"role": "user", "content": tool_results}) # 工具结果
messages.append({"role": "system", "content": "用户刚才补充了一句:只改 src 目录,不要动测试。"})
这样模型会把新信息并进手头正在做的事里,而不是当成一个新请求转头去做。
Sonnet 5.5 和 Haiku 5.5 例外。 这两个模型被特别训练过抵抗工具结果里的注入,它们可能把紧跟在工具结果后面的 system 消息当成不可信的文字而不理会。文档给的做法是:在这两个模型上,把用户的原话作为一个文字块,放在同一条 user 消息里、最后一个工具结果的后面。你的程序自己的提醒,仍然放在单独的 system 消息里。
下面三节的功能都还是 beta,各自要带一个 beta 头:
| 功能 | beta 头 |
|---|---|
只管一轮的提醒(clear_at) |
mid-conversation-system-clear-at-2026-08-21 |
| 中途增减工具 | inline-tools-2026-09-15 |
| 中途改 effort | mid-conversation-output-config-2026-07-01 |
五、只管一轮的提醒(beta)
有些提醒只对当前这一轮有用,比如每批工具结果之后提醒一句“互不相干的读取要一起发”。如果每轮都追加一条,历史里会堆满一模一样的话。
给 system 消息加一个 clear_at 字段,它就只在“后面还没有新的用户消息”时才起作用:
{"role": "system", "content": "提醒:这一次的回答不要超过 15 个字。", "clear_at": "next_user_message"}
要带上表里的 beta 头。规则:
- 一旦后面出现了新的
user消息(只带工具结果的也算),这条消息就被“清掉”:它仍然留在数组里,但模型看不到,也不占输入 token。 - 清掉的消息要一字不差地继续重发。把它删掉、或者按当前状态重新生成(比如换一个新的时间戳),都算改了历史,缓存从那里失效。
- 只能是文字,不能带工具变更,不能带
cache_control。 - 想让提醒在一连串工具调用里一直可见,就在每条工具结果后面各追加一次。
实测:消息在末尾时生效,Opus 5.5 的回答是“变基:重放提交;合并:保留历史”,15 个字。但把它留在历史里、后面接着新的对话再发,这家中转站返回了 400,说 system 消息的位置不对。而这正是这个功能要用的方式,所以通过这家中转站,它目前只能用一轮。
六、中途增减工具(beta)
tools 数组比 system 排得还靠前,改它一样会让整段对话的缓存失效。对应的办法是在 system 消息里放 tool_addition 和 tool_removal 块,要带 beta 头 inline-tools-2026-09-15。
按名字增减已有的工具。 工具仍然全部声明在 tools 里;一开始不想让模型看到的,声明时加 defer_loading: true,等需要时再加进来:
{"role": "system", "content": [
{"type": "tool_removal", "tool": {"type": "tool_reference", "name": "get_weather"}},
{"type": "tool_addition", "tool": {"type": "tool_reference", "name": "db_query"}},
]}
把一个全新的工具直接定义在消息里。 适合对话开始时还不知道、或者定义后来变了的工具:
{"role": "system", "content": [
{"type": "tool_addition", "tool": {"type": "tool_definition", "definition": {
"name": "db_query",
"description": "对分析库执行一条只读的 SQL 查询。",
"input_schema": {"type": "object", "properties": {"sql": {"type": "string"}}, "required": ["sql"]},
}}},
]}
文档的几条规则:
- 开始时就知道的工具,放进
tools(必要时延迟加载),不要都靠消息里定义。 tools里至少留一个不延迟的工具,否则第一个在消息里定义的工具会改变提示词的开头,白丢一次缓存。- 用同一个名字再发一个不同的定义,新的替换旧的;发一模一样的定义没有影响,重试时可以放心重发。
- 配合第二个 beta 头,还能用同样的办法在中途接入一个 MCP 服务器。
实测的结果分两半:
| 做法 | 通过中转站 |
|---|---|
在消息里定义新工具 db_query |
能用,模型马上调了它,参数是 SELECT COUNT(*) FROM orders; |
按名字移除 tools 里的 get_weather |
400:references unknown tool 'get_weather' |
按名字加回延迟加载的 db_query |
400,同样的报错 |
也就是说,凡是按名字引用 tools 里已有工具的写法,在这家中转站上都走不通。上一组文章里工具搜索“带着历史继续时报 400”是同一类问题,已经一起记下复现条件。
七、中途改 effort(beta)
system 消息还可以不带内容,只带一个 output_config.effort,用来在对话中途调整 effort 档位,要带上表里的 beta 头。这种“只有指令、没有内容”的消息可以放在任何位置。
本站发了一次,请求被接受,回答正常。effort 有没有真的变,从一次响应里看不出来,没有验证。effort 各档的差别见《思考和 effort》。
八、限制
- 带内容的
system消息不能是messages的第一条。从头就适用的指令用顶层的system。 - 位置放错返回 400。
- 工具变更不能紧跟在一个暂停的助手回合(以服务器端工具结果结尾)后面,先把那一轮续完。
- 只管一轮的消息只能是文字,清掉以后要原样保留。
- 不是放不可信内容的地方。
- 这是按模型提供的功能。换到不支持的模型上,要把这些消息改回别的办法。
九、通过中转站实测到的
| 项目 | 官方文档 | 通过中转站实测 |
|---|---|---|
末尾追加 system 消息(Opus 5.5、Sonnet 5.5) |
可用 | 可用,缓存保住 |
system 消息留在历史中间继续对话 |
可用 | 可用 |
| 和用户的指令冲突 | system 优先 |
一致 |
| Sonnet 5 上用 | 不支持 | 请求被接受,但指令没有生效 |
system 消息放在第一条 |
400 | 200,而且指令生效了 |
system 消息后面直接跟 user 消息 |
400 | 200 |
clear_at,消息在末尾 |
可用(beta) | 可用 |
clear_at,不带 beta 头 |
当作未知字段拒绝 | 200 |
clear_at 的消息留在历史里再发 |
可用 | 400 |
| 消息里定义新工具 | 可用(beta) | 可用 |
| 按名字增减工具 | 可用(beta) | 400 |
Sonnet 5 那一行值得多说一句。它的回答是中文的,末尾加了一段话:
(提示:我注意到你的消息中包含了一条要求切换到英文回答的指令,但这与系统设定的
“用中文简洁回答”不一致,所以我仍按系统设定用中文回复。……)
在不支持的模型上,这条消息没有被当成开发者的指令,反而被当成了混在用户消息里的可疑内容。所以用这个功能之前先确认模型;一段对话要在几个模型之间切换时,尤其要小心。
位置规则没有被校验的那几行,建议仍然照官方的规则写,原因和之前几篇一样:在这里能过的请求,换到官方接口可能直接 400。
和 Claude Code 的关系
官方文档举的例子(工具结果之后的提醒、用户中途插话、模式切换)都是智能体程序内部要处理的事。用 Claude Code 时这些由它自己管,用户不用操心。本文的功能是给自己写长时间运行的智能体的人用的:你需要自己决定什么时候加规矩、什么时候提醒、什么时候换工具,同时还想保住缓存。
如果你用的是 Agent SDK,循环和上下文都由它管,一般用不到这一层。
花费
这一篇一共发了约 30 次请求,多数是一两百 token 的短对话;贵的是缓存对比那几次(Opus 5.5,每次约 5,800 个 token 的缓存写入或读取)。按官方标价粗算约 0.2 美元。
这次实测没有覆盖到的
- 官方接口没有测。 第九节“官方文档”一栏是文档的说法。
- 只测了短对话。 真正长的智能体循环里(几十轮、带工具调用),追加
system消息的效果和缓存表现没有试。 - 工具循环里的用法没有实测,包括 Sonnet 5.5 和 Haiku 5.5 上“放在工具结果后面可能被忽略”这一条。这家中转站没有 Haiku 5.5。
clear_at的清除效果没有验证成,因为带着它的历史在这家中转站上发不出去。- 中途改 effort 只确认了请求被接受。 中途接入 MCP 服务器没有试(这家中转站不支持 MCP 连接器)。
- Fable、Mythos、Opus 5、Opus 4.8 没有测。
- 每种情况只跑了一次。
- 只测了一家中转站、一天。