一、提示词构建
agent 会话的生命周期中,有哪些地方涉及提示词,提示词是如何构建的?
Prompt 全景:涉及 Prompt 的所有场景
┌──────────────────────────────────────────────────────────────────────────────┐
│ Prompt 出现的 5 大场景 │
│ │
│ ① 主回合 System Prompt --- 冻结不变,会话的"宪法" │
│ ② 主回合 User Prompt --- 用户每轮输入 + skill 注入 + 环境注入 │
│ ③ 辅助模型 Prompt (Aux) --- 压缩、标题生成、记忆、搜索、审查 │
│ ④ 压缩 Summary Prompt --- 压缩时对历史对话做结构性摘要 │
│ ⑤ Skill Prompt (可选) --- slash 命令注入、skill 执行时的一次性指令 │
└──────────────────────────────────────────────────────────────────────────────┘
核心设计洞察:只有 System Prompt 是"长生命"的,其他 Prompt 都是"短生命"的(一轮用完即焚)。 整个 prompt 系统围绕"主回合 System Prompt 必须稳定以命中 prefix cache"这一铁律设计。
1.1 主回合 System Prompt:冻结的"宪法"
设计思想
System Prompt 在第一次 turn 构建后字节稳定,整个会话生命周期内永不变。这是 prompt caching invariant 的核心------所有主流 LLM(Anthropic Claude、OpenAI GPT、Codex)的 prefix cache 机制都要求前缀字节完全一致才命中。一旦你在中间修改了 system prompt,整个对话的 cache prefix 全部失效,每轮都变成冷启动,token 成本和延迟指数级上升。
构建逻辑(按组装顺序)
System Prompt 是多个"区块"拼接的,每个区块有自己的来源、信任级别和注入时机:
System Prompt
│
├─ [1] SOUL.md --- 人格/身份定义
│ 来源: ~/.hermes/SOUL.md(用户自己写的)
│ 信任级别: 高(用户自己的文件)
│ 注入时机: init_agent 时一次性加载,冻结快照
│ 安全: 做注入扫描(ignore previous instructions 模式会 WARN 但仍加载,因为是用户自己的身份文件)
│
├─ [2] Platform & Surface Hints --- 我是谁、在哪运行
│ 来源: agent 的 platform 参数("cli", "telegram", "desktop"...)
│ 内容: "你运行在 Hermes CLI 中"、"当前是 Linux + Python 3.11"、"工作目录是 xxx"
│ 设计: 这些是**静态描述**,不是动态命令------告诉模型"它处于什么环境",而不是"它应该做什么"
│
├─ [3] CWD Context Chain --- 项目级开发指南
│ 来源: 从 git root 到 cwd,逐层扫描 AGENTS.md / .cursorrules / CLAUDE.md
│ 优先级: 越深(越靠近 cwd)的层级优先级越高
│ 安全: 做注入扫描(项目 checkout 来的文件是 BLOCKED,不是 WARN)
│ 超时: 每个文件有 5s daemon 线程超时,防止 iCloud Drive / NFS 这类网络盘冷读卡死整个启动
│ 大小: 有上限,超长会截断(保头保尾,截断中间,日志记录)
│
├─ [4] Skills Index --- 已安装技能目录
│ 来源: skills/*.md + optional-skills/*.md 的 frontmatter
│ 内容: 每个 skill 的名称、描述、触发条件
│ 设计: 不是把每个 skill 的完整内容塞进去,而是塞一个**索引**------模型需要时再主动用 skill_view 工具取完整内容
│ 好处: System Prompt 保持精简,同时模型知道有什么可用
│
├─ [5] Runtime Environment --- 运行时事实
│ 来源: 进程启动时探测
│ 内容: OS 类型、Python 版本、Git 分支/状态、当前 cwd、可用工具数
│ 设计: 纯事实陈述,不嵌入意图
│
├─ [6] Memory 冻结快照 --- 用户记忆
│ 来源: MEMORY.md + USER.md(磁盘持久化的记忆文件)
│ 设计: **冻结快照**------加载后就不再更新。中途 memory provider 写入新记忆,不会刷新到已冻结的 system prompt 里。
│ 原因: 保持 prefix cache 命中。如果每轮都刷新 memory 区块,整个前缀就变了。
│ 替代方案: 新记忆通过**后续 turn 的 tool result** 注入(cache-safe 的位置,因为 tool result 在消息链的尾部)
│
└─ [7] Ephemeral Add-ons --- 调用方临时注入
来源: AIAgent 构造参数(ephemeral_system_prompt)
设计: 留给上层(如 Desktop app、API server)注入一次性补充
生命周期: 同 system prompt 一起冻结
设计权衡的亮点
为什么 skill 只塞索引不塞全文? 因为 skill 可能有几百个,每个几千字------全塞进去 system prompt 会膨胀到几万 token,每轮都发一遍浪费。塞索引让模型按需取,省 token,同时 system prompt 体积稳定(索引的大小变化远小于全文)。
为什么 memory 要冻结? 因为 memory provider 可能在后台异步写入(向量数据库更新、外部 memory API 拉取)。如果 system prompt 动态刷新,刷新的时机和内容大小都不确定,cache 命中率会剧烈波动。冻结 = system prompt 的体积和内容在会话生命周期内完全可预测。
1.2 主回合 User Prompt:每轮动态注入
设计思想
User Prompt 是短生命的 ------每轮构建、每轮发送、用完即焚。它不影响 system prompt 的 cache 命中,因为它在消息链的尾部。
User Prompt 的内容构成
User Prompt
│
├─ 用户原始输入 "帮我写个 React 组件"
│
├─ Skill slash 注入 "/skills 搜索" → 等价于一个 skill 的触发
│ 设计: 作为 user message 注入(不改变 system prompt)
│
├─ 环境通知 [SYSTEM NOTICE --- run time budget nearly exhausted]
│ 设计: 追加到**最新的 tool result** 里(不是新建一条 user message)
│ 原因: 维持 role alternation(assistant → tool → user 的链不能断)
│ cache 安全: tool result 在消息链尾部,改它不影响前面的 cache
│
└─ 迭代预算警告 同上,追加到 tool result
设计权衡
为什么 slash 命令不改变 system prompt? 因为改 system prompt = 打断 cache。Hermes 的做法是把 slash 命令的"效果"做成一条新的 user message 或者 tool result 的增量注入------这两种位置都在消息链尾部,prefix cache 完全不受影响。
1.3 辅助模型 Prompt (Aux):独立路由的"专职"模型
设计思想
主模型只有一个,但 Hermes 可以配置多个辅助模型,各自干不同的专职活:
| 任务 | 辅助模型 | 为什么独立 |
|---|---|---|
| 上下文压缩 | auxiliary.compression | 压缩可能调用比较频繁,用便宜/快的模型 |
| 会话标题生成 | auxiliary.title | 低质量要求,省 token |
| 记忆提取/更新 | auxiliary.memory | 需要理解上下文但不需要写代码 |
| 会话搜索 | auxiliary.search | 可能需要 embedding 能力 |
| 背景审查 | auxiliary.review | 长任务,异步跑,别占主模型槽 |
Prompt 构建原理
辅助模型的 Prompt 和主模型完全独立构建------它有自己的 system prompt、自己的 message 组装逻辑、甚至可以走完全不同的 provider(比如主模型用 Anthropic,压缩模型用 OpenRouter 上的便宜模型)。
关键设计点 :辅助模型的 API 调用会标记为 aux_call,它不会触发主回合的 pre/post_api_request 事件钩子(否则主回合的流会被辅助调用打断),也不共享主模型的 rate limit 池。
1.4 压缩 Summary Prompt:打断 cache 的唯一合法手段
设计思想
当上下文膨胀到 50-85% 时,Hermes 启动会话压缩 。压缩是唯一被允许修改历史消息的机制 ------因为它会把旧消息替换成结构化摘要,整个消息链重写。这意味着 prefix cache 会被打断,但 Hermes 接受这个代价,因为:
一个 80% 膨胀的上下文,即使命中 cache,每轮发送的 token 成本也比"压缩后重建 cache"要高。
压缩 Prompt 的设计
压缩不是一句话"帮我总结上面的对话"------它有结构化的 Prompt 体系:
压缩 Prompt
│
├─ System Prompt: 告诉辅助模型它是"会话压缩器",输出格式是什么
│ 要求: 必须保留所有用户意图、已完成的工作、待办事项、代码片段、关键决策
│ 约束: 不能丢工具调用链,不能改角色,不能简化到模型以后看不懂
│
├─ User Prompt: 把历史对话分批喂进去
│ 策略: 先剪枝旧的 tool results(最大 token 节省),再对剩余对话生成摘要
│ 分批: 如果整个对话塞不下,分批次压缩再合并
│
└─ 输出约束: 结构化 JSON / Markdown,主 agent 解析后替换原消息链
压缩后的 cache 恢复
压缩完成后,消息链被重写,system prompt 不变但下面的 messages 变了------prefix cache 从压缩点开始重建。Hermes 接受这个代价,因为压缩后上下文会小很多,重建 cache 的成本 < 持续发送膨胀上下文的成本。
1.5 Skill Prompt:两种形态
形态 A:Slash 命令注入
用户敲 /skills install xxx → Hermes 把这条 slash 命令转译成一个 skill 执行指令,作为 user message 注入到当前对话里。
形态 B:Skill 内容的延迟加载
System prompt 里只有 skill 的索引 (名字 + 一句话描述)。当模型决定"我需要用某个 skill"时,它调用 skill_view 工具------tool handler 读取 skill 文件的完整内容,作为 tool result 返回给模型。完整 skill 内容是 tool result,不是 system prompt。
为什么不把 skill 内容直接塞 system prompt?
→ skill 可能几百个,每个几千字,system prompt 会膨胀到无法承受
→ 塞索引 = system prompt 体积稳定(索引大小变化远小于全文)
→ 模型按需取 = 只在需要时才花 token 发
→ tool result 位置 = cache safe,不影响 prefix
1.6 Prompt Cache 边界设计
这是整个 prompt 系统最精妙的设计之一。理解它,就理解了为什么有些东西能改、有些绝对不能改:
API 请求发送时的 messages 链:
[0] system prompt ← 冻结!prefix cache 的核心
[1] user message #1 ← 冻结(已发送)
[2] assistant message #1 ← 冻结
[3] tool result #1 ← 冻结(但压缩可能改写)
[4] user message #2 ← 冻结
...
[N] 最新的 tool result ← 可以追加通知(run budget warning 等)
[N+1] 最新的 user message ← 本轮新发送的
prefix cache 覆盖 [0..K],K 是两个相邻请求之间不变的最长前缀
可以安全修改的位置:
- 消息链尾部最新的 tool result(追加通知)
- 本轮新发送的 user message
- tool result 里追加 metadata(不改变已有内容,只是 append)
绝对不能修改的位置:
- System prompt 的任何部分
- 已发送过的 user / assistant / tool 消息(压缩除外)
Hermes 的所有 prompt 注入都遵守这一规则------slash 命令、budget warning、compression notice、subdirectory hints,全部走"追加到尾部 tool result"或"作为新 user message"的路线。
总结:Prompt 系统的设计原则
| 原则 | 含义 |
|---|---|
| System Prompt 冻结 | 第一次构建后字节稳定,直到会话结束 |
| Skill 用索引不用全文 | 控制 system prompt 体积,模型按需加载 |
| Memory 用冻结快照 | 后台更新走 tool result,不刷新 system prompt |
| 只有压缩能改历史 | 代价是打断 cache,但 token 净收益为正 |
| 注入全部走尾部 | slash、通知、警告 → 最新 tool result 或新 user message |
| 辅助模型独立路由 | 压缩、标题、记忆用专职便宜模型,不抢主模型 |
| 平台 surface 不进 check_fn | surface 信息(我在 Desktop / 我在 CLI)走 system prompt 的 platform hints,不是工具可用性检查 |
这套设计的底层逻辑是:每轮 API 调用都会发送完整的 messages 链,任何不必要的字节都是持续的成本 。所以 prompt 系统的所有决策,本质上都在做一件事------在不影响模型能力的前提下,最小化每轮发送的 token 量,最大化 prefix cache 命中的稳定性。
二、提示词中的工具
系统提示词里面,怎么没有 mcp、工具等信息,如果 agent 中注册了 mcp 或者工具信息,模型怎么知道?
核心答案:工具/MCP 不走 System Prompt,走 API 专门字段
传统做法(已经过时):
System Prompt: "你可以使用以下工具:\n1. terminal --- 执行终端命令...\n2. web_search --- 搜索网页..."
messages: [...system_prompt, user_message, ...]
现代做法(Hermes 采用):
System Prompt: "你是一个有工具调用能力的 AI Agent" ← 只需要告诉模型"有这个能力"
messages: [...system_prompt, user_message, ...]
tools: [{name: "terminal", description: "...", parameters: {...}}, ← API 独立字段
{name: "web_search", ...},
...所有内置工具 + MCP 工具...]
这是一个架构级的分离 :System Prompt 负责"告诉模型它是谁、知道什么、在什么环境",而 API 的 tools 字段负责"告诉模型它能做什么"。
完整链路:工具/MCP 如何到达模型
init_agent() 阶段
│
├─ 内置工具: discover_builtin_tools() ← AST 扫描 + import 触发 registry.register()
│
├─ MCP 工具: discover_mcp_tools() ← 连接 config 里配置的所有 MCP server (stdio/SSE/HTTP)
│ 对每个 server:
│ ├─ 连接握手 (initialize)
│ ├─ 请求 tools/list → 拿到每个 MCP 工具的 schema
│ └─ 在本地 registry 里 register(schema 来自远端 MCP server)
│
└─ Plugin 工具: plugins/*.py 在 import 时 ctx.register_tool()
(跟内置工具走同一个 registry)
→ 此时 registry 里已经汇总了: 内置工具 + MCP 工具 + Plugin 工具
turn 开始时
│
├─ model_tools.get_tool_definitions(enabled_toolsets, disabled_toolsets)
│ ├─ 从 TOOLSETS 过滤出当前会话可用的工具(按 toolset membership)
│ ├─ 对每个工具跑 check_fn(检查 API key、服务可达等;TTL cache)
│ ├─ MCP 工具也过同样的 check_fn(server liveness probe)
│ ├─ 过滤掉不可用的、被禁用的、surface 不支持的
│ └─ 输出: [tool_schema_1, tool_schema_2, ...] ← 这就是发给模型的 tools 列表
│
└─ 组装 API 请求:
├─ messages: [system_prompt, user_message, ...]
├─ tools: tools_for_api ← 单独字段,不是 messages 的一部分
└─ 其他 api_kwargs (model, temperature, max_tokens, stream=true, ...)
→ 发送到 LLM API
这个设计比"塞 System Prompt"好在哪里
1. 工具列表本身不影响 prompt cache
System Prompt 冻结不变,但工具列表理论上是会变的(MCP server 断了重连、动态开关 toolset、插件热加载)。如果工具描述塞在 System Prompt 里,工具一变,整个 cache prefix 就断了。
API 独立字段的好处:工具列表在 messages 链之外,它的变化不会破坏 messages 链的 prefix cache。这是两个独立的 cache 维度。
2. LLM API 原生支持、原生解析
现代 LLM(GPT-4、Claude 3.5+、Codex、Gemini)都有原生的 tool use / function calling 协议:
json
// 模型返回的 tool call(原生格式,不是模型"瞎编"的)
{
"role": "assistant",
"content": null,
"tool_calls": [
{
"id": "call_abc123",
"type": "function",
"function": {
"name": "terminal",
"arguments": "{\"command\": \"ls -la\"}"
}
}
]
}
这和"模型从 system prompt 里看到一段工具描述,然后自己编一段 JSON 格式的调用"完全不同------原生 tool use 有 schema 约束、有类型校验、有专门的错误码,可靠性高得多。
3. 工具 schema 有结构化格式,不是自然语言
json
// API 的 tools 字段 (OpenAI 格式)
{
"type": "function",
"function": {
"name": "terminal",
"description": "Execute a shell command in the project directory",
"parameters": {
"type": "object",
"properties": {
"command": {"type": "string", "description": "Shell command to execute"},
"cwd": {"type": "string", "description": "Working directory"}
},
"required": ["command"]
}
}
}
而不是在 system prompt 里写一段:
你有一个 terminal 工具,可以执行 shell 命令。
用法示例: terminal(command="ls", cwd="/path")
参数 command 是必需的,cwd 是可选的...
结构化 schema 的好处:
- 模型更不容易 hallucinate 参数名(schema 告诉它"你只能用这几个参数")
- 参数类型明确(string / number / enum / required)
- API 层可以做 自动参数校验(模型返回的 arguments JSON 对照 schema 检查,不合格直接报错,不会送到 handler 里炸)
- MCP server 自带这个 schema → 发现即注册,零适配成本
4. 工具数量可以很多(Hermes 默认 40-60 个,含 MCP 可达 100+)
如果全塞 System Prompt,每轮都是"你可以使用以下 N 个工具:...",工具描述再简洁也得几万 token。而且工具越多,模型找对工具的认知负担越大。
API 独立字段 + 原生 tool use 的设计,把工具发现和工具调用的复杂度交给了 API 层和模型的 tool-calling 能力,System Prompt 只需要一句话:"你是一个有工具调用能力的 AI Agent"。
MCP 工具和内置工具的统一处理
Hermes 把 MCP server 当成一个动态的"工具提供方" ,它返回的每个 MCP tool schema,和内置工具在本地 registry 里是完全等价的:
registry 里的每个工具条目,来源可以是:
├─ 内置 tools/*.py → registry.register()
├─ Plugin plugins/*.py → ctx.register_tool()
└─ MCP server tools/list 返回 → registry.register()
对模型来说,它们是一样的------都是 tools 数组里的一个 entry,都走原生 tool use 协议
对 dispatch 来说,它们也是统一的:
handle_function_call(name, args)
→ registry.dispatch(name, args)
→ 匹配到 handler
→ 内置工具: 直接调用本地 Python 函数
→ MCP 工具: 通过 MCP 协议转发到远端 server,拿结果回来
这个统一设计的好处是:MCP server 挂了只影响那几个 MCP 工具,不会影响其他工具;同样,工具级别的 check_fn 可以独立检测每个 MCP server 的 liveness,一个 server 断了只 disable 它提供的工具,其他工具继续正常工作。
总结
| 维度 | System Prompt 里的内容 | API tools 字段 |
|---|---|---|
| 是什么 | 一句话:"你有工具调用能力" | 完整的结构化 schema 数组 |
| 变化性 | 冻结不变 | 可动态增删(MCP 断连重连、toolset 切换) |
| 对 cache 的影响 | 有------改了就断 cache | 独立维度------不影响 messages 链的 cache prefix |
| 格式 | 自然语言 | JSON Schema(结构化、可校验) |
| 来源 | prompt_builder 组装 | registry 统一收集 → get_tool_definitions 过滤 |
| MCP 参与方式 | --- | MCP server tools/list → 注册到 registry → 和内置工具一视同仁 |
| 数量上限 | 受 prompt token 预算约束 | 受 provider 限制(通常 128-256 个),Hermes 默认 40-60 |
这个设计的底层思想是关注点分离 :System Prompt 管"认知边界"(我是谁、我知道什么),API tools 字段管"能力列表"(我能做什么)。两者解耦,各自独立演进,各自不破坏对方的稳定性。
三、服务端对提示词中工具的处理
既然API 传递tools,System Prompt又不知道,模型在推理的时候,只接收Prompt,那模型在执行某个任务的时候,模型怎么知道哪些工具可以使用,我想知道api 请求到了模型这一侧,模型端是怎么处理的?
直接回答:模型确实"知道",但不是因为 System Prompt 写了,而是因为 API 请求到模型侧时,框架会把 tools 序列化成文本(或特殊 token)注入到 Prompt 中,KV cache 的命中情况取决于序列化位置是否变化。
1. 模型端到底怎么处理 tools?
当你调用 API 传递 tools 参数时,模型服务端(如 vLLM、SGLang、TGI 或闭源 API)会执行以下操作:
序列化注入(Text Serialization)
服务端将 tools 列表转换为模型能理解的文本格式,拼接在 System Prompt 之后、User Query 之前:
text
<|im_start|>system
You are a helpful assistant.
<|im_end|>
<|im_start|>user
<|im_start|>tools <!-- 注入位置 -->
[Tool A: {...}, Tool B: {...}]
<|im_end|>
<|im_start|>user
帮我查一下北京天气
<|im_end|>
不同模型的序列化格式不同:
- Qwen :
<|im_start|>user\n<tools>...</tools>\n<|im_end|> - Llama 3.1/3.2 :
<|start_header_id|>tools<|end_header_id|>... - GPT-4 :内部使用
functions.前缀或 JSON schema 注入
特殊 Token 注入(Native Tool Calling)
新一代模型(Qwen2.5、Llama 3.1、DeepSeek-V3 等)在训练时就见过 <|tool_call|>、<|tool_response|> 等特殊 token。服务端直接用这些 token 包裹 tools 定义,模型能结构性地区分"这是工具定义"和"这是普通对话"。
关键点 :无论哪种方式,tools 最终都会变成模型输入 token 序列的一部分。模型看到的不再是"纯 Prompt",而是包含了工具描述的扩展上下文。
2. tools 变化时,KV cache 会不会失效?
会,且影响显著。 这取决于 tools 在序列中的位置和变化范围:
场景 A:tools 定义变化(增删改工具)
假设原始序列:
text
[System] [Tools_v1] [User Query] [Assistant Response]
如果 tools 参数从 v1 变成 v2(比如新增一个工具、修改参数描述):
- Tools_v1 部分的 KV cache 完全失效:因为 token 序列变了,Attention 计算结果不同
- User Query 之后的部分也失效:因为 Prompt 前缀变了,后续所有 token 的 KV 都依赖前缀
结论 :tools 变化会导致前缀缓存(Prefix Cache)未命中,需要重新计算整个序列的 KV。
场景 B:tools 不变,仅 User Query 变化
text
[System] [Tools_static] [User Query_A] → 缓存命中
[System] [Tools_static] [User Query_B] → 前缀 [System]+[Tools_static] 部分命中,仅 Query 部分重新计算
这是 vLLM PagedAttention 的优势:静态前缀(System + Tools)可以跨请求共享,即使 Query 不同,只要 Tools 不变,这部分 KV cache 可以直接复用。
场景 C:tools 数量对性能的影响
- 工具越多:注入的 token 越长,Prefill 阶段越慢,KV cache 占用越大
- 工具越复杂(长 JSON Schema):同样增加 Prefill 负担
- 频繁变更 tools:导致缓存频繁失效,吞吐量下降
3. 工程优化建议
如果你在设计高并发的 Tool Calling 系统:
稳定 tools 定义
- 将 tools 定义放在 System Prompt 的固定位置,并保持顺序稳定,最大化前缀缓存命中率
- 避免在 tools 中插入动态内容(如时间戳、随机 ID),这会导致缓存 100% 未命中
利用 vLLM 的 Automatic Prefix Caching
vLLM 默认开启 APC(Automatic Prefix Caching),会自动缓存 System + Tools 的前缀 KV。只要 tools 不变,后续请求即使 Query 不同,也能复用这部分缓存。
减少 tools 数量
- 不要一次性传 50 个工具,模型注意力会分散,且 Prefill 成本高
- 使用 Tool Retrieval:先根据 Query 检索出 Top-3 相关工具,再传给模型
使用 Native Tool Calling 模型
Qwen2.5、Llama 3.1 等模型对 tool 格式做了专项训练,能更准确地理解 tools 边界,减少序列化 token 浪费。
总结
| 问题 | 答案 |
|---|---|
| 模型怎么知道有 tools? | 服务端将 tools 序列化为 token 注入 Prompt,模型通过训练学会识别这些 token |
| tools 变化影响 KV cache? | 是,tools 部分及后续所有 token 的 KV 都需重新计算 |
| 如何优化? | 保持 tools 静态、利用前缀缓存、减少工具数量、使用 Native Tool Calling 模型 |
有个矛盾,既要保持tools 固定不变,提高命中缓存,如果 tools 太多,使用 Tool Retrieval,在传递给模型,
工程矛盾:缓存命中率 vs 工具覆盖率 。这不是二选一,而是需要通过分层缓存策略 和检索时机优化来解决。
1. 矛盾的本质拆解
高并发场景下的冲突:
├─ 缓存友好:希望 [System + Tools] 是静态前缀,所有请求共享
├─ 检索友好:希望根据 Query 动态选择 Top-K 工具,减少噪音
└─ 结果:动态 Top-K 导致前缀碎片化,缓存命中率暴跌
核心洞察 :Tool Retrieval 本身也有冷热分层 。高频工具(如 get_current_time、web_search)几乎每次都被检索到,而长尾工具(如 delete_user_account)很少触发。
2. 分层缓存架构(Hot/Warm/Cold)
策略:静态热工具 + 动态长尾工具
python
# 分层策略示例
HOT_TOOLS = [ # 静态层,永远不变,占 80% 调用量
"web_search",
"calculator",
"get_current_time",
"code_interpreter"
]
WARM_TOOLS = [ # 半静态层,按业务线分组缓存
"query_order_status", # 电商组
"refund_apply", # 电商组
"book_flight", # 出行组
]
COLD_TOOLS = [ # 动态层,实时检索,接受缓存未命中
"internal_data_query_xxx",
"legacy_system_yyy"
]
请求组装逻辑:
text
[System Prompt]
+ [Hot Tools] ← 静态前缀,高缓存命中
+ [Warm Tools Group] ← 按业务线分组,组内命中
+ [Retrieved Cold Tools] ← 动态部分,允许未命中
+ [User Query]
缓存命中率分析
- Hot 层:100% 命中(所有请求共享)
- Warm 层:按业务线隔离,同一业务线内命中(如电商客服会话)
- Cold 层:未命中,但仅占 10-20% 的 Prefill 开销
3. 检索时机优化(Prefill vs Decode)
更激进的优化:将 Tool Retrieval 从 Prefill 阶段移到 Decode 阶段(多轮工具调用场景)。
传统方式(单轮)
text
用户 Query → 检索 Tools → 拼接 Prompt → 生成答案
↑ 这里发生,影响 Prefill 缓存
多轮 Agent 方式(延迟检索)
text
Round 1:
Prompt: [System] + [Hot Tools] + [Query] ← 无 Cold Tools,缓存命中
模型输出: "我需要查询内部数据,请提供 tool_x"
Round 2:
服务端根据模型意图,动态注入 tool_x 的 Schema
Prompt: [System] + [Hot Tools] + [tool_x] + [Query] + [历史] ← 仅 tool_x 部分新增
模型输出: 调用 tool_x 的结果
适用场景 :复杂 Agent 任务,模型需要先"思考"再决定用什么工具。此时工具检索是对话中途发生的,不破坏首轮缓存。
4. 结构化前缀缓存(Prefix Tree)
如果必须做实时 Tool Retrieval,利用 vLLM 的 Automatic Prefix Caching 特性,将工具按树状结构组织:
text
缓存节点树:
├─ [System] ← 所有请求共享
├─ [System + Hot_Tools] ← 80% 请求共享
├─ [System + Hot_Tools + Category_A_Tools] ← 电商类请求共享
├─ [System + Hot_Tools + Category_B_Tools] ← 出行类请求共享
└─ [System + Hot_Tools + ... + Retrieved_Tool_X] ← 叶子节点,未命中但占比小
关键 :即使做了 Retrieval,只要检索结果是有限的固定组合(如 Top-3 从 20 个工具中选,共 C(20,3)=1140 种组合),vLLM 的 Prefix Cache 依然能命中部分中间节点。
5. 工程实践建议
| 场景 | 推荐方案 | 缓存命中率 | 工具覆盖率 |
|---|---|---|---|
| 高频固定工具(<10 个) | 全量传递,不做检索 | 95%+ | 100% |
| 中频分类工具(10-50 个) | 分层传递(Hot 全量 + Cold 检索) | 70-85% | 95%+ |
| 低频海量工具(50+ 个) | 延迟检索(多轮 Agent)或 Fine-tuned Router | 60-80% | 90%+ |
| 极低延迟要求 | 静态工具集,牺牲覆盖率 | 99% | 受限 |
具体实现:双级缓存策略
python
def build_prompt(query, user_context):
# 第一级:静态热工具(缓存友好)
base_tools = get_hot_tools()
# 第二级:基于用户画像的半静态工具(组内缓存)
user_segment = get_user_segment(user_context) # 如 "enterprise_user"
segment_tools = get_segment_tools(user_segment)
# 第三级:动态检索(接受未命中)
if needs_dynamic_tools(query):
dynamic_tools = retrieve_tools(query, top_k=2)
else:
dynamic_tools = []
return concat_prompt(base_tools, segment_tools, dynamic_tools, query)
总结
不要二选一,而是分层:
- Hot 工具静态化:永远传递,保证 80% 缓存命中
- Warm 工具分组化:按业务/用户分群,组内共享缓存
- Cold 工具动态化:接受缓存未命中,但控制数量(Top-2/3)和位置(放在序列尾部,减少失效范围)
这样既能控制 Prefill 成本,又能覆盖长尾工具需求。
四、提示词的长度约束
在构建System Prompt的时候,有些内容的长度是动态的,比如SOUL.md、AGENTS.md这 2用户自己编写、Skills Index、MEMORY.md + USER.md等等,长度限制是如何设计的?
System Prompt 构建的完整设计------三分层缓存架构是整个体系的灵魂。
4.1 三层缓存分桶:Stable / Context / Volatile
这是整个 System Prompt 构建最核心的设计决策。Hermes 把 System Prompt 的所有内容按变化频率 分成三个 tier,用 \n\n 拼接:
完整 System Prompt = Stable + "\n\n" + Context + "\n\n" + Volatile
↑ ↑
最稳定的部分 最容易变化的部分
↓ ↓
缓存命中率最高 缓存命中率最低
为什么要分桶? 因为上下文压缩时,system prompt 会被重建。重建后,如果大部分内容没变,prefix cache 就能继续命中(只需从变化点开始重建缓存)。如果不分区,哪怕只改了一个 timestamp 小细节,整个 system prompt 的 cache 都要从头重建。
三层各自装什么
| Tier | 稳定性 | 装什么 | 变化原因 |
|---|---|---|---|
| Stable | 几乎不变 | 身份(SOUL.md / 默认身份)、帮助指引、执行纪律指令(Tool-use enforcement)、记忆/技能使用指引、编码姿势前缀、阿里模型身份补丁、auto-load skills | 只有换 profile、换 model 时才变 |
| Context | 跨 session 变 | 用户传入的 system_message(ephemeral)、项目上下文文件链(AGENTS.md / .hermes.md / CLAUDE.md / .cursorrules)、工作区快照(git probe 结果)、环境探测、Bot Mode 协议、Platform hint | cwd 变了(跨 worktree)、换平台、项目文件变了 |
| Volatile | 最易变 | Skills 索引、MEMORY.md + USER.md、外部 memory provider 块、Plugin sections、Profile 名称行、时间戳行、Runtime Environment heading | 每次 compression rebuild、每天换日子、新记忆写入 |
设计亮点 :时间戳(Conversation started: Monday, October 13, 2026 (America/New_York, UTC-04:00))放在 Volatile 层的最尾部。这样跨天 rebuild 时,只有尾部变了------前面 Stable + Context 的 cache prefix 全部保留。
4.2 动态内容的长度管理策略
SOUL.md / AGENTS.md / .hermes.md / CLAUDE.md:统一的 Context File 截断机制
这四个文件走同一套截断逻辑 ,核心是Head+Tail 截断(中间砍掉,保头保尾):
原始内容: [头 70%] [中间 10%] [尾 20%]
截断后: [头 70%] [截断标记] [尾 20%]
总长度: max_chars
为什么保头保尾?
- 头部通常是文件的标题、一级规则清单------模型需要知道"这个文件讲的是什么"
- 尾部通常是补充说明、特殊情况、FAQ------模型如果查特定场景需要尾部信息
- 中间通常是展开的细节、长篇示例、重复强调------最容易压缩掉
max_chars 怎么算?------动态预算 + 可配置覆盖
max_chars 计算优先级:
1. 用户在 config.yaml 里显式设置 context_file_max_chars
2. 动态预算 = context_length × CHARS_PER_TOKEN × 6%
(6% 是模型窗口里分给 context 文件的固定比例)
3. Clamp 到 [20,000 floor, 500,000 ceiling]
不同模型的效果:
GPT-4o (128K ctx, ~2.5 chars/token)
→ 128000 × 2.5 × 6% = 19,200 → 触发 floor → 20K chars
Claude 3.5 Sonnet (200K ctx)
→ 200000 × 2.5 × 6% = 30,000 → 刚好
Claude 3 Opus (2M ctx)
→ 2000000 × 2.5 × 6% = 300,000 → 不到 ceiling → 300K chars
某个 8K 小模型
→ 8000 × 2.5 × 6% = 1,200 → 触发 floor → 20K chars
还有什么保护机制?
| 机制 | 说明 |
|---|---|
| 5s 读超时 | 每个文件用 daemon thread 读,超 5s(可 config 覆盖)就跳过。防 iCloud Drive / NFS 冷读卡死整个启动 |
| 安全扫描 | 用 tools/threat_patterns.py 扫描 prompt injection 模式("ignore previous instructions" 等) |
| SOUL.md 信任豁免 | SOUL.md 在用户自己的 HERMES_HOME 里------扫描命中只 WARN 不 BLOCK(因为是用户自己的身份文件,用户写了安全指引不应该丢整个 SOUL.md) |
| AGENTS.md 严格拦截 | 项目 checkout 来的 AGENTS.md / .cursorrules 是 BLOCKED------repo owner 可能在里面藏注入攻击 |
| 截断警告上报 | 截断会记录在 ContextVar 里,构建完成后通过 drain_truncation_warnings() 通知用户 |
| 忽略的 Markdown 标题 | 截断时如果中间被砍掉了几个 heading(# ...),会统计出来追加到截断标记里------让模型知道"第 X、Y、Z 节被砍了" |
AGENTS.md 的文件发现优先级链
从 cwd 向上爬到 git root:
cwd/AGENTS.md → 最优先
cwd/.cursorrules
cwd/.hermes.md / HERMES.md
cwd/CLAUDE.md
git-root/AGENTS.md
git-root/.cursorrules
...
git-root/.hermes.md
每层都有优先级,且每层独立截断。多层拼接在一起。
Skills Index:两层缓存 + 条件过滤
Skills Index 也有动态大小------取决于装了多少 skill、每个 skill 的描述多长。它的策略和 context files 完全不同:
Skills Index 构建链路:
磁盘: ~/.hermes/skills/**/SKILL.md (每个 skill 一个目录)
│
├─ _build_skills_manifest() → 文件签名表 (stat.st_mtime + st_size)
│ 用来判断 "skills 目录有没有变"
│
├─ 查 disk snapshot (.skills_prompt_snapshot.json)
│ 版本号 + manifest 匹配 → 直接用
│ 不匹配 → 全量重新扫描
│
├─ 扫描每个 SKILL.md:
│ parse frontmatter (YAML 头)
│ 提取: skill_name, category, description, platforms, conditions
│ 过滤掉: 平台不匹配、环境不匹配、requires_toolsets 不满足的
│ 渲染成: "- skill_name: one-line description"
│
├─ 进程内 LRU cache (最多 32 条, key = skills_dir × platform)
│ 多 profile 网关防 thrash
│
└─ 输出: 纯文本索引 (N 行 bullet points)
关键设计 :Skills Index 本身没有硬字符上限 ------因为它只是"名字 + 一句话描述",每个 skill 平均 50-100 chars,就算装 200 个也才 20K chars。如果真的膨胀了,截断策略是按 category 降级(Focus 模式下非 coding 类只保留名字、丢掉描述)。
另外,skill 还有条件显示 机制------_skill_should_show() 检查:
- 这个 skill 要求特定 platform?(session 不匹配就隐藏)
- 要求特定 toolset?(工具集没有就隐藏)
- 是某个 toolset 的 fallback?(如果主 toolset 已存在就隐藏)
MEMORY.md + USER.md:记忆的"硬预算"与冻结快照
MEMORY.md 和 USER.md 的大小控制方式,不是截断 ,而是写的时候就限制长度:
记忆写入时的约束:
MEMORY.md: 有硬字符预算(记忆本身就是声明式事实,应该精炼)
USER.md: 同样是预算约束
记忆里存的是"谁是用户、环境事实、长期约定"------
应该是精炼的 bullet points,不应该是长篇大论
System Prompt 里的呈现方式:
## Persistent Memory
- User prefers concise responses
- Working directory convention: /Users/lihy/project
- ...
## User Profile
- Name: Li
- Role: Senior Backend Engineer
- ...
冻结快照机制(这是最关键的设计):
第一次 build_system_prompt() 时:
agent._cached_system_prompt = "完整 system prompt bytes"
MEMORY.md 内容加载并拼接进去
后续所有 turn:
直接返回 _cached_system_prompt
即使 memory provider 后台写了新记忆 → System Prompt 不刷新
什么时候刷新?
只有 context compression → invalidate_system_prompt()
→ 清 cache → 下一轮重新 build → 新的 MEMORY.md 进去
为什么要冻结? 因为 prefix cache 要求前缀字节稳定。如果每轮都重新读 MEMORY.md(memory provider 可能后台异步更新),每轮的 system prompt 都不一样,cache 全部断掉。
4.3 重建边界
什么时候重新 build_system_prompt()?
| 触发条件 | 说明 |
|---|---|
| 第一次 turn | 必须 build |
| Context compression | 唯一合法的中途重建 |
| Agent resume(从 SessionDB 恢复) | 重建,用存下来的 bytes 验证能否命中 cache |
| profile 切换(多 profile 网关) | 不同 profile 的 skills / soul 不同 → 重建 |
| 换 model(fallback 链) | 某些 model 有特殊 guidance(阿里模型身份补丁等) |
重建后会调用 invalidate_system_prompt():
- 清掉
_cached_system_prompt - 清掉 plugin section 的 frozen snapshot(保留上一份当 fail-open fallback)
- 重新从磁盘加载 MEMORY.md / USER.md(捕获这期间写的新记忆)
4.4 构建总览图
build_system_prompt():
Stable tier:
├─ [身份] SOUL.md (截断 20K-500K) 或 DEFAULT_AGENT_IDENTITY
├─ [帮助指引] HERMES_AGENT_HELP_GUIDANCE (variant 按 skill_view 可用性选)
├─ [执行纪律] TASK_COMPLETION_GUIDANCE ← 有 tool 时
├─ [执行纪律] PARALLEL_TOOL_CALL_GUIDANCE ← 有 tool 时
├─ [工具指引] _tool_guidance_block ← memory/skill/kanban/session_search
├─ [转向通道] STEER_CHANNEL_NOTE ← 有 tool 时
├─ [模型特定] TOOL_USE_ENFORCEMENT / GOOGLE_MODEL / OPENAI_MODEL_EXECUTION_GUIDANCE
├─ [阿里补丁] 真实 model identity 注入 ← provider=alibaba
├─ [固定技能] skills.auto_load 区块 (解析一次)
├─ [编码前缀] coding_prefix_parts
└─ [环境探测] environment_probe + bot_mode + platform_hint
Context tier:
├─ [调用方注入] ephemeral_system_prompt (不传就空)
├─ [项目上下文] AGENTS.md / .cursorrules / .hermes.md / CLAUDE.md
│ 按 cwd→gitroot 链发现,每层独立截断,安全扫描
└─ [工作区] git probe 快照 (冻结 pin,rebuild 时复用)
Volatile tier:
├─ [技能索引] Skills Index (两层缓存 + 条件过滤,硬无上限但按描述长度自然收敛)
├─ [内置记忆] MEMORY.md (冻结快照, 写时预算约束)
├─ [内置记忆] USER.md (冻结快照, 写时预算约束)
├─ [外部记忆] memory provider block (插件注入)
├─ [Plugin sections] 冻结的插件区块
├─ [Profile] Active Hermes profile: xxx
├─ [时间戳] Conversation started: ... + 跨天附加行
└─ [Runtime] > Runtime Environment Heading (环境探测、embedder 提示)
拼接: stable \n\n context \n\n volatile
缓存: agent._cached_system_prompt
4.5 总结:动态内容的处理哲学
| 内容 | 动态性 | 长度控制 | 安全机制 | 缓存策略 |
|---|---|---|---|---|
| SOUL.md | 用户自己编辑 | 动态预算 (ctx × 6%, 20K-500K) | 扫描只 WARN 不 BLOCK | Stable tier,冻结 |
| AGENTS.md 链 | checkout / 改动项目 | 同上,每层独立截断 | 扫描 BLOCKED | Context tier,cwd 变才重建 |
| Skills Index | 安装/卸载 skill | 自然收敛(只有名字+描述) | 平台/环境/toolset 条件过滤 | Volatile tier,两层缓存 |
| MEMORY.md | 写记忆时更新 | 写入时硬预算约束 | --- | Volatile tier,冻结快照 |
| USER.md | 同上 | 同上 | --- | 同上 |
| 时间戳 | 每天变 | 固定长度 (~150 chars) | --- | Volatile tier 最尾部,跨天才多一行 |
五、字符长度计算
长度限制是具体怎么计算的,模型上下文的长度是配置的,还是调用模型返回的,模型的长度是 token 单位,文件的内容是字符,这 2 者单位不一致,怎么能在一起计算
5.1 Context Length 到底从哪来?------ 五层解析,逐层 fallback
Hermes 有一个精心设计的 context_length 解析链,因为不同 provider 暴露 context length 的方式完全不同,而且很多模型 API 根本不返回这个值。
context_length 解析优先级:
Layer 1: 用户在 config.yaml 里手动设置
model.context_length: 200000
→ 直接用,跳过所有自动探测
Layer 2: Provider endpoint scope (特定 endpoint 的特例覆盖)
kimi api.kimi.com/coding 上 kimi-k3 = 1_048_576
nvidia integrate.api.nvidia.com 上 deepseek-v4-pro = 262_144
→ 按 base_url + model 精确匹配
Layer 3: 本地硬编码字典 (DEFAULT_CONTEXT_LENGTHS)
dict["gpt-4o"] = 128000
dict["claude-sonnet-4-6"] = 1000000
→ ~200 个条目,最长 key 匹配(specific 优先于 catch-all)
Layer 4: 远程 /models API 探测
├─ OpenRouter /api/v1/models → 每个模型带 context_length
├─ Ollama /api/show → parameters.num_ctx 或 model_info.context_length
├─ LM Studio /api/v1/models → loaded_instances[0].config.context_length
├─ vLLM /v1/models → context_length
├─ 通用 OpenAI-compatible /v1/models → 扫描 context_length / context_window / max_model_len / ...
└─ Anthropic: 不探测(硬编码在字典里,API 不返回这个值)
Layer 5: Live API probe (极端情况)
发一条 prompt,包含 "tell me the first 500 chars of the text I will now quote..."
然后 send 一段超大文本在 user message 的后面
模型如果截断了 → 试更小的 context_length
(Bedrock adapter 用这个方法,因为 Bedrock 的 context 解析非常不透明)
如果全部失败: fallback = 256_000(CONTEXT_PROBE_TIERS[0])
⚠️ 最小值限制: MINIMUM_CONTEXT_LENGTH = 64_000
任何低于 64K 的模型都会被拒绝(agent init 阶段报错)
为什么不直接让模型 API 返回? 因为:
- 不是所有 API 都有这个字段(Anthropic 完全不返回)
- 有些返回的是训练时的 window,不是 runtime window(Ollama 的 GGUF 元信息 vs 用户设的
num_ctx) - Codex OAuth 的 context 取决于用户的 entitlement(免费用户可能只有 128K,付费有 400K)
- 有些是 per-endpoint 的(Kimi K3 在 api.kimi.com/coding 是 1M,在 legacy endpoint 是 256K)
所以 Hermes 才搞了这个五层 fallback 链------能精确就精确,探测不到就兜底。
5.2 CHARS_PER_TOKEN:经验换算常数
python
CHARS_PER_TOKEN = 2.5 # 一个经验值,不是精确值
为什么是 2.5? 这是一个保守的经验换算常数,来自多个主流 tokenizer(GPT-4 的 cl100k、Claude 的 o200k、Llama 的 LLaMA-2)在混合文本(英文代码 + 自然语言 + 中文 + emoji)上的平均:
| Tokenizer | 纯英文 | 代码 | 中文 | 混合平均 |
|---|---|---|---|---|
| cl100k (GPT-4) | ~4 chars/token | ~2-3 | ~1.5 | ~2.5 |
| o200k (Claude 3) | ~4 | ~2-3 | ~1.5 | ~2.5 |
| LLaMA-2 | ~4 | ~2 | ~1.5 | ~2.4 |
| GPT-4o (gpt-4o 中文) | ~4 | ~2-3 | ~1-1.5 | ~2.2 |
2.5 是一个安全下限------实际平均接近 3-4,但用 2.5 换算出来的 char 预算会比实际 tokenizer 更保守,避免因为换算太乐观导致截断不够用。
5.3 完整换算公式
context_length = 128_000 tokens ← 从上面的五层 fallback 链得到
budget_in_chars = context_length × CHARS_PER_TOKEN × 6%
= 128_000 × 2.5 × 0.06
= 19,200 chars
floor = 20,000 chars ← CONTEXT_FILE_MAX_CHARS(硬下限)
ceiling = 500,000 chars ← _CONTEXT_FILE_DYNAMIC_CEILING(硬上限)
max_chars = clamp(budget_in_chars, floor, ceiling)
= clamp(19200, 20000, 500000)
= 20,000 chars ← 这个模型的 context file 截断上限
6% 是怎么来的? _CONTEXT_FILE_WINDOW_FRACTION = 0.06------这是模型窗口里分给"项目上下文文件(AGENTS.md 等)"的固定比例。System prompt 里还有很多其他部分(identity、guidance、skills、memory...)也要占 token,所以 context files 只能占 6%。
这个公式对不同模型的效果:
| 模型 | context_length | × 2.5 × 6% | clamp 到 | 实际值 |
|---|---|---|---|---|
| GPT-4o mini | 128K | 19,200 | ≥ floor | 20K chars |
| Claude Sonnet 4 | 200K | 30,000 | 都在范围内 | 30K chars |
| Claude Opus 5 | 1,000K | 150,000 | 都在范围内 | 150K chars |
| GPT-6 Astra | 1,050K | 157,500 | 都在范围内 | 157K chars |
| 某个小模型 | 64K (刚好 MINIMUM) | 9,600 | ≥ floor | 20K chars |
设计要点:小模型(8K、32K、64K)全部被 floor 到 20K------因为 context files 的最小合理长度就是一个 AGENTS.md 加几个项目说明,20K chars 刚好够用。再少就真的丢信息了。
超大模型(Opus 5 1M、GPT-6 1.05M)能分到 150K+ chars------这意味着它们可以塞下非常长的项目文档链(多层 AGENTS.md + 大 .cursorrules)。
5.4 用户能不能覆盖?------可以
yaml
# config.yaml
model:
context_length: 200000 # 覆盖自动探测
context_file_max_chars: 50000 # 覆盖动态预算计算
显式设置就跳过自动计算,直接用用户给的值。
5.5 这个值在什么时候被用到?
init_agent() / build_system_prompt() 阶段:
├─ load_soul_md(context_length)
│ → _truncate_content(..., max_chars = 20000)
│
├─ discover_context_files(cwd, context_length)
│ └─ 每个 context file 独立走 _truncate_content
│
└─ 最终 system prompt 里每个 context file 区块都 ≤ max_chars
context compression 阶段:
├─ ContextCompressor 也要知道 context_length
│ → 决定什么时候启动压缩(比如 85% 阈值)
│ → 决定压缩后的目标大小(50% context_length)
│ → estimate_messages_tokens_rough() 用 CHARS_PER_TOKEN 换算 messages 大小
│
└─ 如果用辅助压缩模型,context_length 也决定辅助模型的截断预算
Ollama num_ctx clamp:
└─ 如果本地 Ollama 的 num_ctx 设得比探测到的 context_length 小
→ 按 num_ctx 约束,不能发超出 Ollama 实际窗口的请求
5.6 总结:整个流程串起来
config.yaml model.context_length? ──是──→ 直接用
│否
▼
provider endpoint scope 精确匹配? ──是──→ 用 endpoint-specific 值
│否
▼
DEFAULT_CONTEXT_LENGTHS 字典查找? ──是──→ 用最长 key 匹配的值
│否
▼
远程 /models API 探测? ──有返回──→ 用探测结果
│没返回
▼
Live API probe (Bedrock 专用)? ──成功──→ 用 probe 结果
│失败
▼
fallback = 256,000 tokens
│
▼
clamp(MINIMUM = 64,000) → 得到 agent.context_length (tokens)
│
▼
context_file_max_chars = clamp(context_length × 2.5 × 0.06, 20K, 500K)
│
▼
SOUL.md / AGENTS.md / .cursorrules / .hermes.md
→ read full text
→ Head(70%) + Tail(20%) truncate to max_chars
→ 安全扫描 (inject patterns)
→ 拼进 system prompt
核心洞察 :Hermes 从来不会"精确"计算 token 数------它用 CHARS_PER_TOKEN = 2.5 这个经验常数做粗略估算,然后靠 floor/ceiling clamp 保证不会太激进也不会太保守。这种做法在工程上足够好,因为:
- Tokenizer 的精确换算在不同 provider 上差异很大,你没法精确知道
- Context files 占的是 system prompt 的一部分,不是 messages 链的全部
- 真正的 context overflow 由 API 返回错误来兜底(超出窗口会报错,Hermes 有 recovery 逻辑处理)
- 6% fraction + clamp 机制已经足够"够大但不浪费"